Files
admin777 b631acd42e feat: 将插件 stdin 协议定为 v1,并按声明注入凭据
进程外插件改为只读环境变量和自身配置,避免读宿主 appsettings;宿主按 plugin.json 声明注入 LLM / OpenWeather 等凭据。
2026-09-11 10:46:52 +08:00

152 lines
5.7 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.ClientModel;
using Azure.AI.OpenAI;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
using OpenAI;
using OpenAI.Chat;
namespace MAF1.Utils;
/// <summary>
/// 创建聊天 Agent 的工厂。根据 Endpoint 判断走 Azure OpenAI 还是 OpenAI 兼容接口(DeepSeek 等)。
/// 密钥优先级:appsettings Llm → 环境变量。浏览器永远拿不到 Key。
/// </summary>
public sealed class AgentFactory
{
private readonly ChatClient _chatClient;
private readonly bool _disableDeepSeekThinking;
public AgentFactory(LlmOptions options)
{
if (string.IsNullOrWhiteSpace(options.ApiKey))
{
throw new InvalidOperationException(
"""
还没有配置模型。请在 appsettings.json Llm 节点,或环境变量中设置:
OPENAI_API_KEY / Llm:ApiKey
OPENAI_ENDPOINT / Llm:Endpoint
OPENAI_CHAT_MODEL / Llm:Model
""");
}
string model = string.IsNullOrWhiteSpace(options.Model)
? DefaultModelFor(options.Endpoint)
: options.Model;
_disableDeepSeekThinking = LooksLikeDeepSeek(options.Endpoint);
if (LooksLikeAzureOpenAI(options.Endpoint))
{
Console.Error.WriteLine($"使用 Azure OpenAI: {options.Endpoint} 部署: {model}");
_chatClient = new AzureOpenAIClient(new Uri(options.Endpoint), new ApiKeyCredential(options.ApiKey))
.GetChatClient(model);
return;
}
OpenAIClientOptions clientOptions = new();
if (!string.IsNullOrWhiteSpace(options.Endpoint))
{
clientOptions.Endpoint = ToOpenAICompatibleEndpoint(options.Endpoint);
}
Console.Error.WriteLine($"使用 OpenAI 兼容接口: {clientOptions.Endpoint?.ToString() ?? "https://api.openai.com/v1"} 模型: {model}");
if (_disableDeepSeekThinking)
{
Console.Error.WriteLine("已关闭 DeepSeek thinking,避免 Handoff 多轮丢掉 reasoning_content 导致 HTTP 400。");
}
_chatClient = new OpenAIClient(new ApiKeyCredential(options.ApiKey), clientOptions)
.GetChatClient(model);
}
/// <summary>把 ChatClient 包成 Microsoft.Agents.AI 的 AIAgent,并挂上 tools。Id 固定为 nameHandoff 工具才是 handoff_to_n1。</summary>
public AIAgent CreateAgent(string name, string instructions, IList<AITool>? tools = null, string? description = null)
{
ChatOptions chatOptions = new()
{
Instructions = instructions,
Tools = tools,
};
ChatClientAgentOptions options = new()
{
Id = name,
Name = name,
Description = description,
ChatOptions = chatOptions,
};
return _chatClient.AsAIAgent(options, clientFactory: inner =>
_disableDeepSeekThinking
? new ConfigureOptionsChatClient(inner, DeepSeekThinking.Disable)
: inner);
}
/// <summary>合并配置文件和环境变量。插件进程里环境变量通常由宿主写入。</summary>
public static LlmOptions Load(IConfiguration config)
{
LlmOptions options = config.GetSection("Llm").Get<LlmOptions>() ?? new LlmOptions();
options.ApiKey = FirstNonEmpty(options.ApiKey, Env("OPENAI_API_KEY"), Env("AZURE_OPENAI_API_KEY"));
options.Endpoint = FirstNonEmpty(options.Endpoint, Env("OPENAI_ENDPOINT"), Env("OPENAI_BASE_URL"), Env("AZURE_OPENAI_ENDPOINT"));
options.Model = FirstNonEmpty(options.Model, Env("OPENAI_CHAT_MODEL"), Env("AZURE_OPENAI_DEPLOYMENT_NAME"));
return options;
}
/// <summary>只读环境变量。进程外插件应优先用这个,不要去读宿主 appsettings.json。</summary>
public static LlmOptions LoadFromEnvironment()
=> Load(new ConfigurationBuilder().AddEnvironmentVariables().Build());
private static string DefaultModelFor(string? endpoint)
{
if (!string.IsNullOrWhiteSpace(endpoint) && endpoint.Contains("deepseek", StringComparison.OrdinalIgnoreCase))
{
return "deepseek-chat";
}
return "gpt-4o-mini";
}
private static bool LooksLikeDeepSeek(string? endpoint)
=> !string.IsNullOrWhiteSpace(endpoint)
&& endpoint.Contains("deepseek", StringComparison.OrdinalIgnoreCase);
private static bool LooksLikeAzureOpenAI(string? endpoint)
{
if (string.IsNullOrWhiteSpace(endpoint) || !Uri.TryCreate(endpoint, UriKind.Absolute, out Uri? uri))
{
return false;
}
string host = uri.Host;
return host.Contains("openai.azure.com", StringComparison.OrdinalIgnoreCase)
|| host.Contains("cognitiveservices.azure.com", StringComparison.OrdinalIgnoreCase)
|| host.Contains("services.ai.azure.com", StringComparison.OrdinalIgnoreCase);
}
/// <summary>OpenAI 兼容 API 要求 base URL 以 /v1 结尾,用户常只填到域名。</summary>
private static Uri ToOpenAICompatibleEndpoint(string endpoint)
{
string trimmed = endpoint.TrimEnd('/');
if (!trimmed.EndsWith("/v1", StringComparison.OrdinalIgnoreCase))
{
trimmed += "/v1";
}
return new Uri(trimmed);
}
private static string? Env(string name) => Environment.GetEnvironmentVariable(name);
private static string FirstNonEmpty(params string?[] values)
{
foreach (string? value in values)
{
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
}
return "";
}
}