implement verification logic

This commit is contained in:
Zero Liu 2026-05-23 01:53:35 -07:00
parent aaf72c19a1
commit 21b5244b99
No known key found for this signature in database
11 changed files with 695 additions and 24 deletions

View file

@ -0,0 +1,89 @@
/**
* Tests for `anthropicAdapter.verifyCredentials`.
*
* Mocks the shared `verifyViaListModels` helper so we can assert the
* URL + headers without exercising the helper's status-mapping logic
* (covered separately).
*/
import { anthropicAdapter } from "./anthropicAdapter";
jest.mock("./verifyViaListModels", () => ({
verifyViaListModels: jest.fn(),
}));
import { verifyViaListModels } from "./verifyViaListModels";
import type { Provider } from "@/modelManagement/types/persisted";
const mockVerify = verifyViaListModels as jest.MockedFunction<typeof verifyViaListModels>;
function provider(overrides: Partial<Provider> = {}): Provider {
return {
providerId: "p1",
providerType: "anthropic",
displayName: "Anthropic",
origin: { kind: "byok" },
addedAt: 0,
apiKeyKeychainId: null,
...overrides,
};
}
describe("anthropicAdapter.verifyCredentials", () => {
beforeEach(() => {
mockVerify.mockReset();
mockVerify.mockResolvedValue({ ok: true, checkedAt: 1 });
});
it("returns missing_api_key when apiKey is null", async () => {
const result = await anthropicAdapter.verifyCredentials({
provider: provider(),
apiKey: null,
extras: {},
});
expect(result.ok).toBe(false);
expect(result.code).toBe("missing_api_key");
expect(mockVerify).not.toHaveBeenCalled();
});
it("hits the default base URL when provider.baseUrl is unset", async () => {
await anthropicAdapter.verifyCredentials({
provider: provider(),
apiKey: "sk-ant",
extras: {},
});
expect(mockVerify).toHaveBeenCalledWith(
"https://api.anthropic.com/v1/models",
expect.objectContaining({
"x-api-key": "sk-ant",
"anthropic-version": "2023-06-01",
"anthropic-dangerous-direct-browser-access": "true",
})
);
});
it("uses provider.baseUrl when set, stripping trailing slash", async () => {
await anthropicAdapter.verifyCredentials({
provider: provider({ baseUrl: "https://proxy.example/" }),
apiKey: "sk-ant",
extras: {},
});
expect(mockVerify).toHaveBeenCalledWith("https://proxy.example/v1/models", expect.any(Object));
});
it("falls back to the default base URL when provider.baseUrl is empty or whitespace", async () => {
for (const baseUrl of ["", " "]) {
mockVerify.mockClear();
await anthropicAdapter.verifyCredentials({
provider: provider({ baseUrl }),
apiKey: "sk-ant",
extras: {},
});
expect(mockVerify).toHaveBeenCalledWith(
"https://api.anthropic.com/v1/models",
expect.any(Object)
);
}
});
});

View file

