Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions src/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
getProviderBaseUrlEnvKey,
getProviderCredentialHint,
getProviderLabel,
getProviderBaseUrlWarnings,
getProviderModelOptions,
getProviderRegionEnvKey,
getProviderSecretKeyEnvKey,
Expand Down Expand Up @@ -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 {
Expand Down
33 changes: 33 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand Down
10 changes: 7 additions & 3 deletions src/credentials.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ import {
getMissingProviderEnvKey,
getProviderApiKeyEnvKey,
getProviderBaseUrlEnvKey,
getProviderBaseUrlWarnings,
getProviderLabel,
getProviderLocationEnvKey,
getProviderModelOptions,
getProviderProjectEnvKey,
getProviderRegionEnvKey,
getProviderSecretKeyEnvKey,
providerRequiresApiKey,
isValidBaseUrl,
isValidModelId,
normalizeProvider,
normalizeModelId,
Expand Down Expand Up @@ -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;
}

Expand Down
7 changes: 6 additions & 1 deletion src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
};
}

Expand Down
35 changes: 35 additions & 0 deletions test/constants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import {
DEFAULT_PROVIDER,
DEFAULT_VERTEX_LOCATION,
getDefaultModelId,
getProviderBaseUrlWarnings,
getMissingProviderEnvKey,
getProviderApiKeyEnvKey,
getProviderModelOptions,
getProviderRegionEnvKey,
getProviderSecretKeyEnvKey,
isValidBaseUrl,
isValidProviderBaseUrl,
isValidModelId,
isValidProvider,
NEBIUS_BASE_URL,
Expand Down Expand Up @@ -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([
Expand Down
18 changes: 18 additions & 0 deletions test/env-behavior.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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" });

Expand Down