DocHUB

DocHUB 的 AI 金鑰怎麼處理

DocHUB 的 AI 功能(摘要、翻譯字幕、校稿)是讓你用自己的 AI 帳號。這一頁白話說明金鑰存在哪、會連到哪裡、什麼不會被送出,以及你可以怎麼自己確認。

In English: DocHUB's AI features are optional and off by default. Your API key is stored only in Windows Credential Manager on your PC, is sent only in the HTTPS header to the official address of the AI service you chose, and never goes to DocHUB. You see exactly what will be sent before anything goes out, and a local connection log records where and how much was sent (never the key or content). The source code that stores keys and sends requests is published at the bottom of this page.
一句話:金鑰只存在你的電腦;DocHUB 不收、不傳、不經手。你不用相信我們——下面每一點都可以自己檢查,而且建議你替 DocHUB 建一把有花費上限的專用金鑰,萬一外流,最多損失你自己設的上限。

1. 金鑰存在哪裡

照實說限制:Windows 的加密擋得了別的 Windows 帳號、擋得了「把檔案複製到別台電腦」,但擋不了在你帳號裡執行的惡意程式。這是所有存密碼的軟體共同的限制(瀏覽器存的密碼也一樣),所以請一定要設花費上限。

2. 會連到哪裡

網址寫死在程式裡,不接受別的網址、不跟隨轉址。金鑰只放在 HTTPS 請求的標頭,不放在網址上(網址常被公司的代理伺服器記錄)。

你選的服務只會連到
Google Geminihttps://generativelanguage.googleapis.com
OpenAIhttps://api.openai.com
Anthropic Claudehttps://api.anthropic.com
本機 AI(Ollama)http://localhost(只在你的電腦裡,不上網)

DocHUB 本身會連的其他網址(和 AI 無關):檢查更新與下載(本站 www.home-999.com)、第一次使用某些功能時下載工具或模型(例如 huggingface.co、github.com)。這些連線都不會帶金鑰;匿名安裝報到只送安裝編號、版本、Windows 版本與安裝方式。

3. 什麼會送出、什麼不會

4. 你可以怎麼自己確認

  1. 看認證管理員:控制台 → 認證管理員 → Windows 認證,看得到 DocHUB 存了哪幾把,也可以直接刪。
  2. 看 AI 連線紀錄:DocHUB「設定 → AI → AI 連線紀錄」,每次送出的時間、功能、網址、字數、用量都有,不記金鑰和內容。
  3. 看網路連線:開 Windows 內建的「資源監視器」(開始 → 輸入 resmon)→「網路」,按 AI 按鈕時只會看到 DocHUB 連到上面表格裡你選的那個網址。
  4. 核對安裝檔:只從 www.home-999.com/dochub 下載;下載頁有每個檔案的 SHA-256 可以核對。
  5. 看原始碼:本頁最下面是 DocHUB 裡負責存金鑰、送請求的那個檔案的完整原始碼。

5. 保護自己的建議

為什麼要這麼小心?2025~2026 年確實發生過假冒的免費 AI 外掛,在使用者填金鑰時偷偷傳走。所以 DocHUB 的做法是:讓你不用相信我們也能自己確認,再加上花費上限,讓最壞情況的損失由你自己控制。

6. 原始碼:存金鑰、送請求(AiCore.cs)

