mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
implement verification logic
This commit is contained in:
parent
aaf72c19a1
commit
21b5244b99
11 changed files with 695 additions and 24 deletions
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
88
src/modelManagement/providers/adapters/azureAdapter.test.ts
Normal file
88
src/modelManagement/providers/adapters/azureAdapter.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
},
|
||||
|
|
|
|||
92
src/modelManagement/providers/adapters/googleAdapter.test.ts
Normal file
92
src/modelManagement/providers/adapters/googleAdapter.test.ts
Normal 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",
|
||||
{}
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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)}`, {});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
108
src/modelManagement/providers/adapters/verifyViaListModels.ts
Normal file
108
src/modelManagement/providers/adapters/verifyViaListModels.ts
Normal 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 "";
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue