新增 LLM 适配器手册
Tether 的模型抽象直接对齐 .NET 官方标准 Microsoft.Extensions.AI.IChatClient。接入新的大模型服务商只需实现该接口,并将其包装为 Cordis 插件即可。
核心接口
using Microsoft.Extensions.AI;
public interface IChatClient : IDisposable
{
IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default);
Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default);
object? GetService(Type serviceType, object? serviceKey = null);
}
编写自定义 Provider 插件
以下演示如何实现一个支持流式传输的自定义模型客户端插件:
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Runtime.CompilerServices;
using System.Text.Json;
using Cordis;
using Microsoft.Extensions.AI;
using Tether.Llm;
namespace MyPlugins.Llm;
public sealed class CustomLlmConfig
{
public string BaseUrl { get; set; } = "https://api.myllm.com/v1";
public string ApiKeyEnv { get; set; } = "MYLLM_API_KEY";
public string DefaultModel { get; set; } = "my-fast-model";
}
public sealed class CustomLlmPlugin : Plugin
{
private readonly CustomLlmConfig? _config;
public CustomLlmPlugin() { }
// 树外插件包约定:声明了 configType 的插件要么实现 IConfigurablePlugin.AcceptConfig,
// 要么提供一个恰好接收 config 类型的单参构造(PluginBootload.CreatePluginInstance),
// YAML config 经绑定与 DataAnnotations 校验后才传入。
public CustomLlmPlugin(CustomLlmConfig config) => _config = config;
protected override void Apply(Context context)
{
var config = _config ?? new CustomLlmConfig();
var apiKey = Environment.GetEnvironmentVariable(config.ApiKeyEnv)
?? throw new InvalidOperationException($"缺少环境变量 {config.ApiKeyEnv}");
var httpClient = new HttpClient { BaseAddress = new Uri(config.BaseUrl) };
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
var client = new CustomChatClient(httpClient, "custom", config.DefaultModel);
// 向上下文提供 ILlmClient(继承自 IChatClient);Provide 已把移除挂在当前 scope
context.Provide<ILlmClient>(client);
context.OnDispose(client.Dispose); // 插件卸载时释放 HttpClient 与 Client 资源
}
}
实现 SSE 流式解析
大模型交互以流式响应为主。以下展示标准 SSE(Server-Sent Events)处理框架:
internal sealed class CustomChatClient(HttpClient http, string providerId, string defaultModel) : ILlmClient
{
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var requestBody = new
{
model = options?.ModelId ?? defaultModel,
messages = messages.Select(m => new { role = m.Role.Value, content = m.Text }),
stream = true
};
using var request = new HttpRequestMessage(HttpMethod.Post, "chat/completions")
{
Content = JsonContent.Create(requestBody)
};
using var response = await http.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
response.EnsureSuccessStatusCode();
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var reader = new StreamReader(stream);
while (!reader.EndOfStream && !cancellationToken.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data: ")) continue;
if (line == "data: [DONE]") break;
var json = line["data: ".Length..];
using var doc = JsonDocument.Parse(json);
var delta = doc.RootElement
.GetProperty("choices")[0]
.GetProperty("delta");
if (delta.TryGetProperty("content", out var contentProp)
&& contentProp.GetString() is { } text)
{
yield return new ChatResponseUpdate(ChatRole.Assistant, text);
}
}
}
public async Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
// 可基于流式聚合非流式结果
var updates = GetStreamingResponseAsync(messages, options, cancellationToken);
var sb = new System.Text.StringBuilder();
await foreach (var update in updates)
{
sb.Append(update.Text);
}
return new ChatResponse(new ChatMessage(ChatRole.Assistant, sb.ToString()));
}
// Agent 的 llm/stream terminal 会从这里取 ChatClientMetadata 校验:
// ProviderName 必须与请求被路由到的 provider id 完全相等,取不到或不等即拒绝调用。
public object? GetService(Type serviceType, object? serviceKey = null)
=> serviceType == typeof(ChatClientMetadata)
? new ChatClientMetadata(providerId, null, defaultModel)
: serviceType.IsInstanceOfType(this) ? this : null;
public void Dispose() => http.Dispose();
}
挂载到应用
组合行按 name 引用插件。树外插件包经 manifest 注册 catalog name,准入要求 name
必须以 packageId + "." 开头(例如 packageId 为 myplugins 时写 myplugins.custom-llm):
plugins:
- id: custom-llm
name: myplugins.custom-llm # packageId 为 myplugins 的包贡献的 catalog name
config:
apiKeyEnv: MY_CUSTOM_KEY
defaultModel: custom-gpt-4o
何时不需要自己写 adapter
标准 OpenAI 流量(kind: openai、无 baseUrl/自定义 headers/compat)自 #470 起默认由官方 Microsoft.Extensions.AI.OpenAI adapter 服务(MeaiOpenAiBridge),无需自定义插件。只有当你需要接入非 OpenAI 形状端点、自定请求头或 compat 行为时才要手写 wire/IChatClient——网关形 kind: openai profile 会自动回落到通用 ChatCompletionsWire,自定义 wire 适配器则按本手册挂载。