mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 16:41:30 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7af635021d | ||
|
|
13c0324cad |
No files matched your search
@@ -61,6 +61,15 @@ export const enAiMessages: Messages = {
|
||||
'ai.providers.contextWindow.help': 'Leave blank to use the model list value when available, otherwise Netcatty uses a safe default.',
|
||||
'ai.providers.contextWindow.error': 'Enter a positive whole number, or leave it blank.',
|
||||
'ai.providers.refreshModels': 'Refresh models',
|
||||
'ai.providers.test': 'Test',
|
||||
'ai.providers.test.testing': 'Testing…',
|
||||
'ai.providers.test.ok': 'Connected ({latency} ms)',
|
||||
'ai.providers.test.warn': 'Reached endpoint, but response looks incomplete ({latency} ms)',
|
||||
'ai.providers.test.warnSlow': 'Connected, but slow ({latency} ms)',
|
||||
'ai.providers.test.error': 'Failed ({detail})',
|
||||
'ai.providers.test.missingBaseUrl': 'Enter a Base URL first',
|
||||
'ai.providers.test.missingApiKey': 'Enter an API key first',
|
||||
'ai.providers.test.unavailable': 'Connection test is unavailable in this environment',
|
||||
'ai.providers.searchModel': 'Search or type model ID...',
|
||||
'ai.providers.filterModels': 'Filter models...',
|
||||
'ai.providers.loadingModels': 'Loading models...',
|
||||
|
||||
@@ -42,6 +42,15 @@ export const ruAiMessages: Messages = {
|
||||
'ai.providers.contextWindow.help': 'Оставьте пустым, чтобы использовать значение из списка моделей, если оно доступно; иначе Netcatty применит безопасное значение по умолчанию.',
|
||||
'ai.providers.contextWindow.error': 'Введите положительное целое число или оставьте поле пустым.',
|
||||
'ai.providers.refreshModels': 'Обновить модели',
|
||||
'ai.providers.test': 'Проверить',
|
||||
'ai.providers.test.testing': 'Проверка…',
|
||||
'ai.providers.test.ok': 'Подключено ({latency} ms)',
|
||||
'ai.providers.test.warn': 'Эндпоинт доступен, но ответ выглядит неполным ({latency} ms)',
|
||||
'ai.providers.test.warnSlow': 'Подключено, но медленно ({latency} ms)',
|
||||
'ai.providers.test.error': 'Ошибка ({detail})',
|
||||
'ai.providers.test.missingBaseUrl': 'Сначала укажите Base URL',
|
||||
'ai.providers.test.missingApiKey': 'Сначала укажите API-ключ',
|
||||
'ai.providers.test.unavailable': 'Проверка соединения недоступна в этой среде',
|
||||
'ai.providers.searchModel': 'Искать или ввести ID модели...',
|
||||
'ai.providers.filterModels': 'Фильтровать модели...',
|
||||
'ai.providers.loadingModels': 'Загрузка моделей...',
|
||||
|
||||
@@ -61,6 +61,15 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.providers.contextWindow.help': '留空时优先使用模型列表返回的值;如果没有,Netcatty 会使用安全默认值。',
|
||||
'ai.providers.contextWindow.error': '请输入正整数,或留空。',
|
||||
'ai.providers.refreshModels': '刷新模型列表',
|
||||
'ai.providers.test': '检测',
|
||||
'ai.providers.test.testing': '检测中…',
|
||||
'ai.providers.test.ok': '连接正常({latency} ms)',
|
||||
'ai.providers.test.warn': '已连通,但响应不完整({latency} ms)',
|
||||
'ai.providers.test.warnSlow': '已连通,但较慢({latency} ms)',
|
||||
'ai.providers.test.error': '检测失败({detail})',
|
||||
'ai.providers.test.missingBaseUrl': '请先填写 Base URL',
|
||||
'ai.providers.test.missingApiKey': '请先填写 API Key',
|
||||
'ai.providers.test.unavailable': '当前环境无法进行连接检测',
|
||||
'ai.providers.searchModel': '搜索或输入模型 ID...',
|
||||
'ai.providers.filterModels': '筛选模型...',
|
||||
'ai.providers.loadingModels': '加载模型中...',
|
||||
|
||||
@@ -61,6 +61,15 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.providers.contextWindow.help': '留空時優先使用模型列表傳回的值;如果沒有,Netcatty 會使用安全預設值。',
|
||||
'ai.providers.contextWindow.error': '請輸入正整數,或留空。',
|
||||
'ai.providers.refreshModels': '重新整理模型列表',
|
||||
'ai.providers.test': '檢測',
|
||||
'ai.providers.test.testing': '檢測中…',
|
||||
'ai.providers.test.ok': '連線正常({latency} ms)',
|
||||
'ai.providers.test.warn': '已連通,但回應不完整({latency} ms)',
|
||||
'ai.providers.test.warnSlow': '已連通,但較慢({latency} ms)',
|
||||
'ai.providers.test.error': '檢測失敗({detail})',
|
||||
'ai.providers.test.missingBaseUrl': '請先填寫 Base URL',
|
||||
'ai.providers.test.missingApiKey': '請先填寫 API Key',
|
||||
'ai.providers.test.unavailable': '目前環境無法進行連線檢測',
|
||||
'ai.providers.searchModel': '搜尋或輸入模型 ID...',
|
||||
'ai.providers.filterModels': '篩選模型...',
|
||||
'ai.providers.loadingModels': '載入模型中...',
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Pencil, Upload, RotateCcw, X } from "lucide-react";
|
||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Pencil, Upload, RotateCcw, X, RefreshCw } from "lucide-react";
|
||||
import type { ProviderConfig, ProviderAdvancedParams, ProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { PROVIDER_PRESETS, resolveProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { sanitizeContextWindow } from "../../../../infrastructure/ai/contextCompaction";
|
||||
import {
|
||||
probeProviderConnection,
|
||||
validateProviderProbeInputs,
|
||||
type ProviderProbeHealth,
|
||||
} from "../../../../infrastructure/ai/providerConnectionProbe";
|
||||
import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { BuiltinProviderIcon } from "./types";
|
||||
import { BUILTIN_PROVIDER_ICONS } from "./types";
|
||||
import { BUILTIN_PROVIDER_ICONS, getFetchBridge } from "./types";
|
||||
import type { ProviderFormState } from "./types";
|
||||
import { ModelSelector } from "./ModelSelector";
|
||||
import { mergeModelContextWindow } from "./modelMetadata";
|
||||
@@ -77,6 +82,12 @@ export const ProviderConfigForm: React.FC<{
|
||||
const [iconError, setIconError] = useState<string | null>(null);
|
||||
const [contextWindowError, setContextWindowError] = useState<string | null>(null);
|
||||
const [apiKeySourceVersion, setApiKeySourceVersion] = useState(0);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [probeResult, setProbeResult] = useState<{
|
||||
health: ProviderProbeHealth;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const probeRequestIdRef = useRef(0);
|
||||
|
||||
const preset = PROVIDER_PRESETS[provider.providerId];
|
||||
const resolvedStyle: ProviderStyle = form.style || resolveProviderStyle({ providerId: provider.providerId });
|
||||
@@ -96,7 +107,22 @@ export const ProviderConfigForm: React.FC<{
|
||||
preset?.modelsEndpoint,
|
||||
resolvedStyle,
|
||||
]);
|
||||
const probeFingerprint = useMemo(() => JSON.stringify({
|
||||
baseURL: form.baseURL || preset?.defaultBaseURL || "",
|
||||
apiKey: form.apiKey,
|
||||
style: resolvedStyle,
|
||||
skipTLSVerify: form.skipTLSVerify,
|
||||
modelsEndpoint: preset?.modelsEndpoint ?? "",
|
||||
}), [
|
||||
form.apiKey,
|
||||
form.baseURL,
|
||||
form.skipTLSVerify,
|
||||
preset?.defaultBaseURL,
|
||||
preset?.modelsEndpoint,
|
||||
resolvedStyle,
|
||||
]);
|
||||
const modelMetadataSourceKeyRef = useRef<string | null>(null);
|
||||
const probeFingerprintRef = useRef<string | null>(null);
|
||||
const previewProvider: Pick<ProviderConfig, "providerId" | "name" | "iconId" | "iconDataUrl"> = {
|
||||
providerId: provider.providerId,
|
||||
name: form.name,
|
||||
@@ -133,6 +159,19 @@ export const ProviderConfigForm: React.FC<{
|
||||
: prev);
|
||||
}, [modelMetadataSourceKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (probeFingerprintRef.current == null) {
|
||||
probeFingerprintRef.current = probeFingerprint;
|
||||
return;
|
||||
}
|
||||
if (probeFingerprintRef.current === probeFingerprint) return;
|
||||
|
||||
probeFingerprintRef.current = probeFingerprint;
|
||||
probeRequestIdRef.current += 1;
|
||||
setProbeResult(null);
|
||||
setIsTesting(false);
|
||||
}, [probeFingerprint]);
|
||||
|
||||
const [advancedParamRaw, setAdvancedParamRaw] = useState<Record<string, string>>({});
|
||||
const handleAdvancedParam = useCallback((key: keyof ProviderAdvancedParams, raw: string) => {
|
||||
setAdvancedParamRaw((prev) => ({ ...prev, [key]: raw }));
|
||||
@@ -180,6 +219,90 @@ export const ProviderConfigForm: React.FC<{
|
||||
setForm((prev) => ({ ...prev, apiKey: value }));
|
||||
}, []);
|
||||
|
||||
const handleTestConnection = useCallback(async () => {
|
||||
const baseURL = form.baseURL || preset?.defaultBaseURL || "";
|
||||
const inputCheck = validateProviderProbeInputs({
|
||||
baseURL,
|
||||
apiKey: form.apiKey,
|
||||
providerId: provider.providerId,
|
||||
});
|
||||
if (!inputCheck.ok) {
|
||||
probeRequestIdRef.current += 1;
|
||||
setIsTesting(false);
|
||||
setProbeResult({
|
||||
health: "error",
|
||||
message: t(
|
||||
inputCheck.reason === "missing_base_url"
|
||||
? "ai.providers.test.missingBaseUrl"
|
||||
: "ai.providers.test.missingApiKey",
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = ++probeRequestIdRef.current;
|
||||
setIsTesting(true);
|
||||
setProbeResult(null);
|
||||
try {
|
||||
const run = await probeProviderConnection({
|
||||
bridge: getFetchBridge(),
|
||||
baseURL,
|
||||
apiKey: form.apiKey,
|
||||
providerId: provider.providerId,
|
||||
style: resolvedStyle,
|
||||
presetModelsEndpoint: preset?.modelsEndpoint,
|
||||
skipTLSVerify: form.skipTLSVerify,
|
||||
});
|
||||
if (probeRequestIdRef.current !== requestId) return;
|
||||
if (!run.ok) {
|
||||
setProbeResult({
|
||||
health: "error",
|
||||
message: t(
|
||||
run.reason === "missing_base_url"
|
||||
? "ai.providers.test.missingBaseUrl"
|
||||
: run.reason === "missing_api_key"
|
||||
? "ai.providers.test.missingApiKey"
|
||||
: "ai.providers.test.unavailable",
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const classified = run.classification;
|
||||
const latency = String(classified.latencyMs);
|
||||
if (classified.health === "ok") {
|
||||
setProbeResult({
|
||||
health: "ok",
|
||||
message: t("ai.providers.test.ok", { latency }),
|
||||
});
|
||||
} else if (classified.health === "warn") {
|
||||
const warnKey = classified.modelCount === 0 || classified.error
|
||||
? "ai.providers.test.warn"
|
||||
: "ai.providers.test.warnSlow";
|
||||
setProbeResult({
|
||||
health: "warn",
|
||||
message: t(warnKey, { latency }),
|
||||
});
|
||||
} else {
|
||||
const detail = classified.error
|
||||
|| (classified.statusCode ? `HTTP ${classified.statusCode}` : "error");
|
||||
setProbeResult({
|
||||
health: "error",
|
||||
message: t("ai.providers.test.error", { detail }),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (probeRequestIdRef.current !== requestId) return;
|
||||
setProbeResult({
|
||||
health: "error",
|
||||
message: t("ai.providers.test.error", {
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
if (probeRequestIdRef.current === requestId) setIsTesting(false);
|
||||
}
|
||||
}, [form.apiKey, form.baseURL, form.skipTLSVerify, preset?.defaultBaseURL, preset?.modelsEndpoint, provider.providerId, resolvedStyle, t]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const cleanedParams: ProviderAdvancedParams = {};
|
||||
const ap = form.advancedParams;
|
||||
@@ -534,14 +657,40 @@ export const ProviderConfigForm: React.FC<{
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button variant="default" size="sm" onClick={() => void handleSave()}>
|
||||
<Check size={14} className="mr-1.5" />
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="default" size="sm" onClick={() => void handleSave()}>
|
||||
<Check size={14} className="mr-1.5" />
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void handleTestConnection()}
|
||||
disabled={isTesting || isDecrypting}
|
||||
>
|
||||
<RefreshCw size={14} className={cn("mr-1.5", isTesting && "animate-spin")} />
|
||||
{isTesting ? t('ai.providers.test.testing') : t('ai.providers.test')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
{(isTesting || probeResult) && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-[11px]",
|
||||
isTesting && "text-muted-foreground",
|
||||
probeResult?.health === "ok" && "text-emerald-500",
|
||||
probeResult?.health === "warn" && "text-amber-500",
|
||||
probeResult?.health === "error" && "text-destructive",
|
||||
)}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{isTesting ? t('ai.providers.test.testing') : probeResult?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -135,7 +135,7 @@ export interface FetchedModel {
|
||||
}
|
||||
|
||||
export interface FetchBridge {
|
||||
aiFetch?: (url: string, method?: string, headers?: Record<string, string>, body?: string, providerId?: string, skipHostCheck?: boolean, followRedirects?: boolean, skipTLSVerify?: boolean) => Promise<{ ok: boolean; data: string; error?: string }>;
|
||||
aiFetch?: (url: string, method?: string, headers?: Record<string, string>, body?: string, providerId?: string, skipHostCheck?: boolean, followRedirects?: boolean, skipTLSVerify?: boolean) => Promise<{ ok: boolean; status?: number; data: string; error?: string }>;
|
||||
aiAllowlistAddHost?: (baseURL: string) => Promise<{ ok: boolean }>;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
buildProviderProbeUrl,
|
||||
classifyProviderProbeResponse,
|
||||
probeProviderConnection,
|
||||
resolveProviderProbeEndpoint,
|
||||
validateProviderProbeInputs,
|
||||
} from "./providerConnectionProbe";
|
||||
|
||||
test("resolveProviderProbeEndpoint follows style conventions including Google /models", () => {
|
||||
assert.equal(resolveProviderProbeEndpoint("openai"), "/models");
|
||||
assert.equal(resolveProviderProbeEndpoint("anthropic"), "/v1/models");
|
||||
assert.equal(resolveProviderProbeEndpoint("google"), "/models");
|
||||
assert.equal(resolveProviderProbeEndpoint("openai", "/custom"), "/models");
|
||||
assert.equal(resolveProviderProbeEndpoint("google", "/custom/list"), "/models");
|
||||
});
|
||||
|
||||
test("buildProviderProbeUrl joins base URL and endpoint without duplicate slashes", () => {
|
||||
assert.equal(
|
||||
buildProviderProbeUrl("https://api.deepseek.com/v1/", "/models"),
|
||||
"https://api.deepseek.com/v1/models",
|
||||
);
|
||||
assert.equal(
|
||||
buildProviderProbeUrl("https://api.anthropic.com", "/v1/models"),
|
||||
"https://api.anthropic.com/v1/models",
|
||||
);
|
||||
});
|
||||
|
||||
test("validateProviderProbeInputs requires base URL and API key except for ollama", () => {
|
||||
assert.deepEqual(
|
||||
validateProviderProbeInputs({ baseURL: "", apiKey: "sk", providerId: "openai" }),
|
||||
{ ok: false, reason: "missing_base_url" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
validateProviderProbeInputs({ baseURL: "https://api.openai.com/v1", apiKey: "", providerId: "openai" }),
|
||||
{ ok: false, reason: "missing_api_key" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
validateProviderProbeInputs({ baseURL: "http://localhost:11434/v1", apiKey: "", providerId: "ollama" }),
|
||||
{ ok: true },
|
||||
);
|
||||
});
|
||||
|
||||
test("classifyProviderProbeResponse marks auth and transport failures as error", () => {
|
||||
assert.equal(
|
||||
classifyProviderProbeResponse({ ok: false, status: 401, latencyMs: 120, error: "Unauthorized" }).health,
|
||||
"error",
|
||||
);
|
||||
assert.equal(
|
||||
classifyProviderProbeResponse({ ok: false, status: 0, latencyMs: 30, error: "Request timeout" }).health,
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
test("classifyProviderProbeResponse marks 2xx with models payload as ok", () => {
|
||||
const result = classifyProviderProbeResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
latencyMs: 180,
|
||||
data: JSON.stringify({ data: [{ id: "deepseek-chat" }] }),
|
||||
});
|
||||
assert.equal(result.health, "ok");
|
||||
assert.equal(result.latencyMs, 180);
|
||||
assert.equal(result.statusCode, 200);
|
||||
assert.equal(result.modelCount, 1);
|
||||
});
|
||||
|
||||
test("classifyProviderProbeResponse marks slow or empty success as warn", () => {
|
||||
assert.equal(
|
||||
classifyProviderProbeResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
latencyMs: 4500,
|
||||
data: JSON.stringify({ data: [{ id: "m" }] }),
|
||||
slowThresholdMs: 3000,
|
||||
}).health,
|
||||
"warn",
|
||||
);
|
||||
assert.equal(
|
||||
classifyProviderProbeResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
latencyMs: 100,
|
||||
data: JSON.stringify({ data: [] }),
|
||||
}).health,
|
||||
"warn",
|
||||
);
|
||||
});
|
||||
|
||||
test("classifyProviderProbeResponse accepts Google ListModels name-only entries", () => {
|
||||
const result = classifyProviderProbeResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
latencyMs: 220,
|
||||
data: JSON.stringify({ models: [{ name: "models/gemini-2.0-flash" }] }),
|
||||
});
|
||||
assert.equal(result.health, "ok");
|
||||
assert.equal(result.modelCount, 1);
|
||||
});
|
||||
|
||||
test("probeProviderConnection orchestrates allowlist, headers, fetch, and classification", async () => {
|
||||
const calls: Array<{
|
||||
url: string;
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
skipTLSVerify?: boolean;
|
||||
}> = [];
|
||||
const allowlist: string[] = [];
|
||||
let clock = 1_000;
|
||||
const run = await probeProviderConnection({
|
||||
bridge: {
|
||||
aiAllowlistAddHost: async (baseURL) => {
|
||||
allowlist.push(baseURL);
|
||||
return { ok: true };
|
||||
},
|
||||
aiFetch: async (url, method, headers, _body, _providerId, _skipHostCheck, _followRedirects, skipTLSVerify) => {
|
||||
calls.push({ url, method, headers, skipTLSVerify });
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: JSON.stringify({ data: [{ id: "deepseek-chat" }] }),
|
||||
};
|
||||
},
|
||||
},
|
||||
baseURL: "https://api.deepseek.com/v1/",
|
||||
apiKey: "sk-test",
|
||||
providerId: "openai",
|
||||
style: "openai",
|
||||
skipTLSVerify: true,
|
||||
now: () => {
|
||||
clock += 150;
|
||||
return clock;
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(allowlist, ["https://api.deepseek.com/v1/"]);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0]?.url, "https://api.deepseek.com/v1/models");
|
||||
assert.equal(calls[0]?.method, "GET");
|
||||
assert.equal(calls[0]?.skipTLSVerify, true);
|
||||
assert.deepEqual(calls[0]?.headers, { Authorization: "Bearer sk-test" });
|
||||
assert.equal(run.ok, true);
|
||||
if (!run.ok) throw new Error("expected probe success");
|
||||
assert.equal(run.classification.health, "ok");
|
||||
assert.equal(run.classification.latencyMs, 150);
|
||||
assert.equal(run.classification.modelCount, 1);
|
||||
});
|
||||
|
||||
test("probeProviderConnection returns typed failures before fetch", async () => {
|
||||
assert.deepEqual(
|
||||
await probeProviderConnection({
|
||||
bridge: { aiFetch: async () => ({ ok: true, status: 200, data: "{}" }) },
|
||||
baseURL: "",
|
||||
apiKey: "sk",
|
||||
providerId: "openai",
|
||||
style: "openai",
|
||||
}),
|
||||
{ ok: false, reason: "missing_base_url" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
await probeProviderConnection({
|
||||
bridge: undefined,
|
||||
baseURL: "https://api.openai.com/v1",
|
||||
apiKey: "sk",
|
||||
providerId: "openai",
|
||||
style: "openai",
|
||||
}),
|
||||
{ ok: false, reason: "unavailable" },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import type { ProviderStyle } from "./types";
|
||||
import {
|
||||
buildModelDiscoveryHeaders,
|
||||
STYLE_DEFAULT_MODELS_ENDPOINT,
|
||||
} from "./modelDiscoveryHeaders";
|
||||
|
||||
export type ProviderProbeHealth = "ok" | "warn" | "error";
|
||||
|
||||
export type ProviderProbeInputIssue = "missing_base_url" | "missing_api_key";
|
||||
|
||||
export type ProviderProbeUnavailableReason = "unavailable";
|
||||
|
||||
export type ProviderProbeClassification = {
|
||||
health: ProviderProbeHealth;
|
||||
latencyMs: number;
|
||||
statusCode: number;
|
||||
modelCount?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/** Minimal bridge surface needed to probe a provider endpoint. */
|
||||
export type ProviderProbeFetchBridge = {
|
||||
aiFetch?: (
|
||||
url: string,
|
||||
method?: string,
|
||||
headers?: Record<string, string>,
|
||||
body?: string,
|
||||
providerId?: string,
|
||||
skipHostCheck?: boolean,
|
||||
followRedirects?: boolean,
|
||||
skipTLSVerify?: boolean,
|
||||
) => Promise<{ ok: boolean; status?: number; data: string; error?: string }>;
|
||||
aiAllowlistAddHost?: (baseURL: string) => Promise<{ ok: boolean }>;
|
||||
};
|
||||
|
||||
export type ProviderProbeRunResult =
|
||||
| { ok: false; reason: ProviderProbeInputIssue | ProviderProbeUnavailableReason }
|
||||
| { ok: true; classification: ProviderProbeClassification };
|
||||
|
||||
/**
|
||||
* Probe-specific discovery path. Same conventions as model listing, but Google
|
||||
* Generative Language exposes `GET /models`, so the settings "Test" button can
|
||||
* validate key + connectivity without opening chat.
|
||||
*/
|
||||
export function resolveProviderProbeEndpoint(
|
||||
style: ProviderStyle,
|
||||
presetEndpoint?: string,
|
||||
): string | undefined {
|
||||
if (style === "google") return "/models";
|
||||
return STYLE_DEFAULT_MODELS_ENDPOINT[style] ?? presetEndpoint;
|
||||
}
|
||||
|
||||
export function buildProviderProbeUrl(baseURL: string, endpoint: string): string {
|
||||
return `${baseURL.replace(/\/+$/, "")}${endpoint.startsWith("/") ? endpoint : `/${endpoint}`}`;
|
||||
}
|
||||
|
||||
export function validateProviderProbeInputs(input: {
|
||||
baseURL: string;
|
||||
apiKey: string;
|
||||
providerId: string;
|
||||
}): { ok: true } | { ok: false; reason: ProviderProbeInputIssue } {
|
||||
if (!input.baseURL.trim()) return { ok: false, reason: "missing_base_url" };
|
||||
const needsApiKey = input.providerId !== "ollama";
|
||||
if (needsApiKey && !input.apiKey.trim()) return { ok: false, reason: "missing_api_key" };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function countModelsInPayload(data: string | undefined): number | undefined {
|
||||
if (data == null || data.trim() === "") return undefined;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(data);
|
||||
if (!parsed || typeof parsed !== "object") return undefined;
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const rawModels = Array.isArray(record.data)
|
||||
? record.data
|
||||
: Array.isArray(record.models)
|
||||
? record.models
|
||||
: null;
|
||||
if (!rawModels) return undefined;
|
||||
return rawModels.filter((raw) => {
|
||||
if (!raw || typeof raw !== "object") return false;
|
||||
const model = raw as Record<string, unknown>;
|
||||
// OpenAI-compat uses `id`; Google ListModels uses `name` (e.g. models/gemini-…).
|
||||
return (typeof model.id === "string" && model.id.length > 0)
|
||||
|| (typeof model.name === "string" && model.name.length > 0);
|
||||
}).length;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a lightweight `/models` (or equivalent) probe response to green / yellow / red.
|
||||
* Yellow covers slow-but-successful replies and empty model catalogs.
|
||||
*/
|
||||
export function classifyProviderProbeResponse(input: {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
latencyMs: number;
|
||||
data?: string;
|
||||
error?: string;
|
||||
slowThresholdMs?: number;
|
||||
}): ProviderProbeClassification {
|
||||
const slowThresholdMs = input.slowThresholdMs ?? 3000;
|
||||
const base = {
|
||||
latencyMs: input.latencyMs,
|
||||
statusCode: input.status,
|
||||
...(input.error ? { error: input.error } : {}),
|
||||
};
|
||||
|
||||
if (!input.ok || input.status < 200 || input.status >= 300) {
|
||||
return {
|
||||
...base,
|
||||
health: "error",
|
||||
error: input.error || (input.status ? `HTTP ${input.status}` : "Request failed"),
|
||||
};
|
||||
}
|
||||
|
||||
const modelCount = countModelsInPayload(input.data);
|
||||
if (modelCount === undefined) {
|
||||
return {
|
||||
...base,
|
||||
health: "warn",
|
||||
error: "Unexpected response body",
|
||||
};
|
||||
}
|
||||
if (modelCount === 0 || input.latencyMs >= slowThresholdMs) {
|
||||
return {
|
||||
...base,
|
||||
health: "warn",
|
||||
modelCount,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
health: "ok",
|
||||
modelCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a lightweight connectivity probe against the provider's models listing
|
||||
* endpoint. Owns allowlist mutation, auth headers, URL construction, and the
|
||||
* bridge fetch so UI only maps the typed result to presentation.
|
||||
*/
|
||||
export async function probeProviderConnection(input: {
|
||||
bridge: ProviderProbeFetchBridge | undefined;
|
||||
baseURL: string;
|
||||
apiKey: string;
|
||||
providerId: string;
|
||||
style: ProviderStyle;
|
||||
presetModelsEndpoint?: string;
|
||||
skipTLSVerify?: boolean;
|
||||
slowThresholdMs?: number;
|
||||
now?: () => number;
|
||||
}): Promise<ProviderProbeRunResult> {
|
||||
const baseURL = input.baseURL.trim();
|
||||
const inputCheck = validateProviderProbeInputs({
|
||||
baseURL,
|
||||
apiKey: input.apiKey,
|
||||
providerId: input.providerId,
|
||||
});
|
||||
if (!inputCheck.ok) return inputCheck;
|
||||
|
||||
const endpoint = resolveProviderProbeEndpoint(input.style, input.presetModelsEndpoint);
|
||||
if (!endpoint) return { ok: false, reason: "unavailable" };
|
||||
|
||||
const bridge = input.bridge;
|
||||
if (!bridge?.aiFetch) return { ok: false, reason: "unavailable" };
|
||||
|
||||
const now = input.now ?? Date.now;
|
||||
const startedAt = now();
|
||||
if (bridge.aiAllowlistAddHost) {
|
||||
await bridge.aiAllowlistAddHost(baseURL);
|
||||
}
|
||||
const url = buildProviderProbeUrl(baseURL, endpoint);
|
||||
const headers = buildModelDiscoveryHeaders(input.style, input.apiKey);
|
||||
const result = await bridge.aiFetch(
|
||||
url,
|
||||
"GET",
|
||||
headers,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
input.skipTLSVerify,
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
classification: classifyProviderProbeResponse({
|
||||
ok: result.ok,
|
||||
status: result.status ?? (result.ok ? 200 : 0),
|
||||
latencyMs: Math.max(0, now() - startedAt),
|
||||
data: result.data,
|
||||
error: result.error,
|
||||
slowThresholdMs: input.slowThresholdMs,
|
||||
}),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user