Skip to content

Commit 9898e00

Browse files
authored
Merge pull request #173 from codedogQBY/fix/ai-endpoint-missing-protocol
fix(ai): auto-prepend http(s):// to scheme-less base URLs (#170)
2 parents 561dfac + 671d6e8 commit 9898e00

5 files changed

Lines changed: 96 additions & 6 deletions

File tree

packages/core/src/ai/test-endpoint.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,31 @@ async function fetchJson(
106106
contentType: response.headers.get("content-type"),
107107
});
108108
}
109-
return response.json();
109+
const rawBody = await response.text();
110+
try {
111+
return JSON.parse(rawBody);
112+
} catch {
113+
const contentType = response.headers.get("content-type") || "";
114+
if (debug) {
115+
logAIEndpointDebug("error", debug.endpoint, {
116+
action: debug.action,
117+
method: init?.method || "GET",
118+
requestUrl: url,
119+
model: debug.model,
120+
status: response.status,
121+
statusText: response.statusText,
122+
contentType,
123+
responseLength: rawBody.length,
124+
responseBodyPreview: summarizeDebugText(rawBody),
125+
});
126+
}
127+
const lookedLikeHtml = /^\s*<(!doctype|html)/i.test(rawBody) || contentType.includes("html");
128+
throw new Error(
129+
lookedLikeHtml
130+
? `Endpoint returned an HTML page instead of JSON (${url}). Make sure the base URL starts with http:// or https:// and points to the API root.`
131+
: `Endpoint did not return JSON (${url}). Make sure the base URL starts with http:// or https:// and points to the API root.`,
132+
);
133+
}
110134
}
111135

112136
async function listOpenAICompatibleModels(endpoint: AIEndpoint): Promise<string[]> {

packages/core/src/stores/settings-store.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,18 +147,22 @@ async function fetchOpenAIModels(endpoint: AIEndpoint): Promise<string[]> {
147147
try {
148148
data = JSON.parse(rawBody);
149149
} catch {
150+
const contentType = response.headers.get("content-type") || "";
150151
logAIEndpointDebug("error", endpoint, {
151152
action: "fetch-models",
152153
method: "GET",
153154
requestUrl,
154155
status: response.status,
155156
statusText: response.statusText,
156-
contentType: response.headers.get("content-type"),
157+
contentType,
157158
responseLength: rawBody.length,
158159
responseBodyPreview: summarizeDebugText(rawBody),
159160
});
161+
const lookedLikeHtml = /^\s*<(!doctype|html)/i.test(rawBody) || contentType.includes("html");
160162
throw new Error(
161-
"The endpoint did not return JSON. Check whether the base URL points to the API root instead of a console page.",
163+
lookedLikeHtml
164+
? "The endpoint returned an HTML page instead of JSON. Make sure the base URL starts with http:// or https:// and points to the API root, not a console/dashboard page."
165+
: "The endpoint did not return JSON. Make sure the base URL starts with http:// or https:// and points to the API root, not a console page.",
162166
);
163167
}
164168

packages/core/src/utils/api.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
22
import {
33
buildOpenAICompatibleUrl,
44
buildProviderModelsUrl,
5+
ensureUrlProtocol,
6+
formatApiHost,
57
providerSupportsExactRequestUrl,
68
resolveProviderBaseUrl,
79
} from "./api";
@@ -80,4 +82,45 @@ describe("AI API URL helpers", () => {
8082
buildProviderModelsUrl("google", "https://generativelanguage.googleapis.com", "AIza-test"),
8183
).toBe("https://generativelanguage.googleapis.com/v1beta/models?key=AIza-test");
8284
});
85+
86+
describe("ensureUrlProtocol / scheme-less inputs", () => {
87+
it("prepends https:// when a remote URL has no scheme", () => {
88+
expect(ensureUrlProtocol("api.openai.com/v1")).toBe("https://api.openai.com/v1");
89+
expect(ensureUrlProtocol("openrouter.ai/api/v1")).toBe("https://openrouter.ai/api/v1");
90+
});
91+
92+
it("prepends http:// for localhost / loopback hosts", () => {
93+
expect(ensureUrlProtocol("localhost:11434")).toBe("http://localhost:11434");
94+
expect(ensureUrlProtocol("127.0.0.1:8080/api")).toBe("http://127.0.0.1:8080/api");
95+
});
96+
97+
it("leaves already-protocoled URLs alone", () => {
98+
expect(ensureUrlProtocol("https://api.openai.com")).toBe("https://api.openai.com");
99+
expect(ensureUrlProtocol("http://localhost:1234")).toBe("http://localhost:1234");
100+
expect(ensureUrlProtocol(" https://api.openai.com ")).toBe("https://api.openai.com");
101+
});
102+
103+
it("returns empty string for empty / whitespace input", () => {
104+
expect(ensureUrlProtocol("")).toBe("");
105+
expect(ensureUrlProtocol(" ")).toBe("");
106+
});
107+
108+
it("strips leading slashes that would survive concatenation", () => {
109+
expect(ensureUrlProtocol("//api.example.com")).toBe("//api.example.com");
110+
expect(ensureUrlProtocol("/api.example.com")).toBe("https://api.example.com");
111+
});
112+
113+
it("flows through the provider URL builders so requests escape the webview", () => {
114+
expect(resolveProviderBaseUrl("openai", "api.openai.com")).toBe(
115+
"https://api.openai.com/v1",
116+
);
117+
expect(buildProviderModelsUrl("custom", "api.example.com")).toBe(
118+
"https://api.example.com/v1/models",
119+
);
120+
expect(buildProviderModelsUrl("ollama", "localhost:11434")).toBe(
121+
"http://localhost:11434/api/tags",
122+
);
123+
expect(formatApiHost("api.openai.com")).toBe("https://api.openai.com/v1/");
124+
});
125+
});
83126
});