這是 DocHUB v1.21.2 裡負責 AI 金鑰與 AI 連線的完整程式碼(C#)。懂程式的人可以看到:網址寫死、不跟隨轉址、金鑰只放標頭、錯誤訊息會遮掉金鑰、沒有任何送到 DocHUB 的程式。
說明:公開原始碼沒辦法「證明」你下載的安裝檔一定是用這份程式碼編出來的,只是讓檢查多一個依據;上面第 4 點的方法不用懂程式也能確認。

// ═══════════════════════════════════════════════════════════════════════════════
//  DocHUB AI 核心:存金鑰、送請求。
//
//  所有碰到 AI 金鑰和 AI 網路連線的程式都在這一個檔案裡,原始碼公開在
//  https://www.home-999.com/dochub/ai-keys.html ,任何人都可以檢查:
//
//   1. 金鑰只存在這台電腦的「Windows 認證管理員」(Windows 用 DPAPI 加密),
//      名稱是「DocHUB AI - 服務名稱」;DocHUB 自己的設定檔裡沒有金鑰。
//   2. 只連到下面 Providers 表裡寫死的官方網址(本機 AI 只能連 localhost),
//      不接受別的網址、不跟隨轉址。
//   3. 金鑰只放在 HTTPS 請求標頭,不放在網址上;錯誤訊息裡的金鑰一律遮掉。
//   4. 這個檔案不會把金鑰或內容送到 DocHUB 的網站或任何其他地方。
// ═══════════════════════════════════════════════════════════════════════════════
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;

namespace EmailValidator.App.Services.Ai;

public enum AiProvider { Gemini, OpenAI, Claude, Ollama }

/// <summary>一家 AI 服務:官方網址(寫死)與給使用者看的連結。</summary>
public sealed record AiProviderInfo(
    AiProvider Id, string Name, string Host, bool NeedsKey, string KeyPrefix,
    string KeyUrl, string LimitUrl, string RevokeUrl, string PolicyUrl, string PricingUrl);

public enum AiErrorKind { BadKey, NoCredit, RateLimited, Network, Refused, Redirected, Server, NotAllowed, Empty }

/// <summary>AI 連線失敗。訊息已經遮掉金鑰,可以直接顯示。</summary>
public sealed class AiException : Exception
{
    public AiErrorKind Kind { get; }
    public int Status { get; }
    public AiException(AiErrorKind kind, string message, int status = 0) : base(message) { Kind = kind; Status = status; }
}

/// <summary>一次送出的結果:AI 寫的字,和 AI 自己回報的用量(token 數;不知道就是 0)。</summary>
public sealed record AiReply(string Text, int InputTokens, int OutputTokens);

public static class AiProviders
{
    public const int OllamaDefaultPort = 11434;

    public static readonly IReadOnlyList<AiProviderInfo> All = new[]
    {
        new AiProviderInfo(AiProvider.Gemini, "Google Gemini", "generativelanguage.googleapis.com", true, "AIza / AQ.",
            "https://aistudio.google.com/apikey", "https://console.cloud.google.com/apis/credentials",
            "https://aistudio.google.com/apikey", "https://ai.google.dev/gemini-api/terms", "https://ai.google.dev/gemini-api/docs/pricing"),
        new AiProviderInfo(AiProvider.OpenAI, "OpenAI", "api.openai.com", true, "sk-",
            "https://platform.openai.com/api-keys", "https://platform.openai.com/settings/organization/limits",
            "https://platform.openai.com/api-keys", "https://openai.com/policies/api-data-usage-policies", "https://openai.com/api/pricing/"),
        new AiProviderInfo(AiProvider.Claude, "Anthropic Claude", "api.anthropic.com", true, "sk-ant-",
            "https://console.anthropic.com/settings/keys", "https://console.anthropic.com/settings/limits",
            "https://console.anthropic.com/settings/keys", "https://www.anthropic.com/legal/commercial-terms", "https://www.anthropic.com/pricing#api"),
        new AiProviderInfo(AiProvider.Ollama, "Ollama", "localhost", false, "",
            "https://ollama.com/download", "", "", "https://ollama.com", ""),
    };

    public static AiProviderInfo Info(AiProvider p) => All.First(x => x.Id == p);
}

// ───────────────────────────────────────────────────────────────────────────────
//  金鑰保管:Windows 認證管理員
// ───────────────────────────────────────────────────────────────────────────────

/// <summary>存放金鑰的地方(正式用 Windows 認證管理員;測試可以換成記憶體版)。</summary>
public interface IAiKeyVault
{
    void Save(string name, string secret);
    string? Read(string name);
    bool Delete(string name);
    IReadOnlyList<string> List(string prefix);
}

/// <summary>
/// Windows 認證管理員(控制台 → 認證管理員 → Windows 認證 → 一般認證)。
/// 存成「只在這台電腦」(不跟漫遊設定檔走);內容由 Windows 用 DPAPI 加密。
/// </summary>
public sealed class WindowsCredentialVault : IAiKeyVault
{
    private const int CRED_TYPE_GENERIC = 1;
    private const int CRED_PERSIST_LOCAL_MACHINE = 2;

    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
    private struct CREDENTIAL
    {
        public int Flags;
        public int Type;
        public string TargetName;
        public string? Comment;
        public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
        public int CredentialBlobSize;
        public IntPtr CredentialBlob;
        public int Persist;
        public int AttributeCount;
        public IntPtr Attributes;
        public string? TargetAlias;
        public string? UserName;
    }

    [DllImport("advapi32.dll", EntryPoint = "CredWriteW", CharSet = CharSet.Unicode, SetLastError = true)]
    private static extern bool CredWrite(ref CREDENTIAL credential, int flags);
    [DllImport("advapi32.dll", EntryPoint = "CredReadW", CharSet = CharSet.Unicode, SetLastError = true)]
    private static extern bool CredRead(string target, int type, int flags, out IntPtr credential);
    [DllImport("advapi32.dll", EntryPoint = "CredDeleteW", CharSet = CharSet.Unicode, SetLastError = true)]
    private static extern bool CredDelete(string target, int type, int flags);
    [DllImport("advapi32.dll", EntryPoint = "CredEnumerateW", CharSet = CharSet.Unicode, SetLastError = true)]
    private static extern bool CredEnumerate(string filter, int flags, out int count, out IntPtr credentials);
    [DllImport("advapi32.dll")]
    private static extern void CredFree(IntPtr buffer);

    public void Save(string name, string secret)
    {
        byte[] blob = Encoding.UTF8.GetBytes(secret);
        IntPtr p = Marshal.AllocHGlobal(blob.Length);
        try
        {
            Marshal.Copy(blob, 0, p, blob.Length);
            var c = new CREDENTIAL
            {
                Type = CRED_TYPE_GENERIC, TargetName = name, UserName = "DocHUB", Comment = "DocHUB AI",
                CredentialBlob = p, CredentialBlobSize = blob.Length, Persist = CRED_PERSIST_LOCAL_MACHINE,
            };
            if (!CredWrite(ref c, 0)) throw new AiException(AiErrorKind.NotAllowed, "Windows 認證管理員無法存放金鑰(錯誤碼 " + Marshal.GetLastWin32Error() + ")");
        }
        finally
        {
            Array.Clear(blob);
            for (int i = 0; i < blob.Length; i++) Marshal.WriteByte(p, i, 0);
            Marshal.FreeHGlobal(p);
        }
    }

    public string? Read(string name)
    {
        if (!CredRead(name, CRED_TYPE_GENERIC, 0, out IntPtr p)) return null;
        try
        {
            var c = Marshal.PtrToStructure<CREDENTIAL>(p);
            if (c.CredentialBlobSize <= 0) return null;
            var blob = new byte[c.CredentialBlobSize];
            Marshal.Copy(c.CredentialBlob, blob, 0, blob.Length);
            string s = Encoding.UTF8.GetString(blob);
            Array.Clear(blob);
            return s;
        }
        finally { CredFree(p); }
    }

    public bool Delete(string name) => CredDelete(name, CRED_TYPE_GENERIC, 0);

    public IReadOnlyList<string> List(string prefix)
    {
        var names = new List<string>();
        if (!CredEnumerate(prefix + "*", 0, out int n, out IntPtr arr)) return names;
        try
        {
            for (int i = 0; i < n; i++)
            {
                var c = Marshal.PtrToStructure<CREDENTIAL>(Marshal.ReadIntPtr(arr, i * IntPtr.Size));
                if (c.Type == CRED_TYPE_GENERIC && c.TargetName.StartsWith(prefix, StringComparison.Ordinal)) names.Add(c.TargetName);
            }
        }
        finally { CredFree(arr); }
        return names;
    }
}

/// <summary>每家 AI 一筆金鑰,名稱「DocHUB AI - Google Gemini」。</summary>
public sealed class AiKeyStore
{
    public const string DefaultPrefix = "DocHUB AI - ";
    private readonly IAiKeyVault _vault;
    private readonly string _prefix;

    public static AiKeyStore Default { get; set; } = new(new WindowsCredentialVault());

    public AiKeyStore(IAiKeyVault vault, string prefix = DefaultPrefix) { _vault = vault; _prefix = prefix; }

    public string NameOf(AiProvider p) => _prefix + AiProviders.Info(p).Name;
    public void Save(AiProvider p, string key) => _vault.Save(NameOf(p), key.Trim());
    public bool Has(AiProvider p) => !string.IsNullOrEmpty(_vault.Read(NameOf(p)));
    public bool Delete(AiProvider p) => _vault.Delete(NameOf(p));

    /// <summary>金鑰末 4 碼(畫面上只顯示這個)。</summary>
    public string? Last4(AiProvider p) => _vault.Read(NameOf(p)) is { Length: >= 4 } k ? k[^4..] : null;

    /// <summary>刪掉 DocHUB 存的所有 AI 金鑰(「全部刪除」、解除安裝時用)。</summary>
    public int DeleteAll()
    {
        int n = 0;
        foreach (var name in _vault.List(_prefix)) if (_vault.Delete(name)) n++;
        return n;
    }

    /// <summary>只給 <see cref="AiClient"/> 在送出的那一刻用。</summary>
    internal string? ReadForSending(AiProvider p) => _vault.Read(NameOf(p));
}

// ───────────────────────────────────────────────────────────────────────────────
//  遮掉金鑰:錯誤訊息、紀錄裡絕對不能出現金鑰
// ───────────────────────────────────────────────────────────────────────────────

public static class AiRedact
{
    // Google 的金鑰有 AIza… 和較新的 AQ.… 兩種;OpenAI sk-…;Anthropic sk-ant-…
    private static readonly Regex KeyLike = new(@"(AIza[0-9A-Za-z_\-]{10,}|AQ\.[0-9A-Za-z_\-]{20,}|sk-ant-[0-9A-Za-z_\-]{6,}|sk-[0-9A-Za-z_\-]{10,})", RegexOptions.Compiled);

    public static string Clean(string? text, string? key = null)
    {
        if (string.IsNullOrEmpty(text)) return "";
        if (!string.IsNullOrEmpty(key) && key.Length >= 4) text = text.Replace(key, "…" + key[^4..]);
        return KeyLike.Replace(text, m => "…" + m.Value[^4..]);
    }
}

// ───────────────────────────────────────────────────────────────────────────────
//  連線:只連官方網址、不跟隨轉址、金鑰放標頭
// ───────────────────────────────────────────────────────────────────────────────

public sealed class AiClient
{
    private readonly HttpMessageHandler _handler;
    private readonly AiKeyStore _keys;
    private readonly HttpClient _http;
    private static readonly JsonSerializerOptions JsonOut = new() { Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping };

    /// <summary>本機 AI(Ollama)的連接埠;只能連 localhost。</summary>
    public int OllamaPort { get; set; } = AiProviders.OllamaDefaultPort;

    public AiClient(AiKeyStore? keys = null, HttpMessageHandler? handler = null)
    {
        _keys = keys ?? AiKeyStore.Default;
        _handler = handler ?? new SocketsHttpHandler
        {
            AllowAutoRedirect = false,                  // 不跟隨轉址:不會被導到別的網址
            UseProxy = true,                            // 跟瀏覽器一樣用 Windows 的代理設定(公司網路)
            AutomaticDecompression = DecompressionMethods.All,
            ConnectTimeout = TimeSpan.FromSeconds(20),
        };
        _http = new HttpClient(_handler, disposeHandler: handler is null) { Timeout = TimeSpan.FromMinutes(10) };
    }

    public Uri BaseUri(AiProvider p) => p switch
    {
        AiProvider.Gemini => new Uri("https://generativelanguage.googleapis.com/"),
        AiProvider.OpenAI => new Uri("https://api.openai.com/"),
        AiProvider.Claude => new Uri("https://api.anthropic.com/"),
        AiProvider.Ollama => new Uri($"http://localhost:{Math.Clamp(OllamaPort, 1, 65535)}/"),
        _ => throw new ArgumentOutOfRangeException(nameof(p)),
    };

    /// <summary>網址檢查:只能是那家寫死的官方主機(本機 AI 只能 localhost/127.0.0.1)。</summary>
    public static bool IsAllowed(AiProvider p, Uri uri) => p == AiProvider.Ollama
        ? uri.Scheme == Uri.UriSchemeHttp && (uri.Host == "localhost" || uri.Host == "127.0.0.1")
        : uri.Scheme == Uri.UriSchemeHttps && uri.Host == AiProviders.Info(p).Host && uri.IsDefaultPort;

    /// <summary>測試金鑰:問這把金鑰能用哪些模型(只送金鑰,不送任何內容)。<paramref name="key"/> 為 null 時用已存的。</summary>
    public async Task<List<string>> ListModelsAsync(AiProvider p, string? key, CancellationToken ct)
    {
        key ??= _keys.ReadForSending(p);
        using var req = new HttpRequestMessage(HttpMethod.Get, p switch
        {
            AiProvider.Gemini => "v1beta/models?pageSize=1000",
            AiProvider.OpenAI => "v1/models",
            AiProvider.Claude => "v1/models?limit=1000",
            _ => "api/tags",
        });
        using var resp = await SendAsync(p, req, key, HttpCompletionOption.ResponseContentRead, ct).ConfigureAwait(false);
        var root = JsonNode.Parse(await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false));
        var models = new List<string>();
        switch (p)
        {
            case AiProvider.Gemini:
                foreach (var m in root?["models"]?.AsArray() ?? new JsonArray())
                {
                    string name = (string?)m?["name"] ?? "";
                    bool gen = m?["supportedGenerationMethods"]?.AsArray().Any(x => (string?)x == "generateContent") == true;
                    if (gen && name.StartsWith("models/gemini", StringComparison.Ordinal)) models.Add(name["models/".Length..]);
                }
                break;
            case AiProvider.OpenAI:
                foreach (var m in root?["data"]?.AsArray() ?? new JsonArray())
                    if ((string?)m?["id"] is { } id && IsOpenAiChatModel(id)) models.Add(id);
                break;
            case AiProvider.Claude:
                foreach (var m in root?["data"]?.AsArray() ?? new JsonArray())
                    if ((string?)m?["id"] is { } id) models.Add(id);
                break;
            default:
                foreach (var m in root?["models"]?.AsArray() ?? new JsonArray())
                    if ((string?)m?["name"] is { } id) models.Add(id);
                break;
        }
        return models.Distinct().OrderByDescending(x => x, StringComparer.Ordinal).ToList();
    }

    private static bool IsOpenAiChatModel(string id) =>
        (id.StartsWith("gpt-", StringComparison.Ordinal) || Regex.IsMatch(id, @"^o\d")) &&
        !Regex.IsMatch(id, "audio|realtime|tts|transcribe|image|search|embedding|instruct|codex|moderation|computer");

    /// <summary>幫使用者先挑一個建議的模型(便宜又夠用;Claude 照規劃預設 Opus)。</summary>
    public static string? SuggestModel(AiProvider p, IReadOnlyList<string> models)
    {
        if (models.Count == 0) return null;
        bool Stable(string m) => !Regex.IsMatch(m, "preview|exp|latest|-\\d{4}-\\d{2}-\\d{2}|\\d{8}$|tts|image|live|thinking|lite");
        string? pick = p switch
        {
            // Gemini:先挑 flash-lite(快、免費額度多、整理摘要和翻譯夠用;2026-11 實測 flash 常常「需求太高」回 503),再來才是 flash。
            // 名稱帶 omni/robotics/nano 等的是特殊模型,免費額度常常是 0,不要挑。
            AiProvider.Gemini => models.FirstOrDefault(m => m == "gemini-flash-lite-latest")
                ?? models.Where(m => Regex.IsMatch(m, @"^gemini-\d+(\.\d+)?-flash-lite$")).OrderByDescending(VersionOf).FirstOrDefault()
                ?? models.FirstOrDefault(m => m == "gemini-flash-latest")
                ?? models.Where(m => Regex.IsMatch(m, @"^gemini-\d+(\.\d+)?-flash$")).OrderByDescending(VersionOf).FirstOrDefault()
                ?? models.FirstOrDefault(m => m.Contains("flash") && Stable(m) && !Regex.IsMatch(m, "omni|robotics|nano|argon")),
            AiProvider.OpenAI => models.FirstOrDefault(m => m.StartsWith("gpt-") && m.EndsWith("-mini") && Stable(m)) ?? models.FirstOrDefault(m => m.Contains("mini")),
            AiProvider.Claude => models.FirstOrDefault(m => m.StartsWith("claude-opus-5-5")) ?? models.FirstOrDefault(m => m.Contains("opus")),
            _ => null,
        };
        return pick ?? models[0];
    }

    /// <summary>「gemini-3.10-flash」→ 3.10(版本比大小,不能用字串排序)。</summary>
    private static Version VersionOf(string model) =>
        Regex.Match(model, @"\d+(\.\d+)?") is { Success: true } m && Version.TryParse(m.Value.Contains('.') ? m.Value : m.Value + ".0", out var v) ? v : new Version(0, 0);

    /// <summary>
    /// 送出一段文字、邊收邊回報(<paramref name="onText"/> 每收到一小段就呼叫一次)。
    /// <paramref name="system"/> 是整理要求,<paramref name="user"/> 是使用者的內容(逐字稿等)。
    /// </summary>
    public async Task<AiReply> GenerateAsync(AiProvider p, string model, string system, string user, Action<string>? onText, CancellationToken ct)
    {
        string? key = p == AiProvider.Ollama ? null : _keys.ReadForSending(p);
        if (p != AiProvider.Ollama && string.IsNullOrEmpty(key)) throw new AiException(AiErrorKind.BadKey, "還沒有設定這家 AI 的金鑰");
        using var req = new HttpRequestMessage(HttpMethod.Post, p switch
        {
            AiProvider.Gemini => $"v1beta/models/{Uri.EscapeDataString(model)}:streamGenerateContent?alt=sse",
            AiProvider.OpenAI => "v1/chat/completions",
            AiProvider.Claude => "v1/messages",
            _ => "api/chat",
        });
        JsonObject body = p switch
        {
            AiProvider.Gemini => new JsonObject
            {
                ["systemInstruction"] = new JsonObject { ["parts"] = new JsonArray(new JsonObject { ["text"] = system }) },
                ["contents"] = new JsonArray(new JsonObject { ["role"] = "user", ["parts"] = new JsonArray(new JsonObject { ["text"] = user }) }),
            },
            AiProvider.OpenAI => new JsonObject
            {
                ["model"] = model, ["stream"] = true,
                ["stream_options"] = new JsonObject { ["include_usage"] = true },
                ["messages"] = new JsonArray(
                    new JsonObject { ["role"] = "system", ["content"] = system },
                    new JsonObject { ["role"] = "user", ["content"] = user }),
            },
            AiProvider.Claude => new JsonObject
            {
                ["model"] = model, ["stream"] = true, ["max_tokens"] = 16000, ["system"] = system,
                ["messages"] = new JsonArray(new JsonObject { ["role"] = "user", ["content"] = user }),
            },
            _ => new JsonObject
            {
                ["model"] = model, ["stream"] = true,
                ["options"] = new JsonObject { ["num_ctx"] = 16384 },
                ["messages"] = new JsonArray(
                    new JsonObject { ["role"] = "system", ["content"] = system },
                    new JsonObject { ["role"] = "user", ["content"] = user }),
            },
        };
        req.Content = new StringContent(body.ToJsonString(JsonOut), Encoding.UTF8, "application/json");
        using var resp = await SendAsync(p, req, key, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);

        var text = new StringBuilder();
        int inTok = 0, outTok = 0;
        string? stop = null;
        using var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
        using var reader = new StreamReader(stream, Encoding.UTF8);
        string? line;
        while ((line = await reader.ReadLineAsync(ct).ConfigureAwait(false)) != null)
        {
            string data;
            if (p == AiProvider.Ollama) data = line.Trim();                       // 一行一個 JSON
            else if (line.StartsWith("data:", StringComparison.Ordinal)) data = line[5..].Trim();   // SSE
            else continue;
            if (data.Length == 0 || data == "[DONE]") continue;
            JsonNode? j;
            try { j = JsonNode.Parse(data); } catch (JsonException) { continue; }
            if (j is null) continue;
            string? piece = null;
            switch (p)
            {
                case AiProvider.Gemini:
                    var cand = j["candidates"]?[0];
                    piece = string.Concat(cand?["content"]?["parts"]?.AsArray().Select(x => (string?)x?["text"] ?? "") ?? Array.Empty<string>());
                    stop = (string?)cand?["finishReason"] ?? (string?)j["promptFeedback"]?["blockReason"] ?? stop;
                    if (j["usageMetadata"] is { } u) { inTok = (int?)u["promptTokenCount"] ?? inTok; outTok = (int?)u["candidatesTokenCount"] ?? outTok; }
                    break;
                case AiProvider.OpenAI:
                    var ch = j["choices"]?.AsArray().FirstOrDefault();
                    piece = (string?)ch?["delta"]?["content"];
                    stop = (string?)ch?["finish_reason"] ?? stop;
                    if (j["usage"] is JsonObject ou) { inTok = (int?)ou["prompt_tokens"] ?? inTok; outTok = (int?)ou["completion_tokens"] ?? outTok; }
                    break;
                case AiProvider.Claude:
                    switch ((string?)j["type"])
                    {
                        case "content_block_delta": piece = (string?)j["delta"]?["text"]; break;
                        case "message_start": inTok = (int?)j["message"]?["usage"]?["input_tokens"] ?? inTok; break;
                        case "message_delta":
                            outTok = (int?)j["usage"]?["output_tokens"] ?? outTok;
                            stop = (string?)j["delta"]?["stop_reason"] ?? stop;
                            break;
                        case "error": throw Classify(p, 500, j.ToJsonString(), key);
                    }
                    break;
                default:
                    piece = (string?)j["message"]?["content"];
                    if ((bool?)j["done"] == true) { inTok = (int?)j["prompt_eval_count"] ?? 0; outTok = (int?)j["eval_count"] ?? 0; }
                    break;
            }
            if (!string.IsNullOrEmpty(piece)) { text.Append(piece); onText?.Invoke(piece); }
        }
        if (stop is "SAFETY" or "PROHIBITED_CONTENT" or "BLOCKLIST" or "content_filter" or "refusal" or "OTHER" && text.Length == 0)
            throw new AiException(AiErrorKind.Refused, "AI 拒絕處理這段內容(" + stop + ")");
        if (text.Length == 0) throw new AiException(AiErrorKind.Empty, "AI 沒有回傳任何內容");
        return new AiReply(text.ToString(), inTok, outTok);
    }

    /// <summary>真正送出的地方:檢查網址、放金鑰標頭、拒絕轉址、把錯誤分類(訊息遮掉金鑰)。</summary>
    private async Task<HttpResponseMessage> SendAsync(AiProvider p, HttpRequestMessage req, string? key, HttpCompletionOption mode, CancellationToken ct)
    {
        req.RequestUri = new Uri(BaseUri(p), req.RequestUri!);
        if (!IsAllowed(p, req.RequestUri)) throw new AiException(AiErrorKind.NotAllowed, "網址不在允許清單:" + req.RequestUri.Host);
        if (p != AiProvider.Ollama && string.IsNullOrEmpty(key)) throw new AiException(AiErrorKind.BadKey, "還沒有設定這家 AI 的金鑰");
        switch (p)
        {
            case AiProvider.Gemini: req.Headers.Add("x-goog-api-key", key); break;
            case AiProvider.OpenAI: req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", key); break;
            case AiProvider.Claude: req.Headers.Add("x-api-key", key); req.Headers.Add("anthropic-version", "2023-06-01"); break;
        }
        req.Headers.UserAgent.ParseAdd("DocHUB");
        HttpResponseMessage resp;
        try { resp = await _http.SendAsync(req, mode, ct).ConfigureAwait(false); }
        catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
        catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException)
        {
            throw new AiException(AiErrorKind.Network, "連不上 " + req.RequestUri.Host + ":" + AiRedact.Clean(ex.Message, key));
        }
        int code = (int)resp.StatusCode;
        if (code is >= 300 and < 400)
        {
            resp.Dispose();
            throw new AiException(AiErrorKind.Redirected, "AI 服務回應異常(要求轉到別的網址),已停止", code);
        }
        if (resp.RequestMessage?.RequestUri is { } final && !IsAllowed(p, final))
        {
            resp.Dispose();
            throw new AiException(AiErrorKind.Redirected, "AI 服務回應異常(網址不符),已停止", code);
        }
        if (resp.IsSuccessStatusCode) return resp;
        string detail = "";
        try { detail = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); } catch { }
        resp.Dispose();
        throw Classify(p, code, detail, key);
    }

    /// <summary>把各家的錯誤分成幾類(金鑰錯、沒額度、次數限制…),訊息遮掉金鑰。</summary>
    public static AiException Classify(AiProvider p, int code, string body, string? key)
    {
        string msg = "", type = "", reason = "";
        try
        {
            var root = JsonNode.Parse(body);
            if (root is JsonArray arr) root = arr.FirstOrDefault();          // Gemini 串流的錯誤是 [ { "error": … } ]
            var e = root?["error"];
            msg = e?["message"]?.ToString() ?? "";
            // code 有的是字串(OpenAI)、有的是數字(Gemini),一律轉成文字
            type = (e?["code"]?.ToString() ?? "") + " " + (e?["type"]?.ToString() ?? "") + " " + (e?["status"]?.ToString() ?? "");
            reason = string.Join(" ", e?["details"] is JsonArray ds ? ds.Select(d => (d?["reason"]?.ToString() ?? "") + " " + (d?["violations"]?.ToJsonString() ?? "")) : Array.Empty<string>());
        }
        catch { msg = body; }
        if (msg.Length == 0 && type.Trim().Length == 0) msg = body;
        msg = AiRedact.Clean(msg, key);
        if (msg.Length > 300) msg = msg[..300] + "…";
        string all = (type + " " + reason + " " + msg).ToLowerInvariant();
        AiErrorKind kind =
            all.Contains("api_key_invalid") || all.Contains("invalid_api_key") || all.Contains("authentication_error") || all.Contains("api key not valid") || code == 401 ? AiErrorKind.BadKey
            : all.Contains("insufficient_quota") || all.Contains("credit balance") ? AiErrorKind.NoCredit
            : code == 429 || all.Contains("resource_exhausted") || all.Contains("rate_limit") ? AiErrorKind.RateLimited   // Gemini 免費額度用完也是這個
            : all.Contains("billing") ? AiErrorKind.NoCredit
            : code == 403 ? AiErrorKind.NotAllowed
            : AiErrorKind.Server;
        if (kind == AiErrorKind.Server && code == 400 && p == AiProvider.Gemini && all.Contains("api key")) kind = AiErrorKind.BadKey;
        return new AiException(kind, msg.Length > 0 ? msg : "HTTP " + code, code);
    }
}
DocHUB・李適秋 開發・回下載頁・使用條款與免責聲明