diff --git a/src/modelManagement/providers/adapters/anthropicAdapter.test.ts b/src/modelManagement/providers/adapters/anthropicAdapter.test.ts new file mode 100644 index 00000000..afdc22aa --- /dev/null +++ b/src/modelManagement/providers/adapters/anthropicAdapter.test.ts @@ -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; + +function provider(overrides: Partial = {}): 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) + ); + } + }); +}); diff --git a/src/modelManagement/providers/adapters/anthropicAdapter.ts b/src/modelManagement/providers/adapters/anthropicAdapter.ts index b3a3858a..3055efbd 100644 --- a/src/modelManagement/providers/adapters/anthropicAdapter.ts +++ b/src/modelManagement/providers/adapters/anthropicAdapter.ts @@ -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 = { }, verifyCredentials(ctx: AdapterVerifyContext): Promise { - 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", }); }, }; diff --git a/src/modelManagement/providers/adapters/azureAdapter.test.ts b/src/modelManagement/providers/adapters/azureAdapter.test.ts new file mode 100644 index 00000000..4d077e90 --- /dev/null +++ b/src/modelManagement/providers/adapters/azureAdapter.test.ts @@ -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; + +function provider(overrides: Partial = {}): 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(); + }); +}); diff --git a/src/modelManagement/providers/adapters/azureAdapter.ts b/src/modelManagement/providers/adapters/azureAdapter.ts index dec812f0..a4fbbec2 100644 --- a/src/modelManagement/providers/adapters/azureAdapter.ts +++ b/src/modelManagement/providers/adapters/azureAdapter.ts @@ -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://.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 = { }, verifyCredentials(ctx: AdapterVerifyContext): Promise { - 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 }); }, }; diff --git a/src/modelManagement/providers/adapters/bedrockAdapter.ts b/src/modelManagement/providers/adapters/bedrockAdapter.ts index 370a428a..081cad90 100644 --- a/src/modelManagement/providers/adapters/bedrockAdapter.ts +++ b/src/modelManagement/providers/adapters/bedrockAdapter.ts @@ -35,9 +35,15 @@ export const bedrockAdapter: ProviderAdapter = { }, verifyCredentials(ctx: AdapterVerifyContext): Promise { + // 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(), }); }, diff --git a/src/modelManagement/providers/adapters/googleAdapter.test.ts b/src/modelManagement/providers/adapters/googleAdapter.test.ts new file mode 100644 index 00000000..54892a2d --- /dev/null +++ b/src/modelManagement/providers/adapters/googleAdapter.test.ts @@ -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; + +function provider(overrides: Partial = {}): 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", + {} + ); + } + }); +}); diff --git a/src/modelManagement/providers/adapters/googleAdapter.ts b/src/modelManagement/providers/adapters/googleAdapter.ts index a41419c7..f6e0cd85 100644 --- a/src/modelManagement/providers/adapters/googleAdapter.ts +++ b/src/modelManagement/providers/adapters/googleAdapter.ts @@ -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 = { }, verifyCredentials(ctx: AdapterVerifyContext): Promise { - 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)}`, {}); }, }; diff --git a/src/modelManagement/providers/adapters/openaiCompatibleAdapter.test.ts b/src/modelManagement/providers/adapters/openaiCompatibleAdapter.test.ts new file mode 100644 index 00000000..17c5d751 --- /dev/null +++ b/src/modelManagement/providers/adapters/openaiCompatibleAdapter.test.ts @@ -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; + +function provider(overrides: Partial = {}): 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)); + }); +}); diff --git a/src/modelManagement/providers/adapters/openaiCompatibleAdapter.ts b/src/modelManagement/providers/adapters/openaiCompatibleAdapter.ts index 2742faed..84376b6b 100644 --- a/src/modelManagement/providers/adapters/openaiCompatibleAdapter.ts +++ b/src/modelManagement/providers/adapters/openaiCompatibleAdapter.ts @@ -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 = { }, verifyCredentials(ctx: AdapterVerifyContext): Promise { - 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 = {}; + if (ctx.apiKey) { + headers["Authorization"] = `Bearer ${ctx.apiKey}`; + } + if (ctx.extras.openAIOrgId) { + headers["OpenAI-Organization"] = ctx.extras.openAIOrgId; + } + + return verifyViaListModels(`${base}/models`, headers); }, }; diff --git a/src/modelManagement/providers/adapters/verifyViaListModels.test.ts b/src/modelManagement/providers/adapters/verifyViaListModels.test.ts new file mode 100644 index 00000000..642bc475 --- /dev/null +++ b/src/modelManagement/providers/adapters/verifyViaListModels.test.ts @@ -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; + +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); + }); +}); diff --git a/src/modelManagement/providers/adapters/verifyViaListModels.ts b/src/modelManagement/providers/adapters/verifyViaListModels.ts new file mode 100644 index 00000000..b2e8aa87 --- /dev/null +++ b/src/modelManagement/providers/adapters/verifyViaListModels.ts @@ -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, + opts: VerifyViaListModelsOptions = {} +): Promise { + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + + let timer: number | undefined; + const timeoutPromise = new Promise((_, 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 { + 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 { + try { + const body = (await response.text()).trim(); + return body.length > MAX_BODY_CHARS ? `${body.slice(0, MAX_BODY_CHARS)}…` : body; + } catch { + return ""; + } +}