@ -4,9 +4,8 @@
* Anthropic has no provider-level extras. `baseUrl` and `apiKey`
* cover everything the SDK needs.
*
* Placeholder `buildLangChainClient` throws; `verifyCredentials`
* returns a non-ok result so setup wizards that probe on every
* keystroke don't crash.
* `buildLangChainClient` is still a placeholder; `verifyCredentials`
* is implemented via Anthropic's `/v1/models` endpoint.
*/
import { z } from "zod";
@ -15,6 +14,10 @@ import type { BaseChatModel } from "@langchain/core/language_models/chat_models"
import type { VerificationResult } from "@/modelManagement/types/runtime";
import type { AdapterBuildContext, AdapterVerifyContext, ProviderAdapter } from "./ProviderAdapter";
import { verifyViaListModels } from "./verifyViaListModels";
const DEFAULT_BASE_URL = "https://api.anthropic.com";
const ANTHROPIC_VERSION = "2023-06-01";
const extrasSchema = z.object({}).strict();
@ -29,10 +32,24 @@ export const anthropicAdapter: ProviderAdapter<Extras> = {
},
verifyCredentials(ctx: AdapterVerifyContext<Extras>): Promise<VerificationResult> {
return Promise.resolve({
ok: false,
message: "not implemented",
checkedAt: Date.now(),
if (!ctx.apiKey) {
return Promise.resolve({
ok: false,
code: "missing_api_key",
message: "An API key is required to verify this Anthropic provider.",
checkedAt: Date.now(),
});
}
const trimmedBaseUrl = ctx.provider.baseUrl?.trim();
const base = (trimmedBaseUrl || DEFAULT_BASE_URL).replace(/\/$/, "");
return verifyViaListModels(`${base}/v1/models`, {
"x-api-key": ctx.apiKey,
"anthropic-version": ANTHROPIC_VERSION,
// Required when calling Anthropic from a browser-like runtime
// (Obsidian's Electron renderer / mobile WebView). `requestUrl`
// is not subject to CORS itself, but Anthropic still gates the
// request server-side without this header.
"anthropic-dangerous-direct-browser-access": "true",
});
},
};

View file

@ -0,0 +1,88 @@
/**
* Tests for `azureAdapter.verifyCredentials`.
*/
import { azureAdapter } from "./azureAdapter";
jest.mock("./verifyViaListModels", () => ({
verifyViaListModels: jest.fn(),
}));
import { verifyViaListModels } from "./verifyViaListModels";
import type { Provider } from "@/modelManagement/types/persisted";
const mockVerify = verifyViaListModels as jest.MockedFunction<typeof verifyViaListModels>;
function provider(overrides: Partial<Provider> = {}): Provider {
return {
providerId: "p1",
providerType: "azure",
displayName: "Azure",
origin: { kind: "byok" },
addedAt: 0,
apiKeyKeychainId: null,
...overrides,
};
}
const extras = {
azureInstanceName: "my-org-eastus",
azureDeploymentName: "gpt-4o",
azureApiVersion: "2024-08-01-preview",
};
describe("azureAdapter.verifyCredentials", () => {
beforeEach(() => {
mockVerify.mockReset();
mockVerify.mockResolvedValue({ ok: true, checkedAt: 1 });
});
it("returns missing_api_key when apiKey is null", async () => {
const result = await azureAdapter.verifyCredentials({
provider: provider(),
apiKey: null,
extras,
});
expect(result.code).toBe("missing_api_key");
expect(mockVerify).not.toHaveBeenCalled();
});
it("composes the data-plane deployments URL from extras and sends api-key header", async () => {
await azureAdapter.verifyCredentials({
provider: provider(),
apiKey: "azure-secret",
extras,
});
expect(mockVerify).toHaveBeenCalledWith(
"https://my-org-eastus.openai.azure.com/openai/deployments?api-version=2024-08-01-preview",
{ "api-key": "azure-secret" }
);
});
it("url-encodes the api-version", async () => {
await azureAdapter.verifyCredentials({
provider: provider(),
apiKey: "k",
extras: { ...extras, azureApiVersion: "2024-08-01 preview" },
});
const url = mockVerify.mock.calls[0][0];
expect(url).toContain("api-version=2024-08-01%20preview");
});
});
describe("azureAdapter.extrasSchema", () => {
it("accepts alphanumeric + hyphen instance names", () => {
expect(() => azureAdapter.extrasSchema.parse(extras)).not.toThrow();
});
it.each([
["with a slash", "evil.com/foo"],
["with a dot", "my.subdomain"],
["with a colon", "host:443"],
["with an at sign", "user@host"],
["with whitespace", "my name"],
])("rejects azureInstanceName %s", (_label, azureInstanceName) => {
expect(() => azureAdapter.extrasSchema.parse({ ...extras, azureInstanceName })).toThrow();
});
});

View file

@ -13,6 +13,11 @@
* version (e.g. `2024-08-01-preview`).
*
* All three are required; the schema rejects missing or empty values.
*
* Verification hits the data-plane `/openai/deployments` endpoint with
* the resource's `api-key` header returns the deployment list using
* the same key the chat completions endpoint takes, so no separate
* Management plane permissions are needed.
*/
import { z } from "zod";
@ -21,10 +26,20 @@ import type { BaseChatModel } from "@langchain/core/language_models/chat_models"
import type { VerificationResult } from "@/modelManagement/types/runtime";
import type { AdapterBuildContext, AdapterVerifyContext, ProviderAdapter } from "./ProviderAdapter";
import { verifyViaListModels } from "./verifyViaListModels";
// Azure Cognitive Services resource names are alphanumeric + hyphens.
// Enforce here so a stray `/`, `.`, or `:` can't redirect the
// api-key-bearing verification request to an arbitrary host via the
// `https://<instance>.openai.azure.com` interpolation below.
const AZURE_INSTANCE_NAME_RE = /^[a-zA-Z0-9-]+$/;
const extrasSchema = z
.object({
azureInstanceName: z.string().min(1),
azureInstanceName: z
.string()
.min(1)
.regex(AZURE_INSTANCE_NAME_RE, "must contain only letters, digits, and hyphens"),
azureDeploymentName: z.string().min(1),
azureApiVersion: z.string().min(1),
})
@ -41,10 +56,18 @@ export const azureAdapter: ProviderAdapter<Extras> = {
},
verifyCredentials(ctx: AdapterVerifyContext<Extras>): Promise<VerificationResult> {
return Promise.resolve({
ok: false,
message: "not implemented",
checkedAt: Date.now(),
});
if (!ctx.apiKey) {
return Promise.resolve({
ok: false,
code: "missing_api_key",
message: "An API key is required to verify this Azure provider.",
checkedAt: Date.now(),
});
}
const { azureInstanceName, azureApiVersion } = ctx.extras;
const url =
`https://${azureInstanceName}.openai.azure.com/openai/deployments` +
`?api-version=${encodeURIComponent(azureApiVersion)}`;
return verifyViaListModels(url, { "api-key": ctx.apiKey });
},
};

