1 line
2.4 MiB
Plaintext
1 line
2.4 MiB
Plaintext
{"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"}}, "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"}}, "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"}}, "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"}}, "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"}}}} |