Files
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
2.6 MiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "aevatarai/aevatar", "files": {"src/workflow/Aevatar.Workflow.Host.Api/Program.cs": {"4ecb75cf0bf248f7906ff3f1402cda113cceeaea": {"t": 1785745797.3207593, "n": 2090, "h": "6463d63d817d5743", "c": "// ─────────────────────────────────────────────────────────────\n// Aevatar.Workflow.Host.Api — Agent-Actor Host HTTP 入口\n//\n// workflow 启动统一通过已注册 service 的 scope API 触发,当前宿主只保留 run control/query 能力\n// GET /api/agents → 活跃 Agent 列表\n// GET /api/workflows → 可用工作流列表\n//\n// 依赖 ~/.aevatar/ 配置:config.json、secrets.json、connectors.json;\n// LLM API Key 可从环境变量 DEEPSEEK_API_KEY / OPENAI_API_KEY 或 secrets 读取。\n// ─────────────────────────────────────────────────────────────\n\nusing Aevatar.Bootstrap.Hosting;\nusing Aevatar.GAgentService.Hosting.DependencyInjection;\nusing Aevatar.AI.ToolProviders.NyxId;\nusing Aevatar.Workflow.Extensions.Hosting;\nusing Aevatar.Workflow.Host.Api;\n\nvar builder = WebApplication.CreateBuilder(args);\n\nbuilder.AddAevatarDefaultHost(\n configureHost: options =>\n {\n options.ServiceName = \"Aevatar.Workflow.Host.Api\";\n options.EnableWebSockets = true;\n });\nbuilder.AddAevatarPlatform();\nbuilder.AddWorkflowAgentToolAdmission();\n// NyxID-backed current-user resolver plus aevatar admin access policy.\nbuilder.Services.AddNyxIdPlatformAuthorization(builder.Configuration);\nbuilder.Services.AddNyxIdTools(options =>\n{\n // Override the single default (NyxIdToolOptions.DefaultBaseUrl) only when config provides a\n // non-empty value; an absent/empty config key must NOT clobber the default to null.\n var nyxAuthority = builder.Configuration[\"Aevatar:NyxId:ApiBaseUrl\"]\n ?? builder.Configuration[\"Aevatar:NyxId:Authority\"]\n ?? builder.Configuration[\"Cli:App:NyxId:Authority\"]\n ?? builder.Configuration[\"Aevatar:Authentication:Authority\"];\n if (!string.IsNullOrWhiteSpace(nyxAuthority))\n options.BaseUrl = nyxAuthority;\n if (long.TryParse(builder.Configuration[\"Aevatar:NyxId:ProxyFileArtifactMaxBytes\"], out var maxBytes))\n options.ProxyFileArtifactMaxBytes = maxBytes;\n});\nbuilder.Services.AddScheduledDispatchCapability(builder.Configuration);\nbuilder.AddAevatarWorkflowObservability();\n\nvar app = builder.Build();\n\napp.UseAevatarDefaultHost();\n\napp.Run();\n"}, "1eb93b0ab2c2c1ccb3808841d85697345a4981ee": {"t": 1785756594.824876, "n": 2090, "h": "6463d63d817d5743", "c": "// ─────────────────────────────────────────────────────────────\n// Aevatar.Workflow.Host.Api — Agent-Actor Host HTTP 入口\n//\n// workflow 启动统一通过已注册 service 的 scope API 触发,当前宿主只保留 run control/query 能力\n// GET /api/agents → 活跃 Agent 列表\n// GET /api/workflows → 可用工作流列表\n//\n// 依赖 ~/.aevatar/ 配置:config.json、secrets.json、connectors.json;\n// LLM API Key 可从环境变量 DEEPSEEK_API_KEY / OPENAI_API_KEY 或 secrets 读取。\n// ─────────────────────────────────────────────────────────────\n\nusing Aevatar.Bootstrap.Hosting;\nusing Aevatar.GAgentService.Hosting.DependencyInjection;\nusing Aevatar.AI.ToolProviders.NyxId;\nusing Aevatar.Workflow.Extensions.Hosting;\nusing Aevatar.Workflow.Host.Api;\n\nvar builder = WebApplication.CreateBuilder(args);\n\nbuilder.AddAevatarDefaultHost(\n configureHost: options =>\n {\n options.ServiceName = \"Aevatar.Workflow.Host.Api\";\n options.EnableWebSockets = true;\n });\nbuilder.AddAevatarPlatform();\nbuilder.AddWorkflowAgentToolAdmission();\n// NyxID-backed current-user resolver plus aevatar admin access policy.\nbuilder.Services.AddNyxIdPlatformAuthorization(builder.Configuration);\nbuilder.Services.AddNyxIdTools(options =>\n{\n // Override the single default (NyxIdToolOptions.DefaultBaseUrl) only when config provides a\n // non-empty value; an absent/empty config key must NOT clobber the default to null.\n var nyxAuthority = builder.Configuration[\"Aevatar:NyxId:ApiBaseUrl\"]\n ?? builder.Configuration[\"Aevatar:NyxId:Authority\"]\n ?? builder.Configuration[\"Cli:App:NyxId:Authority\"]\n ?? builder.Configuration[\"Aevatar:Authentication:Authority\"];\n if (!string.IsNullOrWhiteSpace(nyxAuthority))\n options.BaseUrl = nyxAuthority;\n if (long.TryParse(builder.Configuration[\"Aevatar:NyxId:ProxyFileArtifactMaxBytes\"], out var maxBytes))\n options.ProxyFileArtifactMaxBytes = maxBytes;\n});\nbuilder.Services.AddScheduledDispatchCapability(builder.Configuration);\nbuilder.AddAevatarWorkflowObservability();\n\nvar app = builder.Build();\n\napp.UseAevatarDefaultHost();\n\napp.Run();\n"}, "0407aa63b6a9a3058bcc5577b1da277859ce088a": {"t": 1785756596.2722116, "n": 2049, "h": "d13e0ddda3963034", "c": "// ─────────────────────────────────────────────────────────────\n// Aevatar.Workflow.Host.Api — Agent-Actor Host HTTP 入口\n//\n// workflow 启动统一通过已注册 service 的 scope API 触发,当前宿主只保留 run control/query 能力\n// GET /api/agents → 活跃 Agent 列表\n// GET /api/workflows → 可用工作流列表\n//\n// 依赖 ~/.aevatar/ 配置:config.json、secrets.json、connectors.json;\n// LLM API Key 可从环境变量 DEEPSEEK_API_KEY / OPENAI_API_KEY 或 secrets 读取。\n// ─────────────────────────────────────────────────────────────\n\nusing Aevatar.Bootstrap.Hosting;\nusing Aevatar.GAgentService.Hosting.DependencyInjection;\nusing Aevatar.AI.ToolProviders.NyxId;\nusing Aevatar.Workflow.Extensions.Hosting;\nusing Aevatar.Workflow.Host.Api;\n\nvar builder = WebApplication.CreateBuilder(args);\n\nbuilder.AddAevatarDefaultHost(\n configureHost: options =>\n {\n options.ServiceName = \"Aevatar.Workflow.Host.Api\";\n options.EnableWebSockets = true;\n });\nbuilder.AddAevatarPlatform();\n// NyxID-backed current-user resolver plus aevatar admin access policy.\nbuilder.Services.AddNyxIdPlatformAuthorization(builder.Configuration);\nbuilder.Services.AddNyxIdTools(options =>\n{\n // Override the single default (NyxIdToolOptions.DefaultBaseUrl) only when config provides a\n // non-empty value; an absent/empty config key must NOT clobber the default to null.\n var nyxAuthority = builder.Configuration[\"Aevatar:NyxId:ApiBaseUrl\"]\n ?? builder.Configuration[\"Aevatar:NyxId:Authority\"]\n ?? builder.Configuration[\"Cli:App:NyxId:Authority\"]\n ?? builder.Configuration[\"Aevatar:Authentication:Authority\"];\n if (!string.IsNullOrWhiteSpace(nyxAuthority))\n options.BaseUrl = nyxAuthority;\n if (long.TryParse(builder.Configuration[\"Aevatar:NyxId:ProxyFileArtifactMaxBytes\"], out var maxBytes))\n options.ProxyFileArtifactMaxBytes = maxBytes;\n});\nbuilder.Services.AddScheduledDispatchCapability(builder.Configuration);\nbuilder.AddAevatarWorkflowObservability();\n\nvar app = builder.Build();\n\napp.UseAevatarDefaultHost();\n\napp.Run();\n"}, "a361e03c0ff94affc34182bfde53e78a4a74318c": {"t": 1785756597.640384, "n": 1973, "h": "cfd3ac7a9a50538e", "c": "// ─────────────────────────────────────────────────────────────\n// Aevatar.Workflow.Host.Api — Agent-Actor Host HTTP 入口\n//\n// workflow 启动统一通过已注册 service 的 scope API 触发,当前宿主只保留 run control/query 能力\n// GET /api/agents → 活跃 Agent 列表\n// GET /api/workflows → 可用工作流列表\n//\n// 依赖 ~/.aevatar/ 配置:config.json、secrets.json、connectors.json;\n// LLM API Key 可从环境变量 DEEPSEEK_API_KEY / OPENAI_API_KEY 或 secrets 读取。\n// ─────────────────────────────────────────────────────────────\n\nusing Aevatar.Bootstrap.Hosting;\nusing Aevatar.GAgentService.Hosting.DependencyInjection;\nusing Aevatar.AI.ToolProviders.NyxId;\nusing Aevatar.Workflow.Extensions.Hosting;\nusing Aevatar.Workflow.Host.Api;\n\nvar builder = WebApplication.CreateBuilder(args);\n\nbuilder.AddAevatarDefaultHost(\n configureHost: options =>\n {\n options.ServiceName = \"Aevatar.Workflow.Host.Api\";\n options.EnableWebSockets = true;\n });\nbuilder.AddAevatarPlatform();\n// NyxID-backed current-user resolver plus aevatar admin access policy.\nbuilder.Services.AddNyxIdPlatformAuthorization(builder.Configuration);\nbuilder.Services.AddNyxIdTools(options =>\n{\n // Override the single default (NyxIdToolOptions.DefaultBaseUrl) only when config provides a\n // non-empty value; an absent/empty config key must NOT clobber the default to null.\n var nyxAuthority = builder.Configuration[\"Aevatar:NyxId:Authority\"]\n ?? builder.Configuration[\"Cli:App:NyxId:Authority\"]\n ?? builder.Configuration[\"Aevatar:Authentication:Authority\"];\n if (!string.IsNullOrWhiteSpace(nyxAuthority))\n options.BaseUrl = nyxAuthority;\n if (long.TryParse(builder.Configuration[\"Aevatar:NyxId:ProxyFileArtifactMaxBytes\"], out var maxBytes))\n options.ProxyFileArtifactMaxBytes = maxBytes;\n});\nbuilder.Services.AddScheduledDispatchCapability(builder.Configuration);\nbuilder.AddAevatarWorkflowObservability();\n\nvar app = builder.Build();\n\napp.UseAevatarDefaultHost();\n\napp.Run();\n"}, "23039ee92fb514d9cf18bd6c8188569ef9597cd9": {"t": 1785756598.86608, "n": 2005, "h": "e07874b067cc3bd8", "c": "// ─────────────────────────────────────────────────────────────\n// Aevatar.Workflow.Host.Api — Agent-Actor Host HTTP 入口\n//\n// workflow 启动统一通过已注册 service 的 scope API 触发,当前宿主只保留 run control/query 能力\n// GET /api/agents → 活跃 Agent 列表\n// GET /api/workflows → 可用工作流列表\n//\n// 依赖 ~/.aevatar/ 配置:config.json、secrets.json、connectors.json;\n// LLM API Key 可从环境变量 DEEPSEEK_API_KEY / OPENAI_API_KEY 或 secrets 读取。\n// ─────────────────────────────────────────────────────────────\n\nusing Aevatar.Bootstrap.Hosting;\nusing Aevatar.GAgentService.Hosting.DependencyInjection;\nusing Aevatar.AI.ToolProviders.NyxId;\nusing Aevatar.Workflow.Extensions.Hosting;\nusing Aevatar.Workflow.Host.Api;\n\nvar builder = WebApplication.CreateBuilder(args);\n\nbuilder.AddAevatarDefaultHost(\n configureHost: options =>\n {\n options.ServiceName = \"Aevatar.Workflow.Host.Api\";\n options.EnableWebSockets = true;\n });\nbuilder.AddAevatarPlatform();\n// 06-20-observatory-admin-cross-scope: NyxID-backed platform-admin authorizer for the run observatory.\nbuilder.Services.AddNyxIdPlatformAuthorization(builder.Configuration);\nbuilder.Services.AddNyxIdTools(options =>\n{\n // Override the single default (NyxIdToolOptions.DefaultBaseUrl) only when config provides a\n // non-empty value; an absent/empty config key must NOT clobber the default to null.\n var nyxAuthority = builder.Configuration[\"Aevatar:NyxId:Authority\"]\n ?? builder.Configuration[\"Cli:App:NyxId:Authority\"]\n ?? builder.Configuration[\"Aevatar:Authentication:Authority\"];\n if (!string.IsNullOrWhiteSpace(nyxAuthority))\n options.BaseUrl = nyxAuthority;\n if (long.TryParse(builder.Configuration[\"Aevatar:NyxId:ProxyFileArtifactMaxBytes\"], out var maxBytes))\n options.ProxyFileArtifactMaxBytes = maxBytes;\n});\nbuilder.Services.AddScheduledDispatchCapability(builder.Configuration);\nbuilder.AddAevatarWorkflowObservability();\n\nvar app = builder.Build();\n\napp.UseAevatarDefaultHost();\n\napp.Run();\n"}, "45c1bd208f061f4fb1918f5ca42693df22b50b33": {"t": 1785756600.2914104, "n": 1726, "h": "03ab0541e89619f3", "c": "// ─────────────────────────────────────────────────────────────\n// Aevatar.Workflow.Host.Api — Agent-Actor Host HTTP 入口\n//\n// workflow 启动统一通过已注册 service 的 scope API 触发,当前宿主只保留 run control/query 能力\n// GET /api/agents → 活跃 Agent 列表\n// GET /api/workflows → 可用工作流列表\n//\n// 依赖 ~/.aevatar/ 配置:config.json、secrets.json、connectors.json;\n// LLM API Key 可从环境变量 DEEPSEEK_API_KEY / OPENAI_API_KEY 或 secrets 读取。\n// ─────────────────────────────────────────────────────────────\n\nusing Aevatar.Bootstrap.Hosting;\nusing Aevatar.GAgentService.Hosting.DependencyInjection;\nusing Aevatar.AI.ToolProviders.NyxId;\nusing Aevatar.Workflow.Extensions.Hosting;\nusing Aevatar.Workflow.Host.Api;\n\nvar builder = WebApplication.CreateBuilder(args);\n\nbuilder.AddAevatarDefaultHost(\n configureHost: options =>\n {\n options.ServiceName = \"Aevatar.Workflow.Host.Api\";\n options.EnableWebSockets = true;\n });\nbuilder.AddAevatarPlatform();\n// 06-20-observatory-admin-cross-scope: NyxID-backed platform-admin authorizer for the run observatory.\nbuilder.Services.AddNyxIdPlatformAuthorization(builder.Configuration);\nbuilder.Services.AddNyxIdTools(options =>\n{\n options.BaseUrl = builder.Configuration[\"Aevatar:NyxId:Authority\"]\n ?? builder.Configuration[\"Cli:App:NyxId:Authority\"]\n ?? builder.Configuration[\"Aevatar:Authentication:Authority\"];\n if (long.TryParse(builder.Configuration[\"Aevatar:NyxId:ProxyFileArtifactMaxBytes\"], out var maxBytes))\n options.ProxyFileArtifactMaxBytes = maxBytes;\n});\nbuilder.Services.AddScheduledDispatchCapability(builder.Configuration);\nbuilder.AddAevatarWorkflowObservability();\n\nvar app = builder.Build();\n\napp.UseAevatarDefaultHost();\n\napp.Run();\n"}, "c0486567abe64f845421988ef2a8e69bf97cf66a": {"t": 1785756601.7441099, "n": 1551, "h": "d9d90b8d647b8f4b", "c": "// ─────────────────────────────────────────────────────────────\n// Aevatar.Workflow.Host.Api — Agent-Actor Host HTTP 入口\n//\n// workflow 启动统一通过已注册 service 的 scope API 触发,当前宿主只保留 run control/query 能力\n// GET /api/agents → 活跃 Agent 列表\n// GET /api/workflows → 可用工作流列表\n//\n// 依赖 ~/.aevatar/ 配置:config.json、secrets.json、connectors.json;\n// LLM API Key 可从环境变量 DEEPSEEK_API_KEY / OPENAI_API_KEY 或 secrets 读取。\n// ─────────────────────────────────────────────────────────────\n\nusing Aevatar.Bootstrap.Hosting;\nusing Aevatar.GAgentService.Hosting.DependencyInjection;\nusing Aevatar.AI.ToolProviders.NyxId;\nusing Aevatar.Workflow.Extensions.Hosting;\nusing Aevatar.Workflow.Host.Api;\n\nvar builder = WebApplication.CreateBuilder(args);\n\nbuilder.AddAevatarDefaultHost(\n configureHost: options =>\n {\n options.ServiceName = \"Aevatar.Workflow.Host.Api\";\n options.EnableWebSockets = true;\n });\nbuilder.AddAevatarPlatform();\nbuilder.Services.AddNyxIdTools(options =>\n{\n options.BaseUrl = builder.Configuration[\"Aevatar:NyxId:Authority\"]\n ?? builder.Configuration[\"Cli:App:NyxId:Authority\"]\n ?? builder.Configuration[\"Aevatar:Authentication:Authority\"];\n if (long.TryParse(builder.Configuration[\"Aevatar:NyxId:ProxyFileArtifactMaxBytes\"], out var maxBytes))\n options.ProxyFileArtifactMaxBytes = maxBytes;\n});\nbuilder.Services.AddScheduledDispatchCapability(builder.Configuration);\nbuilder.AddAevatarWorkflowObservability();\n\nvar app = builder.Build();\n\napp.UseAevatarDefaultHost();\n\napp.Run();\n"}, "18bbae8c3e607e67eed97200675afbd582c6ed6b": {"t": 1785756603.0127685, "n": 1075, "h": "2e41b37b437766a1", "c": "// ─────────────────────────────────────────────────────────────\n// Aevatar.Workflow.Host.Api — Agent-Actor Host HTTP 入口\n//\n// workflow 启动统一通过已注册 service 的 scope API 触发,当前宿主只保留 run control/query 能力\n// GET /api/agents → 活跃 Agent 列表\n// GET /api/workflows → 可用工作流列表\n//\n// 依赖 ~/.aevatar/ 配置:config.json、secrets.json、connectors.json;\n// LLM API Key 可从环境变量 DEEPSEEK_API_KEY / OPENAI_API_KEY 或 secrets 读取。\n// ─────────────────────────────────────────────────────────────\n\nusing Aevatar.Bootstrap.Hosting;\nusing Aevatar.GAgentService.Hosting.DependencyInjection;\nusing Aevatar.Workflow.Extensions.Hosting;\nusing Aevatar.Workflow.Host.Api;\n\nvar builder = WebApplication.CreateBuilder(args);\n\nbuilder.AddAevatarDefaultHost(\n configureHost: options =>\n {\n options.ServiceName = \"Aevatar.Workflow.Host.Api\";\n options.EnableWebSockets = true;\n });\nbuilder.AddAevatarPlatform();\nbuilder.Services.AddScheduledDispatchCapability(builder.Configuration);\nbuilder.AddAevatarWorkflowObservability();\n\nvar app = builder.Build();\n\napp.UseAevatarDefaultHost();\n\napp.Run();\n"}, "77ff833f6065b425831510a939943b82285ebe8b": {"t": 1785756604.299679, "n": 1071, "h": "f3bd5385da1f401d", "c": "// ─────────────────────────────────────────────────────────────\n// Aevatar.Workflow.Host.Api — Agent-Actor Host HTTP 入口\n//\n// workflow 启动统一通过已注册 service 的 scope API 触发,当前宿主只保留 run control/query 能力\n// GET /api/agents → 活跃 Agent 列表\n// GET /api/workflows → 可用工作流列表\n//\n// 依赖 ~/.aevatar/ 配置:config.json、secrets.json、connectors.json;\n// LLM API Key 可从环境变量 DEEPSEEK_API_KEY / OPENAI_API_KEY 或 secrets 读取。\n// ─────────────────────────────────────────────────────────────\n\nusing Aevatar.Bootstrap.Hosting;\nusing Aevatar.GAgentService.Hosting.DependencyInjection;\nusing Aevatar.Workflow.Extensions.Hosting;\nusing Aevatar.Workflow.Host.Api;\n\nvar builder = WebApplication.CreateBuilder(args);\n\nbuilder.AddAevatarDefaultHost(\n configureHost: options =>\n {\n options.ServiceName = \"Aevatar.Workflow.Host.Api\";\n options.EnableWebSockets = true;\n });\nbuilder.AddAevatarPlatform();\nbuilder.Services.AddGAgentServiceCapability(builder.Configuration);\nbuilder.AddAevatarWorkflowObservability();\n\nvar app = builder.Build();\n\napp.UseAevatarDefaultHost();\n\napp.Run();\n"}, "dcb208cf8ade15cefc5e46403acdbf191f06da61": {"t": 1785756605.8209648, "n": 946, "h": "754d871fa3d83494", "c": "// ─────────────────────────────────────────────────────────────\n// Aevatar.Workflow.Host.Api — Agent-Actor Host HTTP 入口\n//\n// workflow 启动统一通过已注册 service 的 scope API 触发,当前宿主只保留 run control/query 能力\n// GET /api/agents → 活跃 Agent 列表\n// GET /api/workflows → 可用工作流列表\n//\n// 依赖 ~/.aevatar/ 配置:config.json、secrets.json、connectors.json;\n// LLM API Key 可从环境变量 DEEPSEEK_API_KEY / OPENAI_API_KEY 或 secrets 读取。\n// ─────────────────────────────────────────────────────────────\n\nusing Aevatar.Bootstrap.Hosting;\nusing Aevatar.Workflow.Extensions.Hosting;\nusing Aevatar.Workflow.Host.Api;\n\nvar builder = WebApplication.CreateBuilder(args);\n\nbuilder.AddAevatarDefaultHost(\n configureHost: options =>\n {\n options.ServiceName = \"Aevatar.Workflow.Host.Api\";\n options.EnableWebSockets = true;\n });\nbuilder.AddAevatarPlatform();\nbuilder.AddAevatarWorkflowObservability();\n\nvar app = builder.Build();\n\napp.UseAevatarDefaultHost();\n\napp.Run();\n"}, "51a66417e17e0f334b6bb62168bbb8aa7e1f8508": {"t": 1785756607.004891, "n": 936, "h": "bdd55da302bfb8b1", "c": "// ─────────────────────────────────────────────────────────────\n// Aevatar.Workflow.Host.Api — Agent-Actor Host HTTP 入口\n//\n// POST /api/chat → 调用 workflow 应用层执行 chat run,SSE 返回 AGUI 事件\n// GET /api/agents → 活跃 Agent 列表\n// GET /api/workflows → 可用工作流列表\n//\n// 依赖 ~/.aevatar/ 配置:config.json、secrets.json、connectors.json;\n// LLM API Key 可从环境变量 DEEPSEEK_API_KEY / OPENAI_API_KEY 或 secrets 读取。\n// ─────────────────────────────────────────────────────────────\n\nusing Aevatar.Bootstrap.Hosting;\nusing Aevatar.Workflow.Extensions.Hosting;\nusing Aevatar.Workflow.Host.Api;\n\nvar builder = WebApplication.CreateBuilder(args);\n\nbuilder.AddAevatarDefaultHost(\n configureHost: options =>\n {\n options.ServiceName = \"Aevatar.Workflow.Host.Api\";\n options.EnableWebSockets = true;\n });\nbuilder.AddAevatarPlatform();\nbuilder.AddAevatarWorkflowObservability();\n\nvar app = builder.Build();\n\napp.UseAevatarDefaultHost();\n\napp.Run();\n"}, "0b3676dacdd084072d33e4ad77d354c5856bc241": {"t": 1785756608.2470582, "n": 953, "h": "b67d078e09cf3023", "c": "// ─────────────────────────────────────────────────────────────\n// Aevatar.Workflow.Host.Api — Agent-Actor Host HTTP 入口\n//\n// POST /api/chat → 调用 workflow 应用层执行 chat run,SSE 返回 AGUI 事件\n// GET /api/agents → 活跃 Agent 列表\n// GET /api/workflows → 可用工作流列表\n//\n// 依赖 ~/.aevatar/ 配置:config.json、secrets.json、connectors.json;\n// LLM API Key 可从环境变量 DEEPSEEK_API_KEY / OPENAI_API_KEY 或 secrets 读取。\n// ─────────────────────────────────────────────────────────────\n\nusing Aevatar.Bootstrap.Hosting;\nusing Aevatar.Workflow.Extensions.Hosting;\nusing Aevatar.Workflow.Host.Api;\n\nvar builder = WebApplication.CreateBuilder(args);\n\nbuilder.AddAevatarDefaultHost(\n configureHost: options =>\n {\n options.ServiceName = \"Aevatar.Workflow.Host.Api\";\n options.EnableWebSockets = true;\n });\nbuilder.AddWorkflowCapabilityWithAIDefaults();\nbuilder.AddAevatarWorkflowObservability();\n\nvar app = builder.Build();\n\napp.UseAevatarDefaultHost();\n\napp.Run();\n"}, "8fee00638f9947a212a6c629da3440c20771b5de": {"t": 1785756609.7330167, "n": 986, "h": "60bb4752c450c709", "c": "// ─────────────────────────────────────────────────────────────\n// Aevatar.Workflow.Host.Api — Agent-Actor Host HTTP 入口\n//\n// POST /api/chat → 调用 workflow 应用层执行 chat run,SSE 返回 AGUI 事件\n// GET /api/agents → 活跃 Agent 列表\n// GET /api/workflows → 可用工作流列表\n//\n// 依赖 ~/.aevatar/ 配置:config.json、secrets.json、connectors.json;\n// LLM API Key 可从环境变量 DEEPSEEK_API_KEY / OPENAI_API_KEY 或 secrets 读取。\n// ─────────────────────────────────────────────────────────────\n\nusing Aevatar.Bootstrap.Hosting;\nusing Aevatar.Workflow.Extensions.Hosting;\nusing Aevatar.Workflow.Host.Api;\n\nvar builder = WebApplication.CreateBuilder(args);\n\nbuilder.AddAevatarDefaultHost(\n configureHost: options =>\n {\n options.ServiceName = \"Aevatar.Workflow.Host.Api\";\n options.EnableWebSockets = true;\n });\nbuilder.AddWorkflowCapabilityWithAIDefaults();\nbuilder.AddAevatarWorkflowObservability();\n\nvar app = builder.Build();\n\napp.UseAevatarApiTracingScope();\napp.UseAevatarDefaultHost();\n\napp.Run();\n"}, "b71cf1ebb594194e14c6808f866e4f80b016b93e": {"t": 1785756611.0253623, "n": 953, "h": "b67d078e09cf3023", "c": "// ─────────────────────────────────────────────────────────────\n// Aevatar.Workflow.Host.Api — Agent-Actor Host HTTP 入口\n//\n// POST /api/chat → 调用 workflow 应用层执行 chat run,SSE 返回 AGUI 事件\n// GET /api/agents → 活跃 Agent 列表\n// GET /api/workflows → 可用工作流列表\n//\n// 依赖 ~/.aevatar/ 配置:config.json、secrets.json、connectors.json;\n// LLM API Key 可从环境变量 DEEPSEEK_API_KEY / OPENAI_API_KEY 或 secrets 读取。\n// ─────────────────────────────────────────────────────────────\n\nusing Aevatar.Bootstrap.Hosting;\nusing Aevatar.Workflow.Extensions.Hosting;\nusing Aevatar.Workflow.Host.Api;\n\nvar builder = WebApplication.CreateBuilder(args);\n\nbuilder.AddAevatarDefaultHost(\n configureHost: options =>\n {\n options.ServiceName = \"Aevatar.Workflow.Host.Api\";\n options.EnableWebSockets = true;\n });\nbuilder.AddWorkflowCapabilityWithAIDefaults();\nbuilder.AddAevatarWorkflowObservability();\n\nvar app = builder.Build();\n\napp.UseAevatarDefaultHost();\n\napp.Run();\n"}, "47d3f5057e874f2ec98634519e1759bcc61fe3bb": {"t": 1785756612.4565673, "n": 877, "h": "f5fe6a1ed3744c9e", "c": "// ─────────────────────────────────────────────────────────────\n// Aevatar.Workflow.Host.Api — Agent-Actor Host HTTP 入口\n//\n// POST /api/chat → 调用 workflow 应用层执行 chat run,SSE 返回 AGUI 事件\n// GET /api/agents → 活跃 Agent 列表\n// GET /api/workflows → 可用工作流列表\n//\n// 依赖 ~/.aevatar/ 配置:config.json、secrets.json、connectors.json;\n// LLM API Key 可从环境变量 DEEPSEEK_API_KEY / OPENAI_API_KEY 或 secrets 读取。\n// ─────────────────────────────────────────────────────────────\n\nusing Aevatar.Bootstrap.Hosting;\nusing Aevatar.Workflow.Extensions.Hosting;\n\nvar builder = WebApplication.CreateBuilder(args);\n\nbuilder.AddAevatarDefaultHost(\n configureHost: options =>\n {\n options.ServiceName = \"Aevatar.Workflow.Host.Api\";\n options.EnableWebSockets = true;\n });\nbuilder.AddWorkflowCapabilityWithAIDefaults();\n\nvar app = builder.Build();\n\napp.UseAevatarDefaultHost();\n\napp.Run();\n"}, "f35edb579ebdc83da36b33ddfb2ff6c9fb317625": {"t": 1785756613.7385724, "n": 1066, "h": "904e15b4d47cc9a1", "c": "// ─────────────────────────────────────────────────────────────\n// Aevatar.Workflow.Host.Api — Agent-Actor Host HTTP 入口\n//\n// POST /api/chat → 调用 workflow 应用层执行 chat run,SSE 返回 AGUI 事件\n// GET /api/agents → 活跃 Agent 列表\n// GET /api/workflows → 可用工作流列表\n//\n// 依赖 ~/.aevatar/ 配置:config.json、secrets.json、connectors.json;\n// LLM API Key 可从环境变量 DEEPSEEK_API_KEY / OPENAI_API_KEY 或 secrets 读取。\n// ─────────────────────────────────────────────────────────────\n\nusing Aevatar.Workflow.Infrastructure.CapabilityApi;\nusing Aevatar.Bootstrap;\nusing Aevatar.Bootstrap.Hosting;\n\nvar builder = WebApplication.CreateBuilder(args);\n\nbuilder.AddAevatarDefaultHost(\n configureBootstrap: options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMCPTools = true;\n options.EnableSkills = true;\n },\n configureHost: options =>\n {\n options.ServiceName = \"Aevatar.Workflow.Host.Api\";\n options.EnableWebSockets = true;\n });\nbuilder.AddWorkflowCapability();\n\nvar app = builder.Build();\n\napp.UseAevatarDefaultHost();\n\napp.Run();\n"}, "c32f0b6b3445e88c0ae24db71e9fac4a218098a1": {"t": 1785756615.0202506, "n": 2148, "h": "b29dfde7fd040752", "c": "// ─────────────────────────────────────────────────────────────\n// Aevatar.Workflow.Host.Api — Agent-Actor Host HTTP 入口\n//\n// POST /api/chat → 调用 workflow 应用层执行 chat run,SSE 返回 AGUI 事件\n// GET /api/agents → 活跃 Agent 列表\n// GET /api/workflows → 可用工作流列表\n//\n// 依赖 ~/.aevatar/ 配置:config.json、secrets.json、connectors.json;\n// LLM API Key 可从环境变量 DEEPSEEK_API_KEY / OPENAI_API_KEY 或 secrets 读取。\n// ─────────────────────────────────────────────────────────────\n\nusing Aevatar.Workflow.Host.Api.Startup;\nusing Aevatar.CQRS.Runtime.Hosting.DependencyInjection;\nusing Aevatar.CQRS.Runtime.Hosting.Hosting;\nusing Aevatar.Workflow.Infrastructure.DependencyInjection;\nusing Aevatar.Workflow.Infrastructure.CapabilityApi;\nusing Aevatar.Bootstrap;\nusing Aevatar.Configuration;\n\nvar builder = WebApplication.CreateBuilder(args);\n\n// ─── ~/.aevatar/ 配置 ───\nbuilder.Configuration.AddAevatarConfig();\nbuilder.Services.AddAevatarBootstrap(builder.Configuration, options =>\n{\n options.EnableMEAIProviders = true;\n options.EnableMCPTools = true;\n options.EnableSkills = true;\n});\nbuilder.Host.UseAevatarCqrsRuntime(builder.Configuration);\nbuilder.Services.AddAevatarCqrsRuntime(builder.Configuration);\nbuilder.Services.AddWorkflowCapability(builder.Configuration);\nbuilder.Services.AddHostedService<ConnectorBootstrapHostedService>();\n\n// ─── CORS(开发默认放开;生产要求显式白名单) ───\nvar allowedOrigins = builder.Configuration.GetSection(\"Cors:AllowedOrigins\").Get<string[]>();\nbuilder.Services.AddCors(o => o.AddPolicy(\"Default\", p =>\n{\n if (builder.Environment.IsDevelopment())\n {\n p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader();\n return;\n }\n\n if (allowedOrigins is { Length: > 0 })\n {\n p.WithOrigins(allowedOrigins).AllowAnyMethod().AllowAnyHeader();\n return;\n }\n\n // Production default: deny all cross-origin requests unless explicitly configured.\n p.SetIsOriginAllowed(_ => false);\n}));\n\nvar app = builder.Build();\n\napp.UseCors(\"Default\");\napp.UseWebSockets();\napp.MapWorkflowCapabilityEndpoints();\napp.MapGet(\"/\", () => Results.Ok(new { name = \"Aevatar.Workflow.Host.Api\", status = \"running\" }));\n\napp.Run();\n"}, "04b5f5be904b049c5f2afe2514f9e41a79a487bc": {"t": 1785756616.3283305, "n": 2123, "h": "dbf01d43685781cb", "c": "// ─────────────────────────────────────────────────────────────\n// Aevatar.Workflow.Host.Api — Agent-Actor Host HTTP 入口\n//\n// POST /api/chat → 调用 workflow 应用层执行 chat run,SSE 返回 AGUI 事件\n// GET /api/agents → 活跃 Agent 列表\n// GET /api/workflows → 可用工作流列表\n//\n// 依赖 ~/.aevatar/ 配置:config.json、secrets.json、connectors.json;\n// LLM API Key 可从环境变量 DEEPSEEK_API_KEY / OPENAI_API_KEY 或 secrets 读取。\n// ─────────────────────────────────────────────────────────────\n\nusing Aevatar.Workflow.Host.Api.Endpoints;\nusing Aevatar.Workflow.Host.Api.Startup;\nusing Aevatar.CQRS.Runtime.Hosting.DependencyInjection;\nusing Aevatar.CQRS.Runtime.Hosting.Hosting;\nusing Aevatar.Workflow.Infrastructure.DependencyInjection;\nusing Aevatar.Bootstrap;\nusing Aevatar.Configuration;\n\nvar builder = WebApplication.CreateBuilder(args);\n\n// ─── ~/.aevatar/ 配置 ───\nbuilder.Configuration.AddAevatarConfig();\nbuilder.Services.AddAevatarBootstrap(builder.Configuration, options =>\n{\n options.EnableMEAIProviders = true;\n options.EnableMCPTools = true;\n options.EnableSkills = true;\n});\nbuilder.Host.UseAevatarCqrsRuntime(builder.Configuration);\nbuilder.Services.AddAevatarCqrsRuntime(builder.Configuration);\nbuilder.Services.AddWorkflowSubsystem(builder.Configuration);\nbuilder.Services.AddHostedService<ConnectorBootstrapHostedService>();\n\n// ─── CORS(开发默认放开;生产要求显式白名单) ───\nvar allowedOrigins = builder.Configuration.GetSection(\"Cors:AllowedOrigins\").Get<string[]>();\nbuilder.Services.AddCors(o => o.AddPolicy(\"Default\", p =>\n{\n if (builder.Environment.IsDevelopment())\n {\n p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader();\n return;\n }\n\n if (allowedOrigins is { Length: > 0 })\n {\n p.WithOrigins(allowedOrigins).AllowAnyMethod().AllowAnyHeader();\n return;\n }\n\n // Production default: deny all cross-origin requests unless explicitly configured.\n p.SetIsOriginAllowed(_ => false);\n}));\n\nvar app = builder.Build();\n\napp.UseCors(\"Default\");\napp.UseWebSockets();\napp.MapChatEndpoints();\napp.MapGet(\"/\", () => Results.Ok(new { name = \"Aevatar.Workflow.Host.Api\", status = \"running\" }));\n\napp.Run();\n"}, "1dc7c691237cc1b4b4bb080e81dfe658908fef74": {"t": 1785756617.6468265, "n": 1978, "h": "0132b40d5f4eef80", "c": "// ─────────────────────────────────────────────────────────────\n// Aevatar.Workflow.Host.Api — Agent-Actor Host HTTP 入口\n//\n// POST /api/chat → 调用 workflow 应用层执行 chat run,SSE 返回 AGUI 事件\n// GET /api/agents → 活跃 Agent 列表\n// GET /api/workflows → 可用工作流列表\n//\n// 依赖 ~/.aevatar/ 配置:config.json、secrets.json、connectors.json;\n// LLM API Key 可从环境变量 DEEPSEEK_API_KEY / OPENAI_API_KEY 或 secrets 读取。\n// ─────────────────────────────────────────────────────────────\n\nusing Aevatar.Workflow.Host.Api.Endpoints;\nusing Aevatar.Workflow.Host.Api.Startup;\nusing Aevatar.CQRS.Core.DependencyInjection;\nusing Aevatar.Workflow.Infrastructure.DependencyInjection;\nusing Aevatar.Bootstrap;\nusing Aevatar.Configuration;\n\nvar builder = WebApplication.CreateBuilder(args);\n\n// ─── ~/.aevatar/ 配置 ───\nbuilder.Configuration.AddAevatarConfig();\nbuilder.Services.AddAevatarBootstrap(builder.Configuration, options =>\n{\n options.EnableMEAIProviders = true;\n options.EnableMCPTools = true;\n options.EnableSkills = true;\n});\nbuilder.Services.AddCqrsCore();\nbuilder.Services.AddWorkflowSubsystem(builder.Configuration);\nbuilder.Services.AddHostedService<ConnectorBootstrapHostedService>();\n\n// ─── CORS(开发默认放开;生产要求显式白名单) ───\nvar allowedOrigins = builder.Configuration.GetSection(\"Cors:AllowedOrigins\").Get<string[]>();\nbuilder.Services.AddCors(o => o.AddPolicy(\"Default\", p =>\n{\n if (builder.Environment.IsDevelopment())\n {\n p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader();\n return;\n }\n\n if (allowedOrigins is { Length: > 0 })\n {\n p.WithOrigins(allowedOrigins).AllowAnyMethod().AllowAnyHeader();\n return;\n }\n\n // Production default: deny all cross-origin requests unless explicitly configured.\n p.SetIsOriginAllowed(_ => false);\n}));\n\nvar app = builder.Build();\n\napp.UseCors(\"Default\");\napp.UseWebSockets();\napp.MapChatEndpoints();\napp.MapGet(\"/\", () => Results.Ok(new { name = \"Aevatar.Workflow.Host.Api\", status = \"running\" }));\n\napp.Run();\n"}, "bbd906eb503a126c1a4b6a9ff67952cc819ccdd4": {"t": 1786067469.9069982, "n": 2049, "h": "d13e0ddda3963034", "c": "// ─────────────────────────────────────────────────────────────\n// Aevatar.Workflow.Host.Api — Agent-Actor Host HTTP 入口\n//\n// workflow 启动统一通过已注册 service 的 scope API 触发,当前宿主只保留 run control/query 能力\n// GET /api/agents → 活跃 Agent 列表\n// GET /api/workflows → 可用工作流列表\n//\n// 依赖 ~/.aevatar/ 配置:config.json、secrets.json、connectors.json;\n// LLM API Key 可从环境变量 DEEPSEEK_API_KEY / OPENAI_API_KEY 或 secrets 读取。\n// ─────────────────────────────────────────────────────────────\n\nusing Aevatar.Bootstrap.Hosting;\nusing Aevatar.GAgentService.Hosting.DependencyInjection;\nusing Aevatar.AI.ToolProviders.NyxId;\nusing Aevatar.Workflow.Extensions.Hosting;\nusing Aevatar.Workflow.Host.Api;\n\nvar builder = WebApplication.CreateBuilder(args);\n\nbuilder.AddAevatarDefaultHost(\n configureHost: options =>\n {\n options.ServiceName = \"Aevatar.Workflow.Host.Api\";\n options.EnableWebSockets = true;\n });\nbuilder.AddAevatarPlatform();\n// NyxID-backed current-user resolver plus aevatar admin access policy.\nbuilder.Services.AddNyxIdPlatformAuthorization(builder.Configuration);\nbuilder.Services.AddNyxIdTools(options =>\n{\n // Override the single default (NyxIdToolOptions.DefaultBaseUrl) only when config provides a\n // non-empty value; an absent/empty config key must NOT clobber the default to null.\n var nyxAuthority = builder.Configuration[\"Aevatar:NyxId:ApiBaseUrl\"]\n ?? builder.Configuration[\"Aevatar:NyxId:Authority\"]\n ?? builder.Configuration[\"Cli:App:NyxId:Authority\"]\n ?? builder.Configuration[\"Aevatar:Authentication:Authority\"];\n if (!string.IsNullOrWhiteSpace(nyxAuthority))\n options.BaseUrl = nyxAuthority;\n if (long.TryParse(builder.Configuration[\"Aevatar:NyxId:ProxyFileArtifactMaxBytes\"], out var maxBytes))\n options.ProxyFileArtifactMaxBytes = maxBytes;\n});\nbuilder.Services.AddScheduledDispatchCapability(builder.Configuration);\nbuilder.AddAevatarWorkflowObservability();\n\nvar app = builder.Build();\n\napp.UseAevatarDefaultHost();\n\napp.Run();\n"}, "6da4bbe8df605114bb63ccfe6f23383c99a50025": {"t": 1786068322.0925868, "n": 2090, "h": "6463d63d817d5743", "c": "// ─────────────────────────────────────────────────────────────\n// Aevatar.Workflow.Host.Api — Agent-Actor Host HTTP 入口\n//\n// workflow 启动统一通过已注册 service 的 scope API 触发,当前宿主只保留 run control/query 能力\n// GET /api/agents → 活跃 Agent 列表\n// GET /api/workflows → 可用工作流列表\n//\n// 依赖 ~/.aevatar/ 配置:config.json、secrets.json、connectors.json;\n// LLM API Key 可从环境变量 DEEPSEEK_API_KEY / OPENAI_API_KEY 或 secrets 读取。\n// ─────────────────────────────────────────────────────────────\n\nusing Aevatar.Bootstrap.Hosting;\nusing Aevatar.GAgentService.Hosting.DependencyInjection;\nusing Aevatar.AI.ToolProviders.NyxId;\nusing Aevatar.Workflow.Extensions.Hosting;\nusing Aevatar.Workflow.Host.Api;\n\nvar builder = WebApplication.CreateBuilder(args);\n\nbuilder.AddAevatarDefaultHost(\n configureHost: options =>\n {\n options.ServiceName = \"Aevatar.Workflow.Host.Api\";\n options.EnableWebSockets = true;\n });\nbuilder.AddAevatarPlatform();\nbuilder.AddWorkflowAgentToolAdmission();\n// NyxID-backed current-user resolver plus aevatar admin access policy.\nbuilder.Services.AddNyxIdPlatformAuthorization(builder.Configuration);\nbuilder.Services.AddNyxIdTools(options =>\n{\n // Override the single default (NyxIdToolOptions.DefaultBaseUrl) only when config provides a\n // non-empty value; an absent/empty config key must NOT clobber the default to null.\n var nyxAuthority = builder.Configuration[\"Aevatar:NyxId:ApiBaseUrl\"]\n ?? builder.Configuration[\"Aevatar:NyxId:Authority\"]\n ?? builder.Configuration[\"Cli:App:NyxId:Authority\"]\n ?? builder.Configuration[\"Aevatar:Authentication:Authority\"];\n if (!string.IsNullOrWhiteSpace(nyxAuthority))\n options.BaseUrl = nyxAuthority;\n if (long.TryParse(builder.Configuration[\"Aevatar:NyxId:ProxyFileArtifactMaxBytes\"], out var maxBytes))\n options.ProxyFileArtifactMaxBytes = maxBytes;\n});\nbuilder.Services.AddScheduledDispatchCapability(builder.Configuration);\nbuilder.AddAevatarWorkflowObservability();\n\nvar app = builder.Build();\n\napp.UseAevatarDefaultHost();\n\napp.Run();\n"}}, "src/Aevatar.AI.Core/Secrets/SecretManager.cs": {"4ecb75cf0bf248f7906ff3f1402cda113cceeaea": {"t": 1785745798.1992729, "n": 2773, "h": "4e4b978aac56d656", "c": "// ─────────────────────────────────────────────────────────────\n// SecretManager — API Key / 密钥管理\n// 支持从环境变量、文件、配置中解析 ${VAR} 引用\n// ─────────────────────────────────────────────────────────────\n\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\n\nnamespace Aevatar.AI.Core.Secrets;\n\n/// <summary>\n/// 密钥管理器。解析 ${VAR} 引用,从环境变量或 secrets 文件获取。\n/// </summary>\npublic sealed partial class SecretManager\n{\n private readonly Dictionary<string, string> _secrets = new(StringComparer.OrdinalIgnoreCase);\n\n /// <summary>\n /// 从 secrets JSON 文件加载密钥。\n /// 文件格式:{\"OPENAI_API_KEY\": \"sk-...\", \"DEEPSEEK_API_KEY\": \"...\"}\n /// </summary>\n public SecretManager LoadFromFile(string filePath)\n {\n var expandedPath = ExpandPath(filePath);\n if (!File.Exists(expandedPath)) return this;\n\n var json = File.ReadAllText(expandedPath);\n var secrets = JsonSerializer.Deserialize<Dictionary<string, string>>(json);\n if (secrets != null)\n foreach (var (k, v) in secrets)\n _secrets[k] = v;\n return this;\n }\n\n /// <summary>\n /// 从环境变量加载(按前缀过滤)。\n /// </summary>\n public SecretManager LoadFromEnvironment(string prefix = \"AEVATAR_\")\n {\n foreach (var entry in Environment.GetEnvironmentVariables())\n {\n if (entry is System.Collections.DictionaryEntry de &&\n de.Key is string key && key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) &&\n de.Value is string value)\n {\n _secrets[key] = value;\n }\n }\n return this;\n }\n\n /// <summary>\n /// 解析字符串中的 ${VAR} 引用。\n /// 查找顺序:内部 secrets → 环境变量。\n /// </summary>\n public string Resolve(string input)\n {\n if (string.IsNullOrEmpty(input) || !input.Contains(\"${\")) return input;\n\n return VarPattern().Replace(input, match =>\n {\n var varName = match.Groups[1].Value;\n if (_secrets.TryGetValue(varName, out var val)) return val;\n return Environment.GetEnvironmentVariable(varName) ?? match.Value;\n });\n }\n\n /// <summary>直接获取密钥。</summary>\n public string? Get(string key) =>\n _secrets.TryGetValue(key, out var val) ? val : Environment.GetEnvironmentVariable(key);\n\n /// <summary>手动设置密钥。</summary>\n public SecretManager Set(string key, string value) { _secrets[key] = value; return this; }\n\n private static string ExpandPath(string path)\n {\n if (path.StartsWith('~'))\n return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), path[2..]);\n return Environment.ExpandEnvironmentVariables(path);\n }\n\n [GeneratedRegex(@\"\\$\\{(\\w+)\\}\")]\n private static partial Regex VarPattern();\n}\n"}, "026f3fb84b9475723e7b3d8d5b6bde5665a73b68": {"t": 1785756597.276357, "n": 2773, "h": "4e4b978aac56d656", "c": "// ─────────────────────────────────────────────────────────────\n// SecretManager — API Key / 密钥管理\n// 支持从环境变量、文件、配置中解析 ${VAR} 引用\n// ─────────────────────────────────────────────────────────────\n\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\n\nnamespace Aevatar.AI.Core.Secrets;\n\n/// <summary>\n/// 密钥管理器。解析 ${VAR} 引用,从环境变量或 secrets 文件获取。\n/// </summary>\npublic sealed partial class SecretManager\n{\n private readonly Dictionary<string, string> _secrets = new(StringComparer.OrdinalIgnoreCase);\n\n /// <summary>\n /// 从 secrets JSON 文件加载密钥。\n /// 文件格式:{\"OPENAI_API_KEY\": \"sk-...\", \"DEEPSEEK_API_KEY\": \"...\"}\n /// </summary>\n public SecretManager LoadFromFile(string filePath)\n {\n var expandedPath = ExpandPath(filePath);\n if (!File.Exists(expandedPath)) return this;\n\n var json = File.ReadAllText(expandedPath);\n var secrets = JsonSerializer.Deserialize<Dictionary<string, string>>(json);\n if (secrets != null)\n foreach (var (k, v) in secrets)\n _secrets[k] = v;\n return this;\n }\n\n /// <summary>\n /// 从环境变量加载(按前缀过滤)。\n /// </summary>\n public SecretManager LoadFromEnvironment(string prefix = \"AEVATAR_\")\n {\n foreach (var entry in Environment.GetEnvironmentVariables())\n {\n if (entry is System.Collections.DictionaryEntry de &&\n de.Key is string key && key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) &&\n de.Value is string value)\n {\n _secrets[key] = value;\n }\n }\n return this;\n }\n\n /// <summary>\n /// 解析字符串中的 ${VAR} 引用。\n /// 查找顺序:内部 secrets → 环境变量。\n /// </summary>\n public string Resolve(string input)\n {\n if (string.IsNullOrEmpty(input) || !input.Contains(\"${\")) return input;\n\n return VarPattern().Replace(input, match =>\n {\n var varName = match.Groups[1].Value;\n if (_secrets.TryGetValue(varName, out var val)) return val;\n return Environment.GetEnvironmentVariable(varName) ?? match.Value;\n });\n }\n\n /// <summary>直接获取密钥。</summary>\n public string? Get(string key) =>\n _secrets.TryGetValue(key, out var val) ? val : Environment.GetEnvironmentVariable(key);\n\n /// <summary>手动设置密钥。</summary>\n public SecretManager Set(string key, string value) { _secrets[key] = value; return this; }\n\n private static string ExpandPath(string path)\n {\n if (path.StartsWith('~'))\n return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), path[2..]);\n return Environment.ExpandEnvironmentVariables(path);\n }\n\n [GeneratedRegex(@\"\\$\\{(\\w+)\\}\")]\n private static partial Regex VarPattern();\n}\n"}, "bbd906eb503a126c1a4b6a9ff67952cc819ccdd4": {"t": 1786067470.303561, "n": 2773, "h": "4e4b978aac56d656", "c": "// ─────────────────────────────────────────────────────────────\n// SecretManager — API Key / 密钥管理\n// 支持从环境变量、文件、配置中解析 ${VAR} 引用\n// ─────────────────────────────────────────────────────────────\n\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\n\nnamespace Aevatar.AI.Core.Secrets;\n\n/// <summary>\n/// 密钥管理器。解析 ${VAR} 引用,从环境变量或 secrets 文件获取。\n/// </summary>\npublic sealed partial class SecretManager\n{\n private readonly Dictionary<string, string> _secrets = new(StringComparer.OrdinalIgnoreCase);\n\n /// <summary>\n /// 从 secrets JSON 文件加载密钥。\n /// 文件格式:{\"OPENAI_API_KEY\": \"sk-...\", \"DEEPSEEK_API_KEY\": \"...\"}\n /// </summary>\n public SecretManager LoadFromFile(string filePath)\n {\n var expandedPath = ExpandPath(filePath);\n if (!File.Exists(expandedPath)) return this;\n\n var json = File.ReadAllText(expandedPath);\n var secrets = JsonSerializer.Deserialize<Dictionary<string, string>>(json);\n if (secrets != null)\n foreach (var (k, v) in secrets)\n _secrets[k] = v;\n return this;\n }\n\n /// <summary>\n /// 从环境变量加载(按前缀过滤)。\n /// </summary>\n public SecretManager LoadFromEnvironment(string prefix = \"AEVATAR_\")\n {\n foreach (var entry in Environment.GetEnvironmentVariables())\n {\n if (entry is System.Collections.DictionaryEntry de &&\n de.Key is string key && key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) &&\n de.Value is string value)\n {\n _secrets[key] = value;\n }\n }\n return this;\n }\n\n /// <summary>\n /// 解析字符串中的 ${VAR} 引用。\n /// 查找顺序:内部 secrets → 环境变量。\n /// </summary>\n public string Resolve(string input)\n {\n if (string.IsNullOrEmpty(input) || !input.Contains(\"${\")) return input;\n\n return VarPattern().Replace(input, match =>\n {\n var varName = match.Groups[1].Value;\n if (_secrets.TryGetValue(varName, out var val)) return val;\n return Environment.GetEnvironmentVariable(varName) ?? match.Value;\n });\n }\n\n /// <summary>直接获取密钥。</summary>\n public string? Get(string key) =>\n _secrets.TryGetValue(key, out var val) ? val : Environment.GetEnvironmentVariable(key);\n\n /// <summary>手动设置密钥。</summary>\n public SecretManager Set(string key, string value) { _secrets[key] = value; return this; }\n\n private static string ExpandPath(string path)\n {\n if (path.StartsWith('~'))\n return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), path[2..]);\n return Environment.ExpandEnvironmentVariables(path);\n }\n\n [GeneratedRegex(@\"\\$\\{(\\w+)\\}\")]\n private static partial Regex VarPattern();\n}\n"}, "6da4bbe8df605114bb63ccfe6f23383c99a50025": {"t": 1786068321.9263363, "n": 2773, "h": "4e4b978aac56d656", "c": "// ─────────────────────────────────────────────────────────────\n// SecretManager — API Key / 密钥管理\n// 支持从环境变量、文件、配置中解析 ${VAR} 引用\n// ─────────────────────────────────────────────────────────────\n\nusing System.Text.Json;\nusing System.Text.RegularExpressions;\n\nnamespace Aevatar.AI.Core.Secrets;\n\n/// <summary>\n/// 密钥管理器。解析 ${VAR} 引用,从环境变量或 secrets 文件获取。\n/// </summary>\npublic sealed partial class SecretManager\n{\n private readonly Dictionary<string, string> _secrets = new(StringComparer.OrdinalIgnoreCase);\n\n /// <summary>\n /// 从 secrets JSON 文件加载密钥。\n /// 文件格式:{\"OPENAI_API_KEY\": \"sk-...\", \"DEEPSEEK_API_KEY\": \"...\"}\n /// </summary>\n public SecretManager LoadFromFile(string filePath)\n {\n var expandedPath = ExpandPath(filePath);\n if (!File.Exists(expandedPath)) return this;\n\n var json = File.ReadAllText(expandedPath);\n var secrets = JsonSerializer.Deserialize<Dictionary<string, string>>(json);\n if (secrets != null)\n foreach (var (k, v) in secrets)\n _secrets[k] = v;\n return this;\n }\n\n /// <summary>\n /// 从环境变量加载(按前缀过滤)。\n /// </summary>\n public SecretManager LoadFromEnvironment(string prefix = \"AEVATAR_\")\n {\n foreach (var entry in Environment.GetEnvironmentVariables())\n {\n if (entry is System.Collections.DictionaryEntry de &&\n de.Key is string key && key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) &&\n de.Value is string value)\n {\n _secrets[key] = value;\n }\n }\n return this;\n }\n\n /// <summary>\n /// 解析字符串中的 ${VAR} 引用。\n /// 查找顺序:内部 secrets → 环境变量。\n /// </summary>\n public string Resolve(string input)\n {\n if (string.IsNullOrEmpty(input) || !input.Contains(\"${\")) return input;\n\n return VarPattern().Replace(input, match =>\n {\n var varName = match.Groups[1].Value;\n if (_secrets.TryGetValue(varName, out var val)) return val;\n return Environment.GetEnvironmentVariable(varName) ?? match.Value;\n });\n }\n\n /// <summary>直接获取密钥。</summary>\n public string? Get(string key) =>\n _secrets.TryGetValue(key, out var val) ? val : Environment.GetEnvironmentVariable(key);\n\n /// <summary>手动设置密钥。</summary>\n public SecretManager Set(string key, string value) { _secrets[key] = value; return this; }\n\n private static string ExpandPath(string path)\n {\n if (path.StartsWith('~'))\n return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), path[2..]);\n return Environment.ExpandEnvironmentVariables(path);\n }\n\n [GeneratedRegex(@\"\\$\\{(\\w+)\\}\")]\n private static partial Regex VarPattern();\n}\n"}}, "test/Aevatar.Bootstrap.Tests/AIFeatureBootstrapCoverageTests.cs": {"4ecb75cf0bf248f7906ff3f1402cda113cceeaea": {"t": 1785745814.477133, "n": 35161, "h": "f919ff0f364eae76", "c": "using System.Collections;\nusing System.Net.Http;\nusing System.Reflection;\nusing System.Text.Json;\nusing Aevatar.AI.Abstractions;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions.TypeSystem;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Workflow.Abstractions;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\n[Collection(ProcessEnvSerialCollection.Name)]\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n var configuration = new ConfigurationBuilder().Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void ReadConfiguredProviders_WhenNyxIdAuthorityConfigured_ShouldResolveGatewayEndpoint()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"gpt-5.4\",\n DefaultProvider = \"nyxid\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n });\n var configuration = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"nyxid\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://nyx.example.com/api/v1/llm/gateway/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n VoicePresenceBootstrapTests.AddToolExecutionAuditDependencies(services);\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableMEAIProviders = true;\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IVoiceToolInvoker>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSystemSkillOverlayEnabledWithSetName_RegistersOrnnProviderWithBoundOptions()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableSystemSkillOverlay = true;\n options.SystemSkillOverlaySetName = \"aevatar-system\";\n options.OrnnNyxIdSlug = \"ornn-api-custom\";\n options.SystemSkillOverlayRefreshTtl = TimeSpan.FromMinutes(15);\n options.SystemSkillOverlayMaxSkills = 32;\n options.SystemSkillOverlayMaxBytes = 32768;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetRequiredService<ISystemSkillOverlayProvider>()\n .Should().BeOfType<Aevatar.AI.ToolProviders.Ornn.SystemSkillOverlay.OrnnSystemSkillOverlayProvider>();\n\n var overlayOptions = provider.GetRequiredService<SystemSkillOverlayOptions>();\n overlayOptions.SetName.Should().Be(\"aevatar-system\");\n overlayOptions.RefreshTtl.Should().Be(TimeSpan.FromMinutes(15));\n overlayOptions.MaxSkills.Should().Be(32);\n overlayOptions.MaxBytes.Should().Be(32768);\n\n // The overlay reads Ornn through the host-configured NyxID proxy slug even when the\n // model-facing Ornn skill tools are not enabled.\n provider.GetRequiredService<Aevatar.AI.ToolProviders.Ornn.OrnnOptions>()\n .NyxIdSlug.Should().Be(\"ornn-api-custom\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSystemSkillOverlayEnabledWithoutSetName_DoesNotRegisterProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableSystemSkillOverlay = true;\n options.SystemSkillOverlaySetName = \" \";\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ISystemSkillOverlayProvider>().Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterWorkflowRolePrimaryKind()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options => options.EnableMEAIProviders = false);\n\n using var provider = services.BuildServiceProvider();\n var registry = provider.GetRequiredService<IAgentKindRegistry>();\n\n var primary = registry.Resolve(WorkflowRoleConventions.DefaultAgentKind);\n var formerAliasFound = registry.TryResolve(\"workflow.assistant-role\", out var formerAlias);\n\n primary.Metadata.Kind.Should().Be(WorkflowRoleConventions.DefaultAgentKind);\n primary.Metadata.ImplementationClrTypeName.Should().Be(typeof(WorkflowRoleGAgent).FullName);\n formerAliasFound.Should().BeFalse();\n formerAlias.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverEnabled_ShouldRegisterFailoverFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = true;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<FailoverLLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceOpenAIConfigured_ShouldRegisterVoicePresenceModuleFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n services.AddSingleton<IActorRuntime, StaticActorRuntime>();\n services.AddSingleton<IActorDispatchPort, NoOpActorDispatchPort>();\n services.AddSingleton<IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>>(\n new EmptyVoicePresenceCapabilityReader());\n AddVoicePresenceTestCredentialResolver(services);\n VoicePresenceBootstrapTests.AddToolExecutionAuditDependencies(services);\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n options.VoicePresence.OpenAISession = new VoiceSessionConfig\n {\n Voice = \"alloy\",\n SampleRateHz = 24000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n provider.GetRequiredService<IVoiceToolCatalog>()\n .Should().BeOfType<AgentToolVoiceCatalog>();\n provider.GetRequiredService<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>>()\n .Should().BeOfType<ActorOwnedVoiceRealtimeSession>();\n provider.GetRequiredService<IVoicePresenceCapabilityQueryPort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoicePresenceCapabilityCommandPort>()\n .Should().BeOfType<VoicePresenceCapabilityCommandPort>();\n provider.GetRequiredService<IVoicePresenceSessionLeasePort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoiceVolatileMediaStreamPort>()\n .Should().BeOfType<VoiceVolatileMediaStreamPort>();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n defaultModule.As<VoicePresenceModule>()\n .CanHandle(new EventEnvelope\n {\n Payload = Google.Protobuf.WellKnownTypes.Any.Pack(new Google.Protobuf.WellKnownTypes.StringValue\n {\n Value = \"doorbell\",\n }),\n Route = EnvelopeRouteSemantics.CreateDirect(\"device-events.callback\", \"voice-agent\"),\n })\n .Should()\n .BeFalse();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeTrue();\n openAIModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm\", out var miniCpmModule).Should().BeFalse();\n miniCpmModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldPreserveConfiguredVoiceDirectExternalEventTypeUrls()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n services.AddSingleton<IActorDispatchPort, NoOpActorDispatchPort>();\n services.AddSingleton<IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>>(\n new EmptyVoicePresenceCapabilityReader());\n AddVoicePresenceTestCredentialResolver(services);\n VoicePresenceBootstrapTests.AddToolExecutionAuditDependencies(services);\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.Module = new VoicePresenceModuleOptions\n {\n DirectExternalEventTypeUrls =\n [\n \" type.googleapis.com/example.External \",\n \"type.googleapis.com/example.External\",\n ],\n };\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n\n factory.TryCreate(\"voice_presence\", out var module).Should().BeTrue();\n var voiceModule = module.Should().BeOfType<VoicePresenceModule>().Subject;\n\n voiceModule.CanHandle(new EventEnvelope\n {\n Payload = new Google.Protobuf.WellKnownTypes.Any\n {\n TypeUrl = \"type.googleapis.com/example.External\",\n Value = Google.Protobuf.ByteString.CopyFromUtf8(\"payload\"),\n },\n Route = EnvelopeRouteSemantics.CreateDirect(\"device-events.callback\", \"voice-agent\"),\n }).Should().BeTrue();\n voiceModule.CanHandle(new EventEnvelope\n {\n Payload = new Google.Protobuf.WellKnownTypes.Any\n {\n TypeUrl = \"type.googleapis.com/example.Other\",\n Value = Google.Protobuf.ByteString.CopyFromUtf8(\"payload\"),\n },\n Route = EnvelopeRouteSemantics.CreateDirect(\"device-events.callback\", \"voice-agent\"),\n }).Should().BeFalse();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverDisabled_ShouldRegisterMEAIFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<MEAILLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetRequiredService<IAgentKindRegistry>()\n .Resolve(WorkflowRoleConventions.DefaultAgentKind)\n .Metadata\n .ImplementationClrTypeName\n .Should()\n .Be(typeof(WorkflowRoleGAgent).FullName);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenReloadableFactoryEnabled_ShouldPickUpdatedSecrets()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-reload-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n var previousAllowPlaintext = Environment.GetEnvironmentVariable(LocalSecretProtectionOptions.AllowPlaintextSecretsEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n Environment.SetEnvironmentVariable(LocalSecretProtectionOptions.AllowPlaintextSecretsEnv, \"true\");\n\n try\n {\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:openai:ApiKey\"] = \"openai-key\",\n [\"LLMProviders:Providers:openai:ProviderType\"] = \"openai\",\n [\"LLMProviders:Providers:openai:Model\"] = \"gpt-5.4\",\n [\"LLMProviders:Default\"] = \"openai\",\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.EnableReloadableProviderFactory = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"openai\");\n\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Providers:deepseek:Model\"] = \"deepseek-chat\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Environment.SetEnvironmentVariable(LocalSecretProtectionOptions.AllowPlaintextSecretsEnv, previousAllowPlaintext);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyNyxIdProviderConfigured_ShouldRegisterNyxIdDefaultProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n [\"LLMProviders:Default\"] = \"nyxid\",\n });\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"nyxid\");\n llmFactory.GetDefault().Name.Should().Be(\"nyxid\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSecretsStoreOptionAbsent_ShouldUseDIRegisteredStore()\n {\n // Mainnet path: a host registers IAevatarSecretsStore (e.g. the\n // read-only EnvironmentSecretsStore) into DI but does not pass\n // options.SecretsStore. Before the fix, AddAevatarAIFeatures fell\n // back to `new AevatarSecretsStore()` which re-opens secrets.json\n // from disk. This asserts that the DI-registered store is honored.\n var diRegistered = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"from-di-registered-store\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n var services = new ServiceCollection();\n services.AddSingleton<IAevatarSecretsStore>(diRegistered);\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n // intentionally leave options.SecretsStore = null\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder(\n new VoicePresenceBootstrapTests.TestHttpClientFactory(),\n new VoicePresenceBootstrapTests.UnusedAgentToolExecutionPort());\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldSupportRemoteUrlConfiguration()\n {\n var builder = new MCPConnectorBuilder(\n new VoicePresenceBootstrapTests.TestHttpClientFactory(),\n new VoicePresenceBootstrapTests.UnusedAgentToolExecutionPort());\n var entry = new ConnectorConfigEntry\n {\n Name = \"nyxid_mcp\",\n Type = \"mcp\",\n TimeoutMs = 15000,\n MCP = new MCPConnectorConfig\n {\n ServerName = \"nyxid\",\n Url = \"https://nyxid.example.com/mcp\",\n AdditionalHeaders = new Dictionary<string, string> { [\"x-tenant\"] = \"demo\" },\n DefaultTool = \"chrono-graph__query\",\n AllowedTools = [\"chrono-graph__query\"],\n },\n };\n\n var ok = builder.TryBuild(entry, NullLogger.Instance, out var connector);\n\n ok.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"nyxid_mcp\");\n\n var serverConfigField = connector.GetType().GetField(\"_serverConfig\", BindingFlags.Instance | BindingFlags.NonPublic);\n serverConfigField.Should().NotBeNull();\n var serverConfig = serverConfigField!.GetValue(connector).Should().BeOfType<MCPServerConfig>().Subject;\n serverConfig.Url.Should().Be(\"https://nyxid.example.com/mcp\");\n serverConfig.HttpClient.Should().NotBeNull();\n serverConfig.HttpClient!.Timeout.Should().Be(Timeout.InfiniteTimeSpan);\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, configuration, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private static void AddVoicePresenceTestCredentialResolver(IServiceCollection services) =>\n services.AddSingleton<IRealtimeProviderCredentialResolver, NoOpRealtimeProviderCredentialResolver>();\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n\n private static void WriteFlatSecrets(string path, IReadOnlyDictionary<string, string> values)\n {\n var directory = Path.GetDirectoryName(path);\n if (!string.IsNullOrWhiteSpace(directory))\n Directory.CreateDirectory(directory);\n\n var json = JsonSerializer.Serialize(values);\n File.WriteAllText(path, json);\n File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddSeconds(1));\n }\n\n private sealed class NoOpActorDispatchPort : IActorDispatchPort\n {\n public Task<DispatchAdmission> DispatchAsync(string actorId, EventEnvelope envelope, CancellationToken ct = default) =>\n Task.FromResult(DispatchAdmissionFactory.Create(actorId, envelope));\n }\n\n private sealed class StaticActorRuntime : IActorRuntime\n {\n public Task<IActor> CreateAsync<TAgent>(string? id = null, CancellationToken ct = default)\n where TAgent : IAgent =>\n throw new NotSupportedException();\n\n public Task<IActor> CreateAsync(Type agentType, string? id = null, CancellationToken ct = default) =>\n throw new NotSupportedException();\n\n public Task DestroyAsync(string id, CancellationToken ct = default) =>\n Task.CompletedTask;\n\n public Task<IActor?> GetAsync(string id) =>\n Task.FromResult<IActor?>(null);\n\n public Task<bool> ExistsAsync(string id) =>\n Task.FromResult(true);\n\n public Task LinkAsync(string parentId, string childId, CancellationToken ct = default) =>\n Task.CompletedTask;\n\n public Task UnlinkAsync(string childId, CancellationToken ct = default) =>\n Task.CompletedTask;\n }\n\n private sealed class EmptyVoicePresenceCapabilityReader\n : IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>\n {\n public Task<VoicePresenceCapabilityReadModel?> GetAsync(string key, CancellationToken ct = default) =>\n Task.FromResult<VoicePresenceCapabilityReadModel?>(null);\n\n public Task<ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>> QueryAsync(\n ProjectionDocumentQuery query,\n CancellationToken ct = default) =>\n Task.FromResult(ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>.Empty);\n }\n\n private sealed class NoOpRealtimeProviderCredentialResolver : IRealtimeProviderCredentialResolver\n {\n public Task<string?> ResolveApiKeyAsync(\n VoiceProviderSessionKey sessionKey,\n VoiceProviderConfig config,\n CancellationToken ct) =>\n Task.FromResult<string?>(null);\n }\n\n}\n"}, "1eb93b0ab2c2c1ccb3808841d85697345a4981ee": {"t": 1785756595.3252122, "n": 35161, "h": "f919ff0f364eae76", "c": "using System.Collections;\nusing System.Net.Http;\nusing System.Reflection;\nusing System.Text.Json;\nusing Aevatar.AI.Abstractions;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions.TypeSystem;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Workflow.Abstractions;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\n[Collection(ProcessEnvSerialCollection.Name)]\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n var configuration = new ConfigurationBuilder().Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void ReadConfiguredProviders_WhenNyxIdAuthorityConfigured_ShouldResolveGatewayEndpoint()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"gpt-5.4\",\n DefaultProvider = \"nyxid\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n });\n var configuration = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"nyxid\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://nyx.example.com/api/v1/llm/gateway/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n VoicePresenceBootstrapTests.AddToolExecutionAuditDependencies(services);\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableMEAIProviders = true;\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IVoiceToolInvoker>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSystemSkillOverlayEnabledWithSetName_RegistersOrnnProviderWithBoundOptions()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableSystemSkillOverlay = true;\n options.SystemSkillOverlaySetName = \"aevatar-system\";\n options.OrnnNyxIdSlug = \"ornn-api-custom\";\n options.SystemSkillOverlayRefreshTtl = TimeSpan.FromMinutes(15);\n options.SystemSkillOverlayMaxSkills = 32;\n options.SystemSkillOverlayMaxBytes = 32768;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetRequiredService<ISystemSkillOverlayProvider>()\n .Should().BeOfType<Aevatar.AI.ToolProviders.Ornn.SystemSkillOverlay.OrnnSystemSkillOverlayProvider>();\n\n var overlayOptions = provider.GetRequiredService<SystemSkillOverlayOptions>();\n overlayOptions.SetName.Should().Be(\"aevatar-system\");\n overlayOptions.RefreshTtl.Should().Be(TimeSpan.FromMinutes(15));\n overlayOptions.MaxSkills.Should().Be(32);\n overlayOptions.MaxBytes.Should().Be(32768);\n\n // The overlay reads Ornn through the host-configured NyxID proxy slug even when the\n // model-facing Ornn skill tools are not enabled.\n provider.GetRequiredService<Aevatar.AI.ToolProviders.Ornn.OrnnOptions>()\n .NyxIdSlug.Should().Be(\"ornn-api-custom\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSystemSkillOverlayEnabledWithoutSetName_DoesNotRegisterProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableSystemSkillOverlay = true;\n options.SystemSkillOverlaySetName = \" \";\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ISystemSkillOverlayProvider>().Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterWorkflowRolePrimaryKind()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options => options.EnableMEAIProviders = false);\n\n using var provider = services.BuildServiceProvider();\n var registry = provider.GetRequiredService<IAgentKindRegistry>();\n\n var primary = registry.Resolve(WorkflowRoleConventions.DefaultAgentKind);\n var formerAliasFound = registry.TryResolve(\"workflow.assistant-role\", out var formerAlias);\n\n primary.Metadata.Kind.Should().Be(WorkflowRoleConventions.DefaultAgentKind);\n primary.Metadata.ImplementationClrTypeName.Should().Be(typeof(WorkflowRoleGAgent).FullName);\n formerAliasFound.Should().BeFalse();\n formerAlias.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverEnabled_ShouldRegisterFailoverFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = true;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<FailoverLLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceOpenAIConfigured_ShouldRegisterVoicePresenceModuleFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n services.AddSingleton<IActorRuntime, StaticActorRuntime>();\n services.AddSingleton<IActorDispatchPort, NoOpActorDispatchPort>();\n services.AddSingleton<IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>>(\n new EmptyVoicePresenceCapabilityReader());\n AddVoicePresenceTestCredentialResolver(services);\n VoicePresenceBootstrapTests.AddToolExecutionAuditDependencies(services);\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n options.VoicePresence.OpenAISession = new VoiceSessionConfig\n {\n Voice = \"alloy\",\n SampleRateHz = 24000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n provider.GetRequiredService<IVoiceToolCatalog>()\n .Should().BeOfType<AgentToolVoiceCatalog>();\n provider.GetRequiredService<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>>()\n .Should().BeOfType<ActorOwnedVoiceRealtimeSession>();\n provider.GetRequiredService<IVoicePresenceCapabilityQueryPort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoicePresenceCapabilityCommandPort>()\n .Should().BeOfType<VoicePresenceCapabilityCommandPort>();\n provider.GetRequiredService<IVoicePresenceSessionLeasePort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoiceVolatileMediaStreamPort>()\n .Should().BeOfType<VoiceVolatileMediaStreamPort>();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n defaultModule.As<VoicePresenceModule>()\n .CanHandle(new EventEnvelope\n {\n Payload = Google.Protobuf.WellKnownTypes.Any.Pack(new Google.Protobuf.WellKnownTypes.StringValue\n {\n Value = \"doorbell\",\n }),\n Route = EnvelopeRouteSemantics.CreateDirect(\"device-events.callback\", \"voice-agent\"),\n })\n .Should()\n .BeFalse();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeTrue();\n openAIModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm\", out var miniCpmModule).Should().BeFalse();\n miniCpmModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldPreserveConfiguredVoiceDirectExternalEventTypeUrls()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n services.AddSingleton<IActorDispatchPort, NoOpActorDispatchPort>();\n services.AddSingleton<IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>>(\n new EmptyVoicePresenceCapabilityReader());\n AddVoicePresenceTestCredentialResolver(services);\n VoicePresenceBootstrapTests.AddToolExecutionAuditDependencies(services);\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.Module = new VoicePresenceModuleOptions\n {\n DirectExternalEventTypeUrls =\n [\n \" type.googleapis.com/example.External \",\n \"type.googleapis.com/example.External\",\n ],\n };\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n\n factory.TryCreate(\"voice_presence\", out var module).Should().BeTrue();\n var voiceModule = module.Should().BeOfType<VoicePresenceModule>().Subject;\n\n voiceModule.CanHandle(new EventEnvelope\n {\n Payload = new Google.Protobuf.WellKnownTypes.Any\n {\n TypeUrl = \"type.googleapis.com/example.External\",\n Value = Google.Protobuf.ByteString.CopyFromUtf8(\"payload\"),\n },\n Route = EnvelopeRouteSemantics.CreateDirect(\"device-events.callback\", \"voice-agent\"),\n }).Should().BeTrue();\n voiceModule.CanHandle(new EventEnvelope\n {\n Payload = new Google.Protobuf.WellKnownTypes.Any\n {\n TypeUrl = \"type.googleapis.com/example.Other\",\n Value = Google.Protobuf.ByteString.CopyFromUtf8(\"payload\"),\n },\n Route = EnvelopeRouteSemantics.CreateDirect(\"device-events.callback\", \"voice-agent\"),\n }).Should().BeFalse();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverDisabled_ShouldRegisterMEAIFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<MEAILLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetRequiredService<IAgentKindRegistry>()\n .Resolve(WorkflowRoleConventions.DefaultAgentKind)\n .Metadata\n .ImplementationClrTypeName\n .Should()\n .Be(typeof(WorkflowRoleGAgent).FullName);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenReloadableFactoryEnabled_ShouldPickUpdatedSecrets()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-reload-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n var previousAllowPlaintext = Environment.GetEnvironmentVariable(LocalSecretProtectionOptions.AllowPlaintextSecretsEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n Environment.SetEnvironmentVariable(LocalSecretProtectionOptions.AllowPlaintextSecretsEnv, \"true\");\n\n try\n {\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:openai:ApiKey\"] = \"openai-key\",\n [\"LLMProviders:Providers:openai:ProviderType\"] = \"openai\",\n [\"LLMProviders:Providers:openai:Model\"] = \"gpt-5.4\",\n [\"LLMProviders:Default\"] = \"openai\",\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.EnableReloadableProviderFactory = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"openai\");\n\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Providers:deepseek:Model\"] = \"deepseek-chat\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Environment.SetEnvironmentVariable(LocalSecretProtectionOptions.AllowPlaintextSecretsEnv, previousAllowPlaintext);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyNyxIdProviderConfigured_ShouldRegisterNyxIdDefaultProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n [\"LLMProviders:Default\"] = \"nyxid\",\n });\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"nyxid\");\n llmFactory.GetDefault().Name.Should().Be(\"nyxid\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSecretsStoreOptionAbsent_ShouldUseDIRegisteredStore()\n {\n // Mainnet path: a host registers IAevatarSecretsStore (e.g. the\n // read-only EnvironmentSecretsStore) into DI but does not pass\n // options.SecretsStore. Before the fix, AddAevatarAIFeatures fell\n // back to `new AevatarSecretsStore()` which re-opens secrets.json\n // from disk. This asserts that the DI-registered store is honored.\n var diRegistered = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"from-di-registered-store\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n var services = new ServiceCollection();\n services.AddSingleton<IAevatarSecretsStore>(diRegistered);\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n // intentionally leave options.SecretsStore = null\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder(\n new VoicePresenceBootstrapTests.TestHttpClientFactory(),\n new VoicePresenceBootstrapTests.UnusedAgentToolExecutionPort());\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldSupportRemoteUrlConfiguration()\n {\n var builder = new MCPConnectorBuilder(\n new VoicePresenceBootstrapTests.TestHttpClientFactory(),\n new VoicePresenceBootstrapTests.UnusedAgentToolExecutionPort());\n var entry = new ConnectorConfigEntry\n {\n Name = \"nyxid_mcp\",\n Type = \"mcp\",\n TimeoutMs = 15000,\n MCP = new MCPConnectorConfig\n {\n ServerName = \"nyxid\",\n Url = \"https://nyxid.example.com/mcp\",\n AdditionalHeaders = new Dictionary<string, string> { [\"x-tenant\"] = \"demo\" },\n DefaultTool = \"chrono-graph__query\",\n AllowedTools = [\"chrono-graph__query\"],\n },\n };\n\n var ok = builder.TryBuild(entry, NullLogger.Instance, out var connector);\n\n ok.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"nyxid_mcp\");\n\n var serverConfigField = connector.GetType().GetField(\"_serverConfig\", BindingFlags.Instance | BindingFlags.NonPublic);\n serverConfigField.Should().NotBeNull();\n var serverConfig = serverConfigField!.GetValue(connector).Should().BeOfType<MCPServerConfig>().Subject;\n serverConfig.Url.Should().Be(\"https://nyxid.example.com/mcp\");\n serverConfig.HttpClient.Should().NotBeNull();\n serverConfig.HttpClient!.Timeout.Should().Be(Timeout.InfiniteTimeSpan);\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, configuration, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private static void AddVoicePresenceTestCredentialResolver(IServiceCollection services) =>\n services.AddSingleton<IRealtimeProviderCredentialResolver, NoOpRealtimeProviderCredentialResolver>();\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n\n private static void WriteFlatSecrets(string path, IReadOnlyDictionary<string, string> values)\n {\n var directory = Path.GetDirectoryName(path);\n if (!string.IsNullOrWhiteSpace(directory))\n Directory.CreateDirectory(directory);\n\n var json = JsonSerializer.Serialize(values);\n File.WriteAllText(path, json);\n File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddSeconds(1));\n }\n\n private sealed class NoOpActorDispatchPort : IActorDispatchPort\n {\n public Task<DispatchAdmission> DispatchAsync(string actorId, EventEnvelope envelope, CancellationToken ct = default) =>\n Task.FromResult(DispatchAdmissionFactory.Create(actorId, envelope));\n }\n\n private sealed class StaticActorRuntime : IActorRuntime\n {\n public Task<IActor> CreateAsync<TAgent>(string? id = null, CancellationToken ct = default)\n where TAgent : IAgent =>\n throw new NotSupportedException();\n\n public Task<IActor> CreateAsync(Type agentType, string? id = null, CancellationToken ct = default) =>\n throw new NotSupportedException();\n\n public Task DestroyAsync(string id, CancellationToken ct = default) =>\n Task.CompletedTask;\n\n public Task<IActor?> GetAsync(string id) =>\n Task.FromResult<IActor?>(null);\n\n public Task<bool> ExistsAsync(string id) =>\n Task.FromResult(true);\n\n public Task LinkAsync(string parentId, string childId, CancellationToken ct = default) =>\n Task.CompletedTask;\n\n public Task UnlinkAsync(string childId, CancellationToken ct = default) =>\n Task.CompletedTask;\n }\n\n private sealed class EmptyVoicePresenceCapabilityReader\n : IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>\n {\n public Task<VoicePresenceCapabilityReadModel?> GetAsync(string key, CancellationToken ct = default) =>\n Task.FromResult<VoicePresenceCapabilityReadModel?>(null);\n\n public Task<ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>> QueryAsync(\n ProjectionDocumentQuery query,\n CancellationToken ct = default) =>\n Task.FromResult(ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>.Empty);\n }\n\n private sealed class NoOpRealtimeProviderCredentialResolver : IRealtimeProviderCredentialResolver\n {\n public Task<string?> ResolveApiKeyAsync(\n VoiceProviderSessionKey sessionKey,\n VoiceProviderConfig config,\n CancellationToken ct) =>\n Task.FromResult<string?>(null);\n }\n\n}\n"}, "b1f1333d652ea01bd691cb0eb759bbdd09bf678a": {"t": 1785756596.720148, "n": 37330, "h": "b881aeb1577ecd93", "c": "using System.Collections;\nusing System.Net.Http;\nusing System.Reflection;\nusing System.Text.Json;\nusing Aevatar.AI.Abstractions;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions.TypeSystem;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Workflow.Abstractions;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\n[Collection(ProcessEnvSerialCollection.Name)]\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n var configuration = new ConfigurationBuilder().Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void ReadConfiguredProviders_WhenNyxIdAuthorityConfigured_ShouldResolveGatewayEndpoint()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"gpt-5.4\",\n DefaultProvider = \"nyxid\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n });\n var configuration = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"nyxid\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://nyx.example.com/api/v1/llm/gateway/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableMEAIProviders = true;\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IVoiceToolInvoker>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSystemSkillOverlayEnabledWithSetName_RegistersOrnnProviderWithBoundOptions()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableSystemSkillOverlay = true;\n options.SystemSkillOverlaySetName = \"aevatar-system\";\n options.OrnnNyxIdSlug = \"ornn-api-custom\";\n options.SystemSkillOverlayRefreshTtl = TimeSpan.FromMinutes(15);\n options.SystemSkillOverlayMaxSkills = 32;\n options.SystemSkillOverlayMaxBytes = 32768;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetRequiredService<ISystemSkillOverlayProvider>()\n .Should().BeOfType<Aevatar.AI.ToolProviders.Ornn.SystemSkillOverlay.OrnnSystemSkillOverlayProvider>();\n\n var overlayOptions = provider.GetRequiredService<SystemSkillOverlayOptions>();\n overlayOptions.SetName.Should().Be(\"aevatar-system\");\n overlayOptions.RefreshTtl.Should().Be(TimeSpan.FromMinutes(15));\n overlayOptions.MaxSkills.Should().Be(32);\n overlayOptions.MaxBytes.Should().Be(32768);\n\n // The overlay reads Ornn through the host-configured NyxID proxy slug even when the\n // model-facing Ornn skill tools are not enabled.\n provider.GetRequiredService<Aevatar.AI.ToolProviders.Ornn.OrnnOptions>()\n .NyxIdSlug.Should().Be(\"ornn-api-custom\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSystemSkillOverlayEnabledWithoutSetName_DoesNotRegisterProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableSystemSkillOverlay = true;\n options.SystemSkillOverlaySetName = \" \";\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ISystemSkillOverlayProvider>().Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterWorkflowRolePrimaryKind()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options => options.EnableMEAIProviders = false);\n\n using var provider = services.BuildServiceProvider();\n var registry = provider.GetRequiredService<IAgentKindRegistry>();\n\n var primary = registry.Resolve(WorkflowRoleConventions.DefaultAgentKind);\n var formerAliasFound = registry.TryResolve(\"workflow.assistant-role\", out var formerAlias);\n\n primary.Metadata.Kind.Should().Be(WorkflowRoleConventions.DefaultAgentKind);\n primary.Metadata.ImplementationClrTypeName.Should().Be(typeof(WorkflowRoleGAgent).FullName);\n formerAliasFound.Should().BeFalse();\n formerAlias.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverEnabled_ShouldRegisterFailoverFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = true;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<FailoverLLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceOpenAIConfigured_ShouldRegisterVoicePresenceModuleFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n services.AddSingleton<IActorRuntime, StaticActorRuntime>();\n services.AddSingleton<IActorDispatchPort, NoOpActorDispatchPort>();\n services.AddSingleton<IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>>(\n new EmptyVoicePresenceCapabilityReader());\n AddVoicePresenceTestCredentialResolver(services);\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n options.VoicePresence.OpenAISession = new VoiceSessionConfig\n {\n Voice = \"alloy\",\n SampleRateHz = 24000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n provider.GetRequiredService<IVoiceToolCatalog>()\n .Should().BeOfType<AgentToolVoiceCatalog>();\n provider.GetRequiredService<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>>()\n .Should().BeOfType<ActorOwnedVoiceRealtimeSession>();\n provider.GetRequiredService<IVoicePresenceCapabilityQueryPort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoicePresenceCapabilityCommandPort>()\n .Should().BeOfType<VoicePresenceCapabilityCommandPort>();\n provider.GetRequiredService<IVoicePresenceSessionLeasePort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoiceVolatileMediaStreamPort>()\n .Should().BeOfType<VoiceVolatileMediaStreamPort>();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n defaultModule.As<VoicePresenceModule>()\n .CanHandle(new EventEnvelope\n {\n Payload = Google.Protobuf.WellKnownTypes.Any.Pack(new Google.Protobuf.WellKnownTypes.StringValue\n {\n Value = \"doorbell\",\n }),\n Route = EnvelopeRouteSemantics.CreateDirect(\"device-events.callback\", \"voice-agent\"),\n })\n .Should()\n .BeFalse();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeTrue();\n openAIModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm\", out var miniCpmModule).Should().BeFalse();\n miniCpmModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldPreserveConfiguredVoiceDirectExternalEventTypeUrls()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n services.AddSingleton<IActorDispatchPort, NoOpActorDispatchPort>();\n services.AddSingleton<IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>>(\n new EmptyVoicePresenceCapabilityReader());\n AddVoicePresenceTestCredentialResolver(services);\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.Module = new VoicePresenceModuleOptions\n {\n DirectExternalEventTypeUrls =\n [\n \" type.googleapis.com/example.External \",\n \"type.googleapis.com/example.External\",\n ],\n };\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n\n factory.TryCreate(\"voice_presence\", out var module).Should().BeTrue();\n var voiceModule = module.Should().BeOfType<VoicePresenceModule>().Subject;\n\n voiceModule.CanHandle(new EventEnvelope\n {\n Payload = new Google.Protobuf.WellKnownTypes.Any\n {\n TypeUrl = \"type.googleapis.com/example.External\",\n Value = Google.Protobuf.ByteString.CopyFromUtf8(\"payload\"),\n },\n Route = EnvelopeRouteSemantics.CreateDirect(\"device-events.callback\", \"voice-agent\"),\n }).Should().BeTrue();\n voiceModule.CanHandle(new EventEnvelope\n {\n Payload = new Google.Protobuf.WellKnownTypes.Any\n {\n TypeUrl = \"type.googleapis.com/example.Other\",\n Value = Google.Protobuf.ByteString.CopyFromUtf8(\"payload\"),\n },\n Route = EnvelopeRouteSemantics.CreateDirect(\"device-events.callback\", \"voice-agent\"),\n }).Should().BeFalse();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverDisabled_ShouldRegisterMEAIFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<MEAILLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetRequiredService<IAgentKindRegistry>()\n .Resolve(WorkflowRoleConventions.DefaultAgentKind)\n .Metadata\n .ImplementationClrTypeName\n .Should()\n .Be(typeof(WorkflowRoleGAgent).FullName);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenReloadableFactoryEnabled_ShouldPickUpdatedSecrets()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-reload-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n var previousAllowPlaintext = Environment.GetEnvironmentVariable(LocalSecretProtectionOptions.AllowPlaintextSecretsEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n Environment.SetEnvironmentVariable(LocalSecretProtectionOptions.AllowPlaintextSecretsEnv, \"true\");\n\n try\n {\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:openai:ApiKey\"] = \"openai-key\",\n [\"LLMProviders:Providers:openai:ProviderType\"] = \"openai\",\n [\"LLMProviders:Providers:openai:Model\"] = \"gpt-5.4\",\n [\"LLMProviders:Default\"] = \"openai\",\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.EnableReloadableProviderFactory = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"openai\");\n\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Providers:deepseek:Model\"] = \"deepseek-chat\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Environment.SetEnvironmentVariable(LocalSecretProtectionOptions.AllowPlaintextSecretsEnv, previousAllowPlaintext);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyNyxIdProviderConfigured_ShouldRegisterNyxIdDefaultProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n [\"LLMProviders:Default\"] = \"nyxid\",\n });\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"nyxid\");\n llmFactory.GetDefault().Name.Should().Be(\"nyxid\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSecretsStoreOptionAbsent_ShouldUseDIRegisteredStore()\n {\n // Mainnet path: a host registers IAevatarSecretsStore (e.g. the\n // read-only EnvironmentSecretsStore) into DI but does not pass\n // options.SecretsStore. Before the fix, AddAevatarAIFeatures fell\n // back to `new AevatarSecretsStore()` which re-opens secrets.json\n // from disk. This asserts that the DI-registered store is honored.\n var diRegistered = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"from-di-registered-store\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n var services = new ServiceCollection();\n services.AddSingleton<IAevatarSecretsStore>(diRegistered);\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n // intentionally leave options.SecretsStore = null\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_ShouldRegisterWorkflowToolSourceAdapterForAgentTools()\n {\n var source = new StubAgentToolSource([new StubAgentTool(\"demo_tool\", \"\"\"{\"ok\":true}\"\"\")]);\n var services = new ServiceCollection()\n .AddSingleton<IAgentToolSource>(source);\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options => options.EnableMEAIProviders = false);\n\n await using var provider = services.BuildServiceProvider();\n // Yield capability follows the actor, never the container (#2004): bootstrap must\n // not hand a yielding handler to surfaces without a pending-approval continuation.\n provider.GetService<IToolApprovalHandler>().Should().BeNull();\n provider.GetServices<IToolCallMiddleware>().Should().NotContain(x => x is ToolApprovalMiddleware);\n\n var workflowSource = provider.GetServices<IWorkflowToolSource>()\n .Should()\n .ContainSingle()\n .Subject;\n var tool = (await workflowSource.GetToolsAsync())\n .Should()\n .ContainSingle()\n .Subject;\n\n tool.Name.Should().Be(\"demo_tool\");\n var result = await tool.ExecuteAsync(\n new WorkflowToolExecutionRequest(\n ArgumentsJson: \"{}\",\n RunId: \"run-1\",\n StepId: \"step-1\",\n ExecutionId: \"exec-1\",\n CallId: \"call-1\",\n ScopeId: \"scope-1\",\n CallerCredential: new WorkflowCallerCredential()));\n result.ResultJson.Should().Be(\"\"\"{\"ok\":true}\"\"\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder();\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldSupportRemoteUrlConfiguration()\n {\n var builder = new MCPConnectorBuilder();\n var entry = new ConnectorConfigEntry\n {\n Name = \"nyxid_mcp\",\n Type = \"mcp\",\n TimeoutMs = 15000,\n MCP = new MCPConnectorConfig\n {\n ServerName = \"nyxid\",\n Url = \"https://nyxid.example.com/mcp\",\n AdditionalHeaders = new Dictionary<string, string> { [\"x-tenant\"] = \"demo\" },\n DefaultTool = \"chrono-graph__query\",\n AllowedTools = [\"chrono-graph__query\"],\n },\n };\n\n var ok = builder.TryBuild(entry, NullLogger.Instance, out var connector);\n\n ok.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"nyxid_mcp\");\n\n var serverConfigField = connector.GetType().GetField(\"_serverConfig\", BindingFlags.Instance | BindingFlags.NonPublic);\n serverConfigField.Should().NotBeNull();\n var serverConfig = serverConfigField!.GetValue(connector).Should().BeOfType<MCPServerConfig>().Subject;\n serverConfig.Url.Should().Be(\"https://nyxid.example.com/mcp\");\n serverConfig.HttpClient.Should().NotBeNull();\n serverConfig.HttpClient!.Timeout.Should().Be(Timeout.InfiniteTimeSpan);\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, configuration, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private static void AddVoicePresenceTestCredentialResolver(IServiceCollection services) =>\n services.AddSingleton<IRealtimeProviderCredentialResolver, NoOpRealtimeProviderCredentialResolver>();\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n\n private sealed class StubAgentToolSource(IReadOnlyList<IAgentTool> tools) : IAgentToolSource\n {\n public Task<IReadOnlyList<IAgentTool>> DiscoverToolsAsync(CancellationToken ct = default)\n {\n _ = ct;\n return Task.FromResult(tools);\n }\n }\n\n private sealed class StubAgentTool(string name, string resultJson) : IAgentTool\n {\n public string Name { get; } = name;\n public string Description => \"test tool\";\n public string ParametersSchema => \"\"\"{\"type\":\"object\"}\"\"\";\n\n public AgentToolReceipt? CreateSuccessReceipt(string callId, string toolName, string result) =>\n new()\n {\n CallId = callId,\n ToolName = toolName,\n Status = AgentToolReceiptStatus.Success,\n };\n\n public Task<string> ExecuteAsync(string argumentsJson, CancellationToken ct = default)\n {\n _ = argumentsJson;\n _ = ct;\n return Task.FromResult(resultJson);\n }\n }\n\n private static void WriteFlatSecrets(string path, IReadOnlyDictionary<string, string> values)\n {\n var directory = Path.GetDirectoryName(path);\n if (!string.IsNullOrWhiteSpace(directory))\n Directory.CreateDirectory(directory);\n\n var json = JsonSerializer.Serialize(values);\n File.WriteAllText(path, json);\n File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddSeconds(1));\n }\n\n private sealed class NoOpActorDispatchPort : IActorDispatchPort\n {\n public Task<DispatchAdmission> DispatchAsync(string actorId, EventEnvelope envelope, CancellationToken ct = default) =>\n Task.FromResult(DispatchAdmissionFactory.Create(actorId, envelope));\n }\n\n private sealed class StaticActorRuntime : IActorRuntime\n {\n public Task<IActor> CreateAsync<TAgent>(string? id = null, CancellationToken ct = default)\n where TAgent : IAgent =>\n throw new NotSupportedException();\n\n public Task<IActor> CreateAsync(Type agentType, string? id = null, CancellationToken ct = default) =>\n throw new NotSupportedException();\n\n public Task DestroyAsync(string id, CancellationToken ct = default) =>\n Task.CompletedTask;\n\n public Task<IActor?> GetAsync(string id) =>\n Task.FromResult<IActor?>(null);\n\n public Task<bool> ExistsAsync(string id) =>\n Task.FromResult(true);\n\n public Task LinkAsync(string parentId, string childId, CancellationToken ct = default) =>\n Task.CompletedTask;\n\n public Task UnlinkAsync(string childId, CancellationToken ct = default) =>\n Task.CompletedTask;\n }\n\n private sealed class EmptyVoicePresenceCapabilityReader\n : IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>\n {\n public Task<VoicePresenceCapabilityReadModel?> GetAsync(string key, CancellationToken ct = default) =>\n Task.FromResult<VoicePresenceCapabilityReadModel?>(null);\n\n public Task<ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>> QueryAsync(\n ProjectionDocumentQuery query,\n CancellationToken ct = default) =>\n Task.FromResult(ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>.Empty);\n }\n\n private sealed class NoOpRealtimeProviderCredentialResolver : IRealtimeProviderCredentialResolver\n {\n public Task<string?> ResolveApiKeyAsync(\n VoiceProviderSessionKey sessionKey,\n VoiceProviderConfig config,\n CancellationToken ct) =>\n Task.FromResult<string?>(null);\n }\n\n}\n"}, "a4b286e97d3768bccbe842ff9814c4e4de4e331b": {"t": 1785756598.1835365, "n": 37020, "h": "8dc1f3f3f8d881c3", "c": "using System.Collections;\nusing System.Net.Http;\nusing System.Reflection;\nusing System.Text.Json;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions.TypeSystem;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Workflow.Abstractions;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\n[Collection(ProcessEnvSerialCollection.Name)]\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n var configuration = new ConfigurationBuilder().Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void ReadConfiguredProviders_WhenNyxIdAuthorityConfigured_ShouldResolveGatewayEndpoint()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"gpt-5.4\",\n DefaultProvider = \"nyxid\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n });\n var configuration = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"nyxid\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://nyx.example.com/api/v1/llm/gateway/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableMEAIProviders = true;\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IVoiceToolInvoker>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSystemSkillOverlayEnabledWithSetName_RegistersOrnnProviderWithBoundOptions()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableSystemSkillOverlay = true;\n options.SystemSkillOverlaySetName = \"aevatar-system\";\n options.OrnnNyxIdSlug = \"ornn-api-custom\";\n options.SystemSkillOverlayRefreshTtl = TimeSpan.FromMinutes(15);\n options.SystemSkillOverlayMaxSkills = 32;\n options.SystemSkillOverlayMaxBytes = 32768;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetRequiredService<ISystemSkillOverlayProvider>()\n .Should().BeOfType<Aevatar.AI.ToolProviders.Ornn.SystemSkillOverlay.OrnnSystemSkillOverlayProvider>();\n\n var overlayOptions = provider.GetRequiredService<SystemSkillOverlayOptions>();\n overlayOptions.SetName.Should().Be(\"aevatar-system\");\n overlayOptions.RefreshTtl.Should().Be(TimeSpan.FromMinutes(15));\n overlayOptions.MaxSkills.Should().Be(32);\n overlayOptions.MaxBytes.Should().Be(32768);\n\n // The overlay reads Ornn through the host-configured NyxID proxy slug even when the\n // model-facing Ornn skill tools are not enabled.\n provider.GetRequiredService<Aevatar.AI.ToolProviders.Ornn.OrnnOptions>()\n .NyxIdSlug.Should().Be(\"ornn-api-custom\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSystemSkillOverlayEnabledWithoutSetName_DoesNotRegisterProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableSystemSkillOverlay = true;\n options.SystemSkillOverlaySetName = \" \";\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ISystemSkillOverlayProvider>().Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterWorkflowRolePrimaryKind()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options => options.EnableMEAIProviders = false);\n\n using var provider = services.BuildServiceProvider();\n var registry = provider.GetRequiredService<IAgentKindRegistry>();\n\n var primary = registry.Resolve(WorkflowRoleConventions.DefaultAgentKind);\n var formerAliasFound = registry.TryResolve(\"workflow.assistant-role\", out var formerAlias);\n\n primary.Metadata.Kind.Should().Be(WorkflowRoleConventions.DefaultAgentKind);\n primary.Metadata.ImplementationClrTypeName.Should().Be(typeof(WorkflowRoleGAgent).FullName);\n formerAliasFound.Should().BeFalse();\n formerAlias.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverEnabled_ShouldRegisterFailoverFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = true;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<FailoverLLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceOpenAIConfigured_ShouldRegisterVoicePresenceModuleFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n services.AddSingleton<IActorRuntime, StaticActorRuntime>();\n services.AddSingleton<IActorDispatchPort, NoOpActorDispatchPort>();\n services.AddSingleton<IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>>(\n new EmptyVoicePresenceCapabilityReader());\n AddVoicePresenceTestCredentialResolver(services);\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n options.VoicePresence.OpenAISession = new VoiceSessionConfig\n {\n Voice = \"alloy\",\n SampleRateHz = 24000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n provider.GetRequiredService<IVoiceToolCatalog>()\n .Should().BeOfType<AgentToolVoiceCatalog>();\n provider.GetRequiredService<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>>()\n .Should().BeOfType<ActorOwnedVoiceRealtimeSession>();\n provider.GetRequiredService<IVoicePresenceCapabilityQueryPort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoicePresenceCapabilityCommandPort>()\n .Should().BeOfType<VoicePresenceCapabilityCommandPort>();\n provider.GetRequiredService<IVoicePresenceSessionLeasePort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoiceVolatileMediaStreamPort>()\n .Should().BeOfType<VoiceVolatileMediaStreamPort>();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n defaultModule.As<VoicePresenceModule>()\n .CanHandle(new EventEnvelope\n {\n Payload = Google.Protobuf.WellKnownTypes.Any.Pack(new Google.Protobuf.WellKnownTypes.StringValue\n {\n Value = \"doorbell\",\n }),\n Route = EnvelopeRouteSemantics.CreateDirect(\"device-events.callback\", \"voice-agent\"),\n })\n .Should()\n .BeFalse();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeTrue();\n openAIModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm\", out var miniCpmModule).Should().BeFalse();\n miniCpmModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldPreserveConfiguredVoiceDirectExternalEventTypeUrls()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n services.AddSingleton<IActorDispatchPort, NoOpActorDispatchPort>();\n services.AddSingleton<IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>>(\n new EmptyVoicePresenceCapabilityReader());\n AddVoicePresenceTestCredentialResolver(services);\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.Module = new VoicePresenceModuleOptions\n {\n DirectExternalEventTypeUrls =\n [\n \" type.googleapis.com/example.External \",\n \"type.googleapis.com/example.External\",\n ],\n };\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n\n factory.TryCreate(\"voice_presence\", out var module).Should().BeTrue();\n var voiceModule = module.Should().BeOfType<VoicePresenceModule>().Subject;\n\n voiceModule.CanHandle(new EventEnvelope\n {\n Payload = new Google.Protobuf.WellKnownTypes.Any\n {\n TypeUrl = \"type.googleapis.com/example.External\",\n Value = Google.Protobuf.ByteString.CopyFromUtf8(\"payload\"),\n },\n Route = EnvelopeRouteSemantics.CreateDirect(\"device-events.callback\", \"voice-agent\"),\n }).Should().BeTrue();\n voiceModule.CanHandle(new EventEnvelope\n {\n Payload = new Google.Protobuf.WellKnownTypes.Any\n {\n TypeUrl = \"type.googleapis.com/example.Other\",\n Value = Google.Protobuf.ByteString.CopyFromUtf8(\"payload\"),\n },\n Route = EnvelopeRouteSemantics.CreateDirect(\"device-events.callback\", \"voice-agent\"),\n }).Should().BeFalse();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverDisabled_ShouldRegisterMEAIFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<MEAILLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetRequiredService<IAgentKindRegistry>()\n .Resolve(WorkflowRoleConventions.DefaultAgentKind)\n .Metadata\n .ImplementationClrTypeName\n .Should()\n .Be(typeof(WorkflowRoleGAgent).FullName);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenReloadableFactoryEnabled_ShouldPickUpdatedSecrets()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-reload-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n var previousAllowPlaintext = Environment.GetEnvironmentVariable(LocalSecretProtectionOptions.AllowPlaintextSecretsEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n Environment.SetEnvironmentVariable(LocalSecretProtectionOptions.AllowPlaintextSecretsEnv, \"true\");\n\n try\n {\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:openai:ApiKey\"] = \"openai-key\",\n [\"LLMProviders:Providers:openai:ProviderType\"] = \"openai\",\n [\"LLMProviders:Providers:openai:Model\"] = \"gpt-5.4\",\n [\"LLMProviders:Default\"] = \"openai\",\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.EnableReloadableProviderFactory = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"openai\");\n\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Providers:deepseek:Model\"] = \"deepseek-chat\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Environment.SetEnvironmentVariable(LocalSecretProtectionOptions.AllowPlaintextSecretsEnv, previousAllowPlaintext);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyNyxIdProviderConfigured_ShouldRegisterNyxIdDefaultProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n [\"LLMProviders:Default\"] = \"nyxid\",\n });\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"nyxid\");\n llmFactory.GetDefault().Name.Should().Be(\"nyxid\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSecretsStoreOptionAbsent_ShouldUseDIRegisteredStore()\n {\n // Mainnet path: a host registers IAevatarSecretsStore (e.g. the\n // read-only EnvironmentSecretsStore) into DI but does not pass\n // options.SecretsStore. Before the fix, AddAevatarAIFeatures fell\n // back to `new AevatarSecretsStore()` which re-opens secrets.json\n // from disk. This asserts that the DI-registered store is honored.\n var diRegistered = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"from-di-registered-store\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n var services = new ServiceCollection();\n services.AddSingleton<IAevatarSecretsStore>(diRegistered);\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n // intentionally leave options.SecretsStore = null\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_ShouldRegisterWorkflowToolSourceAdapterForAgentTools()\n {\n var source = new StubAgentToolSource([new StubAgentTool(\"demo_tool\", \"\"\"{\"ok\":true}\"\"\")]);\n var services = new ServiceCollection()\n .AddSingleton<IAgentToolSource>(source);\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options => options.EnableMEAIProviders = false);\n\n await using var provider = services.BuildServiceProvider();\n // Yield capability follows the actor, never the container (#2004): bootstrap must\n // not hand a yielding handler to surfaces without a pending-approval continuation.\n provider.GetService<IToolApprovalHandler>().Should().BeNull();\n provider.GetServices<IToolCallMiddleware>().Should().NotContain(x => x is ToolApprovalMiddleware);\n\n var workflowSource = provider.GetServices<IWorkflowToolSource>()\n .Should()\n .ContainSingle()\n .Subject;\n var tool = (await workflowSource.GetToolsAsync())\n .Should()\n .ContainSingle()\n .Subject;\n\n tool.Name.Should().Be(\"demo_tool\");\n var result = await tool.ExecuteAsync(\n new WorkflowToolExecutionRequest(\n ArgumentsJson: \"{}\",\n RunId: \"run-1\",\n StepId: \"step-1\",\n ExecutionId: \"exec-1\",\n CallId: \"call-1\",\n ScopeId: \"scope-1\",\n CallerCredential: new WorkflowCallerCredential()));\n result.ResultJson.Should().Be(\"\"\"{\"ok\":true}\"\"\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder();\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldSupportRemoteUrlConfiguration()\n {\n var builder = new MCPConnectorBuilder();\n var entry = new ConnectorConfigEntry\n {\n Name = \"nyxid_mcp\",\n Type = \"mcp\",\n TimeoutMs = 15000,\n MCP = new MCPConnectorConfig\n {\n ServerName = \"nyxid\",\n Url = \"https://nyxid.example.com/mcp\",\n AdditionalHeaders = new Dictionary<string, string> { [\"x-tenant\"] = \"demo\" },\n DefaultTool = \"chrono-graph__query\",\n AllowedTools = [\"chrono-graph__query\"],\n },\n };\n\n var ok = builder.TryBuild(entry, NullLogger.Instance, out var connector);\n\n ok.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"nyxid_mcp\");\n\n var serverConfigField = connector.GetType().GetField(\"_serverConfig\", BindingFlags.Instance | BindingFlags.NonPublic);\n serverConfigField.Should().NotBeNull();\n var serverConfig = serverConfigField!.GetValue(connector).Should().BeOfType<MCPServerConfig>().Subject;\n serverConfig.Url.Should().Be(\"https://nyxid.example.com/mcp\");\n serverConfig.HttpClient.Should().NotBeNull();\n serverConfig.HttpClient!.Timeout.Should().Be(Timeout.InfiniteTimeSpan);\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, configuration, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private static void AddVoicePresenceTestCredentialResolver(IServiceCollection services) =>\n services.AddSingleton<IRealtimeProviderCredentialResolver, NoOpRealtimeProviderCredentialResolver>();\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n\n private sealed class StubAgentToolSource(IReadOnlyList<IAgentTool> tools) : IAgentToolSource\n {\n public Task<IReadOnlyList<IAgentTool>> DiscoverToolsAsync(CancellationToken ct = default)\n {\n _ = ct;\n return Task.FromResult(tools);\n }\n }\n\n private sealed class StubAgentTool(string name, string resultJson) : IAgentTool\n {\n public string Name { get; } = name;\n public string Description => \"test tool\";\n public string ParametersSchema => \"\"\"{\"type\":\"object\"}\"\"\";\n\n public Task<string> ExecuteAsync(string argumentsJson, CancellationToken ct = default)\n {\n _ = argumentsJson;\n _ = ct;\n return Task.FromResult(resultJson);\n }\n }\n\n private static void WriteFlatSecrets(string path, IReadOnlyDictionary<string, string> values)\n {\n var directory = Path.GetDirectoryName(path);\n if (!string.IsNullOrWhiteSpace(directory))\n Directory.CreateDirectory(directory);\n\n var json = JsonSerializer.Serialize(values);\n File.WriteAllText(path, json);\n File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddSeconds(1));\n }\n\n private sealed class NoOpActorDispatchPort : IActorDispatchPort\n {\n public Task<DispatchAdmission> DispatchAsync(string actorId, EventEnvelope envelope, CancellationToken ct = default) =>\n Task.FromResult(DispatchAdmissionFactory.Create(actorId, envelope));\n }\n\n private sealed class StaticActorRuntime : IActorRuntime\n {\n public Task<IActor> CreateAsync<TAgent>(string? id = null, CancellationToken ct = default)\n where TAgent : IAgent =>\n throw new NotSupportedException();\n\n public Task<IActor> CreateAsync(Type agentType, string? id = null, CancellationToken ct = default) =>\n throw new NotSupportedException();\n\n public Task DestroyAsync(string id, CancellationToken ct = default) =>\n Task.CompletedTask;\n\n public Task<IActor?> GetAsync(string id) =>\n Task.FromResult<IActor?>(null);\n\n public Task<bool> ExistsAsync(string id) =>\n Task.FromResult(true);\n\n public Task LinkAsync(string parentId, string childId, CancellationToken ct = default) =>\n Task.CompletedTask;\n\n public Task UnlinkAsync(string childId, CancellationToken ct = default) =>\n Task.CompletedTask;\n }\n\n private sealed class EmptyVoicePresenceCapabilityReader\n : IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>\n {\n public Task<VoicePresenceCapabilityReadModel?> GetAsync(string key, CancellationToken ct = default) =>\n Task.FromResult<VoicePresenceCapabilityReadModel?>(null);\n\n public Task<ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>> QueryAsync(\n ProjectionDocumentQuery query,\n CancellationToken ct = default) =>\n Task.FromResult(ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>.Empty);\n }\n\n private sealed class NoOpRealtimeProviderCredentialResolver : IRealtimeProviderCredentialResolver\n {\n public Task<string?> ResolveApiKeyAsync(\n VoiceProviderSessionKey sessionKey,\n VoiceProviderConfig config,\n CancellationToken ct) =>\n Task.FromResult<string?>(null);\n }\n\n}\n"}, "d06d2fec85d36f93a648ded66f384051a148d364": {"t": 1785756599.6128025, "n": 36894, "h": "20b925f44a49b243", "c": "using System.Collections;\nusing System.Net.Http;\nusing System.Reflection;\nusing System.Text.Json;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions.TypeSystem;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Workflow.Abstractions;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\n[Collection(ProcessEnvSerialCollection.Name)]\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n var configuration = new ConfigurationBuilder().Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void ReadConfiguredProviders_WhenNyxIdAuthorityConfigured_ShouldResolveGatewayEndpoint()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"gpt-5.4\",\n DefaultProvider = \"nyxid\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n });\n var configuration = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"nyxid\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://nyx.example.com/api/v1/llm/gateway/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableMEAIProviders = true;\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IVoiceToolInvoker>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSystemSkillOverlayEnabledWithSetName_RegistersOrnnProviderWithBoundOptions()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableSystemSkillOverlay = true;\n options.SystemSkillOverlaySetName = \"aevatar-system\";\n options.OrnnNyxIdSlug = \"ornn-api-custom\";\n options.SystemSkillOverlayRefreshTtl = TimeSpan.FromMinutes(15);\n options.SystemSkillOverlayMaxSkills = 32;\n options.SystemSkillOverlayMaxBytes = 32768;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetRequiredService<ISystemSkillOverlayProvider>()\n .Should().BeOfType<Aevatar.AI.ToolProviders.Ornn.SystemSkillOverlay.OrnnSystemSkillOverlayProvider>();\n\n var overlayOptions = provider.GetRequiredService<SystemSkillOverlayOptions>();\n overlayOptions.SetName.Should().Be(\"aevatar-system\");\n overlayOptions.RefreshTtl.Should().Be(TimeSpan.FromMinutes(15));\n overlayOptions.MaxSkills.Should().Be(32);\n overlayOptions.MaxBytes.Should().Be(32768);\n\n // The overlay reads Ornn through the host-configured NyxID proxy slug even when the\n // model-facing Ornn skill tools are not enabled.\n provider.GetRequiredService<Aevatar.AI.ToolProviders.Ornn.OrnnOptions>()\n .NyxIdSlug.Should().Be(\"ornn-api-custom\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSystemSkillOverlayEnabledWithoutSetName_DoesNotRegisterProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableSystemSkillOverlay = true;\n options.SystemSkillOverlaySetName = \" \";\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ISystemSkillOverlayProvider>().Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterWorkflowRolePrimaryKind()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options => options.EnableMEAIProviders = false);\n\n using var provider = services.BuildServiceProvider();\n var registry = provider.GetRequiredService<IAgentKindRegistry>();\n\n var primary = registry.Resolve(WorkflowRoleConventions.DefaultAgentKind);\n var formerAliasFound = registry.TryResolve(\"workflow.assistant-role\", out var formerAlias);\n\n primary.Metadata.Kind.Should().Be(WorkflowRoleConventions.DefaultAgentKind);\n primary.Metadata.ImplementationClrTypeName.Should().Be(typeof(WorkflowRoleGAgent).FullName);\n formerAliasFound.Should().BeFalse();\n formerAlias.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverEnabled_ShouldRegisterFailoverFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<FailoverLLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceOpenAIConfigured_ShouldRegisterVoicePresenceModuleFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n services.AddSingleton<IActorRuntime, StaticActorRuntime>();\n services.AddSingleton<IActorDispatchPort, NoOpActorDispatchPort>();\n services.AddSingleton<IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>>(\n new EmptyVoicePresenceCapabilityReader());\n AddVoicePresenceTestCredentialResolver(services);\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n options.VoicePresence.OpenAISession = new VoiceSessionConfig\n {\n Voice = \"alloy\",\n SampleRateHz = 24000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n provider.GetRequiredService<IVoiceToolCatalog>()\n .Should().BeOfType<AgentToolVoiceCatalog>();\n provider.GetRequiredService<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>>()\n .Should().BeOfType<ActorOwnedVoiceRealtimeSession>();\n provider.GetRequiredService<IVoicePresenceCapabilityQueryPort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoicePresenceCapabilityCommandPort>()\n .Should().BeOfType<VoicePresenceCapabilityCommandPort>();\n provider.GetRequiredService<IVoicePresenceSessionLeasePort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoiceVolatileMediaStreamPort>()\n .Should().BeOfType<VoiceVolatileMediaStreamPort>();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n defaultModule.As<VoicePresenceModule>()\n .CanHandle(new EventEnvelope\n {\n Payload = Google.Protobuf.WellKnownTypes.Any.Pack(new Google.Protobuf.WellKnownTypes.StringValue\n {\n Value = \"doorbell\",\n }),\n Route = EnvelopeRouteSemantics.CreateDirect(\"device-events.callback\", \"voice-agent\"),\n })\n .Should()\n .BeFalse();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeTrue();\n openAIModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm\", out var miniCpmModule).Should().BeFalse();\n miniCpmModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldPreserveConfiguredVoiceDirectExternalEventTypeUrls()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n services.AddSingleton<IActorDispatchPort, NoOpActorDispatchPort>();\n services.AddSingleton<IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>>(\n new EmptyVoicePresenceCapabilityReader());\n AddVoicePresenceTestCredentialResolver(services);\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.Module = new VoicePresenceModuleOptions\n {\n DirectExternalEventTypeUrls =\n [\n \" type.googleapis.com/example.External \",\n \"type.googleapis.com/example.External\",\n ],\n };\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n\n factory.TryCreate(\"voice_presence\", out var module).Should().BeTrue();\n var voiceModule = module.Should().BeOfType<VoicePresenceModule>().Subject;\n\n voiceModule.CanHandle(new EventEnvelope\n {\n Payload = new Google.Protobuf.WellKnownTypes.Any\n {\n TypeUrl = \"type.googleapis.com/example.External\",\n Value = Google.Protobuf.ByteString.CopyFromUtf8(\"payload\"),\n },\n Route = EnvelopeRouteSemantics.CreateDirect(\"device-events.callback\", \"voice-agent\"),\n }).Should().BeTrue();\n voiceModule.CanHandle(new EventEnvelope\n {\n Payload = new Google.Protobuf.WellKnownTypes.Any\n {\n TypeUrl = \"type.googleapis.com/example.Other\",\n Value = Google.Protobuf.ByteString.CopyFromUtf8(\"payload\"),\n },\n Route = EnvelopeRouteSemantics.CreateDirect(\"device-events.callback\", \"voice-agent\"),\n }).Should().BeFalse();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverDisabled_ShouldRegisterMEAIFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<MEAILLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetRequiredService<IAgentKindRegistry>()\n .Resolve(WorkflowRoleConventions.DefaultAgentKind)\n .Metadata\n .ImplementationClrTypeName\n .Should()\n .Be(typeof(WorkflowRoleGAgent).FullName);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenReloadableFactoryEnabled_ShouldPickUpdatedSecrets()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-reload-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n var previousAllowPlaintext = Environment.GetEnvironmentVariable(LocalSecretProtectionOptions.AllowPlaintextSecretsEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n Environment.SetEnvironmentVariable(LocalSecretProtectionOptions.AllowPlaintextSecretsEnv, \"true\");\n\n try\n {\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:openai:ApiKey\"] = \"openai-key\",\n [\"LLMProviders:Providers:openai:ProviderType\"] = \"openai\",\n [\"LLMProviders:Providers:openai:Model\"] = \"gpt-5.4\",\n [\"LLMProviders:Default\"] = \"openai\",\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.EnableReloadableProviderFactory = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"openai\");\n\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Providers:deepseek:Model\"] = \"deepseek-chat\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Environment.SetEnvironmentVariable(LocalSecretProtectionOptions.AllowPlaintextSecretsEnv, previousAllowPlaintext);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyNyxIdProviderConfigured_ShouldRegisterNyxIdDefaultProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n [\"LLMProviders:Default\"] = \"nyxid\",\n });\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"nyxid\");\n llmFactory.GetDefault().Name.Should().Be(\"nyxid\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSecretsStoreOptionAbsent_ShouldUseDIRegisteredStore()\n {\n // Mainnet path: a host registers IAevatarSecretsStore (e.g. the\n // read-only EnvironmentSecretsStore) into DI but does not pass\n // options.SecretsStore. Before the fix, AddAevatarAIFeatures fell\n // back to `new AevatarSecretsStore()` which re-opens secrets.json\n // from disk. This asserts that the DI-registered store is honored.\n var diRegistered = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"from-di-registered-store\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n var services = new ServiceCollection();\n services.AddSingleton<IAevatarSecretsStore>(diRegistered);\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n // intentionally leave options.SecretsStore = null\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_ShouldRegisterWorkflowToolSourceAdapterForAgentTools()\n {\n var source = new StubAgentToolSource([new StubAgentTool(\"demo_tool\", \"\"\"{\"ok\":true}\"\"\")]);\n var services = new ServiceCollection()\n .AddSingleton<IAgentToolSource>(source);\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options => options.EnableMEAIProviders = false);\n\n await using var provider = services.BuildServiceProvider();\n // Yield capability follows the actor, never the container (#2004): bootstrap must\n // not hand a yielding handler to surfaces without a pending-approval continuation.\n provider.GetService<IToolApprovalHandler>().Should().BeNull();\n provider.GetServices<IToolCallMiddleware>().Should().NotContain(x => x is ToolApprovalMiddleware);\n\n var workflowSource = provider.GetServices<IWorkflowToolSource>()\n .Should()\n .ContainSingle()\n .Subject;\n var tool = (await workflowSource.GetToolsAsync())\n .Should()\n .ContainSingle()\n .Subject;\n\n tool.Name.Should().Be(\"demo_tool\");\n var result = await tool.ExecuteAsync(\n new WorkflowToolExecutionRequest(\n ArgumentsJson: \"{}\",\n RunId: \"run-1\",\n StepId: \"step-1\",\n ExecutionId: \"exec-1\",\n CallId: \"call-1\",\n ScopeId: \"scope-1\",\n CallerCredential: new WorkflowCallerCredential()));\n result.ResultJson.Should().Be(\"\"\"{\"ok\":true}\"\"\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder();\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldSupportRemoteUrlConfiguration()\n {\n var builder = new MCPConnectorBuilder();\n var entry = new ConnectorConfigEntry\n {\n Name = \"nyxid_mcp\",\n Type = \"mcp\",\n TimeoutMs = 15000,\n MCP = new MCPConnectorConfig\n {\n ServerName = \"nyxid\",\n Url = \"https://nyxid.example.com/mcp\",\n AdditionalHeaders = new Dictionary<string, string> { [\"x-tenant\"] = \"demo\" },\n DefaultTool = \"chrono-graph__query\",\n AllowedTools = [\"chrono-graph__query\"],\n },\n };\n\n var ok = builder.TryBuild(entry, NullLogger.Instance, out var connector);\n\n ok.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"nyxid_mcp\");\n\n var serverConfigField = connector.GetType().GetField(\"_serverConfig\", BindingFlags.Instance | BindingFlags.NonPublic);\n serverConfigField.Should().NotBeNull();\n var serverConfig = serverConfigField!.GetValue(connector).Should().BeOfType<MCPServerConfig>().Subject;\n serverConfig.Url.Should().Be(\"https://nyxid.example.com/mcp\");\n serverConfig.HttpClient.Should().NotBeNull();\n serverConfig.HttpClient!.Timeout.Should().Be(Timeout.InfiniteTimeSpan);\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, configuration, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private static void AddVoicePresenceTestCredentialResolver(IServiceCollection services) =>\n services.AddSingleton<IRealtimeProviderCredentialResolver, NoOpRealtimeProviderCredentialResolver>();\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n\n private sealed class StubAgentToolSource(IReadOnlyList<IAgentTool> tools) : IAgentToolSource\n {\n public Task<IReadOnlyList<IAgentTool>> DiscoverToolsAsync(CancellationToken ct = default)\n {\n _ = ct;\n return Task.FromResult(tools);\n }\n }\n\n private sealed class StubAgentTool(string name, string resultJson) : IAgentTool\n {\n public string Name { get; } = name;\n public string Description => \"test tool\";\n public string ParametersSchema => \"\"\"{\"type\":\"object\"}\"\"\";\n\n public Task<string> ExecuteAsync(string argumentsJson, CancellationToken ct = default)\n {\n _ = argumentsJson;\n _ = ct;\n return Task.FromResult(resultJson);\n }\n }\n\n private static void WriteFlatSecrets(string path, IReadOnlyDictionary<string, string> values)\n {\n var directory = Path.GetDirectoryName(path);\n if (!string.IsNullOrWhiteSpace(directory))\n Directory.CreateDirectory(directory);\n\n var json = JsonSerializer.Serialize(values);\n File.WriteAllText(path, json);\n File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddSeconds(1));\n }\n\n private sealed class NoOpActorDispatchPort : IActorDispatchPort\n {\n public Task<DispatchAdmission> DispatchAsync(string actorId, EventEnvelope envelope, CancellationToken ct = default) =>\n Task.FromResult(DispatchAdmissionFactory.Create(actorId, envelope));\n }\n\n private sealed class StaticActorRuntime : IActorRuntime\n {\n public Task<IActor> CreateAsync<TAgent>(string? id = null, CancellationToken ct = default)\n where TAgent : IAgent =>\n throw new NotSupportedException();\n\n public Task<IActor> CreateAsync(Type agentType, string? id = null, CancellationToken ct = default) =>\n throw new NotSupportedException();\n\n public Task DestroyAsync(string id, CancellationToken ct = default) =>\n Task.CompletedTask;\n\n public Task<IActor?> GetAsync(string id) =>\n Task.FromResult<IActor?>(null);\n\n public Task<bool> ExistsAsync(string id) =>\n Task.FromResult(true);\n\n public Task LinkAsync(string parentId, string childId, CancellationToken ct = default) =>\n Task.CompletedTask;\n\n public Task UnlinkAsync(string childId, CancellationToken ct = default) =>\n Task.CompletedTask;\n }\n\n private sealed class EmptyVoicePresenceCapabilityReader\n : IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>\n {\n public Task<VoicePresenceCapabilityReadModel?> GetAsync(string key, CancellationToken ct = default) =>\n Task.FromResult<VoicePresenceCapabilityReadModel?>(null);\n\n public Task<ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>> QueryAsync(\n ProjectionDocumentQuery query,\n CancellationToken ct = default) =>\n Task.FromResult(ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>.Empty);\n }\n\n private sealed class NoOpRealtimeProviderCredentialResolver : IRealtimeProviderCredentialResolver\n {\n public Task<string?> ResolveApiKeyAsync(\n VoiceProviderSessionKey sessionKey,\n VoiceProviderConfig config,\n CancellationToken ct) =>\n Task.FromResult<string?>(null);\n }\n\n}\n"}, "e61dfed554fa25337d46a740236acad262c7e766": {"t": 1785756601.0565758, "n": 36532, "h": "5c288ef03b95301a", "c": "using System.Collections;\nusing System.Net.Http;\nusing System.Reflection;\nusing System.Text.Json;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions.TypeSystem;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Workflow.Abstractions;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\n[Collection(ProcessEnvSerialCollection.Name)]\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n var configuration = new ConfigurationBuilder().Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void ReadConfiguredProviders_WhenNyxIdAuthorityConfigured_ShouldResolveGatewayEndpoint()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"gpt-5.4\",\n DefaultProvider = \"nyxid\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n });\n var configuration = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"nyxid\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://nyx.example.com/api/v1/llm/gateway/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableMEAIProviders = true;\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IVoiceToolInvoker>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSystemSkillOverlayEnabledWithSetName_RegistersOrnnProviderWithBoundOptions()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableSystemSkillOverlay = true;\n options.SystemSkillOverlaySetName = \"aevatar-system\";\n options.OrnnNyxIdSlug = \"ornn-api-custom\";\n options.SystemSkillOverlayRefreshTtl = TimeSpan.FromMinutes(15);\n options.SystemSkillOverlayMaxSkills = 32;\n options.SystemSkillOverlayMaxBytes = 32768;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetRequiredService<ISystemSkillOverlayProvider>()\n .Should().BeOfType<Aevatar.AI.ToolProviders.Ornn.SystemSkillOverlay.OrnnSystemSkillOverlayProvider>();\n\n var overlayOptions = provider.GetRequiredService<SystemSkillOverlayOptions>();\n overlayOptions.SetName.Should().Be(\"aevatar-system\");\n overlayOptions.RefreshTtl.Should().Be(TimeSpan.FromMinutes(15));\n overlayOptions.MaxSkills.Should().Be(32);\n overlayOptions.MaxBytes.Should().Be(32768);\n\n // The overlay reads Ornn through the host-configured NyxID proxy slug even when the\n // model-facing Ornn skill tools are not enabled.\n provider.GetRequiredService<Aevatar.AI.ToolProviders.Ornn.OrnnOptions>()\n .NyxIdSlug.Should().Be(\"ornn-api-custom\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSystemSkillOverlayEnabledWithoutSetName_DoesNotRegisterProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableSystemSkillOverlay = true;\n options.SystemSkillOverlaySetName = \" \";\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ISystemSkillOverlayProvider>().Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterWorkflowRolePrimaryKind()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options => options.EnableMEAIProviders = false);\n\n using var provider = services.BuildServiceProvider();\n var registry = provider.GetRequiredService<IAgentKindRegistry>();\n\n var primary = registry.Resolve(WorkflowRoleConventions.DefaultAgentKind);\n var formerAliasFound = registry.TryResolve(\"workflow.assistant-role\", out var formerAlias);\n\n primary.Metadata.Kind.Should().Be(WorkflowRoleConventions.DefaultAgentKind);\n primary.Metadata.ImplementationClrTypeName.Should().Be(typeof(WorkflowRoleGAgent).FullName);\n formerAliasFound.Should().BeFalse();\n formerAlias.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverEnabled_ShouldRegisterFailoverFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<FailoverLLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceOpenAIConfigured_ShouldRegisterVoicePresenceModuleFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n services.AddSingleton<IActorRuntime, StaticActorRuntime>();\n services.AddSingleton<IActorDispatchPort, NoOpActorDispatchPort>();\n services.AddSingleton<IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>>(\n new EmptyVoicePresenceCapabilityReader());\n AddVoicePresenceTestCredentialResolver(services);\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n options.VoicePresence.OpenAISession = new VoiceSessionConfig\n {\n Voice = \"alloy\",\n SampleRateHz = 24000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n provider.GetRequiredService<IVoiceToolCatalog>()\n .Should().BeOfType<AgentToolVoiceCatalog>();\n provider.GetRequiredService<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>>()\n .Should().BeOfType<ActorOwnedVoiceRealtimeSession>();\n provider.GetRequiredService<IVoicePresenceCapabilityQueryPort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoicePresenceCapabilityCommandPort>()\n .Should().BeOfType<VoicePresenceCapabilityCommandPort>();\n provider.GetRequiredService<IVoicePresenceSessionLeasePort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoiceVolatileMediaStreamPort>()\n .Should().BeOfType<VoiceVolatileMediaStreamPort>();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n defaultModule.As<VoicePresenceModule>()\n .CanHandle(new EventEnvelope\n {\n Payload = Google.Protobuf.WellKnownTypes.Any.Pack(new Google.Protobuf.WellKnownTypes.StringValue\n {\n Value = \"doorbell\",\n }),\n Route = EnvelopeRouteSemantics.CreateDirect(\"device-events.callback\", \"voice-agent\"),\n })\n .Should()\n .BeFalse();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeTrue();\n openAIModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm\", out var miniCpmModule).Should().BeFalse();\n miniCpmModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldPreserveConfiguredVoiceDirectExternalEventTypeUrls()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n services.AddSingleton<IActorDispatchPort, NoOpActorDispatchPort>();\n services.AddSingleton<IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>>(\n new EmptyVoicePresenceCapabilityReader());\n AddVoicePresenceTestCredentialResolver(services);\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.Module = new VoicePresenceModuleOptions\n {\n DirectExternalEventTypeUrls =\n [\n \" type.googleapis.com/example.External \",\n \"type.googleapis.com/example.External\",\n ],\n };\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n\n factory.TryCreate(\"voice_presence\", out var module).Should().BeTrue();\n var voiceModule = module.Should().BeOfType<VoicePresenceModule>().Subject;\n\n voiceModule.CanHandle(new EventEnvelope\n {\n Payload = new Google.Protobuf.WellKnownTypes.Any\n {\n TypeUrl = \"type.googleapis.com/example.External\",\n Value = Google.Protobuf.ByteString.CopyFromUtf8(\"payload\"),\n },\n Route = EnvelopeRouteSemantics.CreateDirect(\"device-events.callback\", \"voice-agent\"),\n }).Should().BeTrue();\n voiceModule.CanHandle(new EventEnvelope\n {\n Payload = new Google.Protobuf.WellKnownTypes.Any\n {\n TypeUrl = \"type.googleapis.com/example.Other\",\n Value = Google.Protobuf.ByteString.CopyFromUtf8(\"payload\"),\n },\n Route = EnvelopeRouteSemantics.CreateDirect(\"device-events.callback\", \"voice-agent\"),\n }).Should().BeFalse();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverDisabled_ShouldRegisterMEAIFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<MEAILLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetRequiredService<IAgentKindRegistry>()\n .Resolve(WorkflowRoleConventions.DefaultAgentKind)\n .Metadata\n .ImplementationClrTypeName\n .Should()\n .Be(typeof(WorkflowRoleGAgent).FullName);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenReloadableFactoryEnabled_ShouldPickUpdatedSecrets()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-reload-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:openai:ApiKey\"] = \"openai-key\",\n [\"LLMProviders:Providers:openai:ProviderType\"] = \"openai\",\n [\"LLMProviders:Providers:openai:Model\"] = \"gpt-5.4\",\n [\"LLMProviders:Default\"] = \"openai\",\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.EnableReloadableProviderFactory = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"openai\");\n\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Providers:deepseek:Model\"] = \"deepseek-chat\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyNyxIdProviderConfigured_ShouldRegisterNyxIdDefaultProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n [\"LLMProviders:Default\"] = \"nyxid\",\n });\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"nyxid\");\n llmFactory.GetDefault().Name.Should().Be(\"nyxid\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSecretsStoreOptionAbsent_ShouldUseDIRegisteredStore()\n {\n // Mainnet path: a host registers IAevatarSecretsStore (e.g. the\n // read-only EnvironmentSecretsStore) into DI but does not pass\n // options.SecretsStore. Before the fix, AddAevatarAIFeatures fell\n // back to `new AevatarSecretsStore()` which re-opens secrets.json\n // from disk. This asserts that the DI-registered store is honored.\n var diRegistered = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"from-di-registered-store\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n var services = new ServiceCollection();\n services.AddSingleton<IAevatarSecretsStore>(diRegistered);\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n // intentionally leave options.SecretsStore = null\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_ShouldRegisterWorkflowToolSourceAdapterForAgentTools()\n {\n var source = new StubAgentToolSource([new StubAgentTool(\"demo_tool\", \"\"\"{\"ok\":true}\"\"\")]);\n var services = new ServiceCollection()\n .AddSingleton<IAgentToolSource>(source);\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options => options.EnableMEAIProviders = false);\n\n await using var provider = services.BuildServiceProvider();\n // Yield capability follows the actor, never the container (#2004): bootstrap must\n // not hand a yielding handler to surfaces without a pending-approval continuation.\n provider.GetService<IToolApprovalHandler>().Should().BeNull();\n provider.GetServices<IToolCallMiddleware>().Should().NotContain(x => x is ToolApprovalMiddleware);\n\n var workflowSource = provider.GetServices<IWorkflowToolSource>()\n .Should()\n .ContainSingle()\n .Subject;\n var tool = (await workflowSource.GetToolsAsync())\n .Should()\n .ContainSingle()\n .Subject;\n\n tool.Name.Should().Be(\"demo_tool\");\n var result = await tool.ExecuteAsync(\n new WorkflowToolExecutionRequest(\n ArgumentsJson: \"{}\",\n RunId: \"run-1\",\n StepId: \"step-1\",\n ExecutionId: \"exec-1\",\n CallId: \"call-1\",\n ScopeId: \"scope-1\",\n CallerCredential: new WorkflowCallerCredential()));\n result.ResultJson.Should().Be(\"\"\"{\"ok\":true}\"\"\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder();\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldSupportRemoteUrlConfiguration()\n {\n var builder = new MCPConnectorBuilder();\n var entry = new ConnectorConfigEntry\n {\n Name = \"nyxid_mcp\",\n Type = \"mcp\",\n TimeoutMs = 15000,\n MCP = new MCPConnectorConfig\n {\n ServerName = \"nyxid\",\n Url = \"https://nyxid.example.com/mcp\",\n AdditionalHeaders = new Dictionary<string, string> { [\"x-tenant\"] = \"demo\" },\n DefaultTool = \"chrono-graph__query\",\n AllowedTools = [\"chrono-graph__query\"],\n },\n };\n\n var ok = builder.TryBuild(entry, NullLogger.Instance, out var connector);\n\n ok.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"nyxid_mcp\");\n\n var serverConfigField = connector.GetType().GetField(\"_serverConfig\", BindingFlags.Instance | BindingFlags.NonPublic);\n serverConfigField.Should().NotBeNull();\n var serverConfig = serverConfigField!.GetValue(connector).Should().BeOfType<MCPServerConfig>().Subject;\n serverConfig.Url.Should().Be(\"https://nyxid.example.com/mcp\");\n serverConfig.HttpClient.Should().NotBeNull();\n serverConfig.HttpClient!.Timeout.Should().Be(Timeout.InfiniteTimeSpan);\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, configuration, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private static void AddVoicePresenceTestCredentialResolver(IServiceCollection services) =>\n services.AddSingleton<IRealtimeProviderCredentialResolver, NoOpRealtimeProviderCredentialResolver>();\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n\n private sealed class StubAgentToolSource(IReadOnlyList<IAgentTool> tools) : IAgentToolSource\n {\n public Task<IReadOnlyList<IAgentTool>> DiscoverToolsAsync(CancellationToken ct = default)\n {\n _ = ct;\n return Task.FromResult(tools);\n }\n }\n\n private sealed class StubAgentTool(string name, string resultJson) : IAgentTool\n {\n public string Name { get; } = name;\n public string Description => \"test tool\";\n public string ParametersSchema => \"\"\"{\"type\":\"object\"}\"\"\";\n\n public Task<string> ExecuteAsync(string argumentsJson, CancellationToken ct = default)\n {\n _ = argumentsJson;\n _ = ct;\n return Task.FromResult(resultJson);\n }\n }\n\n private static void WriteFlatSecrets(string path, IReadOnlyDictionary<string, string> values)\n {\n var directory = Path.GetDirectoryName(path);\n if (!string.IsNullOrWhiteSpace(directory))\n Directory.CreateDirectory(directory);\n\n var json = JsonSerializer.Serialize(values);\n File.WriteAllText(path, json);\n File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddSeconds(1));\n }\n\n private sealed class NoOpActorDispatchPort : IActorDispatchPort\n {\n public Task<DispatchAdmission> DispatchAsync(string actorId, EventEnvelope envelope, CancellationToken ct = default) =>\n Task.FromResult(DispatchAdmissionFactory.Create(actorId, envelope));\n }\n\n private sealed class StaticActorRuntime : IActorRuntime\n {\n public Task<IActor> CreateAsync<TAgent>(string? id = null, CancellationToken ct = default)\n where TAgent : IAgent =>\n throw new NotSupportedException();\n\n public Task<IActor> CreateAsync(Type agentType, string? id = null, CancellationToken ct = default) =>\n throw new NotSupportedException();\n\n public Task DestroyAsync(string id, CancellationToken ct = default) =>\n Task.CompletedTask;\n\n public Task<IActor?> GetAsync(string id) =>\n Task.FromResult<IActor?>(null);\n\n public Task<bool> ExistsAsync(string id) =>\n Task.FromResult(true);\n\n public Task LinkAsync(string parentId, string childId, CancellationToken ct = default) =>\n Task.CompletedTask;\n\n public Task UnlinkAsync(string childId, CancellationToken ct = default) =>\n Task.CompletedTask;\n }\n\n private sealed class EmptyVoicePresenceCapabilityReader\n : IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>\n {\n public Task<VoicePresenceCapabilityReadModel?> GetAsync(string key, CancellationToken ct = default) =>\n Task.FromResult<VoicePresenceCapabilityReadModel?>(null);\n\n public Task<ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>> QueryAsync(\n ProjectionDocumentQuery query,\n CancellationToken ct = default) =>\n Task.FromResult(ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>.Empty);\n }\n\n private sealed class NoOpRealtimeProviderCredentialResolver : IRealtimeProviderCredentialResolver\n {\n public Task<string?> ResolveApiKeyAsync(\n VoiceProviderSessionKey sessionKey,\n VoiceProviderConfig config,\n CancellationToken ct) =>\n Task.FromResult<string?>(null);\n }\n\n}\n"}, "432293c206fddd400c5b9c1a734279fd5d8fd075": {"t": 1785756602.4664447, "n": 34323, "h": "12f69903d47cbb74", "c": "using System.Collections;\nusing System.Net.Http;\nusing System.Reflection;\nusing System.Text.Json;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions.TypeSystem;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Workflow.Abstractions;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\n[Collection(ProcessEnvSerialCollection.Name)]\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n var configuration = new ConfigurationBuilder().Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void ReadConfiguredProviders_WhenNyxIdAuthorityConfigured_ShouldResolveGatewayEndpoint()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"gpt-5.4\",\n DefaultProvider = \"nyxid\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n });\n var configuration = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"nyxid\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://nyx.example.com/api/v1/llm/gateway/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableMEAIProviders = true;\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IVoiceToolInvoker>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterWorkflowRolePrimaryKind()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options => options.EnableMEAIProviders = false);\n\n using var provider = services.BuildServiceProvider();\n var registry = provider.GetRequiredService<IAgentKindRegistry>();\n\n var primary = registry.Resolve(WorkflowRoleConventions.DefaultAgentKind);\n var formerAliasFound = registry.TryResolve(\"workflow.assistant-role\", out var formerAlias);\n\n primary.Metadata.Kind.Should().Be(WorkflowRoleConventions.DefaultAgentKind);\n primary.Metadata.ImplementationClrTypeName.Should().Be(typeof(WorkflowRoleGAgent).FullName);\n formerAliasFound.Should().BeFalse();\n formerAlias.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverEnabled_ShouldRegisterFailoverFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<FailoverLLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceOpenAIConfigured_ShouldRegisterVoicePresenceModuleFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n services.AddSingleton<IActorRuntime, StaticActorRuntime>();\n services.AddSingleton<IActorDispatchPort, NoOpActorDispatchPort>();\n services.AddSingleton<IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>>(\n new EmptyVoicePresenceCapabilityReader());\n AddVoicePresenceTestCredentialResolver(services);\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n options.VoicePresence.OpenAISession = new VoiceSessionConfig\n {\n Voice = \"alloy\",\n SampleRateHz = 24000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n provider.GetRequiredService<IVoiceToolCatalog>()\n .Should().BeOfType<AgentToolVoiceCatalog>();\n provider.GetRequiredService<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>>()\n .Should().BeOfType<ActorOwnedVoiceRealtimeSession>();\n provider.GetRequiredService<IVoicePresenceCapabilityQueryPort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoicePresenceCapabilityCommandPort>()\n .Should().BeOfType<VoicePresenceCapabilityCommandPort>();\n provider.GetRequiredService<IVoicePresenceSessionLeasePort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoiceVolatileMediaStreamPort>()\n .Should().BeOfType<VoiceVolatileMediaStreamPort>();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n defaultModule.As<VoicePresenceModule>()\n .CanHandle(new EventEnvelope\n {\n Payload = Google.Protobuf.WellKnownTypes.Any.Pack(new Google.Protobuf.WellKnownTypes.StringValue\n {\n Value = \"doorbell\",\n }),\n Route = EnvelopeRouteSemantics.CreateDirect(\"device-events.callback\", \"voice-agent\"),\n })\n .Should()\n .BeFalse();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeTrue();\n openAIModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm\", out var miniCpmModule).Should().BeFalse();\n miniCpmModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldPreserveConfiguredVoiceDirectExternalEventTypeUrls()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n services.AddSingleton<IActorDispatchPort, NoOpActorDispatchPort>();\n services.AddSingleton<IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>>(\n new EmptyVoicePresenceCapabilityReader());\n AddVoicePresenceTestCredentialResolver(services);\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.Module = new VoicePresenceModuleOptions\n {\n DirectExternalEventTypeUrls =\n [\n \" type.googleapis.com/example.External \",\n \"type.googleapis.com/example.External\",\n ],\n };\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n\n factory.TryCreate(\"voice_presence\", out var module).Should().BeTrue();\n var voiceModule = module.Should().BeOfType<VoicePresenceModule>().Subject;\n\n voiceModule.CanHandle(new EventEnvelope\n {\n Payload = new Google.Protobuf.WellKnownTypes.Any\n {\n TypeUrl = \"type.googleapis.com/example.External\",\n Value = Google.Protobuf.ByteString.CopyFromUtf8(\"payload\"),\n },\n Route = EnvelopeRouteSemantics.CreateDirect(\"device-events.callback\", \"voice-agent\"),\n }).Should().BeTrue();\n voiceModule.CanHandle(new EventEnvelope\n {\n Payload = new Google.Protobuf.WellKnownTypes.Any\n {\n TypeUrl = \"type.googleapis.com/example.Other\",\n Value = Google.Protobuf.ByteString.CopyFromUtf8(\"payload\"),\n },\n Route = EnvelopeRouteSemantics.CreateDirect(\"device-events.callback\", \"voice-agent\"),\n }).Should().BeFalse();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverDisabled_ShouldRegisterMEAIFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<MEAILLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetRequiredService<IAgentKindRegistry>()\n .Resolve(WorkflowRoleConventions.DefaultAgentKind)\n .Metadata\n .ImplementationClrTypeName\n .Should()\n .Be(typeof(WorkflowRoleGAgent).FullName);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenReloadableFactoryEnabled_ShouldPickUpdatedSecrets()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-reload-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:openai:ApiKey\"] = \"openai-key\",\n [\"LLMProviders:Providers:openai:ProviderType\"] = \"openai\",\n [\"LLMProviders:Providers:openai:Model\"] = \"gpt-5.4\",\n [\"LLMProviders:Default\"] = \"openai\",\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.EnableReloadableProviderFactory = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"openai\");\n\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Providers:deepseek:Model\"] = \"deepseek-chat\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyNyxIdProviderConfigured_ShouldRegisterNyxIdDefaultProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n [\"LLMProviders:Default\"] = \"nyxid\",\n });\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"nyxid\");\n llmFactory.GetDefault().Name.Should().Be(\"nyxid\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSecretsStoreOptionAbsent_ShouldUseDIRegisteredStore()\n {\n // Mainnet path: a host registers IAevatarSecretsStore (e.g. the\n // read-only EnvironmentSecretsStore) into DI but does not pass\n // options.SecretsStore. Before the fix, AddAevatarAIFeatures fell\n // back to `new AevatarSecretsStore()` which re-opens secrets.json\n // from disk. This asserts that the DI-registered store is honored.\n var diRegistered = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"from-di-registered-store\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n var services = new ServiceCollection();\n services.AddSingleton<IAevatarSecretsStore>(diRegistered);\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n // intentionally leave options.SecretsStore = null\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_ShouldRegisterWorkflowToolSourceAdapterForAgentTools()\n {\n var source = new StubAgentToolSource([new StubAgentTool(\"demo_tool\", \"\"\"{\"ok\":true}\"\"\")]);\n var services = new ServiceCollection()\n .AddSingleton<IAgentToolSource>(source);\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options => options.EnableMEAIProviders = false);\n\n await using var provider = services.BuildServiceProvider();\n // Yield capability follows the actor, never the container (#2004): bootstrap must\n // not hand a yielding handler to surfaces without a pending-approval continuation.\n provider.GetService<IToolApprovalHandler>().Should().BeNull();\n provider.GetServices<IToolCallMiddleware>().Should().NotContain(x => x is ToolApprovalMiddleware);\n\n var workflowSource = provider.GetServices<IWorkflowToolSource>()\n .Should()\n .ContainSingle()\n .Subject;\n var tool = (await workflowSource.GetToolsAsync())\n .Should()\n .ContainSingle()\n .Subject;\n\n tool.Name.Should().Be(\"demo_tool\");\n var result = await tool.ExecuteAsync(\n new WorkflowToolExecutionRequest(\n ArgumentsJson: \"{}\",\n RunId: \"run-1\",\n StepId: \"step-1\",\n ExecutionId: \"exec-1\",\n CallId: \"call-1\",\n ScopeId: \"scope-1\",\n CallerCredential: new WorkflowCallerCredential()));\n result.ResultJson.Should().Be(\"\"\"{\"ok\":true}\"\"\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder();\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldSupportRemoteUrlConfiguration()\n {\n var builder = new MCPConnectorBuilder();\n var entry = new ConnectorConfigEntry\n {\n Name = \"nyxid_mcp\",\n Type = \"mcp\",\n TimeoutMs = 15000,\n MCP = new MCPConnectorConfig\n {\n ServerName = \"nyxid\",\n Url = \"https://nyxid.example.com/mcp\",\n AdditionalHeaders = new Dictionary<string, string> { [\"x-tenant\"] = \"demo\" },\n DefaultTool = \"chrono-graph__query\",\n AllowedTools = [\"chrono-graph__query\"],\n },\n };\n\n var ok = builder.TryBuild(entry, NullLogger.Instance, out var connector);\n\n ok.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"nyxid_mcp\");\n\n var serverConfigField = connector.GetType().GetField(\"_serverConfig\", BindingFlags.Instance | BindingFlags.NonPublic);\n serverConfigField.Should().NotBeNull();\n var serverConfig = serverConfigField!.GetValue(connector).Should().BeOfType<MCPServerConfig>().Subject;\n serverConfig.Url.Should().Be(\"https://nyxid.example.com/mcp\");\n serverConfig.HttpClient.Should().NotBeNull();\n serverConfig.HttpClient!.Timeout.Should().Be(Timeout.InfiniteTimeSpan);\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, configuration, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private static void AddVoicePresenceTestCredentialResolver(IServiceCollection services) =>\n services.AddSingleton<IRealtimeProviderCredentialResolver, NoOpRealtimeProviderCredentialResolver>();\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n\n private sealed class StubAgentToolSource(IReadOnlyList<IAgentTool> tools) : IAgentToolSource\n {\n public Task<IReadOnlyList<IAgentTool>> DiscoverToolsAsync(CancellationToken ct = default)\n {\n _ = ct;\n return Task.FromResult(tools);\n }\n }\n\n private sealed class StubAgentTool(string name, string resultJson) : IAgentTool\n {\n public string Name { get; } = name;\n public string Description => \"test tool\";\n public string ParametersSchema => \"\"\"{\"type\":\"object\"}\"\"\";\n\n public Task<string> ExecuteAsync(string argumentsJson, CancellationToken ct = default)\n {\n _ = argumentsJson;\n _ = ct;\n return Task.FromResult(resultJson);\n }\n }\n\n private static void WriteFlatSecrets(string path, IReadOnlyDictionary<string, string> values)\n {\n var directory = Path.GetDirectoryName(path);\n if (!string.IsNullOrWhiteSpace(directory))\n Directory.CreateDirectory(directory);\n\n var json = JsonSerializer.Serialize(values);\n File.WriteAllText(path, json);\n File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddSeconds(1));\n }\n\n private sealed class NoOpActorDispatchPort : IActorDispatchPort\n {\n public Task<DispatchAdmission> DispatchAsync(string actorId, EventEnvelope envelope, CancellationToken ct = default) =>\n Task.FromResult(DispatchAdmissionFactory.Create(actorId, envelope));\n }\n\n private sealed class StaticActorRuntime : IActorRuntime\n {\n public Task<IActor> CreateAsync<TAgent>(string? id = null, CancellationToken ct = default)\n where TAgent : IAgent =>\n throw new NotSupportedException();\n\n public Task<IActor> CreateAsync(Type agentType, string? id = null, CancellationToken ct = default) =>\n throw new NotSupportedException();\n\n public Task DestroyAsync(string id, CancellationToken ct = default) =>\n Task.CompletedTask;\n\n public Task<IActor?> GetAsync(string id) =>\n Task.FromResult<IActor?>(null);\n\n public Task<bool> ExistsAsync(string id) =>\n Task.FromResult(true);\n\n public Task LinkAsync(string parentId, string childId, CancellationToken ct = default) =>\n Task.CompletedTask;\n\n public Task UnlinkAsync(string childId, CancellationToken ct = default) =>\n Task.CompletedTask;\n }\n\n private sealed class EmptyVoicePresenceCapabilityReader\n : IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>\n {\n public Task<VoicePresenceCapabilityReadModel?> GetAsync(string key, CancellationToken ct = default) =>\n Task.FromResult<VoicePresenceCapabilityReadModel?>(null);\n\n public Task<ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>> QueryAsync(\n ProjectionDocumentQuery query,\n CancellationToken ct = default) =>\n Task.FromResult(ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>.Empty);\n }\n\n private sealed class NoOpRealtimeProviderCredentialResolver : IRealtimeProviderCredentialResolver\n {\n public Task<string?> ResolveApiKeyAsync(\n VoiceProviderSessionKey sessionKey,\n VoiceProviderConfig config,\n CancellationToken ct) =>\n Task.FromResult<string?>(null);\n }\n\n}\n"}, "84bf0489a5893bad26e082ac1cb8b41d3952f3f7": {"t": 1785756603.9063246, "n": 37398, "h": "bca5db109101f507", "c": "using System.Collections;\nusing System.Net.Http;\nusing System.Reflection;\nusing System.Text.Json;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions.TypeSystem;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Workflow.Abstractions;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\n[Collection(ProcessEnvSerialCollection.Name)]\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n var configuration = new ConfigurationBuilder().Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void ReadConfiguredProviders_WhenNyxIdAuthorityConfigured_ShouldResolveGatewayEndpoint()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"gpt-5.4\",\n DefaultProvider = \"nyxid\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n });\n var configuration = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"nyxid\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://nyx.example.com/api/v1/llm/gateway/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableMEAIProviders = true;\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IVoiceToolInvoker>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterWorkflowRolePrimaryKind()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options => options.EnableMEAIProviders = false);\n\n using var provider = services.BuildServiceProvider();\n var registry = provider.GetRequiredService<IAgentKindRegistry>();\n\n var primary = registry.Resolve(WorkflowRoleConventions.DefaultAgentKind);\n var formerAliasFound = registry.TryResolve(\"workflow.assistant-role\", out var formerAlias);\n\n primary.Metadata.Kind.Should().Be(WorkflowRoleConventions.DefaultAgentKind);\n primary.Metadata.ImplementationClrTypeName.Should().Be(typeof(WorkflowRoleGAgent).FullName);\n formerAliasFound.Should().BeFalse();\n formerAlias.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverEnabled_ShouldRegisterFailoverFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<FailoverLLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceOpenAIConfigured_ShouldRegisterVoicePresenceModuleFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n services.AddSingleton<IActorRuntime, StaticActorRuntime>();\n services.AddSingleton<IActorDispatchPort, NoOpActorDispatchPort>();\n services.AddSingleton<IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>>(\n new EmptyVoicePresenceCapabilityReader());\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n options.VoicePresence.OpenAISession = new VoiceSessionConfig\n {\n Voice = \"alloy\",\n SampleRateHz = 24000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n provider.GetRequiredService<IVoiceToolCatalog>()\n .Should().BeOfType<AgentToolVoiceCatalog>();\n provider.GetRequiredService<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>>()\n .Should().BeOfType<ActorOwnedVoiceRealtimeSession>();\n provider.GetRequiredService<IVoicePresenceCapabilityQueryPort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoicePresenceCapabilityCommandPort>()\n .Should().BeOfType<VoicePresenceCapabilityCommandPort>();\n provider.GetRequiredService<IVoicePresenceSessionLeasePort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoiceVolatileMediaStreamPort>()\n .Should().BeOfType<VoiceVolatileMediaStreamPort>();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n defaultModule.As<VoicePresenceModule>()\n .CanHandle(new EventEnvelope\n {\n Payload = Google.Protobuf.WellKnownTypes.Any.Pack(new Google.Protobuf.WellKnownTypes.StringValue\n {\n Value = \"doorbell\",\n }),\n Route = EnvelopeRouteSemantics.CreateDirect(\"device-events.callback\", \"voice-agent\"),\n })\n .Should()\n .BeFalse();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeTrue();\n openAIModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm\", out var miniCpmModule).Should().BeFalse();\n miniCpmModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldPreserveConfiguredVoiceDirectExternalEventTypeUrls()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n services.AddSingleton<IActorDispatchPort, NoOpActorDispatchPort>();\n services.AddSingleton<IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>>(\n new EmptyVoicePresenceCapabilityReader());\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.Module = new VoicePresenceModuleOptions\n {\n DirectExternalEventTypeUrls =\n [\n \" type.googleapis.com/example.External \",\n \"type.googleapis.com/example.External\",\n ],\n };\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n\n factory.TryCreate(\"voice_presence\", out var module).Should().BeTrue();\n var voiceModule = module.Should().BeOfType<VoicePresenceModule>().Subject;\n\n voiceModule.CanHandle(new EventEnvelope\n {\n Payload = new Google.Protobuf.WellKnownTypes.Any\n {\n TypeUrl = \"type.googleapis.com/example.External\",\n Value = Google.Protobuf.ByteString.CopyFromUtf8(\"payload\"),\n },\n Route = EnvelopeRouteSemantics.CreateDirect(\"device-events.callback\", \"voice-agent\"),\n }).Should().BeTrue();\n voiceModule.CanHandle(new EventEnvelope\n {\n Payload = new Google.Protobuf.WellKnownTypes.Any\n {\n TypeUrl = \"type.googleapis.com/example.Other\",\n Value = Google.Protobuf.ByteString.CopyFromUtf8(\"payload\"),\n },\n Route = EnvelopeRouteSemantics.CreateDirect(\"device-events.callback\", \"voice-agent\"),\n }).Should().BeFalse();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceOpenAIEndpointOnlyConfigured_ShouldNotRegisterOpenAI()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"OPENAI_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n services.AddSingleton<IActorDispatchPort, NoOpActorDispatchPort>();\n services.AddSingleton<IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>>(\n new EmptyVoicePresenceCapabilityReader());\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.DefaultProvider = \"minicpm\";\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n Endpoint = \"https://nyx.example.com/api/v1/proxy/s/llm-openai\",\n };\n options.VoicePresence.OpenAISession = new VoiceSessionConfig\n {\n Voice = \"alloy\",\n SampleRateHz = 24000,\n };\n options.VoicePresence.MiniCPMProvider = new VoiceProviderConfig\n {\n ProviderName = \"minicpm\",\n Endpoint = \"https://minicpm.example.com\",\n };\n options.VoicePresence.MiniCPMSession = new VoiceSessionConfig\n {\n SampleRateHz = 16000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeFalse();\n openAIModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceMiniCpmConfiguredAsDefault_ShouldCreateDefaultAlias()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"OPENAI_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.DefaultProvider = \"minicpm-o\";\n options.VoicePresence.MiniCPMProvider = new VoiceProviderConfig\n {\n ProviderName = \"minicpm-o\",\n Endpoint = \"https://minicpm.example.com\",\n };\n options.VoicePresence.MiniCPMSession = new VoiceSessionConfig\n {\n SampleRateHz = 16000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm_o\", out var miniCpmModule).Should().BeTrue();\n miniCpmModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeFalse();\n openAIModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverDisabled_ShouldRegisterMEAIFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<MEAILLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetRequiredService<IAgentKindRegistry>()\n .Resolve(WorkflowRoleConventions.DefaultAgentKind)\n .Metadata\n .ImplementationClrTypeName\n .Should()\n .Be(typeof(WorkflowRoleGAgent).FullName);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenReloadableFactoryEnabled_ShouldPickUpdatedSecrets()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-reload-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:openai:ApiKey\"] = \"openai-key\",\n [\"LLMProviders:Providers:openai:ProviderType\"] = \"openai\",\n [\"LLMProviders:Providers:openai:Model\"] = \"gpt-5.4\",\n [\"LLMProviders:Default\"] = \"openai\",\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.EnableReloadableProviderFactory = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"openai\");\n\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Providers:deepseek:Model\"] = \"deepseek-chat\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyNyxIdProviderConfigured_ShouldRegisterNyxIdDefaultProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n [\"LLMProviders:Default\"] = \"nyxid\",\n });\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"nyxid\");\n llmFactory.GetDefault().Name.Should().Be(\"nyxid\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSecretsStoreOptionAbsent_ShouldUseDIRegisteredStore()\n {\n // Mainnet path: a host registers IAevatarSecretsStore (e.g. the\n // read-only EnvironmentSecretsStore) into DI but does not pass\n // options.SecretsStore. Before the fix, AddAevatarAIFeatures fell\n // back to `new AevatarSecretsStore()` which re-opens secrets.json\n // from disk. This asserts that the DI-registered store is honored.\n var diRegistered = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"from-di-registered-store\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n var services = new ServiceCollection();\n services.AddSingleton<IAevatarSecretsStore>(diRegistered);\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n // intentionally leave options.SecretsStore = null\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_ShouldRegisterWorkflowToolSourceAdapterForAgentTools()\n {\n var source = new StubAgentToolSource([new StubAgentTool(\"demo_tool\", \"\"\"{\"ok\":true}\"\"\")]);\n var services = new ServiceCollection()\n .AddSingleton<IAgentToolSource>(source);\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options => options.EnableMEAIProviders = false);\n\n await using var provider = services.BuildServiceProvider();\n // Yield capability follows the actor, never the container (#2004): bootstrap must\n // not hand a yielding handler to surfaces without a pending-approval continuation.\n provider.GetService<IToolApprovalHandler>().Should().BeNull();\n provider.GetServices<IToolCallMiddleware>().Should().NotContain(x => x is ToolApprovalMiddleware);\n\n var workflowSource = provider.GetServices<IWorkflowToolSource>()\n .Should()\n .ContainSingle()\n .Subject;\n var tool = (await workflowSource.GetToolsAsync())\n .Should()\n .ContainSingle()\n .Subject;\n\n tool.Name.Should().Be(\"demo_tool\");\n var result = await tool.ExecuteAsync(\n new WorkflowToolExecutionRequest(\n ArgumentsJson: \"{}\",\n RunId: \"run-1\",\n StepId: \"step-1\",\n ExecutionId: \"exec-1\",\n CallId: \"call-1\",\n ScopeId: \"scope-1\",\n CallerCredential: new WorkflowCallerCredential()));\n result.ResultJson.Should().Be(\"\"\"{\"ok\":true}\"\"\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder();\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldSupportRemoteUrlConfiguration()\n {\n var builder = new MCPConnectorBuilder();\n var entry = new ConnectorConfigEntry\n {\n Name = \"nyxid_mcp\",\n Type = \"mcp\",\n TimeoutMs = 15000,\n MCP = new MCPConnectorConfig\n {\n ServerName = \"nyxid\",\n Url = \"https://nyxid.example.com/mcp\",\n AdditionalHeaders = new Dictionary<string, string> { [\"x-tenant\"] = \"demo\" },\n DefaultTool = \"chrono-graph__query\",\n AllowedTools = [\"chrono-graph__query\"],\n },\n };\n\n var ok = builder.TryBuild(entry, NullLogger.Instance, out var connector);\n\n ok.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"nyxid_mcp\");\n\n var serverConfigField = connector.GetType().GetField(\"_serverConfig\", BindingFlags.Instance | BindingFlags.NonPublic);\n serverConfigField.Should().NotBeNull();\n var serverConfig = serverConfigField!.GetValue(connector).Should().BeOfType<MCPServerConfig>().Subject;\n serverConfig.Url.Should().Be(\"https://nyxid.example.com/mcp\");\n serverConfig.HttpClient.Should().NotBeNull();\n serverConfig.HttpClient!.Timeout.Should().Be(Timeout.InfiniteTimeSpan);\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, configuration, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n\n private sealed class StubAgentToolSource(IReadOnlyList<IAgentTool> tools) : IAgentToolSource\n {\n public Task<IReadOnlyList<IAgentTool>> DiscoverToolsAsync(CancellationToken ct = default)\n {\n _ = ct;\n return Task.FromResult(tools);\n }\n }\n\n private sealed class StubAgentTool(string name, string resultJson) : IAgentTool\n {\n public string Name { get; } = name;\n public string Description => \"test tool\";\n public string ParametersSchema => \"\"\"{\"type\":\"object\"}\"\"\";\n\n public Task<string> ExecuteAsync(string argumentsJson, CancellationToken ct = default)\n {\n _ = argumentsJson;\n _ = ct;\n return Task.FromResult(resultJson);\n }\n }\n\n private static void WriteFlatSecrets(string path, IReadOnlyDictionary<string, string> values)\n {\n var directory = Path.GetDirectoryName(path);\n if (!string.IsNullOrWhiteSpace(directory))\n Directory.CreateDirectory(directory);\n\n var json = JsonSerializer.Serialize(values);\n File.WriteAllText(path, json);\n File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddSeconds(1));\n }\n\n private sealed class NoOpActorDispatchPort : IActorDispatchPort\n {\n public Task<DispatchAdmission> DispatchAsync(string actorId, EventEnvelope envelope, CancellationToken ct = default) =>\n Task.FromResult(DispatchAdmissionFactory.Create(actorId, envelope));\n }\n\n private sealed class StaticActorRuntime : IActorRuntime\n {\n public Task<IActor> CreateAsync<TAgent>(string? id = null, CancellationToken ct = default)\n where TAgent : IAgent =>\n throw new NotSupportedException();\n\n public Task<IActor> CreateAsync(Type agentType, string? id = null, CancellationToken ct = default) =>\n throw new NotSupportedException();\n\n public Task DestroyAsync(string id, CancellationToken ct = default) =>\n Task.CompletedTask;\n\n public Task<IActor?> GetAsync(string id) =>\n Task.FromResult<IActor?>(null);\n\n public Task<bool> ExistsAsync(string id) =>\n Task.FromResult(true);\n\n public Task LinkAsync(string parentId, string childId, CancellationToken ct = default) =>\n Task.CompletedTask;\n\n public Task UnlinkAsync(string childId, CancellationToken ct = default) =>\n Task.CompletedTask;\n }\n\n private sealed class EmptyVoicePresenceCapabilityReader\n : IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>\n {\n public Task<VoicePresenceCapabilityReadModel?> GetAsync(string key, CancellationToken ct = default) =>\n Task.FromResult<VoicePresenceCapabilityReadModel?>(null);\n\n public Task<ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>> QueryAsync(\n ProjectionDocumentQuery query,\n CancellationToken ct = default) =>\n Task.FromResult(ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>.Empty);\n }\n}\n"}, "a1ab64219ae7c713d7f788f0910feac00a5cd453": {"t": 1785756605.3137193, "n": 31446, "h": "c3cbe5bb68a03f8e", "c": "using System.Collections;\nusing System.Net.Http;\nusing System.Reflection;\nusing System.Text.Json;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Workflow.Core.Modules;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\n[Collection(ProcessEnvSerialCollection.Name)]\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n var configuration = new ConfigurationBuilder().Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void ReadConfiguredProviders_WhenNyxIdAuthorityConfigured_ShouldResolveGatewayEndpoint()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"gpt-5.4\",\n DefaultProvider = \"nyxid\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n });\n var configuration = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"nyxid\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://nyx.example.com/api/v1/llm/gateway/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableMEAIProviders = true;\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n provider.GetService<IVoiceToolInvoker>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverEnabled_ShouldRegisterFailoverFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<FailoverLLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceOpenAIConfigured_ShouldRegisterVoicePresenceModuleFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n services.AddSingleton<IActorDispatchPort, NoOpActorDispatchPort>();\n services.AddSingleton<IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>>(\n new EmptyVoicePresenceCapabilityReader());\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n options.VoicePresence.OpenAISession = new VoiceSessionConfig\n {\n Voice = \"alloy\",\n SampleRateHz = 24000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n provider.GetRequiredService<IVoiceToolCatalog>()\n .Should().BeOfType<AgentToolVoiceCatalog>();\n provider.GetRequiredService<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>>()\n .Should().BeOfType<ActorOwnedVoiceRealtimeSession>();\n provider.GetRequiredService<IVoicePresenceCapabilityQueryPort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoicePresenceSessionLeasePort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoiceVolatileMediaStreamPort>()\n .Should().BeOfType<FailClosedVoiceVolatileMediaStreamPort>();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeTrue();\n openAIModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm\", out var miniCpmModule).Should().BeFalse();\n miniCpmModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceOpenAIEndpointOnlyConfigured_ShouldNotRegisterOpenAI()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"OPENAI_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n services.AddSingleton<IActorDispatchPort, NoOpActorDispatchPort>();\n services.AddSingleton<IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>>(\n new EmptyVoicePresenceCapabilityReader());\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.DefaultProvider = \"minicpm\";\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n Endpoint = \"https://nyx.example.com/api/v1/proxy/s/llm-openai\",\n };\n options.VoicePresence.OpenAISession = new VoiceSessionConfig\n {\n Voice = \"alloy\",\n SampleRateHz = 24000,\n };\n options.VoicePresence.MiniCPMProvider = new VoiceProviderConfig\n {\n ProviderName = \"minicpm\",\n Endpoint = \"https://minicpm.example.com\",\n };\n options.VoicePresence.MiniCPMSession = new VoiceSessionConfig\n {\n SampleRateHz = 16000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeFalse();\n openAIModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceMiniCpmConfiguredAsDefault_ShouldCreateDefaultAlias()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"OPENAI_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.DefaultProvider = \"minicpm-o\";\n options.VoicePresence.MiniCPMProvider = new VoiceProviderConfig\n {\n ProviderName = \"minicpm-o\",\n Endpoint = \"https://minicpm.example.com\",\n };\n options.VoicePresence.MiniCPMSession = new VoiceSessionConfig\n {\n SampleRateHz = 16000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm_o\", out var miniCpmModule).Should().BeTrue();\n miniCpmModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeFalse();\n openAIModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverDisabled_ShouldRegisterMEAIFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<MEAILLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenReloadableFactoryEnabled_ShouldPickUpdatedSecrets()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-reload-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:openai:ApiKey\"] = \"openai-key\",\n [\"LLMProviders:Providers:openai:ProviderType\"] = \"openai\",\n [\"LLMProviders:Providers:openai:Model\"] = \"gpt-5.4\",\n [\"LLMProviders:Default\"] = \"openai\",\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.EnableReloadableProviderFactory = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"openai\");\n\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Providers:deepseek:Model\"] = \"deepseek-chat\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyNyxIdProviderConfigured_ShouldRegisterNyxIdDefaultProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n [\"LLMProviders:Default\"] = \"nyxid\",\n });\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"nyxid\");\n llmFactory.GetDefault().Name.Should().Be(\"nyxid\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSecretsStoreOptionAbsent_ShouldUseDIRegisteredStore()\n {\n // Mainnet path: a host registers IAevatarSecretsStore (e.g. the\n // read-only EnvironmentSecretsStore) into DI but does not pass\n // options.SecretsStore. Before the fix, AddAevatarAIFeatures fell\n // back to `new AevatarSecretsStore()` which re-opens secrets.json\n // from disk. This asserts that the DI-registered store is honored.\n var diRegistered = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"from-di-registered-store\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n var services = new ServiceCollection();\n services.AddSingleton<IAevatarSecretsStore>(diRegistered);\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n // intentionally leave options.SecretsStore = null\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_ShouldRegisterWorkflowToolSourceAdapterForAgentTools()\n {\n var source = new StubAgentToolSource([new StubAgentTool(\"demo_tool\", \"\"\"{\"ok\":true}\"\"\")]);\n var services = new ServiceCollection()\n .AddSingleton<IAgentToolSource>(source);\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options => options.EnableMEAIProviders = false);\n\n await using var provider = services.BuildServiceProvider();\n var workflowSource = provider.GetServices<IWorkflowToolSource>()\n .Should()\n .ContainSingle()\n .Subject;\n var tool = (await workflowSource.GetToolsAsync())\n .Should()\n .ContainSingle()\n .Subject;\n\n tool.Name.Should().Be(\"demo_tool\");\n (await tool.ExecuteAsync(\"{}\")).Should().Be(\"\"\"{\"ok\":true}\"\"\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder();\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldSupportRemoteUrlConfiguration()\n {\n var builder = new MCPConnectorBuilder();\n var entry = new ConnectorConfigEntry\n {\n Name = \"nyxid_mcp\",\n Type = \"mcp\",\n TimeoutMs = 15000,\n MCP = new MCPConnectorConfig\n {\n ServerName = \"nyxid\",\n Url = \"https://nyxid.example.com/mcp\",\n AdditionalHeaders = new Dictionary<string, string> { [\"x-tenant\"] = \"demo\" },\n DefaultTool = \"chrono-graph__query\",\n AllowedTools = [\"chrono-graph__query\"],\n },\n };\n\n var ok = builder.TryBuild(entry, NullLogger.Instance, out var connector);\n\n ok.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"nyxid_mcp\");\n\n var serverConfigField = connector.GetType().GetField(\"_serverConfig\", BindingFlags.Instance | BindingFlags.NonPublic);\n serverConfigField.Should().NotBeNull();\n var serverConfig = serverConfigField!.GetValue(connector).Should().BeOfType<MCPServerConfig>().Subject;\n serverConfig.Url.Should().Be(\"https://nyxid.example.com/mcp\");\n serverConfig.HttpClient.Should().NotBeNull();\n serverConfig.HttpClient!.Timeout.Should().Be(Timeout.InfiniteTimeSpan);\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, configuration, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n\n private sealed class StubAgentToolSource(IReadOnlyList<IAgentTool> tools) : IAgentToolSource\n {\n public Task<IReadOnlyList<IAgentTool>> DiscoverToolsAsync(CancellationToken ct = default)\n {\n _ = ct;\n return Task.FromResult(tools);\n }\n }\n\n private sealed class StubAgentTool(string name, string resultJson) : IAgentTool\n {\n public string Name { get; } = name;\n public string Description => \"test tool\";\n public string ParametersSchema => \"\"\"{\"type\":\"object\"}\"\"\";\n\n public Task<string> ExecuteAsync(string argumentsJson, CancellationToken ct = default)\n {\n _ = argumentsJson;\n _ = ct;\n return Task.FromResult(resultJson);\n }\n }\n\n private static void WriteFlatSecrets(string path, IReadOnlyDictionary<string, string> values)\n {\n var directory = Path.GetDirectoryName(path);\n if (!string.IsNullOrWhiteSpace(directory))\n Directory.CreateDirectory(directory);\n\n var json = JsonSerializer.Serialize(values);\n File.WriteAllText(path, json);\n File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddSeconds(1));\n }\n\n private sealed class NoOpActorDispatchPort : IActorDispatchPort\n {\n public Task<DispatchAdmission> DispatchAsync(string actorId, EventEnvelope envelope, CancellationToken ct = default) =>\n Task.FromResult(DispatchAdmissionFactory.Create(actorId, envelope));\n }\n\n private sealed class EmptyVoicePresenceCapabilityReader\n : IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>\n {\n public Task<VoicePresenceCapabilityReadModel?> GetAsync(string key, CancellationToken ct = default) =>\n Task.FromResult<VoicePresenceCapabilityReadModel?>(null);\n\n public Task<ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>> QueryAsync(\n ProjectionDocumentQuery query,\n CancellationToken ct = default) =>\n Task.FromResult(ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>.Empty);\n }\n}\n"}, "8d0ac77337d2d3fb938a27d03ae586750203492d": {"t": 1785756606.9733162, "n": 31293, "h": "d1d9fb3ef37ef893", "c": "using System.Collections;\nusing System.Net.Http;\nusing System.Reflection;\nusing System.Text.Json;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Workflow.Core.Modules;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\n[Collection(ProcessEnvSerialCollection.Name)]\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n var configuration = new ConfigurationBuilder().Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void ReadConfiguredProviders_WhenNyxIdAuthorityConfigured_ShouldResolveGatewayEndpoint()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"gpt-5.4\",\n DefaultProvider = \"nyxid\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n });\n var configuration = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"nyxid\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://nyx.example.com/api/v1/llm/gateway/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableMEAIProviders = true;\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n provider.GetService<IVoiceToolInvoker>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverEnabled_ShouldRegisterFailoverFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<FailoverLLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceOpenAIConfigured_ShouldRegisterVoicePresenceModuleFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n services.AddSingleton<IActorDispatchPort, NoOpActorDispatchPort>();\n services.AddSingleton<IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>>(\n new EmptyVoicePresenceCapabilityReader());\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n options.VoicePresence.OpenAISession = new VoiceSessionConfig\n {\n Voice = \"alloy\",\n SampleRateHz = 24000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n provider.GetRequiredService<IVoiceToolCatalog>()\n .Should().BeOfType<AgentToolVoiceCatalog>();\n provider.GetRequiredService<IVoicePresenceSessionResolver>()\n .Should().BeOfType<ActorOwnedVoicePresenceSessionResolver>();\n provider.GetRequiredService<IVoicePresenceCapabilityQueryPort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoicePresenceSessionLeasePort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoicePresenceTransportAttachmentPort>()\n .Should().BeOfType<UnavailableVoicePresenceTransportAttachmentPort>();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeTrue();\n openAIModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm\", out var miniCpmModule).Should().BeFalse();\n miniCpmModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceOpenAIEndpointOnlyConfigured_ShouldNotRegisterOpenAI()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"OPENAI_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n services.AddSingleton<IActorDispatchPort, NoOpActorDispatchPort>();\n services.AddSingleton<IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>>(\n new EmptyVoicePresenceCapabilityReader());\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.DefaultProvider = \"minicpm\";\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n Endpoint = \"https://nyx.example.com/api/v1/proxy/s/llm-openai\",\n };\n options.VoicePresence.OpenAISession = new VoiceSessionConfig\n {\n Voice = \"alloy\",\n SampleRateHz = 24000,\n };\n options.VoicePresence.MiniCPMProvider = new VoiceProviderConfig\n {\n ProviderName = \"minicpm\",\n Endpoint = \"https://minicpm.example.com\",\n };\n options.VoicePresence.MiniCPMSession = new VoiceSessionConfig\n {\n SampleRateHz = 16000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeFalse();\n openAIModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceMiniCpmConfiguredAsDefault_ShouldCreateDefaultAlias()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"OPENAI_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.DefaultProvider = \"minicpm-o\";\n options.VoicePresence.MiniCPMProvider = new VoiceProviderConfig\n {\n ProviderName = \"minicpm-o\",\n Endpoint = \"https://minicpm.example.com\",\n };\n options.VoicePresence.MiniCPMSession = new VoiceSessionConfig\n {\n SampleRateHz = 16000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm_o\", out var miniCpmModule).Should().BeTrue();\n miniCpmModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeFalse();\n openAIModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverDisabled_ShouldRegisterMEAIFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<MEAILLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenReloadableFactoryEnabled_ShouldPickUpdatedSecrets()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-reload-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:openai:ApiKey\"] = \"openai-key\",\n [\"LLMProviders:Providers:openai:ProviderType\"] = \"openai\",\n [\"LLMProviders:Providers:openai:Model\"] = \"gpt-5.4\",\n [\"LLMProviders:Default\"] = \"openai\",\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.EnableReloadableProviderFactory = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"openai\");\n\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Providers:deepseek:Model\"] = \"deepseek-chat\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyNyxIdProviderConfigured_ShouldRegisterNyxIdDefaultProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n [\"LLMProviders:Default\"] = \"nyxid\",\n });\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"nyxid\");\n llmFactory.GetDefault().Name.Should().Be(\"nyxid\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSecretsStoreOptionAbsent_ShouldUseDIRegisteredStore()\n {\n // Mainnet path: a host registers IAevatarSecretsStore (e.g. the\n // read-only EnvironmentSecretsStore) into DI but does not pass\n // options.SecretsStore. Before the fix, AddAevatarAIFeatures fell\n // back to `new AevatarSecretsStore()` which re-opens secrets.json\n // from disk. This asserts that the DI-registered store is honored.\n var diRegistered = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"from-di-registered-store\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n var services = new ServiceCollection();\n services.AddSingleton<IAevatarSecretsStore>(diRegistered);\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n // intentionally leave options.SecretsStore = null\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_ShouldRegisterWorkflowToolSourceAdapterForAgentTools()\n {\n var source = new StubAgentToolSource([new StubAgentTool(\"demo_tool\", \"\"\"{\"ok\":true}\"\"\")]);\n var services = new ServiceCollection()\n .AddSingleton<IAgentToolSource>(source);\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options => options.EnableMEAIProviders = false);\n\n await using var provider = services.BuildServiceProvider();\n var workflowSource = provider.GetServices<IWorkflowToolSource>()\n .Should()\n .ContainSingle()\n .Subject;\n var tool = (await workflowSource.GetToolsAsync())\n .Should()\n .ContainSingle()\n .Subject;\n\n tool.Name.Should().Be(\"demo_tool\");\n (await tool.ExecuteAsync(\"{}\")).Should().Be(\"\"\"{\"ok\":true}\"\"\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder();\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldSupportRemoteUrlConfiguration()\n {\n var builder = new MCPConnectorBuilder();\n var entry = new ConnectorConfigEntry\n {\n Name = \"nyxid_mcp\",\n Type = \"mcp\",\n TimeoutMs = 15000,\n MCP = new MCPConnectorConfig\n {\n ServerName = \"nyxid\",\n Url = \"https://nyxid.example.com/mcp\",\n AdditionalHeaders = new Dictionary<string, string> { [\"x-tenant\"] = \"demo\" },\n DefaultTool = \"chrono-graph__query\",\n AllowedTools = [\"chrono-graph__query\"],\n },\n };\n\n var ok = builder.TryBuild(entry, NullLogger.Instance, out var connector);\n\n ok.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"nyxid_mcp\");\n\n var serverConfigField = connector.GetType().GetField(\"_serverConfig\", BindingFlags.Instance | BindingFlags.NonPublic);\n serverConfigField.Should().NotBeNull();\n var serverConfig = serverConfigField!.GetValue(connector).Should().BeOfType<MCPServerConfig>().Subject;\n serverConfig.Url.Should().Be(\"https://nyxid.example.com/mcp\");\n serverConfig.HttpClient.Should().NotBeNull();\n serverConfig.HttpClient!.Timeout.Should().Be(Timeout.InfiniteTimeSpan);\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, configuration, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n\n private sealed class StubAgentToolSource(IReadOnlyList<IAgentTool> tools) : IAgentToolSource\n {\n public Task<IReadOnlyList<IAgentTool>> DiscoverToolsAsync(CancellationToken ct = default)\n {\n _ = ct;\n return Task.FromResult(tools);\n }\n }\n\n private sealed class StubAgentTool(string name, string resultJson) : IAgentTool\n {\n public string Name { get; } = name;\n public string Description => \"test tool\";\n public string ParametersSchema => \"\"\"{\"type\":\"object\"}\"\"\";\n\n public Task<string> ExecuteAsync(string argumentsJson, CancellationToken ct = default)\n {\n _ = argumentsJson;\n _ = ct;\n return Task.FromResult(resultJson);\n }\n }\n\n private static void WriteFlatSecrets(string path, IReadOnlyDictionary<string, string> values)\n {\n var directory = Path.GetDirectoryName(path);\n if (!string.IsNullOrWhiteSpace(directory))\n Directory.CreateDirectory(directory);\n\n var json = JsonSerializer.Serialize(values);\n File.WriteAllText(path, json);\n File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddSeconds(1));\n }\n\n private sealed class NoOpActorDispatchPort : IActorDispatchPort\n {\n public Task<DispatchAdmission> DispatchAsync(string actorId, EventEnvelope envelope, CancellationToken ct = default) =>\n Task.FromResult(DispatchAdmissionFactory.Create(actorId, envelope));\n }\n\n private sealed class EmptyVoicePresenceCapabilityReader\n : IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>\n {\n public Task<VoicePresenceCapabilityReadModel?> GetAsync(string key, CancellationToken ct = default) =>\n Task.FromResult<VoicePresenceCapabilityReadModel?>(null);\n\n public Task<ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>> QueryAsync(\n ProjectionDocumentQuery query,\n CancellationToken ct = default) =>\n Task.FromResult(ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>.Empty);\n }\n}\n"}, "da816d977ebbbf788f8b1f551f4d1212c6eac210": {"t": 1785756608.4705682, "n": 29555, "h": "e9ac26504b3e0de9", "c": "using System.Collections;\nusing System.Net.Http;\nusing System.Reflection;\nusing System.Text.Json;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\n[Collection(ProcessEnvSerialCollection.Name)]\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n var configuration = new ConfigurationBuilder().Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void ReadConfiguredProviders_WhenNyxIdAuthorityConfigured_ShouldResolveGatewayEndpoint()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"gpt-5.4\",\n DefaultProvider = \"nyxid\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n });\n var configuration = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"nyxid\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://nyx.example.com/api/v1/llm/gateway/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableMEAIProviders = true;\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n provider.GetService<IVoiceToolInvoker>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverEnabled_ShouldRegisterFailoverFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<FailoverLLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceOpenAIConfigured_ShouldRegisterVoicePresenceModuleFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n services.AddSingleton<IActorDispatchPort, NoOpActorDispatchPort>();\n services.AddSingleton<IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>>(\n new EmptyVoicePresenceCapabilityReader());\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n options.VoicePresence.OpenAISession = new VoiceSessionConfig\n {\n Voice = \"alloy\",\n SampleRateHz = 24000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n provider.GetRequiredService<IVoiceToolCatalog>()\n .Should().BeOfType<AgentToolVoiceCatalog>();\n provider.GetRequiredService<IVoicePresenceSessionResolver>()\n .Should().BeOfType<ActorOwnedVoicePresenceSessionResolver>();\n provider.GetRequiredService<IVoicePresenceCapabilityQueryPort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoicePresenceSessionLeasePort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoicePresenceTransportAttachmentPort>()\n .Should().BeOfType<UnavailableVoicePresenceTransportAttachmentPort>();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeTrue();\n openAIModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm\", out var miniCpmModule).Should().BeFalse();\n miniCpmModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceOpenAIEndpointOnlyConfigured_ShouldNotRegisterOpenAI()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"OPENAI_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n services.AddSingleton<IActorDispatchPort, NoOpActorDispatchPort>();\n services.AddSingleton<IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>>(\n new EmptyVoicePresenceCapabilityReader());\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.DefaultProvider = \"minicpm\";\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n Endpoint = \"https://nyx.example.com/api/v1/proxy/s/llm-openai\",\n };\n options.VoicePresence.OpenAISession = new VoiceSessionConfig\n {\n Voice = \"alloy\",\n SampleRateHz = 24000,\n };\n options.VoicePresence.MiniCPMProvider = new VoiceProviderConfig\n {\n ProviderName = \"minicpm\",\n Endpoint = \"https://minicpm.example.com\",\n };\n options.VoicePresence.MiniCPMSession = new VoiceSessionConfig\n {\n SampleRateHz = 16000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeFalse();\n openAIModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceMiniCpmConfiguredAsDefault_ShouldCreateDefaultAlias()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"OPENAI_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.DefaultProvider = \"minicpm-o\";\n options.VoicePresence.MiniCPMProvider = new VoiceProviderConfig\n {\n ProviderName = \"minicpm-o\",\n Endpoint = \"https://minicpm.example.com\",\n };\n options.VoicePresence.MiniCPMSession = new VoiceSessionConfig\n {\n SampleRateHz = 16000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm_o\", out var miniCpmModule).Should().BeTrue();\n miniCpmModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeFalse();\n openAIModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverDisabled_ShouldRegisterMEAIFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<MEAILLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenReloadableFactoryEnabled_ShouldPickUpdatedSecrets()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-reload-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:openai:ApiKey\"] = \"openai-key\",\n [\"LLMProviders:Providers:openai:ProviderType\"] = \"openai\",\n [\"LLMProviders:Providers:openai:Model\"] = \"gpt-5.4\",\n [\"LLMProviders:Default\"] = \"openai\",\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.EnableReloadableProviderFactory = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"openai\");\n\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Providers:deepseek:Model\"] = \"deepseek-chat\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyNyxIdProviderConfigured_ShouldRegisterNyxIdDefaultProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n [\"LLMProviders:Default\"] = \"nyxid\",\n });\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"nyxid\");\n llmFactory.GetDefault().Name.Should().Be(\"nyxid\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSecretsStoreOptionAbsent_ShouldUseDIRegisteredStore()\n {\n // Mainnet path: a host registers IAevatarSecretsStore (e.g. the\n // read-only EnvironmentSecretsStore) into DI but does not pass\n // options.SecretsStore. Before the fix, AddAevatarAIFeatures fell\n // back to `new AevatarSecretsStore()` which re-opens secrets.json\n // from disk. This asserts that the DI-registered store is honored.\n var diRegistered = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"from-di-registered-store\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n var services = new ServiceCollection();\n services.AddSingleton<IAevatarSecretsStore>(diRegistered);\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n // intentionally leave options.SecretsStore = null\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder();\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldSupportRemoteUrlConfiguration()\n {\n var builder = new MCPConnectorBuilder();\n var entry = new ConnectorConfigEntry\n {\n Name = \"nyxid_mcp\",\n Type = \"mcp\",\n TimeoutMs = 15000,\n MCP = new MCPConnectorConfig\n {\n ServerName = \"nyxid\",\n Url = \"https://nyxid.example.com/mcp\",\n AdditionalHeaders = new Dictionary<string, string> { [\"x-tenant\"] = \"demo\" },\n DefaultTool = \"chrono-graph__query\",\n AllowedTools = [\"chrono-graph__query\"],\n },\n };\n\n var ok = builder.TryBuild(entry, NullLogger.Instance, out var connector);\n\n ok.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"nyxid_mcp\");\n\n var serverConfigField = connector.GetType().GetField(\"_serverConfig\", BindingFlags.Instance | BindingFlags.NonPublic);\n serverConfigField.Should().NotBeNull();\n var serverConfig = serverConfigField!.GetValue(connector).Should().BeOfType<MCPServerConfig>().Subject;\n serverConfig.Url.Should().Be(\"https://nyxid.example.com/mcp\");\n serverConfig.HttpClient.Should().NotBeNull();\n serverConfig.HttpClient!.Timeout.Should().Be(Timeout.InfiniteTimeSpan);\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, configuration, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n\n private static void WriteFlatSecrets(string path, IReadOnlyDictionary<string, string> values)\n {\n var directory = Path.GetDirectoryName(path);\n if (!string.IsNullOrWhiteSpace(directory))\n Directory.CreateDirectory(directory);\n\n var json = JsonSerializer.Serialize(values);\n File.WriteAllText(path, json);\n File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddSeconds(1));\n }\n\n private sealed class NoOpActorDispatchPort : IActorDispatchPort\n {\n public Task<DispatchAdmission> DispatchAsync(string actorId, EventEnvelope envelope, CancellationToken ct = default) =>\n Task.FromResult(DispatchAdmissionFactory.Create(actorId, envelope));\n }\n\n private sealed class EmptyVoicePresenceCapabilityReader\n : IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>\n {\n public Task<VoicePresenceCapabilityReadModel?> GetAsync(string key, CancellationToken ct = default) =>\n Task.FromResult<VoicePresenceCapabilityReadModel?>(null);\n\n public Task<ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>> QueryAsync(\n ProjectionDocumentQuery query,\n CancellationToken ct = default) =>\n Task.FromResult(ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>.Empty);\n }\n}\n"}, "9856c626d322dc1b8906b4711d1da0b2cd291579": {"t": 1785756609.8792086, "n": 27457, "h": "f13e00da414b6a7f", "c": "using System.Collections;\nusing System.Net.Http;\nusing System.Reflection;\nusing System.Text.Json;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\n[Collection(ProcessEnvSerialCollection.Name)]\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n var configuration = new ConfigurationBuilder().Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void ReadConfiguredProviders_WhenNyxIdAuthorityConfigured_ShouldResolveGatewayEndpoint()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"gpt-5.4\",\n DefaultProvider = \"nyxid\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n });\n var configuration = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"nyxid\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://nyx.example.com/api/v1/llm/gateway/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableMEAIProviders = true;\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n provider.GetService<IVoiceToolInvoker>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverEnabled_ShouldRegisterFailoverFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<FailoverLLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceOpenAIConfigured_ShouldRegisterVoicePresenceModuleFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n services.AddSingleton<IActorDispatchPort, NoOpActorDispatchPort>();\n services.AddSingleton<IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>>(\n new EmptyVoicePresenceCapabilityReader());\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n options.VoicePresence.OpenAISession = new VoiceSessionConfig\n {\n Voice = \"alloy\",\n SampleRateHz = 24000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n provider.GetRequiredService<IVoiceToolCatalog>()\n .Should().BeOfType<AgentToolVoiceCatalog>();\n provider.GetRequiredService<IVoicePresenceSessionResolver>()\n .Should().BeOfType<ActorOwnedVoicePresenceSessionResolver>();\n provider.GetRequiredService<IVoicePresenceCapabilityQueryPort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoicePresenceSessionLeasePort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoicePresenceTransportAttachmentPort>()\n .Should().BeOfType<UnavailableVoicePresenceTransportAttachmentPort>();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeTrue();\n openAIModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm\", out var miniCpmModule).Should().BeFalse();\n miniCpmModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceMiniCpmConfiguredAsDefault_ShouldCreateDefaultAlias()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"OPENAI_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.DefaultProvider = \"minicpm-o\";\n options.VoicePresence.MiniCPMProvider = new VoiceProviderConfig\n {\n ProviderName = \"minicpm-o\",\n Endpoint = \"https://minicpm.example.com\",\n };\n options.VoicePresence.MiniCPMSession = new VoiceSessionConfig\n {\n SampleRateHz = 16000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm_o\", out var miniCpmModule).Should().BeTrue();\n miniCpmModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeFalse();\n openAIModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverDisabled_ShouldRegisterMEAIFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<MEAILLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenReloadableFactoryEnabled_ShouldPickUpdatedSecrets()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-reload-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:openai:ApiKey\"] = \"openai-key\",\n [\"LLMProviders:Providers:openai:ProviderType\"] = \"openai\",\n [\"LLMProviders:Providers:openai:Model\"] = \"gpt-5.4\",\n [\"LLMProviders:Default\"] = \"openai\",\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.EnableReloadableProviderFactory = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"openai\");\n\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Providers:deepseek:Model\"] = \"deepseek-chat\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyNyxIdProviderConfigured_ShouldRegisterNyxIdDefaultProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n [\"LLMProviders:Default\"] = \"nyxid\",\n });\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"nyxid\");\n llmFactory.GetDefault().Name.Should().Be(\"nyxid\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSecretsStoreOptionAbsent_ShouldUseDIRegisteredStore()\n {\n // Mainnet path: a host registers IAevatarSecretsStore (e.g. the\n // read-only EnvironmentSecretsStore) into DI but does not pass\n // options.SecretsStore. Before the fix, AddAevatarAIFeatures fell\n // back to `new AevatarSecretsStore()` which re-opens secrets.json\n // from disk. This asserts that the DI-registered store is honored.\n var diRegistered = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"from-di-registered-store\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n var services = new ServiceCollection();\n services.AddSingleton<IAevatarSecretsStore>(diRegistered);\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n // intentionally leave options.SecretsStore = null\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder();\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldSupportRemoteUrlConfiguration()\n {\n var builder = new MCPConnectorBuilder();\n var entry = new ConnectorConfigEntry\n {\n Name = \"nyxid_mcp\",\n Type = \"mcp\",\n TimeoutMs = 15000,\n MCP = new MCPConnectorConfig\n {\n ServerName = \"nyxid\",\n Url = \"https://nyxid.example.com/mcp\",\n AdditionalHeaders = new Dictionary<string, string> { [\"x-tenant\"] = \"demo\" },\n DefaultTool = \"chrono-graph__query\",\n AllowedTools = [\"chrono-graph__query\"],\n },\n };\n\n var ok = builder.TryBuild(entry, NullLogger.Instance, out var connector);\n\n ok.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"nyxid_mcp\");\n\n var serverConfigField = connector.GetType().GetField(\"_serverConfig\", BindingFlags.Instance | BindingFlags.NonPublic);\n serverConfigField.Should().NotBeNull();\n var serverConfig = serverConfigField!.GetValue(connector).Should().BeOfType<MCPServerConfig>().Subject;\n serverConfig.Url.Should().Be(\"https://nyxid.example.com/mcp\");\n serverConfig.HttpClient.Should().NotBeNull();\n serverConfig.HttpClient!.Timeout.Should().Be(Timeout.InfiniteTimeSpan);\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, configuration, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n\n private static void WriteFlatSecrets(string path, IReadOnlyDictionary<string, string> values)\n {\n var directory = Path.GetDirectoryName(path);\n if (!string.IsNullOrWhiteSpace(directory))\n Directory.CreateDirectory(directory);\n\n var json = JsonSerializer.Serialize(values);\n File.WriteAllText(path, json);\n File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddSeconds(1));\n }\n\n private sealed class NoOpActorDispatchPort : IActorDispatchPort\n {\n public Task<DispatchAdmission> DispatchAsync(string actorId, EventEnvelope envelope, CancellationToken ct = default) =>\n Task.FromResult(DispatchAdmissionFactory.Create(actorId, envelope));\n }\n\n private sealed class EmptyVoicePresenceCapabilityReader\n : IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>\n {\n public Task<VoicePresenceCapabilityReadModel?> GetAsync(string key, CancellationToken ct = default) =>\n Task.FromResult<VoicePresenceCapabilityReadModel?>(null);\n\n public Task<ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>> QueryAsync(\n ProjectionDocumentQuery query,\n CancellationToken ct = default) =>\n Task.FromResult(ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>.Empty);\n }\n}\n"}, "f4ee1d491a1ba25c9869cbd9cc976235c3443550": {"t": 1785756612.4578905, "n": 27389, "h": "b3104eda1743c67c", "c": "using System.Collections;\nusing System.Net.Http;\nusing System.Reflection;\nusing System.Text.Json;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\n[Collection(ProcessEnvSerialCollection.Name)]\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n var configuration = new ConfigurationBuilder().Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void ReadConfiguredProviders_WhenNyxIdAuthorityConfigured_ShouldResolveGatewayEndpoint()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"gpt-5.4\",\n DefaultProvider = \"nyxid\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n });\n var configuration = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"nyxid\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://nyx.example.com/api/v1/llm/gateway/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableMEAIProviders = true;\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n provider.GetService<IVoiceToolInvoker>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverEnabled_ShouldRegisterFailoverFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<FailoverLLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceOpenAIConfigured_ShouldRegisterVoicePresenceModuleFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n services.AddSingleton<IActorDispatchPort, NoOpActorDispatchPort>();\n services.AddSingleton<IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>>(\n new EmptyVoicePresenceCapabilityReader());\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n options.VoicePresence.OpenAISession = new VoiceSessionConfig\n {\n Voice = \"alloy\",\n SampleRateHz = 24000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n provider.GetRequiredService<IVoiceToolCatalog>()\n .Should().BeOfType<AgentToolVoiceCatalog>();\n provider.GetRequiredService<IVoicePresenceSessionResolver>()\n .Should().BeOfType<ActorOwnedVoicePresenceSessionResolver>();\n provider.GetRequiredService<IVoicePresenceCapabilityQueryPort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoicePresenceSessionLeasePort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoicePresenceTransportAttachmentPort>()\n .Should().BeOfType<UnavailableVoicePresenceTransportAttachmentPort>();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeTrue();\n openAIModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm\", out var miniCpmModule).Should().BeFalse();\n miniCpmModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceMiniCpmConfiguredAsDefault_ShouldCreateDefaultAlias()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"OPENAI_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.DefaultProvider = \"minicpm-o\";\n options.VoicePresence.MiniCPMProvider = new VoiceProviderConfig\n {\n ProviderName = \"minicpm-o\",\n Endpoint = \"https://minicpm.example.com\",\n };\n options.VoicePresence.MiniCPMSession = new VoiceSessionConfig\n {\n SampleRateHz = 16000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm_o\", out var miniCpmModule).Should().BeTrue();\n miniCpmModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeFalse();\n openAIModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverDisabled_ShouldRegisterMEAIFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<MEAILLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenReloadableFactoryEnabled_ShouldPickUpdatedSecrets()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-reload-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:openai:ApiKey\"] = \"openai-key\",\n [\"LLMProviders:Providers:openai:ProviderType\"] = \"openai\",\n [\"LLMProviders:Providers:openai:Model\"] = \"gpt-5.4\",\n [\"LLMProviders:Default\"] = \"openai\",\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.EnableReloadableProviderFactory = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"openai\");\n\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Providers:deepseek:Model\"] = \"deepseek-chat\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyNyxIdProviderConfigured_ShouldRegisterNyxIdDefaultProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n [\"LLMProviders:Default\"] = \"nyxid\",\n });\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"nyxid\");\n llmFactory.GetDefault().Name.Should().Be(\"nyxid\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSecretsStoreOptionAbsent_ShouldUseDIRegisteredStore()\n {\n // Mainnet path: a host registers IAevatarSecretsStore (e.g. the\n // read-only EnvironmentSecretsStore) into DI but does not pass\n // options.SecretsStore. Before the fix, AddAevatarAIFeatures fell\n // back to `new AevatarSecretsStore()` which re-opens secrets.json\n // from disk. This asserts that the DI-registered store is honored.\n var diRegistered = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"from-di-registered-store\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n var services = new ServiceCollection();\n services.AddSingleton<IAevatarSecretsStore>(diRegistered);\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n // intentionally leave options.SecretsStore = null\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder();\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldSupportRemoteUrlConfiguration()\n {\n var builder = new MCPConnectorBuilder();\n var entry = new ConnectorConfigEntry\n {\n Name = \"nyxid_mcp\",\n Type = \"mcp\",\n TimeoutMs = 15000,\n MCP = new MCPConnectorConfig\n {\n ServerName = \"nyxid\",\n Url = \"https://nyxid.example.com/mcp\",\n AdditionalHeaders = new Dictionary<string, string> { [\"x-tenant\"] = \"demo\" },\n DefaultTool = \"chrono-graph__query\",\n AllowedTools = [\"chrono-graph__query\"],\n },\n };\n\n var ok = builder.TryBuild(entry, NullLogger.Instance, out var connector);\n\n ok.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"nyxid_mcp\");\n\n var serverConfigField = connector.GetType().GetField(\"_serverConfig\", BindingFlags.Instance | BindingFlags.NonPublic);\n serverConfigField.Should().NotBeNull();\n var serverConfig = serverConfigField!.GetValue(connector).Should().BeOfType<MCPServerConfig>().Subject;\n serverConfig.Url.Should().Be(\"https://nyxid.example.com/mcp\");\n serverConfig.HttpClient.Should().NotBeNull();\n serverConfig.HttpClient!.Timeout.Should().Be(Timeout.InfiniteTimeSpan);\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, configuration, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n\n private static void WriteFlatSecrets(string path, IReadOnlyDictionary<string, string> values)\n {\n var directory = Path.GetDirectoryName(path);\n if (!string.IsNullOrWhiteSpace(directory))\n Directory.CreateDirectory(directory);\n\n var json = JsonSerializer.Serialize(values);\n File.WriteAllText(path, json);\n File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddSeconds(1));\n }\n\n private sealed class NoOpActorDispatchPort : IActorDispatchPort\n {\n public Task DispatchAsync(string actorId, EventEnvelope envelope, CancellationToken ct = default) =>\n Task.CompletedTask;\n }\n\n private sealed class EmptyVoicePresenceCapabilityReader\n : IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>\n {\n public Task<VoicePresenceCapabilityReadModel?> GetAsync(string key, CancellationToken ct = default) =>\n Task.FromResult<VoicePresenceCapabilityReadModel?>(null);\n\n public Task<ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>> QueryAsync(\n ProjectionDocumentQuery query,\n CancellationToken ct = default) =>\n Task.FromResult(ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>.Empty);\n }\n}\n"}, "afef2ffc7dda9dcf4f4ccb508082c9c5d83b8660": {"t": 1785756613.7918704, "n": 27343, "h": "eada6d9ca7e8e0e3", "c": "using System.Collections;\nusing System.Net.Http;\nusing System.Reflection;\nusing System.Text.Json;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n var configuration = new ConfigurationBuilder().Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void ReadConfiguredProviders_WhenNyxIdAuthorityConfigured_ShouldResolveGatewayEndpoint()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"gpt-5.4\",\n DefaultProvider = \"nyxid\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n });\n var configuration = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"nyxid\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://nyx.example.com/api/v1/llm/gateway/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableMEAIProviders = true;\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n provider.GetService<IVoiceToolInvoker>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverEnabled_ShouldRegisterFailoverFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<FailoverLLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceOpenAIConfigured_ShouldRegisterVoicePresenceModuleFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n services.AddSingleton<IActorDispatchPort, NoOpActorDispatchPort>();\n services.AddSingleton<IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>>(\n new EmptyVoicePresenceCapabilityReader());\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n options.VoicePresence.OpenAISession = new VoiceSessionConfig\n {\n Voice = \"alloy\",\n SampleRateHz = 24000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n provider.GetRequiredService<IVoiceToolCatalog>()\n .Should().BeOfType<AgentToolVoiceCatalog>();\n provider.GetRequiredService<IVoicePresenceSessionResolver>()\n .Should().BeOfType<ActorOwnedVoicePresenceSessionResolver>();\n provider.GetRequiredService<IVoicePresenceCapabilityQueryPort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoicePresenceSessionLeasePort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoicePresenceTransportAttachmentPort>()\n .Should().BeOfType<UnavailableVoicePresenceTransportAttachmentPort>();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeTrue();\n openAIModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm\", out var miniCpmModule).Should().BeFalse();\n miniCpmModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceMiniCpmConfiguredAsDefault_ShouldCreateDefaultAlias()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"OPENAI_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.DefaultProvider = \"minicpm-o\";\n options.VoicePresence.MiniCPMProvider = new VoiceProviderConfig\n {\n ProviderName = \"minicpm-o\",\n Endpoint = \"https://minicpm.example.com\",\n };\n options.VoicePresence.MiniCPMSession = new VoiceSessionConfig\n {\n SampleRateHz = 16000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm_o\", out var miniCpmModule).Should().BeTrue();\n miniCpmModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeFalse();\n openAIModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverDisabled_ShouldRegisterMEAIFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<MEAILLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenReloadableFactoryEnabled_ShouldPickUpdatedSecrets()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-reload-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:openai:ApiKey\"] = \"openai-key\",\n [\"LLMProviders:Providers:openai:ProviderType\"] = \"openai\",\n [\"LLMProviders:Providers:openai:Model\"] = \"gpt-5.4\",\n [\"LLMProviders:Default\"] = \"openai\",\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.EnableReloadableProviderFactory = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"openai\");\n\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Providers:deepseek:Model\"] = \"deepseek-chat\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyNyxIdProviderConfigured_ShouldRegisterNyxIdDefaultProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n [\"LLMProviders:Default\"] = \"nyxid\",\n });\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"nyxid\");\n llmFactory.GetDefault().Name.Should().Be(\"nyxid\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSecretsStoreOptionAbsent_ShouldUseDIRegisteredStore()\n {\n // Mainnet path: a host registers IAevatarSecretsStore (e.g. the\n // read-only EnvironmentSecretsStore) into DI but does not pass\n // options.SecretsStore. Before the fix, AddAevatarAIFeatures fell\n // back to `new AevatarSecretsStore()` which re-opens secrets.json\n // from disk. This asserts that the DI-registered store is honored.\n var diRegistered = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"from-di-registered-store\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n var services = new ServiceCollection();\n services.AddSingleton<IAevatarSecretsStore>(diRegistered);\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n // intentionally leave options.SecretsStore = null\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder();\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldSupportRemoteUrlConfiguration()\n {\n var builder = new MCPConnectorBuilder();\n var entry = new ConnectorConfigEntry\n {\n Name = \"nyxid_mcp\",\n Type = \"mcp\",\n TimeoutMs = 15000,\n MCP = new MCPConnectorConfig\n {\n ServerName = \"nyxid\",\n Url = \"https://nyxid.example.com/mcp\",\n AdditionalHeaders = new Dictionary<string, string> { [\"x-tenant\"] = \"demo\" },\n DefaultTool = \"chrono-graph__query\",\n AllowedTools = [\"chrono-graph__query\"],\n },\n };\n\n var ok = builder.TryBuild(entry, NullLogger.Instance, out var connector);\n\n ok.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"nyxid_mcp\");\n\n var serverConfigField = connector.GetType().GetField(\"_serverConfig\", BindingFlags.Instance | BindingFlags.NonPublic);\n serverConfigField.Should().NotBeNull();\n var serverConfig = serverConfigField!.GetValue(connector).Should().BeOfType<MCPServerConfig>().Subject;\n serverConfig.Url.Should().Be(\"https://nyxid.example.com/mcp\");\n serverConfig.HttpClient.Should().NotBeNull();\n serverConfig.HttpClient!.Timeout.Should().Be(Timeout.InfiniteTimeSpan);\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, configuration, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n\n private static void WriteFlatSecrets(string path, IReadOnlyDictionary<string, string> values)\n {\n var directory = Path.GetDirectoryName(path);\n if (!string.IsNullOrWhiteSpace(directory))\n Directory.CreateDirectory(directory);\n\n var json = JsonSerializer.Serialize(values);\n File.WriteAllText(path, json);\n File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddSeconds(1));\n }\n\n private sealed class NoOpActorDispatchPort : IActorDispatchPort\n {\n public Task DispatchAsync(string actorId, EventEnvelope envelope, CancellationToken ct = default) =>\n Task.CompletedTask;\n }\n\n private sealed class EmptyVoicePresenceCapabilityReader\n : IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>\n {\n public Task<VoicePresenceCapabilityReadModel?> GetAsync(string key, CancellationToken ct = default) =>\n Task.FromResult<VoicePresenceCapabilityReadModel?>(null);\n\n public Task<ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>> QueryAsync(\n ProjectionDocumentQuery query,\n CancellationToken ct = default) =>\n Task.FromResult(ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>.Empty);\n }\n}\n"}, "3b7e09b3a300e698944dee0b2de57d708a5764fd": {"t": 1785756615.3254368, "n": 25749, "h": "0d94bf1fffa5a766", "c": "using System.Collections;\nusing System.Net.Http;\nusing System.Reflection;\nusing System.Text.Json;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n var configuration = new ConfigurationBuilder().Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void ReadConfiguredProviders_WhenNyxIdAuthorityConfigured_ShouldResolveGatewayEndpoint()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"gpt-5.4\",\n DefaultProvider = \"nyxid\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n });\n var configuration = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"nyxid\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://nyx.example.com/api/v1/llm/gateway/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableMEAIProviders = true;\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n provider.GetService<IVoiceToolInvoker>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverEnabled_ShouldRegisterFailoverFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<FailoverLLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceOpenAIConfigured_ShouldRegisterVoicePresenceModuleFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n options.VoicePresence.OpenAISession = new VoiceSessionConfig\n {\n Voice = \"alloy\",\n SampleRateHz = 24000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n provider.GetRequiredService<IVoiceToolCatalog>()\n .Should().BeOfType<AgentToolVoiceCatalog>();\n provider.GetRequiredService<IVoicePresenceSessionResolver>()\n .Should().BeOfType<InProcessActorVoicePresenceSessionResolver>();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeTrue();\n openAIModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm\", out var miniCpmModule).Should().BeFalse();\n miniCpmModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceMiniCpmConfiguredAsDefault_ShouldCreateDefaultAlias()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"OPENAI_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.DefaultProvider = \"minicpm-o\";\n options.VoicePresence.MiniCPMProvider = new VoiceProviderConfig\n {\n ProviderName = \"minicpm-o\",\n Endpoint = \"https://minicpm.example.com\",\n };\n options.VoicePresence.MiniCPMSession = new VoiceSessionConfig\n {\n SampleRateHz = 16000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm_o\", out var miniCpmModule).Should().BeTrue();\n miniCpmModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeFalse();\n openAIModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverDisabled_ShouldRegisterMEAIFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<MEAILLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenReloadableFactoryEnabled_ShouldPickUpdatedSecrets()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-reload-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:openai:ApiKey\"] = \"openai-key\",\n [\"LLMProviders:Providers:openai:ProviderType\"] = \"openai\",\n [\"LLMProviders:Providers:openai:Model\"] = \"gpt-5.4\",\n [\"LLMProviders:Default\"] = \"openai\",\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.EnableReloadableProviderFactory = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"openai\");\n\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Providers:deepseek:Model\"] = \"deepseek-chat\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyNyxIdProviderConfigured_ShouldRegisterNyxIdDefaultProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n [\"LLMProviders:Default\"] = \"nyxid\",\n });\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"nyxid\");\n llmFactory.GetDefault().Name.Should().Be(\"nyxid\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSecretsStoreOptionAbsent_ShouldUseDIRegisteredStore()\n {\n // Mainnet path: a host registers IAevatarSecretsStore (e.g. the\n // read-only EnvironmentSecretsStore) into DI but does not pass\n // options.SecretsStore. Before the fix, AddAevatarAIFeatures fell\n // back to `new AevatarSecretsStore()` which re-opens secrets.json\n // from disk. This asserts that the DI-registered store is honored.\n var diRegistered = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"from-di-registered-store\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n var services = new ServiceCollection();\n services.AddSingleton<IAevatarSecretsStore>(diRegistered);\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n // intentionally leave options.SecretsStore = null\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder();\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldSupportRemoteUrlConfiguration()\n {\n var builder = new MCPConnectorBuilder();\n var entry = new ConnectorConfigEntry\n {\n Name = \"nyxid_mcp\",\n Type = \"mcp\",\n TimeoutMs = 15000,\n MCP = new MCPConnectorConfig\n {\n ServerName = \"nyxid\",\n Url = \"https://nyxid.example.com/mcp\",\n AdditionalHeaders = new Dictionary<string, string> { [\"x-tenant\"] = \"demo\" },\n DefaultTool = \"chrono-graph__query\",\n AllowedTools = [\"chrono-graph__query\"],\n },\n };\n\n var ok = builder.TryBuild(entry, NullLogger.Instance, out var connector);\n\n ok.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"nyxid_mcp\");\n\n var serverConfigField = connector.GetType().GetField(\"_serverConfig\", BindingFlags.Instance | BindingFlags.NonPublic);\n serverConfigField.Should().NotBeNull();\n var serverConfig = serverConfigField!.GetValue(connector).Should().BeOfType<MCPServerConfig>().Subject;\n serverConfig.Url.Should().Be(\"https://nyxid.example.com/mcp\");\n serverConfig.HttpClient.Should().NotBeNull();\n serverConfig.HttpClient!.Timeout.Should().Be(Timeout.InfiniteTimeSpan);\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, configuration, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n\n private static void WriteFlatSecrets(string path, IReadOnlyDictionary<string, string> values)\n {\n var directory = Path.GetDirectoryName(path);\n if (!string.IsNullOrWhiteSpace(directory))\n Directory.CreateDirectory(directory);\n\n var json = JsonSerializer.Serialize(values);\n File.WriteAllText(path, json);\n File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddSeconds(1));\n }\n}\n"}, "27741f5df5bef02cdf5d2783df70ef67ce2475f7": {"t": 1785756616.750205, "n": 25744, "h": "6821bad54e741f7a", "c": "using System.Collections;\nusing System.Net.Http;\nusing System.Reflection;\nusing System.Text.Json;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n var configuration = new ConfigurationBuilder().Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void ReadConfiguredProviders_WhenNyxIdAuthorityConfigured_ShouldResolveGatewayEndpoint()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"gpt-5.4\",\n DefaultProvider = \"nyxid\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n });\n var configuration = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"nyxid\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://nyx.example.com/api/v1/llm/gateway/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableMEAIProviders = true;\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n provider.GetService<IVoiceToolInvoker>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverEnabled_ShouldRegisterFailoverFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<FailoverLLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceOpenAIConfigured_ShouldRegisterVoicePresenceModuleFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n options.VoicePresence.OpenAISession = new VoiceSessionConfig\n {\n Voice = \"alloy\",\n SampleRateHz = 24000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n provider.GetRequiredService<IVoiceToolCatalog>()\n .Should().BeOfType<AgentToolVoiceCatalog>();\n provider.GetRequiredService<IVoicePresenceSessionResolver>()\n .Should().BeOfType<CompositeVoicePresenceSessionResolver>();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeTrue();\n openAIModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm\", out var miniCpmModule).Should().BeFalse();\n miniCpmModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceMiniCpmConfiguredAsDefault_ShouldCreateDefaultAlias()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"OPENAI_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.DefaultProvider = \"minicpm-o\";\n options.VoicePresence.MiniCPMProvider = new VoiceProviderConfig\n {\n ProviderName = \"minicpm-o\",\n Endpoint = \"https://minicpm.example.com\",\n };\n options.VoicePresence.MiniCPMSession = new VoiceSessionConfig\n {\n SampleRateHz = 16000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm_o\", out var miniCpmModule).Should().BeTrue();\n miniCpmModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeFalse();\n openAIModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverDisabled_ShouldRegisterMEAIFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<MEAILLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenReloadableFactoryEnabled_ShouldPickUpdatedSecrets()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-reload-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:openai:ApiKey\"] = \"openai-key\",\n [\"LLMProviders:Providers:openai:ProviderType\"] = \"openai\",\n [\"LLMProviders:Providers:openai:Model\"] = \"gpt-5.4\",\n [\"LLMProviders:Default\"] = \"openai\",\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.EnableReloadableProviderFactory = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"openai\");\n\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Providers:deepseek:Model\"] = \"deepseek-chat\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyNyxIdProviderConfigured_ShouldRegisterNyxIdDefaultProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n [\"LLMProviders:Default\"] = \"nyxid\",\n });\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"nyxid\");\n llmFactory.GetDefault().Name.Should().Be(\"nyxid\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSecretsStoreOptionAbsent_ShouldUseDIRegisteredStore()\n {\n // Mainnet path: a host registers IAevatarSecretsStore (e.g. the\n // read-only EnvironmentSecretsStore) into DI but does not pass\n // options.SecretsStore. Before the fix, AddAevatarAIFeatures fell\n // back to `new AevatarSecretsStore()` which re-opens secrets.json\n // from disk. This asserts that the DI-registered store is honored.\n var diRegistered = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"from-di-registered-store\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n var services = new ServiceCollection();\n services.AddSingleton<IAevatarSecretsStore>(diRegistered);\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n // intentionally leave options.SecretsStore = null\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder();\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldSupportRemoteUrlConfiguration()\n {\n var builder = new MCPConnectorBuilder();\n var entry = new ConnectorConfigEntry\n {\n Name = \"nyxid_mcp\",\n Type = \"mcp\",\n TimeoutMs = 15000,\n MCP = new MCPConnectorConfig\n {\n ServerName = \"nyxid\",\n Url = \"https://nyxid.example.com/mcp\",\n AdditionalHeaders = new Dictionary<string, string> { [\"x-tenant\"] = \"demo\" },\n DefaultTool = \"chrono-graph__query\",\n AllowedTools = [\"chrono-graph__query\"],\n },\n };\n\n var ok = builder.TryBuild(entry, NullLogger.Instance, out var connector);\n\n ok.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"nyxid_mcp\");\n\n var serverConfigField = connector.GetType().GetField(\"_serverConfig\", BindingFlags.Instance | BindingFlags.NonPublic);\n serverConfigField.Should().NotBeNull();\n var serverConfig = serverConfigField!.GetValue(connector).Should().BeOfType<MCPServerConfig>().Subject;\n serverConfig.Url.Should().Be(\"https://nyxid.example.com/mcp\");\n serverConfig.HttpClient.Should().NotBeNull();\n serverConfig.HttpClient!.Timeout.Should().Be(Timeout.InfiniteTimeSpan);\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, configuration, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n\n private static void WriteFlatSecrets(string path, IReadOnlyDictionary<string, string> values)\n {\n var directory = Path.GetDirectoryName(path);\n if (!string.IsNullOrWhiteSpace(directory))\n Directory.CreateDirectory(directory);\n\n var json = JsonSerializer.Serialize(values);\n File.WriteAllText(path, json);\n File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddSeconds(1));\n }\n}\n"}, "9382d0d648191321283a25fb3e59e856aad23b2a": {"t": 1785756618.192623, "n": 24217, "h": "9a7e5b2dad723c6c", "c": "using System.Collections;\nusing System.Net.Http;\nusing System.Reflection;\nusing System.Text.Json;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n var configuration = new ConfigurationBuilder().Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void ReadConfiguredProviders_WhenNyxIdAuthorityConfigured_ShouldResolveGatewayEndpoint()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"gpt-5.4\",\n DefaultProvider = \"nyxid\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n });\n var configuration = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"nyxid\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://nyx.example.com/api/v1/llm/gateway/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableMEAIProviders = true;\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n provider.GetService<IVoiceToolInvoker>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverEnabled_ShouldRegisterFailoverFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<FailoverLLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceOpenAIConfigured_ShouldRegisterVoicePresenceModuleFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n options.VoicePresence.OpenAISession = new VoiceSessionConfig\n {\n Voice = \"alloy\",\n SampleRateHz = 24000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n provider.GetRequiredService<IVoiceToolCatalog>()\n .Should().BeOfType<AgentToolVoiceCatalog>();\n provider.GetRequiredService<IVoicePresenceSessionResolver>()\n .Should().BeOfType<CompositeVoicePresenceSessionResolver>();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeTrue();\n openAIModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm\", out var miniCpmModule).Should().BeFalse();\n miniCpmModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceMiniCpmConfiguredAsDefault_ShouldCreateDefaultAlias()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"OPENAI_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.DefaultProvider = \"minicpm-o\";\n options.VoicePresence.MiniCPMProvider = new VoiceProviderConfig\n {\n ProviderName = \"minicpm-o\",\n Endpoint = \"https://minicpm.example.com\",\n };\n options.VoicePresence.MiniCPMSession = new VoiceSessionConfig\n {\n SampleRateHz = 16000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm_o\", out var miniCpmModule).Should().BeTrue();\n miniCpmModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeFalse();\n openAIModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverDisabled_ShouldRegisterMEAIFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<MEAILLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenReloadableFactoryEnabled_ShouldPickUpdatedSecrets()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-reload-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:openai:ApiKey\"] = \"openai-key\",\n [\"LLMProviders:Providers:openai:ProviderType\"] = \"openai\",\n [\"LLMProviders:Providers:openai:Model\"] = \"gpt-5.4\",\n [\"LLMProviders:Default\"] = \"openai\",\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.EnableReloadableProviderFactory = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"openai\");\n\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Providers:deepseek:Model\"] = \"deepseek-chat\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyNyxIdProviderConfigured_ShouldRegisterNyxIdDefaultProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n [\"LLMProviders:Default\"] = \"nyxid\",\n });\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"nyxid\");\n llmFactory.GetDefault().Name.Should().Be(\"nyxid\");\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder();\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldSupportRemoteUrlConfiguration()\n {\n var builder = new MCPConnectorBuilder();\n var entry = new ConnectorConfigEntry\n {\n Name = \"nyxid_mcp\",\n Type = \"mcp\",\n TimeoutMs = 15000,\n MCP = new MCPConnectorConfig\n {\n ServerName = \"nyxid\",\n Url = \"https://nyxid.example.com/mcp\",\n AdditionalHeaders = new Dictionary<string, string> { [\"x-tenant\"] = \"demo\" },\n DefaultTool = \"chrono-graph__query\",\n AllowedTools = [\"chrono-graph__query\"],\n },\n };\n\n var ok = builder.TryBuild(entry, NullLogger.Instance, out var connector);\n\n ok.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"nyxid_mcp\");\n\n var serverConfigField = connector.GetType().GetField(\"_serverConfig\", BindingFlags.Instance | BindingFlags.NonPublic);\n serverConfigField.Should().NotBeNull();\n var serverConfig = serverConfigField!.GetValue(connector).Should().BeOfType<MCPServerConfig>().Subject;\n serverConfig.Url.Should().Be(\"https://nyxid.example.com/mcp\");\n serverConfig.HttpClient.Should().NotBeNull();\n serverConfig.HttpClient!.Timeout.Should().Be(Timeout.InfiniteTimeSpan);\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, configuration, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n\n private static void WriteFlatSecrets(string path, IReadOnlyDictionary<string, string> values)\n {\n var directory = Path.GetDirectoryName(path);\n if (!string.IsNullOrWhiteSpace(directory))\n Directory.CreateDirectory(directory);\n\n var json = JsonSerializer.Serialize(values);\n File.WriteAllText(path, json);\n File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddSeconds(1));\n }\n}\n"}, "cd3354be57a817c656ac6853ef8bc13f062eb523": {"t": 1785756619.75155, "n": 24222, "h": "32d919fa6f7976a9", "c": "using System.Collections;\nusing System.Net.Http;\nusing System.Reflection;\nusing System.Text.Json;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n var configuration = new ConfigurationBuilder().Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void ReadConfiguredProviders_WhenNyxIdAuthorityConfigured_ShouldResolveGatewayEndpoint()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"gpt-5.4\",\n DefaultProvider = \"nyxid\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n });\n var configuration = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"nyxid\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://nyx.example.com/api/v1/llm/gateway/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableMEAIProviders = true;\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n provider.GetService<IVoiceToolInvoker>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverEnabled_ShouldRegisterFailoverFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<FailoverLLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceOpenAIConfigured_ShouldRegisterVoicePresenceModuleFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n options.VoicePresence.OpenAISession = new VoiceSessionConfig\n {\n Voice = \"alloy\",\n SampleRateHz = 24000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n provider.GetRequiredService<IVoiceToolCatalog>()\n .Should().BeOfType<AgentToolVoiceCatalog>();\n provider.GetRequiredService<IVoicePresenceSessionResolver>()\n .Should().BeOfType<InProcessActorVoicePresenceSessionResolver>();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeTrue();\n openAIModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm\", out var miniCpmModule).Should().BeFalse();\n miniCpmModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceMiniCpmConfiguredAsDefault_ShouldCreateDefaultAlias()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"OPENAI_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.DefaultProvider = \"minicpm-o\";\n options.VoicePresence.MiniCPMProvider = new VoiceProviderConfig\n {\n ProviderName = \"minicpm-o\",\n Endpoint = \"https://minicpm.example.com\",\n };\n options.VoicePresence.MiniCPMSession = new VoiceSessionConfig\n {\n SampleRateHz = 16000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm_o\", out var miniCpmModule).Should().BeTrue();\n miniCpmModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeFalse();\n openAIModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverDisabled_ShouldRegisterMEAIFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<MEAILLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenReloadableFactoryEnabled_ShouldPickUpdatedSecrets()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-reload-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:openai:ApiKey\"] = \"openai-key\",\n [\"LLMProviders:Providers:openai:ProviderType\"] = \"openai\",\n [\"LLMProviders:Providers:openai:Model\"] = \"gpt-5.4\",\n [\"LLMProviders:Default\"] = \"openai\",\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.EnableReloadableProviderFactory = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"openai\");\n\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Providers:deepseek:Model\"] = \"deepseek-chat\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyNyxIdProviderConfigured_ShouldRegisterNyxIdDefaultProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n [\"LLMProviders:Default\"] = \"nyxid\",\n });\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"nyxid\");\n llmFactory.GetDefault().Name.Should().Be(\"nyxid\");\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder();\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldSupportRemoteUrlConfiguration()\n {\n var builder = new MCPConnectorBuilder();\n var entry = new ConnectorConfigEntry\n {\n Name = \"nyxid_mcp\",\n Type = \"mcp\",\n TimeoutMs = 15000,\n MCP = new MCPConnectorConfig\n {\n ServerName = \"nyxid\",\n Url = \"https://nyxid.example.com/mcp\",\n AdditionalHeaders = new Dictionary<string, string> { [\"x-tenant\"] = \"demo\" },\n DefaultTool = \"chrono-graph__query\",\n AllowedTools = [\"chrono-graph__query\"],\n },\n };\n\n var ok = builder.TryBuild(entry, NullLogger.Instance, out var connector);\n\n ok.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"nyxid_mcp\");\n\n var serverConfigField = connector.GetType().GetField(\"_serverConfig\", BindingFlags.Instance | BindingFlags.NonPublic);\n serverConfigField.Should().NotBeNull();\n var serverConfig = serverConfigField!.GetValue(connector).Should().BeOfType<MCPServerConfig>().Subject;\n serverConfig.Url.Should().Be(\"https://nyxid.example.com/mcp\");\n serverConfig.HttpClient.Should().NotBeNull();\n serverConfig.HttpClient!.Timeout.Should().Be(Timeout.InfiniteTimeSpan);\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, configuration, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n\n private static void WriteFlatSecrets(string path, IReadOnlyDictionary<string, string> values)\n {\n var directory = Path.GetDirectoryName(path);\n if (!string.IsNullOrWhiteSpace(directory))\n Directory.CreateDirectory(directory);\n\n var json = JsonSerializer.Serialize(values);\n File.WriteAllText(path, json);\n File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddSeconds(1));\n }\n}\n"}, "08b936a01b96dd963213e9b2af1cbe25e4a735da": {"t": 1785756621.3143382, "n": 24079, "h": "dcad4dae87c05e2b", "c": "using System.Collections;\nusing System.Net.Http;\nusing System.Reflection;\nusing System.Text.Json;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n var configuration = new ConfigurationBuilder().Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void ReadConfiguredProviders_WhenNyxIdAuthorityConfigured_ShouldResolveGatewayEndpoint()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"gpt-5.4\",\n DefaultProvider = \"nyxid\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n });\n var configuration = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"nyxid\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://nyx.example.com/api/v1/llm/gateway/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableMEAIProviders = true;\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n provider.GetService<IVoiceToolInvoker>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverEnabled_ShouldRegisterFailoverFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<FailoverLLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceOpenAIConfigured_ShouldRegisterVoicePresenceModuleFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n options.VoicePresence.OpenAISession = new VoiceSessionConfig\n {\n Voice = \"alloy\",\n SampleRateHz = 24000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n provider.GetRequiredService<IVoicePresenceSessionResolver>()\n .Should().BeOfType<InProcessActorVoicePresenceSessionResolver>();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeTrue();\n openAIModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm\", out var miniCpmModule).Should().BeFalse();\n miniCpmModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceMiniCpmConfiguredAsDefault_ShouldCreateDefaultAlias()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"OPENAI_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.DefaultProvider = \"minicpm-o\";\n options.VoicePresence.MiniCPMProvider = new VoiceProviderConfig\n {\n ProviderName = \"minicpm-o\",\n Endpoint = \"https://minicpm.example.com\",\n };\n options.VoicePresence.MiniCPMSession = new VoiceSessionConfig\n {\n SampleRateHz = 16000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm_o\", out var miniCpmModule).Should().BeTrue();\n miniCpmModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeFalse();\n openAIModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverDisabled_ShouldRegisterMEAIFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<MEAILLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenReloadableFactoryEnabled_ShouldPickUpdatedSecrets()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-reload-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:openai:ApiKey\"] = \"openai-key\",\n [\"LLMProviders:Providers:openai:ProviderType\"] = \"openai\",\n [\"LLMProviders:Providers:openai:Model\"] = \"gpt-5.4\",\n [\"LLMProviders:Default\"] = \"openai\",\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.EnableReloadableProviderFactory = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"openai\");\n\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Providers:deepseek:Model\"] = \"deepseek-chat\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyNyxIdProviderConfigured_ShouldRegisterNyxIdDefaultProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n [\"LLMProviders:Default\"] = \"nyxid\",\n });\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"nyxid\");\n llmFactory.GetDefault().Name.Should().Be(\"nyxid\");\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder();\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldSupportRemoteUrlConfiguration()\n {\n var builder = new MCPConnectorBuilder();\n var entry = new ConnectorConfigEntry\n {\n Name = \"nyxid_mcp\",\n Type = \"mcp\",\n TimeoutMs = 15000,\n MCP = new MCPConnectorConfig\n {\n ServerName = \"nyxid\",\n Url = \"https://nyxid.example.com/mcp\",\n AdditionalHeaders = new Dictionary<string, string> { [\"x-tenant\"] = \"demo\" },\n DefaultTool = \"chrono-graph__query\",\n AllowedTools = [\"chrono-graph__query\"],\n },\n };\n\n var ok = builder.TryBuild(entry, NullLogger.Instance, out var connector);\n\n ok.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"nyxid_mcp\");\n\n var serverConfigField = connector.GetType().GetField(\"_serverConfig\", BindingFlags.Instance | BindingFlags.NonPublic);\n serverConfigField.Should().NotBeNull();\n var serverConfig = serverConfigField!.GetValue(connector).Should().BeOfType<MCPServerConfig>().Subject;\n serverConfig.Url.Should().Be(\"https://nyxid.example.com/mcp\");\n serverConfig.HttpClient.Should().NotBeNull();\n serverConfig.HttpClient!.Timeout.Should().Be(Timeout.InfiniteTimeSpan);\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, configuration, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n\n private static void WriteFlatSecrets(string path, IReadOnlyDictionary<string, string> values)\n {\n var directory = Path.GetDirectoryName(path);\n if (!string.IsNullOrWhiteSpace(directory))\n Directory.CreateDirectory(directory);\n\n var json = JsonSerializer.Serialize(values);\n File.WriteAllText(path, json);\n File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddSeconds(1));\n }\n}\n"}, "ac6bf366f422f9680baf565622d5d7ddbdb71d51": {"t": 1785756624.2025206, "n": 23884, "h": "1f13df8e9215eff0", "c": "using System.Collections;\nusing System.Net.Http;\nusing System.Reflection;\nusing System.Text.Json;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n var configuration = new ConfigurationBuilder().Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void ReadConfiguredProviders_WhenNyxIdAuthorityConfigured_ShouldResolveGatewayEndpoint()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"gpt-5.4\",\n DefaultProvider = \"nyxid\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n });\n var configuration = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"nyxid\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://nyx.example.com/api/v1/llm/gateway/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableMEAIProviders = true;\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n provider.GetService<IVoiceToolInvoker>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverEnabled_ShouldRegisterFailoverFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<FailoverLLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceOpenAIConfigured_ShouldRegisterVoicePresenceModuleFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n options.VoicePresence.OpenAISession = new VoiceSessionConfig\n {\n Voice = \"alloy\",\n SampleRateHz = 24000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeTrue();\n openAIModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm\", out var miniCpmModule).Should().BeFalse();\n miniCpmModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceMiniCpmConfiguredAsDefault_ShouldCreateDefaultAlias()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"OPENAI_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.DefaultProvider = \"minicpm-o\";\n options.VoicePresence.MiniCPMProvider = new VoiceProviderConfig\n {\n ProviderName = \"minicpm-o\",\n Endpoint = \"https://minicpm.example.com\",\n };\n options.VoicePresence.MiniCPMSession = new VoiceSessionConfig\n {\n SampleRateHz = 16000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm_o\", out var miniCpmModule).Should().BeTrue();\n miniCpmModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeFalse();\n openAIModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverDisabled_ShouldRegisterMEAIFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<MEAILLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenReloadableFactoryEnabled_ShouldPickUpdatedSecrets()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-reload-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:openai:ApiKey\"] = \"openai-key\",\n [\"LLMProviders:Providers:openai:ProviderType\"] = \"openai\",\n [\"LLMProviders:Providers:openai:Model\"] = \"gpt-5.4\",\n [\"LLMProviders:Default\"] = \"openai\",\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.EnableReloadableProviderFactory = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"openai\");\n\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Providers:deepseek:Model\"] = \"deepseek-chat\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyNyxIdProviderConfigured_ShouldRegisterNyxIdDefaultProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n [\"LLMProviders:Default\"] = \"nyxid\",\n });\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"nyxid\");\n llmFactory.GetDefault().Name.Should().Be(\"nyxid\");\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder();\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldSupportRemoteUrlConfiguration()\n {\n var builder = new MCPConnectorBuilder();\n var entry = new ConnectorConfigEntry\n {\n Name = \"nyxid_mcp\",\n Type = \"mcp\",\n TimeoutMs = 15000,\n MCP = new MCPConnectorConfig\n {\n ServerName = \"nyxid\",\n Url = \"https://nyxid.example.com/mcp\",\n AdditionalHeaders = new Dictionary<string, string> { [\"x-tenant\"] = \"demo\" },\n DefaultTool = \"chrono-graph__query\",\n AllowedTools = [\"chrono-graph__query\"],\n },\n };\n\n var ok = builder.TryBuild(entry, NullLogger.Instance, out var connector);\n\n ok.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"nyxid_mcp\");\n\n var serverConfigField = connector.GetType().GetField(\"_serverConfig\", BindingFlags.Instance | BindingFlags.NonPublic);\n serverConfigField.Should().NotBeNull();\n var serverConfig = serverConfigField!.GetValue(connector).Should().BeOfType<MCPServerConfig>().Subject;\n serverConfig.Url.Should().Be(\"https://nyxid.example.com/mcp\");\n serverConfig.HttpClient.Should().NotBeNull();\n serverConfig.HttpClient!.Timeout.Should().Be(Timeout.InfiniteTimeSpan);\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, configuration, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n\n private static void WriteFlatSecrets(string path, IReadOnlyDictionary<string, string> values)\n {\n var directory = Path.GetDirectoryName(path);\n if (!string.IsNullOrWhiteSpace(directory))\n Directory.CreateDirectory(directory);\n\n var json = JsonSerializer.Serialize(values);\n File.WriteAllText(path, json);\n File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddSeconds(1));\n }\n}\n"}, "444ab464859bb1704fd9651a9e6a2881686d9720": {"t": 1785756625.8475747, "n": 20677, "h": "c3c9bce3771ab10e", "c": "using System.Collections;\nusing System.Net.Http;\nusing System.Reflection;\nusing System.Text.Json;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n var configuration = new ConfigurationBuilder().Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void ReadConfiguredProviders_WhenNyxIdAuthorityConfigured_ShouldResolveGatewayEndpoint()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"gpt-5.4\",\n DefaultProvider = \"nyxid\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n });\n var configuration = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"nyxid\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://nyx.example.com/api/v1/llm/gateway/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableMEAIProviders = true;\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n provider.GetService<IVoiceToolInvoker>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverEnabled_ShouldRegisterFailoverFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<FailoverLLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverDisabled_ShouldRegisterMEAIFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<MEAILLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenReloadableFactoryEnabled_ShouldPickUpdatedSecrets()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-reload-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:openai:ApiKey\"] = \"openai-key\",\n [\"LLMProviders:Providers:openai:ProviderType\"] = \"openai\",\n [\"LLMProviders:Providers:openai:Model\"] = \"gpt-5.4\",\n [\"LLMProviders:Default\"] = \"openai\",\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.EnableReloadableProviderFactory = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"openai\");\n\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Providers:deepseek:Model\"] = \"deepseek-chat\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyNyxIdProviderConfigured_ShouldRegisterNyxIdDefaultProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n [\"LLMProviders:Default\"] = \"nyxid\",\n });\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"nyxid\");\n llmFactory.GetDefault().Name.Should().Be(\"nyxid\");\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder();\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldSupportRemoteUrlConfiguration()\n {\n var builder = new MCPConnectorBuilder();\n var entry = new ConnectorConfigEntry\n {\n Name = \"nyxid_mcp\",\n Type = \"mcp\",\n TimeoutMs = 15000,\n MCP = new MCPConnectorConfig\n {\n ServerName = \"nyxid\",\n Url = \"https://nyxid.example.com/mcp\",\n AdditionalHeaders = new Dictionary<string, string> { [\"x-tenant\"] = \"demo\" },\n DefaultTool = \"chrono-graph__query\",\n AllowedTools = [\"chrono-graph__query\"],\n },\n };\n\n var ok = builder.TryBuild(entry, NullLogger.Instance, out var connector);\n\n ok.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"nyxid_mcp\");\n\n var serverConfigField = connector.GetType().GetField(\"_serverConfig\", BindingFlags.Instance | BindingFlags.NonPublic);\n serverConfigField.Should().NotBeNull();\n var serverConfig = serverConfigField!.GetValue(connector).Should().BeOfType<MCPServerConfig>().Subject;\n serverConfig.Url.Should().Be(\"https://nyxid.example.com/mcp\");\n serverConfig.HttpClient.Should().NotBeNull();\n serverConfig.HttpClient!.Timeout.Should().Be(Timeout.InfiniteTimeSpan);\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, configuration, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n\n private static void WriteFlatSecrets(string path, IReadOnlyDictionary<string, string> values)\n {\n var directory = Path.GetDirectoryName(path);\n if (!string.IsNullOrWhiteSpace(directory))\n Directory.CreateDirectory(directory);\n\n var json = JsonSerializer.Serialize(values);\n File.WriteAllText(path, json);\n File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddSeconds(1));\n }\n}\n"}, "8973df6d0faf998caf861cd8a005f8541bb3c1b5": {"t": 1785756627.3166723, "n": 20553, "h": "53ef2cdc8fab3d54", "c": "using System.Collections;\nusing System.Net.Http;\nusing System.Reflection;\nusing System.Text.Json;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n var configuration = new ConfigurationBuilder().Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void ReadConfiguredProviders_WhenNyxIdAuthorityConfigured_ShouldResolveGatewayEndpoint()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"gpt-5.4\",\n DefaultProvider = \"nyxid\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n });\n var configuration = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"nyxid\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://nyx.example.com/api/v1/llm/gateway/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableMEAIProviders = true;\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverEnabled_ShouldRegisterFailoverFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<FailoverLLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverDisabled_ShouldRegisterMEAIFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<MEAILLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenReloadableFactoryEnabled_ShouldPickUpdatedSecrets()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-reload-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:openai:ApiKey\"] = \"openai-key\",\n [\"LLMProviders:Providers:openai:ProviderType\"] = \"openai\",\n [\"LLMProviders:Providers:openai:Model\"] = \"gpt-5.4\",\n [\"LLMProviders:Default\"] = \"openai\",\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.EnableReloadableProviderFactory = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"openai\");\n\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Providers:deepseek:Model\"] = \"deepseek-chat\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyNyxIdProviderConfigured_ShouldRegisterNyxIdDefaultProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n [\"LLMProviders:Default\"] = \"nyxid\",\n });\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"nyxid\");\n llmFactory.GetDefault().Name.Should().Be(\"nyxid\");\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder();\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldSupportRemoteUrlConfiguration()\n {\n var builder = new MCPConnectorBuilder();\n var entry = new ConnectorConfigEntry\n {\n Name = \"nyxid_mcp\",\n Type = \"mcp\",\n TimeoutMs = 15000,\n MCP = new MCPConnectorConfig\n {\n ServerName = \"nyxid\",\n Url = \"https://nyxid.example.com/mcp\",\n AdditionalHeaders = new Dictionary<string, string> { [\"x-tenant\"] = \"demo\" },\n DefaultTool = \"chrono-graph__query\",\n AllowedTools = [\"chrono-graph__query\"],\n },\n };\n\n var ok = builder.TryBuild(entry, NullLogger.Instance, out var connector);\n\n ok.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"nyxid_mcp\");\n\n var serverConfigField = connector.GetType().GetField(\"_serverConfig\", BindingFlags.Instance | BindingFlags.NonPublic);\n serverConfigField.Should().NotBeNull();\n var serverConfig = serverConfigField!.GetValue(connector).Should().BeOfType<MCPServerConfig>().Subject;\n serverConfig.Url.Should().Be(\"https://nyxid.example.com/mcp\");\n serverConfig.HttpClient.Should().NotBeNull();\n serverConfig.HttpClient!.Timeout.Should().Be(Timeout.InfiniteTimeSpan);\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, configuration, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n\n private static void WriteFlatSecrets(string path, IReadOnlyDictionary<string, string> values)\n {\n var directory = Path.GetDirectoryName(path);\n if (!string.IsNullOrWhiteSpace(directory))\n Directory.CreateDirectory(directory);\n\n var json = JsonSerializer.Serialize(values);\n File.WriteAllText(path, json);\n File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddSeconds(1));\n }\n}\n"}, "916b18814fc2cc5854a8f68bc9bb843a0c321aec": {"t": 1785756628.9926505, "n": 20561, "h": "decebe69004a1f04", "c": "using System.Collections;\nusing System.Net.Http;\nusing System.Reflection;\nusing System.Text.Json;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n var configuration = new ConfigurationBuilder().Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void ReadConfiguredProviders_WhenNyxIdAuthorityConfigured_ShouldResolveGatewayEndpoint()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"gpt-4o-mini\",\n DefaultProvider = \"nyxid\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n });\n var configuration = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"nyxid\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://nyx.example.com/api/v1/llm/gateway/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableMEAIProviders = true;\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverEnabled_ShouldRegisterFailoverFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<FailoverLLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverDisabled_ShouldRegisterMEAIFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<MEAILLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenReloadableFactoryEnabled_ShouldPickUpdatedSecrets()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-reload-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:openai:ApiKey\"] = \"openai-key\",\n [\"LLMProviders:Providers:openai:ProviderType\"] = \"openai\",\n [\"LLMProviders:Providers:openai:Model\"] = \"gpt-4o-mini\",\n [\"LLMProviders:Default\"] = \"openai\",\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.EnableReloadableProviderFactory = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"openai\");\n\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Providers:deepseek:Model\"] = \"deepseek-chat\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyNyxIdProviderConfigured_ShouldRegisterNyxIdDefaultProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n [\"LLMProviders:Default\"] = \"nyxid\",\n });\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"nyxid\");\n llmFactory.GetDefault().Name.Should().Be(\"nyxid\");\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder();\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldSupportRemoteUrlConfiguration()\n {\n var builder = new MCPConnectorBuilder();\n var entry = new ConnectorConfigEntry\n {\n Name = \"nyxid_mcp\",\n Type = \"mcp\",\n TimeoutMs = 15000,\n MCP = new MCPConnectorConfig\n {\n ServerName = \"nyxid\",\n Url = \"https://nyxid.example.com/mcp\",\n AdditionalHeaders = new Dictionary<string, string> { [\"x-tenant\"] = \"demo\" },\n DefaultTool = \"chrono-graph__query\",\n AllowedTools = [\"chrono-graph__query\"],\n },\n };\n\n var ok = builder.TryBuild(entry, NullLogger.Instance, out var connector);\n\n ok.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"nyxid_mcp\");\n\n var serverConfigField = connector.GetType().GetField(\"_serverConfig\", BindingFlags.Instance | BindingFlags.NonPublic);\n serverConfigField.Should().NotBeNull();\n var serverConfig = serverConfigField!.GetValue(connector).Should().BeOfType<MCPServerConfig>().Subject;\n serverConfig.Url.Should().Be(\"https://nyxid.example.com/mcp\");\n serverConfig.HttpClient.Should().NotBeNull();\n serverConfig.HttpClient!.Timeout.Should().Be(Timeout.InfiniteTimeSpan);\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, configuration, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n\n private static void WriteFlatSecrets(string path, IReadOnlyDictionary<string, string> values)\n {\n var directory = Path.GetDirectoryName(path);\n if (!string.IsNullOrWhiteSpace(directory))\n Directory.CreateDirectory(directory);\n\n var json = JsonSerializer.Serialize(values);\n File.WriteAllText(path, json);\n File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddSeconds(1));\n }\n}\n"}, "81b5dee5413855ead835b79a90fbef444eaf2c7c": {"t": 1785756630.5216064, "n": 19130, "h": "312442fd63f22ac2", "c": "using System.Collections;\nusing System.Reflection;\nusing System.Text.Json;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n var configuration = new ConfigurationBuilder().Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void ReadConfiguredProviders_WhenNyxIdAuthorityConfigured_ShouldResolveGatewayEndpoint()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"gpt-4o-mini\",\n DefaultProvider = \"nyxid\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n });\n var configuration = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"nyxid\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://nyx.example.com/api/v1/llm/gateway/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableMEAIProviders = true;\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverEnabled_ShouldRegisterFailoverFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<FailoverLLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverDisabled_ShouldRegisterMEAIFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<MEAILLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenReloadableFactoryEnabled_ShouldPickUpdatedSecrets()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-reload-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:openai:ApiKey\"] = \"openai-key\",\n [\"LLMProviders:Providers:openai:ProviderType\"] = \"openai\",\n [\"LLMProviders:Providers:openai:Model\"] = \"gpt-4o-mini\",\n [\"LLMProviders:Default\"] = \"openai\",\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.EnableReloadableProviderFactory = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"openai\");\n\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Providers:deepseek:Model\"] = \"deepseek-chat\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyNyxIdProviderConfigured_ShouldRegisterNyxIdDefaultProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n [\"LLMProviders:Default\"] = \"nyxid\",\n });\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"nyxid\");\n llmFactory.GetDefault().Name.Should().Be(\"nyxid\");\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder();\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, configuration, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n\n private static void WriteFlatSecrets(string path, IReadOnlyDictionary<string, string> values)\n {\n var directory = Path.GetDirectoryName(path);\n if (!string.IsNullOrWhiteSpace(directory))\n Directory.CreateDirectory(directory);\n\n var json = JsonSerializer.Serialize(values);\n File.WriteAllText(path, json);\n File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddSeconds(1));\n }\n}\n"}, "e1f0ca19c85e3ffb10f6dbe43b821dae030c1221": {"t": 1785756631.863005, "n": 19157, "h": "dca66915fe46f4a4", "c": "using System.Collections;\nusing System.Reflection;\nusing System.Text.Json;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n var configuration = new ConfigurationBuilder().Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void ReadConfiguredProviders_WhenNyxIdAuthorityConfigured_ShouldResolveGatewayEndpoint()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"gpt-4o-mini\",\n DefaultProvider = \"nyxid\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyx-main:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyx-main:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyx-main:Model\"] = \"claude-sonnet-4-5-20250929\",\n });\n var configuration = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"nyxid\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://nyx.example.com/api/v1/llm/gateway/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableMEAIProviders = true;\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverEnabled_ShouldRegisterFailoverFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<FailoverLLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverDisabled_ShouldRegisterMEAIFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<MEAILLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenReloadableFactoryEnabled_ShouldPickUpdatedSecrets()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-reload-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:openai:ApiKey\"] = \"openai-key\",\n [\"LLMProviders:Providers:openai:ProviderType\"] = \"openai\",\n [\"LLMProviders:Providers:openai:Model\"] = \"gpt-4o-mini\",\n [\"LLMProviders:Default\"] = \"openai\",\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.EnableReloadableProviderFactory = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"openai\");\n\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Providers:deepseek:Model\"] = \"deepseek-chat\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyNyxIdProviderConfigured_ShouldRegisterNyxIdDefaultProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyx-main:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyx-main:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyx-main:Model\"] = \"claude-sonnet-4-5-20250929\",\n [\"LLMProviders:Default\"] = \"nyx-main\",\n });\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"nyx-main\");\n llmFactory.GetDefault().Name.Should().Be(\"nyx-main\");\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder();\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, configuration, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n\n private static void WriteFlatSecrets(string path, IReadOnlyDictionary<string, string> values)\n {\n var directory = Path.GetDirectoryName(path);\n if (!string.IsNullOrWhiteSpace(directory))\n Directory.CreateDirectory(directory);\n\n var json = JsonSerializer.Serialize(values);\n File.WriteAllText(path, json);\n File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddSeconds(1));\n }\n}\n"}, "e5e5b2885322e7b678ba1ffae8aca4bf9c44c037": {"t": 1785756633.1799362, "n": 16439, "h": "839c83ac87efdffd", "c": "using System.Collections;\nusing System.Reflection;\nusing System.Text.Json;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableMEAIProviders = true;\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverEnabled_ShouldRegisterFailoverFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<FailoverLLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverDisabled_ShouldRegisterMEAIFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<MEAILLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenReloadableFactoryEnabled_ShouldPickUpdatedSecrets()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-reload-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:openai:ApiKey\"] = \"openai-key\",\n [\"LLMProviders:Providers:openai:ProviderType\"] = \"openai\",\n [\"LLMProviders:Providers:openai:Model\"] = \"gpt-4o-mini\",\n [\"LLMProviders:Default\"] = \"openai\",\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.EnableReloadableProviderFactory = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"openai\");\n\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Providers:deepseek:Model\"] = \"deepseek-chat\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder();\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n\n private static void WriteFlatSecrets(string path, IReadOnlyDictionary<string, string> values)\n {\n var directory = Path.GetDirectoryName(path);\n if (!string.IsNullOrWhiteSpace(directory))\n Directory.CreateDirectory(directory);\n\n var json = JsonSerializer.Serialize(values);\n File.WriteAllText(path, json);\n File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddSeconds(1));\n }\n}\n"}, "8f0e3a80e0815c4600f21eb24358a62de80608c8": {"t": 1785756634.5337412, "n": 16313, "h": "0138e98b63e5a0ef", "c": "using System.Collections;\nusing System.Reflection;\nusing System.Text.Json;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.EnableMEAIProviders = true;\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverEnabled_ShouldRegisterFailoverFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<FailoverLLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverDisabled_ShouldRegisterMEAIFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<MEAILLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenReloadableFactoryEnabled_ShouldPickUpdatedSecrets()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-reload-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:openai:ApiKey\"] = \"openai-key\",\n [\"LLMProviders:Providers:openai:ProviderType\"] = \"openai\",\n [\"LLMProviders:Providers:openai:Model\"] = \"gpt-4o-mini\",\n [\"LLMProviders:Default\"] = \"openai\",\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.EnableReloadableProviderFactory = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"openai\");\n\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Providers:deepseek:Model\"] = \"deepseek-chat\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder();\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n\n private static void WriteFlatSecrets(string path, IReadOnlyDictionary<string, string> values)\n {\n var directory = Path.GetDirectoryName(path);\n if (!string.IsNullOrWhiteSpace(directory))\n Directory.CreateDirectory(directory);\n\n var json = JsonSerializer.Serialize(values);\n File.WriteAllText(path, json);\n File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddSeconds(1));\n }\n}\n"}, "e477adb82e461e2aa5194d256341a14eb1f95d53": {"t": 1785756636.2950413, "n": 13614, "h": "165148e9373001e7", "c": "using System.Collections;\nusing System.Reflection;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableMEAIProviders = true;\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverEnabled_ShouldRegisterFailoverFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<FailoverLLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverDisabled_ShouldRegisterMEAIFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<MEAILLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder();\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n}\n"}, "883cd58152387afd772254fd48b58a0687a2074a": {"t": 1785756637.6823804, "n": 13551, "h": "01dd8594f0a62e3c", "c": "using System.Collections;\nusing System.Reflection;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableMEAIProviders = true;\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverEnabled_ShouldRegisterFailoverFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<FailoverLLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverDisabled_ShouldRegisterMEAIFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<MEAILLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder();\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n}\n"}, "625d80ac09fd174b9fe3b1fb218faf6cf1711344": {"t": 1785756638.8896806, "n": 12022, "h": "74918f97837b662a", "c": "using System.Collections;\nusing System.Reflection;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.EnableMEAIProviders = true;\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder();\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n}\n"}, "b71cf1ebb594194e14c6808f866e4f80b016b93e": {"t": 1785756640.117448, "n": 12022, "h": "377b346dbffb14bc", "c": "using System.Collections;\nusing System.Reflection;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableMEAIProviders = true;\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetService<IRoleAgentTypeResolver>().Should().NotBeNull();\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder();\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n}\n"}, "bbd906eb503a126c1a4b6a9ff67952cc819ccdd4": {"t": 1786067475.0562267, "n": 37020, "h": "8dc1f3f3f8d881c3", "c": "using System.Collections;\nusing System.Net.Http;\nusing System.Reflection;\nusing System.Text.Json;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions.TypeSystem;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Workflow.Abstractions;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\n[Collection(ProcessEnvSerialCollection.Name)]\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n var configuration = new ConfigurationBuilder().Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void ReadConfiguredProviders_WhenNyxIdAuthorityConfigured_ShouldResolveGatewayEndpoint()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"gpt-5.4\",\n DefaultProvider = \"nyxid\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n });\n var configuration = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"nyxid\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://nyx.example.com/api/v1/llm/gateway/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableMEAIProviders = true;\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IVoiceToolInvoker>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSystemSkillOverlayEnabledWithSetName_RegistersOrnnProviderWithBoundOptions()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableSystemSkillOverlay = true;\n options.SystemSkillOverlaySetName = \"aevatar-system\";\n options.OrnnNyxIdSlug = \"ornn-api-custom\";\n options.SystemSkillOverlayRefreshTtl = TimeSpan.FromMinutes(15);\n options.SystemSkillOverlayMaxSkills = 32;\n options.SystemSkillOverlayMaxBytes = 32768;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetRequiredService<ISystemSkillOverlayProvider>()\n .Should().BeOfType<Aevatar.AI.ToolProviders.Ornn.SystemSkillOverlay.OrnnSystemSkillOverlayProvider>();\n\n var overlayOptions = provider.GetRequiredService<SystemSkillOverlayOptions>();\n overlayOptions.SetName.Should().Be(\"aevatar-system\");\n overlayOptions.RefreshTtl.Should().Be(TimeSpan.FromMinutes(15));\n overlayOptions.MaxSkills.Should().Be(32);\n overlayOptions.MaxBytes.Should().Be(32768);\n\n // The overlay reads Ornn through the host-configured NyxID proxy slug even when the\n // model-facing Ornn skill tools are not enabled.\n provider.GetRequiredService<Aevatar.AI.ToolProviders.Ornn.OrnnOptions>()\n .NyxIdSlug.Should().Be(\"ornn-api-custom\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSystemSkillOverlayEnabledWithoutSetName_DoesNotRegisterProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableSystemSkillOverlay = true;\n options.SystemSkillOverlaySetName = \" \";\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ISystemSkillOverlayProvider>().Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterWorkflowRolePrimaryKind()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options => options.EnableMEAIProviders = false);\n\n using var provider = services.BuildServiceProvider();\n var registry = provider.GetRequiredService<IAgentKindRegistry>();\n\n var primary = registry.Resolve(WorkflowRoleConventions.DefaultAgentKind);\n var formerAliasFound = registry.TryResolve(\"workflow.assistant-role\", out var formerAlias);\n\n primary.Metadata.Kind.Should().Be(WorkflowRoleConventions.DefaultAgentKind);\n primary.Metadata.ImplementationClrTypeName.Should().Be(typeof(WorkflowRoleGAgent).FullName);\n formerAliasFound.Should().BeFalse();\n formerAlias.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverEnabled_ShouldRegisterFailoverFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = true;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<FailoverLLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceOpenAIConfigured_ShouldRegisterVoicePresenceModuleFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n services.AddSingleton<IActorRuntime, StaticActorRuntime>();\n services.AddSingleton<IActorDispatchPort, NoOpActorDispatchPort>();\n services.AddSingleton<IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>>(\n new EmptyVoicePresenceCapabilityReader());\n AddVoicePresenceTestCredentialResolver(services);\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n options.VoicePresence.OpenAISession = new VoiceSessionConfig\n {\n Voice = \"alloy\",\n SampleRateHz = 24000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n provider.GetRequiredService<IVoiceToolCatalog>()\n .Should().BeOfType<AgentToolVoiceCatalog>();\n provider.GetRequiredService<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>>()\n .Should().BeOfType<ActorOwnedVoiceRealtimeSession>();\n provider.GetRequiredService<IVoicePresenceCapabilityQueryPort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoicePresenceCapabilityCommandPort>()\n .Should().BeOfType<VoicePresenceCapabilityCommandPort>();\n provider.GetRequiredService<IVoicePresenceSessionLeasePort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoiceVolatileMediaStreamPort>()\n .Should().BeOfType<VoiceVolatileMediaStreamPort>();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n defaultModule.As<VoicePresenceModule>()\n .CanHandle(new EventEnvelope\n {\n Payload = Google.Protobuf.WellKnownTypes.Any.Pack(new Google.Protobuf.WellKnownTypes.StringValue\n {\n Value = \"doorbell\",\n }),\n Route = EnvelopeRouteSemantics.CreateDirect(\"device-events.callback\", \"voice-agent\"),\n })\n .Should()\n .BeFalse();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeTrue();\n openAIModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm\", out var miniCpmModule).Should().BeFalse();\n miniCpmModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldPreserveConfiguredVoiceDirectExternalEventTypeUrls()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n services.AddSingleton<IActorDispatchPort, NoOpActorDispatchPort>();\n services.AddSingleton<IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>>(\n new EmptyVoicePresenceCapabilityReader());\n AddVoicePresenceTestCredentialResolver(services);\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.Module = new VoicePresenceModuleOptions\n {\n DirectExternalEventTypeUrls =\n [\n \" type.googleapis.com/example.External \",\n \"type.googleapis.com/example.External\",\n ],\n };\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n\n factory.TryCreate(\"voice_presence\", out var module).Should().BeTrue();\n var voiceModule = module.Should().BeOfType<VoicePresenceModule>().Subject;\n\n voiceModule.CanHandle(new EventEnvelope\n {\n Payload = new Google.Protobuf.WellKnownTypes.Any\n {\n TypeUrl = \"type.googleapis.com/example.External\",\n Value = Google.Protobuf.ByteString.CopyFromUtf8(\"payload\"),\n },\n Route = EnvelopeRouteSemantics.CreateDirect(\"device-events.callback\", \"voice-agent\"),\n }).Should().BeTrue();\n voiceModule.CanHandle(new EventEnvelope\n {\n Payload = new Google.Protobuf.WellKnownTypes.Any\n {\n TypeUrl = \"type.googleapis.com/example.Other\",\n Value = Google.Protobuf.ByteString.CopyFromUtf8(\"payload\"),\n },\n Route = EnvelopeRouteSemantics.CreateDirect(\"device-events.callback\", \"voice-agent\"),\n }).Should().BeFalse();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverDisabled_ShouldRegisterMEAIFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<MEAILLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetRequiredService<IAgentKindRegistry>()\n .Resolve(WorkflowRoleConventions.DefaultAgentKind)\n .Metadata\n .ImplementationClrTypeName\n .Should()\n .Be(typeof(WorkflowRoleGAgent).FullName);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenReloadableFactoryEnabled_ShouldPickUpdatedSecrets()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-reload-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n var previousAllowPlaintext = Environment.GetEnvironmentVariable(LocalSecretProtectionOptions.AllowPlaintextSecretsEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n Environment.SetEnvironmentVariable(LocalSecretProtectionOptions.AllowPlaintextSecretsEnv, \"true\");\n\n try\n {\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:openai:ApiKey\"] = \"openai-key\",\n [\"LLMProviders:Providers:openai:ProviderType\"] = \"openai\",\n [\"LLMProviders:Providers:openai:Model\"] = \"gpt-5.4\",\n [\"LLMProviders:Default\"] = \"openai\",\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.EnableReloadableProviderFactory = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"openai\");\n\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Providers:deepseek:Model\"] = \"deepseek-chat\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Environment.SetEnvironmentVariable(LocalSecretProtectionOptions.AllowPlaintextSecretsEnv, previousAllowPlaintext);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyNyxIdProviderConfigured_ShouldRegisterNyxIdDefaultProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n [\"LLMProviders:Default\"] = \"nyxid\",\n });\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"nyxid\");\n llmFactory.GetDefault().Name.Should().Be(\"nyxid\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSecretsStoreOptionAbsent_ShouldUseDIRegisteredStore()\n {\n // Mainnet path: a host registers IAevatarSecretsStore (e.g. the\n // read-only EnvironmentSecretsStore) into DI but does not pass\n // options.SecretsStore. Before the fix, AddAevatarAIFeatures fell\n // back to `new AevatarSecretsStore()` which re-opens secrets.json\n // from disk. This asserts that the DI-registered store is honored.\n var diRegistered = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"from-di-registered-store\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n var services = new ServiceCollection();\n services.AddSingleton<IAevatarSecretsStore>(diRegistered);\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n // intentionally leave options.SecretsStore = null\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_ShouldRegisterWorkflowToolSourceAdapterForAgentTools()\n {\n var source = new StubAgentToolSource([new StubAgentTool(\"demo_tool\", \"\"\"{\"ok\":true}\"\"\")]);\n var services = new ServiceCollection()\n .AddSingleton<IAgentToolSource>(source);\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options => options.EnableMEAIProviders = false);\n\n await using var provider = services.BuildServiceProvider();\n // Yield capability follows the actor, never the container (#2004): bootstrap must\n // not hand a yielding handler to surfaces without a pending-approval continuation.\n provider.GetService<IToolApprovalHandler>().Should().BeNull();\n provider.GetServices<IToolCallMiddleware>().Should().NotContain(x => x is ToolApprovalMiddleware);\n\n var workflowSource = provider.GetServices<IWorkflowToolSource>()\n .Should()\n .ContainSingle()\n .Subject;\n var tool = (await workflowSource.GetToolsAsync())\n .Should()\n .ContainSingle()\n .Subject;\n\n tool.Name.Should().Be(\"demo_tool\");\n var result = await tool.ExecuteAsync(\n new WorkflowToolExecutionRequest(\n ArgumentsJson: \"{}\",\n RunId: \"run-1\",\n StepId: \"step-1\",\n ExecutionId: \"exec-1\",\n CallId: \"call-1\",\n ScopeId: \"scope-1\",\n CallerCredential: new WorkflowCallerCredential()));\n result.ResultJson.Should().Be(\"\"\"{\"ok\":true}\"\"\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder();\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldSupportRemoteUrlConfiguration()\n {\n var builder = new MCPConnectorBuilder();\n var entry = new ConnectorConfigEntry\n {\n Name = \"nyxid_mcp\",\n Type = \"mcp\",\n TimeoutMs = 15000,\n MCP = new MCPConnectorConfig\n {\n ServerName = \"nyxid\",\n Url = \"https://nyxid.example.com/mcp\",\n AdditionalHeaders = new Dictionary<string, string> { [\"x-tenant\"] = \"demo\" },\n DefaultTool = \"chrono-graph__query\",\n AllowedTools = [\"chrono-graph__query\"],\n },\n };\n\n var ok = builder.TryBuild(entry, NullLogger.Instance, out var connector);\n\n ok.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"nyxid_mcp\");\n\n var serverConfigField = connector.GetType().GetField(\"_serverConfig\", BindingFlags.Instance | BindingFlags.NonPublic);\n serverConfigField.Should().NotBeNull();\n var serverConfig = serverConfigField!.GetValue(connector).Should().BeOfType<MCPServerConfig>().Subject;\n serverConfig.Url.Should().Be(\"https://nyxid.example.com/mcp\");\n serverConfig.HttpClient.Should().NotBeNull();\n serverConfig.HttpClient!.Timeout.Should().Be(Timeout.InfiniteTimeSpan);\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, configuration, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private static void AddVoicePresenceTestCredentialResolver(IServiceCollection services) =>\n services.AddSingleton<IRealtimeProviderCredentialResolver, NoOpRealtimeProviderCredentialResolver>();\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n\n private sealed class StubAgentToolSource(IReadOnlyList<IAgentTool> tools) : IAgentToolSource\n {\n public Task<IReadOnlyList<IAgentTool>> DiscoverToolsAsync(CancellationToken ct = default)\n {\n _ = ct;\n return Task.FromResult(tools);\n }\n }\n\n private sealed class StubAgentTool(string name, string resultJson) : IAgentTool\n {\n public string Name { get; } = name;\n public string Description => \"test tool\";\n public string ParametersSchema => \"\"\"{\"type\":\"object\"}\"\"\";\n\n public Task<string> ExecuteAsync(string argumentsJson, CancellationToken ct = default)\n {\n _ = argumentsJson;\n _ = ct;\n return Task.FromResult(resultJson);\n }\n }\n\n private static void WriteFlatSecrets(string path, IReadOnlyDictionary<string, string> values)\n {\n var directory = Path.GetDirectoryName(path);\n if (!string.IsNullOrWhiteSpace(directory))\n Directory.CreateDirectory(directory);\n\n var json = JsonSerializer.Serialize(values);\n File.WriteAllText(path, json);\n File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddSeconds(1));\n }\n\n private sealed class NoOpActorDispatchPort : IActorDispatchPort\n {\n public Task<DispatchAdmission> DispatchAsync(string actorId, EventEnvelope envelope, CancellationToken ct = default) =>\n Task.FromResult(DispatchAdmissionFactory.Create(actorId, envelope));\n }\n\n private sealed class StaticActorRuntime : IActorRuntime\n {\n public Task<IActor> CreateAsync<TAgent>(string? id = null, CancellationToken ct = default)\n where TAgent : IAgent =>\n throw new NotSupportedException();\n\n public Task<IActor> CreateAsync(Type agentType, string? id = null, CancellationToken ct = default) =>\n throw new NotSupportedException();\n\n public Task DestroyAsync(string id, CancellationToken ct = default) =>\n Task.CompletedTask;\n\n public Task<IActor?> GetAsync(string id) =>\n Task.FromResult<IActor?>(null);\n\n public Task<bool> ExistsAsync(string id) =>\n Task.FromResult(true);\n\n public Task LinkAsync(string parentId, string childId, CancellationToken ct = default) =>\n Task.CompletedTask;\n\n public Task UnlinkAsync(string childId, CancellationToken ct = default) =>\n Task.CompletedTask;\n }\n\n private sealed class EmptyVoicePresenceCapabilityReader\n : IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>\n {\n public Task<VoicePresenceCapabilityReadModel?> GetAsync(string key, CancellationToken ct = default) =>\n Task.FromResult<VoicePresenceCapabilityReadModel?>(null);\n\n public Task<ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>> QueryAsync(\n ProjectionDocumentQuery query,\n CancellationToken ct = default) =>\n Task.FromResult(ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>.Empty);\n }\n\n private sealed class NoOpRealtimeProviderCredentialResolver : IRealtimeProviderCredentialResolver\n {\n public Task<string?> ResolveApiKeyAsync(\n VoiceProviderSessionKey sessionKey,\n VoiceProviderConfig config,\n CancellationToken ct) =>\n Task.FromResult<string?>(null);\n }\n\n}\n"}, "6da4bbe8df605114bb63ccfe6f23383c99a50025": {"t": 1786068324.3274415, "n": 35161, "h": "f919ff0f364eae76", "c": "using System.Collections;\nusing System.Net.Http;\nusing System.Reflection;\nusing System.Text.Json;\nusing Aevatar.AI.Abstractions;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.AI.Abstractions.Agents;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions.TypeSystem;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.Abstractions.Connectors;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Workflow.Abstractions;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing FluentAssertions;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Logging.Abstractions;\n\nnamespace Aevatar.Bootstrap.Tests;\n\n[Collection(ProcessEnvSerialCollection.Name)]\npublic class AIFeatureBootstrapCoverageTests\n{\n [Fact]\n public void ReadConfiguredProviders_WhenProviderTypeMissing_ShouldInferProviderSemanticFromName()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"openai-default\",\n DeepSeekModel = \"deepseek-default\",\n DefaultProvider = \"openai\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n });\n var configuration = new ConfigurationBuilder().Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"Name\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"deepseek\");\n ReadConfiguredProviderString(provider, \"Model\").Should().Be(\"deepseek-default\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://api.deepseek.com/v1\");\n }\n\n [Fact]\n public void ReadConfiguredProviders_WhenNyxIdAuthorityConfigured_ShouldResolveGatewayEndpoint()\n {\n var options = new AevatarAIFeatureOptions\n {\n OpenAIModel = \"gpt-5.4\",\n DefaultProvider = \"nyxid\",\n };\n var secretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n });\n var configuration = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n var configuredProviders = InvokeReadConfiguredProviders(secretsStore, configuration, options);\n\n configuredProviders.Should().ContainSingle();\n var provider = configuredProviders[0];\n ReadConfiguredProviderString(provider, \"ProviderType\").Should().Be(\"nyxid\");\n ReadConfiguredProviderString(provider, \"Endpoint\").Should().Be(\"https://nyx.example.com/api/v1/llm/gateway/v1\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterCoreServicesWithExplicitApiKey()\n {\n var services = new ServiceCollection();\n VoicePresenceBootstrapTests.AddToolExecutionAuditDependencies(services);\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"deepseek\";\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableMEAIProviders = true;\n options.SecretsStore = new InMemorySecretsStore();\n options.EnableSkills = true;\n options.SkillDirectories.Add(\"./skills-a\");\n options.EnableMCPTools = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<IVoiceToolInvoker>().Should().NotBeNull();\n\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n\n var skillOptions = provider.GetRequiredService<SkillsOptions>();\n skillOptions.Directories.Should().ContainSingle().Which.Should().Be(\"./skills-a\");\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is SkillsAgentToolSource);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSystemSkillOverlayEnabledWithSetName_RegistersOrnnProviderWithBoundOptions()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableSystemSkillOverlay = true;\n options.SystemSkillOverlaySetName = \"aevatar-system\";\n options.OrnnNyxIdSlug = \"ornn-api-custom\";\n options.SystemSkillOverlayRefreshTtl = TimeSpan.FromMinutes(15);\n options.SystemSkillOverlayMaxSkills = 32;\n options.SystemSkillOverlayMaxBytes = 32768;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetRequiredService<ISystemSkillOverlayProvider>()\n .Should().BeOfType<Aevatar.AI.ToolProviders.Ornn.SystemSkillOverlay.OrnnSystemSkillOverlayProvider>();\n\n var overlayOptions = provider.GetRequiredService<SystemSkillOverlayOptions>();\n overlayOptions.SetName.Should().Be(\"aevatar-system\");\n overlayOptions.RefreshTtl.Should().Be(TimeSpan.FromMinutes(15));\n overlayOptions.MaxSkills.Should().Be(32);\n overlayOptions.MaxBytes.Should().Be(32768);\n\n // The overlay reads Ornn through the host-configured NyxID proxy slug even when the\n // model-facing Ornn skill tools are not enabled.\n provider.GetRequiredService<Aevatar.AI.ToolProviders.Ornn.OrnnOptions>()\n .NyxIdSlug.Should().Be(\"ornn-api-custom\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSystemSkillOverlayEnabledWithoutSetName_DoesNotRegisterProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableSystemSkillOverlay = true;\n options.SystemSkillOverlaySetName = \" \";\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ISystemSkillOverlayProvider>().Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldRegisterWorkflowRolePrimaryKind()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options => options.EnableMEAIProviders = false);\n\n using var provider = services.BuildServiceProvider();\n var registry = provider.GetRequiredService<IAgentKindRegistry>();\n\n var primary = registry.Resolve(WorkflowRoleConventions.DefaultAgentKind);\n var formerAliasFound = registry.TryResolve(\"workflow.assistant-role\", out var formerAlias);\n\n primary.Metadata.Kind.Should().Be(WorkflowRoleConventions.DefaultAgentKind);\n primary.Metadata.ImplementationClrTypeName.Should().Be(typeof(WorkflowRoleGAgent).FullName);\n formerAliasFound.Should().BeFalse();\n formerAlias.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverEnabled_ShouldRegisterFailoverFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = true;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<FailoverLLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenVoicePresenceOpenAIConfigured_ShouldRegisterVoicePresenceModuleFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n services.AddSingleton<IActorRuntime, StaticActorRuntime>();\n services.AddSingleton<IActorDispatchPort, NoOpActorDispatchPort>();\n services.AddSingleton<IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>>(\n new EmptyVoicePresenceCapabilityReader());\n AddVoicePresenceTestCredentialResolver(services);\n VoicePresenceBootstrapTests.AddToolExecutionAuditDependencies(services);\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n options.VoicePresence.OpenAISession = new VoiceSessionConfig\n {\n Voice = \"alloy\",\n SampleRateHz = 24000,\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n provider.GetRequiredService<IVoiceToolCatalog>()\n .Should().BeOfType<AgentToolVoiceCatalog>();\n provider.GetRequiredService<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>>()\n .Should().BeOfType<ActorOwnedVoiceRealtimeSession>();\n provider.GetRequiredService<IVoicePresenceCapabilityQueryPort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoicePresenceCapabilityCommandPort>()\n .Should().BeOfType<VoicePresenceCapabilityCommandPort>();\n provider.GetRequiredService<IVoicePresenceSessionLeasePort>()\n .Should().NotBeNull();\n provider.GetRequiredService<IVoiceVolatileMediaStreamPort>()\n .Should().BeOfType<VoiceVolatileMediaStreamPort>();\n\n factory.TryCreate(\"voice_presence\", out var defaultModule).Should().BeTrue();\n defaultModule.Should().BeOfType<VoicePresenceModule>();\n defaultModule.As<VoicePresenceModule>()\n .CanHandle(new EventEnvelope\n {\n Payload = Google.Protobuf.WellKnownTypes.Any.Pack(new Google.Protobuf.WellKnownTypes.StringValue\n {\n Value = \"doorbell\",\n }),\n Route = EnvelopeRouteSemantics.CreateDirect(\"device-events.callback\", \"voice-agent\"),\n })\n .Should()\n .BeFalse();\n\n factory.TryCreate(\"voice_presence_openai\", out var openAIModule).Should().BeTrue();\n openAIModule.Should().BeOfType<VoicePresenceModule>();\n\n factory.TryCreate(\"voice_presence_minicpm\", out var miniCpmModule).Should().BeFalse();\n miniCpmModule.Should().BeNull();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_ShouldPreserveConfiguredVoiceDirectExternalEventTypeUrls()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddLogging();\n services.AddSingleton<IActorDispatchPort, NoOpActorDispatchPort>();\n services.AddSingleton<IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>>(\n new EmptyVoicePresenceCapabilityReader());\n AddVoicePresenceTestCredentialResolver(services);\n VoicePresenceBootstrapTests.AddToolExecutionAuditDependencies(services);\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.VoicePresence.Module = new VoicePresenceModuleOptions\n {\n DirectExternalEventTypeUrls =\n [\n \" type.googleapis.com/example.External \",\n \"type.googleapis.com/example.External\",\n ],\n };\n options.VoicePresence.OpenAIProvider = new VoiceProviderConfig\n {\n ProviderName = \"openai\",\n ApiKey = \"voice-openai-key\",\n };\n });\n\n using var provider = services.BuildServiceProvider();\n var factory = provider.GetServices<IEventModuleFactory<IEventHandlerContext>>()\n .OfType<VoicePresenceModuleFactory>()\n .Single();\n\n factory.TryCreate(\"voice_presence\", out var module).Should().BeTrue();\n var voiceModule = module.Should().BeOfType<VoicePresenceModule>().Subject;\n\n voiceModule.CanHandle(new EventEnvelope\n {\n Payload = new Google.Protobuf.WellKnownTypes.Any\n {\n TypeUrl = \"type.googleapis.com/example.External\",\n Value = Google.Protobuf.ByteString.CopyFromUtf8(\"payload\"),\n },\n Route = EnvelopeRouteSemantics.CreateDirect(\"device-events.callback\", \"voice-agent\"),\n }).Should().BeTrue();\n voiceModule.CanHandle(new EventEnvelope\n {\n Payload = new Google.Protobuf.WellKnownTypes.Any\n {\n TypeUrl = \"type.googleapis.com/example.Other\",\n Value = Google.Protobuf.ByteString.CopyFromUtf8(\"payload\"),\n },\n Route = EnvelopeRouteSemantics.CreateDirect(\"device-events.callback\", \"voice-agent\"),\n }).Should().BeFalse();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenFailoverDisabled_ShouldRegisterMEAIFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.ApiKey = \"demo-key\";\n options.DefaultProvider = \"openai\";\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.Should().BeOfType<MEAILLMProviderFactory>();\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyDeepSeekEnvironmentKeyExists_ShouldBindDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = null,\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenDeepSeekAndOpenAIEnvironmentKeysBothExist_ShouldPreferDeepSeekFallbackProvider()\n {\n using var envScope = new EnvironmentVariablesScope(new Dictionary<string, string?>\n {\n [\"DEEPSEEK_API_KEY\"] = \"deepseek-env-key\",\n [\"OPENAI_API_KEY\"] = \"openai-env-key\",\n [\"AEVATAR_LLM_API_KEY\"] = null,\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.DefaultProvider = \"openai\";\n options.SecretsStore = new InMemorySecretsStore();\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenMEAIDisabled_ShouldNotRegisterFactory()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = false;\n options.EnableSkills = false;\n });\n\n using var provider = services.BuildServiceProvider();\n provider.GetService<ILLMProviderFactory>().Should().BeNull();\n provider.GetRequiredService<IAgentKindRegistry>()\n .Resolve(WorkflowRoleConventions.DefaultAgentKind)\n .Metadata\n .ImplementationClrTypeName\n .Should()\n .Be(typeof(WorkflowRoleGAgent).FullName);\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenReloadableFactoryEnabled_ShouldPickUpdatedSecrets()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-reload-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n var previousAllowPlaintext = Environment.GetEnvironmentVariable(LocalSecretProtectionOptions.AllowPlaintextSecretsEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n Environment.SetEnvironmentVariable(LocalSecretProtectionOptions.AllowPlaintextSecretsEnv, \"true\");\n\n try\n {\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:openai:ApiKey\"] = \"openai-key\",\n [\"LLMProviders:Providers:openai:ProviderType\"] = \"openai\",\n [\"LLMProviders:Providers:openai:Model\"] = \"gpt-5.4\",\n [\"LLMProviders:Default\"] = \"openai\",\n });\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.EnableReloadableProviderFactory = true;\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetDefault().Name.Should().Be(\"openai\");\n\n WriteFlatSecrets(\n AevatarPaths.SecretsJson,\n new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"deepseek-key\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Providers:deepseek:Model\"] = \"deepseek-chat\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Environment.SetEnvironmentVariable(LocalSecretProtectionOptions.AllowPlaintextSecretsEnv, previousAllowPlaintext);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenOnlyNyxIdProviderConfigured_ShouldRegisterNyxIdDefaultProvider()\n {\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder()\n .AddInMemoryCollection(new Dictionary<string, string?>\n {\n [\"Cli:App:NyxId:Authority\"] = \"https://nyx.example.com\",\n })\n .Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n options.SecretsStore = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:nyxid:ApiKey\"] = \"nyx-token\",\n [\"LLMProviders:Providers:nyxid:ProviderType\"] = \"nyxid\",\n [\"LLMProviders:Providers:nyxid:Model\"] = \"claude-sonnet-4-5-20250929\",\n [\"LLMProviders:Default\"] = \"nyxid\",\n });\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"nyxid\");\n llmFactory.GetDefault().Name.Should().Be(\"nyxid\");\n }\n\n [Fact]\n public void AddAevatarAIFeatures_WhenSecretsStoreOptionAbsent_ShouldUseDIRegisteredStore()\n {\n // Mainnet path: a host registers IAevatarSecretsStore (e.g. the\n // read-only EnvironmentSecretsStore) into DI but does not pass\n // options.SecretsStore. Before the fix, AddAevatarAIFeatures fell\n // back to `new AevatarSecretsStore()` which re-opens secrets.json\n // from disk. This asserts that the DI-registered store is honored.\n var diRegistered = new InMemorySecretsStore(new Dictionary<string, string>\n {\n [\"LLMProviders:Providers:deepseek:ApiKey\"] = \"from-di-registered-store\",\n [\"LLMProviders:Providers:deepseek:ProviderType\"] = \"deepseek\",\n [\"LLMProviders:Default\"] = \"deepseek\",\n });\n var services = new ServiceCollection();\n services.AddSingleton<IAevatarSecretsStore>(diRegistered);\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = true;\n options.EnableMEAIToTornadoFailover = false;\n // intentionally leave options.SecretsStore = null\n });\n\n using var provider = services.BuildServiceProvider();\n var llmFactory = provider.GetRequiredService<ILLMProviderFactory>();\n llmFactory.GetAvailableProviders().Should().ContainSingle().Which.Should().Be(\"deepseek\");\n llmFactory.GetDefault().Name.Should().Be(\"deepseek\");\n }\n\n [Fact]\n public async Task AddAevatarAIFeatures_WhenMCPEnabledAndConfigured_ShouldRegisterMCPToolSourceAndConnectorBuilder()\n {\n var tempHome = Path.Combine(Path.GetTempPath(), $\"ai-feature-mcp-{Guid.NewGuid():N}\");\n Directory.CreateDirectory(tempHome);\n var previousHome = Environment.GetEnvironmentVariable(AevatarPaths.HomeEnv);\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, tempHome);\n\n try\n {\n File.WriteAllText(Path.Combine(tempHome, \"mcp.json\"),\n \"\"\"\n {\n \"mcpServers\": {\n \"local\": {\n \"command\": \"echo\",\n \"args\": [\"hello\"],\n \"env\": { \"K\": \"V\" }\n }\n }\n }\n \"\"\");\n\n var services = new ServiceCollection();\n var config = new ConfigurationBuilder().Build();\n\n services.AddAevatarAIFeatures(config, options =>\n {\n options.EnableMEAIProviders = false;\n options.EnableMCPTools = true;\n options.EnableSkills = false;\n });\n\n await using var provider = services.BuildServiceProvider();\n\n provider.GetServices<IAgentToolSource>().Should().ContainSingle(x => x is MCPAgentToolSource);\n provider.GetServices<IConnectorBuilder>().Should().ContainSingle(x => x is MCPConnectorBuilder);\n\n var mcpOptions = provider.GetRequiredService<MCPToolsOptions>();\n mcpOptions.Servers.Should().ContainSingle();\n mcpOptions.Servers[0].Name.Should().Be(\"local\");\n mcpOptions.Servers[0].Environment[\"K\"].Should().Be(\"V\");\n }\n finally\n {\n Environment.SetEnvironmentVariable(AevatarPaths.HomeEnv, previousHome);\n Directory.Delete(tempHome, recursive: true);\n }\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldValidateCommandAndBuildConnector()\n {\n var builder = new MCPConnectorBuilder(\n new VoicePresenceBootstrapTests.TestHttpClientFactory(),\n new VoicePresenceBootstrapTests.UnusedAgentToolExecutionPort());\n\n var invalidEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-a\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig { Command = \"\" },\n };\n\n var okInvalid = builder.TryBuild(invalidEntry, NullLogger.Instance, out var invalidConnector);\n okInvalid.Should().BeFalse();\n invalidConnector.Should().BeNull();\n\n var validEntry = new ConnectorConfigEntry\n {\n Name = \"mcp-b\",\n Type = \"mcp\",\n MCP = new MCPConnectorConfig\n {\n ServerName = \"server-a\",\n Command = \"echo\",\n Arguments = [\"hello\"],\n Environment = new Dictionary<string, string> { [\"A\"] = \"B\" },\n DefaultTool = \"tool-a\",\n AllowedTools = [\"tool-a\"],\n AllowedInputKeys = [\"q\"],\n },\n };\n\n var okValid = builder.TryBuild(validEntry, NullLogger.Instance, out var connector);\n okValid.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"mcp-b\");\n }\n\n [Fact]\n public void MCPConnectorBuilder_ShouldSupportRemoteUrlConfiguration()\n {\n var builder = new MCPConnectorBuilder(\n new VoicePresenceBootstrapTests.TestHttpClientFactory(),\n new VoicePresenceBootstrapTests.UnusedAgentToolExecutionPort());\n var entry = new ConnectorConfigEntry\n {\n Name = \"nyxid_mcp\",\n Type = \"mcp\",\n TimeoutMs = 15000,\n MCP = new MCPConnectorConfig\n {\n ServerName = \"nyxid\",\n Url = \"https://nyxid.example.com/mcp\",\n AdditionalHeaders = new Dictionary<string, string> { [\"x-tenant\"] = \"demo\" },\n DefaultTool = \"chrono-graph__query\",\n AllowedTools = [\"chrono-graph__query\"],\n },\n };\n\n var ok = builder.TryBuild(entry, NullLogger.Instance, out var connector);\n\n ok.Should().BeTrue();\n connector.Should().NotBeNull();\n connector!.Type.Should().Be(\"mcp\");\n connector.Name.Should().Be(\"nyxid_mcp\");\n\n var serverConfigField = connector.GetType().GetField(\"_serverConfig\", BindingFlags.Instance | BindingFlags.NonPublic);\n serverConfigField.Should().NotBeNull();\n var serverConfig = serverConfigField!.GetValue(connector).Should().BeOfType<MCPServerConfig>().Subject;\n serverConfig.Url.Should().Be(\"https://nyxid.example.com/mcp\");\n serverConfig.HttpClient.Should().NotBeNull();\n serverConfig.HttpClient!.Timeout.Should().Be(Timeout.InfiniteTimeSpan);\n }\n\n private static IReadOnlyList<object> InvokeReadConfiguredProviders(\n IAevatarSecretsStore secretsStore,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var method = typeof(global::Aevatar.Bootstrap.Extensions.AI.ServiceCollectionExtensions).GetMethod(\n \"ReadConfiguredProviders\",\n BindingFlags.NonPublic | BindingFlags.Static);\n method.Should().NotBeNull();\n\n var result = method!.Invoke(null, [secretsStore, configuration, options]);\n result.Should().NotBeNull();\n return ((IEnumerable)result!).Cast<object>().ToList();\n }\n\n private static string? ReadConfiguredProviderString(object configuredProvider, string propertyName)\n {\n var property = configuredProvider.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);\n property.Should().NotBeNull();\n return property!.GetValue(configuredProvider) as string;\n }\n\n private static void AddVoicePresenceTestCredentialResolver(IServiceCollection services) =>\n services.AddSingleton<IRealtimeProviderCredentialResolver, NoOpRealtimeProviderCredentialResolver>();\n\n private sealed class EnvironmentVariablesScope : IDisposable\n {\n private readonly Dictionary<string, string?> _previousValues = new(StringComparer.Ordinal);\n\n public EnvironmentVariablesScope(IReadOnlyDictionary<string, string?> overrides)\n {\n foreach (var pair in overrides)\n {\n _previousValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n public void Dispose()\n {\n foreach (var pair in _previousValues)\n Environment.SetEnvironmentVariable(pair.Key, pair.Value);\n }\n }\n\n private sealed class InMemorySecretsStore : IAevatarSecretsStore\n {\n private readonly Dictionary<string, string> _values;\n\n public InMemorySecretsStore(Dictionary<string, string>? seed = null)\n {\n _values = seed is null\n ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)\n : new Dictionary<string, string>(seed, StringComparer.OrdinalIgnoreCase);\n }\n\n public string? Get(string key) => _values.GetValueOrDefault(key);\n\n public string? GetApiKey(string providerName)\n {\n if (_values.TryGetValue($\"LLMProviders:Providers:{providerName}:ApiKey\", out var providerScoped) &&\n !string.IsNullOrWhiteSpace(providerScoped))\n return providerScoped;\n\n if (_values.TryGetValue($\"LLMProviders:{providerName}:ApiKey\", out var legacyScoped) &&\n !string.IsNullOrWhiteSpace(legacyScoped))\n return legacyScoped;\n\n if (_values.TryGetValue($\"{providerName}_API_KEY\", out var envScoped) &&\n !string.IsNullOrWhiteSpace(envScoped))\n return envScoped;\n\n return null;\n }\n\n public string? GetDefaultProvider() => Get(\"LLMProviders:Default\");\n\n public IReadOnlyDictionary<string, string> GetAll() => _values;\n\n public void Set(string key, string value) => _values[key] = value;\n\n public void Remove(string key) => _values.Remove(key);\n }\n\n private static void WriteFlatSecrets(string path, IReadOnlyDictionary<string, string> values)\n {\n var directory = Path.GetDirectoryName(path);\n if (!string.IsNullOrWhiteSpace(directory))\n Directory.CreateDirectory(directory);\n\n var json = JsonSerializer.Serialize(values);\n File.WriteAllText(path, json);\n File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddSeconds(1));\n }\n\n private sealed class NoOpActorDispatchPort : IActorDispatchPort\n {\n public Task<DispatchAdmission> DispatchAsync(string actorId, EventEnvelope envelope, CancellationToken ct = default) =>\n Task.FromResult(DispatchAdmissionFactory.Create(actorId, envelope));\n }\n\n private sealed class StaticActorRuntime : IActorRuntime\n {\n public Task<IActor> CreateAsync<TAgent>(string? id = null, CancellationToken ct = default)\n where TAgent : IAgent =>\n throw new NotSupportedException();\n\n public Task<IActor> CreateAsync(Type agentType, string? id = null, CancellationToken ct = default) =>\n throw new NotSupportedException();\n\n public Task DestroyAsync(string id, CancellationToken ct = default) =>\n Task.CompletedTask;\n\n public Task<IActor?> GetAsync(string id) =>\n Task.FromResult<IActor?>(null);\n\n public Task<bool> ExistsAsync(string id) =>\n Task.FromResult(true);\n\n public Task LinkAsync(string parentId, string childId, CancellationToken ct = default) =>\n Task.CompletedTask;\n\n public Task UnlinkAsync(string childId, CancellationToken ct = default) =>\n Task.CompletedTask;\n }\n\n private sealed class EmptyVoicePresenceCapabilityReader\n : IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>\n {\n public Task<VoicePresenceCapabilityReadModel?> GetAsync(string key, CancellationToken ct = default) =>\n Task.FromResult<VoicePresenceCapabilityReadModel?>(null);\n\n public Task<ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>> QueryAsync(\n ProjectionDocumentQuery query,\n CancellationToken ct = default) =>\n Task.FromResult(ProjectionDocumentQueryResult<VoicePresenceCapabilityReadModel>.Empty);\n }\n\n private sealed class NoOpRealtimeProviderCredentialResolver : IRealtimeProviderCredentialResolver\n {\n public Task<string?> ResolveApiKeyAsync(\n VoiceProviderSessionKey sessionKey,\n VoiceProviderConfig config,\n CancellationToken ct) =>\n Task.FromResult<string?>(null);\n }\n\n}\n"}}, "src/Aevatar.Bootstrap.Extensions.AI/ServiceCollectionExtensions.cs": {"4ecb75cf0bf248f7906ff3f1402cda113cceeaea": {"t": 1785745817.3651874, "n": 57278, "h": "e35544bfa8885a66", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Auditing;\nusing Aevatar.AI.Core.DependencyInjection;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.NyxId;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.Credentials;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\nusing Microsoft.Extensions.Options;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public int MaxTurnDeadlineMs { get; set; } = RoleChatExecutionOptions.DefaultMaxTurnDeadlineMs;\n public int PostCommitConfigRefreshTimeoutMs { get; set; } =\n RoleChatExecutionOptions.DefaultPostCommitConfigRefreshTimeoutMs;\n public int PostTurnProcessingTimeoutMs { get; set; } =\n RoleChatExecutionOptions.DefaultPostTurnProcessingTimeoutMs;\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n /// <summary>\n /// Enables the host-bound system skill overlay scaffold. Mainnet-style hosts bind this from\n /// <c>Aevatar:SystemSkills:Enabled</c>.\n /// </summary>\n public bool EnableSystemSkillOverlay { get; set; }\n /// <summary>\n /// Non-secret name of the public, org-owned Ornn skillset that sources the overlay (issue #2498).\n /// Bound from <c>Aevatar:SystemSkills:SetName</c>. No organization service token is needed: set\n /// ownership is the trust anchor and the set is read publicly through the existing ornn-api proxy.\n /// </summary>\n public string? SystemSkillOverlaySetName { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:RefreshTtl</c>.\n /// </summary>\n public TimeSpan SystemSkillOverlayRefreshTtl { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:MaxSkills</c>.\n /// </summary>\n public int SystemSkillOverlayMaxSkills { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:MaxBytes</c>.\n /// </summary>\n public int SystemSkillOverlayMaxBytes { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = LlmDefaults.Model;\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n options.MaxTurnDeadlineMs = ReadPositiveInteger(\n configuration,\n \"Aevatar:AI:MaxTurnDeadlineMs\",\n options.MaxTurnDeadlineMs);\n options.PostCommitConfigRefreshTimeoutMs = ReadPositiveInteger(\n configuration,\n \"Aevatar:AI:PostCommitConfigRefreshTimeoutMs\",\n options.PostCommitConfigRefreshTimeoutMs);\n options.PostTurnProcessingTimeoutMs = ReadPositiveInteger(\n configuration,\n \"Aevatar:AI:PostTurnProcessingTimeoutMs\",\n options.PostTurnProcessingTimeoutMs);\n configure?.Invoke(options);\n services.TryAddSingleton(new RoleChatExecutionOptions(\n options.MaxTurnDeadlineMs,\n options.PostCommitConfigRefreshTimeoutMs,\n options.PostTurnProcessingTimeoutMs));\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n services.AddAgentToolExecution();\n services.TryAddSingleton<IVoiceToolInvoker>(sp => new AgentToolVoiceInvoker(\n sp.GetServices<IAgentToolSource>(),\n sp.GetRequiredService<IAgentToolExecutionPort>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceInvoker>>()));\n services.TryAddSingleton<IVoiceToolCatalog>(sp => new AgentToolVoiceCatalog(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceCatalog>>()));\n services.TryAddSingleton<IVoicePresenceCapabilityCommandPort, VoicePresenceCapabilityCommandPort>();\n // Zero-config /ws/voice: auto-provision a never-enabled default voice agent on first connect by\n // committing the same enable voice-presence/enable issues. The attach path (ActorOwnedVoiceRealtimeSession)\n // resolves this optionally and only invokes it when the capability is still null after the\n // re-projection self-heal — i.e. a genuinely-unprovisioned default agent.\n services.TryAddSingleton<IVoicePresenceCapabilityAutoEnablePort, VoicePresenceCapabilityAutoEnablePort>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableSystemSkillOverlay)\n RegisterSystemSkillOverlay(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static int ReadPositiveInteger(\n IConfiguration configuration,\n string key,\n int defaultValue)\n {\n var configuredValue = configuration[key];\n if (string.IsNullOrWhiteSpace(configuredValue))\n return defaultValue;\n\n if (!int.TryParse(configuredValue, out var value))\n throw new InvalidOperationException($\"{key} must be a positive integer.\");\n\n return value;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): when the OpenAI realtime credential is\n // brokered through NyxID, register the resolver so the provider mints a per-session ephemeral\n // instead of reading a static OPENAI_API_KEY. Requires NyxID tools (INyxIdApiClientFactory) wired.\n var nyxIdRealtimeCredentialOptions = BuildNyxIdRealtimeCredentialOptions(configuration);\n if (nyxIdRealtimeCredentialOptions.Enabled)\n {\n services.TryAddSingleton(nyxIdRealtimeCredentialOptions);\n services.TryAddSingleton<IRealtimeProviderCredentialResolver>(sp => new NyxIdRealtimeProviderCredentialResolver(\n sp.GetRequiredService<INyxIdApiClientFactory>(),\n sp.GetRequiredService<NyxIdRealtimeProviderCredentialOptions>(),\n sp.GetRequiredService<ILogger<NyxIdRealtimeProviderCredentialResolver>>(),\n ResolveVoiceCredentialProviders(sp)));\n }\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceLeaseObservationPort, VoicePresenceLeaseObservationPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton(sp => new VoiceVolatileToolCredentialPort(sp.GetService<TimeProvider>()));\n services.TryAddSingleton<IVoiceVolatileToolCredentialPort>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.TryAddSingleton<IVoiceToolCredentialIssuer>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.AddOptions<VoiceWebSocketAttachOptions>();\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IValidateOptions<VoiceWebSocketAttachOptions>, VoiceWebSocketAttachOptionsValidator>();\n services.TryAddSingleton<VoiceWebSocketAttachExecutor>();\n services.AddVoiceWebRtcTransport();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IReadOnlyList<ICredentialProvider> ResolveVoiceCredentialProviders(IServiceProvider services)\n {\n var providers = new List<ICredentialProvider>();\n var voiceCredentialProvider = services.GetService<IVoiceToolCredentialIssuer>() as ICredentialProvider;\n if (voiceCredentialProvider is not null)\n providers.Add(voiceCredentialProvider);\n\n foreach (var provider in services.GetServices<ICredentialProvider>())\n {\n if (!ReferenceEquals(provider, voiceCredentialProvider))\n providers.Add(provider);\n }\n\n return providers;\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var nyxIdRealtimeBrokerEnabled = IsNyxIdRealtimeBrokerEnabled(configuration);\n // The openai module registers below when EITHER a raw ApiKey OR the NyxID ephemeral broker is\n // available (ADR-0033 forbids long-lived keys in production, so broker-only is the normal shape).\n // Default-provider resolution must use the same availability, or broker-only deployments register\n // the module solely as \"voice_presence_openai\" while the auto-enable/mount/read default path uses\n // \"voice_presence\" — the module factory then never mounts a module for the enabled name, the\n // session-lease signal is silently dropped, and /ws/voice loops on 503 voice_capability_not_ready.\n var openAIVoiceAvailable = IsOpenAIVoiceConfigured(openAIProviderConfig) || nyxIdRealtimeBrokerEnabled;\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIVoiceAvailable,\n miniCpmProviderConfig);\n\n if (openAIVoiceAvailable)\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n return await ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger: null,\n ct);\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionWithLoggingAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n ILogger<ReadinessGatedRealtimeVoiceProviderSession>? logger,\n CancellationToken ct)\n {\n return await VoiceRealtimeSessionReadinessBootstrapper.ConnectAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger,\n ct);\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n bool openAIVoiceAvailable,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n openAIVoiceAvailable)\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (openAIVoiceAvailable)\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): the broker slug defaults to\n // NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug so voice stays enabled even when the\n // deployment config/secret is wiped on redeploy — config only overrides the slug, never disables it.\n private static string ResolveNyxIdRealtimeServiceSlug(IConfiguration configuration)\n {\n var configured = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:ServiceSlug\"]?.Trim();\n return string.IsNullOrWhiteSpace(configured)\n ? NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug\n : configured;\n }\n\n private static bool IsNyxIdRealtimeBrokerEnabled(IConfiguration configuration) =>\n !string.IsNullOrWhiteSpace(ResolveNyxIdRealtimeServiceSlug(configuration));\n\n private static NyxIdRealtimeProviderCredentialOptions BuildNyxIdRealtimeCredentialOptions(\n IConfiguration configuration)\n {\n var options = new NyxIdRealtimeProviderCredentialOptions\n {\n ServiceSlug = ResolveNyxIdRealtimeServiceSlug(configuration),\n };\n\n var mintPath = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:MintPath\"];\n if (!string.IsNullOrWhiteSpace(mintPath))\n options.MintPath = mintPath.Trim();\n\n var model = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:Model\"];\n if (!string.IsNullOrWhiteSpace(model))\n options.Model = model.Trim();\n\n return options;\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n DrainTimeout = options.DrainTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n var loggerFactory = sp.GetService<ILoggerFactory>();\n var toolExecutionPort = new ServiceProviderAgentToolExecutionPort(sp);\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, toolExecutionPort, loggerFactory),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(\n configuration,\n options,\n secretsStoreAccessor,\n new ServiceProviderAgentToolExecutionPort(sp),\n sp.GetService<ILoggerFactory>());\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor,\n IAgentToolExecutionPort toolExecutionPort,\n ILoggerFactory? loggerFactory = null)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options, toolExecutionPort, loggerFactory);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName, toolExecutionPort, loggerFactory);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options, toolExecutionPort, loggerFactory);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options,\n IAgentToolExecutionPort toolExecutionPort,\n ILoggerFactory? loggerFactory = null)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName, toolExecutionPort, loggerFactory);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName, loggerFactory);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n IAgentToolExecutionPort toolExecutionPort,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new MEAILLMProviderFactory(toolExecutionPort);\n var providerLogger = loggerFactory?.CreateLogger<MEAILLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new TornadoLLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<TornadoLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n IAgentToolExecutionPort toolExecutionPort,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new NyxIdLLMProviderFactory(toolExecutionPort);\n // Without an explicit logger the provider chain (NyxIdLLMProvider and the\n // MEAILLMProvider it delegates to) falls back to NullLogger, which silences\n // upstream LLM error translations and the no-chunks streaming fallback in\n // production. Always wire the host logger when one is available.\n var providerLogger = loggerFactory?.CreateLogger<NyxIdLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null,\n provider.DefaultRoutePreference,\n providerLogger);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = ResolveNyxIdDefaultModel(configuration, options);\n var defaultRoute = ResolveNyxIdDefaultRoute(configuration);\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty, defaultRoute);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n var defaultRoute = IsNyxIdProviderType(semantic.ProviderType)\n ? ResolveNyxIdDefaultRoute(configuration)\n : null;\n configured.Add(new ConfiguredProvider(\n name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim(), defaultRoute));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", ResolveNyxIdDefaultModel(configuration, options), ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n // The default route/model literals live once in LlmDefaults (Aevatar.AI.Abstractions) so the\n // NyxID server-default path and the OpenAI-compatible Responses ingress default share one\n // source and cannot drift. Every nyxid registration path resolves its default through these\n // helpers, which apply per-deployment config overrides on top.\n private static string ResolveNyxIdDefaultRoute(IConfiguration configuration) =>\n configuration[\"Aevatar:NyxId:DefaultRoute\"] is { } route && !string.IsNullOrWhiteSpace(route)\n ? route.Trim()\n : LlmDefaults.NyxIdRoute;\n\n private static string ResolveNyxIdDefaultModel(IConfiguration configuration, AevatarAIFeatureOptions options) =>\n configuration[\"Aevatar:NyxId:DefaultModel\"] is { Length: > 0 } model\n ? model\n : options.OpenAIModel;\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey,\n string? DefaultRoutePreference = null);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug.Trim();\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterSystemSkillOverlay(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n if (!options.EnableSystemSkillOverlay || string.IsNullOrWhiteSpace(options.SystemSkillOverlaySetName))\n return;\n\n // Ensure the Ornn skill client (with the configured slug) is registered even when a host enables\n // the overlay without the Ornn skill tools; the overlay reads the set through this client.\n services.AddOrnnSkillClient(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug.Trim();\n });\n\n services.AddSystemSkillOverlay(o =>\n {\n o.Enabled = options.EnableSystemSkillOverlay;\n o.SetName = options.SystemSkillOverlaySetName ?? string.Empty;\n o.RefreshTtl = options.SystemSkillOverlayRefreshTtl;\n o.MaxSkills = options.SystemSkillOverlayMaxSkills;\n o.MaxBytes = options.SystemSkillOverlayMaxBytes;\n });\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n\n private sealed class ServiceProviderAgentToolExecutionPort(IServiceProvider serviceProvider) : IAgentToolExecutionPort\n {\n public Task<AgentToolExecutionOutcome> ExecuteAsync(\n AgentToolExecutionRequest request,\n CancellationToken ct = default) =>\n serviceProvider.GetRequiredService<IAgentToolExecutionPort>().ExecuteAsync(request, ct);\n }\n}\n"}, "7a311fe3faa7bdba7fa6f1c5666c0df167e18db7": {"t": 1785756596.262174, "n": 57278, "h": "e35544bfa8885a66", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Auditing;\nusing Aevatar.AI.Core.DependencyInjection;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.NyxId;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.Credentials;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\nusing Microsoft.Extensions.Options;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public int MaxTurnDeadlineMs { get; set; } = RoleChatExecutionOptions.DefaultMaxTurnDeadlineMs;\n public int PostCommitConfigRefreshTimeoutMs { get; set; } =\n RoleChatExecutionOptions.DefaultPostCommitConfigRefreshTimeoutMs;\n public int PostTurnProcessingTimeoutMs { get; set; } =\n RoleChatExecutionOptions.DefaultPostTurnProcessingTimeoutMs;\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n /// <summary>\n /// Enables the host-bound system skill overlay scaffold. Mainnet-style hosts bind this from\n /// <c>Aevatar:SystemSkills:Enabled</c>.\n /// </summary>\n public bool EnableSystemSkillOverlay { get; set; }\n /// <summary>\n /// Non-secret name of the public, org-owned Ornn skillset that sources the overlay (issue #2498).\n /// Bound from <c>Aevatar:SystemSkills:SetName</c>. No organization service token is needed: set\n /// ownership is the trust anchor and the set is read publicly through the existing ornn-api proxy.\n /// </summary>\n public string? SystemSkillOverlaySetName { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:RefreshTtl</c>.\n /// </summary>\n public TimeSpan SystemSkillOverlayRefreshTtl { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:MaxSkills</c>.\n /// </summary>\n public int SystemSkillOverlayMaxSkills { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:MaxBytes</c>.\n /// </summary>\n public int SystemSkillOverlayMaxBytes { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = LlmDefaults.Model;\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n options.MaxTurnDeadlineMs = ReadPositiveInteger(\n configuration,\n \"Aevatar:AI:MaxTurnDeadlineMs\",\n options.MaxTurnDeadlineMs);\n options.PostCommitConfigRefreshTimeoutMs = ReadPositiveInteger(\n configuration,\n \"Aevatar:AI:PostCommitConfigRefreshTimeoutMs\",\n options.PostCommitConfigRefreshTimeoutMs);\n options.PostTurnProcessingTimeoutMs = ReadPositiveInteger(\n configuration,\n \"Aevatar:AI:PostTurnProcessingTimeoutMs\",\n options.PostTurnProcessingTimeoutMs);\n configure?.Invoke(options);\n services.TryAddSingleton(new RoleChatExecutionOptions(\n options.MaxTurnDeadlineMs,\n options.PostCommitConfigRefreshTimeoutMs,\n options.PostTurnProcessingTimeoutMs));\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n services.AddAgentToolExecution();\n services.TryAddSingleton<IVoiceToolInvoker>(sp => new AgentToolVoiceInvoker(\n sp.GetServices<IAgentToolSource>(),\n sp.GetRequiredService<IAgentToolExecutionPort>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceInvoker>>()));\n services.TryAddSingleton<IVoiceToolCatalog>(sp => new AgentToolVoiceCatalog(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceCatalog>>()));\n services.TryAddSingleton<IVoicePresenceCapabilityCommandPort, VoicePresenceCapabilityCommandPort>();\n // Zero-config /ws/voice: auto-provision a never-enabled default voice agent on first connect by\n // committing the same enable voice-presence/enable issues. The attach path (ActorOwnedVoiceRealtimeSession)\n // resolves this optionally and only invokes it when the capability is still null after the\n // re-projection self-heal — i.e. a genuinely-unprovisioned default agent.\n services.TryAddSingleton<IVoicePresenceCapabilityAutoEnablePort, VoicePresenceCapabilityAutoEnablePort>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableSystemSkillOverlay)\n RegisterSystemSkillOverlay(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static int ReadPositiveInteger(\n IConfiguration configuration,\n string key,\n int defaultValue)\n {\n var configuredValue = configuration[key];\n if (string.IsNullOrWhiteSpace(configuredValue))\n return defaultValue;\n\n if (!int.TryParse(configuredValue, out var value))\n throw new InvalidOperationException($\"{key} must be a positive integer.\");\n\n return value;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): when the OpenAI realtime credential is\n // brokered through NyxID, register the resolver so the provider mints a per-session ephemeral\n // instead of reading a static OPENAI_API_KEY. Requires NyxID tools (INyxIdApiClientFactory) wired.\n var nyxIdRealtimeCredentialOptions = BuildNyxIdRealtimeCredentialOptions(configuration);\n if (nyxIdRealtimeCredentialOptions.Enabled)\n {\n services.TryAddSingleton(nyxIdRealtimeCredentialOptions);\n services.TryAddSingleton<IRealtimeProviderCredentialResolver>(sp => new NyxIdRealtimeProviderCredentialResolver(\n sp.GetRequiredService<INyxIdApiClientFactory>(),\n sp.GetRequiredService<NyxIdRealtimeProviderCredentialOptions>(),\n sp.GetRequiredService<ILogger<NyxIdRealtimeProviderCredentialResolver>>(),\n ResolveVoiceCredentialProviders(sp)));\n }\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceLeaseObservationPort, VoicePresenceLeaseObservationPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton(sp => new VoiceVolatileToolCredentialPort(sp.GetService<TimeProvider>()));\n services.TryAddSingleton<IVoiceVolatileToolCredentialPort>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.TryAddSingleton<IVoiceToolCredentialIssuer>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.AddOptions<VoiceWebSocketAttachOptions>();\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IValidateOptions<VoiceWebSocketAttachOptions>, VoiceWebSocketAttachOptionsValidator>();\n services.TryAddSingleton<VoiceWebSocketAttachExecutor>();\n services.AddVoiceWebRtcTransport();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IReadOnlyList<ICredentialProvider> ResolveVoiceCredentialProviders(IServiceProvider services)\n {\n var providers = new List<ICredentialProvider>();\n var voiceCredentialProvider = services.GetService<IVoiceToolCredentialIssuer>() as ICredentialProvider;\n if (voiceCredentialProvider is not null)\n providers.Add(voiceCredentialProvider);\n\n foreach (var provider in services.GetServices<ICredentialProvider>())\n {\n if (!ReferenceEquals(provider, voiceCredentialProvider))\n providers.Add(provider);\n }\n\n return providers;\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var nyxIdRealtimeBrokerEnabled = IsNyxIdRealtimeBrokerEnabled(configuration);\n // The openai module registers below when EITHER a raw ApiKey OR the NyxID ephemeral broker is\n // available (ADR-0033 forbids long-lived keys in production, so broker-only is the normal shape).\n // Default-provider resolution must use the same availability, or broker-only deployments register\n // the module solely as \"voice_presence_openai\" while the auto-enable/mount/read default path uses\n // \"voice_presence\" — the module factory then never mounts a module for the enabled name, the\n // session-lease signal is silently dropped, and /ws/voice loops on 503 voice_capability_not_ready.\n var openAIVoiceAvailable = IsOpenAIVoiceConfigured(openAIProviderConfig) || nyxIdRealtimeBrokerEnabled;\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIVoiceAvailable,\n miniCpmProviderConfig);\n\n if (openAIVoiceAvailable)\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n return await ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger: null,\n ct);\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionWithLoggingAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n ILogger<ReadinessGatedRealtimeVoiceProviderSession>? logger,\n CancellationToken ct)\n {\n return await VoiceRealtimeSessionReadinessBootstrapper.ConnectAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger,\n ct);\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n bool openAIVoiceAvailable,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n openAIVoiceAvailable)\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (openAIVoiceAvailable)\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): the broker slug defaults to\n // NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug so voice stays enabled even when the\n // deployment config/secret is wiped on redeploy — config only overrides the slug, never disables it.\n private static string ResolveNyxIdRealtimeServiceSlug(IConfiguration configuration)\n {\n var configured = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:ServiceSlug\"]?.Trim();\n return string.IsNullOrWhiteSpace(configured)\n ? NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug\n : configured;\n }\n\n private static bool IsNyxIdRealtimeBrokerEnabled(IConfiguration configuration) =>\n !string.IsNullOrWhiteSpace(ResolveNyxIdRealtimeServiceSlug(configuration));\n\n private static NyxIdRealtimeProviderCredentialOptions BuildNyxIdRealtimeCredentialOptions(\n IConfiguration configuration)\n {\n var options = new NyxIdRealtimeProviderCredentialOptions\n {\n ServiceSlug = ResolveNyxIdRealtimeServiceSlug(configuration),\n };\n\n var mintPath = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:MintPath\"];\n if (!string.IsNullOrWhiteSpace(mintPath))\n options.MintPath = mintPath.Trim();\n\n var model = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:Model\"];\n if (!string.IsNullOrWhiteSpace(model))\n options.Model = model.Trim();\n\n return options;\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n DrainTimeout = options.DrainTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n var loggerFactory = sp.GetService<ILoggerFactory>();\n var toolExecutionPort = new ServiceProviderAgentToolExecutionPort(sp);\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, toolExecutionPort, loggerFactory),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(\n configuration,\n options,\n secretsStoreAccessor,\n new ServiceProviderAgentToolExecutionPort(sp),\n sp.GetService<ILoggerFactory>());\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor,\n IAgentToolExecutionPort toolExecutionPort,\n ILoggerFactory? loggerFactory = null)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options, toolExecutionPort, loggerFactory);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName, toolExecutionPort, loggerFactory);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options, toolExecutionPort, loggerFactory);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options,\n IAgentToolExecutionPort toolExecutionPort,\n ILoggerFactory? loggerFactory = null)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName, toolExecutionPort, loggerFactory);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName, loggerFactory);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n IAgentToolExecutionPort toolExecutionPort,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new MEAILLMProviderFactory(toolExecutionPort);\n var providerLogger = loggerFactory?.CreateLogger<MEAILLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new TornadoLLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<TornadoLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n IAgentToolExecutionPort toolExecutionPort,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new NyxIdLLMProviderFactory(toolExecutionPort);\n // Without an explicit logger the provider chain (NyxIdLLMProvider and the\n // MEAILLMProvider it delegates to) falls back to NullLogger, which silences\n // upstream LLM error translations and the no-chunks streaming fallback in\n // production. Always wire the host logger when one is available.\n var providerLogger = loggerFactory?.CreateLogger<NyxIdLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null,\n provider.DefaultRoutePreference,\n providerLogger);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = ResolveNyxIdDefaultModel(configuration, options);\n var defaultRoute = ResolveNyxIdDefaultRoute(configuration);\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty, defaultRoute);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n var defaultRoute = IsNyxIdProviderType(semantic.ProviderType)\n ? ResolveNyxIdDefaultRoute(configuration)\n : null;\n configured.Add(new ConfiguredProvider(\n name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim(), defaultRoute));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", ResolveNyxIdDefaultModel(configuration, options), ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n // The default route/model literals live once in LlmDefaults (Aevatar.AI.Abstractions) so the\n // NyxID server-default path and the OpenAI-compatible Responses ingress default share one\n // source and cannot drift. Every nyxid registration path resolves its default through these\n // helpers, which apply per-deployment config overrides on top.\n private static string ResolveNyxIdDefaultRoute(IConfiguration configuration) =>\n configuration[\"Aevatar:NyxId:DefaultRoute\"] is { } route && !string.IsNullOrWhiteSpace(route)\n ? route.Trim()\n : LlmDefaults.NyxIdRoute;\n\n private static string ResolveNyxIdDefaultModel(IConfiguration configuration, AevatarAIFeatureOptions options) =>\n configuration[\"Aevatar:NyxId:DefaultModel\"] is { Length: > 0 } model\n ? model\n : options.OpenAIModel;\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey,\n string? DefaultRoutePreference = null);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug.Trim();\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterSystemSkillOverlay(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n if (!options.EnableSystemSkillOverlay || string.IsNullOrWhiteSpace(options.SystemSkillOverlaySetName))\n return;\n\n // Ensure the Ornn skill client (with the configured slug) is registered even when a host enables\n // the overlay without the Ornn skill tools; the overlay reads the set through this client.\n services.AddOrnnSkillClient(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug.Trim();\n });\n\n services.AddSystemSkillOverlay(o =>\n {\n o.Enabled = options.EnableSystemSkillOverlay;\n o.SetName = options.SystemSkillOverlaySetName ?? string.Empty;\n o.RefreshTtl = options.SystemSkillOverlayRefreshTtl;\n o.MaxSkills = options.SystemSkillOverlayMaxSkills;\n o.MaxBytes = options.SystemSkillOverlayMaxBytes;\n });\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n\n private sealed class ServiceProviderAgentToolExecutionPort(IServiceProvider serviceProvider) : IAgentToolExecutionPort\n {\n public Task<AgentToolExecutionOutcome> ExecuteAsync(\n AgentToolExecutionRequest request,\n CancellationToken ct = default) =>\n serviceProvider.GetRequiredService<IAgentToolExecutionPort>().ExecuteAsync(request, ct);\n }\n}\n"}, "15860900a054ae55c84991b28820e41200d65d6c": {"t": 1785756597.7440639, "n": 56338, "h": "9dedd3fac897f96a", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Auditing;\nusing Aevatar.AI.Core.DependencyInjection;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.NyxId;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.Credentials;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\nusing Microsoft.Extensions.Options;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public int MaxTurnDeadlineMs { get; set; } = RoleChatExecutionOptions.DefaultMaxTurnDeadlineMs;\n public int PostCommitConfigRefreshTimeoutMs { get; set; } =\n RoleChatExecutionOptions.DefaultPostCommitConfigRefreshTimeoutMs;\n public int PostTurnProcessingTimeoutMs { get; set; } =\n RoleChatExecutionOptions.DefaultPostTurnProcessingTimeoutMs;\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n /// <summary>\n /// Enables the host-bound system skill overlay scaffold. Mainnet-style hosts bind this from\n /// <c>Aevatar:SystemSkills:Enabled</c>.\n /// </summary>\n public bool EnableSystemSkillOverlay { get; set; }\n /// <summary>\n /// Non-secret name of the public, org-owned Ornn skillset that sources the overlay (issue #2498).\n /// Bound from <c>Aevatar:SystemSkills:SetName</c>. No organization service token is needed: set\n /// ownership is the trust anchor and the set is read publicly through the existing ornn-api proxy.\n /// </summary>\n public string? SystemSkillOverlaySetName { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:RefreshTtl</c>.\n /// </summary>\n public TimeSpan SystemSkillOverlayRefreshTtl { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:MaxSkills</c>.\n /// </summary>\n public int SystemSkillOverlayMaxSkills { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:MaxBytes</c>.\n /// </summary>\n public int SystemSkillOverlayMaxBytes { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = LlmDefaults.Model;\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n options.MaxTurnDeadlineMs = ReadPositiveInteger(\n configuration,\n \"Aevatar:AI:MaxTurnDeadlineMs\",\n options.MaxTurnDeadlineMs);\n options.PostCommitConfigRefreshTimeoutMs = ReadPositiveInteger(\n configuration,\n \"Aevatar:AI:PostCommitConfigRefreshTimeoutMs\",\n options.PostCommitConfigRefreshTimeoutMs);\n options.PostTurnProcessingTimeoutMs = ReadPositiveInteger(\n configuration,\n \"Aevatar:AI:PostTurnProcessingTimeoutMs\",\n options.PostTurnProcessingTimeoutMs);\n configure?.Invoke(options);\n services.TryAddSingleton(new RoleChatExecutionOptions(\n options.MaxTurnDeadlineMs,\n options.PostCommitConfigRefreshTimeoutMs,\n options.PostTurnProcessingTimeoutMs));\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n services.AddAgentToolExecution();\n services.TryAddSingleton<IVoiceToolInvoker>(sp => new AgentToolVoiceInvoker(\n sp.GetServices<IAgentToolSource>(),\n sp.GetRequiredService<IAgentToolExecutionPort>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceInvoker>>()));\n services.TryAddSingleton<IVoiceToolCatalog>(sp => new AgentToolVoiceCatalog(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceCatalog>>()));\n services.TryAddSingleton<IVoicePresenceCapabilityCommandPort, VoicePresenceCapabilityCommandPort>();\n // Zero-config /ws/voice: auto-provision a never-enabled default voice agent on first connect by\n // committing the same enable voice-presence/enable issues. The attach path (ActorOwnedVoiceRealtimeSession)\n // resolves this optionally and only invokes it when the capability is still null after the\n // re-projection self-heal — i.e. a genuinely-unprovisioned default agent.\n services.TryAddSingleton<IVoicePresenceCapabilityAutoEnablePort, VoicePresenceCapabilityAutoEnablePort>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableSystemSkillOverlay)\n RegisterSystemSkillOverlay(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static int ReadPositiveInteger(\n IConfiguration configuration,\n string key,\n int defaultValue)\n {\n var configuredValue = configuration[key];\n if (string.IsNullOrWhiteSpace(configuredValue))\n return defaultValue;\n\n if (!int.TryParse(configuredValue, out var value))\n throw new InvalidOperationException($\"{key} must be a positive integer.\");\n\n return value;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): when the OpenAI realtime credential is\n // brokered through NyxID, register the resolver so the provider mints a per-session ephemeral\n // instead of reading a static OPENAI_API_KEY. Requires NyxID tools (INyxIdApiClientFactory) wired.\n var nyxIdRealtimeCredentialOptions = BuildNyxIdRealtimeCredentialOptions(configuration);\n if (nyxIdRealtimeCredentialOptions.Enabled)\n {\n services.TryAddSingleton(nyxIdRealtimeCredentialOptions);\n services.TryAddSingleton<IRealtimeProviderCredentialResolver>(sp => new NyxIdRealtimeProviderCredentialResolver(\n sp.GetRequiredService<INyxIdApiClientFactory>(),\n sp.GetRequiredService<NyxIdRealtimeProviderCredentialOptions>(),\n sp.GetRequiredService<ILogger<NyxIdRealtimeProviderCredentialResolver>>(),\n ResolveVoiceCredentialProviders(sp)));\n }\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceLeaseObservationPort, VoicePresenceLeaseObservationPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton(sp => new VoiceVolatileToolCredentialPort(sp.GetService<TimeProvider>()));\n services.TryAddSingleton<IVoiceVolatileToolCredentialPort>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.TryAddSingleton<IVoiceToolCredentialIssuer>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.AddOptions<VoiceWebSocketAttachOptions>();\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IValidateOptions<VoiceWebSocketAttachOptions>, VoiceWebSocketAttachOptionsValidator>();\n services.TryAddSingleton<VoiceWebSocketAttachExecutor>();\n services.AddVoiceWebRtcTransport();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IReadOnlyList<ICredentialProvider> ResolveVoiceCredentialProviders(IServiceProvider services)\n {\n var providers = new List<ICredentialProvider>();\n var voiceCredentialProvider = services.GetService<IVoiceToolCredentialIssuer>() as ICredentialProvider;\n if (voiceCredentialProvider is not null)\n providers.Add(voiceCredentialProvider);\n\n foreach (var provider in services.GetServices<ICredentialProvider>())\n {\n if (!ReferenceEquals(provider, voiceCredentialProvider))\n providers.Add(provider);\n }\n\n return providers;\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var nyxIdRealtimeBrokerEnabled = IsNyxIdRealtimeBrokerEnabled(configuration);\n // The openai module registers below when EITHER a raw ApiKey OR the NyxID ephemeral broker is\n // available (ADR-0033 forbids long-lived keys in production, so broker-only is the normal shape).\n // Default-provider resolution must use the same availability, or broker-only deployments register\n // the module solely as \"voice_presence_openai\" while the auto-enable/mount/read default path uses\n // \"voice_presence\" — the module factory then never mounts a module for the enabled name, the\n // session-lease signal is silently dropped, and /ws/voice loops on 503 voice_capability_not_ready.\n var openAIVoiceAvailable = IsOpenAIVoiceConfigured(openAIProviderConfig) || nyxIdRealtimeBrokerEnabled;\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIVoiceAvailable,\n miniCpmProviderConfig);\n\n if (openAIVoiceAvailable)\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n return await ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger: null,\n ct);\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionWithLoggingAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n ILogger<ReadinessGatedRealtimeVoiceProviderSession>? logger,\n CancellationToken ct)\n {\n return await VoiceRealtimeSessionReadinessBootstrapper.ConnectAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger,\n ct);\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n bool openAIVoiceAvailable,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n openAIVoiceAvailable)\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (openAIVoiceAvailable)\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): the broker slug defaults to\n // NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug so voice stays enabled even when the\n // deployment config/secret is wiped on redeploy — config only overrides the slug, never disables it.\n private static string ResolveNyxIdRealtimeServiceSlug(IConfiguration configuration)\n {\n var configured = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:ServiceSlug\"]?.Trim();\n return string.IsNullOrWhiteSpace(configured)\n ? NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug\n : configured;\n }\n\n private static bool IsNyxIdRealtimeBrokerEnabled(IConfiguration configuration) =>\n !string.IsNullOrWhiteSpace(ResolveNyxIdRealtimeServiceSlug(configuration));\n\n private static NyxIdRealtimeProviderCredentialOptions BuildNyxIdRealtimeCredentialOptions(\n IConfiguration configuration)\n {\n var options = new NyxIdRealtimeProviderCredentialOptions\n {\n ServiceSlug = ResolveNyxIdRealtimeServiceSlug(configuration),\n };\n\n var mintPath = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:MintPath\"];\n if (!string.IsNullOrWhiteSpace(mintPath))\n options.MintPath = mintPath.Trim();\n\n var model = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:Model\"];\n if (!string.IsNullOrWhiteSpace(model))\n options.Model = model.Trim();\n\n return options;\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n DrainTimeout = options.DrainTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n var loggerFactory = sp.GetService<ILoggerFactory>();\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, loggerFactory),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, sp.GetService<ILoggerFactory>());\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor,\n ILoggerFactory? loggerFactory = null)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options, loggerFactory);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName, loggerFactory);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options, loggerFactory);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options,\n ILoggerFactory? loggerFactory = null)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName, loggerFactory);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName, loggerFactory);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new MEAILLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<MEAILLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new TornadoLLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<TornadoLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new NyxIdLLMProviderFactory();\n // Without an explicit logger the provider chain (NyxIdLLMProvider and the\n // MEAILLMProvider it delegates to) falls back to NullLogger, which silences\n // upstream LLM error translations and the no-chunks streaming fallback in\n // production. Always wire the host logger when one is available.\n var providerLogger = loggerFactory?.CreateLogger<NyxIdLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null,\n provider.DefaultRoutePreference,\n providerLogger);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = ResolveNyxIdDefaultModel(configuration, options);\n var defaultRoute = ResolveNyxIdDefaultRoute(configuration);\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty, defaultRoute);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n var defaultRoute = IsNyxIdProviderType(semantic.ProviderType)\n ? ResolveNyxIdDefaultRoute(configuration)\n : null;\n configured.Add(new ConfiguredProvider(\n name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim(), defaultRoute));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", ResolveNyxIdDefaultModel(configuration, options), ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n // The default route/model literals live once in LlmDefaults (Aevatar.AI.Abstractions) so the\n // NyxID server-default path and the OpenAI-compatible Responses ingress default share one\n // source and cannot drift. Every nyxid registration path resolves its default through these\n // helpers, which apply per-deployment config overrides on top.\n private static string ResolveNyxIdDefaultRoute(IConfiguration configuration) =>\n configuration[\"Aevatar:NyxId:DefaultRoute\"] is { } route && !string.IsNullOrWhiteSpace(route)\n ? route.Trim()\n : LlmDefaults.NyxIdRoute;\n\n private static string ResolveNyxIdDefaultModel(IConfiguration configuration, AevatarAIFeatureOptions options) =>\n configuration[\"Aevatar:NyxId:DefaultModel\"] is { Length: > 0 } model\n ? model\n : options.OpenAIModel;\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey,\n string? DefaultRoutePreference = null);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug.Trim();\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterSystemSkillOverlay(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n if (!options.EnableSystemSkillOverlay || string.IsNullOrWhiteSpace(options.SystemSkillOverlaySetName))\n return;\n\n // Ensure the Ornn skill client (with the configured slug) is registered even when a host enables\n // the overlay without the Ornn skill tools; the overlay reads the set through this client.\n services.AddOrnnSkillClient(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug.Trim();\n });\n\n services.AddSystemSkillOverlay(o =>\n {\n o.Enabled = options.EnableSystemSkillOverlay;\n o.SetName = options.SystemSkillOverlaySetName ?? string.Empty;\n o.RefreshTtl = options.SystemSkillOverlayRefreshTtl;\n o.MaxSkills = options.SystemSkillOverlayMaxSkills;\n o.MaxBytes = options.SystemSkillOverlayMaxBytes;\n });\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n}\n"}, "34779641a8795833bd306f1cf4e8be30e3cd588c": {"t": 1785756599.3034964, "n": 55676, "h": "dfb73d6e9a6f398a", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Auditing;\nusing Aevatar.AI.Core.DependencyInjection;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.NyxId;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.Credentials;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\nusing Microsoft.Extensions.Options;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n /// <summary>\n /// Enables the host-bound system skill overlay scaffold. Mainnet-style hosts bind this from\n /// <c>Aevatar:SystemSkills:Enabled</c>.\n /// </summary>\n public bool EnableSystemSkillOverlay { get; set; }\n /// <summary>\n /// Non-secret name of the public, org-owned Ornn skillset that sources the overlay (issue #2498).\n /// Bound from <c>Aevatar:SystemSkills:SetName</c>. No organization service token is needed: set\n /// ownership is the trust anchor and the set is read publicly through the existing ornn-api proxy.\n /// </summary>\n public string? SystemSkillOverlaySetName { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:RefreshTtl</c>.\n /// </summary>\n public TimeSpan SystemSkillOverlayRefreshTtl { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:MaxSkills</c>.\n /// </summary>\n public int SystemSkillOverlayMaxSkills { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:MaxBytes</c>.\n /// </summary>\n public int SystemSkillOverlayMaxBytes { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = LlmDefaults.Model;\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n configure?.Invoke(options);\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n services.AddAgentToolExecution();\n services.TryAddSingleton<IVoiceToolInvoker>(sp => new AgentToolVoiceInvoker(\n sp.GetServices<IAgentToolSource>(),\n sp.GetRequiredService<IAgentToolExecutionPort>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceInvoker>>()));\n services.TryAddSingleton<IVoiceToolCatalog>(sp => new AgentToolVoiceCatalog(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceCatalog>>()));\n services.TryAddSingleton<IVoicePresenceCapabilityCommandPort, VoicePresenceCapabilityCommandPort>();\n // Zero-config /ws/voice: auto-provision a never-enabled default voice agent on first connect by\n // committing the same enable voice-presence/enable issues. The attach path (ActorOwnedVoiceRealtimeSession)\n // resolves this optionally and only invokes it when the capability is still null after the\n // re-projection self-heal — i.e. a genuinely-unprovisioned default agent.\n services.TryAddSingleton<IVoicePresenceCapabilityAutoEnablePort, VoicePresenceCapabilityAutoEnablePort>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableSystemSkillOverlay)\n RegisterSystemSkillOverlay(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): when the OpenAI realtime credential is\n // brokered through NyxID, register the resolver so the provider mints a per-session ephemeral\n // instead of reading a static OPENAI_API_KEY. Requires NyxID tools (INyxIdApiClientFactory) wired.\n var nyxIdRealtimeCredentialOptions = BuildNyxIdRealtimeCredentialOptions(configuration);\n if (nyxIdRealtimeCredentialOptions.Enabled)\n {\n services.TryAddSingleton(nyxIdRealtimeCredentialOptions);\n services.TryAddSingleton<IRealtimeProviderCredentialResolver>(sp => new NyxIdRealtimeProviderCredentialResolver(\n sp.GetRequiredService<INyxIdApiClientFactory>(),\n sp.GetRequiredService<NyxIdRealtimeProviderCredentialOptions>(),\n sp.GetRequiredService<ILogger<NyxIdRealtimeProviderCredentialResolver>>(),\n ResolveVoiceCredentialProviders(sp)));\n }\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceLeaseObservationPort, VoicePresenceLeaseObservationPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton(sp => new VoiceVolatileToolCredentialPort(sp.GetService<TimeProvider>()));\n services.TryAddSingleton<IVoiceVolatileToolCredentialPort>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.TryAddSingleton<IVoiceToolCredentialIssuer>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.AddOptions<VoiceWebSocketAttachOptions>();\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IValidateOptions<VoiceWebSocketAttachOptions>, VoiceWebSocketAttachOptionsValidator>();\n services.TryAddSingleton<VoiceWebSocketAttachExecutor>();\n services.AddVoiceWebRtcTransport();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IReadOnlyList<ICredentialProvider> ResolveVoiceCredentialProviders(IServiceProvider services)\n {\n var providers = new List<ICredentialProvider>();\n var voiceCredentialProvider = services.GetService<IVoiceToolCredentialIssuer>() as ICredentialProvider;\n if (voiceCredentialProvider is not null)\n providers.Add(voiceCredentialProvider);\n\n foreach (var provider in services.GetServices<ICredentialProvider>())\n {\n if (!ReferenceEquals(provider, voiceCredentialProvider))\n providers.Add(provider);\n }\n\n return providers;\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var nyxIdRealtimeBrokerEnabled = IsNyxIdRealtimeBrokerEnabled(configuration);\n // The openai module registers below when EITHER a raw ApiKey OR the NyxID ephemeral broker is\n // available (ADR-0033 forbids long-lived keys in production, so broker-only is the normal shape).\n // Default-provider resolution must use the same availability, or broker-only deployments register\n // the module solely as \"voice_presence_openai\" while the auto-enable/mount/read default path uses\n // \"voice_presence\" — the module factory then never mounts a module for the enabled name, the\n // session-lease signal is silently dropped, and /ws/voice loops on 503 voice_capability_not_ready.\n var openAIVoiceAvailable = IsOpenAIVoiceConfigured(openAIProviderConfig) || nyxIdRealtimeBrokerEnabled;\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIVoiceAvailable,\n miniCpmProviderConfig);\n\n if (openAIVoiceAvailable)\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n return await ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger: null,\n ct);\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionWithLoggingAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n ILogger<ReadinessGatedRealtimeVoiceProviderSession>? logger,\n CancellationToken ct)\n {\n return await VoiceRealtimeSessionReadinessBootstrapper.ConnectAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger,\n ct);\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n bool openAIVoiceAvailable,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n openAIVoiceAvailable)\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (openAIVoiceAvailable)\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): the broker slug defaults to\n // NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug so voice stays enabled even when the\n // deployment config/secret is wiped on redeploy — config only overrides the slug, never disables it.\n private static string ResolveNyxIdRealtimeServiceSlug(IConfiguration configuration)\n {\n var configured = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:ServiceSlug\"]?.Trim();\n return string.IsNullOrWhiteSpace(configured)\n ? NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug\n : configured;\n }\n\n private static bool IsNyxIdRealtimeBrokerEnabled(IConfiguration configuration) =>\n !string.IsNullOrWhiteSpace(ResolveNyxIdRealtimeServiceSlug(configuration));\n\n private static NyxIdRealtimeProviderCredentialOptions BuildNyxIdRealtimeCredentialOptions(\n IConfiguration configuration)\n {\n var options = new NyxIdRealtimeProviderCredentialOptions\n {\n ServiceSlug = ResolveNyxIdRealtimeServiceSlug(configuration),\n };\n\n var mintPath = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:MintPath\"];\n if (!string.IsNullOrWhiteSpace(mintPath))\n options.MintPath = mintPath.Trim();\n\n var model = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:Model\"];\n if (!string.IsNullOrWhiteSpace(model))\n options.Model = model.Trim();\n\n return options;\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n DrainTimeout = options.DrainTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n var loggerFactory = sp.GetService<ILoggerFactory>();\n var toolExecutionPort = new ServiceProviderAgentToolExecutionPort(sp);\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, toolExecutionPort, loggerFactory),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(\n configuration,\n options,\n secretsStoreAccessor,\n new ServiceProviderAgentToolExecutionPort(sp),\n sp.GetService<ILoggerFactory>());\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor,\n IAgentToolExecutionPort toolExecutionPort,\n ILoggerFactory? loggerFactory = null)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options, toolExecutionPort, loggerFactory);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName, toolExecutionPort, loggerFactory);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options, toolExecutionPort, loggerFactory);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options,\n IAgentToolExecutionPort toolExecutionPort,\n ILoggerFactory? loggerFactory = null)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName, toolExecutionPort, loggerFactory);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName, loggerFactory);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n IAgentToolExecutionPort toolExecutionPort,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new MEAILLMProviderFactory(toolExecutionPort);\n var providerLogger = loggerFactory?.CreateLogger<MEAILLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new TornadoLLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<TornadoLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n IAgentToolExecutionPort toolExecutionPort,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new NyxIdLLMProviderFactory(toolExecutionPort);\n // Without an explicit logger the provider chain (NyxIdLLMProvider and the\n // MEAILLMProvider it delegates to) falls back to NullLogger, which silences\n // upstream LLM error translations and the no-chunks streaming fallback in\n // production. Always wire the host logger when one is available.\n var providerLogger = loggerFactory?.CreateLogger<NyxIdLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null,\n provider.DefaultRoutePreference,\n providerLogger);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = ResolveNyxIdDefaultModel(configuration, options);\n var defaultRoute = ResolveNyxIdDefaultRoute(configuration);\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty, defaultRoute);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n var defaultRoute = IsNyxIdProviderType(semantic.ProviderType)\n ? ResolveNyxIdDefaultRoute(configuration)\n : null;\n configured.Add(new ConfiguredProvider(\n name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim(), defaultRoute));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", ResolveNyxIdDefaultModel(configuration, options), ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n // The default route/model literals live once in LlmDefaults (Aevatar.AI.Abstractions) so the\n // NyxID server-default path and the OpenAI-compatible Responses ingress default share one\n // source and cannot drift. Every nyxid registration path resolves its default through these\n // helpers, which apply per-deployment config overrides on top.\n private static string ResolveNyxIdDefaultRoute(IConfiguration configuration) =>\n configuration[\"Aevatar:NyxId:DefaultRoute\"] is { } route && !string.IsNullOrWhiteSpace(route)\n ? route.Trim()\n : LlmDefaults.NyxIdRoute;\n\n private static string ResolveNyxIdDefaultModel(IConfiguration configuration, AevatarAIFeatureOptions options) =>\n configuration[\"Aevatar:NyxId:DefaultModel\"] is { Length: > 0 } model\n ? model\n : options.OpenAIModel;\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey,\n string? DefaultRoutePreference = null);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug.Trim();\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterSystemSkillOverlay(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n if (!options.EnableSystemSkillOverlay || string.IsNullOrWhiteSpace(options.SystemSkillOverlaySetName))\n return;\n\n // Ensure the Ornn skill client (with the configured slug) is registered even when a host enables\n // the overlay without the Ornn skill tools; the overlay reads the set through this client.\n services.AddOrnnSkillClient(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug.Trim();\n });\n\n services.AddSystemSkillOverlay(o =>\n {\n o.Enabled = options.EnableSystemSkillOverlay;\n o.SetName = options.SystemSkillOverlaySetName ?? string.Empty;\n o.RefreshTtl = options.SystemSkillOverlayRefreshTtl;\n o.MaxSkills = options.SystemSkillOverlayMaxSkills;\n o.MaxBytes = options.SystemSkillOverlayMaxBytes;\n });\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n\n private sealed class ServiceProviderAgentToolExecutionPort(IServiceProvider serviceProvider) : IAgentToolExecutionPort\n {\n public Task<AgentToolExecutionOutcome> ExecuteAsync(\n AgentToolExecutionRequest request,\n CancellationToken ct = default) =>\n serviceProvider.GetRequiredService<IAgentToolExecutionPort>().ExecuteAsync(request, ct);\n }\n}\n"}, "4d8088048f94af4814d2b6ff25f18b3ccba7d658": {"t": 1785756600.7476141, "n": 55290, "h": "fe3111a1ed93ac0f", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Auditing;\nusing Aevatar.AI.Core.DependencyInjection;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.NyxId;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.Credentials;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\nusing Microsoft.Extensions.Options;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n /// <summary>\n /// Enables the host-bound system skill overlay scaffold. Mainnet-style hosts bind this from\n /// <c>Aevatar:SystemSkills:Enabled</c>.\n /// </summary>\n public bool EnableSystemSkillOverlay { get; set; }\n /// <summary>\n /// Non-secret name of the public, org-owned Ornn skillset that sources the overlay (issue #2498).\n /// Bound from <c>Aevatar:SystemSkills:SetName</c>. No organization service token is needed: set\n /// ownership is the trust anchor and the set is read publicly through the existing ornn-api proxy.\n /// </summary>\n public string? SystemSkillOverlaySetName { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:RefreshTtl</c>.\n /// </summary>\n public TimeSpan SystemSkillOverlayRefreshTtl { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:MaxSkills</c>.\n /// </summary>\n public int SystemSkillOverlayMaxSkills { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:MaxBytes</c>.\n /// </summary>\n public int SystemSkillOverlayMaxBytes { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = LlmDefaults.Model;\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n configure?.Invoke(options);\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n services.AddAgentToolExecution();\n services.TryAddSingleton<IVoiceToolInvoker>(sp => new AgentToolVoiceInvoker(\n sp.GetServices<IAgentToolSource>(),\n sp.GetRequiredService<IAgentToolExecutionPort>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceInvoker>>()));\n services.TryAddSingleton<IVoiceToolCatalog>(sp => new AgentToolVoiceCatalog(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceCatalog>>()));\n services.TryAddSingleton<IVoicePresenceCapabilityCommandPort, VoicePresenceCapabilityCommandPort>();\n // Zero-config /ws/voice: auto-provision a never-enabled default voice agent on first connect by\n // committing the same enable voice-presence/enable issues. The attach path (ActorOwnedVoiceRealtimeSession)\n // resolves this optionally and only invokes it when the capability is still null after the\n // re-projection self-heal — i.e. a genuinely-unprovisioned default agent.\n services.TryAddSingleton<IVoicePresenceCapabilityAutoEnablePort, VoicePresenceCapabilityAutoEnablePort>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableSystemSkillOverlay)\n RegisterSystemSkillOverlay(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): when the OpenAI realtime credential is\n // brokered through NyxID, register the resolver so the provider mints a per-session ephemeral\n // instead of reading a static OPENAI_API_KEY. Requires NyxID tools (INyxIdApiClientFactory) wired.\n var nyxIdRealtimeCredentialOptions = BuildNyxIdRealtimeCredentialOptions(configuration);\n if (nyxIdRealtimeCredentialOptions.Enabled)\n {\n services.TryAddSingleton(nyxIdRealtimeCredentialOptions);\n services.TryAddSingleton<IRealtimeProviderCredentialResolver>(sp => new NyxIdRealtimeProviderCredentialResolver(\n sp.GetRequiredService<INyxIdApiClientFactory>(),\n sp.GetRequiredService<NyxIdRealtimeProviderCredentialOptions>(),\n sp.GetRequiredService<ILogger<NyxIdRealtimeProviderCredentialResolver>>(),\n ResolveVoiceCredentialProviders(sp)));\n }\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceLeaseObservationPort, VoicePresenceLeaseObservationPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton(sp => new VoiceVolatileToolCredentialPort(sp.GetService<TimeProvider>()));\n services.TryAddSingleton<IVoiceVolatileToolCredentialPort>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.TryAddSingleton<IVoiceToolCredentialIssuer>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.AddOptions<VoiceWebSocketAttachOptions>();\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IValidateOptions<VoiceWebSocketAttachOptions>, VoiceWebSocketAttachOptionsValidator>();\n services.TryAddSingleton<VoiceWebSocketAttachExecutor>();\n services.AddVoiceWebRtcTransport();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IReadOnlyList<ICredentialProvider> ResolveVoiceCredentialProviders(IServiceProvider services)\n {\n var providers = new List<ICredentialProvider>();\n var voiceCredentialProvider = services.GetService<IVoiceToolCredentialIssuer>() as ICredentialProvider;\n if (voiceCredentialProvider is not null)\n providers.Add(voiceCredentialProvider);\n\n foreach (var provider in services.GetServices<ICredentialProvider>())\n {\n if (!ReferenceEquals(provider, voiceCredentialProvider))\n providers.Add(provider);\n }\n\n return providers;\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var nyxIdRealtimeBrokerEnabled = IsNyxIdRealtimeBrokerEnabled(configuration);\n // The openai module registers below when EITHER a raw ApiKey OR the NyxID ephemeral broker is\n // available (ADR-0033 forbids long-lived keys in production, so broker-only is the normal shape).\n // Default-provider resolution must use the same availability, or broker-only deployments register\n // the module solely as \"voice_presence_openai\" while the auto-enable/mount/read default path uses\n // \"voice_presence\" — the module factory then never mounts a module for the enabled name, the\n // session-lease signal is silently dropped, and /ws/voice loops on 503 voice_capability_not_ready.\n var openAIVoiceAvailable = IsOpenAIVoiceConfigured(openAIProviderConfig) || nyxIdRealtimeBrokerEnabled;\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIVoiceAvailable,\n miniCpmProviderConfig);\n\n if (openAIVoiceAvailable)\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n return await ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger: null,\n ct);\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionWithLoggingAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n ILogger<ReadinessGatedRealtimeVoiceProviderSession>? logger,\n CancellationToken ct)\n {\n return await VoiceRealtimeSessionReadinessBootstrapper.ConnectAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger,\n ct);\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n bool openAIVoiceAvailable,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n openAIVoiceAvailable)\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (openAIVoiceAvailable)\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): the broker slug defaults to\n // NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug so voice stays enabled even when the\n // deployment config/secret is wiped on redeploy — config only overrides the slug, never disables it.\n private static string ResolveNyxIdRealtimeServiceSlug(IConfiguration configuration)\n {\n var configured = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:ServiceSlug\"]?.Trim();\n return string.IsNullOrWhiteSpace(configured)\n ? NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug\n : configured;\n }\n\n private static bool IsNyxIdRealtimeBrokerEnabled(IConfiguration configuration) =>\n !string.IsNullOrWhiteSpace(ResolveNyxIdRealtimeServiceSlug(configuration));\n\n private static NyxIdRealtimeProviderCredentialOptions BuildNyxIdRealtimeCredentialOptions(\n IConfiguration configuration)\n {\n var options = new NyxIdRealtimeProviderCredentialOptions\n {\n ServiceSlug = ResolveNyxIdRealtimeServiceSlug(configuration),\n };\n\n var mintPath = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:MintPath\"];\n if (!string.IsNullOrWhiteSpace(mintPath))\n options.MintPath = mintPath.Trim();\n\n var model = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:Model\"];\n if (!string.IsNullOrWhiteSpace(model))\n options.Model = model.Trim();\n\n return options;\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n DrainTimeout = options.DrainTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n var loggerFactory = sp.GetService<ILoggerFactory>();\n var toolExecutionPort = sp.GetRequiredService<IAgentToolExecutionPort>();\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, toolExecutionPort, loggerFactory),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(\n configuration,\n options,\n secretsStoreAccessor,\n sp.GetRequiredService<IAgentToolExecutionPort>(),\n sp.GetService<ILoggerFactory>());\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor,\n IAgentToolExecutionPort toolExecutionPort,\n ILoggerFactory? loggerFactory = null)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options, toolExecutionPort, loggerFactory);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName, toolExecutionPort, loggerFactory);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options, toolExecutionPort, loggerFactory);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options,\n IAgentToolExecutionPort toolExecutionPort,\n ILoggerFactory? loggerFactory = null)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName, toolExecutionPort, loggerFactory);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName, loggerFactory);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n IAgentToolExecutionPort toolExecutionPort,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new MEAILLMProviderFactory(toolExecutionPort);\n var providerLogger = loggerFactory?.CreateLogger<MEAILLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new TornadoLLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<TornadoLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n IAgentToolExecutionPort toolExecutionPort,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new NyxIdLLMProviderFactory(toolExecutionPort);\n // Without an explicit logger the provider chain (NyxIdLLMProvider and the\n // MEAILLMProvider it delegates to) falls back to NullLogger, which silences\n // upstream LLM error translations and the no-chunks streaming fallback in\n // production. Always wire the host logger when one is available.\n var providerLogger = loggerFactory?.CreateLogger<NyxIdLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null,\n provider.DefaultRoutePreference,\n providerLogger);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = ResolveNyxIdDefaultModel(configuration, options);\n var defaultRoute = ResolveNyxIdDefaultRoute(configuration);\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty, defaultRoute);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n var defaultRoute = IsNyxIdProviderType(semantic.ProviderType)\n ? ResolveNyxIdDefaultRoute(configuration)\n : null;\n configured.Add(new ConfiguredProvider(\n name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim(), defaultRoute));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", ResolveNyxIdDefaultModel(configuration, options), ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n // The default route/model literals live once in LlmDefaults (Aevatar.AI.Abstractions) so the\n // NyxID server-default path and the OpenAI-compatible Responses ingress default share one\n // source and cannot drift. Every nyxid registration path resolves its default through these\n // helpers, which apply per-deployment config overrides on top.\n private static string ResolveNyxIdDefaultRoute(IConfiguration configuration) =>\n configuration[\"Aevatar:NyxId:DefaultRoute\"] is { } route && !string.IsNullOrWhiteSpace(route)\n ? route.Trim()\n : LlmDefaults.NyxIdRoute;\n\n private static string ResolveNyxIdDefaultModel(IConfiguration configuration, AevatarAIFeatureOptions options) =>\n configuration[\"Aevatar:NyxId:DefaultModel\"] is { Length: > 0 } model\n ? model\n : options.OpenAIModel;\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey,\n string? DefaultRoutePreference = null);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug.Trim();\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterSystemSkillOverlay(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n if (!options.EnableSystemSkillOverlay || string.IsNullOrWhiteSpace(options.SystemSkillOverlaySetName))\n return;\n\n // Ensure the Ornn skill client (with the configured slug) is registered even when a host enables\n // the overlay without the Ornn skill tools; the overlay reads the set through this client.\n services.AddOrnnSkillClient(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug.Trim();\n });\n\n services.AddSystemSkillOverlay(o =>\n {\n o.Enabled = options.EnableSystemSkillOverlay;\n o.SetName = options.SystemSkillOverlaySetName ?? string.Empty;\n o.RefreshTtl = options.SystemSkillOverlayRefreshTtl;\n o.MaxSkills = options.SystemSkillOverlayMaxSkills;\n o.MaxBytes = options.SystemSkillOverlayMaxBytes;\n });\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n}\n"}, "1eb93b0ab2c2c1ccb3808841d85697345a4981ee": {"t": 1785756602.3333144, "n": 54736, "h": "53005dbec8b0f30b", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Auditing;\nusing Aevatar.AI.Core.DependencyInjection;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.NyxId;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.Credentials;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\nusing Microsoft.Extensions.Options;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n /// <summary>\n /// Enables the host-bound system skill overlay scaffold. Mainnet-style hosts bind this from\n /// <c>Aevatar:SystemSkills:Enabled</c>.\n /// </summary>\n public bool EnableSystemSkillOverlay { get; set; }\n /// <summary>\n /// Non-secret name of the public, org-owned Ornn skillset that sources the overlay (issue #2498).\n /// Bound from <c>Aevatar:SystemSkills:SetName</c>. No organization service token is needed: set\n /// ownership is the trust anchor and the set is read publicly through the existing ornn-api proxy.\n /// </summary>\n public string? SystemSkillOverlaySetName { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:RefreshTtl</c>.\n /// </summary>\n public TimeSpan SystemSkillOverlayRefreshTtl { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:MaxSkills</c>.\n /// </summary>\n public int SystemSkillOverlayMaxSkills { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:MaxBytes</c>.\n /// </summary>\n public int SystemSkillOverlayMaxBytes { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = LlmDefaults.Model;\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n configure?.Invoke(options);\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n services.AddAgentToolExecution();\n services.TryAddSingleton<IVoiceToolInvoker>(sp => new AgentToolVoiceInvoker(\n sp.GetServices<IAgentToolSource>(),\n sp.GetRequiredService<IAgentToolExecutionPort>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceInvoker>>()));\n services.TryAddSingleton<IVoiceToolCatalog>(sp => new AgentToolVoiceCatalog(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceCatalog>>()));\n services.TryAddSingleton<IVoicePresenceCapabilityCommandPort, VoicePresenceCapabilityCommandPort>();\n // Zero-config /ws/voice: auto-provision a never-enabled default voice agent on first connect by\n // committing the same enable voice-presence/enable issues. The attach path (ActorOwnedVoiceRealtimeSession)\n // resolves this optionally and only invokes it when the capability is still null after the\n // re-projection self-heal — i.e. a genuinely-unprovisioned default agent.\n services.TryAddSingleton<IVoicePresenceCapabilityAutoEnablePort, VoicePresenceCapabilityAutoEnablePort>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableSystemSkillOverlay)\n RegisterSystemSkillOverlay(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): when the OpenAI realtime credential is\n // brokered through NyxID, register the resolver so the provider mints a per-session ephemeral\n // instead of reading a static OPENAI_API_KEY. Requires NyxID tools (INyxIdApiClientFactory) wired.\n var nyxIdRealtimeCredentialOptions = BuildNyxIdRealtimeCredentialOptions(configuration);\n if (nyxIdRealtimeCredentialOptions.Enabled)\n {\n services.TryAddSingleton(nyxIdRealtimeCredentialOptions);\n services.TryAddSingleton<IRealtimeProviderCredentialResolver>(sp => new NyxIdRealtimeProviderCredentialResolver(\n sp.GetRequiredService<INyxIdApiClientFactory>(),\n sp.GetRequiredService<NyxIdRealtimeProviderCredentialOptions>(),\n sp.GetRequiredService<ILogger<NyxIdRealtimeProviderCredentialResolver>>(),\n ResolveVoiceCredentialProviders(sp)));\n }\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceLeaseObservationPort, VoicePresenceLeaseObservationPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton(sp => new VoiceVolatileToolCredentialPort(sp.GetService<TimeProvider>()));\n services.TryAddSingleton<IVoiceVolatileToolCredentialPort>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.TryAddSingleton<IVoiceToolCredentialIssuer>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.AddOptions<VoiceWebSocketAttachOptions>();\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IValidateOptions<VoiceWebSocketAttachOptions>, VoiceWebSocketAttachOptionsValidator>();\n services.TryAddSingleton<VoiceWebSocketAttachExecutor>();\n services.AddVoiceWebRtcTransport();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IReadOnlyList<ICredentialProvider> ResolveVoiceCredentialProviders(IServiceProvider services)\n {\n var providers = new List<ICredentialProvider>();\n var voiceCredentialProvider = services.GetService<IVoiceToolCredentialIssuer>() as ICredentialProvider;\n if (voiceCredentialProvider is not null)\n providers.Add(voiceCredentialProvider);\n\n foreach (var provider in services.GetServices<ICredentialProvider>())\n {\n if (!ReferenceEquals(provider, voiceCredentialProvider))\n providers.Add(provider);\n }\n\n return providers;\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var nyxIdRealtimeBrokerEnabled = IsNyxIdRealtimeBrokerEnabled(configuration);\n // The openai module registers below when EITHER a raw ApiKey OR the NyxID ephemeral broker is\n // available (ADR-0033 forbids long-lived keys in production, so broker-only is the normal shape).\n // Default-provider resolution must use the same availability, or broker-only deployments register\n // the module solely as \"voice_presence_openai\" while the auto-enable/mount/read default path uses\n // \"voice_presence\" — the module factory then never mounts a module for the enabled name, the\n // session-lease signal is silently dropped, and /ws/voice loops on 503 voice_capability_not_ready.\n var openAIVoiceAvailable = IsOpenAIVoiceConfigured(openAIProviderConfig) || nyxIdRealtimeBrokerEnabled;\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIVoiceAvailable,\n miniCpmProviderConfig);\n\n if (openAIVoiceAvailable)\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n return await ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger: null,\n ct);\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionWithLoggingAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n ILogger<ReadinessGatedRealtimeVoiceProviderSession>? logger,\n CancellationToken ct)\n {\n return await VoiceRealtimeSessionReadinessBootstrapper.ConnectAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger,\n ct);\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n bool openAIVoiceAvailable,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n openAIVoiceAvailable)\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (openAIVoiceAvailable)\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): the broker slug defaults to\n // NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug so voice stays enabled even when the\n // deployment config/secret is wiped on redeploy — config only overrides the slug, never disables it.\n private static string ResolveNyxIdRealtimeServiceSlug(IConfiguration configuration)\n {\n var configured = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:ServiceSlug\"]?.Trim();\n return string.IsNullOrWhiteSpace(configured)\n ? NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug\n : configured;\n }\n\n private static bool IsNyxIdRealtimeBrokerEnabled(IConfiguration configuration) =>\n !string.IsNullOrWhiteSpace(ResolveNyxIdRealtimeServiceSlug(configuration));\n\n private static NyxIdRealtimeProviderCredentialOptions BuildNyxIdRealtimeCredentialOptions(\n IConfiguration configuration)\n {\n var options = new NyxIdRealtimeProviderCredentialOptions\n {\n ServiceSlug = ResolveNyxIdRealtimeServiceSlug(configuration),\n };\n\n var mintPath = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:MintPath\"];\n if (!string.IsNullOrWhiteSpace(mintPath))\n options.MintPath = mintPath.Trim();\n\n var model = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:Model\"];\n if (!string.IsNullOrWhiteSpace(model))\n options.Model = model.Trim();\n\n return options;\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n DrainTimeout = options.DrainTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n var loggerFactory = sp.GetService<ILoggerFactory>();\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, loggerFactory),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, sp.GetService<ILoggerFactory>());\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor,\n ILoggerFactory? loggerFactory = null)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options, loggerFactory);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName, loggerFactory);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options, loggerFactory);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options,\n ILoggerFactory? loggerFactory = null)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName, loggerFactory);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName, loggerFactory);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new MEAILLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<MEAILLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new TornadoLLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<TornadoLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new NyxIdLLMProviderFactory();\n // Without an explicit logger the provider chain (NyxIdLLMProvider and the\n // MEAILLMProvider it delegates to) falls back to NullLogger, which silences\n // upstream LLM error translations and the no-chunks streaming fallback in\n // production. Always wire the host logger when one is available.\n var providerLogger = loggerFactory?.CreateLogger<NyxIdLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null,\n provider.DefaultRoutePreference,\n providerLogger);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = ResolveNyxIdDefaultModel(configuration, options);\n var defaultRoute = ResolveNyxIdDefaultRoute(configuration);\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty, defaultRoute);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n var defaultRoute = IsNyxIdProviderType(semantic.ProviderType)\n ? ResolveNyxIdDefaultRoute(configuration)\n : null;\n configured.Add(new ConfiguredProvider(\n name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim(), defaultRoute));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", ResolveNyxIdDefaultModel(configuration, options), ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n // The default route/model literals live once in LlmDefaults (Aevatar.AI.Abstractions) so the\n // NyxID server-default path and the OpenAI-compatible Responses ingress default share one\n // source and cannot drift. Every nyxid registration path resolves its default through these\n // helpers, which apply per-deployment config overrides on top.\n private static string ResolveNyxIdDefaultRoute(IConfiguration configuration) =>\n configuration[\"Aevatar:NyxId:DefaultRoute\"] is { } route && !string.IsNullOrWhiteSpace(route)\n ? route.Trim()\n : LlmDefaults.NyxIdRoute;\n\n private static string ResolveNyxIdDefaultModel(IConfiguration configuration, AevatarAIFeatureOptions options) =>\n configuration[\"Aevatar:NyxId:DefaultModel\"] is { Length: > 0 } model\n ? model\n : options.OpenAIModel;\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey,\n string? DefaultRoutePreference = null);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug.Trim();\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterSystemSkillOverlay(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n if (!options.EnableSystemSkillOverlay || string.IsNullOrWhiteSpace(options.SystemSkillOverlaySetName))\n return;\n\n // Ensure the Ornn skill client (with the configured slug) is registered even when a host enables\n // the overlay without the Ornn skill tools; the overlay reads the set through this client.\n services.AddOrnnSkillClient(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug.Trim();\n });\n\n services.AddSystemSkillOverlay(o =>\n {\n o.Enabled = options.EnableSystemSkillOverlay;\n o.SetName = options.SystemSkillOverlaySetName ?? string.Empty;\n o.RefreshTtl = options.SystemSkillOverlayRefreshTtl;\n o.MaxSkills = options.SystemSkillOverlayMaxSkills;\n o.MaxBytes = options.SystemSkillOverlayMaxBytes;\n });\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n}\n"}, "f4b75f3413c41142ec65d84fbbf6419f84ab7253": {"t": 1785756604.1607444, "n": 54982, "h": "9001865436ea31ea", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Auditing;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.NyxId;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.Credentials;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\nusing Microsoft.Extensions.Options;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n /// <summary>\n /// Enables the host-bound system skill overlay scaffold. Mainnet-style hosts bind this from\n /// <c>Aevatar:SystemSkills:Enabled</c>.\n /// </summary>\n public bool EnableSystemSkillOverlay { get; set; }\n /// <summary>\n /// Non-secret name of the public, org-owned Ornn skillset that sources the overlay (issue #2498).\n /// Bound from <c>Aevatar:SystemSkills:SetName</c>. No organization service token is needed: set\n /// ownership is the trust anchor and the set is read publicly through the existing ornn-api proxy.\n /// </summary>\n public string? SystemSkillOverlaySetName { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:RefreshTtl</c>.\n /// </summary>\n public TimeSpan SystemSkillOverlayRefreshTtl { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:MaxSkills</c>.\n /// </summary>\n public int SystemSkillOverlayMaxSkills { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:MaxBytes</c>.\n /// </summary>\n public int SystemSkillOverlayMaxBytes { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = LlmDefaults.Model;\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n configure?.Invoke(options);\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n services.AddToolExecutionAuditObserver();\n // No container-level IToolApprovalHandler: a yielding handler is only valid on\n // actors that implement the pending-approval continuation (RoleGAgent wires its\n // own). Surfaces without that capability fall back to MissingApprovalHandler and\n // fail closed instead of stranding a dead-letter approval (#2004).\n services.TryAddSingleton<IVoiceToolInvoker>(sp => new AgentToolVoiceInvoker(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceInvoker>>()));\n services.TryAddSingleton<IVoiceToolCatalog>(sp => new AgentToolVoiceCatalog(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceCatalog>>()));\n services.TryAddSingleton<IVoicePresenceCapabilityCommandPort, VoicePresenceCapabilityCommandPort>();\n // Zero-config /ws/voice: auto-provision a never-enabled default voice agent on first connect by\n // committing the same enable voice-presence/enable issues. The attach path (ActorOwnedVoiceRealtimeSession)\n // resolves this optionally and only invokes it when the capability is still null after the\n // re-projection self-heal — i.e. a genuinely-unprovisioned default agent.\n services.TryAddSingleton<IVoicePresenceCapabilityAutoEnablePort, VoicePresenceCapabilityAutoEnablePort>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableSystemSkillOverlay)\n RegisterSystemSkillOverlay(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): when the OpenAI realtime credential is\n // brokered through NyxID, register the resolver so the provider mints a per-session ephemeral\n // instead of reading a static OPENAI_API_KEY. Requires NyxID tools (INyxIdApiClientFactory) wired.\n var nyxIdRealtimeCredentialOptions = BuildNyxIdRealtimeCredentialOptions(configuration);\n if (nyxIdRealtimeCredentialOptions.Enabled)\n {\n services.TryAddSingleton(nyxIdRealtimeCredentialOptions);\n services.TryAddSingleton<IRealtimeProviderCredentialResolver>(sp => new NyxIdRealtimeProviderCredentialResolver(\n sp.GetRequiredService<INyxIdApiClientFactory>(),\n sp.GetRequiredService<NyxIdRealtimeProviderCredentialOptions>(),\n sp.GetRequiredService<ILogger<NyxIdRealtimeProviderCredentialResolver>>(),\n ResolveVoiceCredentialProviders(sp)));\n }\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceLeaseObservationPort, VoicePresenceLeaseObservationPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton(sp => new VoiceVolatileToolCredentialPort(sp.GetService<TimeProvider>()));\n services.TryAddSingleton<IVoiceVolatileToolCredentialPort>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.TryAddSingleton<IVoiceToolCredentialIssuer>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.AddOptions<VoiceWebSocketAttachOptions>();\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IValidateOptions<VoiceWebSocketAttachOptions>, VoiceWebSocketAttachOptionsValidator>();\n services.TryAddSingleton<VoiceWebSocketAttachExecutor>();\n services.AddVoiceWebRtcTransport();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IReadOnlyList<ICredentialProvider> ResolveVoiceCredentialProviders(IServiceProvider services)\n {\n var providers = new List<ICredentialProvider>();\n var voiceCredentialProvider = services.GetService<IVoiceToolCredentialIssuer>() as ICredentialProvider;\n if (voiceCredentialProvider is not null)\n providers.Add(voiceCredentialProvider);\n\n foreach (var provider in services.GetServices<ICredentialProvider>())\n {\n if (!ReferenceEquals(provider, voiceCredentialProvider))\n providers.Add(provider);\n }\n\n return providers;\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var nyxIdRealtimeBrokerEnabled = IsNyxIdRealtimeBrokerEnabled(configuration);\n // The openai module registers below when EITHER a raw ApiKey OR the NyxID ephemeral broker is\n // available (ADR-0033 forbids long-lived keys in production, so broker-only is the normal shape).\n // Default-provider resolution must use the same availability, or broker-only deployments register\n // the module solely as \"voice_presence_openai\" while the auto-enable/mount/read default path uses\n // \"voice_presence\" — the module factory then never mounts a module for the enabled name, the\n // session-lease signal is silently dropped, and /ws/voice loops on 503 voice_capability_not_ready.\n var openAIVoiceAvailable = IsOpenAIVoiceConfigured(openAIProviderConfig) || nyxIdRealtimeBrokerEnabled;\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIVoiceAvailable,\n miniCpmProviderConfig);\n\n if (openAIVoiceAvailable)\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n return await ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger: null,\n ct);\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionWithLoggingAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n ILogger<ReadinessGatedRealtimeVoiceProviderSession>? logger,\n CancellationToken ct)\n {\n return await VoiceRealtimeSessionReadinessBootstrapper.ConnectAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger,\n ct);\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n bool openAIVoiceAvailable,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n openAIVoiceAvailable)\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (openAIVoiceAvailable)\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): the broker slug defaults to\n // NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug so voice stays enabled even when the\n // deployment config/secret is wiped on redeploy — config only overrides the slug, never disables it.\n private static string ResolveNyxIdRealtimeServiceSlug(IConfiguration configuration)\n {\n var configured = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:ServiceSlug\"]?.Trim();\n return string.IsNullOrWhiteSpace(configured)\n ? NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug\n : configured;\n }\n\n private static bool IsNyxIdRealtimeBrokerEnabled(IConfiguration configuration) =>\n !string.IsNullOrWhiteSpace(ResolveNyxIdRealtimeServiceSlug(configuration));\n\n private static NyxIdRealtimeProviderCredentialOptions BuildNyxIdRealtimeCredentialOptions(\n IConfiguration configuration)\n {\n var options = new NyxIdRealtimeProviderCredentialOptions\n {\n ServiceSlug = ResolveNyxIdRealtimeServiceSlug(configuration),\n };\n\n var mintPath = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:MintPath\"];\n if (!string.IsNullOrWhiteSpace(mintPath))\n options.MintPath = mintPath.Trim();\n\n var model = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:Model\"];\n if (!string.IsNullOrWhiteSpace(model))\n options.Model = model.Trim();\n\n return options;\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n DrainTimeout = options.DrainTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n var loggerFactory = sp.GetService<ILoggerFactory>();\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, loggerFactory),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, sp.GetService<ILoggerFactory>());\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor,\n ILoggerFactory? loggerFactory = null)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options, loggerFactory);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName, loggerFactory);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options, loggerFactory);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options,\n ILoggerFactory? loggerFactory = null)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName, loggerFactory);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName, loggerFactory);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new MEAILLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<MEAILLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new TornadoLLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<TornadoLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new NyxIdLLMProviderFactory();\n // Without an explicit logger the provider chain (NyxIdLLMProvider and the\n // MEAILLMProvider it delegates to) falls back to NullLogger, which silences\n // upstream LLM error translations and the no-chunks streaming fallback in\n // production. Always wire the host logger when one is available.\n var providerLogger = loggerFactory?.CreateLogger<NyxIdLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null,\n provider.DefaultRoutePreference,\n providerLogger);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = ResolveNyxIdDefaultModel(configuration, options);\n var defaultRoute = ResolveNyxIdDefaultRoute(configuration);\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty, defaultRoute);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n var defaultRoute = IsNyxIdProviderType(semantic.ProviderType)\n ? ResolveNyxIdDefaultRoute(configuration)\n : null;\n configured.Add(new ConfiguredProvider(\n name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim(), defaultRoute));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", ResolveNyxIdDefaultModel(configuration, options), ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n // The default route/model literals live once in LlmDefaults (Aevatar.AI.Abstractions) so the\n // NyxID server-default path and the OpenAI-compatible Responses ingress default share one\n // source and cannot drift. Every nyxid registration path resolves its default through these\n // helpers, which apply per-deployment config overrides on top.\n private static string ResolveNyxIdDefaultRoute(IConfiguration configuration) =>\n configuration[\"Aevatar:NyxId:DefaultRoute\"] is { } route && !string.IsNullOrWhiteSpace(route)\n ? route.Trim()\n : LlmDefaults.NyxIdRoute;\n\n private static string ResolveNyxIdDefaultModel(IConfiguration configuration, AevatarAIFeatureOptions options) =>\n configuration[\"Aevatar:NyxId:DefaultModel\"] is { Length: > 0 } model\n ? model\n : options.OpenAIModel;\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey,\n string? DefaultRoutePreference = null);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug.Trim();\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterSystemSkillOverlay(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n if (!options.EnableSystemSkillOverlay || string.IsNullOrWhiteSpace(options.SystemSkillOverlaySetName))\n return;\n\n // Ensure the Ornn skill client (with the configured slug) is registered even when a host enables\n // the overlay without the Ornn skill tools; the overlay reads the set through this client.\n services.AddOrnnSkillClient(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug.Trim();\n });\n\n services.AddSystemSkillOverlay(o =>\n {\n o.Enabled = options.EnableSystemSkillOverlay;\n o.SetName = options.SystemSkillOverlaySetName ?? string.Empty;\n o.RefreshTtl = options.SystemSkillOverlayRefreshTtl;\n o.MaxSkills = options.SystemSkillOverlayMaxSkills;\n o.MaxBytes = options.SystemSkillOverlayMaxBytes;\n });\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n}\n"}, "c8e7313bb88d7c259d1b178885e942feb55ad807": {"t": 1785756605.8293536, "n": 54936, "h": "de334e5fe70045bf", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Auditing;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.NyxId;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.Credentials;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\nusing Microsoft.Extensions.Options;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n /// <summary>\n /// Enables the host-bound system skill overlay scaffold. Mainnet-style hosts bind this from\n /// <c>Aevatar:SystemSkills:Enabled</c>.\n /// </summary>\n public bool EnableSystemSkillOverlay { get; set; }\n /// <summary>\n /// Non-secret name of the public, org-owned Ornn skillset that sources the overlay (issue #2498).\n /// Bound from <c>Aevatar:SystemSkills:SetName</c>. No organization service token is needed: set\n /// ownership is the trust anchor and the set is read publicly through the existing ornn-api proxy.\n /// </summary>\n public string? SystemSkillOverlaySetName { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:RefreshTtl</c>.\n /// </summary>\n public TimeSpan SystemSkillOverlayRefreshTtl { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:MaxSkills</c>.\n /// </summary>\n public int SystemSkillOverlayMaxSkills { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:MaxBytes</c>.\n /// </summary>\n public int SystemSkillOverlayMaxBytes { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = LlmDefaults.Model;\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n configure?.Invoke(options);\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n services.AddToolExecutionAuditObserver();\n // No container-level IToolApprovalHandler: a yielding handler is only valid on\n // actors that implement the pending-approval continuation (RoleGAgent wires its\n // own). Surfaces without that capability fall back to MissingApprovalHandler and\n // fail closed instead of stranding a dead-letter approval (#2004).\n services.TryAddSingleton<IVoiceToolInvoker>(sp => new AgentToolVoiceInvoker(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceInvoker>>()));\n services.TryAddSingleton<IVoiceToolCatalog>(sp => new AgentToolVoiceCatalog(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceCatalog>>()));\n services.TryAddSingleton<IVoicePresenceCapabilityCommandPort, VoicePresenceCapabilityCommandPort>();\n // Zero-config /ws/voice: auto-provision a never-enabled default voice agent on first connect by\n // committing the same enable voice-presence/enable issues. The attach path (ActorOwnedVoiceRealtimeSession)\n // resolves this optionally and only invokes it when the capability is still null after the\n // re-projection self-heal — i.e. a genuinely-unprovisioned default agent.\n services.TryAddSingleton<IVoicePresenceCapabilityAutoEnablePort, VoicePresenceCapabilityAutoEnablePort>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableSystemSkillOverlay)\n RegisterSystemSkillOverlay(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): when the OpenAI realtime credential is\n // brokered through NyxID, register the resolver so the provider mints a per-session ephemeral\n // instead of reading a static OPENAI_API_KEY. Requires NyxID tools (INyxIdApiClientFactory) wired.\n var nyxIdRealtimeCredentialOptions = BuildNyxIdRealtimeCredentialOptions(configuration);\n if (nyxIdRealtimeCredentialOptions.Enabled)\n {\n services.TryAddSingleton(nyxIdRealtimeCredentialOptions);\n services.TryAddSingleton<IRealtimeProviderCredentialResolver>(sp => new NyxIdRealtimeProviderCredentialResolver(\n sp.GetRequiredService<INyxIdApiClientFactory>(),\n sp.GetRequiredService<NyxIdRealtimeProviderCredentialOptions>(),\n sp.GetRequiredService<ILogger<NyxIdRealtimeProviderCredentialResolver>>(),\n ResolveVoiceCredentialProviders(sp)));\n }\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceLeaseObservationPort, VoicePresenceLeaseObservationPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton(sp => new VoiceVolatileToolCredentialPort(sp.GetService<TimeProvider>()));\n services.TryAddSingleton<IVoiceVolatileToolCredentialPort>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.TryAddSingleton<IVoiceToolCredentialIssuer>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.AddOptions<VoiceWebSocketAttachOptions>();\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IValidateOptions<VoiceWebSocketAttachOptions>, VoiceWebSocketAttachOptionsValidator>();\n services.TryAddSingleton<VoiceWebSocketAttachExecutor>();\n services.AddVoiceWebRtcTransport();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IReadOnlyList<ICredentialProvider> ResolveVoiceCredentialProviders(IServiceProvider services)\n {\n var providers = new List<ICredentialProvider>();\n var voiceCredentialProvider = services.GetService<IVoiceToolCredentialIssuer>() as ICredentialProvider;\n if (voiceCredentialProvider is not null)\n providers.Add(voiceCredentialProvider);\n\n foreach (var provider in services.GetServices<ICredentialProvider>())\n {\n if (!ReferenceEquals(provider, voiceCredentialProvider))\n providers.Add(provider);\n }\n\n return providers;\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var nyxIdRealtimeBrokerEnabled = IsNyxIdRealtimeBrokerEnabled(configuration);\n // The openai module registers below when EITHER a raw ApiKey OR the NyxID ephemeral broker is\n // available (ADR-0033 forbids long-lived keys in production, so broker-only is the normal shape).\n // Default-provider resolution must use the same availability, or broker-only deployments register\n // the module solely as \"voice_presence_openai\" while the auto-enable/mount/read default path uses\n // \"voice_presence\" — the module factory then never mounts a module for the enabled name, the\n // session-lease signal is silently dropped, and /ws/voice loops on 503 voice_capability_not_ready.\n var openAIVoiceAvailable = IsOpenAIVoiceConfigured(openAIProviderConfig) || nyxIdRealtimeBrokerEnabled;\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIVoiceAvailable,\n miniCpmProviderConfig);\n\n if (openAIVoiceAvailable)\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n return await ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger: null,\n ct);\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionWithLoggingAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n ILogger<ReadinessGatedRealtimeVoiceProviderSession>? logger,\n CancellationToken ct)\n {\n return await VoiceRealtimeSessionReadinessBootstrapper.ConnectAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger,\n ct);\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n bool openAIVoiceAvailable,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n openAIVoiceAvailable)\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (openAIVoiceAvailable)\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): the broker slug defaults to\n // NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug so voice stays enabled even when the\n // deployment config/secret is wiped on redeploy — config only overrides the slug, never disables it.\n private static string ResolveNyxIdRealtimeServiceSlug(IConfiguration configuration)\n {\n var configured = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:ServiceSlug\"]?.Trim();\n return string.IsNullOrWhiteSpace(configured)\n ? NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug\n : configured;\n }\n\n private static bool IsNyxIdRealtimeBrokerEnabled(IConfiguration configuration) =>\n !string.IsNullOrWhiteSpace(ResolveNyxIdRealtimeServiceSlug(configuration));\n\n private static NyxIdRealtimeProviderCredentialOptions BuildNyxIdRealtimeCredentialOptions(\n IConfiguration configuration)\n {\n var options = new NyxIdRealtimeProviderCredentialOptions\n {\n ServiceSlug = ResolveNyxIdRealtimeServiceSlug(configuration),\n };\n\n var mintPath = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:MintPath\"];\n if (!string.IsNullOrWhiteSpace(mintPath))\n options.MintPath = mintPath.Trim();\n\n var model = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:Model\"];\n if (!string.IsNullOrWhiteSpace(model))\n options.Model = model.Trim();\n\n return options;\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n DrainTimeout = options.DrainTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n var loggerFactory = sp.GetService<ILoggerFactory>();\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, loggerFactory),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, sp.GetService<ILoggerFactory>());\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor,\n ILoggerFactory? loggerFactory = null)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options, loggerFactory);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName, loggerFactory);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options, loggerFactory);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options,\n ILoggerFactory? loggerFactory = null)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName, loggerFactory);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName, loggerFactory);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new MEAILLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<MEAILLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new TornadoLLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<TornadoLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new NyxIdLLMProviderFactory();\n // Without an explicit logger the provider chain (NyxIdLLMProvider and the\n // MEAILLMProvider it delegates to) falls back to NullLogger, which silences\n // upstream LLM error translations and the no-chunks streaming fallback in\n // production. Always wire the host logger when one is available.\n var providerLogger = loggerFactory?.CreateLogger<NyxIdLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null,\n provider.DefaultRoutePreference,\n providerLogger);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = ResolveNyxIdDefaultModel(configuration, options);\n var defaultRoute = ResolveNyxIdDefaultRoute(configuration);\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty, defaultRoute);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n var defaultRoute = IsNyxIdProviderType(semantic.ProviderType)\n ? ResolveNyxIdDefaultRoute(configuration)\n : null;\n configured.Add(new ConfiguredProvider(\n name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim(), defaultRoute));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", ResolveNyxIdDefaultModel(configuration, options), ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n // The default route/model literals live once in LlmDefaults (Aevatar.AI.Abstractions) so the\n // NyxID server-default path and the OpenAI-compatible Responses ingress default share one\n // source and cannot drift. Every nyxid registration path resolves its default through these\n // helpers, which apply per-deployment config overrides on top.\n private static string ResolveNyxIdDefaultRoute(IConfiguration configuration) =>\n configuration[\"Aevatar:NyxId:DefaultRoute\"] is { Length: > 0 } route\n ? route\n : LlmDefaults.NyxIdRoute;\n\n private static string ResolveNyxIdDefaultModel(IConfiguration configuration, AevatarAIFeatureOptions options) =>\n configuration[\"Aevatar:NyxId:DefaultModel\"] is { Length: > 0 } model\n ? model\n : options.OpenAIModel;\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey,\n string? DefaultRoutePreference = null);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug;\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterSystemSkillOverlay(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n if (!options.EnableSystemSkillOverlay || string.IsNullOrWhiteSpace(options.SystemSkillOverlaySetName))\n return;\n\n // Ensure the Ornn skill client (with the configured slug) is registered even when a host enables\n // the overlay without the Ornn skill tools; the overlay reads the set through this client.\n services.AddOrnnSkillClient(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug;\n });\n\n services.AddSystemSkillOverlay(o =>\n {\n o.Enabled = options.EnableSystemSkillOverlay;\n o.SetName = options.SystemSkillOverlaySetName ?? string.Empty;\n o.RefreshTtl = options.SystemSkillOverlayRefreshTtl;\n o.MaxSkills = options.SystemSkillOverlayMaxSkills;\n o.MaxBytes = options.SystemSkillOverlayMaxBytes;\n });\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n}\n"}, "4a57086ec8d13287e1d3ce75d49879c7e8b4a15b": {"t": 1785756607.3171387, "n": 54310, "h": "029d5537ba38985f", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Auditing;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.NyxId;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.Credentials;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\nusing Microsoft.Extensions.Options;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n /// <summary>\n /// Enables the host-bound system skill overlay scaffold. Mainnet-style hosts bind this from\n /// <c>Aevatar:SystemSkills:Enabled</c>.\n /// </summary>\n public bool EnableSystemSkillOverlay { get; set; }\n /// <summary>\n /// Non-secret name of the public, org-owned Ornn skillset that sources the overlay (issue #2498).\n /// Bound from <c>Aevatar:SystemSkills:SetName</c>. No organization service token is needed: set\n /// ownership is the trust anchor and the set is read publicly through the existing ornn-api proxy.\n /// </summary>\n public string? SystemSkillOverlaySetName { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:RefreshTtl</c>.\n /// </summary>\n public TimeSpan SystemSkillOverlayRefreshTtl { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:MaxSkills</c>.\n /// </summary>\n public int SystemSkillOverlayMaxSkills { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:MaxBytes</c>.\n /// </summary>\n public int SystemSkillOverlayMaxBytes { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = LlmDefaults.Model;\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n configure?.Invoke(options);\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n services.AddToolExecutionAuditObserver();\n // No container-level IToolApprovalHandler: a yielding handler is only valid on\n // actors that implement the pending-approval continuation (RoleGAgent wires its\n // own). Surfaces without that capability fall back to MissingApprovalHandler and\n // fail closed instead of stranding a dead-letter approval (#2004).\n services.TryAddSingleton<IVoiceToolInvoker>(sp => new AgentToolVoiceInvoker(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceInvoker>>()));\n services.TryAddSingleton<IVoiceToolCatalog>(sp => new AgentToolVoiceCatalog(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceCatalog>>()));\n services.TryAddSingleton<IVoicePresenceCapabilityCommandPort, VoicePresenceCapabilityCommandPort>();\n // Zero-config /ws/voice: auto-provision a never-enabled default voice agent on first connect by\n // committing the same enable voice-presence/enable issues. The attach path (ActorOwnedVoiceRealtimeSession)\n // resolves this optionally and only invokes it when the capability is still null after the\n // re-projection self-heal — i.e. a genuinely-unprovisioned default agent.\n services.TryAddSingleton<IVoicePresenceCapabilityAutoEnablePort, VoicePresenceCapabilityAutoEnablePort>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableSystemSkillOverlay)\n RegisterSystemSkillOverlay(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): when the OpenAI realtime credential is\n // brokered through NyxID, register the resolver so the provider mints a per-session ephemeral\n // instead of reading a static OPENAI_API_KEY. Requires NyxID tools (INyxIdApiClientFactory) wired.\n var nyxIdRealtimeCredentialOptions = BuildNyxIdRealtimeCredentialOptions(configuration);\n if (nyxIdRealtimeCredentialOptions.Enabled)\n {\n services.TryAddSingleton(nyxIdRealtimeCredentialOptions);\n services.TryAddSingleton<IRealtimeProviderCredentialResolver>(sp => new NyxIdRealtimeProviderCredentialResolver(\n sp.GetRequiredService<INyxIdApiClientFactory>(),\n sp.GetRequiredService<NyxIdRealtimeProviderCredentialOptions>(),\n sp.GetRequiredService<ILogger<NyxIdRealtimeProviderCredentialResolver>>(),\n ResolveVoiceCredentialProviders(sp)));\n }\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceLeaseObservationPort, VoicePresenceLeaseObservationPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton(sp => new VoiceVolatileToolCredentialPort(sp.GetService<TimeProvider>()));\n services.TryAddSingleton<IVoiceVolatileToolCredentialPort>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.TryAddSingleton<IVoiceToolCredentialIssuer>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.AddOptions<VoiceWebSocketAttachOptions>();\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IValidateOptions<VoiceWebSocketAttachOptions>, VoiceWebSocketAttachOptionsValidator>();\n services.TryAddSingleton<VoiceWebSocketAttachExecutor>();\n services.AddVoiceWebRtcTransport();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IReadOnlyList<ICredentialProvider> ResolveVoiceCredentialProviders(IServiceProvider services)\n {\n var providers = new List<ICredentialProvider>();\n var voiceCredentialProvider = services.GetService<IVoiceToolCredentialIssuer>() as ICredentialProvider;\n if (voiceCredentialProvider is not null)\n providers.Add(voiceCredentialProvider);\n\n foreach (var provider in services.GetServices<ICredentialProvider>())\n {\n if (!ReferenceEquals(provider, voiceCredentialProvider))\n providers.Add(provider);\n }\n\n return providers;\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var nyxIdRealtimeBrokerEnabled = IsNyxIdRealtimeBrokerEnabled(configuration);\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIProviderConfig,\n miniCpmProviderConfig);\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig) || nyxIdRealtimeBrokerEnabled)\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n return await ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger: null,\n ct);\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionWithLoggingAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n ILogger<ReadinessGatedRealtimeVoiceProviderSession>? logger,\n CancellationToken ct)\n {\n return await VoiceRealtimeSessionReadinessBootstrapper.ConnectAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger,\n ct);\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n VoiceProviderConfig openAIProviderConfig,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n IsOpenAIVoiceConfigured(openAIProviderConfig))\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig))\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): the broker slug defaults to\n // NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug so voice stays enabled even when the\n // deployment config/secret is wiped on redeploy — config only overrides the slug, never disables it.\n private static string ResolveNyxIdRealtimeServiceSlug(IConfiguration configuration)\n {\n var configured = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:ServiceSlug\"]?.Trim();\n return string.IsNullOrWhiteSpace(configured)\n ? NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug\n : configured;\n }\n\n private static bool IsNyxIdRealtimeBrokerEnabled(IConfiguration configuration) =>\n !string.IsNullOrWhiteSpace(ResolveNyxIdRealtimeServiceSlug(configuration));\n\n private static NyxIdRealtimeProviderCredentialOptions BuildNyxIdRealtimeCredentialOptions(\n IConfiguration configuration)\n {\n var options = new NyxIdRealtimeProviderCredentialOptions\n {\n ServiceSlug = ResolveNyxIdRealtimeServiceSlug(configuration),\n };\n\n var mintPath = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:MintPath\"];\n if (!string.IsNullOrWhiteSpace(mintPath))\n options.MintPath = mintPath.Trim();\n\n var model = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:Model\"];\n if (!string.IsNullOrWhiteSpace(model))\n options.Model = model.Trim();\n\n return options;\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n DrainTimeout = options.DrainTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n var loggerFactory = sp.GetService<ILoggerFactory>();\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, loggerFactory),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, sp.GetService<ILoggerFactory>());\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor,\n ILoggerFactory? loggerFactory = null)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options, loggerFactory);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName, loggerFactory);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options, loggerFactory);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options,\n ILoggerFactory? loggerFactory = null)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName, loggerFactory);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName, loggerFactory);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new MEAILLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<MEAILLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new TornadoLLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<TornadoLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new NyxIdLLMProviderFactory();\n // Without an explicit logger the provider chain (NyxIdLLMProvider and the\n // MEAILLMProvider it delegates to) falls back to NullLogger, which silences\n // upstream LLM error translations and the no-chunks streaming fallback in\n // production. Always wire the host logger when one is available.\n var providerLogger = loggerFactory?.CreateLogger<NyxIdLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null,\n provider.DefaultRoutePreference,\n providerLogger);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = ResolveNyxIdDefaultModel(configuration, options);\n var defaultRoute = ResolveNyxIdDefaultRoute(configuration);\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty, defaultRoute);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n var defaultRoute = IsNyxIdProviderType(semantic.ProviderType)\n ? ResolveNyxIdDefaultRoute(configuration)\n : null;\n configured.Add(new ConfiguredProvider(\n name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim(), defaultRoute));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", ResolveNyxIdDefaultModel(configuration, options), ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n // The default route/model literals live once in LlmDefaults (Aevatar.AI.Abstractions) so the\n // NyxID server-default path and the OpenAI-compatible Responses ingress default share one\n // source and cannot drift. Every nyxid registration path resolves its default through these\n // helpers, which apply per-deployment config overrides on top.\n private static string ResolveNyxIdDefaultRoute(IConfiguration configuration) =>\n configuration[\"Aevatar:NyxId:DefaultRoute\"] is { Length: > 0 } route\n ? route\n : LlmDefaults.NyxIdRoute;\n\n private static string ResolveNyxIdDefaultModel(IConfiguration configuration, AevatarAIFeatureOptions options) =>\n configuration[\"Aevatar:NyxId:DefaultModel\"] is { Length: > 0 } model\n ? model\n : options.OpenAIModel;\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey,\n string? DefaultRoutePreference = null);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug;\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterSystemSkillOverlay(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n if (!options.EnableSystemSkillOverlay || string.IsNullOrWhiteSpace(options.SystemSkillOverlaySetName))\n return;\n\n // Ensure the Ornn skill client (with the configured slug) is registered even when a host enables\n // the overlay without the Ornn skill tools; the overlay reads the set through this client.\n services.AddOrnnSkillClient(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug;\n });\n\n services.AddSystemSkillOverlay(o =>\n {\n o.Enabled = options.EnableSystemSkillOverlay;\n o.SetName = options.SystemSkillOverlaySetName ?? string.Empty;\n o.RefreshTtl = options.SystemSkillOverlayRefreshTtl;\n o.MaxSkills = options.SystemSkillOverlayMaxSkills;\n o.MaxBytes = options.SystemSkillOverlayMaxBytes;\n });\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n}\n"}, "60c3764efe618102e0a376a2f0ac994b1dd404de": {"t": 1785756608.7140896, "n": 54228, "h": "c30136428cb0b9e3", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.NyxId;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.Credentials;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\nusing Microsoft.Extensions.Options;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n /// <summary>\n /// Enables the host-bound system skill overlay scaffold. Mainnet-style hosts bind this from\n /// <c>Aevatar:SystemSkills:Enabled</c>.\n /// </summary>\n public bool EnableSystemSkillOverlay { get; set; }\n /// <summary>\n /// Non-secret name of the public, org-owned Ornn skillset that sources the overlay (issue #2498).\n /// Bound from <c>Aevatar:SystemSkills:SetName</c>. No organization service token is needed: set\n /// ownership is the trust anchor and the set is read publicly through the existing ornn-api proxy.\n /// </summary>\n public string? SystemSkillOverlaySetName { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:RefreshTtl</c>.\n /// </summary>\n public TimeSpan SystemSkillOverlayRefreshTtl { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:MaxSkills</c>.\n /// </summary>\n public int SystemSkillOverlayMaxSkills { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:MaxBytes</c>.\n /// </summary>\n public int SystemSkillOverlayMaxBytes { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = LlmDefaults.Model;\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n configure?.Invoke(options);\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n // No container-level IToolApprovalHandler: a yielding handler is only valid on\n // actors that implement the pending-approval continuation (RoleGAgent wires its\n // own). Surfaces without that capability fall back to MissingApprovalHandler and\n // fail closed instead of stranding a dead-letter approval (#2004).\n services.TryAddSingleton<IVoiceToolInvoker>(sp => new AgentToolVoiceInvoker(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceInvoker>>()));\n services.TryAddSingleton<IVoiceToolCatalog>(sp => new AgentToolVoiceCatalog(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceCatalog>>()));\n services.TryAddSingleton<IVoicePresenceCapabilityCommandPort, VoicePresenceCapabilityCommandPort>();\n // Zero-config /ws/voice: auto-provision a never-enabled default voice agent on first connect by\n // committing the same enable voice-presence/enable issues. The attach path (ActorOwnedVoiceRealtimeSession)\n // resolves this optionally and only invokes it when the capability is still null after the\n // re-projection self-heal — i.e. a genuinely-unprovisioned default agent.\n services.TryAddSingleton<IVoicePresenceCapabilityAutoEnablePort, VoicePresenceCapabilityAutoEnablePort>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableSystemSkillOverlay)\n RegisterSystemSkillOverlay(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): when the OpenAI realtime credential is\n // brokered through NyxID, register the resolver so the provider mints a per-session ephemeral\n // instead of reading a static OPENAI_API_KEY. Requires NyxID tools (INyxIdApiClientFactory) wired.\n var nyxIdRealtimeCredentialOptions = BuildNyxIdRealtimeCredentialOptions(configuration);\n if (nyxIdRealtimeCredentialOptions.Enabled)\n {\n services.TryAddSingleton(nyxIdRealtimeCredentialOptions);\n services.TryAddSingleton<IRealtimeProviderCredentialResolver>(sp => new NyxIdRealtimeProviderCredentialResolver(\n sp.GetRequiredService<INyxIdApiClientFactory>(),\n sp.GetRequiredService<NyxIdRealtimeProviderCredentialOptions>(),\n sp.GetRequiredService<ILogger<NyxIdRealtimeProviderCredentialResolver>>(),\n ResolveVoiceCredentialProviders(sp)));\n }\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceLeaseObservationPort, VoicePresenceLeaseObservationPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton(sp => new VoiceVolatileToolCredentialPort(sp.GetService<TimeProvider>()));\n services.TryAddSingleton<IVoiceVolatileToolCredentialPort>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.TryAddSingleton<IVoiceToolCredentialIssuer>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.AddOptions<VoiceWebSocketAttachOptions>();\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IValidateOptions<VoiceWebSocketAttachOptions>, VoiceWebSocketAttachOptionsValidator>();\n services.TryAddSingleton<VoiceWebSocketAttachExecutor>();\n services.AddVoiceWebRtcTransport();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IReadOnlyList<ICredentialProvider> ResolveVoiceCredentialProviders(IServiceProvider services)\n {\n var providers = new List<ICredentialProvider>();\n var voiceCredentialProvider = services.GetService<IVoiceToolCredentialIssuer>() as ICredentialProvider;\n if (voiceCredentialProvider is not null)\n providers.Add(voiceCredentialProvider);\n\n foreach (var provider in services.GetServices<ICredentialProvider>())\n {\n if (!ReferenceEquals(provider, voiceCredentialProvider))\n providers.Add(provider);\n }\n\n return providers;\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var nyxIdRealtimeBrokerEnabled = IsNyxIdRealtimeBrokerEnabled(configuration);\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIProviderConfig,\n miniCpmProviderConfig);\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig) || nyxIdRealtimeBrokerEnabled)\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n return await ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger: null,\n ct);\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionWithLoggingAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n ILogger<ReadinessGatedRealtimeVoiceProviderSession>? logger,\n CancellationToken ct)\n {\n return await VoiceRealtimeSessionReadinessBootstrapper.ConnectAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger,\n ct);\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n VoiceProviderConfig openAIProviderConfig,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n IsOpenAIVoiceConfigured(openAIProviderConfig))\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig))\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): the broker slug defaults to\n // NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug so voice stays enabled even when the\n // deployment config/secret is wiped on redeploy — config only overrides the slug, never disables it.\n private static string ResolveNyxIdRealtimeServiceSlug(IConfiguration configuration)\n {\n var configured = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:ServiceSlug\"]?.Trim();\n return string.IsNullOrWhiteSpace(configured)\n ? NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug\n : configured;\n }\n\n private static bool IsNyxIdRealtimeBrokerEnabled(IConfiguration configuration) =>\n !string.IsNullOrWhiteSpace(ResolveNyxIdRealtimeServiceSlug(configuration));\n\n private static NyxIdRealtimeProviderCredentialOptions BuildNyxIdRealtimeCredentialOptions(\n IConfiguration configuration)\n {\n var options = new NyxIdRealtimeProviderCredentialOptions\n {\n ServiceSlug = ResolveNyxIdRealtimeServiceSlug(configuration),\n };\n\n var mintPath = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:MintPath\"];\n if (!string.IsNullOrWhiteSpace(mintPath))\n options.MintPath = mintPath.Trim();\n\n var model = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:Model\"];\n if (!string.IsNullOrWhiteSpace(model))\n options.Model = model.Trim();\n\n return options;\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n DrainTimeout = options.DrainTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n var loggerFactory = sp.GetService<ILoggerFactory>();\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, loggerFactory),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, sp.GetService<ILoggerFactory>());\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor,\n ILoggerFactory? loggerFactory = null)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options, loggerFactory);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName, loggerFactory);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options, loggerFactory);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options,\n ILoggerFactory? loggerFactory = null)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName, loggerFactory);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName, loggerFactory);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new MEAILLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<MEAILLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new TornadoLLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<TornadoLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new NyxIdLLMProviderFactory();\n // Without an explicit logger the provider chain (NyxIdLLMProvider and the\n // MEAILLMProvider it delegates to) falls back to NullLogger, which silences\n // upstream LLM error translations and the no-chunks streaming fallback in\n // production. Always wire the host logger when one is available.\n var providerLogger = loggerFactory?.CreateLogger<NyxIdLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null,\n provider.DefaultRoutePreference,\n providerLogger);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = ResolveNyxIdDefaultModel(configuration, options);\n var defaultRoute = ResolveNyxIdDefaultRoute(configuration);\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty, defaultRoute);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n var defaultRoute = IsNyxIdProviderType(semantic.ProviderType)\n ? ResolveNyxIdDefaultRoute(configuration)\n : null;\n configured.Add(new ConfiguredProvider(\n name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim(), defaultRoute));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", ResolveNyxIdDefaultModel(configuration, options), ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n // The default route/model literals live once in LlmDefaults (Aevatar.AI.Abstractions) so the\n // NyxID server-default path and the OpenAI-compatible Responses ingress default share one\n // source and cannot drift. Every nyxid registration path resolves its default through these\n // helpers, which apply per-deployment config overrides on top.\n private static string ResolveNyxIdDefaultRoute(IConfiguration configuration) =>\n configuration[\"Aevatar:NyxId:DefaultRoute\"] is { Length: > 0 } route\n ? route\n : LlmDefaults.NyxIdRoute;\n\n private static string ResolveNyxIdDefaultModel(IConfiguration configuration, AevatarAIFeatureOptions options) =>\n configuration[\"Aevatar:NyxId:DefaultModel\"] is { Length: > 0 } model\n ? model\n : options.OpenAIModel;\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey,\n string? DefaultRoutePreference = null);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug;\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterSystemSkillOverlay(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n if (!options.EnableSystemSkillOverlay || string.IsNullOrWhiteSpace(options.SystemSkillOverlaySetName))\n return;\n\n // Ensure the Ornn skill client (with the configured slug) is registered even when a host enables\n // the overlay without the Ornn skill tools; the overlay reads the set through this client.\n services.AddOrnnSkillClient(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug;\n });\n\n services.AddSystemSkillOverlay(o =>\n {\n o.Enabled = options.EnableSystemSkillOverlay;\n o.SetName = options.SystemSkillOverlaySetName ?? string.Empty;\n o.RefreshTtl = options.SystemSkillOverlayRefreshTtl;\n o.MaxSkills = options.SystemSkillOverlayMaxSkills;\n o.MaxBytes = options.SystemSkillOverlayMaxBytes;\n });\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n}\n"}, "6e460e1a2cf0903ac0720156bcc3f2144b9b4e25": {"t": 1785756610.1561656, "n": 54045, "h": "ed9ba0fc2555267d", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.NyxId;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.Credentials;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\nusing Microsoft.Extensions.Options;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n /// <summary>\n /// Enables the host-bound system skill overlay scaffold. Mainnet-style hosts bind this from\n /// <c>Aevatar:SystemSkills:Enabled</c>.\n /// </summary>\n public bool EnableSystemSkillOverlay { get; set; }\n /// <summary>\n /// Host-bound Ornn tag used to select system skills. Bound from\n /// <c>Aevatar:SystemSkills:Tag</c>; this value is sensitive and should come from host secrets.\n /// </summary>\n public string? SystemSkillOverlayTag { get; set; }\n /// <summary>\n /// Host-bound organization service token used by the future materializer. Bound from\n /// <c>Aevatar:SystemSkills:OrgServiceToken</c>; this value is a secret.\n /// </summary>\n public string? SystemSkillOverlayOrgServiceToken { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:RefreshTtl</c>.\n /// </summary>\n public TimeSpan SystemSkillOverlayRefreshTtl { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:MaxSkills</c>.\n /// </summary>\n public int SystemSkillOverlayMaxSkills { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:MaxBytes</c>.\n /// </summary>\n public int SystemSkillOverlayMaxBytes { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = LlmDefaults.Model;\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n configure?.Invoke(options);\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n // No container-level IToolApprovalHandler: a yielding handler is only valid on\n // actors that implement the pending-approval continuation (RoleGAgent wires its\n // own). Surfaces without that capability fall back to MissingApprovalHandler and\n // fail closed instead of stranding a dead-letter approval (#2004).\n services.TryAddSingleton<IVoiceToolInvoker>(sp => new AgentToolVoiceInvoker(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceInvoker>>()));\n services.TryAddSingleton<IVoiceToolCatalog>(sp => new AgentToolVoiceCatalog(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceCatalog>>()));\n services.TryAddSingleton<IVoicePresenceCapabilityCommandPort, VoicePresenceCapabilityCommandPort>();\n // Zero-config /ws/voice: auto-provision a never-enabled default voice agent on first connect by\n // committing the same enable voice-presence/enable issues. The attach path (ActorOwnedVoiceRealtimeSession)\n // resolves this optionally and only invokes it when the capability is still null after the\n // re-projection self-heal — i.e. a genuinely-unprovisioned default agent.\n services.TryAddSingleton<IVoicePresenceCapabilityAutoEnablePort, VoicePresenceCapabilityAutoEnablePort>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableSystemSkillOverlay)\n RegisterSystemSkillOverlay(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): when the OpenAI realtime credential is\n // brokered through NyxID, register the resolver so the provider mints a per-session ephemeral\n // instead of reading a static OPENAI_API_KEY. Requires NyxID tools (INyxIdApiClientFactory) wired.\n var nyxIdRealtimeCredentialOptions = BuildNyxIdRealtimeCredentialOptions(configuration);\n if (nyxIdRealtimeCredentialOptions.Enabled)\n {\n services.TryAddSingleton(nyxIdRealtimeCredentialOptions);\n services.TryAddSingleton<IRealtimeProviderCredentialResolver>(sp => new NyxIdRealtimeProviderCredentialResolver(\n sp.GetRequiredService<INyxIdApiClientFactory>(),\n sp.GetRequiredService<NyxIdRealtimeProviderCredentialOptions>(),\n sp.GetRequiredService<ILogger<NyxIdRealtimeProviderCredentialResolver>>(),\n ResolveVoiceCredentialProviders(sp)));\n }\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceLeaseObservationPort, VoicePresenceLeaseObservationPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton(sp => new VoiceVolatileToolCredentialPort(sp.GetService<TimeProvider>()));\n services.TryAddSingleton<IVoiceVolatileToolCredentialPort>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.TryAddSingleton<IVoiceToolCredentialIssuer>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.AddOptions<VoiceWebSocketAttachOptions>();\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IValidateOptions<VoiceWebSocketAttachOptions>, VoiceWebSocketAttachOptionsValidator>();\n services.TryAddSingleton<VoiceWebSocketAttachExecutor>();\n services.AddVoiceWebRtcTransport();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IReadOnlyList<ICredentialProvider> ResolveVoiceCredentialProviders(IServiceProvider services)\n {\n var providers = new List<ICredentialProvider>();\n var voiceCredentialProvider = services.GetService<IVoiceToolCredentialIssuer>() as ICredentialProvider;\n if (voiceCredentialProvider is not null)\n providers.Add(voiceCredentialProvider);\n\n foreach (var provider in services.GetServices<ICredentialProvider>())\n {\n if (!ReferenceEquals(provider, voiceCredentialProvider))\n providers.Add(provider);\n }\n\n return providers;\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var nyxIdRealtimeBrokerEnabled = IsNyxIdRealtimeBrokerEnabled(configuration);\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIProviderConfig,\n miniCpmProviderConfig);\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig) || nyxIdRealtimeBrokerEnabled)\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n return await ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger: null,\n ct);\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionWithLoggingAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n ILogger<ReadinessGatedRealtimeVoiceProviderSession>? logger,\n CancellationToken ct)\n {\n return await VoiceRealtimeSessionReadinessBootstrapper.ConnectAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger,\n ct);\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n VoiceProviderConfig openAIProviderConfig,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n IsOpenAIVoiceConfigured(openAIProviderConfig))\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig))\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): the broker slug defaults to\n // NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug so voice stays enabled even when the\n // deployment config/secret is wiped on redeploy — config only overrides the slug, never disables it.\n private static string ResolveNyxIdRealtimeServiceSlug(IConfiguration configuration)\n {\n var configured = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:ServiceSlug\"]?.Trim();\n return string.IsNullOrWhiteSpace(configured)\n ? NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug\n : configured;\n }\n\n private static bool IsNyxIdRealtimeBrokerEnabled(IConfiguration configuration) =>\n !string.IsNullOrWhiteSpace(ResolveNyxIdRealtimeServiceSlug(configuration));\n\n private static NyxIdRealtimeProviderCredentialOptions BuildNyxIdRealtimeCredentialOptions(\n IConfiguration configuration)\n {\n var options = new NyxIdRealtimeProviderCredentialOptions\n {\n ServiceSlug = ResolveNyxIdRealtimeServiceSlug(configuration),\n };\n\n var mintPath = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:MintPath\"];\n if (!string.IsNullOrWhiteSpace(mintPath))\n options.MintPath = mintPath.Trim();\n\n var model = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:Model\"];\n if (!string.IsNullOrWhiteSpace(model))\n options.Model = model.Trim();\n\n return options;\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n DrainTimeout = options.DrainTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n var loggerFactory = sp.GetService<ILoggerFactory>();\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, loggerFactory),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, sp.GetService<ILoggerFactory>());\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor,\n ILoggerFactory? loggerFactory = null)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options, loggerFactory);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName, loggerFactory);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options, loggerFactory);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options,\n ILoggerFactory? loggerFactory = null)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName, loggerFactory);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName, loggerFactory);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new MEAILLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<MEAILLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new TornadoLLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<TornadoLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new NyxIdLLMProviderFactory();\n // Without an explicit logger the provider chain (NyxIdLLMProvider and the\n // MEAILLMProvider it delegates to) falls back to NullLogger, which silences\n // upstream LLM error translations and the no-chunks streaming fallback in\n // production. Always wire the host logger when one is available.\n var providerLogger = loggerFactory?.CreateLogger<NyxIdLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null,\n provider.DefaultRoutePreference,\n providerLogger);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = ResolveNyxIdDefaultModel(configuration, options);\n var defaultRoute = ResolveNyxIdDefaultRoute(configuration);\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty, defaultRoute);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n var defaultRoute = IsNyxIdProviderType(semantic.ProviderType)\n ? ResolveNyxIdDefaultRoute(configuration)\n : null;\n configured.Add(new ConfiguredProvider(\n name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim(), defaultRoute));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", ResolveNyxIdDefaultModel(configuration, options), ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n // The default route/model literals live once in LlmDefaults (Aevatar.AI.Abstractions) so the\n // NyxID server-default path and the OpenAI-compatible Responses ingress default share one\n // source and cannot drift. Every nyxid registration path resolves its default through these\n // helpers, which apply per-deployment config overrides on top.\n private static string ResolveNyxIdDefaultRoute(IConfiguration configuration) =>\n configuration[\"Aevatar:NyxId:DefaultRoute\"] is { Length: > 0 } route\n ? route\n : LlmDefaults.NyxIdRoute;\n\n private static string ResolveNyxIdDefaultModel(IConfiguration configuration, AevatarAIFeatureOptions options) =>\n configuration[\"Aevatar:NyxId:DefaultModel\"] is { Length: > 0 } model\n ? model\n : options.OpenAIModel;\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey,\n string? DefaultRoutePreference = null);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug;\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterSystemSkillOverlay(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n if (!options.EnableSystemSkillOverlay || string.IsNullOrWhiteSpace(options.SystemSkillOverlayTag))\n return;\n\n services.AddSystemSkillOverlay(o =>\n {\n o.Enabled = options.EnableSystemSkillOverlay;\n o.Tag = options.SystemSkillOverlayTag ?? string.Empty;\n o.OrgServiceToken = options.SystemSkillOverlayOrgServiceToken ?? string.Empty;\n o.RefreshTtl = options.SystemSkillOverlayRefreshTtl;\n o.MaxSkills = options.SystemSkillOverlayMaxSkills;\n o.MaxBytes = options.SystemSkillOverlayMaxBytes;\n });\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n}\n"}, "15c43da6fbd497be17cdf221af112f21a6b3b317": {"t": 1785756611.6830947, "n": 51987, "h": "c57afaed5cabdea5", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.NyxId;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.Credentials;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\nusing Microsoft.Extensions.Options;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = LlmDefaults.Model;\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n configure?.Invoke(options);\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n // No container-level IToolApprovalHandler: a yielding handler is only valid on\n // actors that implement the pending-approval continuation (RoleGAgent wires its\n // own). Surfaces without that capability fall back to MissingApprovalHandler and\n // fail closed instead of stranding a dead-letter approval (#2004).\n services.TryAddSingleton<IVoiceToolInvoker>(sp => new AgentToolVoiceInvoker(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceInvoker>>()));\n services.TryAddSingleton<IVoiceToolCatalog>(sp => new AgentToolVoiceCatalog(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceCatalog>>()));\n services.TryAddSingleton<IVoicePresenceCapabilityCommandPort, VoicePresenceCapabilityCommandPort>();\n // Zero-config /ws/voice: auto-provision a never-enabled default voice agent on first connect by\n // committing the same enable voice-presence/enable issues. The attach path (ActorOwnedVoiceRealtimeSession)\n // resolves this optionally and only invokes it when the capability is still null after the\n // re-projection self-heal — i.e. a genuinely-unprovisioned default agent.\n services.TryAddSingleton<IVoicePresenceCapabilityAutoEnablePort, VoicePresenceCapabilityAutoEnablePort>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): when the OpenAI realtime credential is\n // brokered through NyxID, register the resolver so the provider mints a per-session ephemeral\n // instead of reading a static OPENAI_API_KEY. Requires NyxID tools (INyxIdApiClientFactory) wired.\n var nyxIdRealtimeCredentialOptions = BuildNyxIdRealtimeCredentialOptions(configuration);\n if (nyxIdRealtimeCredentialOptions.Enabled)\n {\n services.TryAddSingleton(nyxIdRealtimeCredentialOptions);\n services.TryAddSingleton<IRealtimeProviderCredentialResolver>(sp => new NyxIdRealtimeProviderCredentialResolver(\n sp.GetRequiredService<INyxIdApiClientFactory>(),\n sp.GetRequiredService<NyxIdRealtimeProviderCredentialOptions>(),\n sp.GetRequiredService<ILogger<NyxIdRealtimeProviderCredentialResolver>>(),\n ResolveVoiceCredentialProviders(sp)));\n }\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceLeaseObservationPort, VoicePresenceLeaseObservationPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton(sp => new VoiceVolatileToolCredentialPort(sp.GetService<TimeProvider>()));\n services.TryAddSingleton<IVoiceVolatileToolCredentialPort>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.TryAddSingleton<IVoiceToolCredentialIssuer>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.AddOptions<VoiceWebSocketAttachOptions>();\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IValidateOptions<VoiceWebSocketAttachOptions>, VoiceWebSocketAttachOptionsValidator>();\n services.TryAddSingleton<VoiceWebSocketAttachExecutor>();\n services.AddVoiceWebRtcTransport();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IReadOnlyList<ICredentialProvider> ResolveVoiceCredentialProviders(IServiceProvider services)\n {\n var providers = new List<ICredentialProvider>();\n var voiceCredentialProvider = services.GetService<IVoiceToolCredentialIssuer>() as ICredentialProvider;\n if (voiceCredentialProvider is not null)\n providers.Add(voiceCredentialProvider);\n\n foreach (var provider in services.GetServices<ICredentialProvider>())\n {\n if (!ReferenceEquals(provider, voiceCredentialProvider))\n providers.Add(provider);\n }\n\n return providers;\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var nyxIdRealtimeBrokerEnabled = IsNyxIdRealtimeBrokerEnabled(configuration);\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIProviderConfig,\n miniCpmProviderConfig);\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig) || nyxIdRealtimeBrokerEnabled)\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n return await ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger: null,\n ct);\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionWithLoggingAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n ILogger<ReadinessGatedRealtimeVoiceProviderSession>? logger,\n CancellationToken ct)\n {\n return await VoiceRealtimeSessionReadinessBootstrapper.ConnectAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger,\n ct);\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n VoiceProviderConfig openAIProviderConfig,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n IsOpenAIVoiceConfigured(openAIProviderConfig))\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig))\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): the broker slug defaults to\n // NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug so voice stays enabled even when the\n // deployment config/secret is wiped on redeploy — config only overrides the slug, never disables it.\n private static string ResolveNyxIdRealtimeServiceSlug(IConfiguration configuration)\n {\n var configured = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:ServiceSlug\"]?.Trim();\n return string.IsNullOrWhiteSpace(configured)\n ? NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug\n : configured;\n }\n\n private static bool IsNyxIdRealtimeBrokerEnabled(IConfiguration configuration) =>\n !string.IsNullOrWhiteSpace(ResolveNyxIdRealtimeServiceSlug(configuration));\n\n private static NyxIdRealtimeProviderCredentialOptions BuildNyxIdRealtimeCredentialOptions(\n IConfiguration configuration)\n {\n var options = new NyxIdRealtimeProviderCredentialOptions\n {\n ServiceSlug = ResolveNyxIdRealtimeServiceSlug(configuration),\n };\n\n var mintPath = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:MintPath\"];\n if (!string.IsNullOrWhiteSpace(mintPath))\n options.MintPath = mintPath.Trim();\n\n var model = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:Model\"];\n if (!string.IsNullOrWhiteSpace(model))\n options.Model = model.Trim();\n\n return options;\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n DrainTimeout = options.DrainTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n var loggerFactory = sp.GetService<ILoggerFactory>();\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, loggerFactory),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, sp.GetService<ILoggerFactory>());\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor,\n ILoggerFactory? loggerFactory = null)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options, loggerFactory);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName, loggerFactory);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options, loggerFactory);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options,\n ILoggerFactory? loggerFactory = null)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName, loggerFactory);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName, loggerFactory);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new MEAILLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<MEAILLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new TornadoLLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<TornadoLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new NyxIdLLMProviderFactory();\n // Without an explicit logger the provider chain (NyxIdLLMProvider and the\n // MEAILLMProvider it delegates to) falls back to NullLogger, which silences\n // upstream LLM error translations and the no-chunks streaming fallback in\n // production. Always wire the host logger when one is available.\n var providerLogger = loggerFactory?.CreateLogger<NyxIdLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null,\n provider.DefaultRoutePreference,\n providerLogger);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = ResolveNyxIdDefaultModel(configuration, options);\n var defaultRoute = ResolveNyxIdDefaultRoute(configuration);\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty, defaultRoute);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n var defaultRoute = IsNyxIdProviderType(semantic.ProviderType)\n ? ResolveNyxIdDefaultRoute(configuration)\n : null;\n configured.Add(new ConfiguredProvider(\n name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim(), defaultRoute));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", ResolveNyxIdDefaultModel(configuration, options), ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n // The default route/model literals live once in LlmDefaults (Aevatar.AI.Abstractions) so the\n // NyxID server-default path and the OpenAI-compatible Responses ingress default share one\n // source and cannot drift. Every nyxid registration path resolves its default through these\n // helpers, which apply per-deployment config overrides on top.\n private static string ResolveNyxIdDefaultRoute(IConfiguration configuration) =>\n configuration[\"Aevatar:NyxId:DefaultRoute\"] is { Length: > 0 } route\n ? route\n : LlmDefaults.NyxIdRoute;\n\n private static string ResolveNyxIdDefaultModel(IConfiguration configuration, AevatarAIFeatureOptions options) =>\n configuration[\"Aevatar:NyxId:DefaultModel\"] is { Length: > 0 } model\n ? model\n : options.OpenAIModel;\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey,\n string? DefaultRoutePreference = null);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug;\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n}\n"}, "265ef9e8a4543833dc44084fc4ed89f725bb94fb": {"t": 1785756613.256981, "n": 51943, "h": "4c91de191d2628c8", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.NyxId;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.Credentials;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\nusing Microsoft.Extensions.Options;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = LlmDefaults.Model;\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n configure?.Invoke(options);\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n // No container-level IToolApprovalHandler: a yielding handler is only valid on\n // actors that implement the pending-approval continuation (RoleGAgent wires its\n // own). Surfaces without that capability fall back to MissingApprovalHandler and\n // fail closed instead of stranding a dead-letter approval (#2004).\n services.TryAddSingleton<IVoiceToolInvoker>(sp => new AgentToolVoiceInvoker(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceInvoker>>()));\n services.TryAddSingleton<IVoiceToolCatalog>(sp => new AgentToolVoiceCatalog(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceCatalog>>()));\n services.TryAddSingleton<IVoicePresenceCapabilityCommandPort, VoicePresenceCapabilityCommandPort>();\n // Zero-config /ws/voice: auto-provision a never-enabled default voice agent on first connect by\n // committing the same enable voice-presence/enable issues. The attach path (ActorOwnedVoiceRealtimeSession)\n // resolves this optionally and only invokes it when the capability is still null after the\n // re-projection self-heal — i.e. a genuinely-unprovisioned default agent.\n services.TryAddSingleton<IVoicePresenceCapabilityAutoEnablePort, VoicePresenceCapabilityAutoEnablePort>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): when the OpenAI realtime credential is\n // brokered through NyxID, register the resolver so the provider mints a per-session ephemeral\n // instead of reading a static OPENAI_API_KEY. Requires NyxID tools (INyxIdApiClientFactory) wired.\n var nyxIdRealtimeCredentialOptions = BuildNyxIdRealtimeCredentialOptions(configuration);\n if (nyxIdRealtimeCredentialOptions.Enabled)\n {\n services.TryAddSingleton(nyxIdRealtimeCredentialOptions);\n services.TryAddSingleton<IRealtimeProviderCredentialResolver>(sp => new NyxIdRealtimeProviderCredentialResolver(\n sp.GetRequiredService<INyxIdApiClientFactory>(),\n sp.GetRequiredService<NyxIdRealtimeProviderCredentialOptions>(),\n sp.GetRequiredService<ILogger<NyxIdRealtimeProviderCredentialResolver>>(),\n ResolveVoiceCredentialProviders(sp)));\n }\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceLeaseObservationPort, VoicePresenceLeaseObservationPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton(sp => new VoiceVolatileToolCredentialPort(sp.GetService<TimeProvider>()));\n services.TryAddSingleton<IVoiceVolatileToolCredentialPort>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.TryAddSingleton<IVoiceToolCredentialIssuer>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.AddOptions<VoiceWebSocketAttachOptions>();\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IValidateOptions<VoiceWebSocketAttachOptions>, VoiceWebSocketAttachOptionsValidator>();\n services.TryAddSingleton<VoiceWebSocketAttachExecutor>();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IReadOnlyList<ICredentialProvider> ResolveVoiceCredentialProviders(IServiceProvider services)\n {\n var providers = new List<ICredentialProvider>();\n var voiceCredentialProvider = services.GetService<IVoiceToolCredentialIssuer>() as ICredentialProvider;\n if (voiceCredentialProvider is not null)\n providers.Add(voiceCredentialProvider);\n\n foreach (var provider in services.GetServices<ICredentialProvider>())\n {\n if (!ReferenceEquals(provider, voiceCredentialProvider))\n providers.Add(provider);\n }\n\n return providers;\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var nyxIdRealtimeBrokerEnabled = IsNyxIdRealtimeBrokerEnabled(configuration);\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIProviderConfig,\n miniCpmProviderConfig);\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig) || nyxIdRealtimeBrokerEnabled)\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n return await ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger: null,\n ct);\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionWithLoggingAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n ILogger<ReadinessGatedRealtimeVoiceProviderSession>? logger,\n CancellationToken ct)\n {\n return await VoiceRealtimeSessionReadinessBootstrapper.ConnectAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger,\n ct);\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n VoiceProviderConfig openAIProviderConfig,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n IsOpenAIVoiceConfigured(openAIProviderConfig))\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig))\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): the broker slug defaults to\n // NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug so voice stays enabled even when the\n // deployment config/secret is wiped on redeploy — config only overrides the slug, never disables it.\n private static string ResolveNyxIdRealtimeServiceSlug(IConfiguration configuration)\n {\n var configured = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:ServiceSlug\"]?.Trim();\n return string.IsNullOrWhiteSpace(configured)\n ? NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug\n : configured;\n }\n\n private static bool IsNyxIdRealtimeBrokerEnabled(IConfiguration configuration) =>\n !string.IsNullOrWhiteSpace(ResolveNyxIdRealtimeServiceSlug(configuration));\n\n private static NyxIdRealtimeProviderCredentialOptions BuildNyxIdRealtimeCredentialOptions(\n IConfiguration configuration)\n {\n var options = new NyxIdRealtimeProviderCredentialOptions\n {\n ServiceSlug = ResolveNyxIdRealtimeServiceSlug(configuration),\n };\n\n var mintPath = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:MintPath\"];\n if (!string.IsNullOrWhiteSpace(mintPath))\n options.MintPath = mintPath.Trim();\n\n var model = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:Model\"];\n if (!string.IsNullOrWhiteSpace(model))\n options.Model = model.Trim();\n\n return options;\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n DrainTimeout = options.DrainTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n var loggerFactory = sp.GetService<ILoggerFactory>();\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, loggerFactory),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, sp.GetService<ILoggerFactory>());\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor,\n ILoggerFactory? loggerFactory = null)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options, loggerFactory);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName, loggerFactory);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options, loggerFactory);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options,\n ILoggerFactory? loggerFactory = null)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName, loggerFactory);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName, loggerFactory);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new MEAILLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<MEAILLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new TornadoLLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<TornadoLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new NyxIdLLMProviderFactory();\n // Without an explicit logger the provider chain (NyxIdLLMProvider and the\n // MEAILLMProvider it delegates to) falls back to NullLogger, which silences\n // upstream LLM error translations and the no-chunks streaming fallback in\n // production. Always wire the host logger when one is available.\n var providerLogger = loggerFactory?.CreateLogger<NyxIdLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null,\n provider.DefaultRoutePreference,\n providerLogger);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = ResolveNyxIdDefaultModel(configuration, options);\n var defaultRoute = ResolveNyxIdDefaultRoute(configuration);\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty, defaultRoute);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n var defaultRoute = IsNyxIdProviderType(semantic.ProviderType)\n ? ResolveNyxIdDefaultRoute(configuration)\n : null;\n configured.Add(new ConfiguredProvider(\n name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim(), defaultRoute));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", ResolveNyxIdDefaultModel(configuration, options), ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n // The default route/model literals live once in LlmDefaults (Aevatar.AI.Abstractions) so the\n // NyxID server-default path and the OpenAI-compatible Responses ingress default share one\n // source and cannot drift. Every nyxid registration path resolves its default through these\n // helpers, which apply per-deployment config overrides on top.\n private static string ResolveNyxIdDefaultRoute(IConfiguration configuration) =>\n configuration[\"Aevatar:NyxId:DefaultRoute\"] is { Length: > 0 } route\n ? route\n : LlmDefaults.NyxIdRoute;\n\n private static string ResolveNyxIdDefaultModel(IConfiguration configuration, AevatarAIFeatureOptions options) =>\n configuration[\"Aevatar:NyxId:DefaultModel\"] is { Length: > 0 } model\n ? model\n : options.OpenAIModel;\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey,\n string? DefaultRoutePreference = null);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug;\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n}\n"}, "8102bdc02669c34210e73097551fb0735648ae43": {"t": 1785756614.9639065, "n": 51423, "h": "90a75ddb2dac4d62", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.NyxId;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.Credentials;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\nusing Microsoft.Extensions.Options;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = LlmDefaults.Model;\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n configure?.Invoke(options);\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n // No container-level IToolApprovalHandler: a yielding handler is only valid on\n // actors that implement the pending-approval continuation (RoleGAgent wires its\n // own). Surfaces without that capability fall back to MissingApprovalHandler and\n // fail closed instead of stranding a dead-letter approval (#2004).\n services.TryAddSingleton<IVoiceToolInvoker>(sp => new AgentToolVoiceInvoker(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceInvoker>>()));\n services.TryAddSingleton<IVoiceToolCatalog>(sp => new AgentToolVoiceCatalog(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceCatalog>>()));\n services.TryAddSingleton<IVoicePresenceCapabilityCommandPort, VoicePresenceCapabilityCommandPort>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): when the OpenAI realtime credential is\n // brokered through NyxID, register the resolver so the provider mints a per-session ephemeral\n // instead of reading a static OPENAI_API_KEY. Requires NyxID tools (INyxIdApiClientFactory) wired.\n var nyxIdRealtimeCredentialOptions = BuildNyxIdRealtimeCredentialOptions(configuration);\n if (nyxIdRealtimeCredentialOptions.Enabled)\n {\n services.TryAddSingleton(nyxIdRealtimeCredentialOptions);\n services.TryAddSingleton<IRealtimeProviderCredentialResolver>(sp => new NyxIdRealtimeProviderCredentialResolver(\n sp.GetRequiredService<INyxIdApiClientFactory>(),\n sp.GetRequiredService<NyxIdRealtimeProviderCredentialOptions>(),\n sp.GetRequiredService<ILogger<NyxIdRealtimeProviderCredentialResolver>>(),\n ResolveVoiceCredentialProviders(sp)));\n }\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceLeaseObservationPort, VoicePresenceLeaseObservationPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton(sp => new VoiceVolatileToolCredentialPort(sp.GetService<TimeProvider>()));\n services.TryAddSingleton<IVoiceVolatileToolCredentialPort>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.TryAddSingleton<IVoiceToolCredentialIssuer>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.AddOptions<VoiceWebSocketAttachOptions>();\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IValidateOptions<VoiceWebSocketAttachOptions>, VoiceWebSocketAttachOptionsValidator>();\n services.TryAddSingleton<VoiceWebSocketAttachExecutor>();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IReadOnlyList<ICredentialProvider> ResolveVoiceCredentialProviders(IServiceProvider services)\n {\n var providers = new List<ICredentialProvider>();\n var voiceCredentialProvider = services.GetService<IVoiceToolCredentialIssuer>() as ICredentialProvider;\n if (voiceCredentialProvider is not null)\n providers.Add(voiceCredentialProvider);\n\n foreach (var provider in services.GetServices<ICredentialProvider>())\n {\n if (!ReferenceEquals(provider, voiceCredentialProvider))\n providers.Add(provider);\n }\n\n return providers;\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var nyxIdRealtimeBrokerEnabled = IsNyxIdRealtimeBrokerEnabled(configuration);\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIProviderConfig,\n miniCpmProviderConfig);\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig) || nyxIdRealtimeBrokerEnabled)\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n return await ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger: null,\n ct);\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionWithLoggingAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n ILogger<ReadinessGatedRealtimeVoiceProviderSession>? logger,\n CancellationToken ct)\n {\n return await VoiceRealtimeSessionReadinessBootstrapper.ConnectAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger,\n ct);\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n VoiceProviderConfig openAIProviderConfig,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n IsOpenAIVoiceConfigured(openAIProviderConfig))\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig))\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): the broker slug defaults to\n // NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug so voice stays enabled even when the\n // deployment config/secret is wiped on redeploy — config only overrides the slug, never disables it.\n private static string ResolveNyxIdRealtimeServiceSlug(IConfiguration configuration)\n {\n var configured = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:ServiceSlug\"]?.Trim();\n return string.IsNullOrWhiteSpace(configured)\n ? NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug\n : configured;\n }\n\n private static bool IsNyxIdRealtimeBrokerEnabled(IConfiguration configuration) =>\n !string.IsNullOrWhiteSpace(ResolveNyxIdRealtimeServiceSlug(configuration));\n\n private static NyxIdRealtimeProviderCredentialOptions BuildNyxIdRealtimeCredentialOptions(\n IConfiguration configuration)\n {\n var options = new NyxIdRealtimeProviderCredentialOptions\n {\n ServiceSlug = ResolveNyxIdRealtimeServiceSlug(configuration),\n };\n\n var mintPath = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:MintPath\"];\n if (!string.IsNullOrWhiteSpace(mintPath))\n options.MintPath = mintPath.Trim();\n\n var model = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:Model\"];\n if (!string.IsNullOrWhiteSpace(model))\n options.Model = model.Trim();\n\n return options;\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n DrainTimeout = options.DrainTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n var loggerFactory = sp.GetService<ILoggerFactory>();\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, loggerFactory),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, sp.GetService<ILoggerFactory>());\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor,\n ILoggerFactory? loggerFactory = null)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options, loggerFactory);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName, loggerFactory);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options, loggerFactory);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options,\n ILoggerFactory? loggerFactory = null)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName, loggerFactory);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName, loggerFactory);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new MEAILLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<MEAILLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new TornadoLLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<TornadoLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new NyxIdLLMProviderFactory();\n // Without an explicit logger the provider chain (NyxIdLLMProvider and the\n // MEAILLMProvider it delegates to) falls back to NullLogger, which silences\n // upstream LLM error translations and the no-chunks streaming fallback in\n // production. Always wire the host logger when one is available.\n var providerLogger = loggerFactory?.CreateLogger<NyxIdLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null,\n provider.DefaultRoutePreference,\n providerLogger);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = ResolveNyxIdDefaultModel(configuration, options);\n var defaultRoute = ResolveNyxIdDefaultRoute(configuration);\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty, defaultRoute);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n var defaultRoute = IsNyxIdProviderType(semantic.ProviderType)\n ? ResolveNyxIdDefaultRoute(configuration)\n : null;\n configured.Add(new ConfiguredProvider(\n name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim(), defaultRoute));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", ResolveNyxIdDefaultModel(configuration, options), ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n // The default route/model literals live once in LlmDefaults (Aevatar.AI.Abstractions) so the\n // NyxID server-default path and the OpenAI-compatible Responses ingress default share one\n // source and cannot drift. Every nyxid registration path resolves its default through these\n // helpers, which apply per-deployment config overrides on top.\n private static string ResolveNyxIdDefaultRoute(IConfiguration configuration) =>\n configuration[\"Aevatar:NyxId:DefaultRoute\"] is { Length: > 0 } route\n ? route\n : LlmDefaults.NyxIdRoute;\n\n private static string ResolveNyxIdDefaultModel(IConfiguration configuration, AevatarAIFeatureOptions options) =>\n configuration[\"Aevatar:NyxId:DefaultModel\"] is { Length: > 0 } model\n ? model\n : options.OpenAIModel;\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey,\n string? DefaultRoutePreference = null);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug;\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n}\n"}, "97be0fbddb2c83d72d5d1d29ba48d28a26e83766": {"t": 1785756616.6901124, "n": 51140, "h": "7df3f2d638aab198", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.NyxId;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.Credentials;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = LlmDefaults.Model;\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n configure?.Invoke(options);\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n // No container-level IToolApprovalHandler: a yielding handler is only valid on\n // actors that implement the pending-approval continuation (RoleGAgent wires its\n // own). Surfaces without that capability fall back to MissingApprovalHandler and\n // fail closed instead of stranding a dead-letter approval (#2004).\n services.TryAddSingleton<IVoiceToolInvoker>(sp => new AgentToolVoiceInvoker(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceInvoker>>()));\n services.TryAddSingleton<IVoiceToolCatalog>(sp => new AgentToolVoiceCatalog(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceCatalog>>()));\n services.TryAddSingleton<IVoicePresenceCapabilityCommandPort, VoicePresenceCapabilityCommandPort>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): when the OpenAI realtime credential is\n // brokered through NyxID, register the resolver so the provider mints a per-session ephemeral\n // instead of reading a static OPENAI_API_KEY. Requires NyxID tools (INyxIdApiClientFactory) wired.\n var nyxIdRealtimeCredentialOptions = BuildNyxIdRealtimeCredentialOptions(configuration);\n if (nyxIdRealtimeCredentialOptions.Enabled)\n {\n services.TryAddSingleton(nyxIdRealtimeCredentialOptions);\n services.TryAddSingleton<IRealtimeProviderCredentialResolver>(sp => new NyxIdRealtimeProviderCredentialResolver(\n sp.GetRequiredService<INyxIdApiClientFactory>(),\n sp.GetRequiredService<NyxIdRealtimeProviderCredentialOptions>(),\n sp.GetRequiredService<ILogger<NyxIdRealtimeProviderCredentialResolver>>(),\n ResolveVoiceCredentialProviders(sp)));\n }\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceLeaseObservationPort, VoicePresenceLeaseObservationPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton(sp => new VoiceVolatileToolCredentialPort(sp.GetService<TimeProvider>()));\n services.TryAddSingleton<IVoiceVolatileToolCredentialPort>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.TryAddSingleton<IVoiceToolCredentialIssuer>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IReadOnlyList<ICredentialProvider> ResolveVoiceCredentialProviders(IServiceProvider services)\n {\n var providers = new List<ICredentialProvider>();\n var voiceCredentialProvider = services.GetService<IVoiceToolCredentialIssuer>() as ICredentialProvider;\n if (voiceCredentialProvider is not null)\n providers.Add(voiceCredentialProvider);\n\n foreach (var provider in services.GetServices<ICredentialProvider>())\n {\n if (!ReferenceEquals(provider, voiceCredentialProvider))\n providers.Add(provider);\n }\n\n return providers;\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var nyxIdRealtimeBrokerEnabled = IsNyxIdRealtimeBrokerEnabled(configuration);\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIProviderConfig,\n miniCpmProviderConfig);\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig) || nyxIdRealtimeBrokerEnabled)\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n return await ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger: null,\n ct);\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionWithLoggingAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n ILogger<ReadinessGatedRealtimeVoiceProviderSession>? logger,\n CancellationToken ct)\n {\n return await VoiceRealtimeSessionReadinessBootstrapper.ConnectAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger,\n ct);\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n VoiceProviderConfig openAIProviderConfig,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n IsOpenAIVoiceConfigured(openAIProviderConfig))\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig))\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): the broker slug defaults to\n // NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug so voice stays enabled even when the\n // deployment config/secret is wiped on redeploy — config only overrides the slug, never disables it.\n private static string ResolveNyxIdRealtimeServiceSlug(IConfiguration configuration)\n {\n var configured = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:ServiceSlug\"]?.Trim();\n return string.IsNullOrWhiteSpace(configured)\n ? NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug\n : configured;\n }\n\n private static bool IsNyxIdRealtimeBrokerEnabled(IConfiguration configuration) =>\n !string.IsNullOrWhiteSpace(ResolveNyxIdRealtimeServiceSlug(configuration));\n\n private static NyxIdRealtimeProviderCredentialOptions BuildNyxIdRealtimeCredentialOptions(\n IConfiguration configuration)\n {\n var options = new NyxIdRealtimeProviderCredentialOptions\n {\n ServiceSlug = ResolveNyxIdRealtimeServiceSlug(configuration),\n };\n\n var mintPath = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:MintPath\"];\n if (!string.IsNullOrWhiteSpace(mintPath))\n options.MintPath = mintPath.Trim();\n\n var model = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:Model\"];\n if (!string.IsNullOrWhiteSpace(model))\n options.Model = model.Trim();\n\n return options;\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n DrainTimeout = options.DrainTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n var loggerFactory = sp.GetService<ILoggerFactory>();\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, loggerFactory),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, sp.GetService<ILoggerFactory>());\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor,\n ILoggerFactory? loggerFactory = null)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options, loggerFactory);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName, loggerFactory);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options, loggerFactory);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options,\n ILoggerFactory? loggerFactory = null)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName, loggerFactory);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName, loggerFactory);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new MEAILLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<MEAILLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new TornadoLLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<TornadoLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new NyxIdLLMProviderFactory();\n // Without an explicit logger the provider chain (NyxIdLLMProvider and the\n // MEAILLMProvider it delegates to) falls back to NullLogger, which silences\n // upstream LLM error translations and the no-chunks streaming fallback in\n // production. Always wire the host logger when one is available.\n var providerLogger = loggerFactory?.CreateLogger<NyxIdLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null,\n provider.DefaultRoutePreference,\n providerLogger);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = ResolveNyxIdDefaultModel(configuration, options);\n var defaultRoute = ResolveNyxIdDefaultRoute(configuration);\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty, defaultRoute);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n var defaultRoute = IsNyxIdProviderType(semantic.ProviderType)\n ? ResolveNyxIdDefaultRoute(configuration)\n : null;\n configured.Add(new ConfiguredProvider(\n name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim(), defaultRoute));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", ResolveNyxIdDefaultModel(configuration, options), ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n // The default route/model literals live once in LlmDefaults (Aevatar.AI.Abstractions) so the\n // NyxID server-default path and the OpenAI-compatible Responses ingress default share one\n // source and cannot drift. Every nyxid registration path resolves its default through these\n // helpers, which apply per-deployment config overrides on top.\n private static string ResolveNyxIdDefaultRoute(IConfiguration configuration) =>\n configuration[\"Aevatar:NyxId:DefaultRoute\"] is { Length: > 0 } route\n ? route\n : LlmDefaults.NyxIdRoute;\n\n private static string ResolveNyxIdDefaultModel(IConfiguration configuration, AevatarAIFeatureOptions options) =>\n configuration[\"Aevatar:NyxId:DefaultModel\"] is { Length: > 0 } model\n ? model\n : options.OpenAIModel;\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey,\n string? DefaultRoutePreference = null);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug;\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n}\n"}, "1a67b50c3641fbd65dc13498252fbc11f80c8282": {"t": 1785756618.1476705, "n": 49990, "h": "53d1bc840abf6c57", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.NyxId;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.Credentials;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = LlmDefaults.Model;\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n configure?.Invoke(options);\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n // No container-level IToolApprovalHandler: a yielding handler is only valid on\n // actors that implement the pending-approval continuation (RoleGAgent wires its\n // own). Surfaces without that capability fall back to MissingApprovalHandler and\n // fail closed instead of stranding a dead-letter approval (#2004).\n services.TryAddSingleton<IVoiceToolInvoker>(sp => new AgentToolVoiceInvoker(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceInvoker>>()));\n services.TryAddSingleton<IVoiceToolCatalog>(sp => new AgentToolVoiceCatalog(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceCatalog>>()));\n services.TryAddSingleton<IVoicePresenceCapabilityCommandPort, VoicePresenceCapabilityCommandPort>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): when the OpenAI realtime credential is\n // brokered through NyxID, register the resolver so the provider mints a per-session ephemeral\n // instead of reading a static OPENAI_API_KEY. Requires NyxID tools (INyxIdApiClientFactory) wired.\n var nyxIdRealtimeCredentialOptions = BuildNyxIdRealtimeCredentialOptions(configuration);\n if (nyxIdRealtimeCredentialOptions.Enabled)\n {\n services.TryAddSingleton(nyxIdRealtimeCredentialOptions);\n services.TryAddSingleton<IRealtimeProviderCredentialResolver>(sp => new NyxIdRealtimeProviderCredentialResolver(\n sp.GetRequiredService<INyxIdApiClientFactory>(),\n sp.GetRequiredService<NyxIdRealtimeProviderCredentialOptions>(),\n sp.GetRequiredService<ILogger<NyxIdRealtimeProviderCredentialResolver>>(),\n ResolveVoiceCredentialProviders(sp)));\n }\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceLeaseObservationPort, VoicePresenceLeaseObservationPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton(sp => new VoiceVolatileToolCredentialPort(sp.GetService<TimeProvider>()));\n services.TryAddSingleton<IVoiceVolatileToolCredentialPort>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.TryAddSingleton<IVoiceToolCredentialIssuer>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IReadOnlyList<ICredentialProvider> ResolveVoiceCredentialProviders(IServiceProvider services)\n {\n var providers = new List<ICredentialProvider>();\n var voiceCredentialProvider = services.GetService<IVoiceToolCredentialIssuer>() as ICredentialProvider;\n if (voiceCredentialProvider is not null)\n providers.Add(voiceCredentialProvider);\n\n foreach (var provider in services.GetServices<ICredentialProvider>())\n {\n if (!ReferenceEquals(provider, voiceCredentialProvider))\n providers.Add(provider);\n }\n\n return providers;\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var nyxIdRealtimeBrokerEnabled = IsNyxIdRealtimeBrokerEnabled(configuration);\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIProviderConfig,\n miniCpmProviderConfig);\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig) || nyxIdRealtimeBrokerEnabled)\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n return await VoiceRealtimeSessionReadinessBootstrapper.ConnectAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n ct);\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n VoiceProviderConfig openAIProviderConfig,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n IsOpenAIVoiceConfigured(openAIProviderConfig))\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig))\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): the broker slug defaults to\n // NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug so voice stays enabled even when the\n // deployment config/secret is wiped on redeploy — config only overrides the slug, never disables it.\n private static string ResolveNyxIdRealtimeServiceSlug(IConfiguration configuration)\n {\n var configured = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:ServiceSlug\"]?.Trim();\n return string.IsNullOrWhiteSpace(configured)\n ? NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug\n : configured;\n }\n\n private static bool IsNyxIdRealtimeBrokerEnabled(IConfiguration configuration) =>\n !string.IsNullOrWhiteSpace(ResolveNyxIdRealtimeServiceSlug(configuration));\n\n private static NyxIdRealtimeProviderCredentialOptions BuildNyxIdRealtimeCredentialOptions(\n IConfiguration configuration)\n {\n var options = new NyxIdRealtimeProviderCredentialOptions\n {\n ServiceSlug = ResolveNyxIdRealtimeServiceSlug(configuration),\n };\n\n var mintPath = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:MintPath\"];\n if (!string.IsNullOrWhiteSpace(mintPath))\n options.MintPath = mintPath.Trim();\n\n var model = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:Model\"];\n if (!string.IsNullOrWhiteSpace(model))\n options.Model = model.Trim();\n\n return options;\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n DrainTimeout = options.DrainTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n var loggerFactory = sp.GetService<ILoggerFactory>();\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, loggerFactory),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, sp.GetService<ILoggerFactory>());\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor,\n ILoggerFactory? loggerFactory = null)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options, loggerFactory);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName, loggerFactory);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options, loggerFactory);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options,\n ILoggerFactory? loggerFactory = null)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName, loggerFactory);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName, loggerFactory);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new MEAILLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<MEAILLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new TornadoLLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<TornadoLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new NyxIdLLMProviderFactory();\n // Without an explicit logger the provider chain (NyxIdLLMProvider and the\n // MEAILLMProvider it delegates to) falls back to NullLogger, which silences\n // upstream LLM error translations and the no-chunks streaming fallback in\n // production. Always wire the host logger when one is available.\n var providerLogger = loggerFactory?.CreateLogger<NyxIdLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null,\n provider.DefaultRoutePreference,\n providerLogger);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = ResolveNyxIdDefaultModel(configuration, options);\n var defaultRoute = ResolveNyxIdDefaultRoute(configuration);\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty, defaultRoute);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n var defaultRoute = IsNyxIdProviderType(semantic.ProviderType)\n ? ResolveNyxIdDefaultRoute(configuration)\n : null;\n configured.Add(new ConfiguredProvider(\n name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim(), defaultRoute));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", ResolveNyxIdDefaultModel(configuration, options), ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n // The default route/model literals live once in LlmDefaults (Aevatar.AI.Abstractions) so the\n // NyxID server-default path and the OpenAI-compatible Responses ingress default share one\n // source and cannot drift. Every nyxid registration path resolves its default through these\n // helpers, which apply per-deployment config overrides on top.\n private static string ResolveNyxIdDefaultRoute(IConfiguration configuration) =>\n configuration[\"Aevatar:NyxId:DefaultRoute\"] is { Length: > 0 } route\n ? route\n : LlmDefaults.NyxIdRoute;\n\n private static string ResolveNyxIdDefaultModel(IConfiguration configuration, AevatarAIFeatureOptions options) =>\n configuration[\"Aevatar:NyxId:DefaultModel\"] is { Length: > 0 } model\n ? model\n : options.OpenAIModel;\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey,\n string? DefaultRoutePreference = null);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug;\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n}\n"}, "a2f727e9c679f02d3036734e2ec78afc6beefbf6": {"t": 1785756619.6995778, "n": 51842, "h": "72953327ae01368c", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.NyxId;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.Credentials;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = LlmDefaults.Model;\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n configure?.Invoke(options);\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n // No container-level IToolApprovalHandler: a yielding handler is only valid on\n // actors that implement the pending-approval continuation (RoleGAgent wires its\n // own). Surfaces without that capability fall back to MissingApprovalHandler and\n // fail closed instead of stranding a dead-letter approval (#2004).\n services.TryAddSingleton<IVoiceToolInvoker>(sp => new AgentToolVoiceInvoker(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceInvoker>>()));\n services.TryAddSingleton<IVoiceToolCatalog>(sp => new AgentToolVoiceCatalog(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceCatalog>>()));\n services.TryAddSingleton<IVoicePresenceCapabilityCommandPort, VoicePresenceCapabilityCommandPort>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): when the OpenAI realtime credential is\n // brokered through NyxID, register the resolver so the provider mints a per-session ephemeral\n // instead of reading a static OPENAI_API_KEY. Requires NyxID tools (INyxIdApiClientFactory) wired.\n var nyxIdRealtimeCredentialOptions = BuildNyxIdRealtimeCredentialOptions(configuration);\n if (nyxIdRealtimeCredentialOptions.Enabled)\n {\n services.TryAddSingleton(nyxIdRealtimeCredentialOptions);\n services.TryAddSingleton<IRealtimeProviderCredentialResolver>(sp => new NyxIdRealtimeProviderCredentialResolver(\n sp.GetRequiredService<INyxIdApiClientFactory>(),\n sp.GetRequiredService<NyxIdRealtimeProviderCredentialOptions>(),\n sp.GetRequiredService<ILogger<NyxIdRealtimeProviderCredentialResolver>>(),\n ResolveVoiceCredentialProviders(sp)));\n }\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceLeaseObservationPort, VoicePresenceLeaseObservationPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton(sp => new VoiceVolatileToolCredentialPort(sp.GetService<TimeProvider>()));\n services.TryAddSingleton<IVoiceVolatileToolCredentialPort>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.TryAddSingleton<IVoiceToolCredentialIssuer>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IReadOnlyList<ICredentialProvider> ResolveVoiceCredentialProviders(IServiceProvider services)\n {\n var providers = new List<ICredentialProvider>();\n var voiceCredentialProvider = services.GetService<IVoiceToolCredentialIssuer>() as ICredentialProvider;\n if (voiceCredentialProvider is not null)\n providers.Add(voiceCredentialProvider);\n\n foreach (var provider in services.GetServices<ICredentialProvider>())\n {\n if (!ReferenceEquals(provider, voiceCredentialProvider))\n providers.Add(provider);\n }\n\n return providers;\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var nyxIdRealtimeBrokerEnabled = IsNyxIdRealtimeBrokerEnabled(configuration);\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIProviderConfig,\n miniCpmProviderConfig);\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig) || nyxIdRealtimeBrokerEnabled)\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n var providerSession = await provider.ConnectAsync(\n new VoiceProviderSessionKey(\n handle.SessionId,\n handle.OwnerId,\n handle.ActiveTransportLeaseId ?? string.Empty,\n handle.LeaseEpoch,\n Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(handle.ExpiresAtUtc.ToUniversalTime()),\n handle.ActorId,\n handle.ModuleName,\n handle.ToolContext?.Clone()),\n providerConfig,\n eventSink,\n audioSink,\n ct);\n\n var effectiveSession = await BuildEffectiveVoiceSessionConfigAsync(sessionConfig, toolCatalog, ct);\n await providerSession.UpdateSessionAsync(effectiveSession, ct);\n return providerSession;\n }\n\n private static async Task<VoiceSessionConfig> BuildEffectiveVoiceSessionConfigAsync(\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n CancellationToken ct)\n {\n var effectiveSession = sessionConfig.Clone();\n if (toolCatalog == null)\n return effectiveSession;\n\n var knownNames = new HashSet<string>(\n effectiveSession.ToolDefinitions\n .Select(static definition => definition.Name)\n .Where(static name => !string.IsNullOrWhiteSpace(name)),\n StringComparer.OrdinalIgnoreCase);\n\n foreach (var discoveredTool in await toolCatalog.DiscoverAsync(toolContext: null, ct: ct))\n {\n var toolName = discoveredTool.Name?.Trim();\n if (string.IsNullOrWhiteSpace(toolName) || !knownNames.Add(toolName))\n continue;\n\n effectiveSession.ToolDefinitions.Add(new VoiceToolDefinition\n {\n Name = toolName,\n Description = discoveredTool.Description ?? string.Empty,\n ParametersSchema = string.IsNullOrWhiteSpace(discoveredTool.ParametersSchema)\n ? \"{}\"\n : discoveredTool.ParametersSchema,\n });\n }\n\n return effectiveSession;\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n VoiceProviderConfig openAIProviderConfig,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n IsOpenAIVoiceConfigured(openAIProviderConfig))\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig))\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): the broker slug defaults to\n // NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug so voice stays enabled even when the\n // deployment config/secret is wiped on redeploy — config only overrides the slug, never disables it.\n private static string ResolveNyxIdRealtimeServiceSlug(IConfiguration configuration)\n {\n var configured = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:ServiceSlug\"]?.Trim();\n return string.IsNullOrWhiteSpace(configured)\n ? NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug\n : configured;\n }\n\n private static bool IsNyxIdRealtimeBrokerEnabled(IConfiguration configuration) =>\n !string.IsNullOrWhiteSpace(ResolveNyxIdRealtimeServiceSlug(configuration));\n\n private static NyxIdRealtimeProviderCredentialOptions BuildNyxIdRealtimeCredentialOptions(\n IConfiguration configuration)\n {\n var options = new NyxIdRealtimeProviderCredentialOptions\n {\n ServiceSlug = ResolveNyxIdRealtimeServiceSlug(configuration),\n };\n\n var mintPath = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:MintPath\"];\n if (!string.IsNullOrWhiteSpace(mintPath))\n options.MintPath = mintPath.Trim();\n\n var model = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:Model\"];\n if (!string.IsNullOrWhiteSpace(model))\n options.Model = model.Trim();\n\n return options;\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n DrainTimeout = options.DrainTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n var loggerFactory = sp.GetService<ILoggerFactory>();\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, loggerFactory),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, sp.GetService<ILoggerFactory>());\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor,\n ILoggerFactory? loggerFactory = null)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options, loggerFactory);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName, loggerFactory);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options, loggerFactory);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options,\n ILoggerFactory? loggerFactory = null)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName, loggerFactory);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName, loggerFactory);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new MEAILLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<MEAILLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new TornadoLLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<TornadoLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new NyxIdLLMProviderFactory();\n // Without an explicit logger the provider chain (NyxIdLLMProvider and the\n // MEAILLMProvider it delegates to) falls back to NullLogger, which silences\n // upstream LLM error translations and the no-chunks streaming fallback in\n // production. Always wire the host logger when one is available.\n var providerLogger = loggerFactory?.CreateLogger<NyxIdLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null,\n provider.DefaultRoutePreference,\n providerLogger);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = ResolveNyxIdDefaultModel(configuration, options);\n var defaultRoute = ResolveNyxIdDefaultRoute(configuration);\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty, defaultRoute);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n var defaultRoute = IsNyxIdProviderType(semantic.ProviderType)\n ? ResolveNyxIdDefaultRoute(configuration)\n : null;\n configured.Add(new ConfiguredProvider(\n name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim(), defaultRoute));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", ResolveNyxIdDefaultModel(configuration, options), ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n // The default route/model literals live once in LlmDefaults (Aevatar.AI.Abstractions) so the\n // NyxID server-default path and the OpenAI-compatible Responses ingress default share one\n // source and cannot drift. Every nyxid registration path resolves its default through these\n // helpers, which apply per-deployment config overrides on top.\n private static string ResolveNyxIdDefaultRoute(IConfiguration configuration) =>\n configuration[\"Aevatar:NyxId:DefaultRoute\"] is { Length: > 0 } route\n ? route\n : LlmDefaults.NyxIdRoute;\n\n private static string ResolveNyxIdDefaultModel(IConfiguration configuration, AevatarAIFeatureOptions options) =>\n configuration[\"Aevatar:NyxId:DefaultModel\"] is { Length: > 0 } model\n ? model\n : options.OpenAIModel;\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey,\n string? DefaultRoutePreference = null);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug;\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n}\n"}, "225d48a7f41c8d5a0f38751b2a07379f7b2506f5": {"t": 1785756621.2282007, "n": 51793, "h": "d768026c7e0acfc3", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.NyxId;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.Credentials;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = LlmDefaults.Model;\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n configure?.Invoke(options);\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n // No container-level IToolApprovalHandler: a yielding handler is only valid on\n // actors that implement the pending-approval continuation (RoleGAgent wires its\n // own). Surfaces without that capability fall back to MissingApprovalHandler and\n // fail closed instead of stranding a dead-letter approval (#2004).\n services.TryAddSingleton<IVoiceToolInvoker>(sp => new AgentToolVoiceInvoker(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceInvoker>>()));\n services.TryAddSingleton<IVoiceToolCatalog>(sp => new AgentToolVoiceCatalog(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceCatalog>>()));\n services.TryAddSingleton<IVoicePresenceCapabilityCommandPort, VoicePresenceCapabilityCommandPort>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): when the OpenAI realtime credential is\n // brokered through NyxID, register the resolver so the provider mints a per-session ephemeral\n // instead of reading a static OPENAI_API_KEY. Requires NyxID tools (INyxIdApiClientFactory) wired.\n var nyxIdRealtimeCredentialOptions = BuildNyxIdRealtimeCredentialOptions(configuration);\n if (nyxIdRealtimeCredentialOptions.Enabled)\n {\n services.TryAddSingleton(nyxIdRealtimeCredentialOptions);\n services.TryAddSingleton<IRealtimeProviderCredentialResolver>(sp => new NyxIdRealtimeProviderCredentialResolver(\n sp.GetRequiredService<INyxIdApiClientFactory>(),\n sp.GetRequiredService<NyxIdRealtimeProviderCredentialOptions>(),\n sp.GetRequiredService<ILogger<NyxIdRealtimeProviderCredentialResolver>>(),\n ResolveVoiceCredentialProviders(sp)));\n }\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceLeaseObservationPort, VoicePresenceLeaseObservationPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton(sp => new VoiceVolatileToolCredentialPort(sp.GetService<TimeProvider>()));\n services.TryAddSingleton<IVoiceVolatileToolCredentialPort>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.TryAddSingleton<IVoiceToolCredentialIssuer>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IReadOnlyList<ICredentialProvider> ResolveVoiceCredentialProviders(IServiceProvider services)\n {\n var providers = new List<ICredentialProvider>();\n var voiceCredentialProvider = services.GetService<IVoiceToolCredentialIssuer>() as ICredentialProvider;\n if (voiceCredentialProvider is not null)\n providers.Add(voiceCredentialProvider);\n\n foreach (var provider in services.GetServices<ICredentialProvider>())\n {\n if (!ReferenceEquals(provider, voiceCredentialProvider))\n providers.Add(provider);\n }\n\n return providers;\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var nyxIdRealtimeBrokerEnabled = IsNyxIdRealtimeBrokerEnabled(configuration);\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIProviderConfig,\n miniCpmProviderConfig);\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig) || nyxIdRealtimeBrokerEnabled)\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n var providerSession = await provider.ConnectAsync(\n new VoiceProviderSessionKey(\n handle.SessionId,\n handle.OwnerId,\n handle.ActiveTransportLeaseId ?? string.Empty,\n handle.LeaseEpoch,\n Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(handle.ExpiresAtUtc.ToUniversalTime()),\n handle.ActorId,\n handle.ModuleName,\n handle.ToolContext?.Clone()),\n providerConfig,\n eventSink,\n audioSink,\n ct);\n\n var effectiveSession = await BuildEffectiveVoiceSessionConfigAsync(sessionConfig, toolCatalog, ct);\n await providerSession.UpdateSessionAsync(effectiveSession, ct);\n return providerSession;\n }\n\n private static async Task<VoiceSessionConfig> BuildEffectiveVoiceSessionConfigAsync(\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n CancellationToken ct)\n {\n var effectiveSession = sessionConfig.Clone();\n if (toolCatalog == null)\n return effectiveSession;\n\n var knownNames = new HashSet<string>(\n effectiveSession.ToolDefinitions\n .Select(static definition => definition.Name)\n .Where(static name => !string.IsNullOrWhiteSpace(name)),\n StringComparer.OrdinalIgnoreCase);\n\n foreach (var discoveredTool in await toolCatalog.DiscoverAsync(toolContext: null, ct: ct))\n {\n var toolName = discoveredTool.Name?.Trim();\n if (string.IsNullOrWhiteSpace(toolName) || !knownNames.Add(toolName))\n continue;\n\n effectiveSession.ToolDefinitions.Add(new VoiceToolDefinition\n {\n Name = toolName,\n Description = discoveredTool.Description ?? string.Empty,\n ParametersSchema = string.IsNullOrWhiteSpace(discoveredTool.ParametersSchema)\n ? \"{}\"\n : discoveredTool.ParametersSchema,\n });\n }\n\n return effectiveSession;\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n VoiceProviderConfig openAIProviderConfig,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n IsOpenAIVoiceConfigured(openAIProviderConfig))\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig))\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): the broker slug defaults to\n // NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug so voice stays enabled even when the\n // deployment config/secret is wiped on redeploy — config only overrides the slug, never disables it.\n private static string ResolveNyxIdRealtimeServiceSlug(IConfiguration configuration)\n {\n var configured = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:ServiceSlug\"]?.Trim();\n return string.IsNullOrWhiteSpace(configured)\n ? NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug\n : configured;\n }\n\n private static bool IsNyxIdRealtimeBrokerEnabled(IConfiguration configuration) =>\n !string.IsNullOrWhiteSpace(ResolveNyxIdRealtimeServiceSlug(configuration));\n\n private static NyxIdRealtimeProviderCredentialOptions BuildNyxIdRealtimeCredentialOptions(\n IConfiguration configuration)\n {\n var options = new NyxIdRealtimeProviderCredentialOptions\n {\n ServiceSlug = ResolveNyxIdRealtimeServiceSlug(configuration),\n };\n\n var mintPath = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:MintPath\"];\n if (!string.IsNullOrWhiteSpace(mintPath))\n options.MintPath = mintPath.Trim();\n\n var model = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:Model\"];\n if (!string.IsNullOrWhiteSpace(model))\n options.Model = model.Trim();\n\n return options;\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n var loggerFactory = sp.GetService<ILoggerFactory>();\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, loggerFactory),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, sp.GetService<ILoggerFactory>());\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor,\n ILoggerFactory? loggerFactory = null)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options, loggerFactory);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName, loggerFactory);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options, loggerFactory);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options,\n ILoggerFactory? loggerFactory = null)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName, loggerFactory);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName, loggerFactory);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new MEAILLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<MEAILLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new TornadoLLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<TornadoLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new NyxIdLLMProviderFactory();\n // Without an explicit logger the provider chain (NyxIdLLMProvider and the\n // MEAILLMProvider it delegates to) falls back to NullLogger, which silences\n // upstream LLM error translations and the no-chunks streaming fallback in\n // production. Always wire the host logger when one is available.\n var providerLogger = loggerFactory?.CreateLogger<NyxIdLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null,\n provider.DefaultRoutePreference,\n providerLogger);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = ResolveNyxIdDefaultModel(configuration, options);\n var defaultRoute = ResolveNyxIdDefaultRoute(configuration);\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty, defaultRoute);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n var defaultRoute = IsNyxIdProviderType(semantic.ProviderType)\n ? ResolveNyxIdDefaultRoute(configuration)\n : null;\n configured.Add(new ConfiguredProvider(\n name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim(), defaultRoute));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", ResolveNyxIdDefaultModel(configuration, options), ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n // The default route/model literals live once in LlmDefaults (Aevatar.AI.Abstractions) so the\n // NyxID server-default path and the OpenAI-compatible Responses ingress default share one\n // source and cannot drift. Every nyxid registration path resolves its default through these\n // helpers, which apply per-deployment config overrides on top.\n private static string ResolveNyxIdDefaultRoute(IConfiguration configuration) =>\n configuration[\"Aevatar:NyxId:DefaultRoute\"] is { Length: > 0 } route\n ? route\n : LlmDefaults.NyxIdRoute;\n\n private static string ResolveNyxIdDefaultModel(IConfiguration configuration, AevatarAIFeatureOptions options) =>\n configuration[\"Aevatar:NyxId:DefaultModel\"] is { Length: > 0 } model\n ? model\n : options.OpenAIModel;\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey,\n string? DefaultRoutePreference = null);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug;\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n}\n"}, "17c326e7aca9f4c9e2242cb27eb99b7dd8e76d89": {"t": 1785756623.317843, "n": 50581, "h": "210c91938df192e9", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.NyxId;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.Credentials;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = \"gpt-5.4\";\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n configure?.Invoke(options);\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n // No container-level IToolApprovalHandler: a yielding handler is only valid on\n // actors that implement the pending-approval continuation (RoleGAgent wires its\n // own). Surfaces without that capability fall back to MissingApprovalHandler and\n // fail closed instead of stranding a dead-letter approval (#2004).\n services.TryAddSingleton<IVoiceToolInvoker>(sp => new AgentToolVoiceInvoker(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceInvoker>>()));\n services.TryAddSingleton<IVoiceToolCatalog>(sp => new AgentToolVoiceCatalog(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceCatalog>>()));\n services.TryAddSingleton<IVoicePresenceCapabilityCommandPort, VoicePresenceCapabilityCommandPort>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): when the OpenAI realtime credential is\n // brokered through NyxID, register the resolver so the provider mints a per-session ephemeral\n // instead of reading a static OPENAI_API_KEY. Requires NyxID tools (INyxIdApiClientFactory) wired.\n var nyxIdRealtimeCredentialOptions = BuildNyxIdRealtimeCredentialOptions(configuration);\n if (nyxIdRealtimeCredentialOptions.Enabled)\n {\n services.TryAddSingleton(nyxIdRealtimeCredentialOptions);\n services.TryAddSingleton<IRealtimeProviderCredentialResolver>(sp => new NyxIdRealtimeProviderCredentialResolver(\n sp.GetRequiredService<INyxIdApiClientFactory>(),\n sp.GetRequiredService<NyxIdRealtimeProviderCredentialOptions>(),\n sp.GetRequiredService<ILogger<NyxIdRealtimeProviderCredentialResolver>>(),\n ResolveVoiceCredentialProviders(sp)));\n }\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceLeaseObservationPort, VoicePresenceLeaseObservationPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton(sp => new VoiceVolatileToolCredentialPort(sp.GetService<TimeProvider>()));\n services.TryAddSingleton<IVoiceVolatileToolCredentialPort>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.TryAddSingleton<IVoiceToolCredentialIssuer>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IReadOnlyList<ICredentialProvider> ResolveVoiceCredentialProviders(IServiceProvider services)\n {\n var providers = new List<ICredentialProvider>();\n var voiceCredentialProvider = services.GetService<IVoiceToolCredentialIssuer>() as ICredentialProvider;\n if (voiceCredentialProvider is not null)\n providers.Add(voiceCredentialProvider);\n\n foreach (var provider in services.GetServices<ICredentialProvider>())\n {\n if (!ReferenceEquals(provider, voiceCredentialProvider))\n providers.Add(provider);\n }\n\n return providers;\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var nyxIdRealtimeBrokerEnabled = IsNyxIdRealtimeBrokerEnabled(configuration);\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIProviderConfig,\n miniCpmProviderConfig);\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig) || nyxIdRealtimeBrokerEnabled)\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n var providerSession = await provider.ConnectAsync(\n new VoiceProviderSessionKey(\n handle.SessionId,\n handle.OwnerId,\n handle.ActiveTransportLeaseId ?? string.Empty,\n handle.LeaseEpoch,\n Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(handle.ExpiresAtUtc.ToUniversalTime()),\n handle.ActorId,\n handle.ModuleName,\n handle.ToolContext?.Clone()),\n providerConfig,\n eventSink,\n audioSink,\n ct);\n\n var effectiveSession = await BuildEffectiveVoiceSessionConfigAsync(sessionConfig, toolCatalog, ct);\n await providerSession.UpdateSessionAsync(effectiveSession, ct);\n return providerSession;\n }\n\n private static async Task<VoiceSessionConfig> BuildEffectiveVoiceSessionConfigAsync(\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n CancellationToken ct)\n {\n var effectiveSession = sessionConfig.Clone();\n if (toolCatalog == null)\n return effectiveSession;\n\n var knownNames = new HashSet<string>(\n effectiveSession.ToolDefinitions\n .Select(static definition => definition.Name)\n .Where(static name => !string.IsNullOrWhiteSpace(name)),\n StringComparer.OrdinalIgnoreCase);\n\n foreach (var discoveredTool in await toolCatalog.DiscoverAsync(toolContext: null, ct: ct))\n {\n var toolName = discoveredTool.Name?.Trim();\n if (string.IsNullOrWhiteSpace(toolName) || !knownNames.Add(toolName))\n continue;\n\n effectiveSession.ToolDefinitions.Add(new VoiceToolDefinition\n {\n Name = toolName,\n Description = discoveredTool.Description ?? string.Empty,\n ParametersSchema = string.IsNullOrWhiteSpace(discoveredTool.ParametersSchema)\n ? \"{}\"\n : discoveredTool.ParametersSchema,\n });\n }\n\n return effectiveSession;\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n VoiceProviderConfig openAIProviderConfig,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n IsOpenAIVoiceConfigured(openAIProviderConfig))\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig))\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): the broker slug defaults to\n // NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug so voice stays enabled even when the\n // deployment config/secret is wiped on redeploy — config only overrides the slug, never disables it.\n private static string ResolveNyxIdRealtimeServiceSlug(IConfiguration configuration)\n {\n var configured = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:ServiceSlug\"]?.Trim();\n return string.IsNullOrWhiteSpace(configured)\n ? NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug\n : configured;\n }\n\n private static bool IsNyxIdRealtimeBrokerEnabled(IConfiguration configuration) =>\n !string.IsNullOrWhiteSpace(ResolveNyxIdRealtimeServiceSlug(configuration));\n\n private static NyxIdRealtimeProviderCredentialOptions BuildNyxIdRealtimeCredentialOptions(\n IConfiguration configuration)\n {\n var options = new NyxIdRealtimeProviderCredentialOptions\n {\n ServiceSlug = ResolveNyxIdRealtimeServiceSlug(configuration),\n };\n\n var mintPath = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:MintPath\"];\n if (!string.IsNullOrWhiteSpace(mintPath))\n options.MintPath = mintPath.Trim();\n\n var model = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:Model\"];\n if (!string.IsNullOrWhiteSpace(model))\n options.Model = model.Trim();\n\n return options;\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n var loggerFactory = sp.GetService<ILoggerFactory>();\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, loggerFactory),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, sp.GetService<ILoggerFactory>());\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor,\n ILoggerFactory? loggerFactory = null)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options, loggerFactory);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName, loggerFactory);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options, loggerFactory);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options,\n ILoggerFactory? loggerFactory = null)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName, loggerFactory);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName, loggerFactory);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new MEAILLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<MEAILLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new TornadoLLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<TornadoLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new NyxIdLLMProviderFactory();\n // Without an explicit logger the provider chain (NyxIdLLMProvider and the\n // MEAILLMProvider it delegates to) falls back to NullLogger, which silences\n // upstream LLM error translations and the no-chunks streaming fallback in\n // production. Always wire the host logger when one is available.\n var providerLogger = loggerFactory?.CreateLogger<NyxIdLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null,\n providerLogger);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = configuration[\"Aevatar:NyxId:DefaultModel\"] ?? options.OpenAIModel;\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n configured.Add(new ConfiguredProvider(name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim()));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", options.OpenAIModel, ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug;\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n}\n"}, "432293c206fddd400c5b9c1a734279fd5d8fd075": {"t": 1785756624.9082484, "n": 48896, "h": "a8718a362ea9a901", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = \"gpt-5.4\";\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n configure?.Invoke(options);\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n // No container-level IToolApprovalHandler: a yielding handler is only valid on\n // actors that implement the pending-approval continuation (RoleGAgent wires its\n // own). Surfaces without that capability fall back to MissingApprovalHandler and\n // fail closed instead of stranding a dead-letter approval (#2004).\n services.TryAddSingleton<IVoiceToolInvoker, AgentToolVoiceInvoker>();\n services.TryAddSingleton<IVoiceToolCatalog, AgentToolVoiceCatalog>();\n services.TryAddSingleton<IVoicePresenceCapabilityCommandPort, VoicePresenceCapabilityCommandPort>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): when the OpenAI realtime credential is\n // brokered through NyxID, register the resolver so the provider mints a per-session ephemeral\n // instead of reading a static OPENAI_API_KEY. Requires NyxID tools (INyxIdApiClientFactory) wired.\n var nyxIdRealtimeCredentialOptions = BuildNyxIdRealtimeCredentialOptions(configuration);\n if (nyxIdRealtimeCredentialOptions.Enabled)\n {\n services.TryAddSingleton(nyxIdRealtimeCredentialOptions);\n services.TryAddSingleton<IRealtimeProviderCredentialResolver, NyxIdRealtimeProviderCredentialResolver>();\n }\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceLeaseObservationPort, VoicePresenceLeaseObservationPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IVoiceSessionCredentialStore, VoiceVolatileSessionCredentialStore>();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var nyxIdRealtimeBrokerEnabled = IsNyxIdRealtimeBrokerEnabled(configuration);\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIProviderConfig,\n miniCpmProviderConfig);\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig) || nyxIdRealtimeBrokerEnabled)\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n var providerSession = await provider.ConnectAsync(\n new VoiceProviderSessionKey(\n handle.SessionId,\n handle.OwnerId,\n handle.ActiveTransportLeaseId ?? string.Empty,\n handle.LeaseEpoch,\n Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(handle.ExpiresAtUtc.ToUniversalTime()),\n handle.ActorId,\n handle.ModuleName),\n providerConfig,\n eventSink,\n audioSink,\n ct);\n\n var effectiveSession = await BuildEffectiveVoiceSessionConfigAsync(sessionConfig, toolCatalog, ct);\n await providerSession.UpdateSessionAsync(effectiveSession, ct);\n return providerSession;\n }\n\n private static async Task<VoiceSessionConfig> BuildEffectiveVoiceSessionConfigAsync(\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n CancellationToken ct)\n {\n var effectiveSession = sessionConfig.Clone();\n if (toolCatalog == null)\n return effectiveSession;\n\n var knownNames = new HashSet<string>(\n effectiveSession.ToolDefinitions\n .Select(static definition => definition.Name)\n .Where(static name => !string.IsNullOrWhiteSpace(name)),\n StringComparer.OrdinalIgnoreCase);\n\n foreach (var discoveredTool in await toolCatalog.DiscoverAsync(ct))\n {\n var toolName = discoveredTool.Name?.Trim();\n if (string.IsNullOrWhiteSpace(toolName) || !knownNames.Add(toolName))\n continue;\n\n effectiveSession.ToolDefinitions.Add(new VoiceToolDefinition\n {\n Name = toolName,\n Description = discoveredTool.Description ?? string.Empty,\n ParametersSchema = string.IsNullOrWhiteSpace(discoveredTool.ParametersSchema)\n ? \"{}\"\n : discoveredTool.ParametersSchema,\n });\n }\n\n return effectiveSession;\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n VoiceProviderConfig openAIProviderConfig,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n IsOpenAIVoiceConfigured(openAIProviderConfig))\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig))\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): the broker slug defaults to\n // NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug so voice stays enabled even when the\n // deployment config/secret is wiped on redeploy — config only overrides the slug, never disables it.\n private static string ResolveNyxIdRealtimeServiceSlug(IConfiguration configuration)\n {\n var configured = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:ServiceSlug\"]?.Trim();\n return string.IsNullOrWhiteSpace(configured)\n ? NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug\n : configured;\n }\n\n private static bool IsNyxIdRealtimeBrokerEnabled(IConfiguration configuration) =>\n !string.IsNullOrWhiteSpace(ResolveNyxIdRealtimeServiceSlug(configuration));\n\n private static NyxIdRealtimeProviderCredentialOptions BuildNyxIdRealtimeCredentialOptions(\n IConfiguration configuration)\n {\n var options = new NyxIdRealtimeProviderCredentialOptions\n {\n ServiceSlug = ResolveNyxIdRealtimeServiceSlug(configuration),\n };\n\n var mintPath = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:MintPath\"];\n if (!string.IsNullOrWhiteSpace(mintPath))\n options.MintPath = mintPath.Trim();\n\n var model = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:Model\"];\n if (!string.IsNullOrWhiteSpace(model))\n options.Model = model.Trim();\n\n return options;\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n var loggerFactory = sp.GetService<ILoggerFactory>();\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, loggerFactory),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, sp.GetService<ILoggerFactory>());\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor,\n ILoggerFactory? loggerFactory = null)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options, loggerFactory);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName, loggerFactory);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options, loggerFactory);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options,\n ILoggerFactory? loggerFactory = null)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName, loggerFactory);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName, loggerFactory);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new MEAILLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<MEAILLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new TornadoLLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<TornadoLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new NyxIdLLMProviderFactory();\n // Without an explicit logger the provider chain (NyxIdLLMProvider and the\n // MEAILLMProvider it delegates to) falls back to NullLogger, which silences\n // upstream LLM error translations and the no-chunks streaming fallback in\n // production. Always wire the host logger when one is available.\n var providerLogger = loggerFactory?.CreateLogger<NyxIdLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null,\n providerLogger);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = configuration[\"Aevatar:NyxId:DefaultModel\"] ?? options.OpenAIModel;\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n configured.Add(new ConfiguredProvider(name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim()));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", options.OpenAIModel, ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug;\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n}\n"}, "f8b3b56537147cca604358f76e665bef72a48ad3": {"t": 1785756626.4606686, "n": 48773, "h": "84ab354ad94c6ff4", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = \"gpt-5.4\";\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n configure?.Invoke(options);\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n // No container-level IToolApprovalHandler: a yielding handler is only valid on\n // actors that implement the pending-approval continuation (RoleGAgent wires its\n // own). Surfaces without that capability fall back to MissingApprovalHandler and\n // fail closed instead of stranding a dead-letter approval (#2004).\n services.TryAddSingleton<IVoiceToolInvoker, AgentToolVoiceInvoker>();\n services.TryAddSingleton<IVoiceToolCatalog, AgentToolVoiceCatalog>();\n services.TryAddSingleton<IVoicePresenceCapabilityCommandPort, VoicePresenceCapabilityCommandPort>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): when the OpenAI realtime credential is\n // brokered through NyxID, register the resolver so the provider mints a per-session ephemeral\n // instead of reading a static OPENAI_API_KEY. Requires NyxID tools (INyxIdApiClientFactory) wired.\n var nyxIdRealtimeCredentialOptions = BuildNyxIdRealtimeCredentialOptions(configuration);\n if (nyxIdRealtimeCredentialOptions.Enabled)\n {\n services.TryAddSingleton(nyxIdRealtimeCredentialOptions);\n services.TryAddSingleton<IRealtimeProviderCredentialResolver, NyxIdRealtimeProviderCredentialResolver>();\n }\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IVoiceSessionCredentialStore, VoiceVolatileSessionCredentialStore>();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var nyxIdRealtimeBrokerEnabled = IsNyxIdRealtimeBrokerEnabled(configuration);\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIProviderConfig,\n miniCpmProviderConfig);\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig) || nyxIdRealtimeBrokerEnabled)\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n var providerSession = await provider.ConnectAsync(\n new VoiceProviderSessionKey(\n handle.SessionId,\n handle.OwnerId,\n handle.ActiveTransportLeaseId ?? string.Empty,\n 0,\n Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(handle.ExpiresAtUtc.ToUniversalTime()),\n handle.ActorId,\n handle.ModuleName),\n providerConfig,\n eventSink,\n audioSink,\n ct);\n\n var effectiveSession = await BuildEffectiveVoiceSessionConfigAsync(sessionConfig, toolCatalog, ct);\n await providerSession.UpdateSessionAsync(effectiveSession, ct);\n return providerSession;\n }\n\n private static async Task<VoiceSessionConfig> BuildEffectiveVoiceSessionConfigAsync(\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n CancellationToken ct)\n {\n var effectiveSession = sessionConfig.Clone();\n if (toolCatalog == null)\n return effectiveSession;\n\n var knownNames = new HashSet<string>(\n effectiveSession.ToolDefinitions\n .Select(static definition => definition.Name)\n .Where(static name => !string.IsNullOrWhiteSpace(name)),\n StringComparer.OrdinalIgnoreCase);\n\n foreach (var discoveredTool in await toolCatalog.DiscoverAsync(ct))\n {\n var toolName = discoveredTool.Name?.Trim();\n if (string.IsNullOrWhiteSpace(toolName) || !knownNames.Add(toolName))\n continue;\n\n effectiveSession.ToolDefinitions.Add(new VoiceToolDefinition\n {\n Name = toolName,\n Description = discoveredTool.Description ?? string.Empty,\n ParametersSchema = string.IsNullOrWhiteSpace(discoveredTool.ParametersSchema)\n ? \"{}\"\n : discoveredTool.ParametersSchema,\n });\n }\n\n return effectiveSession;\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n VoiceProviderConfig openAIProviderConfig,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n IsOpenAIVoiceConfigured(openAIProviderConfig))\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig))\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): the broker slug defaults to\n // NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug so voice stays enabled even when the\n // deployment config/secret is wiped on redeploy — config only overrides the slug, never disables it.\n private static string ResolveNyxIdRealtimeServiceSlug(IConfiguration configuration)\n {\n var configured = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:ServiceSlug\"]?.Trim();\n return string.IsNullOrWhiteSpace(configured)\n ? NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug\n : configured;\n }\n\n private static bool IsNyxIdRealtimeBrokerEnabled(IConfiguration configuration) =>\n !string.IsNullOrWhiteSpace(ResolveNyxIdRealtimeServiceSlug(configuration));\n\n private static NyxIdRealtimeProviderCredentialOptions BuildNyxIdRealtimeCredentialOptions(\n IConfiguration configuration)\n {\n var options = new NyxIdRealtimeProviderCredentialOptions\n {\n ServiceSlug = ResolveNyxIdRealtimeServiceSlug(configuration),\n };\n\n var mintPath = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:MintPath\"];\n if (!string.IsNullOrWhiteSpace(mintPath))\n options.MintPath = mintPath.Trim();\n\n var model = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:Model\"];\n if (!string.IsNullOrWhiteSpace(model))\n options.Model = model.Trim();\n\n return options;\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n var loggerFactory = sp.GetService<ILoggerFactory>();\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, loggerFactory),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, sp.GetService<ILoggerFactory>());\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor,\n ILoggerFactory? loggerFactory = null)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options, loggerFactory);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName, loggerFactory);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options, loggerFactory);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options,\n ILoggerFactory? loggerFactory = null)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName, loggerFactory);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName, loggerFactory);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new MEAILLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<MEAILLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new TornadoLLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<TornadoLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new NyxIdLLMProviderFactory();\n // Without an explicit logger the provider chain (NyxIdLLMProvider and the\n // MEAILLMProvider it delegates to) falls back to NullLogger, which silences\n // upstream LLM error translations and the no-chunks streaming fallback in\n // production. Always wire the host logger when one is available.\n var providerLogger = loggerFactory?.CreateLogger<NyxIdLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null,\n providerLogger);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = configuration[\"Aevatar:NyxId:DefaultModel\"] ?? options.OpenAIModel;\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n configured.Add(new ConfiguredProvider(name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim()));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", options.OpenAIModel, ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug;\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n}\n"}, "7b28fc8480263853f0d7ae3541e97e0c2242bbd9": {"t": 1785756628.006261, "n": 48670, "h": "d3dee48237ba4a24", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = \"gpt-5.4\";\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n configure?.Invoke(options);\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n // No container-level IToolApprovalHandler: a yielding handler is only valid on\n // actors that implement the pending-approval continuation (RoleGAgent wires its\n // own). Surfaces without that capability fall back to MissingApprovalHandler and\n // fail closed instead of stranding a dead-letter approval (#2004).\n services.TryAddSingleton<IVoiceToolInvoker, AgentToolVoiceInvoker>();\n services.TryAddSingleton<IVoiceToolCatalog, AgentToolVoiceCatalog>();\n services.TryAddSingleton<IVoicePresenceCapabilityCommandPort, VoicePresenceCapabilityCommandPort>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): when the OpenAI realtime credential is\n // brokered through NyxID, register the resolver so the provider mints a per-session ephemeral\n // instead of reading a static OPENAI_API_KEY. Requires NyxID tools (INyxIdApiClientFactory) wired.\n var nyxIdRealtimeCredentialOptions = BuildNyxIdRealtimeCredentialOptions(configuration);\n if (nyxIdRealtimeCredentialOptions.Enabled)\n {\n services.TryAddSingleton(nyxIdRealtimeCredentialOptions);\n services.TryAddSingleton<IRealtimeProviderCredentialResolver, NyxIdRealtimeProviderCredentialResolver>();\n }\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var nyxIdRealtimeBrokerEnabled = IsNyxIdRealtimeBrokerEnabled(configuration);\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIProviderConfig,\n miniCpmProviderConfig);\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig) || nyxIdRealtimeBrokerEnabled)\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n var providerSession = await provider.ConnectAsync(\n new VoiceProviderSessionKey(\n handle.SessionId,\n handle.OwnerId,\n handle.ActiveTransportLeaseId ?? string.Empty,\n 0,\n Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(handle.ExpiresAtUtc.ToUniversalTime()),\n handle.ActorId,\n handle.ModuleName),\n providerConfig,\n eventSink,\n audioSink,\n ct);\n\n var effectiveSession = await BuildEffectiveVoiceSessionConfigAsync(sessionConfig, toolCatalog, ct);\n await providerSession.UpdateSessionAsync(effectiveSession, ct);\n return providerSession;\n }\n\n private static async Task<VoiceSessionConfig> BuildEffectiveVoiceSessionConfigAsync(\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n CancellationToken ct)\n {\n var effectiveSession = sessionConfig.Clone();\n if (toolCatalog == null)\n return effectiveSession;\n\n var knownNames = new HashSet<string>(\n effectiveSession.ToolDefinitions\n .Select(static definition => definition.Name)\n .Where(static name => !string.IsNullOrWhiteSpace(name)),\n StringComparer.OrdinalIgnoreCase);\n\n foreach (var discoveredTool in await toolCatalog.DiscoverAsync(ct))\n {\n var toolName = discoveredTool.Name?.Trim();\n if (string.IsNullOrWhiteSpace(toolName) || !knownNames.Add(toolName))\n continue;\n\n effectiveSession.ToolDefinitions.Add(new VoiceToolDefinition\n {\n Name = toolName,\n Description = discoveredTool.Description ?? string.Empty,\n ParametersSchema = string.IsNullOrWhiteSpace(discoveredTool.ParametersSchema)\n ? \"{}\"\n : discoveredTool.ParametersSchema,\n });\n }\n\n return effectiveSession;\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n VoiceProviderConfig openAIProviderConfig,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n IsOpenAIVoiceConfigured(openAIProviderConfig))\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig))\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): the broker slug defaults to\n // NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug so voice stays enabled even when the\n // deployment config/secret is wiped on redeploy — config only overrides the slug, never disables it.\n private static string ResolveNyxIdRealtimeServiceSlug(IConfiguration configuration)\n {\n var configured = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:ServiceSlug\"]?.Trim();\n return string.IsNullOrWhiteSpace(configured)\n ? NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug\n : configured;\n }\n\n private static bool IsNyxIdRealtimeBrokerEnabled(IConfiguration configuration) =>\n !string.IsNullOrWhiteSpace(ResolveNyxIdRealtimeServiceSlug(configuration));\n\n private static NyxIdRealtimeProviderCredentialOptions BuildNyxIdRealtimeCredentialOptions(\n IConfiguration configuration)\n {\n var options = new NyxIdRealtimeProviderCredentialOptions\n {\n ServiceSlug = ResolveNyxIdRealtimeServiceSlug(configuration),\n };\n\n var mintPath = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:MintPath\"];\n if (!string.IsNullOrWhiteSpace(mintPath))\n options.MintPath = mintPath.Trim();\n\n var model = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:Model\"];\n if (!string.IsNullOrWhiteSpace(model))\n options.Model = model.Trim();\n\n return options;\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n var loggerFactory = sp.GetService<ILoggerFactory>();\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, loggerFactory),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, sp.GetService<ILoggerFactory>());\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor,\n ILoggerFactory? loggerFactory = null)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options, loggerFactory);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName, loggerFactory);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options, loggerFactory);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options,\n ILoggerFactory? loggerFactory = null)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName, loggerFactory);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName, loggerFactory);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new MEAILLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<MEAILLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new TornadoLLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<TornadoLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new NyxIdLLMProviderFactory();\n // Without an explicit logger the provider chain (NyxIdLLMProvider and the\n // MEAILLMProvider it delegates to) falls back to NullLogger, which silences\n // upstream LLM error translations and the no-chunks streaming fallback in\n // production. Always wire the host logger when one is available.\n var providerLogger = loggerFactory?.CreateLogger<NyxIdLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null,\n providerLogger);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = configuration[\"Aevatar:NyxId:DefaultModel\"] ?? options.OpenAIModel;\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n configured.Add(new ConfiguredProvider(name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim()));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", options.OpenAIModel, ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug;\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n}\n"}, "ff9d84dac3f42dc246efc2d64006d3e46f9ce3ec": {"t": 1785756629.4292803, "n": 48086, "h": "6dcd423b6e5753e6", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = \"gpt-5.4\";\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n configure?.Invoke(options);\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n // No container-level IToolApprovalHandler: a yielding handler is only valid on\n // actors that implement the pending-approval continuation (RoleGAgent wires its\n // own). Surfaces without that capability fall back to MissingApprovalHandler and\n // fail closed instead of stranding a dead-letter approval (#2004).\n services.TryAddSingleton<IVoiceToolInvoker, AgentToolVoiceInvoker>();\n services.TryAddSingleton<IVoiceToolCatalog, AgentToolVoiceCatalog>();\n services.TryAddSingleton<IVoicePresenceCapabilityCommandPort, VoicePresenceCapabilityCommandPort>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): when the OpenAI realtime credential is\n // brokered through NyxID, register the resolver so the provider mints a per-session ephemeral\n // instead of reading a static OPENAI_API_KEY. Requires NyxID tools (INyxIdApiClientFactory) wired.\n var nyxIdRealtimeCredentialOptions = BuildNyxIdRealtimeCredentialOptions(configuration);\n if (nyxIdRealtimeCredentialOptions.Enabled)\n {\n services.TryAddSingleton(nyxIdRealtimeCredentialOptions);\n services.TryAddSingleton<IRealtimeProviderCredentialResolver, NyxIdRealtimeProviderCredentialResolver>();\n }\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var nyxIdRealtimeBrokerEnabled = IsNyxIdRealtimeBrokerEnabled(configuration);\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIProviderConfig,\n miniCpmProviderConfig);\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig) || nyxIdRealtimeBrokerEnabled)\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n var providerSession = await provider.ConnectAsync(\n new VoiceProviderSessionKey(\n handle.SessionId,\n handle.OwnerId,\n handle.ActiveTransportLeaseId ?? string.Empty,\n 0,\n Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(handle.ExpiresAtUtc.ToUniversalTime()),\n handle.ActorId,\n handle.ModuleName),\n providerConfig,\n eventSink,\n audioSink,\n ct);\n\n var effectiveSession = await BuildEffectiveVoiceSessionConfigAsync(sessionConfig, toolCatalog, ct);\n await providerSession.UpdateSessionAsync(effectiveSession, ct);\n return providerSession;\n }\n\n private static async Task<VoiceSessionConfig> BuildEffectiveVoiceSessionConfigAsync(\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n CancellationToken ct)\n {\n var effectiveSession = sessionConfig.Clone();\n if (toolCatalog == null)\n return effectiveSession;\n\n var knownNames = new HashSet<string>(\n effectiveSession.ToolDefinitions\n .Select(static definition => definition.Name)\n .Where(static name => !string.IsNullOrWhiteSpace(name)),\n StringComparer.OrdinalIgnoreCase);\n\n foreach (var discoveredTool in await toolCatalog.DiscoverAsync(ct))\n {\n var toolName = discoveredTool.Name?.Trim();\n if (string.IsNullOrWhiteSpace(toolName) || !knownNames.Add(toolName))\n continue;\n\n effectiveSession.ToolDefinitions.Add(new VoiceToolDefinition\n {\n Name = toolName,\n Description = discoveredTool.Description ?? string.Empty,\n ParametersSchema = string.IsNullOrWhiteSpace(discoveredTool.ParametersSchema)\n ? \"{}\"\n : discoveredTool.ParametersSchema,\n });\n }\n\n return effectiveSession;\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n VoiceProviderConfig openAIProviderConfig,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n IsOpenAIVoiceConfigured(openAIProviderConfig))\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig))\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n private static bool IsNyxIdRealtimeBrokerEnabled(IConfiguration configuration) =>\n !string.IsNullOrWhiteSpace(configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:ServiceSlug\"]);\n\n private static NyxIdRealtimeProviderCredentialOptions BuildNyxIdRealtimeCredentialOptions(\n IConfiguration configuration)\n {\n var options = new NyxIdRealtimeProviderCredentialOptions\n {\n ServiceSlug = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:ServiceSlug\"]?.Trim() ?? string.Empty,\n };\n\n var mintPath = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:MintPath\"];\n if (!string.IsNullOrWhiteSpace(mintPath))\n options.MintPath = mintPath.Trim();\n\n var model = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:Model\"];\n if (!string.IsNullOrWhiteSpace(model))\n options.Model = model.Trim();\n\n return options;\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n var loggerFactory = sp.GetService<ILoggerFactory>();\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, loggerFactory),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, sp.GetService<ILoggerFactory>());\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor,\n ILoggerFactory? loggerFactory = null)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options, loggerFactory);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName, loggerFactory);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options, loggerFactory);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options,\n ILoggerFactory? loggerFactory = null)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName, loggerFactory);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName, loggerFactory);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new MEAILLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<MEAILLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new TornadoLLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<TornadoLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new NyxIdLLMProviderFactory();\n // Without an explicit logger the provider chain (NyxIdLLMProvider and the\n // MEAILLMProvider it delegates to) falls back to NullLogger, which silences\n // upstream LLM error translations and the no-chunks streaming fallback in\n // production. Always wire the host logger when one is available.\n var providerLogger = loggerFactory?.CreateLogger<NyxIdLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null,\n providerLogger);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = configuration[\"Aevatar:NyxId:DefaultModel\"] ?? options.OpenAIModel;\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n configured.Add(new ConfiguredProvider(name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim()));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", options.OpenAIModel, ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug;\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n}\n"}, "73f30af2f666a9253fd8f39eb39658796bc59740": {"t": 1785756631.0329385, "n": 46207, "h": "6cabf17bb8e22624", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = \"gpt-5.4\";\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n configure?.Invoke(options);\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n // No container-level IToolApprovalHandler: a yielding handler is only valid on\n // actors that implement the pending-approval continuation (RoleGAgent wires its\n // own). Surfaces without that capability fall back to MissingApprovalHandler and\n // fail closed instead of stranding a dead-letter approval (#2004).\n services.TryAddSingleton<IVoiceToolInvoker, AgentToolVoiceInvoker>();\n services.TryAddSingleton<IVoiceToolCatalog, AgentToolVoiceCatalog>();\n services.TryAddSingleton<IVoicePresenceCapabilityCommandPort, VoicePresenceCapabilityCommandPort>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIProviderConfig,\n miniCpmProviderConfig);\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n var providerSession = await provider.ConnectAsync(\n new VoiceProviderSessionKey(\n handle.SessionId,\n handle.OwnerId,\n handle.ActiveTransportLeaseId ?? string.Empty,\n 0,\n Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(handle.ExpiresAtUtc.ToUniversalTime()),\n handle.ActorId,\n handle.ModuleName),\n providerConfig,\n eventSink,\n audioSink,\n ct);\n\n var effectiveSession = await BuildEffectiveVoiceSessionConfigAsync(sessionConfig, toolCatalog, ct);\n await providerSession.UpdateSessionAsync(effectiveSession, ct);\n return providerSession;\n }\n\n private static async Task<VoiceSessionConfig> BuildEffectiveVoiceSessionConfigAsync(\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n CancellationToken ct)\n {\n var effectiveSession = sessionConfig.Clone();\n if (toolCatalog == null)\n return effectiveSession;\n\n var knownNames = new HashSet<string>(\n effectiveSession.ToolDefinitions\n .Select(static definition => definition.Name)\n .Where(static name => !string.IsNullOrWhiteSpace(name)),\n StringComparer.OrdinalIgnoreCase);\n\n foreach (var discoveredTool in await toolCatalog.DiscoverAsync(ct))\n {\n var toolName = discoveredTool.Name?.Trim();\n if (string.IsNullOrWhiteSpace(toolName) || !knownNames.Add(toolName))\n continue;\n\n effectiveSession.ToolDefinitions.Add(new VoiceToolDefinition\n {\n Name = toolName,\n Description = discoveredTool.Description ?? string.Empty,\n ParametersSchema = string.IsNullOrWhiteSpace(discoveredTool.ParametersSchema)\n ? \"{}\"\n : discoveredTool.ParametersSchema,\n });\n }\n\n return effectiveSession;\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n VoiceProviderConfig openAIProviderConfig,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n IsOpenAIVoiceConfigured(openAIProviderConfig))\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig))\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n var loggerFactory = sp.GetService<ILoggerFactory>();\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, loggerFactory),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, sp.GetService<ILoggerFactory>());\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor,\n ILoggerFactory? loggerFactory = null)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options, loggerFactory);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName, loggerFactory);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options, loggerFactory);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options,\n ILoggerFactory? loggerFactory = null)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName, loggerFactory);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName, loggerFactory);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new MEAILLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<MEAILLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new TornadoLLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<TornadoLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new NyxIdLLMProviderFactory();\n // Without an explicit logger the provider chain (NyxIdLLMProvider and the\n // MEAILLMProvider it delegates to) falls back to NullLogger, which silences\n // upstream LLM error translations and the no-chunks streaming fallback in\n // production. Always wire the host logger when one is available.\n var providerLogger = loggerFactory?.CreateLogger<NyxIdLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null,\n providerLogger);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = configuration[\"Aevatar:NyxId:DefaultModel\"] ?? options.OpenAIModel;\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n configured.Add(new ConfiguredProvider(name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim()));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", options.OpenAIModel, ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug;\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n}\n"}, "80425e868320e45de09e13c31fae544cc21e588a": {"t": 1785756632.491108, "n": 46058, "h": "66cb419315d2a85c", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = \"gpt-5.4\";\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n configure?.Invoke(options);\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n // No container-level IToolApprovalHandler: a yielding handler is only valid on\n // actors that implement the pending-approval continuation (RoleGAgent wires its\n // own). Surfaces without that capability fall back to MissingApprovalHandler and\n // fail closed instead of stranding a dead-letter approval (#2004).\n services.TryAddSingleton<IVoiceToolInvoker, AgentToolVoiceInvoker>();\n services.TryAddSingleton<IVoiceToolCatalog, AgentToolVoiceCatalog>();\n services.TryAddSingleton<IVoicePresenceCapabilityCommandPort, VoicePresenceCapabilityCommandPort>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIProviderConfig,\n miniCpmProviderConfig);\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n var providerSession = await provider.ConnectAsync(\n new VoiceProviderSessionKey(\n handle.SessionId,\n handle.OwnerId,\n handle.ActiveTransportLeaseId ?? string.Empty,\n 0,\n Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(handle.ExpiresAtUtc.ToUniversalTime()),\n handle.ActorId,\n handle.ModuleName),\n providerConfig,\n eventSink,\n audioSink,\n ct);\n\n var effectiveSession = await BuildEffectiveVoiceSessionConfigAsync(sessionConfig, toolCatalog, ct);\n await providerSession.UpdateSessionAsync(effectiveSession, ct);\n return providerSession;\n }\n\n private static async Task<VoiceSessionConfig> BuildEffectiveVoiceSessionConfigAsync(\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n CancellationToken ct)\n {\n var effectiveSession = sessionConfig.Clone();\n if (toolCatalog == null)\n return effectiveSession;\n\n var knownNames = new HashSet<string>(\n effectiveSession.ToolDefinitions\n .Select(static definition => definition.Name)\n .Where(static name => !string.IsNullOrWhiteSpace(name)),\n StringComparer.OrdinalIgnoreCase);\n\n foreach (var discoveredTool in await toolCatalog.DiscoverAsync(ct))\n {\n var toolName = discoveredTool.Name?.Trim();\n if (string.IsNullOrWhiteSpace(toolName) || !knownNames.Add(toolName))\n continue;\n\n effectiveSession.ToolDefinitions.Add(new VoiceToolDefinition\n {\n Name = toolName,\n Description = discoveredTool.Description ?? string.Empty,\n ParametersSchema = string.IsNullOrWhiteSpace(discoveredTool.ParametersSchema)\n ? \"{}\"\n : discoveredTool.ParametersSchema,\n });\n }\n\n return effectiveSession;\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n VoiceProviderConfig openAIProviderConfig,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n IsOpenAIVoiceConfigured(openAIProviderConfig))\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig))\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n var loggerFactory = sp.GetService<ILoggerFactory>();\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, loggerFactory),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, sp.GetService<ILoggerFactory>());\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor,\n ILoggerFactory? loggerFactory = null)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options, loggerFactory);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName, loggerFactory);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options, loggerFactory);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options,\n ILoggerFactory? loggerFactory = null)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName, loggerFactory);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName, loggerFactory);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new MEAILLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<MEAILLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new TornadoLLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<TornadoLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new NyxIdLLMProviderFactory();\n // Providers default to NullLogger when no logger is supplied, which silenced every\n // upstream LLM warning (and the 2026-06-12 incident probes) in production.\n var providerLogger = loggerFactory?.CreateLogger<NyxIdLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null,\n providerLogger);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = configuration[\"Aevatar:NyxId:DefaultModel\"] ?? options.OpenAIModel;\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n configured.Add(new ConfiguredProvider(name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim()));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", options.OpenAIModel, ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug;\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n}\n"}, "0ae5a00721e0cc4975c4757af2b6e401307269a0": {"t": 1785756634.0642161, "n": 45912, "h": "a6a2f2105135df24", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = \"gpt-5.4\";\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n configure?.Invoke(options);\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n // No container-level IToolApprovalHandler: a yielding handler is only valid on\n // actors that implement the pending-approval continuation (RoleGAgent wires its\n // own). Surfaces without that capability fall back to MissingApprovalHandler and\n // fail closed instead of stranding a dead-letter approval (#2004).\n services.TryAddSingleton<IVoiceToolInvoker, AgentToolVoiceInvoker>();\n services.TryAddSingleton<IVoiceToolCatalog, AgentToolVoiceCatalog>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIProviderConfig,\n miniCpmProviderConfig);\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n var providerSession = await provider.ConnectAsync(\n new VoiceProviderSessionKey(\n handle.SessionId,\n handle.OwnerId,\n handle.ActiveTransportLeaseId ?? string.Empty,\n 0,\n Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(handle.ExpiresAtUtc.ToUniversalTime()),\n handle.ActorId,\n handle.ModuleName),\n providerConfig,\n eventSink,\n audioSink,\n ct);\n\n var effectiveSession = await BuildEffectiveVoiceSessionConfigAsync(sessionConfig, toolCatalog, ct);\n await providerSession.UpdateSessionAsync(effectiveSession, ct);\n return providerSession;\n }\n\n private static async Task<VoiceSessionConfig> BuildEffectiveVoiceSessionConfigAsync(\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n CancellationToken ct)\n {\n var effectiveSession = sessionConfig.Clone();\n if (toolCatalog == null)\n return effectiveSession;\n\n var knownNames = new HashSet<string>(\n effectiveSession.ToolDefinitions\n .Select(static definition => definition.Name)\n .Where(static name => !string.IsNullOrWhiteSpace(name)),\n StringComparer.OrdinalIgnoreCase);\n\n foreach (var discoveredTool in await toolCatalog.DiscoverAsync(ct))\n {\n var toolName = discoveredTool.Name?.Trim();\n if (string.IsNullOrWhiteSpace(toolName) || !knownNames.Add(toolName))\n continue;\n\n effectiveSession.ToolDefinitions.Add(new VoiceToolDefinition\n {\n Name = toolName,\n Description = discoveredTool.Description ?? string.Empty,\n ParametersSchema = string.IsNullOrWhiteSpace(discoveredTool.ParametersSchema)\n ? \"{}\"\n : discoveredTool.ParametersSchema,\n });\n }\n\n return effectiveSession;\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n VoiceProviderConfig openAIProviderConfig,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n IsOpenAIVoiceConfigured(openAIProviderConfig))\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig))\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n var loggerFactory = sp.GetService<ILoggerFactory>();\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, loggerFactory),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, sp.GetService<ILoggerFactory>());\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor,\n ILoggerFactory? loggerFactory = null)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options, loggerFactory);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName, loggerFactory);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options, loggerFactory);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options,\n ILoggerFactory? loggerFactory = null)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName, loggerFactory);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName, loggerFactory);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new MEAILLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<MEAILLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new TornadoLLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<TornadoLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new NyxIdLLMProviderFactory();\n // Providers default to NullLogger when no logger is supplied, which silenced every\n // upstream LLM warning (and the 2026-06-12 incident probes) in production.\n var providerLogger = loggerFactory?.CreateLogger<NyxIdLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null,\n providerLogger);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = configuration[\"Aevatar:NyxId:DefaultModel\"] ?? options.OpenAIModel;\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n configured.Add(new ConfiguredProvider(name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim()));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", options.OpenAIModel, ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug;\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n}\n"}, "79cee13a6bae3f47dc843165c29cc8a86da3a477": {"t": 1785756635.5655913, "n": 45493, "h": "e2c59b52a468c90b", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = \"gpt-5.4\";\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n configure?.Invoke(options);\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n // No container-level IToolApprovalHandler: a yielding handler is only valid on\n // actors that implement the pending-approval continuation (RoleGAgent wires its\n // own). Surfaces without that capability fall back to MissingApprovalHandler and\n // fail closed instead of stranding a dead-letter approval (#2004).\n services.TryAddSingleton<IVoiceToolInvoker, AgentToolVoiceInvoker>();\n services.TryAddSingleton<IVoiceToolCatalog, AgentToolVoiceCatalog>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIProviderConfig,\n miniCpmProviderConfig);\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n var providerSession = await provider.ConnectAsync(\n new VoiceProviderSessionKey(\n handle.SessionId,\n handle.OwnerId,\n handle.ActiveTransportLeaseId ?? string.Empty,\n 0,\n Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(handle.ExpiresAtUtc.ToUniversalTime()),\n handle.ActorId,\n handle.ModuleName),\n providerConfig,\n eventSink,\n audioSink,\n ct);\n\n var effectiveSession = await BuildEffectiveVoiceSessionConfigAsync(sessionConfig, toolCatalog, ct);\n await providerSession.UpdateSessionAsync(effectiveSession, ct);\n return providerSession;\n }\n\n private static async Task<VoiceSessionConfig> BuildEffectiveVoiceSessionConfigAsync(\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n CancellationToken ct)\n {\n var effectiveSession = sessionConfig.Clone();\n if (toolCatalog == null)\n return effectiveSession;\n\n var knownNames = new HashSet<string>(\n effectiveSession.ToolDefinitions\n .Select(static definition => definition.Name)\n .Where(static name => !string.IsNullOrWhiteSpace(name)),\n StringComparer.OrdinalIgnoreCase);\n\n foreach (var discoveredTool in await toolCatalog.DiscoverAsync(ct))\n {\n var toolName = discoveredTool.Name?.Trim();\n if (string.IsNullOrWhiteSpace(toolName) || !knownNames.Add(toolName))\n continue;\n\n effectiveSession.ToolDefinitions.Add(new VoiceToolDefinition\n {\n Name = toolName,\n Description = discoveredTool.Description ?? string.Empty,\n ParametersSchema = string.IsNullOrWhiteSpace(discoveredTool.ParametersSchema)\n ? \"{}\"\n : discoveredTool.ParametersSchema,\n });\n }\n\n return effectiveSession;\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n VoiceProviderConfig openAIProviderConfig,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n IsOpenAIVoiceConfigured(openAIProviderConfig))\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig))\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n var loggerFactory = sp.GetService<ILoggerFactory>();\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, loggerFactory),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, sp.GetService<ILoggerFactory>());\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor,\n ILoggerFactory? loggerFactory = null)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName, loggerFactory);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName)\n {\n var factory = new MEAILLMProviderFactory();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName)\n {\n var factory = new TornadoLLMProviderFactory();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new NyxIdLLMProviderFactory();\n // Providers default to NullLogger when no logger is supplied, which silenced every\n // upstream LLM warning (and the 2026-06-12 incident probes) in production.\n var providerLogger = loggerFactory?.CreateLogger<NyxIdLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null,\n providerLogger);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = configuration[\"Aevatar:NyxId:DefaultModel\"] ?? options.OpenAIModel;\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n configured.Add(new ConfiguredProvider(name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim()));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", options.OpenAIModel, ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug;\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n}\n"}, "93a7a3d43048772295bc942ddb549b36cba660ec": {"t": 1785756638.017901, "n": 45642, "h": "2058b2e85d96dd2b", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = \"gpt-5.4\";\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n configure?.Invoke(options);\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n // No container-level IToolApprovalHandler: a yielding handler is only valid on\n // actors that implement the pending-approval continuation (RoleGAgent wires its\n // own). Surfaces without that capability fall back to MissingApprovalHandler and\n // fail closed instead of stranding a dead-letter approval (#2004).\n services.TryAddSingleton<IVoiceToolInvoker, AgentToolVoiceInvoker>();\n services.TryAddSingleton<IVoiceToolCatalog, AgentToolVoiceCatalog>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIProviderConfig,\n miniCpmProviderConfig);\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n var providerSession = await provider.ConnectAsync(\n new VoiceProviderSessionKey(\n handle.SessionId,\n handle.OwnerId,\n handle.ActiveTransportLeaseId ?? string.Empty,\n 0,\n Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(handle.ExpiresAtUtc.ToUniversalTime()),\n handle.ActorId,\n handle.ModuleName),\n providerConfig,\n eventSink,\n audioSink,\n ct);\n\n var effectiveSession = await BuildEffectiveVoiceSessionConfigAsync(sessionConfig, toolCatalog, ct);\n await providerSession.UpdateSessionAsync(effectiveSession, ct);\n return providerSession;\n }\n\n private static async Task<VoiceSessionConfig> BuildEffectiveVoiceSessionConfigAsync(\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n CancellationToken ct)\n {\n var effectiveSession = sessionConfig.Clone();\n if (toolCatalog == null)\n return effectiveSession;\n\n var knownNames = new HashSet<string>(\n effectiveSession.ToolDefinitions\n .Select(static definition => definition.Name)\n .Where(static name => !string.IsNullOrWhiteSpace(name)),\n StringComparer.OrdinalIgnoreCase);\n\n foreach (var discoveredTool in await toolCatalog.DiscoverAsync(ct))\n {\n var toolName = discoveredTool.Name?.Trim();\n if (string.IsNullOrWhiteSpace(toolName) || !knownNames.Add(toolName))\n continue;\n\n effectiveSession.ToolDefinitions.Add(new VoiceToolDefinition\n {\n Name = toolName,\n Description = discoveredTool.Description ?? string.Empty,\n ParametersSchema = string.IsNullOrWhiteSpace(discoveredTool.ParametersSchema)\n ? \"{}\"\n : discoveredTool.ParametersSchema,\n });\n }\n\n return effectiveSession;\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n VoiceProviderConfig openAIProviderConfig,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n IsOpenAIVoiceConfigured(openAIProviderConfig))\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig))\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n var loggerFactory = sp.GetService<ILoggerFactory>();\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, loggerFactory),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, sp.GetService<ILoggerFactory>());\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor,\n ILoggerFactory? loggerFactory = null)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName, loggerFactory);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName)\n {\n var factory = new MEAILLMProviderFactory();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName)\n {\n var factory = new TornadoLLMProviderFactory();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new NyxIdLLMProviderFactory();\n // Without an explicit logger the provider chain (NyxIdLLMProvider and the\n // MEAILLMProvider it delegates to) falls back to NullLogger, which silences\n // upstream LLM error translations and the no-chunks streaming fallback in\n // production. Always wire the host logger when one is available.\n var providerLogger = loggerFactory?.CreateLogger<NyxIdLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null,\n providerLogger);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = configuration[\"Aevatar:NyxId:DefaultModel\"] ?? options.OpenAIModel;\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n configured.Add(new ConfiguredProvider(name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim()));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", options.OpenAIModel, ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug;\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n}\n"}, "728bc4c40063f891417d32cf98ab19c22066e8e0": {"t": 1785756639.7346606, "n": 44983, "h": "294fb122fc998108", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = \"gpt-5.4\";\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n configure?.Invoke(options);\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n // No container-level IToolApprovalHandler: a yielding handler is only valid on\n // actors that implement the pending-approval continuation (RoleGAgent wires its\n // own). Surfaces without that capability fall back to MissingApprovalHandler and\n // fail closed instead of stranding a dead-letter approval (#2004).\n services.TryAddSingleton<IVoiceToolInvoker, AgentToolVoiceInvoker>();\n services.TryAddSingleton<IVoiceToolCatalog, AgentToolVoiceCatalog>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIProviderConfig,\n miniCpmProviderConfig);\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n var providerSession = await provider.ConnectAsync(\n new VoiceProviderSessionKey(\n handle.SessionId,\n handle.OwnerId,\n handle.ActiveTransportLeaseId ?? string.Empty,\n 0,\n Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(handle.ExpiresAtUtc.ToUniversalTime()),\n handle.ActorId,\n handle.ModuleName),\n providerConfig,\n eventSink,\n audioSink,\n ct);\n\n var effectiveSession = await BuildEffectiveVoiceSessionConfigAsync(sessionConfig, toolCatalog, ct);\n await providerSession.UpdateSessionAsync(effectiveSession, ct);\n return providerSession;\n }\n\n private static async Task<VoiceSessionConfig> BuildEffectiveVoiceSessionConfigAsync(\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n CancellationToken ct)\n {\n var effectiveSession = sessionConfig.Clone();\n if (toolCatalog == null)\n return effectiveSession;\n\n var knownNames = new HashSet<string>(\n effectiveSession.ToolDefinitions\n .Select(static definition => definition.Name)\n .Where(static name => !string.IsNullOrWhiteSpace(name)),\n StringComparer.OrdinalIgnoreCase);\n\n foreach (var discoveredTool in await toolCatalog.DiscoverAsync(ct))\n {\n var toolName = discoveredTool.Name?.Trim();\n if (string.IsNullOrWhiteSpace(toolName) || !knownNames.Add(toolName))\n continue;\n\n effectiveSession.ToolDefinitions.Add(new VoiceToolDefinition\n {\n Name = toolName,\n Description = discoveredTool.Description ?? string.Empty,\n ParametersSchema = string.IsNullOrWhiteSpace(discoveredTool.ParametersSchema)\n ? \"{}\"\n : discoveredTool.ParametersSchema,\n });\n }\n\n return effectiveSession;\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n VoiceProviderConfig openAIProviderConfig,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n IsOpenAIVoiceConfigured(openAIProviderConfig))\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig))\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(configuration, options, secretsStoreAccessor);\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName)\n {\n var factory = new MEAILLMProviderFactory();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName)\n {\n var factory = new TornadoLLMProviderFactory();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName)\n {\n var factory = new NyxIdLLMProviderFactory();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = configuration[\"Aevatar:NyxId:DefaultModel\"] ?? options.OpenAIModel;\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n configured.Add(new ConfiguredProvider(name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim()));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", options.OpenAIModel, ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug;\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n}\n"}, "5a378209e1d93b9a0f55b84db1ac72ca5ba0cd4b": {"t": 1785756641.2779884, "n": 44831, "h": "a63bd5a9c2e08d92", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = \"gpt-5.4\";\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n configure?.Invoke(options);\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n services.TryAddSingleton<IToolApprovalHandler, YieldApprovalHandler>();\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IToolCallMiddleware, ToolApprovalMiddleware>());\n services.TryAddSingleton<IVoiceToolInvoker, AgentToolVoiceInvoker>();\n services.TryAddSingleton<IVoiceToolCatalog, AgentToolVoiceCatalog>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIProviderConfig,\n miniCpmProviderConfig);\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n var providerSession = await provider.ConnectAsync(\n new VoiceProviderSessionKey(\n handle.SessionId,\n handle.OwnerId,\n handle.ActiveTransportLeaseId ?? string.Empty,\n 0,\n Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(handle.ExpiresAtUtc.ToUniversalTime()),\n handle.ActorId,\n handle.ModuleName),\n providerConfig,\n eventSink,\n audioSink,\n ct);\n\n var effectiveSession = await BuildEffectiveVoiceSessionConfigAsync(sessionConfig, toolCatalog, ct);\n await providerSession.UpdateSessionAsync(effectiveSession, ct);\n return providerSession;\n }\n\n private static async Task<VoiceSessionConfig> BuildEffectiveVoiceSessionConfigAsync(\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n CancellationToken ct)\n {\n var effectiveSession = sessionConfig.Clone();\n if (toolCatalog == null)\n return effectiveSession;\n\n var knownNames = new HashSet<string>(\n effectiveSession.ToolDefinitions\n .Select(static definition => definition.Name)\n .Where(static name => !string.IsNullOrWhiteSpace(name)),\n StringComparer.OrdinalIgnoreCase);\n\n foreach (var discoveredTool in await toolCatalog.DiscoverAsync(ct))\n {\n var toolName = discoveredTool.Name?.Trim();\n if (string.IsNullOrWhiteSpace(toolName) || !knownNames.Add(toolName))\n continue;\n\n effectiveSession.ToolDefinitions.Add(new VoiceToolDefinition\n {\n Name = toolName,\n Description = discoveredTool.Description ?? string.Empty,\n ParametersSchema = string.IsNullOrWhiteSpace(discoveredTool.ParametersSchema)\n ? \"{}\"\n : discoveredTool.ParametersSchema,\n });\n }\n\n return effectiveSession;\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n VoiceProviderConfig openAIProviderConfig,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n IsOpenAIVoiceConfigured(openAIProviderConfig))\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig))\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(configuration, options, secretsStoreAccessor);\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName)\n {\n var factory = new MEAILLMProviderFactory();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName)\n {\n var factory = new TornadoLLMProviderFactory();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName)\n {\n var factory = new NyxIdLLMProviderFactory();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = configuration[\"Aevatar:NyxId:DefaultModel\"] ?? options.OpenAIModel;\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n configured.Add(new ConfiguredProvider(name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim()));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", options.OpenAIModel, ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug;\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n}\n"}, "5872a7408330c12d6d617a7d6338aa32625da166": {"t": 1785756642.6454067, "n": 44647, "h": "f2e36244d778257f", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = \"gpt-5.4\";\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n configure?.Invoke(options);\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n services.TryAddSingleton<IToolApprovalHandler, YieldApprovalHandler>();\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IToolCallMiddleware, ToolApprovalMiddleware>());\n services.TryAddSingleton<IVoiceToolInvoker, AgentToolVoiceInvoker>();\n services.TryAddSingleton<IVoiceToolCatalog, AgentToolVoiceCatalog>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIProviderConfig,\n miniCpmProviderConfig);\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n var providerSession = await provider.ConnectAsync(\n new VoiceProviderSessionKey(\n handle.SessionId,\n handle.OwnerId,\n handle.ActiveTransportLeaseId ?? string.Empty,\n 0,\n Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(handle.ExpiresAtUtc.ToUniversalTime()),\n handle.ActorId,\n handle.ModuleName),\n providerConfig,\n eventSink,\n audioSink,\n ct);\n\n var effectiveSession = await BuildEffectiveVoiceSessionConfigAsync(sessionConfig, toolCatalog, ct);\n await providerSession.UpdateSessionAsync(effectiveSession, ct);\n return providerSession;\n }\n\n private static async Task<VoiceSessionConfig> BuildEffectiveVoiceSessionConfigAsync(\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n CancellationToken ct)\n {\n var effectiveSession = sessionConfig.Clone();\n if (toolCatalog == null)\n return effectiveSession;\n\n var knownNames = new HashSet<string>(\n effectiveSession.ToolDefinitions\n .Select(static definition => definition.Name)\n .Where(static name => !string.IsNullOrWhiteSpace(name)),\n StringComparer.OrdinalIgnoreCase);\n\n foreach (var discoveredTool in await toolCatalog.DiscoverAsync(ct))\n {\n var toolName = discoveredTool.Name?.Trim();\n if (string.IsNullOrWhiteSpace(toolName) || !knownNames.Add(toolName))\n continue;\n\n effectiveSession.ToolDefinitions.Add(new VoiceToolDefinition\n {\n Name = toolName,\n Description = discoveredTool.Description ?? string.Empty,\n ParametersSchema = string.IsNullOrWhiteSpace(discoveredTool.ParametersSchema)\n ? \"{}\"\n : discoveredTool.ParametersSchema,\n });\n }\n\n return effectiveSession;\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n VoiceProviderConfig openAIProviderConfig,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n IsOpenAIVoiceConfigured(openAIProviderConfig))\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (IsOpenAIVoiceConfigured(openAIProviderConfig))\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(configuration, options, secretsStoreAccessor);\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName)\n {\n var factory = new MEAILLMProviderFactory();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName)\n {\n var factory = new TornadoLLMProviderFactory();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName)\n {\n var factory = new NyxIdLLMProviderFactory();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = configuration[\"Aevatar:NyxId:DefaultModel\"] ?? options.OpenAIModel;\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n configured.Add(new ConfiguredProvider(name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim()));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", options.OpenAIModel, ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug;\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n}\n"}, "bbd906eb503a126c1a4b6a9ff67952cc819ccdd4": {"t": 1786067471.8574626, "n": 54982, "h": "9001865436ea31ea", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Auditing;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.NyxId;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.Credentials;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\nusing Microsoft.Extensions.Options;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n /// <summary>\n /// Enables the host-bound system skill overlay scaffold. Mainnet-style hosts bind this from\n /// <c>Aevatar:SystemSkills:Enabled</c>.\n /// </summary>\n public bool EnableSystemSkillOverlay { get; set; }\n /// <summary>\n /// Non-secret name of the public, org-owned Ornn skillset that sources the overlay (issue #2498).\n /// Bound from <c>Aevatar:SystemSkills:SetName</c>. No organization service token is needed: set\n /// ownership is the trust anchor and the set is read publicly through the existing ornn-api proxy.\n /// </summary>\n public string? SystemSkillOverlaySetName { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:RefreshTtl</c>.\n /// </summary>\n public TimeSpan SystemSkillOverlayRefreshTtl { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:MaxSkills</c>.\n /// </summary>\n public int SystemSkillOverlayMaxSkills { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:MaxBytes</c>.\n /// </summary>\n public int SystemSkillOverlayMaxBytes { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = LlmDefaults.Model;\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n configure?.Invoke(options);\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n services.AddToolExecutionAuditObserver();\n // No container-level IToolApprovalHandler: a yielding handler is only valid on\n // actors that implement the pending-approval continuation (RoleGAgent wires its\n // own). Surfaces without that capability fall back to MissingApprovalHandler and\n // fail closed instead of stranding a dead-letter approval (#2004).\n services.TryAddSingleton<IVoiceToolInvoker>(sp => new AgentToolVoiceInvoker(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceInvoker>>()));\n services.TryAddSingleton<IVoiceToolCatalog>(sp => new AgentToolVoiceCatalog(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceCatalog>>()));\n services.TryAddSingleton<IVoicePresenceCapabilityCommandPort, VoicePresenceCapabilityCommandPort>();\n // Zero-config /ws/voice: auto-provision a never-enabled default voice agent on first connect by\n // committing the same enable voice-presence/enable issues. The attach path (ActorOwnedVoiceRealtimeSession)\n // resolves this optionally and only invokes it when the capability is still null after the\n // re-projection self-heal — i.e. a genuinely-unprovisioned default agent.\n services.TryAddSingleton<IVoicePresenceCapabilityAutoEnablePort, VoicePresenceCapabilityAutoEnablePort>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableSystemSkillOverlay)\n RegisterSystemSkillOverlay(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): when the OpenAI realtime credential is\n // brokered through NyxID, register the resolver so the provider mints a per-session ephemeral\n // instead of reading a static OPENAI_API_KEY. Requires NyxID tools (INyxIdApiClientFactory) wired.\n var nyxIdRealtimeCredentialOptions = BuildNyxIdRealtimeCredentialOptions(configuration);\n if (nyxIdRealtimeCredentialOptions.Enabled)\n {\n services.TryAddSingleton(nyxIdRealtimeCredentialOptions);\n services.TryAddSingleton<IRealtimeProviderCredentialResolver>(sp => new NyxIdRealtimeProviderCredentialResolver(\n sp.GetRequiredService<INyxIdApiClientFactory>(),\n sp.GetRequiredService<NyxIdRealtimeProviderCredentialOptions>(),\n sp.GetRequiredService<ILogger<NyxIdRealtimeProviderCredentialResolver>>(),\n ResolveVoiceCredentialProviders(sp)));\n }\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceLeaseObservationPort, VoicePresenceLeaseObservationPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton(sp => new VoiceVolatileToolCredentialPort(sp.GetService<TimeProvider>()));\n services.TryAddSingleton<IVoiceVolatileToolCredentialPort>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.TryAddSingleton<IVoiceToolCredentialIssuer>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.AddOptions<VoiceWebSocketAttachOptions>();\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IValidateOptions<VoiceWebSocketAttachOptions>, VoiceWebSocketAttachOptionsValidator>();\n services.TryAddSingleton<VoiceWebSocketAttachExecutor>();\n services.AddVoiceWebRtcTransport();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IReadOnlyList<ICredentialProvider> ResolveVoiceCredentialProviders(IServiceProvider services)\n {\n var providers = new List<ICredentialProvider>();\n var voiceCredentialProvider = services.GetService<IVoiceToolCredentialIssuer>() as ICredentialProvider;\n if (voiceCredentialProvider is not null)\n providers.Add(voiceCredentialProvider);\n\n foreach (var provider in services.GetServices<ICredentialProvider>())\n {\n if (!ReferenceEquals(provider, voiceCredentialProvider))\n providers.Add(provider);\n }\n\n return providers;\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var nyxIdRealtimeBrokerEnabled = IsNyxIdRealtimeBrokerEnabled(configuration);\n // The openai module registers below when EITHER a raw ApiKey OR the NyxID ephemeral broker is\n // available (ADR-0033 forbids long-lived keys in production, so broker-only is the normal shape).\n // Default-provider resolution must use the same availability, or broker-only deployments register\n // the module solely as \"voice_presence_openai\" while the auto-enable/mount/read default path uses\n // \"voice_presence\" — the module factory then never mounts a module for the enabled name, the\n // session-lease signal is silently dropped, and /ws/voice loops on 503 voice_capability_not_ready.\n var openAIVoiceAvailable = IsOpenAIVoiceConfigured(openAIProviderConfig) || nyxIdRealtimeBrokerEnabled;\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIVoiceAvailable,\n miniCpmProviderConfig);\n\n if (openAIVoiceAvailable)\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n return await ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger: null,\n ct);\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionWithLoggingAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n ILogger<ReadinessGatedRealtimeVoiceProviderSession>? logger,\n CancellationToken ct)\n {\n return await VoiceRealtimeSessionReadinessBootstrapper.ConnectAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger,\n ct);\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n bool openAIVoiceAvailable,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n openAIVoiceAvailable)\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (openAIVoiceAvailable)\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): the broker slug defaults to\n // NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug so voice stays enabled even when the\n // deployment config/secret is wiped on redeploy — config only overrides the slug, never disables it.\n private static string ResolveNyxIdRealtimeServiceSlug(IConfiguration configuration)\n {\n var configured = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:ServiceSlug\"]?.Trim();\n return string.IsNullOrWhiteSpace(configured)\n ? NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug\n : configured;\n }\n\n private static bool IsNyxIdRealtimeBrokerEnabled(IConfiguration configuration) =>\n !string.IsNullOrWhiteSpace(ResolveNyxIdRealtimeServiceSlug(configuration));\n\n private static NyxIdRealtimeProviderCredentialOptions BuildNyxIdRealtimeCredentialOptions(\n IConfiguration configuration)\n {\n var options = new NyxIdRealtimeProviderCredentialOptions\n {\n ServiceSlug = ResolveNyxIdRealtimeServiceSlug(configuration),\n };\n\n var mintPath = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:MintPath\"];\n if (!string.IsNullOrWhiteSpace(mintPath))\n options.MintPath = mintPath.Trim();\n\n var model = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:Model\"];\n if (!string.IsNullOrWhiteSpace(model))\n options.Model = model.Trim();\n\n return options;\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n DrainTimeout = options.DrainTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n var loggerFactory = sp.GetService<ILoggerFactory>();\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, loggerFactory),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, sp.GetService<ILoggerFactory>());\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor,\n ILoggerFactory? loggerFactory = null)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options, loggerFactory);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName, loggerFactory);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options, loggerFactory);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options,\n ILoggerFactory? loggerFactory = null)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName, loggerFactory);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName, loggerFactory);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new MEAILLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<MEAILLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new TornadoLLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<TornadoLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new NyxIdLLMProviderFactory();\n // Without an explicit logger the provider chain (NyxIdLLMProvider and the\n // MEAILLMProvider it delegates to) falls back to NullLogger, which silences\n // upstream LLM error translations and the no-chunks streaming fallback in\n // production. Always wire the host logger when one is available.\n var providerLogger = loggerFactory?.CreateLogger<NyxIdLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null,\n provider.DefaultRoutePreference,\n providerLogger);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = ResolveNyxIdDefaultModel(configuration, options);\n var defaultRoute = ResolveNyxIdDefaultRoute(configuration);\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty, defaultRoute);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n var defaultRoute = IsNyxIdProviderType(semantic.ProviderType)\n ? ResolveNyxIdDefaultRoute(configuration)\n : null;\n configured.Add(new ConfiguredProvider(\n name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim(), defaultRoute));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", ResolveNyxIdDefaultModel(configuration, options), ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n // The default route/model literals live once in LlmDefaults (Aevatar.AI.Abstractions) so the\n // NyxID server-default path and the OpenAI-compatible Responses ingress default share one\n // source and cannot drift. Every nyxid registration path resolves its default through these\n // helpers, which apply per-deployment config overrides on top.\n private static string ResolveNyxIdDefaultRoute(IConfiguration configuration) =>\n configuration[\"Aevatar:NyxId:DefaultRoute\"] is { } route && !string.IsNullOrWhiteSpace(route)\n ? route.Trim()\n : LlmDefaults.NyxIdRoute;\n\n private static string ResolveNyxIdDefaultModel(IConfiguration configuration, AevatarAIFeatureOptions options) =>\n configuration[\"Aevatar:NyxId:DefaultModel\"] is { Length: > 0 } model\n ? model\n : options.OpenAIModel;\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey,\n string? DefaultRoutePreference = null);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug.Trim();\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterSystemSkillOverlay(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n if (!options.EnableSystemSkillOverlay || string.IsNullOrWhiteSpace(options.SystemSkillOverlaySetName))\n return;\n\n // Ensure the Ornn skill client (with the configured slug) is registered even when a host enables\n // the overlay without the Ornn skill tools; the overlay reads the set through this client.\n services.AddOrnnSkillClient(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug.Trim();\n });\n\n services.AddSystemSkillOverlay(o =>\n {\n o.Enabled = options.EnableSystemSkillOverlay;\n o.SetName = options.SystemSkillOverlaySetName ?? string.Empty;\n o.RefreshTtl = options.SystemSkillOverlayRefreshTtl;\n o.MaxSkills = options.SystemSkillOverlayMaxSkills;\n o.MaxBytes = options.SystemSkillOverlayMaxBytes;\n });\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n}\n"}, "6da4bbe8df605114bb63ccfe6f23383c99a50025": {"t": 1786068324.2421422, "n": 57278, "h": "e35544bfa8885a66", "c": "using Aevatar.AI.Abstractions.Middleware;\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Aevatar.AI.Core;\nusing Aevatar.AI.Abstractions.ToolProviders;\nusing Aevatar.AI.Abstractions.Voice;\nusing Aevatar.AI.Core.Middleware;\nusing Aevatar.AI.Core.Auditing;\nusing Aevatar.AI.Core.DependencyInjection;\nusing Aevatar.AI.Core.Voice;\nusing Aevatar.AI.Core.LLMProviders;\nusing Aevatar.AI.LLMProviders.MEAI;\nusing Aevatar.AI.LLMProviders.NyxId;\nusing Aevatar.AI.LLMProviders.Tornado;\nusing Aevatar.AI.ToolProviders.MCP;\nusing Aevatar.AI.ToolProviders.NyxId;\nusing Aevatar.AI.ToolProviders.Ornn;\nusing Aevatar.AI.ToolProviders.Scripting;\nusing Aevatar.AI.ToolProviders.ServiceInvoke;\nusing Aevatar.AI.ToolProviders.Skills;\nusing Aevatar.AI.ToolProviders.Web;\nusing Aevatar.AI.ToolProviders.Binding;\nusing Aevatar.AI.ToolProviders.Ornn.Publishing;\nusing Aevatar.AI.ToolProviders.Workflow;\nusing Aevatar.AI.ToolProviders.Workflow.Ports;\nusing Aevatar.Bootstrap.Extensions.AI.OrnnPublishing;\nusing Aevatar.AI.Infrastructure.Local.Adapters;\nusing Aevatar.Bootstrap.Connectors;\nusing Aevatar.Bootstrap.Extensions.AI.Connectors;\nusing Aevatar.Workflow.Application.Abstractions.Workflows;\nusing Aevatar.Workflow.Core.Modules;\nusing Aevatar.Workflow.Core.Primitives;\nusing Aevatar.Workflow.Integration.AI;\nusing Aevatar.Configuration;\nusing Aevatar.CQRS.Core.Abstractions.Streaming;\nusing Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection;\nusing Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection;\nusing Aevatar.CQRS.Projection.Stores.Abstractions;\nusing Aevatar.Foundation.Abstractions;\nusing Aevatar.Foundation.Abstractions.Credentials;\nusing Aevatar.Foundation.Abstractions.EventModules;\nusing Aevatar.Foundation.Core.TypeSystem;\nusing Aevatar.Foundation.VoicePresence;\nusing Aevatar.Foundation.VoicePresence.Abstractions;\nusing Aevatar.Foundation.VoicePresence.Abstractions.Sessions;\nusing Aevatar.Foundation.VoicePresence.Hosting;\nusing Aevatar.Foundation.VoicePresence.MiniCPM;\nusing Aevatar.Foundation.VoicePresence.Modules;\nusing Aevatar.Foundation.VoicePresence.OpenAI;\nusing Aevatar.Foundation.VoicePresence.Projection;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.DependencyInjection.Extensions;\nusing Microsoft.Extensions.Logging;\nusing Microsoft.Extensions.Options;\n\nnamespace Aevatar.Bootstrap.Extensions.AI;\n\npublic sealed class AevatarAIFeatureOptions\n{\n public int MaxTurnDeadlineMs { get; set; } = RoleChatExecutionOptions.DefaultMaxTurnDeadlineMs;\n public int PostCommitConfigRefreshTimeoutMs { get; set; } =\n RoleChatExecutionOptions.DefaultPostCommitConfigRefreshTimeoutMs;\n public int PostTurnProcessingTimeoutMs { get; set; } =\n RoleChatExecutionOptions.DefaultPostTurnProcessingTimeoutMs;\n public bool EnableMEAIProviders { get; set; } = true;\n public bool EnableMEAIToTornadoFailover { get; set; } = true;\n public bool EnableReloadableProviderFactory { get; set; }\n public bool FailoverFallbackToDefaultProviderWhenNamedProviderMissing { get; set; } = true;\n public bool FailoverPreferFallbackDefaultProvider { get; set; } = true;\n public bool FailoverPreferDeepSeekAsFallbackDefault { get; set; } = true;\n public bool EnableMCPTools { get; set; }\n public bool EnableSkills { get; set; }\n public bool EnableOrnnSkills { get; set; }\n /// <summary>\n /// Optional override for the NyxID-bound slug pointing at the Ornn skill API. Defaults to\n /// chrono-ornn's canonical <c>\"ornn\"</c> when null/empty. Override only if the deployment's\n /// NyxID catalog uses a different slug (e.g. organisations that re-registered the service).\n /// </summary>\n public string? OrnnNyxIdSlug { get; set; }\n /// <summary>\n /// Enables the host-bound system skill overlay scaffold. Mainnet-style hosts bind this from\n /// <c>Aevatar:SystemSkills:Enabled</c>.\n /// </summary>\n public bool EnableSystemSkillOverlay { get; set; }\n /// <summary>\n /// Non-secret name of the public, org-owned Ornn skillset that sources the overlay (issue #2498).\n /// Bound from <c>Aevatar:SystemSkills:SetName</c>. No organization service token is needed: set\n /// ownership is the trust anchor and the set is read publicly through the existing ornn-api proxy.\n /// </summary>\n public string? SystemSkillOverlaySetName { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:RefreshTtl</c>.\n /// </summary>\n public TimeSpan SystemSkillOverlayRefreshTtl { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:MaxSkills</c>.\n /// </summary>\n public int SystemSkillOverlayMaxSkills { get; set; }\n /// <summary>\n /// Bound from <c>Aevatar:SystemSkills:MaxBytes</c>.\n /// </summary>\n public int SystemSkillOverlayMaxBytes { get; set; }\n public IAevatarSecretsStore? SecretsStore { get; set; }\n public string? ApiKey { get; set; }\n public NyxIdLlmEndpointSpec? NyxIdLlmEndpoint { get; set; }\n public string DefaultProvider { get; set; } = \"openai\";\n public string OpenAIModel { get; set; } = LlmDefaults.Model;\n public string DeepSeekModel { get; set; } = \"deepseek-chat\";\n public List<string> SkillDirectories { get; } = [];\n public bool EnableServiceInvokeTools { get; set; }\n public string? ServiceInvokeTenantId { get; set; }\n public string? ServiceInvokeAppId { get; set; }\n public string? ServiceInvokeNamespace { get; set; }\n public bool BypassServiceInvokeApproval { get; set; }\n public bool EnableWebTools { get; set; }\n public string? WebSearchNyxIdSlug { get; set; }\n public string? WebSearchApiBaseUrl { get; set; }\n public bool EnableWorkflowTools { get; set; }\n public bool EnableScriptingTools { get; set; }\n public bool EnableBindingTools { get; set; }\n public VoicePresenceFeatureOptions VoicePresence { get; } = new();\n}\n\npublic static class ServiceCollectionExtensions\n{\n public static IServiceCollection AddAevatarAIFeatures(\n this IServiceCollection services,\n IConfiguration configuration,\n Action<AevatarAIFeatureOptions>? configure = null)\n {\n ArgumentNullException.ThrowIfNull(services);\n ArgumentNullException.ThrowIfNull(configuration);\n\n var options = new AevatarAIFeatureOptions();\n options.MaxTurnDeadlineMs = ReadPositiveInteger(\n configuration,\n \"Aevatar:AI:MaxTurnDeadlineMs\",\n options.MaxTurnDeadlineMs);\n options.PostCommitConfigRefreshTimeoutMs = ReadPositiveInteger(\n configuration,\n \"Aevatar:AI:PostCommitConfigRefreshTimeoutMs\",\n options.PostCommitConfigRefreshTimeoutMs);\n options.PostTurnProcessingTimeoutMs = ReadPositiveInteger(\n configuration,\n \"Aevatar:AI:PostTurnProcessingTimeoutMs\",\n options.PostTurnProcessingTimeoutMs);\n configure?.Invoke(options);\n services.TryAddSingleton(new RoleChatExecutionOptions(\n options.MaxTurnDeadlineMs,\n options.PostCommitConfigRefreshTimeoutMs,\n options.PostTurnProcessingTimeoutMs));\n\n services.AddAevatarAgentKindRegistry(builder => builder\n .ScanAssemblies(typeof(RoleGAgent).Assembly)\n .Register<WorkflowRoleGAgent>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IWorkflowToolSource, AgentWorkflowToolSourceAdapter>());\n services.AddAgentToolExecution();\n services.TryAddSingleton<IVoiceToolInvoker>(sp => new AgentToolVoiceInvoker(\n sp.GetServices<IAgentToolSource>(),\n sp.GetRequiredService<IAgentToolExecutionPort>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceInvoker>>()));\n services.TryAddSingleton<IVoiceToolCatalog>(sp => new AgentToolVoiceCatalog(\n sp.GetServices<IAgentToolSource>(),\n ResolveVoiceCredentialProviders(sp),\n sp.GetService<ILogger<AgentToolVoiceCatalog>>()));\n services.TryAddSingleton<IVoicePresenceCapabilityCommandPort, VoicePresenceCapabilityCommandPort>();\n // Zero-config /ws/voice: auto-provision a never-enabled default voice agent on first connect by\n // committing the same enable voice-presence/enable issues. The attach path (ActorOwnedVoiceRealtimeSession)\n // resolves this optionally and only invokes it when the capability is still null after the\n // re-projection self-heal — i.e. a genuinely-unprovisioned default agent.\n services.TryAddSingleton<IVoicePresenceCapabilityAutoEnablePort, VoicePresenceCapabilityAutoEnablePort>();\n services.TryAddSingleton<IWorkflowYamlValidator, WorkflowYamlValidatorImpl>();\n services.TryAddSingleton<IWorkflowDefinitionCommandAdapter>(sp =>\n new LocalWorkflowDefinitionCommandAdapter(\n sp.GetRequiredService<IWorkflowYamlValidator>(),\n workflowsDirectory: null,\n sp.GetService<ILogger<LocalWorkflowDefinitionCommandAdapter>>()));\n RegisterMeaiProviders(services, configuration, options);\n\n if (options.EnableMCPTools)\n {\n RegisterMCPTools(services);\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IConnectorBuilder, MCPConnectorBuilder>());\n }\n\n if (options.EnableSkills)\n RegisterSkills(services, options);\n\n if (options.EnableOrnnSkills)\n RegisterOrnnSkills(services, options);\n\n if (options.EnableSystemSkillOverlay)\n RegisterSystemSkillOverlay(services, options);\n\n if (options.EnableServiceInvokeTools)\n RegisterServiceInvokeTools(services, options);\n\n if (options.EnableWebTools)\n RegisterWebTools(services, options);\n\n if (options.EnableWorkflowTools)\n RegisterWorkflowTools(services);\n\n if (options.EnableScriptingTools)\n RegisterScriptingTools(services);\n\n if (options.EnableBindingTools)\n RegisterBindingTools(services);\n\n RegisterVoicePresenceModules(services, configuration, options);\n\n return services;\n }\n\n private static int ReadPositiveInteger(\n IConfiguration configuration,\n string key,\n int defaultValue)\n {\n var configuredValue = configuration[key];\n if (string.IsNullOrWhiteSpace(configuredValue))\n return defaultValue;\n\n if (!int.TryParse(configuredValue, out var value))\n throw new InvalidOperationException($\"{key} must be a positive integer.\");\n\n return value;\n }\n\n private static void RegisterVoicePresenceModules(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var voiceOptions = options.VoicePresence;\n if (!voiceOptions.EnableModuleFactory)\n return;\n\n var registrations = BuildVoicePresenceModuleRegistrations(configuration, options);\n if (registrations.Count == 0)\n return;\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): when the OpenAI realtime credential is\n // brokered through NyxID, register the resolver so the provider mints a per-session ephemeral\n // instead of reading a static OPENAI_API_KEY. Requires NyxID tools (INyxIdApiClientFactory) wired.\n var nyxIdRealtimeCredentialOptions = BuildNyxIdRealtimeCredentialOptions(configuration);\n if (nyxIdRealtimeCredentialOptions.Enabled)\n {\n services.TryAddSingleton(nyxIdRealtimeCredentialOptions);\n services.TryAddSingleton<IRealtimeProviderCredentialResolver>(sp => new NyxIdRealtimeProviderCredentialResolver(\n sp.GetRequiredService<INyxIdApiClientFactory>(),\n sp.GetRequiredService<NyxIdRealtimeProviderCredentialOptions>(),\n sp.GetRequiredService<ILogger<NyxIdRealtimeProviderCredentialResolver>>(),\n ResolveVoiceCredentialProviders(sp)));\n }\n\n services.TryAddSingleton<IVoicePresenceCapabilityQueryPort, VoicePresenceCapabilityQueryPort>();\n services.TryAddSingleton<IVoicePresenceLeaseObservationPort, VoicePresenceLeaseObservationPort>();\n services.TryAddSingleton<IVoicePresenceSessionLeasePort, VoicePresenceSessionLeasePort>();\n services.TryAddSingleton<IVoicePresenceTransportAttachmentPort, VoicePresenceTransportAttachmentPort>();\n services.TryAddSingleton(sp => new VoiceVolatileToolCredentialPort(sp.GetService<TimeProvider>()));\n services.TryAddSingleton<IVoiceVolatileToolCredentialPort>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.TryAddSingleton<IVoiceToolCredentialIssuer>(sp => sp.GetRequiredService<VoiceVolatileToolCredentialPort>());\n services.AddOptions<VoiceWebSocketAttachOptions>();\n services.TryAddSingleton<IVoiceVolatileMediaStreamPort, VoiceVolatileMediaStreamPort>();\n services.TryAddSingleton<IValidateOptions<VoiceWebSocketAttachOptions>, VoiceWebSocketAttachOptionsValidator>();\n services.TryAddSingleton<VoiceWebSocketAttachExecutor>();\n services.AddVoiceWebRtcTransport();\n services.TryAddSingleton<IRealtimeSession<VoiceRealtimeSessionRequest, VoiceRealtimeSessionAccepted, VoiceRealtimeSessionStartError, VoiceRealtimeFrame, VoiceRealtimeSessionCompletion>, ActorOwnedVoiceRealtimeSession>();\n services.AddVoicePresenceCapabilityProjection();\n services.AddVoicePresenceCapabilityProjectionStore(configuration);\n services.TryAddEnumerable(\n ServiceDescriptor.Singleton<IEventModuleFactory<IEventHandlerContext>, VoicePresenceModuleFactory>());\n foreach (var registration in registrations)\n services.AddSingleton(registration);\n }\n\n private static IReadOnlyList<ICredentialProvider> ResolveVoiceCredentialProviders(IServiceProvider services)\n {\n var providers = new List<ICredentialProvider>();\n var voiceCredentialProvider = services.GetService<IVoiceToolCredentialIssuer>() as ICredentialProvider;\n if (voiceCredentialProvider is not null)\n providers.Add(voiceCredentialProvider);\n\n foreach (var provider in services.GetServices<ICredentialProvider>())\n {\n if (!ReferenceEquals(provider, voiceCredentialProvider))\n providers.Add(provider);\n }\n\n return providers;\n }\n\n private static IServiceCollection AddVoicePresenceCapabilityProjectionStore(\n this IServiceCollection services,\n IConfiguration configuration)\n {\n var documentProvider = ProjectionDocumentProviderConfiguration.Resolve(configuration, \"VoicePresence\");\n\n if (HasAnyVoicePresenceCapabilityReader(services))\n return services;\n\n if (documentProvider.ElasticsearchEnabled)\n {\n services.AddElasticsearchDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n optionsFactory: _ => ProjectionDocumentProviderConfiguration.BindRequiredElasticsearchOptions(configuration),\n metadataFactory: sp => sp.GetRequiredService<IProjectionDocumentMetadataProvider<VoicePresenceCapabilityReadModel>>().Metadata,\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key);\n }\n else\n {\n services.AddInMemoryDocumentProjectionStore<VoicePresenceCapabilityReadModel, string>(\n keySelector: static readModel => readModel.Id,\n keyFormatter: static key => key,\n defaultSortSelector: static readModel => readModel.UpdatedAt);\n }\n\n return services;\n }\n\n private static bool HasAnyVoicePresenceCapabilityReader(IServiceCollection services)\n {\n return services.Any(x =>\n x.ServiceType == typeof(IProjectionDocumentReader<VoicePresenceCapabilityReadModel, string>));\n }\n\n private static List<VoicePresenceModuleRegistration> BuildVoicePresenceModuleRegistrations(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var registrations = new List<VoicePresenceModuleRegistration>();\n var voiceOptions = options.VoicePresence;\n var openAIProviderConfig = BuildOpenAIVoiceProviderConfig(configuration, options);\n var miniCpmProviderConfig = BuildMiniCpmVoiceProviderConfig(configuration, options);\n var nyxIdRealtimeBrokerEnabled = IsNyxIdRealtimeBrokerEnabled(configuration);\n // The openai module registers below when EITHER a raw ApiKey OR the NyxID ephemeral broker is\n // available (ADR-0033 forbids long-lived keys in production, so broker-only is the normal shape).\n // Default-provider resolution must use the same availability, or broker-only deployments register\n // the module solely as \"voice_presence_openai\" while the auto-enable/mount/read default path uses\n // \"voice_presence\" — the module factory then never mounts a module for the enabled name, the\n // session-lease signal is silently dropped, and /ws/voice loops on 503 voice_capability_not_ready.\n var openAIVoiceAvailable = IsOpenAIVoiceConfigured(openAIProviderConfig) || nyxIdRealtimeBrokerEnabled;\n var resolvedDefaultProvider = ResolveVoicePresenceDefaultProvider(\n voiceOptions.DefaultProvider,\n openAIVoiceAvailable,\n miniCpmProviderConfig);\n\n if (openAIVoiceAvailable)\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"openai\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"openai\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_openai\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new OpenAIRealtimeProvider(\n voiceOptions.OpenAIProviderOptions,\n serviceProvider.GetService<ILogger<OpenAIRealtimeProvider>>(),\n serviceProvider.GetService<IRealtimeProviderCredentialResolver>()),\n openAIProviderConfig.Clone(),\n BuildOpenAIVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildOpenAIVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n registrations.Add(new VoicePresenceModuleRegistration(\n BuildVoicePresenceModuleNames(\n providerName: \"minicpm\",\n isDefaultProvider: string.Equals(resolvedDefaultProvider, \"minicpm\", StringComparison.OrdinalIgnoreCase),\n providerAliases: [\"voice_presence_minicpm\", \"voice_presence_minicpm_o\"]),\n (serviceProvider, resolvedModuleName) => new VoicePresenceModule(\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n CloneVoicePresenceModuleOptions(voiceOptions.Module, resolvedModuleName),\n serviceProvider.GetService<IVoiceToolInvoker>(),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n serviceProvider.GetService<ILogger<VoicePresenceModule>>()),\n (serviceProvider, handle, eventSink, audioSink, ct) => ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n new MiniCPMRealtimeProvider(\n voiceOptions.MiniCPMProviderOptions,\n serviceProvider.GetService<ILogger<MiniCPMRealtimeProvider>>()),\n miniCpmProviderConfig.Clone(),\n BuildMiniCpmVoiceSessionConfig(configuration, options),\n serviceProvider.GetService<IVoiceToolCatalog>(),\n eventSink,\n audioSink,\n serviceProvider.GetService<ILogger<ReadinessGatedRealtimeVoiceProviderSession>>(),\n ct),\n BuildMiniCpmVoiceSessionConfig(configuration, options).SampleRateHz));\n }\n\n return registrations;\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n CancellationToken ct)\n {\n return await ConnectVoiceProviderSessionWithLoggingAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger: null,\n ct);\n }\n\n private static async Task<RealtimeVoiceProviderSession> ConnectVoiceProviderSessionWithLoggingAsync(\n VoicePresenceSessionLeaseHandle handle,\n IRealtimeVoiceProvider provider,\n VoiceProviderConfig providerConfig,\n VoiceSessionConfig sessionConfig,\n IVoiceToolCatalog? toolCatalog,\n Func<VoiceProviderSessionKey, VoiceProviderEvent, CancellationToken, Task> eventSink,\n Func<VoiceProviderSessionKey, VoiceProviderAudioFrame, CancellationToken, Task> audioSink,\n ILogger<ReadinessGatedRealtimeVoiceProviderSession>? logger,\n CancellationToken ct)\n {\n return await VoiceRealtimeSessionReadinessBootstrapper.ConnectAsync(\n handle,\n provider,\n providerConfig,\n sessionConfig,\n toolCatalog,\n eventSink,\n audioSink,\n logger,\n ct);\n }\n\n private static string? ResolveVoicePresenceDefaultProvider(\n string? requestedProvider,\n bool openAIVoiceAvailable,\n VoiceProviderConfig miniCpmProviderConfig)\n {\n var normalizedRequested = NormalizeVoicePresenceProviderName(requestedProvider);\n if (string.Equals(normalizedRequested, \"openai\", StringComparison.OrdinalIgnoreCase) &&\n openAIVoiceAvailable)\n {\n return \"openai\";\n }\n\n if (string.Equals(normalizedRequested, \"minicpm\", StringComparison.OrdinalIgnoreCase) &&\n IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n {\n return \"minicpm\";\n }\n\n if (openAIVoiceAvailable)\n return \"openai\";\n\n if (IsMiniCpmVoiceConfigured(miniCpmProviderConfig))\n return \"minicpm\";\n\n return null;\n }\n\n private static string[] BuildVoicePresenceModuleNames(\n string providerName,\n bool isDefaultProvider,\n IEnumerable<string> providerAliases)\n {\n var names = new List<string>();\n if (isDefaultProvider)\n names.Add(\"voice_presence\");\n\n names.AddRange(providerAliases);\n if (!names.Contains(providerName, StringComparer.OrdinalIgnoreCase))\n names.Add(providerName == \"openai\" ? \"voice_presence_openai\" : \"voice_presence_minicpm\");\n\n return names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();\n }\n\n // Refactor (cluster-voice-nyxid-ephemeral-broker): the broker slug defaults to\n // NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug so voice stays enabled even when the\n // deployment config/secret is wiped on redeploy — config only overrides the slug, never disables it.\n private static string ResolveNyxIdRealtimeServiceSlug(IConfiguration configuration)\n {\n var configured = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:ServiceSlug\"]?.Trim();\n return string.IsNullOrWhiteSpace(configured)\n ? NyxIdRealtimeProviderCredentialOptions.DefaultServiceSlug\n : configured;\n }\n\n private static bool IsNyxIdRealtimeBrokerEnabled(IConfiguration configuration) =>\n !string.IsNullOrWhiteSpace(ResolveNyxIdRealtimeServiceSlug(configuration));\n\n private static NyxIdRealtimeProviderCredentialOptions BuildNyxIdRealtimeCredentialOptions(\n IConfiguration configuration)\n {\n var options = new NyxIdRealtimeProviderCredentialOptions\n {\n ServiceSlug = ResolveNyxIdRealtimeServiceSlug(configuration),\n };\n\n var mintPath = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:MintPath\"];\n if (!string.IsNullOrWhiteSpace(mintPath))\n options.MintPath = mintPath.Trim();\n\n var model = configuration[\"Aevatar:VoicePresence:OpenAI:Nyxid:Model\"];\n if (!string.IsNullOrWhiteSpace(model))\n options.Model = model.Trim();\n\n return options;\n }\n\n private static VoiceProviderConfig BuildOpenAIVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.OpenAIProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"openai\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:OpenAI:ApiKey\"],\n Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\"),\n options.ApiKey));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:OpenAI:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:OpenAI:Model\"],\n OpenAIRealtimeProviderOptions.DefaultModelName)!;\n return config;\n }\n\n private static VoiceSessionConfig BuildOpenAIVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.OpenAISession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:OpenAI:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:OpenAI:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = OpenAIRealtimeProviderOptions.DefaultSampleRateHz;\n return session;\n }\n\n private static VoiceProviderConfig BuildMiniCpmVoiceProviderConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var config = options.VoicePresence.MiniCPMProvider.Clone();\n config.ProviderName = FirstNonEmpty(config.ProviderName, \"minicpm\")!;\n AssignIfNonEmpty(config, static (target, value) => target.ApiKey = value, FirstNonEmpty(\n config.ApiKey,\n configuration[\"Aevatar:VoicePresence:MiniCPM:ApiKey\"]));\n AssignIfNonEmpty(config, static (target, value) => target.Endpoint = value, FirstNonEmpty(\n config.Endpoint,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Endpoint\"]));\n config.Model = FirstNonEmpty(\n config.Model,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Model\"],\n \"minicpm-o\")!;\n return config;\n }\n\n private static VoiceSessionConfig BuildMiniCpmVoiceSessionConfig(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var session = options.VoicePresence.MiniCPMSession.Clone();\n session.Voice = FirstNonEmpty(session.Voice, configuration[\"Aevatar:VoicePresence:MiniCPM:Voice\"]) ?? string.Empty;\n session.Instructions = FirstNonEmpty(\n session.Instructions,\n configuration[\"Aevatar:VoicePresence:MiniCPM:Instructions\"]) ?? string.Empty;\n if (session.SampleRateHz == 0)\n session.SampleRateHz = MiniCPMRealtimeProviderOptions.DefaultInputSampleRateHz;\n return session;\n }\n\n private static VoicePresenceModuleOptions CloneVoicePresenceModuleOptions(\n VoicePresenceModuleOptions options,\n string? resolvedName = null) =>\n new()\n {\n Name = FirstNonEmpty(resolvedName, options.Name) ?? \"voice_presence\",\n Priority = options.Priority,\n LinkId = options.LinkId,\n StaleAfter = options.StaleAfter,\n DedupeWindow = options.DedupeWindow,\n ToolExecutionTimeout = options.ToolExecutionTimeout,\n DrainTimeout = options.DrainTimeout,\n PendingInjectionCapacity = options.PendingInjectionCapacity,\n TimeProvider = options.TimeProvider,\n DirectExternalEventTypeUrls = options.DirectExternalEventTypeUrls,\n DirectExternalEventNoActiveSessionPolicy = options.DirectExternalEventNoActiveSessionPolicy,\n };\n\n private static bool IsOpenAIVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.ApiKey);\n\n private static bool IsMiniCpmVoiceConfigured(VoiceProviderConfig config) =>\n !string.IsNullOrWhiteSpace(config.Endpoint);\n\n private static string? NormalizeVoicePresenceProviderName(string? providerName)\n {\n if (string.IsNullOrWhiteSpace(providerName))\n return null;\n\n return providerName.Trim().ToLowerInvariant() switch\n {\n \"minicpm\" => \"minicpm\",\n \"minicpm-o\" => \"minicpm\",\n \"openai\" => \"openai\",\n _ => null,\n };\n }\n\n private static string? FirstNonEmpty(params string?[] candidates)\n {\n foreach (var candidate in candidates)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n return candidate.Trim();\n }\n\n return null;\n }\n\n private static void AssignIfNonEmpty<T>(\n T target,\n Action<T, string> assign,\n string? value)\n {\n if (!string.IsNullOrWhiteSpace(value))\n assign(target, value);\n }\n\n private static void RegisterMeaiProviders(\n IServiceCollection services,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n if (!options.EnableMEAIProviders)\n return;\n\n if (options.EnableReloadableProviderFactory)\n {\n var versionProvider = BuildProviderConfigVersionProvider(options);\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n var logger = sp.GetService<ILogger<ReloadableLLMProviderFactory>>();\n var loggerFactory = sp.GetService<ILoggerFactory>();\n var toolExecutionPort = new ServiceProviderAgentToolExecutionPort(sp);\n return new ReloadableLLMProviderFactory(\n () => BuildLlmProviderFactory(configuration, options, secretsStoreAccessor, toolExecutionPort, loggerFactory),\n versionProvider,\n logger);\n });\n return;\n }\n\n services.TryAddSingleton<ILLMProviderFactory>(sp =>\n {\n var secretsStoreAccessor = CreateSecretsStoreAccessor(options, sp);\n return BuildLlmProviderFactory(\n configuration,\n options,\n secretsStoreAccessor,\n new ServiceProviderAgentToolExecutionPort(sp),\n sp.GetService<ILoggerFactory>());\n });\n }\n\n private static ILLMProviderFactory BuildLlmProviderFactory(\n IConfiguration configuration,\n AevatarAIFeatureOptions options,\n Func<IAevatarSecretsStore> secretsStoreAccessor,\n IAgentToolExecutionPort toolExecutionPort,\n ILoggerFactory? loggerFactory = null)\n {\n var secrets = secretsStoreAccessor();\n var configuredProviders = ReadConfiguredProviders(secrets, configuration, options);\n if (configuredProviders.Count == 0)\n {\n var fallbackRegistration = ResolveFallbackRegistration(options);\n if (fallbackRegistration != null)\n {\n configuredProviders.Add(new ConfiguredProvider(\n fallbackRegistration.ProviderName,\n fallbackRegistration.ProviderName,\n fallbackRegistration.Model,\n fallbackRegistration.Endpoint,\n fallbackRegistration.ApiKey));\n }\n }\n\n var preferredDefault = secrets.GetDefaultProvider()\n ?? configuration[\"Models:DefaultProvider\"]\n ?? options.DefaultProvider;\n var defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n\n var nyxIdProviders = configuredProviders\n .Where(provider => IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n\n // Auto-register NyxID provider from appsettings when Aevatar:NyxId is configured\n if (nyxIdProviders.Count == 0)\n {\n var autoRegistered = TryAutoRegisterNyxIdProvider(configuration, options);\n if (autoRegistered != null)\n {\n nyxIdProviders.Add(autoRegistered);\n configuredProviders.Add(autoRegistered);\n defaultName = ResolveDefaultProviderName(configuredProviders, preferredDefault);\n }\n }\n\n if (nyxIdProviders.Count == 0)\n return BuildPrimaryFactory(configuredProviders, defaultName, options, toolExecutionPort, loggerFactory);\n\n var standardProviders = configuredProviders\n .Where(provider => !IsNyxIdProviderType(provider.ProviderType))\n .ToList();\n var nyxIdFactory = BuildNyxIdFactory(nyxIdProviders, defaultName, toolExecutionPort, loggerFactory);\n if (standardProviders.Count == 0)\n return nyxIdFactory;\n\n var primaryFactory = BuildPrimaryFactory(standardProviders, defaultName, options, toolExecutionPort, loggerFactory);\n var extraProviders = nyxIdFactory\n .GetAvailableProviders()\n .Select(nyxIdFactory.GetProvider)\n .ToList();\n return new CompositeLLMProviderFactory(primaryFactory, extraProviders, defaultName);\n }\n\n private static ILLMProviderFactory BuildPrimaryFactory(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options,\n IAgentToolExecutionPort toolExecutionPort,\n ILoggerFactory? loggerFactory = null)\n {\n var primaryDefaultName = ResolveDefaultProviderName(configuredProviders, defaultName);\n var meaiFactory = BuildMeaiFactory(configuredProviders, primaryDefaultName, toolExecutionPort, loggerFactory);\n if (!options.EnableMEAIToTornadoFailover)\n return meaiFactory;\n\n var tornadoDefaultName = ResolveTornadoDefaultProviderName(configuredProviders, primaryDefaultName, options);\n var tornadoFactory = BuildTornadoFactory(configuredProviders, tornadoDefaultName, loggerFactory);\n return new FailoverLLMProviderFactory(\n meaiFactory,\n tornadoFactory,\n new LLMProviderFailoverOptions\n {\n FallbackToDefaultProviderWhenNamedProviderMissing =\n options.FailoverFallbackToDefaultProviderWhenNamedProviderMissing,\n PreferFallbackDefaultProvider = options.FailoverPreferFallbackDefaultProvider,\n });\n }\n\n private static string ResolveTornadoDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string defaultName,\n AevatarAIFeatureOptions options)\n {\n if (!options.FailoverPreferDeepSeekAsFallbackDefault || configuredProviders.Count == 0)\n return defaultName;\n\n var deepSeek = configuredProviders.FirstOrDefault(static p =>\n string.Equals(p.ProviderType, \"deepseek\", StringComparison.OrdinalIgnoreCase));\n return deepSeek?.Name ?? defaultName;\n }\n\n private static string ResolveDefaultProviderName(\n IReadOnlyList<ConfiguredProvider> configuredProviders,\n string? preferredDefault)\n {\n var normalizedPreferred = string.IsNullOrWhiteSpace(preferredDefault)\n ? null\n : preferredDefault.Trim();\n\n if (configuredProviders.Count == 0)\n return normalizedPreferred ?? \"openai\";\n\n if (!string.IsNullOrWhiteSpace(normalizedPreferred) &&\n configuredProviders.Any(p => string.Equals(p.Name, normalizedPreferred, StringComparison.OrdinalIgnoreCase)))\n {\n return normalizedPreferred;\n }\n\n return configuredProviders[0].Name;\n }\n\n private static MEAILLMProviderFactory BuildMeaiFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n IAgentToolExecutionPort toolExecutionPort,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new MEAILLMProviderFactory(toolExecutionPort);\n var providerLogger = loggerFactory?.CreateLogger<MEAILLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAI(\n provider.Name,\n provider.Model,\n provider.ApiKey,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static TornadoLLMProviderFactory BuildTornadoFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new TornadoLLMProviderFactory();\n var providerLogger = loggerFactory?.CreateLogger<TornadoLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n factory.RegisterOpenAICompatible(\n provider.Name,\n provider.ApiKey,\n provider.Model,\n string.IsNullOrWhiteSpace(provider.Endpoint) ? null : provider.Endpoint,\n providerLogger);\n }\n\n factory.SetDefault(defaultName);\n return factory;\n }\n\n private static NyxIdLLMProviderFactory BuildNyxIdFactory(\n IEnumerable<ConfiguredProvider> configuredProviders,\n string defaultName,\n IAgentToolExecutionPort toolExecutionPort,\n ILoggerFactory? loggerFactory = null)\n {\n var factory = new NyxIdLLMProviderFactory(toolExecutionPort);\n // Without an explicit logger the provider chain (NyxIdLLMProvider and the\n // MEAILLMProvider it delegates to) falls back to NullLogger, which silences\n // upstream LLM error translations and the no-chunks streaming fallback in\n // production. Always wire the host logger when one is available.\n var providerLogger = loggerFactory?.CreateLogger<NyxIdLLMProvider>();\n foreach (var provider in configuredProviders)\n {\n if (string.IsNullOrWhiteSpace(provider.Endpoint))\n {\n throw new InvalidOperationException(\n $\"NyxID provider '{provider.Name}' requires a gateway endpoint. \" +\n $\"Configure LLMProviders:Providers:{provider.Name}:Endpoint or set Aevatar:NyxId:Authority.\");\n }\n\n factory.RegisterGateway(\n provider.Name,\n provider.Model,\n provider.Endpoint,\n // NyxID gateway token comes exclusively from per-request metadata\n // (the caller's Bearer token). No local secrets fallback.\n static () => null,\n provider.DefaultRoutePreference,\n providerLogger);\n }\n\n factory.SetDefault(ResolveDefaultProviderName(configuredProviders.ToList(), defaultName));\n return factory;\n }\n\n private static ConfiguredProvider? TryAutoRegisterNyxIdProvider(\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n var gatewayEndpoint = ResolveNyxIdGatewayEndpoint(configuration, options);\n if (string.IsNullOrWhiteSpace(gatewayEndpoint))\n return null;\n\n var model = ResolveNyxIdDefaultModel(configuration, options);\n var defaultRoute = ResolveNyxIdDefaultRoute(configuration);\n return new ConfiguredProvider(\"nyxid\", \"nyxid\", model, gatewayEndpoint, string.Empty, defaultRoute);\n }\n\n private static Func<IAevatarSecretsStore> CreateSecretsStoreAccessor(\n AevatarAIFeatureOptions options,\n IServiceProvider services)\n {\n if (options.SecretsStore != null)\n return () => options.SecretsStore;\n\n // Prefer the DI-registered store so hosts that opted into the\n // read-only EnvironmentSecretsStore (e.g. mainnet) are honored\n // here too. Falling back to a fresh AevatarSecretsStore() would\n // re-open the local secrets.json on disk.\n var registered = services.GetService<IAevatarSecretsStore>();\n if (registered != null)\n return () => registered;\n\n return static () => new AevatarSecretsStore();\n }\n\n private static Func<long> BuildProviderConfigVersionProvider(AevatarAIFeatureOptions options)\n {\n if (options.SecretsStore != null)\n return static () => 0L;\n\n return static () => HashCode.Combine(\n ComputeFileVersion(AevatarPaths.SecretsJson),\n ComputeFileVersion(AevatarPaths.ConfigJson));\n }\n\n private static long ComputeFileVersion(string path)\n {\n try\n {\n if (!File.Exists(path))\n return 0L;\n\n var info = new FileInfo(path);\n return HashCode.Combine(info.LastWriteTimeUtc.Ticks, info.Length);\n }\n catch\n {\n return 0L;\n }\n }\n\n private static List<ConfiguredProvider> ReadConfiguredProviders(\n IAevatarSecretsStore secrets,\n IConfiguration configuration,\n AevatarAIFeatureOptions options)\n {\n const string prefix = \"LLMProviders:Providers:\";\n var all = secrets.GetAll();\n var names = all.Keys\n .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))\n .Select(key => key[prefix.Length..])\n .Select(rest =>\n {\n var splitIndex = rest.IndexOf(':');\n return splitIndex <= 0 ? string.Empty : rest[..splitIndex];\n })\n .Where(name => !string.IsNullOrWhiteSpace(name))\n .Distinct(StringComparer.OrdinalIgnoreCase)\n .ToList();\n\n var configured = new List<ConfiguredProvider>();\n foreach (var name in names)\n {\n var apiKey = secrets.GetApiKey(name);\n if (string.IsNullOrWhiteSpace(apiKey))\n continue;\n\n all.TryGetValue($\"LLMProviders:Providers:{name}:ProviderType\", out var providerType);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Model\", out var model);\n all.TryGetValue($\"LLMProviders:Providers:{name}:Endpoint\", out var endpoint);\n\n var semantic = ResolveProviderSemantic(configuration, providerType, name, options.DefaultProvider, options);\n var resolvedModel = string.IsNullOrWhiteSpace(model)\n ? semantic.Model\n : model.Trim();\n var resolvedEndpoint = string.IsNullOrWhiteSpace(endpoint)\n ? semantic.Endpoint\n : endpoint.Trim();\n\n var defaultRoute = IsNyxIdProviderType(semantic.ProviderType)\n ? ResolveNyxIdDefaultRoute(configuration)\n : null;\n configured.Add(new ConfiguredProvider(\n name.Trim(), semantic.ProviderType, resolvedModel, resolvedEndpoint, apiKey.Trim(), defaultRoute));\n }\n\n return configured;\n }\n\n private static FallbackRegistration? ResolveFallbackRegistration(AevatarAIFeatureOptions options)\n {\n var apiKeySelection = ResolveApiKeySelection(options);\n if (apiKeySelection is null)\n return null;\n\n ProviderSemantic semantic;\n string providerName;\n switch (apiKeySelection.Source)\n {\n case ApiKeySource.DeepSeekEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.DeepSeek, options);\n providerName = semantic.ProviderType;\n break;\n case ApiKeySource.OpenAIEnvironment:\n semantic = BuildProviderSemantic(new ConfigurationBuilder().Build(), ProviderKind.OpenAI, options);\n providerName = semantic.ProviderType;\n break;\n default:\n semantic = ResolveProviderSemantic(new ConfigurationBuilder().Build(), null, options.DefaultProvider, null, options);\n providerName = string.IsNullOrWhiteSpace(options.DefaultProvider)\n ? semantic.ProviderType\n : options.DefaultProvider.Trim();\n break;\n }\n\n return new FallbackRegistration(providerName, semantic.Model, semantic.Endpoint, apiKeySelection.ApiKey);\n }\n\n private static ApiKeySelection? ResolveApiKeySelection(AevatarAIFeatureOptions options)\n {\n if (!string.IsNullOrWhiteSpace(options.ApiKey))\n return new ApiKeySelection(options.ApiKey.Trim(), ApiKeySource.Options);\n\n var deepSeekApiKey = Environment.GetEnvironmentVariable(\"DEEPSEEK_API_KEY\");\n if (!string.IsNullOrWhiteSpace(deepSeekApiKey))\n return new ApiKeySelection(deepSeekApiKey.Trim(), ApiKeySource.DeepSeekEnvironment);\n\n var openAiApiKey = Environment.GetEnvironmentVariable(\"OPENAI_API_KEY\");\n if (!string.IsNullOrWhiteSpace(openAiApiKey))\n return new ApiKeySelection(openAiApiKey.Trim(), ApiKeySource.OpenAIEnvironment);\n\n var genericApiKey = Environment.GetEnvironmentVariable(\"AEVATAR_LLM_API_KEY\");\n if (!string.IsNullOrWhiteSpace(genericApiKey))\n return new ApiKeySelection(genericApiKey.Trim(), ApiKeySource.GenericEnvironment);\n\n return null;\n }\n\n private static ProviderSemantic ResolveProviderSemantic(\n IConfiguration configuration,\n string? providerTypeHint,\n string? providerNameHint,\n string? fallbackHint,\n AevatarAIFeatureOptions options)\n {\n if (TryResolveProviderKind(providerTypeHint, out var providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(providerNameHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n if (TryResolveProviderKind(fallbackHint, out providerKind))\n return BuildProviderSemantic(configuration, providerKind, options);\n\n return BuildProviderSemantic(configuration, ProviderKind.OpenAI, options);\n }\n\n private static bool TryResolveProviderKind(string? candidate, out ProviderKind providerKind)\n {\n if (!string.IsNullOrWhiteSpace(candidate))\n {\n if (candidate.Contains(\"deepseek\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.DeepSeek;\n return true;\n }\n\n if (candidate.Contains(\"openai\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.OpenAI;\n return true;\n }\n\n if (candidate.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase))\n {\n providerKind = ProviderKind.NyxId;\n return true;\n }\n }\n\n providerKind = default;\n return false;\n }\n\n private static ProviderSemantic BuildProviderSemantic(\n IConfiguration configuration,\n ProviderKind providerKind,\n AevatarAIFeatureOptions options)\n {\n return providerKind switch\n {\n ProviderKind.DeepSeek => new ProviderSemantic(\"deepseek\", options.DeepSeekModel, \"https://api.deepseek.com/v1\"),\n ProviderKind.NyxId => new ProviderSemantic(\"nyxid\", ResolveNyxIdDefaultModel(configuration, options), ResolveNyxIdGatewayEndpoint(configuration, options)),\n _ => new ProviderSemantic(\"openai\", options.OpenAIModel, null),\n };\n }\n\n private static string? ResolveNyxIdGatewayEndpoint(IConfiguration configuration, AevatarAIFeatureOptions options)\n {\n if (options.NyxIdLlmEndpoint != null)\n {\n var authority = configuration[\"Cli:App:NyxId:Authority\"]\n ?? configuration[\"Aevatar:NyxId:Authority\"]\n ?? configuration[\"Aevatar:Authentication:Authority\"];\n return NyxIdLlmEndpointResolver.ResolveEndpoint(authority, options.NyxIdLlmEndpoint);\n }\n\n return NyxIdLlmEndpointResolver.ResolveEndpoint(configuration);\n }\n\n private sealed record FallbackRegistration(\n string ProviderName,\n string Model,\n string? Endpoint,\n string ApiKey);\n\n private sealed record ApiKeySelection(\n string ApiKey,\n ApiKeySource Source);\n\n private sealed record ProviderSemantic(\n string ProviderType,\n string Model,\n string? Endpoint);\n\n private enum ApiKeySource\n {\n Options,\n DeepSeekEnvironment,\n OpenAIEnvironment,\n GenericEnvironment,\n }\n\n private enum ProviderKind\n {\n OpenAI,\n DeepSeek,\n NyxId,\n }\n\n // The default route/model literals live once in LlmDefaults (Aevatar.AI.Abstractions) so the\n // NyxID server-default path and the OpenAI-compatible Responses ingress default share one\n // source and cannot drift. Every nyxid registration path resolves its default through these\n // helpers, which apply per-deployment config overrides on top.\n private static string ResolveNyxIdDefaultRoute(IConfiguration configuration) =>\n configuration[\"Aevatar:NyxId:DefaultRoute\"] is { } route && !string.IsNullOrWhiteSpace(route)\n ? route.Trim()\n : LlmDefaults.NyxIdRoute;\n\n private static string ResolveNyxIdDefaultModel(IConfiguration configuration, AevatarAIFeatureOptions options) =>\n configuration[\"Aevatar:NyxId:DefaultModel\"] is { Length: > 0 } model\n ? model\n : options.OpenAIModel;\n\n private sealed record ConfiguredProvider(\n string Name,\n string ProviderType,\n string Model,\n string? Endpoint,\n string ApiKey,\n string? DefaultRoutePreference = null);\n\n private static bool IsNyxIdProviderType(string providerType) =>\n providerType.Contains(\"nyxid\", StringComparison.OrdinalIgnoreCase);\n\n private static void RegisterMCPTools(IServiceCollection services)\n {\n var servers = AevatarMCPConfig.LoadServers();\n if (servers.Count == 0)\n return;\n\n services.AddMCPTools(options =>\n {\n foreach (var server in servers)\n {\n options.Servers.Add(new MCPServerConfig\n {\n Name = server.Name,\n Command = server.Command,\n Arguments = server.Args,\n Environment = server.Env,\n });\n }\n });\n }\n\n private static void RegisterSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddSkills(skillOptions =>\n {\n if (options.SkillDirectories.Count == 0)\n {\n skillOptions.ScanDirectory(\"~/.aevatar/skills\");\n skillOptions.ScanDirectory(\"./skills\");\n return;\n }\n\n foreach (var directory in options.SkillDirectories)\n skillOptions.ScanDirectory(directory);\n });\n }\n\n private static void RegisterServiceInvokeTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddServiceInvokeTools(o =>\n {\n o.TenantId = options.ServiceInvokeTenantId;\n o.AppId = options.ServiceInvokeAppId;\n o.Namespace = options.ServiceInvokeNamespace;\n o.BypassInvokeApproval = options.BypassServiceInvokeApproval;\n o.EnableDynamicScopeResolution = true;\n });\n }\n\n private static void RegisterOrnnSkills(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n // EnableOrnnSkills is the only gate. OrnnSkillClient routes through NyxID's proxy\n // (slug defaults to chrono-ornn's canonical \"ornn\") so the upstream Ornn URL is\n // not a configuration concern at this layer — NyxIdToolOptions.BaseUrl already\n // supplies the NyxID host, and NyxID resolves the Ornn backend from the catalog\n // entry matching the slug. Deployments override the slug only when their NyxID\n // catalog re-registered the service under a non-default name.\n services.AddOrnnSkills(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug.Trim();\n });\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, WorkflowOrnnSkillPublishAssetValidator>());\n services.TryAddEnumerable(ServiceDescriptor.Singleton<IOrnnSkillPublishAssetValidator, ScriptOrnnSkillPublishAssetValidator>());\n }\n\n private static void RegisterSystemSkillOverlay(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n if (!options.EnableSystemSkillOverlay || string.IsNullOrWhiteSpace(options.SystemSkillOverlaySetName))\n return;\n\n // Ensure the Ornn skill client (with the configured slug) is registered even when a host enables\n // the overlay without the Ornn skill tools; the overlay reads the set through this client.\n services.AddOrnnSkillClient(o =>\n {\n if (!string.IsNullOrWhiteSpace(options.OrnnNyxIdSlug))\n o.NyxIdSlug = options.OrnnNyxIdSlug.Trim();\n });\n\n services.AddSystemSkillOverlay(o =>\n {\n o.Enabled = options.EnableSystemSkillOverlay;\n o.SetName = options.SystemSkillOverlaySetName ?? string.Empty;\n o.RefreshTtl = options.SystemSkillOverlayRefreshTtl;\n o.MaxSkills = options.SystemSkillOverlayMaxSkills;\n o.MaxBytes = options.SystemSkillOverlayMaxBytes;\n });\n }\n\n private static void RegisterWebTools(IServiceCollection services, AevatarAIFeatureOptions options)\n {\n services.AddWebTools(o =>\n {\n o.NyxIdSearchSlug = options.WebSearchNyxIdSlug;\n o.SearchApiBaseUrl = options.WebSearchApiBaseUrl;\n });\n }\n\n private static void RegisterWorkflowTools(IServiceCollection services)\n {\n services.AddWorkflowTools();\n }\n\n private static void RegisterScriptingTools(IServiceCollection services)\n {\n services.AddScriptingTools();\n }\n\n private static void RegisterBindingTools(IServiceCollection services)\n {\n services.AddBindingTools();\n }\n\n private sealed class ServiceProviderAgentToolExecutionPort(IServiceProvider serviceProvider) : IAgentToolExecutionPort\n {\n public Task<AgentToolExecutionOutcome> ExecuteAsync(\n AgentToolExecutionRequest request,\n CancellationToken ct = default) =>\n serviceProvider.GetRequiredService<IAgentToolExecutionPort>().ExecuteAsync(request, ct);\n }\n}\n"}}, "test/Aevatar.Integration.Tests/SecretsStoreTests.cs": {"4ecb75cf0bf248f7906ff3f1402cda113cceeaea": {"t": 1785745826.1778522, "n": 6464, "h": "87d40ea1fb8cc386", "c": "using Aevatar.Configuration;\nusing FluentAssertions;\nusing System.Security.Cryptography;\n\nnamespace Aevatar.Integration.Tests;\n\n[Collection(ProcessEnvSerialCollection.Name)]\npublic class SecretsStoreTests\n{\n [Fact]\n public void Constructor_WithPlaintextDevOptIn_ShouldLoadPlaintextJson_AndSupportLookupConventions()\n {\n var path = NewTempSecretsPath();\n File.WriteAllText(path, \"\"\"\n {\n \"LLMProviders:Providers:deepseek:ApiKey\": \"k1\",\n \"LLMProviders:openai:ApiKey\": \"k2\",\n \"DEEPSEEK_API_KEY\": \"k3\",\n \"LLMProviders:Default\": \"deepseek\",\n \"Custom:Value\": \"x\"\n }\n \"\"\");\n\n try\n {\n var store = new AevatarSecretsStore(path, PlaintextAllowedNoLocalMasterKeySources);\n\n store.Get(\"Custom:Value\").Should().Be(\"x\");\n store.GetApiKey(\"deepseek\").Should().Be(\"k1\");\n store.GetApiKey(\"openai\").Should().Be(\"k2\");\n store.GetApiKey(\"DEEPSEEK\").Should().Be(\"k3\");\n store.GetDefaultProvider().Should().Be(\"deepseek\");\n store.GetAll().Should().ContainKey(\"Custom:Value\");\n }\n finally\n {\n File.Delete(path);\n }\n }\n\n [Fact]\n public void Constructor_WhenPlaintextExistsWithoutOptIn_ShouldFailClosed()\n {\n var path = NewTempSecretsPath();\n File.WriteAllText(path, \"\"\"{\"Custom:Value\":\"x\"}\"\"\");\n\n try\n {\n var act = () => new AevatarSecretsStore(path, NoPlaintextNoLocalMasterKeySources);\n\n act.Should().Throw<InvalidOperationException>()\n .WithMessage(\"*plaintext*AEVATAR_ALLOW_PLAINTEXT_SECRETS*\");\n }\n finally\n {\n File.Delete(path);\n }\n }\n\n [Fact]\n public void AevatarSecretsStore_WithInvalidEncryptedEnvelope_ShouldFailClosedWithoutPlaintextOptIn()\n {\n var path = NewTempSecretsPath();\n File.WriteAllText(path, \"\"\"\n {\n \"schemaVersion\": 1,\n \"algorithm\": \"AES-256-GCM\",\n \"nonceB64\": \"invalid\",\n \"tagB64\": \"invalid\",\n \"ciphertextB64\": \"invalid\"\n }\n \"\"\");\n\n try\n {\n using var plaintextScope = new EnvironmentVariableScope(\n LocalSecretProtectionOptions.AllowPlaintextSecretsEnv,\n null);\n\n var act = () => new AevatarSecretsStore(path);\n\n act.Should().Throw<InvalidOperationException>()\n .WithMessage(\"*plaintext*AEVATAR_ALLOW_PLAINTEXT_SECRETS*\");\n }\n finally\n {\n File.Delete(path);\n }\n }\n\n [Fact]\n public void SetAndRemove_WhenEncryptionUnavailableWithoutPlaintextOptIn_ShouldFailClosed()\n {\n var path = NewTempSecretsPath();\n\n try\n {\n WriteSiblingMasterKey(path);\n var store = new AevatarSecretsStore(path, NoPlaintextNoLocalMasterKeySources);\n\n var act = () => store.Set(\"K1\", \"V1\");\n\n act.Should().Throw<InvalidOperationException>()\n .WithMessage(\"*plaintext*AEVATAR_ALLOW_PLAINTEXT_SECRETS*\");\n File.Exists(path).Should().BeFalse();\n }\n finally\n {\n File.Delete(path);\n TryDelete(path + \".tmp\");\n }\n }\n\n [Fact]\n public void SetAndRemove_WithPlaintextDevOptIn_ShouldPersistToFile_AsPlaintextWhenNoMasterKey()\n {\n var path = NewTempSecretsPath();\n\n try\n {\n WriteSiblingMasterKey(path);\n var store = new AevatarSecretsStore(path, PlaintextAllowedNoLocalMasterKeySources);\n store.Set(\"K1\", \"V1\");\n store.Set(\"K2\", \"V2\");\n store.Remove(\"K1\");\n\n var reloaded = new AevatarSecretsStore(path, PlaintextAllowedNoLocalMasterKeySources);\n reloaded.Get(\"K1\").Should().BeNull();\n reloaded.Get(\"K2\").Should().Be(\"V2\");\n\n var text = File.ReadAllText(path);\n text.Should().Contain(\"\\\"K2\\\"\");\n text.Should().NotContain(\"\\\"K1\\\"\");\n }\n finally\n {\n File.Delete(path);\n TryDelete(path + \".tmp\");\n }\n }\n\n [Fact]\n public void Constructor_WhenInvalidJson_ShouldFallbackToEmptyAndStillWritable()\n {\n var path = NewTempSecretsPath();\n File.WriteAllText(path, \"{ invalid json\");\n\n try\n {\n var store = new AevatarSecretsStore(path, PlaintextAllowedNoLocalMasterKeySources);\n store.GetAll().Should().BeEmpty();\n\n store.Set(\"After\", \"Write\");\n\n var reloaded = new AevatarSecretsStore(path, PlaintextAllowedNoLocalMasterKeySources);\n reloaded.Get(\"After\").Should().Be(\"Write\");\n }\n finally\n {\n File.Delete(path);\n }\n }\n\n private static string NewTempSecretsPath()\n {\n var dir = Path.Combine(Path.GetTempPath(), \"aevatar-secrets-test-\" + Guid.NewGuid().ToString(\"N\"));\n Directory.CreateDirectory(dir);\n return Path.Combine(dir, \"secrets.json\");\n }\n\n private static void TryDelete(string path)\n {\n try\n {\n if (File.Exists(path))\n File.Delete(path);\n }\n catch\n {\n // no-op\n }\n }\n\n private static LocalSecretProtectionOptions NoPlaintextNoLocalMasterKeySources =>\n new(false, LocalSecretMasterKeySource.Disabled);\n\n private static LocalSecretProtectionOptions PlaintextAllowedNoLocalMasterKeySources =>\n new(true, LocalSecretMasterKeySource.Disabled);\n\n private static void WriteSiblingMasterKey(string secretsPath)\n {\n var directory = Path.GetDirectoryName(secretsPath);\n Directory.CreateDirectory(directory!);\n File.WriteAllBytes(Path.Combine(directory!, \"masterkey.bin\"), RandomNumberGenerator.GetBytes(32));\n }\n\n private sealed class EnvironmentVariableScope : IDisposable\n {\n private readonly string _name;\n private readonly string? _previous;\n\n public EnvironmentVariableScope(string name, string? value)\n {\n _name = name;\n _previous = Environment.GetEnvironmentVariable(name);\n Environment.SetEnvironmentVariable(name, value);\n }\n\n public void Dispose()\n {\n Environment.SetEnvironmentVariable(_name, _previous);\n }\n }\n}\n"}, "c83b1714c17fb7a341f412603eec0983a18f61d6": {"t": 1785756595.4729867, "n": 6464, "h": "87d40ea1fb8cc386", "c": "using Aevatar.Configuration;\nusing FluentAssertions;\nusing System.Security.Cryptography;\n\nnamespace Aevatar.Integration.Tests;\n\n[Collection(ProcessEnvSerialCollection.Name)]\npublic class SecretsStoreTests\n{\n [Fact]\n public void Constructor_WithPlaintextDevOptIn_ShouldLoadPlaintextJson_AndSupportLookupConventions()\n {\n var path = NewTempSecretsPath();\n File.WriteAllText(path, \"\"\"\n {\n \"LLMProviders:Providers:deepseek:ApiKey\": \"k1\",\n \"LLMProviders:openai:ApiKey\": \"k2\",\n \"DEEPSEEK_API_KEY\": \"k3\",\n \"LLMProviders:Default\": \"deepseek\",\n \"Custom:Value\": \"x\"\n }\n \"\"\");\n\n try\n {\n var store = new AevatarSecretsStore(path, PlaintextAllowedNoLocalMasterKeySources);\n\n store.Get(\"Custom:Value\").Should().Be(\"x\");\n store.GetApiKey(\"deepseek\").Should().Be(\"k1\");\n store.GetApiKey(\"openai\").Should().Be(\"k2\");\n store.GetApiKey(\"DEEPSEEK\").Should().Be(\"k3\");\n store.GetDefaultProvider().Should().Be(\"deepseek\");\n store.GetAll().Should().ContainKey(\"Custom:Value\");\n }\n finally\n {\n File.Delete(path);\n }\n }\n\n [Fact]\n public void Constructor_WhenPlaintextExistsWithoutOptIn_ShouldFailClosed()\n {\n var path = NewTempSecretsPath();\n File.WriteAllText(path, \"\"\"{\"Custom:Value\":\"x\"}\"\"\");\n\n try\n {\n var act = () => new AevatarSecretsStore(path, NoPlaintextNoLocalMasterKeySources);\n\n act.Should().Throw<InvalidOperationException>()\n .WithMessage(\"*plaintext*AEVATAR_ALLOW_PLAINTEXT_SECRETS*\");\n }\n finally\n {\n File.Delete(path);\n }\n }\n\n [Fact]\n public void AevatarSecretsStore_WithInvalidEncryptedEnvelope_ShouldFailClosedWithoutPlaintextOptIn()\n {\n var path = NewTempSecretsPath();\n File.WriteAllText(path, \"\"\"\n {\n \"schemaVersion\": 1,\n \"algorithm\": \"AES-256-GCM\",\n \"nonceB64\": \"invalid\",\n \"tagB64\": \"invalid\",\n \"ciphertextB64\": \"invalid\"\n }\n \"\"\");\n\n try\n {\n using var plaintextScope = new EnvironmentVariableScope(\n LocalSecretProtectionOptions.AllowPlaintextSecretsEnv,\n null);\n\n var act = () => new AevatarSecretsStore(path);\n\n act.Should().Throw<InvalidOperationException>()\n .WithMessage(\"*plaintext*AEVATAR_ALLOW_PLAINTEXT_SECRETS*\");\n }\n finally\n {\n File.Delete(path);\n }\n }\n\n [Fact]\n public void SetAndRemove_WhenEncryptionUnavailableWithoutPlaintextOptIn_ShouldFailClosed()\n {\n var path = NewTempSecretsPath();\n\n try\n {\n WriteSiblingMasterKey(path);\n var store = new AevatarSecretsStore(path, NoPlaintextNoLocalMasterKeySources);\n\n var act = () => store.Set(\"K1\", \"V1\");\n\n act.Should().Throw<InvalidOperationException>()\n .WithMessage(\"*plaintext*AEVATAR_ALLOW_PLAINTEXT_SECRETS*\");\n File.Exists(path).Should().BeFalse();\n }\n finally\n {\n File.Delete(path);\n TryDelete(path + \".tmp\");\n }\n }\n\n [Fact]\n public void SetAndRemove_WithPlaintextDevOptIn_ShouldPersistToFile_AsPlaintextWhenNoMasterKey()\n {\n var path = NewTempSecretsPath();\n\n try\n {\n WriteSiblingMasterKey(path);\n var store = new AevatarSecretsStore(path, PlaintextAllowedNoLocalMasterKeySources);\n store.Set(\"K1\", \"V1\");\n store.Set(\"K2\", \"V2\");\n store.Remove(\"K1\");\n\n var reloaded = new AevatarSecretsStore(path, PlaintextAllowedNoLocalMasterKeySources);\n reloaded.Get(\"K1\").Should().BeNull();\n reloaded.Get(\"K2\").Should().Be(\"V2\");\n\n var text = File.ReadAllText(path);\n text.Should().Contain(\"\\\"K2\\\"\");\n text.Should().NotContain(\"\\\"K1\\\"\");\n }\n finally\n {\n File.Delete(path);\n TryDelete(path + \".tmp\");\n }\n }\n\n [Fact]\n public void Constructor_WhenInvalidJson_ShouldFallbackToEmptyAndStillWritable()\n {\n var path = NewTempSecretsPath();\n File.WriteAllText(path, \"{ invalid json\");\n\n try\n {\n var store = new AevatarSecretsStore(path, PlaintextAllowedNoLocalMasterKeySources);\n store.GetAll().Should().BeEmpty();\n\n store.Set(\"After\", \"Write\");\n\n var reloaded = new AevatarSecretsStore(path, PlaintextAllowedNoLocalMasterKeySources);\n reloaded.Get(\"After\").Should().Be(\"Write\");\n }\n finally\n {\n File.Delete(path);\n }\n }\n\n private static string NewTempSecretsPath()\n {\n var dir = Path.Combine(Path.GetTempPath(), \"aevatar-secrets-test-\" + Guid.NewGuid().ToString(\"N\"));\n Directory.CreateDirectory(dir);\n return Path.Combine(dir, \"secrets.json\");\n }\n\n private static void TryDelete(string path)\n {\n try\n {\n if (File.Exists(path))\n File.Delete(path);\n }\n catch\n {\n // no-op\n }\n }\n\n private static LocalSecretProtectionOptions NoPlaintextNoLocalMasterKeySources =>\n new(false, LocalSecretMasterKeySource.Disabled);\n\n private static LocalSecretProtectionOptions PlaintextAllowedNoLocalMasterKeySources =>\n new(true, LocalSecretMasterKeySource.Disabled);\n\n private static void WriteSiblingMasterKey(string secretsPath)\n {\n var directory = Path.GetDirectoryName(secretsPath);\n Directory.CreateDirectory(directory!);\n File.WriteAllBytes(Path.Combine(directory!, \"masterkey.bin\"), RandomNumberGenerator.GetBytes(32));\n }\n\n private sealed class EnvironmentVariableScope : IDisposable\n {\n private readonly string _name;\n private readonly string? _previous;\n\n public EnvironmentVariableScope(string name, string? value)\n {\n _name = name;\n _previous = Environment.GetEnvironmentVariable(name);\n Environment.SetEnvironmentVariable(name, value);\n }\n\n public void Dispose()\n {\n Environment.SetEnvironmentVariable(_name, _previous);\n }\n }\n}\n"}, "2c7ba9cf184359a5481fa539f402ed5cd56cad00": {"t": 1785756596.6893365, "n": 3266, "h": "e587c5a4564bcee6", "c": "using Aevatar.Configuration;\nusing FluentAssertions;\n\nnamespace Aevatar.Integration.Tests;\n\npublic class SecretsStoreTests\n{\n [Fact]\n public void Constructor_ShouldLoadPlaintextJson_AndSupportLookupConventions()\n {\n var path = NewTempSecretsPath();\n File.WriteAllText(path, \"\"\"\n {\n \"LLMProviders:Providers:deepseek:ApiKey\": \"k1\",\n \"LLMProviders:openai:ApiKey\": \"k2\",\n \"DEEPSEEK_API_KEY\": \"k3\",\n \"LLMProviders:Default\": \"deepseek\",\n \"Custom:Value\": \"x\"\n }\n \"\"\");\n\n try\n {\n var store = new AevatarSecretsStore(path);\n\n store.Get(\"Custom:Value\").Should().Be(\"x\");\n store.GetApiKey(\"deepseek\").Should().Be(\"k1\");\n store.GetApiKey(\"openai\").Should().Be(\"k2\");\n store.GetApiKey(\"DEEPSEEK\").Should().Be(\"k3\");\n store.GetDefaultProvider().Should().Be(\"deepseek\");\n store.GetAll().Should().ContainKey(\"Custom:Value\");\n }\n finally\n {\n File.Delete(path);\n }\n }\n\n [Fact]\n public void SetAndRemove_ShouldPersistToFile_AsPlaintextWhenNoMasterKey()\n {\n var path = NewTempSecretsPath();\n\n try\n {\n var store = new AevatarSecretsStore(path);\n store.Set(\"K1\", \"V1\");\n store.Set(\"K2\", \"V2\");\n store.Remove(\"K1\");\n\n // Set/Remove 持久化后重新加载仍能正确读取(无论明文或加密均成立)\n var reloaded = new AevatarSecretsStore(path);\n reloaded.Get(\"K1\").Should().BeNull();\n reloaded.Get(\"K2\").Should().Be(\"V2\");\n\n // 明文文件内容断言:仅在无 master key 时文件为明文 JSON\n // 若机器配置了 macOS Keychain 或 masterkey.bin,Save() 会走 AES-256-GCM\n // 加密路径,文件内容为密文,无法直接匹配 key 字面量。\n // 此时 Set/Remove 正确性已通过上方 reload 验证,跳过明文断言。\n var text = File.ReadAllText(path);\n if (text.Contains(\"ciphertextB64\", StringComparison.OrdinalIgnoreCase))\n return;\n\n text.Should().Contain(\"\\\"K2\\\"\");\n text.Should().NotContain(\"\\\"K1\\\"\");\n }\n finally\n {\n File.Delete(path);\n TryDelete(path + \".tmp\");\n }\n }\n\n [Fact]\n public void Constructor_WhenInvalidJson_ShouldFallbackToEmptyAndStillWritable()\n {\n var path = NewTempSecretsPath();\n File.WriteAllText(path, \"{ invalid json\");\n\n try\n {\n var store = new AevatarSecretsStore(path);\n store.GetAll().Should().BeEmpty();\n\n store.Set(\"After\", \"Write\");\n\n var reloaded = new AevatarSecretsStore(path);\n reloaded.Get(\"After\").Should().Be(\"Write\");\n }\n finally\n {\n File.Delete(path);\n }\n }\n\n private static string NewTempSecretsPath()\n {\n var dir = Path.Combine(Path.GetTempPath(), \"aevatar-secrets-test-\" + Guid.NewGuid().ToString(\"N\"));\n Directory.CreateDirectory(dir);\n return Path.Combine(dir, \"secrets.json\");\n }\n\n private static void TryDelete(string path)\n {\n try\n {\n if (File.Exists(path))\n File.Delete(path);\n }\n catch\n {\n // no-op\n }\n }\n}\n"}, "60df5b70463c0a1eea1cc5e4edf2f1e663ab89f5": {"t": 1785756597.9764326, "n": 2878, "h": "6f28586d270d7474", "c": "using Aevatar.Configuration;\nusing FluentAssertions;\n\nnamespace Aevatar.Integration.Tests;\n\npublic class SecretsStoreTests\n{\n [Fact]\n public void Constructor_ShouldLoadPlaintextJson_AndSupportLookupConventions()\n {\n var path = NewTempSecretsPath();\n File.WriteAllText(path, \"\"\"\n {\n \"LLMProviders:Providers:deepseek:ApiKey\": \"k1\",\n \"LLMProviders:openai:ApiKey\": \"k2\",\n \"DEEPSEEK_API_KEY\": \"k3\",\n \"LLMProviders:Default\": \"deepseek\",\n \"Custom:Value\": \"x\"\n }\n \"\"\");\n\n try\n {\n var store = new AevatarSecretsStore(path);\n\n store.Get(\"Custom:Value\").Should().Be(\"x\");\n store.GetApiKey(\"deepseek\").Should().Be(\"k1\");\n store.GetApiKey(\"openai\").Should().Be(\"k2\");\n store.GetApiKey(\"DEEPSEEK\").Should().Be(\"k3\");\n store.GetDefaultProvider().Should().Be(\"deepseek\");\n store.GetAll().Should().ContainKey(\"Custom:Value\");\n }\n finally\n {\n File.Delete(path);\n }\n }\n\n [Fact]\n public void SetAndRemove_ShouldPersistToFile_AsPlaintextWhenNoMasterKey()\n {\n var path = NewTempSecretsPath();\n\n try\n {\n var store = new AevatarSecretsStore(path);\n store.Set(\"K1\", \"V1\");\n store.Set(\"K2\", \"V2\");\n store.Remove(\"K1\");\n\n var reloaded = new AevatarSecretsStore(path);\n reloaded.Get(\"K1\").Should().BeNull();\n reloaded.Get(\"K2\").Should().Be(\"V2\");\n\n var text = File.ReadAllText(path);\n text.Should().Contain(\"\\\"K2\\\"\");\n text.Should().NotContain(\"\\\"K1\\\"\");\n }\n finally\n {\n File.Delete(path);\n TryDelete(path + \".tmp\");\n }\n }\n\n [Fact]\n public void Constructor_WhenInvalidJson_ShouldFallbackToEmptyAndStillWritable()\n {\n var path = NewTempSecretsPath();\n File.WriteAllText(path, \"{ invalid json\");\n\n try\n {\n var store = new AevatarSecretsStore(path);\n store.GetAll().Should().BeEmpty();\n\n store.Set(\"After\", \"Write\");\n\n var reloaded = new AevatarSecretsStore(path);\n reloaded.Get(\"After\").Should().Be(\"Write\");\n }\n finally\n {\n File.Delete(path);\n }\n }\n\n private static string NewTempSecretsPath()\n {\n var dir = Path.Combine(Path.GetTempPath(), \"aevatar-secrets-test-\" + Guid.NewGuid().ToString(\"N\"));\n Directory.CreateDirectory(dir);\n return Path.Combine(dir, \"secrets.json\");\n }\n\n private static void TryDelete(string path)\n {\n try\n {\n if (File.Exists(path))\n File.Delete(path);\n }\n catch\n {\n // no-op\n }\n }\n}\n"}, "bbd906eb503a126c1a4b6a9ff67952cc819ccdd4": {"t": 1786067477.9094183, "n": 6464, "h": "87d40ea1fb8cc386", "c": "using Aevatar.Configuration;\nusing FluentAssertions;\nusing System.Security.Cryptography;\n\nnamespace Aevatar.Integration.Tests;\n\n[Collection(ProcessEnvSerialCollection.Name)]\npublic class SecretsStoreTests\n{\n [Fact]\n public void Constructor_WithPlaintextDevOptIn_ShouldLoadPlaintextJson_AndSupportLookupConventions()\n {\n var path = NewTempSecretsPath();\n File.WriteAllText(path, \"\"\"\n {\n \"LLMProviders:Providers:deepseek:ApiKey\": \"k1\",\n \"LLMProviders:openai:ApiKey\": \"k2\",\n \"DEEPSEEK_API_KEY\": \"k3\",\n \"LLMProviders:Default\": \"deepseek\",\n \"Custom:Value\": \"x\"\n }\n \"\"\");\n\n try\n {\n var store = new AevatarSecretsStore(path, PlaintextAllowedNoLocalMasterKeySources);\n\n store.Get(\"Custom:Value\").Should().Be(\"x\");\n store.GetApiKey(\"deepseek\").Should().Be(\"k1\");\n store.GetApiKey(\"openai\").Should().Be(\"k2\");\n store.GetApiKey(\"DEEPSEEK\").Should().Be(\"k3\");\n store.GetDefaultProvider().Should().Be(\"deepseek\");\n store.GetAll().Should().ContainKey(\"Custom:Value\");\n }\n finally\n {\n File.Delete(path);\n }\n }\n\n [Fact]\n public void Constructor_WhenPlaintextExistsWithoutOptIn_ShouldFailClosed()\n {\n var path = NewTempSecretsPath();\n File.WriteAllText(path, \"\"\"{\"Custom:Value\":\"x\"}\"\"\");\n\n try\n {\n var act = () => new AevatarSecretsStore(path, NoPlaintextNoLocalMasterKeySources);\n\n act.Should().Throw<InvalidOperationException>()\n .WithMessage(\"*plaintext*AEVATAR_ALLOW_PLAINTEXT_SECRETS*\");\n }\n finally\n {\n File.Delete(path);\n }\n }\n\n [Fact]\n public void AevatarSecretsStore_WithInvalidEncryptedEnvelope_ShouldFailClosedWithoutPlaintextOptIn()\n {\n var path = NewTempSecretsPath();\n File.WriteAllText(path, \"\"\"\n {\n \"schemaVersion\": 1,\n \"algorithm\": \"AES-256-GCM\",\n \"nonceB64\": \"invalid\",\n \"tagB64\": \"invalid\",\n \"ciphertextB64\": \"invalid\"\n }\n \"\"\");\n\n try\n {\n using var plaintextScope = new EnvironmentVariableScope(\n LocalSecretProtectionOptions.AllowPlaintextSecretsEnv,\n null);\n\n var act = () => new AevatarSecretsStore(path);\n\n act.Should().Throw<InvalidOperationException>()\n .WithMessage(\"*plaintext*AEVATAR_ALLOW_PLAINTEXT_SECRETS*\");\n }\n finally\n {\n File.Delete(path);\n }\n }\n\n [Fact]\n public void SetAndRemove_WhenEncryptionUnavailableWithoutPlaintextOptIn_ShouldFailClosed()\n {\n var path = NewTempSecretsPath();\n\n try\n {\n WriteSiblingMasterKey(path);\n var store = new AevatarSecretsStore(path, NoPlaintextNoLocalMasterKeySources);\n\n var act = () => store.Set(\"K1\", \"V1\");\n\n act.Should().Throw<InvalidOperationException>()\n .WithMessage(\"*plaintext*AEVATAR_ALLOW_PLAINTEXT_SECRETS*\");\n File.Exists(path).Should().BeFalse();\n }\n finally\n {\n File.Delete(path);\n TryDelete(path + \".tmp\");\n }\n }\n\n [Fact]\n public void SetAndRemove_WithPlaintextDevOptIn_ShouldPersistToFile_AsPlaintextWhenNoMasterKey()\n {\n var path = NewTempSecretsPath();\n\n try\n {\n WriteSiblingMasterKey(path);\n var store = new AevatarSecretsStore(path, PlaintextAllowedNoLocalMasterKeySources);\n store.Set(\"K1\", \"V1\");\n store.Set(\"K2\", \"V2\");\n store.Remove(\"K1\");\n\n var reloaded = new AevatarSecretsStore(path, PlaintextAllowedNoLocalMasterKeySources);\n reloaded.Get(\"K1\").Should().BeNull();\n reloaded.Get(\"K2\").Should().Be(\"V2\");\n\n var text = File.ReadAllText(path);\n text.Should().Contain(\"\\\"K2\\\"\");\n text.Should().NotContain(\"\\\"K1\\\"\");\n }\n finally\n {\n File.Delete(path);\n TryDelete(path + \".tmp\");\n }\n }\n\n [Fact]\n public void Constructor_WhenInvalidJson_ShouldFallbackToEmptyAndStillWritable()\n {\n var path = NewTempSecretsPath();\n File.WriteAllText(path, \"{ invalid json\");\n\n try\n {\n var store = new AevatarSecretsStore(path, PlaintextAllowedNoLocalMasterKeySources);\n store.GetAll().Should().BeEmpty();\n\n store.Set(\"After\", \"Write\");\n\n var reloaded = new AevatarSecretsStore(path, PlaintextAllowedNoLocalMasterKeySources);\n reloaded.Get(\"After\").Should().Be(\"Write\");\n }\n finally\n {\n File.Delete(path);\n }\n }\n\n private static string NewTempSecretsPath()\n {\n var dir = Path.Combine(Path.GetTempPath(), \"aevatar-secrets-test-\" + Guid.NewGuid().ToString(\"N\"));\n Directory.CreateDirectory(dir);\n return Path.Combine(dir, \"secrets.json\");\n }\n\n private static void TryDelete(string path)\n {\n try\n {\n if (File.Exists(path))\n File.Delete(path);\n }\n catch\n {\n // no-op\n }\n }\n\n private static LocalSecretProtectionOptions NoPlaintextNoLocalMasterKeySources =>\n new(false, LocalSecretMasterKeySource.Disabled);\n\n private static LocalSecretProtectionOptions PlaintextAllowedNoLocalMasterKeySources =>\n new(true, LocalSecretMasterKeySource.Disabled);\n\n private static void WriteSiblingMasterKey(string secretsPath)\n {\n var directory = Path.GetDirectoryName(secretsPath);\n Directory.CreateDirectory(directory!);\n File.WriteAllBytes(Path.Combine(directory!, \"masterkey.bin\"), RandomNumberGenerator.GetBytes(32));\n }\n\n private sealed class EnvironmentVariableScope : IDisposable\n {\n private readonly string _name;\n private readonly string? _previous;\n\n public EnvironmentVariableScope(string name, string? value)\n {\n _name = name;\n _previous = Environment.GetEnvironmentVariable(name);\n Environment.SetEnvironmentVariable(name, value);\n }\n\n public void Dispose()\n {\n Environment.SetEnvironmentVariable(_name, _previous);\n }\n }\n}\n"}, "6da4bbe8df605114bb63ccfe6f23383c99a50025": {"t": 1786068324.400783, "n": 6464, "h": "87d40ea1fb8cc386", "c": "using Aevatar.Configuration;\nusing FluentAssertions;\nusing System.Security.Cryptography;\n\nnamespace Aevatar.Integration.Tests;\n\n[Collection(ProcessEnvSerialCollection.Name)]\npublic class SecretsStoreTests\n{\n [Fact]\n public void Constructor_WithPlaintextDevOptIn_ShouldLoadPlaintextJson_AndSupportLookupConventions()\n {\n var path = NewTempSecretsPath();\n File.WriteAllText(path, \"\"\"\n {\n \"LLMProviders:Providers:deepseek:ApiKey\": \"k1\",\n \"LLMProviders:openai:ApiKey\": \"k2\",\n \"DEEPSEEK_API_KEY\": \"k3\",\n \"LLMProviders:Default\": \"deepseek\",\n \"Custom:Value\": \"x\"\n }\n \"\"\");\n\n try\n {\n var store = new AevatarSecretsStore(path, PlaintextAllowedNoLocalMasterKeySources);\n\n store.Get(\"Custom:Value\").Should().Be(\"x\");\n store.GetApiKey(\"deepseek\").Should().Be(\"k1\");\n store.GetApiKey(\"openai\").Should().Be(\"k2\");\n store.GetApiKey(\"DEEPSEEK\").Should().Be(\"k3\");\n store.GetDefaultProvider().Should().Be(\"deepseek\");\n store.GetAll().Should().ContainKey(\"Custom:Value\");\n }\n finally\n {\n File.Delete(path);\n }\n }\n\n [Fact]\n public void Constructor_WhenPlaintextExistsWithoutOptIn_ShouldFailClosed()\n {\n var path = NewTempSecretsPath();\n File.WriteAllText(path, \"\"\"{\"Custom:Value\":\"x\"}\"\"\");\n\n try\n {\n var act = () => new AevatarSecretsStore(path, NoPlaintextNoLocalMasterKeySources);\n\n act.Should().Throw<InvalidOperationException>()\n .WithMessage(\"*plaintext*AEVATAR_ALLOW_PLAINTEXT_SECRETS*\");\n }\n finally\n {\n File.Delete(path);\n }\n }\n\n [Fact]\n public void AevatarSecretsStore_WithInvalidEncryptedEnvelope_ShouldFailClosedWithoutPlaintextOptIn()\n {\n var path = NewTempSecretsPath();\n File.WriteAllText(path, \"\"\"\n {\n \"schemaVersion\": 1,\n \"algorithm\": \"AES-256-GCM\",\n \"nonceB64\": \"invalid\",\n \"tagB64\": \"invalid\",\n \"ciphertextB64\": \"invalid\"\n }\n \"\"\");\n\n try\n {\n using var plaintextScope = new EnvironmentVariableScope(\n LocalSecretProtectionOptions.AllowPlaintextSecretsEnv,\n null);\n\n var act = () => new AevatarSecretsStore(path);\n\n act.Should().Throw<InvalidOperationException>()\n .WithMessage(\"*plaintext*AEVATAR_ALLOW_PLAINTEXT_SECRETS*\");\n }\n finally\n {\n File.Delete(path);\n }\n }\n\n [Fact]\n public void SetAndRemove_WhenEncryptionUnavailableWithoutPlaintextOptIn_ShouldFailClosed()\n {\n var path = NewTempSecretsPath();\n\n try\n {\n WriteSiblingMasterKey(path);\n var store = new AevatarSecretsStore(path, NoPlaintextNoLocalMasterKeySources);\n\n var act = () => store.Set(\"K1\", \"V1\");\n\n act.Should().Throw<InvalidOperationException>()\n .WithMessage(\"*plaintext*AEVATAR_ALLOW_PLAINTEXT_SECRETS*\");\n File.Exists(path).Should().BeFalse();\n }\n finally\n {\n File.Delete(path);\n TryDelete(path + \".tmp\");\n }\n }\n\n [Fact]\n public void SetAndRemove_WithPlaintextDevOptIn_ShouldPersistToFile_AsPlaintextWhenNoMasterKey()\n {\n var path = NewTempSecretsPath();\n\n try\n {\n WriteSiblingMasterKey(path);\n var store = new AevatarSecretsStore(path, PlaintextAllowedNoLocalMasterKeySources);\n store.Set(\"K1\", \"V1\");\n store.Set(\"K2\", \"V2\");\n store.Remove(\"K1\");\n\n var reloaded = new AevatarSecretsStore(path, PlaintextAllowedNoLocalMasterKeySources);\n reloaded.Get(\"K1\").Should().BeNull();\n reloaded.Get(\"K2\").Should().Be(\"V2\");\n\n var text = File.ReadAllText(path);\n text.Should().Contain(\"\\\"K2\\\"\");\n text.Should().NotContain(\"\\\"K1\\\"\");\n }\n finally\n {\n File.Delete(path);\n TryDelete(path + \".tmp\");\n }\n }\n\n [Fact]\n public void Constructor_WhenInvalidJson_ShouldFallbackToEmptyAndStillWritable()\n {\n var path = NewTempSecretsPath();\n File.WriteAllText(path, \"{ invalid json\");\n\n try\n {\n var store = new AevatarSecretsStore(path, PlaintextAllowedNoLocalMasterKeySources);\n store.GetAll().Should().BeEmpty();\n\n store.Set(\"After\", \"Write\");\n\n var reloaded = new AevatarSecretsStore(path, PlaintextAllowedNoLocalMasterKeySources);\n reloaded.Get(\"After\").Should().Be(\"Write\");\n }\n finally\n {\n File.Delete(path);\n }\n }\n\n private static string NewTempSecretsPath()\n {\n var dir = Path.Combine(Path.GetTempPath(), \"aevatar-secrets-test-\" + Guid.NewGuid().ToString(\"N\"));\n Directory.CreateDirectory(dir);\n return Path.Combine(dir, \"secrets.json\");\n }\n\n private static void TryDelete(string path)\n {\n try\n {\n if (File.Exists(path))\n File.Delete(path);\n }\n catch\n {\n // no-op\n }\n }\n\n private static LocalSecretProtectionOptions NoPlaintextNoLocalMasterKeySources =>\n new(false, LocalSecretMasterKeySource.Disabled);\n\n private static LocalSecretProtectionOptions PlaintextAllowedNoLocalMasterKeySources =>\n new(true, LocalSecretMasterKeySource.Disabled);\n\n private static void WriteSiblingMasterKey(string secretsPath)\n {\n var directory = Path.GetDirectoryName(secretsPath);\n Directory.CreateDirectory(directory!);\n File.WriteAllBytes(Path.Combine(directory!, \"masterkey.bin\"), RandomNumberGenerator.GetBytes(32));\n }\n\n private sealed class EnvironmentVariableScope : IDisposable\n {\n private readonly string _name;\n private readonly string? _previous;\n\n public EnvironmentVariableScope(string name, string? value)\n {\n _name = name;\n _previous = Environment.GetEnvironmentVariable(name);\n Environment.SetEnvironmentVariable(name, value);\n }\n\n public void Dispose()\n {\n Environment.SetEnvironmentVariable(_name, _previous);\n }\n }\n}\n"}}, "src/Aevatar.AI.LLMProviders.MEAI/ServiceCollectionExtensions.cs": {"bbd906eb503a126c1a4b6a9ff67952cc819ccdd4": {"t": 1786067631.2799132, "n": 1455, "h": "ef22a06b1423fe89", "c": "// ─────────────────────────────────────────────────────────────\n// ServiceCollectionExtensions — MEAI DI 注册\n// ─────────────────────────────────────────────────────────────\n\nusing Aevatar.AI.Abstractions.LLMProviders;\nusing Microsoft.Extensions.DependencyInjection;\nusing System.Linq;\n\nnamespace Aevatar.AI.LLMProviders.MEAI;\n\n/// <summary>MEAI LLM Provider 的 DI 注册扩展。</summary>\npublic static class ServiceCollectionExtensions\n{\n /// <summary>\n /// 注册 MEAI LLM Provider Factory 并配置 providers。\n /// </summary>\n /// <example>\n /// services.AddMEAIProviders(factory => factory\n /// .RegisterOpenAI(\"openai\", \"gpt-5.4\", openaiKey)\n /// .RegisterOpenAI(\"deepseek\", \"deepseek-chat\", deepseekKey,\n /// baseUrl: \"https://api.deepseek.com/v1\")\n /// .SetDefault(\"deepseek\"));\n /// </example>\n public static IServiceCollection AddMEAIProviders(\n this IServiceCollection services,\n Action<IMEAILLMProviderRegistry> configure)\n {\n if (services.Any(x => x.ServiceType == typeof(ILLMProviderFactory)))\n {\n throw new InvalidOperationException(\n \"ILLMProviderFactory is already registered. Multiple factory implementations are not supported in the same IServiceCollection.\");\n }\n\n var factory = new MEAILLMProviderFactory();\n configure(factory);\n services.AddSingleton<ILLMProviderFactory>(factory);\n return services;\n }\n}\n"}}}}