View file

@ -35,9 +35,15 @@ export const bedrockAdapter: ProviderAdapter<Extras> = {
},
verifyCredentials(ctx: AdapterVerifyContext<Extras>): Promise<VerificationResult> {
// AWS Bedrock requires SigV4-signed requests; there's no flat
// HTTP `GET /models` endpoint. Real verification (signed
// ListFoundationModels or STS GetCallerIdentity) lands together
// with `buildLangChainClient` in a follow-up.
return Promise.resolve({
ok: false,
message: "not implemented",
code: "not_implemented",
message:
"AWS Bedrock verification is not yet supported. Save the provider, then test it from the chat picker.",
checkedAt: Date.now(),
});
},

View file

@ -0,0 +1,92 @@
/**
* Tests for `googleAdapter.verifyCredentials`.
*/
import { googleAdapter } from "./googleAdapter";
jest.mock("./verifyViaListModels", () => ({
verifyViaListModels: jest.fn(),
}));
import { verifyViaListModels } from "./verifyViaListModels";
import type { Provider } from "@/modelManagement/types/persisted";
const mockVerify = verifyViaListModels as jest.MockedFunction<typeof verifyViaListModels>;
function provider(overrides: Partial<Provider> = {}): Provider {
return {
providerId: "p1",
providerType: "google",
displayName: "Google",
origin: { kind: "byok" },
addedAt: 0,
apiKeyKeychainId: null,
...overrides,
};
}
describe("googleAdapter.verifyCredentials", () => {
beforeEach(() => {
mockVerify.mockReset();
mockVerify.mockResolvedValue({ ok: true, checkedAt: 1 });
});
it("returns missing_api_key when apiKey is null", async () => {
const result = await googleAdapter.verifyCredentials({
provider: provider(),
apiKey: null,
extras: {},
});
expect(result.code).toBe("missing_api_key");
expect(mockVerify).not.toHaveBeenCalled();
});
it("passes apiKey via query param against the default base URL", async () => {
await googleAdapter.verifyCredentials({
provider: provider(),
apiKey: "g-secret",
extras: {},
});
expect(mockVerify).toHaveBeenCalledWith(
"https://generativelanguage.googleapis.com/v1beta/models?key=g-secret",
{}
);
});
it("url-encodes special characters in the apiKey", async () => {
await googleAdapter.verifyCredentials({
provider: provider(),
apiKey: "weird&key=stuff",
extras: {},
});
expect(mockVerify).toHaveBeenCalledWith(
"https://generativelanguage.googleapis.com/v1beta/models?key=weird%26key%3Dstuff",
{}
);
});
it("respects provider.baseUrl override", async () => {
await googleAdapter.verifyCredentials({
provider: provider({ baseUrl: "https://proxy.example/" }),
apiKey: "g",
extras: {},
});
expect(mockVerify).toHaveBeenCalledWith("https://proxy.example/v1beta/models?key=g", {});
});
it("falls back to the default base URL when provider.baseUrl is empty or whitespace", async () => {
for (const baseUrl of ["", " "]) {
mockVerify.mockClear();
await googleAdapter.verifyCredentials({
provider: provider({ baseUrl }),
apiKey: "g",
extras: {},
});
expect(mockVerify).toHaveBeenCalledWith(
"https://generativelanguage.googleapis.com/v1beta/models?key=g",
{}
);
}
});
});

