diff --git a/src/agent/index.ts b/src/agent/index.ts index 49f2cc70..e38ce91c 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -61,6 +61,7 @@ import { getProviderBaseUrlEnvKey, getProviderCredentialHint, getProviderLabel, + getProviderBaseUrlWarnings, getProviderModelOptions, getProviderRegionEnvKey, getProviderSecretKeyEnvKey, @@ -523,13 +524,19 @@ function ensureProviderBaseUrl(provider: OpenWikiProvider): void { return; } - if (!resolveProviderBaseUrl(provider)) { - const baseUrlEnvKey = getProviderBaseUrlEnvKey(provider) ?? "base URL"; + const baseUrlEnvKey = getProviderBaseUrlEnvKey(provider) ?? "base URL"; + const baseUrl = resolveProviderBaseUrl(provider); + if (!baseUrl) { throw new Error( `${baseUrlEnvKey} is required to run OpenWiki with ${getProviderLabel(provider)}.`, ); } + + const warnings = getProviderBaseUrlWarnings(provider, baseUrl); + if (warnings.length > 0) { + throw new Error(`${baseUrlEnvKey} is invalid: ${warnings.join(", ")}.`); + } } function ensureProviderSecretKey(provider: OpenWikiProvider): void { diff --git a/src/constants.ts b/src/constants.ts index 2c47fb84..d6a8b4cb 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -512,6 +512,39 @@ export function isValidBaseUrl(value: string): boolean { } } +export function getProviderBaseUrlWarnings( + provider: OpenWikiProvider, + value: string, +): string[] { + if (!isValidBaseUrl(value)) { + return ["invalid base URL"]; + } + + if (provider === "openai-compatible" && isChatCompletionsEndpointUrl(value)) { + return ["use API root URL, not /chat/completions endpoint"]; + } + + return []; +} + +export function isValidProviderBaseUrl( + provider: OpenWikiProvider, + value: string, +): boolean { + return getProviderBaseUrlWarnings(provider, value).length === 0; +} + +function isChatCompletionsEndpointUrl(value: string): boolean { + try { + const url = new URL(value.trim()); + const normalizedPath = url.pathname.replace(/\/+$/u, "").toLowerCase(); + + return normalizedPath.endsWith("/chat/completions"); + } catch { + return false; + } +} + export function getProviderModelOptions( provider: OpenWikiProvider, ): ProviderModelOption[] { diff --git a/src/credentials.tsx b/src/credentials.tsx index 9428c47c..62be961c 100644 --- a/src/credentials.tsx +++ b/src/credentials.tsx @@ -13,6 +13,7 @@ import { getMissingProviderEnvKey, getProviderApiKeyEnvKey, getProviderBaseUrlEnvKey, + getProviderBaseUrlWarnings, getProviderLabel, getProviderLocationEnvKey, getProviderModelOptions, @@ -20,7 +21,6 @@ import { getProviderRegionEnvKey, getProviderSecretKeyEnvKey, providerRequiresApiKey, - isValidBaseUrl, isValidModelId, normalizeProvider, normalizeModelId, @@ -1813,8 +1813,12 @@ export function InitSetup({ return; } - if (!isValidBaseUrl(trimmedInput)) { - setError("Enter a valid http(s) base URL."); + const baseUrlWarnings = getProviderBaseUrlWarnings( + provider, + trimmedInput, + ); + if (baseUrlWarnings.length > 0) { + setError(`Enter a valid base URL: ${baseUrlWarnings.join(", ")}.`); return; } diff --git a/src/env.ts b/src/env.ts index 4b50e0c9..13f6c89c 100644 --- a/src/env.ts +++ b/src/env.ts @@ -9,6 +9,7 @@ import { BEDROCK_AWS_REGION_ENV_KEY, BEDROCK_AWS_SECRET_ACCESS_KEY_ENV_KEY, FIREWORKS_API_KEY_ENV_KEY, + getProviderBaseUrlWarnings, GEMINI_API_KEY_ENV_KEY, GOOGLE_APPLICATION_CREDENTIALS_ENV_KEY, GOOGLE_CLOUD_LOCATION_ENV_KEY, @@ -346,7 +347,11 @@ function createCredentialDiagnostic( ? getProviderWarnings(value) : key === OPENWIKI_PROVIDER_RETRY_ATTEMPTS_ENV_KEY ? getRetryAttemptsWarnings(value) - : getCredentialWarnings(value), + : key === OPENAI_COMPATIBLE_BASE_URL_ENV_KEY + ? getProviderBaseUrlWarnings("openai-compatible", value) + : key === ANTHROPIC_BASE_URL_ENV_KEY + ? getProviderBaseUrlWarnings("anthropic", value) + : getCredentialWarnings(value), }; } diff --git a/test/constants.test.ts b/test/constants.test.ts index bdcee408..52d85bff 100644 --- a/test/constants.test.ts +++ b/test/constants.test.ts @@ -5,12 +5,14 @@ import { DEFAULT_PROVIDER, DEFAULT_VERTEX_LOCATION, getDefaultModelId, + getProviderBaseUrlWarnings, getMissingProviderEnvKey, getProviderApiKeyEnvKey, getProviderModelOptions, getProviderRegionEnvKey, getProviderSecretKeyEnvKey, isValidBaseUrl, + isValidProviderBaseUrl, isValidModelId, isValidProvider, NEBIUS_BASE_URL, @@ -242,6 +244,39 @@ describe("isValidBaseUrl", () => { }); }); +describe("isValidProviderBaseUrl", () => { + test("accepts OpenAI-compatible API root URLs", () => { + expect( + isValidProviderBaseUrl( + "openai-compatible", + "https://gateway.example.com/v1", + ), + ).toBe(true); + }); + + test("rejects OpenAI-compatible chat completions endpoint URLs", () => { + expect( + isValidProviderBaseUrl( + "openai-compatible", + "https://gateway.example.com/v1/chat/completions", + ), + ).toBe(false); + expect( + getProviderBaseUrlWarnings( + "openai-compatible", + "https://gateway.example.com/v1/chat/completions/", + ), + ).toContain("use API root URL, not /chat/completions endpoint"); + }); + + test("keeps generic http URL validation for other provider base URLs", () => { + expect(isValidProviderBaseUrl("anthropic", "not a url")).toBe(false); + expect( + getProviderBaseUrlWarnings("anthropic", "https://proxy.example.com"), + ).toEqual([]); + }); +}); + describe("getProviderModelOptions", () => { test("returns OpenAI models in display order", () => { expect(getProviderModelOptions("openai")).toEqual([ diff --git a/test/env-behavior.test.ts b/test/env-behavior.test.ts index e427a37b..efec5792 100644 --- a/test/env-behavior.test.ts +++ b/test/env-behavior.test.ts @@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { ANTHROPIC_API_KEY_ENV_KEY, ANTHROPIC_BASE_URL_ENV_KEY, + OPENAI_COMPATIBLE_BASE_URL_ENV_KEY, OPENAI_API_KEY_ENV_KEY, OPENROUTER_API_KEY_ENV_KEY, OPENWIKI_MODEL_ID_ENV_KEY, @@ -42,6 +43,7 @@ type EnvModule = typeof import("../src/env.ts"); const KEYS_UNDER_TEST = [ ANTHROPIC_API_KEY_ENV_KEY, ANTHROPIC_BASE_URL_ENV_KEY, + OPENAI_COMPATIBLE_BASE_URL_ENV_KEY, OPENAI_API_KEY_ENV_KEY, OPENROUTER_API_KEY_ENV_KEY, OPENWIKI_MODEL_ID_ENV_KEY, @@ -342,6 +344,22 @@ describe("getCredentialDiagnostics", () => { expect(entry?.warnings).toContain("invalid provider"); }); + test("flags an OpenAI-compatible chat completions endpoint as a base URL warning", async () => { + await env.saveOpenWikiEnv({ + [OPENAI_COMPATIBLE_BASE_URL_ENV_KEY]: + "https://gateway.example.com/v1/chat/completions", + }); + + const diagnostics = await env.getCredentialDiagnostics(); + const entry = diagnostics.find( + (item) => item.key === OPENAI_COMPATIBLE_BASE_URL_ENV_KEY, + ); + + expect(entry?.warnings).toContain( + "use API root URL, not /chat/completions endpoint", + ); + }); + test("prefers process.env over the file when both are set", async () => { await env.saveOpenWikiEnv({ [OPENROUTER_API_KEY_ENV_KEY]: "from-file" });