packages/core/src/utils/api.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ const VERSION_PATTERN = /\/(v[1-9]\d*|api\/v[1-9]\d*|api\/paas\/v[1-9]\d*|compat
214214
export function formatApiHost(host: string): string {
215215
if (!host) return host;
216216

217-
host = host.trim();
217+
host = ensureUrlProtocol(host.trim());
218218

219219
if (host.endsWith("/")) {
220220
return host;
@@ -237,6 +237,24 @@ export function trimApiUrl(url: string): string {
237237
return url.replace(/\/+$/, "");
238238
}
239239

240+
/**
241+
* If the URL has no scheme, prepend https:// (or http:// for localhost / loopback).
242+
* Without this, scheme-less inputs like "api.openai.com/v1" get resolved by the
243+
* Tauri webview as relative paths against tauri://localhost and fall through to
244+
* the SPA's own index.html, producing the confusing "Unexpected token '<'" /
245+
* "endpoint did not return JSON" error chain on connect.
246+
*/
247+
export function ensureUrlProtocol(url: string): string {
248+
const trimmed = url.trim();
249+
if (!trimmed) return trimmed;
250+
if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(trimmed)) return trimmed;
251+
if (trimmed.startsWith("//")) return trimmed;
252+
const stripped = trimmed.replace(/^\/+/, "");
253+
const isLoopback =
254+
/^(localhost(?:[:/]|$)|127\.\d+\.\d+\.\d+|0\.0\.0\.0(?:[:/]|$)|\[::1?\])/i.test(stripped);
255+
return `${isLoopback ? "http://" : "https://"}${stripped}`;
256+
}
257+
240258
function sanitizeOpenAICompatibleBaseUrl(url: string): string {
241259
const trimmed = trimApiUrl(url);
242260

@@ -271,7 +289,7 @@ export function resolveProviderBaseUrl(
271289
baseUrl?: string,
272290
exactRequestUrl = false,
273291
): string {
274-
const rawBaseUrl = (baseUrl || getDefaultBaseUrl(providerId) || "").trim();
292+
const rawBaseUrl = ensureUrlProtocol((baseUrl || getDefaultBaseUrl(providerId) || "").trim());
275293
if (!rawBaseUrl) return "";
276294
const providerConfig = getProviderConfig(providerId);
277295
const trimmedRawBaseUrl = trimApiUrl(rawBaseUrl);
@@ -298,7 +316,7 @@ export function buildProviderModelsUrl(
298316
apiKey?: string,
299317
exactRequestUrl = false,
300318
): string {
301-
const rawBaseUrl = (baseUrl || getDefaultBaseUrl(providerId) || "").trim();
319+
const rawBaseUrl = ensureUrlProtocol((baseUrl || getDefaultBaseUrl(providerId) || "").trim());
302320
if (!rawBaseUrl) return "";
303321

304322
if (exactRequestUrl && providerSupportsExactRequestUrl(providerId)) {

packages/core/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export type { TimeGroup, GroupedThreads } from "./time-group";
2929
export {
3030
formatApiHost,
3131
trimApiUrl,
32+
ensureUrlProtocol,
3233
providerSupportsExactRequestUrl,
3334
resolveProviderBaseUrl,
3435
buildProviderModelsUrl,

0 commit comments

Comments
 (0)