View file

@ -3,6 +3,9 @@
* `ProviderType === "google"`.
*
* Backs Gemini via `@langchain/google-genai`. No provider-level extras.
*
* Verification hits `GET /v1beta/models?key=…` Google takes the API
* key as a query parameter rather than a header.
*/
import { z } from "zod";
@ -11,6 +14,9 @@ import type { BaseChatModel } from "@langchain/core/language_models/chat_models"
import type { VerificationResult } from "@/modelManagement/types/runtime";
import type { AdapterBuildContext, AdapterVerifyContext, ProviderAdapter } from "./ProviderAdapter";
import { verifyViaListModels } from "./verifyViaListModels";
const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com";
const extrasSchema = z.object({}).strict();
@ -25,10 +31,16 @@ export const googleAdapter: ProviderAdapter<Extras> = {
},
verifyCredentials(ctx: AdapterVerifyContext<Extras>): Promise<VerificationResult> {
return Promise.resolve({
ok: false,
message: "not implemented",
checkedAt: Date.now(),
});
if (!ctx.apiKey) {
return Promise.resolve({
ok: false,
code: "missing_api_key",
message: "An API key is required to verify this Google provider.",
checkedAt: Date.now(),
});
}
const trimmedBaseUrl = ctx.provider.baseUrl?.trim();
const base = (trimmedBaseUrl || DEFAULT_BASE_URL).replace(/\/$/, "");
return verifyViaListModels(`${base}/v1beta/models?key=${encodeURIComponent(ctx.apiKey)}`, {});
},
};

View file

