Compare commits

...
Author SHA1 Message Date
netcatty-bot 7af635021d fix: address Codex review on PR #2845 2026-08-09 15:23:34 +00:00
netcatty-bot 13c0324cad fix(#2844): automated Cursor CLI fix 2026-08-09 15:05:06 +00:00
8 changed files with 566 additions and 11 deletions

No files matched your search

+9
View File
@@ -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...',
+9
View File
@@ -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': 'Загрузка моделей...',
+9
View File
@@ -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': '加载模型中...',
+9
View File
@@ -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>
);
+1 -1
View File
@@ -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,
}),
};
}