DocHUB 的 AI 功能(摘要、翻譯字幕、校稿)是讓你用自己的 AI 帳號。這一頁白話說明金鑰存在哪、會連到哪裡、什麼不會被送出,以及你可以怎麼自己確認。
%AppData%\DocHUB\settings.json)只記「有沒有打開、選哪家、哪個模型」,沒有金鑰。網址寫死在程式裡,不接受別的網址、不跟隨轉址。金鑰只放在 HTTPS 請求的標頭,不放在網址上(網址常被公司的代理伺服器記錄)。
| 你選的服務 | 只會連到 |
|---|---|
| Google Gemini | https://generativelanguage.googleapis.com |
| OpenAI | https://api.openai.com |
| Anthropic Claude | https://api.anthropic.com |
| 本機 AI(Ollama) | http://localhost(只在你的電腦裡,不上網) |
DocHUB 本身會連的其他網址(和 AI 無關):檢查更新與下載(本站 www.home-999.com)、第一次使用某些功能時下載工具或模型(例如 huggingface.co、github.com)。這些連線都不會帶金鑰;匿名安裝報到只送安裝編號、版本、Windows 版本與安裝方式。
resmon)→「網路」,按 AI 按鈕時只會看到 DocHUB 連到上面表格裡你選的那個網址。這是 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);
}
}