@ -0,0 +1,101 @@
/**
* Tests for `openaiCompatibleAdapter.verifyCredentials`.
*/
import { openaiCompatibleAdapter } from "./openaiCompatibleAdapter";
jest.mock("./verifyViaListModels", () => ({
verifyViaListModels: jest.fn(),
}));
import { verifyViaListModels } from "./verifyViaListModels";
import type { Provider } from "@/modelManagement/types/persisted";
const mockVerify = verifyViaListModels as jest.MockedFunction<typeof verifyViaListModels>;
function provider(overrides: Partial<Provider> = {}): Provider {
return {
providerId: "p1",
providerType: "openai-compatible",
displayName: "OpenAI",
baseUrl: "https://api.openai.com/v1",
origin: { kind: "byok" },
addedAt: 0,
apiKeyKeychainId: null,
...overrides,
};
}
describe("openaiCompatibleAdapter.verifyCredentials", () => {
beforeEach(() => {
mockVerify.mockReset();
mockVerify.mockResolvedValue({ ok: true, checkedAt: 1 });
});
it("returns missing_base_url when baseUrl is unset", async () => {
const result = await openaiCompatibleAdapter.verifyCredentials({
provider: provider({ baseUrl: undefined }),
apiKey: "sk-openai",
extras: {},
});
expect(result.ok).toBe(false);
expect(result.code).toBe("missing_base_url");
expect(mockVerify).not.toHaveBeenCalled();
});
it("returns missing_base_url when baseUrl is only whitespace", async () => {
const result = await openaiCompatibleAdapter.verifyCredentials({
provider: provider({ baseUrl: " " }),
apiKey: "sk-openai",
extras: {},
});
expect(result.code).toBe("missing_base_url");
});
it("sends Bearer auth when apiKey is set", async () => {
await openaiCompatibleAdapter.verifyCredentials({
provider: provider(),
apiKey: "sk-openai",
extras: {},
});
expect(mockVerify).toHaveBeenCalledWith("https://api.openai.com/v1/models", {
Authorization: "Bearer sk-openai",
});
});
it("omits Authorization header for keyless providers (Ollama / LMStudio)", async () => {
await openaiCompatibleAdapter.verifyCredentials({
provider: provider({ baseUrl: "http://localhost:11434/v1" }),
apiKey: null,
extras: {},
});
const headers = mockVerify.mock.calls[0][1];
expect(headers).not.toHaveProperty("Authorization");
expect(mockVerify).toHaveBeenCalledWith("http://localhost:11434/v1/models", {});
});
it("adds OpenAI-Organization header when extras.openAIOrgId is set", async () => {
await openaiCompatibleAdapter.verifyCredentials({
provider: provider(),
apiKey: "sk-openai",
extras: { openAIOrgId: "org-abc" },
});
expect(mockVerify).toHaveBeenCalledWith(
"https://api.openai.com/v1/models",
expect.objectContaining({
Authorization: "Bearer sk-openai",
"OpenAI-Organization": "org-abc",
})
);
});
it("strips trailing slash from baseUrl", async () => {
await openaiCompatibleAdapter.verifyCredentials({
provider: provider({ baseUrl: "https://example.test/v1/" }),
apiKey: "k",
extras: {},
});
expect(mockVerify).toHaveBeenCalledWith("https://example.test/v1/models", expect.any(Object));
});
});

View file

