From 33195aa25f7c94d46a285339edd006eea856af02 Mon Sep 17 00:00:00 2001 From: HwangJohn Date: Tue, 14 Jul 2026 21:13:55 +0900 Subject: [PATCH] fix: validate openai-compatible base urls --- src/agent/index.ts | 11 +++++++++-- src/constants.ts | 33 +++++++++++++++++++++++++++++++++ src/credentials.tsx | 10 +++++++--- src/env.ts | 7 ++++++- test/constants.test.ts | 35 +++++++++++++++++++++++++++++++++++ test/env-behavior.test.ts | 18 ++++++++++++++++++ 6 files changed, 108 insertions(+), 6 deletions(-) diff --git a/src/agent/index.ts b/src/agent/index.ts index 0f9455c4..1c44e74e 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -42,6 +42,7 @@ import { getProviderApiKeyEnvKey, getProviderBaseUrlEnvKey, getProviderLabel, + getProviderBaseUrlWarnings, isValidModelId, normalizeModelId, OPENAI_COMPATIBLE_BASE_URL_ENV_KEY, @@ -417,13 +418,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 resolveModelId( diff --git a/src/constants.ts b/src/constants.ts index cb62841f..f93a9e31 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -288,6 +288,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 9b3ae8bf..905f8bdd 100644 --- a/src/credentials.tsx +++ b/src/credentials.tsx @@ -11,9 +11,9 @@ import { getDefaultModelId, getProviderApiKeyEnvKey, getProviderBaseUrlEnvKey, + getProviderBaseUrlWarnings, getProviderLabel, getProviderModelOptions, - isValidBaseUrl, isValidModelId, normalizeProvider, normalizeModelId, @@ -1167,8 +1167,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 2db41781..da24e894 100644 --- a/src/env.ts +++ b/src/env.ts @@ -6,6 +6,7 @@ import { ANTHROPIC_BASE_URL_ENV_KEY, BASETEN_API_KEY_ENV_KEY, FIREWORKS_API_KEY_ENV_KEY, + getProviderBaseUrlWarnings, isValidModelId, normalizeProvider, NVIDIA_API_KEY_ENV_KEY, @@ -237,7 +238,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 bbcae399..37919903 100644 --- a/test/constants.test.ts +++ b/test/constants.test.ts @@ -4,8 +4,10 @@ import { DEFAULT_PROVIDER_RETRY_ATTEMPTS, DEFAULT_PROVIDER, getDefaultModelId, + getProviderBaseUrlWarnings, getProviderModelOptions, isValidBaseUrl, + isValidProviderBaseUrl, isValidModelId, isValidProvider, normalizeModelId, @@ -168,6 +170,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 03bace18..2f80460c 100644 --- a/test/env-behavior.test.ts +++ b/test/env-behavior.test.ts @@ -18,6 +18,7 @@ import { 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, @@ -37,6 +38,7 @@ import { 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, @@ -243,6 +245,22 @@ describe("getCredentialDiagnostics", () => { expect(entry?.warnings).toContain("invalid provider"); }); + test("flags an OpenAI-compatible chat completions endpoint as a base URL warning", async () => { + await saveOpenWikiEnv({ + [OPENAI_COMPATIBLE_BASE_URL_ENV_KEY]: + "https://gateway.example.com/v1/chat/completions", + }); + + const diagnostics = await 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 saveOpenWikiEnv({ [OPENROUTER_API_KEY_ENV_KEY]: "from-file" });