feat: 将插件 stdin 协议定为 v1,并按声明注入凭据
进程外插件改为只读环境变量和自身配置,避免读宿主 appsettings;宿主按 plugin.json 声明注入 LLM / OpenWeather 等凭据。
This commit is contained in:
@@ -12,6 +12,10 @@ public sealed class PluginManifest
|
||||
public string Name { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public string Version { get; set; } = "1.0.0";
|
||||
|
||||
/// <summary>stdin/stdout 协议主版本。缺省或 0 视为 1。宿主拒绝大于 <see cref="PluginProtocol.Current"/> 的值。</summary>
|
||||
public int ProtocolVersion { get; set; }
|
||||
|
||||
public PluginLaunch Launch { get; set; } = new();
|
||||
public int TimeoutSeconds { get; set; }
|
||||
public Dictionary<string, string> Env { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -45,9 +49,51 @@ public sealed class PluginCredentialNeed
|
||||
public string Description { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>stdin/stdout JSON 协议版本。缺省字段按 1 处理,便于旧 plugin.json 继续用。</summary>
|
||||
public static class PluginProtocol
|
||||
{
|
||||
public const int Current = 1;
|
||||
|
||||
public static int Normalize(int version) => version <= 0 ? 1 : version;
|
||||
|
||||
public static bool IsSupported(int version)
|
||||
{
|
||||
int normalized = Normalize(version);
|
||||
return normalized >= 1 && normalized <= Current;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>凭据 type 约定。宿主按类型注入环境变量,未知类型只传 stdin Extra。</summary>
|
||||
public static class PluginCredentialTypes
|
||||
{
|
||||
public const string OpenAiCompatible = "openai-compatible";
|
||||
public const string Llm = "llm";
|
||||
public const string OpenWeather = "openweather";
|
||||
|
||||
public static bool IsLlm(string? type)
|
||||
=> type is OpenAiCompatible or Llm
|
||||
|| string.Equals(type, "openai-compatible", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(type, "llm", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public static bool IsOpenWeather(string? type)
|
||||
=> string.Equals(type, OpenWeather, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public static bool Matches(string? declaredType, string? recordType)
|
||||
{
|
||||
if (IsLlm(declaredType) && IsLlm(recordType))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return string.Equals(declaredType, recordType, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>宿主写入插件 stdin 的整包请求:业务输入 + 凭据。</summary>
|
||||
public sealed class PluginRequest
|
||||
{
|
||||
public int ProtocolVersion { get; set; } = PluginProtocol.Current;
|
||||
|
||||
public Dictionary<string, object?> Inputs { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
[JsonPropertyName("credentials")]
|
||||
|
||||
@@ -33,53 +33,55 @@ public static class PluginStdio
|
||||
await Console.Out.FlushAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>把宿主注入的 LLM 凭据写进环境变量,这样 AgentFactory.Load 能读到 Key。</summary>
|
||||
public static void ApplyCredentialsToEnvironment(IReadOnlyDictionary<string, PluginCredentialPayload> credentials)
|
||||
/// <summary>
|
||||
/// 把凭据写进环境变量。不传 target 时改当前进程(插件自己);宿主传入 ProcessStartInfo.Environment。
|
||||
/// </summary>
|
||||
public static void ApplyCredentialsToEnvironment(
|
||||
IReadOnlyDictionary<string, PluginCredentialPayload> credentials,
|
||||
IDictionary<string, string?>? target = null)
|
||||
{
|
||||
foreach (KeyValuePair<string, PluginCredentialPayload> pair in credentials)
|
||||
{
|
||||
PluginCredentialPayload cred = pair.Value;
|
||||
if (cred.Type is "openai-compatible" or "llm" || pair.Key.Equals("llm", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
SetIfNotEmpty("OPENAI_ENDPOINT", cred.Endpoint);
|
||||
SetIfNotEmpty("OPENAI_API_KEY", cred.ApiKey);
|
||||
SetIfNotEmpty("OPENAI_CHAT_MODEL", cred.Model);
|
||||
}
|
||||
ApplyOne(pair.Key, pair.Value, target);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (KeyValuePair<string, string> extra in cred.Extra)
|
||||
private static void ApplyOne(string name, PluginCredentialPayload cred, IDictionary<string, string?>? target)
|
||||
{
|
||||
if (PluginCredentialTypes.IsLlm(cred.Type) || name.Equals("llm", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
SetIfNotEmpty(target, "OPENAI_ENDPOINT", cred.Endpoint);
|
||||
SetIfNotEmpty(target, "OPENAI_API_KEY", cred.ApiKey);
|
||||
SetIfNotEmpty(target, "OPENAI_CHAT_MODEL", cred.Model);
|
||||
}
|
||||
|
||||
if (PluginCredentialTypes.IsOpenWeather(cred.Type) || name.Equals("weather", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
SetIfNotEmpty(target, "OPENWEATHER_API_KEY", cred.ApiKey);
|
||||
}
|
||||
|
||||
foreach (KeyValuePair<string, string> extra in cred.Extra)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(extra.Value))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(extra.Value))
|
||||
{
|
||||
Environment.SetEnvironmentVariable(extra.Key, extra.Value);
|
||||
}
|
||||
SetIfNotEmpty(target, extra.Key, extra.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 插件自己读天气等配置时用。优先 MAF1_CONTENT_ROOT(宿主 exe 目录),否则向上找 appsettings.json。
|
||||
/// 只读插件自己目录的 appsettings.json(工作目录或 dll 旁),再加上环境变量。不要去读宿主 exe 目录。
|
||||
/// </summary>
|
||||
public static IConfiguration LoadHostConfiguration()
|
||||
public static IConfiguration LoadPluginConfiguration()
|
||||
{
|
||||
string? contentRoot = Environment.GetEnvironmentVariable("MAF1_CONTENT_ROOT");
|
||||
if (string.IsNullOrWhiteSpace(contentRoot) || !Directory.Exists(contentRoot))
|
||||
string basePath = Directory.GetCurrentDirectory();
|
||||
if (!File.Exists(Path.Combine(basePath, "appsettings.json")))
|
||||
{
|
||||
contentRoot = AppContext.BaseDirectory;
|
||||
DirectoryInfo? parent = Directory.GetParent(contentRoot.TrimEnd(Path.DirectorySeparatorChar));
|
||||
if (parent?.Name.Equals("plugins", StringComparison.OrdinalIgnoreCase) == false
|
||||
&& parent?.Parent is not null
|
||||
&& File.Exists(Path.Combine(parent.Parent.FullName, "appsettings.json")))
|
||||
{
|
||||
contentRoot = parent.Parent.FullName;
|
||||
}
|
||||
else if (parent is not null && File.Exists(Path.Combine(parent.FullName, "appsettings.json")))
|
||||
{
|
||||
contentRoot = parent.FullName;
|
||||
}
|
||||
basePath = AppContext.BaseDirectory;
|
||||
}
|
||||
|
||||
return new ConfigurationBuilder()
|
||||
.SetBasePath(contentRoot)
|
||||
.SetBasePath(basePath)
|
||||
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false)
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
@@ -143,11 +145,19 @@ public static class PluginStdio
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static void SetIfNotEmpty(string name, string? value)
|
||||
private static void SetIfNotEmpty(IDictionary<string, string?>? target, string name, string? value)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
Environment.SetEnvironmentVariable(name, value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (target is not null)
|
||||
{
|
||||
target[name] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
Environment.SetEnvironmentVariable(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user