@ -10,6 +10,10 @@
* The only optional extra is the OpenAI organization id, which a
* subset of OpenAI accounts require. Everything else (base URL, API
* key) lives on `Provider` directly.
*
* Verification hits the standard `GET /models` endpoint implemented
* by every public OpenAI-compatible provider and by both local
* runners (Ollama / LMStudio).
*/
import { z } from "zod";
@ -18,6 +22,7 @@ import type { BaseChatModel } from "@langchain/core/language_models/chat_models"
import type { VerificationResult } from "@/modelManagement/types/runtime";
import type { AdapterBuildContext, AdapterVerifyContext, ProviderAdapter } from "./ProviderAdapter";
import { verifyViaListModels } from "./verifyViaListModels";
const extrasSchema = z
.object({
@ -40,10 +45,30 @@ export const openaiCompatibleAdapter: ProviderAdapter<Extras> = {
},
verifyCredentials(ctx: AdapterVerifyContext<Extras>): Promise<VerificationResult> {
return Promise.resolve({
ok: false,
message: "not implemented",
checkedAt: Date.now(),
});
const baseUrl = ctx.provider.baseUrl?.trim();
if (!baseUrl) {
return Promise.resolve({
ok: false,
code: "missing_base_url",
message: "A base URL is required to verify this OpenAI-compatible provider.",
checkedAt: Date.now(),
});
}
const base = baseUrl.replace(/\/$/, "");
// No `missing_api_key` guard here — the adapter cannot tell whether
// an absent key is legitimate (Ollama / LMStudio, which set
// `requiresApiKey: false` at the template layer) or a mistake on
// OpenAI proper. Let the server's 401 surface as `invalid_api_key`
// in the cases that matter.
const headers: Record<string, string> = {};
if (ctx.apiKey) {
headers["Authorization"] = `Bearer ${ctx.apiKey}`;
}
if (ctx.extras.openAIOrgId) {
headers["OpenAI-Organization"] = ctx.extras.openAIOrgId;
}
return verifyViaListModels(`${base}/models`, headers);
},
};

View file

@ -0,0 +1,110 @@
/**
* Tests for the shared `verifyViaListModels` helper.
*
* Mocks `@/utils.safeFetchNoThrow` directly to drive each status
* branch (200 / 401 / 403 / 429 / 500 / thrown error) plus the
* timeout branch.
*/
import { verifyViaListModels } from "./verifyViaListModels";
jest.mock("@/utils", () => ({
safeFetchNoThrow: jest.fn(),
}));
import { safeFetchNoThrow } from "@/utils";
const mockSafeFetch = safeFetchNoThrow as jest.MockedFunction<typeof safeFetchNoThrow>;
function fakeResponse(status: number, body = ""): Response {
return { status, text: async () => body } as unknown as Response;
}
describe("verifyViaListModels", () => {
beforeEach(() => {
mockSafeFetch.mockReset();
});
it("returns ok for 200", async () => {
mockSafeFetch.mockResolvedValue(fakeResponse(200));
const result = await verifyViaListModels("https://example.test/models", { h: "v" });
expect(result.ok).toBe(true);
expect(result.checkedAt).toEqual(expect.any(Number));
expect(mockSafeFetch).toHaveBeenCalledWith("https://example.test/models", {
method: "GET",
headers: { h: "v" },
});
});
it("returns ok for 204", async () => {
mockSafeFetch.mockResolvedValue(fakeResponse(204));
expect((await verifyViaListModels("u", {})).ok).toBe(true);
});
it("maps 401 to invalid_api_key", async () => {
mockSafeFetch.mockResolvedValue(fakeResponse(401));
const result = await verifyViaListModels("u", {});
expect(result.ok).toBe(false);
expect(result.code).toBe("invalid_api_key");
expect(result.message).toMatch(/check your API key/i);
});
it("maps 403 to invalid_api_key", async () => {
mockSafeFetch.mockResolvedValue(fakeResponse(403));
const result = await verifyViaListModels("u", {});
expect(result.code).toBe("invalid_api_key");
});
it("maps 429 to rate_limited", async () => {
mockSafeFetch.mockResolvedValue(fakeResponse(429));
const result = await verifyViaListModels("u", {});
expect(result.ok).toBe(false);
expect(result.code).toBe("rate_limited");
});
it("maps other non-2xx to http_error and includes the body", async () => {
mockSafeFetch.mockResolvedValue(fakeResponse(500, '{"error":"internal server explosion"}'));
const result = await verifyViaListModels("u", {});
expect(result.ok).toBe(false);
expect(result.code).toBe("http_error");
expect(result.message).toContain("500");
expect(result.message).toContain("internal server explosion");
});
it("falls back to status-only http_error when the body is empty", async () => {
mockSafeFetch.mockResolvedValue(fakeResponse(502));
const result = await verifyViaListModels("u", {});
expect(result.message).toBe("HTTP 502");
});
it("truncates very long error bodies", async () => {
const longBody = "x".repeat(500);
mockSafeFetch.mockResolvedValue(fakeResponse(500, longBody));
const result = await verifyViaListModels("u", {});
expect(result.message).toContain("…");
expect(result.message?.length).toBeLessThan(longBody.length);
});
it("surfaces 400 body text so Gemini-style bad-key responses are readable", async () => {
mockSafeFetch.mockResolvedValue(fakeResponse(400, "API key not valid"));
const result = await verifyViaListModels("u", {});
expect(result.code).toBe("http_error");
expect(result.message).toContain("API key not valid");
});
it("maps thrown fetch errors to network", async () => {
mockSafeFetch.mockRejectedValue(new Error("ECONNREFUSED"));
const result = await verifyViaListModels("u", {});
expect(result.ok).toBe(false);
expect(result.code).toBe("network");
expect(result.message).toBe("ECONNREFUSED");
});
it("returns code: timeout (distinct from network) when the fetch hangs past timeoutMs", async () => {
mockSafeFetch.mockImplementation(() => new Promise(() => {}));
const result = await verifyViaListModels("u", {}, { timeoutMs: 5 });
expect(result.ok).toBe(false);
expect(result.code).toBe("timeout");
expect(result.message).toMatch(/timed out/i);
});
});

View file

@ -0,0 +1,108 @@
/**
* Shared helper for adapters that verify credentials by issuing a
* GET against an OpenAI-style `/models` endpoint (or its provider-
* specific equivalent Anthropic's `/v1/models`, Google's
* `/v1beta/models`, Azure's `/openai/deployments`). All four share the
* same wire-level signal: 2xx means the credentials parse and the
* caller has read access; 401/403 means the key is wrong; 429 means
* the key is fine but rate-limited; anything else surfaces as
* `http_error` with the response body included so adapters whose APIs
* use 400 for auth failures (Gemini) still give the user a readable
* signal.
*
* Uses `safeFetchNoThrow` so the helper can inspect `response.status`
* for the 401/403 `invalid_api_key` mapping without try/catching on
* `requestUrl`'s default throw-on-4xx behavior.
*
* Hard 8s timeout via `Promise.race` because `safeFetch` does not
* honor `AbortSignal` (see `src/utils.ts` comment). The timeout
* surfaces as `code: "timeout"` so callers can distinguish a slow
* upstream from a connection refused (`code: "network"`).
*/
import { safeFetchNoThrow } from "@/utils";
import type { VerificationResult } from "@/modelManagement/types/runtime";
const DEFAULT_TIMEOUT_MS = 8000;
const MAX_BODY_CHARS = 200;
class TimeoutError extends Error {}
export interface VerifyViaListModelsOptions {
/** Overrides the 8s default. Tests pass a tiny value to force the
* timeout branch. */
timeoutMs?: number;
}
export async function verifyViaListModels(
url: string,
headers: Record<string, string>,
opts: VerifyViaListModelsOptions = {}
): Promise<VerificationResult> {
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
let timer: number | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = window.setTimeout(
() => reject(new TimeoutError(`Timed out after ${timeoutMs}ms`)),
timeoutMs
);
});
try {
const response = await Promise.race([
safeFetchNoThrow(url, { method: "GET", headers }),
timeoutPromise,
]);
return await mapResponse(response);
} catch (err) {
return {
ok: false,
code: err instanceof TimeoutError ? "timeout" : "network",
message: err instanceof Error ? err.message : String(err),
checkedAt: Date.now(),
};
} finally {
if (timer !== undefined) window.clearTimeout(timer);
}
}
async function mapResponse(response: Response): Promise<VerificationResult> {
const { status } = response;
const checkedAt = Date.now();
if (status >= 200 && status < 300) {
return { ok: true, checkedAt };
}
if (status === 401 || status === 403) {
return {
ok: false,
code: "invalid_api_key",
message: "Authentication failed — check your API key.",
checkedAt,
};
}
if (status === 429) {
return {
ok: false,
code: "rate_limited",
message: "Rate limited — try again in a moment.",
checkedAt,
};
}
const snippet = await readBodySnippet(response);
return {
ok: false,
code: "http_error",
message: snippet ? `HTTP ${status}: ${snippet}` : `HTTP ${status}`,
checkedAt,
};
}
async function readBodySnippet(response: Response): Promise<string> {
try {
const body = (await response.text()).trim();
return body.length > MAX_BODY_CHARS ? `${body.slice(0, MAX_BODY_CHARS)}` : body;
} catch {
return "";
}
}