新增 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 适配器则按本手册挂载。


下一步

在 GitHub 上编辑此页