Skip to content

Commit 0bba7f6

Browse files
committed
fix: clarify custom provider HTML responses
1 parent 9364b21 commit 0bba7f6

5 files changed

Lines changed: 141 additions & 5 deletions

File tree

src/agents/openai-transport-stream.test.ts

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1028,7 +1028,7 @@ describe("openai transport stream", () => {
10281028
}
10291029
});
10301030

1031-
it("parses JSON chat completions returned to streaming requests", async () => {
1031+
it("streams OpenAI-compatible non-streaming JSON completions as a fallback", async () => {
10321032
let capturedStreamFlag: unknown;
10331033
const server = createServer((req, res) => {
10341034
let body = "";
@@ -1193,7 +1193,61 @@ describe("openai transport stream", () => {
11931193
}
11941194
});
11951195

1196-
it("preserves reasoning tokens without double-counting them", () => {
1196+
it("adds a base URL hint when OpenAI-compatible streaming returns HTML", async () => {
1197+
const server = createServer((_req, res) => {
1198+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
1199+
res.end("<html><body>not an API endpoint</body></html>");
1200+
});
1201+
1202+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
1203+
try {
1204+
const address = server.address();
1205+
if (!address || typeof address === "string") {
1206+
throw new Error("Missing loopback server address");
1207+
}
1208+
const model = {
1209+
id: "deepseek-v4-flash",
1210+
name: "DeepSeek V4 Flash",
1211+
api: "openai-completions",
1212+
provider: "spanagent",
1213+
baseUrl: `http://127.0.0.1:${address.port}`,
1214+
reasoning: false,
1215+
input: ["text"],
1216+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1217+
contextWindow: 128_000,
1218+
maxTokens: 4096,
1219+
} satisfies Model<"openai-completions">;
1220+
const stream = createOpenAICompletionsTransportStreamFn()(
1221+
model,
1222+
{
1223+
systemPrompt: "system",
1224+
messages: [{ role: "user", content: "Reply ok", timestamp: Date.now() }],
1225+
tools: [],
1226+
} as never,
1227+
{ apiKey: "test-key" } as never,
1228+
);
1229+
1230+
let errorMessage = "";
1231+
for await (const event of stream as AsyncIterable<{
1232+
type: string;
1233+
error?: { errorMessage?: string };
1234+
}>) {
1235+
if (event.type === "error") {
1236+
errorMessage = event.error?.errorMessage ?? "";
1237+
}
1238+
}
1239+
1240+
expect(errorMessage).toContain("returned HTML instead of an API response");
1241+
expect(errorMessage).toContain("baseUrl includes the provider API path, such as /v1");
1242+
expect(errorMessage).toContain(`http://127.0.0.1:${address.port}`);
1243+
} finally {
1244+
await new Promise<void>((resolve, reject) => {
1245+
server.close((error) => (error ? reject(error) : resolve()));
1246+
});
1247+
}
1248+
});
1249+
1250+
it("does not double-count reasoning tokens and clamps uncached prompt usage at zero", () => {
11971251
const model = {
11981252
id: "gpt-5",
11991253
name: "GPT-5",

src/agents/openai-transport-stream.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,30 @@ function summarizeOpenAITransportError(error: unknown): string {
754754
].join(" ");
755755
}
756756

757+
function normalizeOpenAICompatibleErrorMessage(error: unknown, model: Model<Api>): string {
758+
const message = error instanceof Error ? error.message : JSON.stringify(error);
759+
const cause =
760+
error && typeof error === "object" && "cause" in error
761+
? (error as { cause?: unknown }).cause
762+
: undefined;
763+
const causeMessage =
764+
cause instanceof Error ? cause.message : typeof cause === "string" ? cause : "";
765+
const normalized = `${message}\n${causeMessage}`.toLowerCase();
766+
const pointsAtNonApiHtml =
767+
normalized.includes("text/html") ||
768+
normalized.includes("unexpected token '<'") ||
769+
normalized.includes("provider returned html") ||
770+
(normalized.includes("html") && normalized.includes("json"));
771+
if (!pointsAtNonApiHtml) {
772+
return message;
773+
}
774+
return (
775+
`${message}. The OpenAI-compatible provider returned HTML instead of an API response; ` +
776+
`check that baseUrl includes the provider API path, such as /v1. ` +
777+
`Configured baseUrl: ${formatModelTransportDebugBaseUrl(model.baseUrl)}`
778+
);
779+
}
780+
757781
function isInvalidEncryptedContentError(error: unknown): boolean {
758782
if (!error || typeof error !== "object") {
759783
return false;
@@ -2301,7 +2325,7 @@ function createOpenAICompletionsClient(
23012325
dangerouslyAllowBrowser: true,
23022326
defaultHeaders: clientConfig.defaultHeaders,
23032327
defaultQuery: clientConfig.defaultQuery,
2304-
fetch: buildGuardedModelFetch(model),
2328+
fetch: buildGuardedModelFetch(model, undefined, { rejectHtmlAsApiResponse: true }),
23052329
...buildOpenAISdkClientOptions(model),
23062330
});
23072331
}
@@ -2418,6 +2442,7 @@ export function createOpenAICompletionsTransportStreamFn(): StreamFn {
24182442
stream.end();
24192443
} catch (error) {
24202444
assignTransportErrorDetails(output, error, options?.signal);
2445+
output.errorMessage = normalizeOpenAICompatibleErrorMessage(error, model);
24212446
stream.push({ type: "error", reason: output.stopReason as never, error: output as never });
24222447
stream.end();
24232448
}

src/agents/provider-transport-fetch.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -495,7 +495,7 @@ function resolveModelTransportSsrFPolicy(params: {
495495
export function buildGuardedModelFetch(
496496
model: Model<Api>,
497497
timeoutMs?: number,
498-
options?: { sanitizeSse?: boolean },
498+
options?: { sanitizeSse?: boolean; rejectHtmlAsApiResponse?: boolean },
499499
): typeof fetch {
500500
const requestConfig = resolveModelRequestPolicy(model);
501501
const dispatcherPolicy = buildProviderRequestDispatcherPolicy(requestConfig);
@@ -614,6 +614,16 @@ export function buildGuardedModelFetch(
614614
headers,
615615
});
616616
}
617+
if (
618+
options?.rejectHtmlAsApiResponse === true &&
619+
response.ok &&
620+
/\btext\/html\b/i.test(response.headers.get("content-type") ?? "")
621+
) {
622+
await response.body?.cancel().catch(() => undefined);
623+
void result.release();
624+
localServiceLease?.release();
625+
throw new Error("OpenAI-compatible provider returned text/html instead of an API response");
626+
}
617627
response = buildManagedResponse(
618628
response,
619629
result.release,

src/commands/onboard-custom.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,35 @@ describe("promptCustomApiConfig", () => {
163163
expect(prompter.select).toHaveBeenCalledTimes(3);
164164
});
165165

166+
it("rejects successful non-json verification responses with a base URL hint", async () => {
167+
const prompter = createTestPrompter({
168+
text: ["https://spanagent.xyz", "test-key", "bad-model", "good-model", "custom", ""],
169+
select: ["plaintext", "openai", "model"],
170+
});
171+
const fetchMock = vi
172+
.fn()
173+
.mockResolvedValueOnce({
174+
ok: true,
175+
status: 200,
176+
json: async () => {
177+
throw new SyntaxError("Unexpected token '<'");
178+
},
179+
})
180+
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ id: "ok" }) });
181+
vi.stubGlobal("fetch", fetchMock);
182+
183+
await runPromptCustomApi(prompter);
184+
185+
const stopMessages = prompter.progress.mock.results.flatMap((result) => {
186+
const progress = result.value as { stop: ReturnType<typeof vi.fn> };
187+
return progress.stop.mock.calls.map(([message]) => message);
188+
});
189+
expect(stopMessages).toContain(
190+
"Verification failed: Verification response was not JSON. Check that the base URL includes the provider API path, for example /v1 for OpenAI-compatible servers.",
191+
);
192+
expect(prompter.select).toHaveBeenCalledTimes(3);
193+
});
194+
166195
it("detects openai compatibility when unknown", async () => {
167196
const prompter = createTestPrompter({
168197
text: ["https://example.com/v1", "test-key", "detected-model", "custom", "alias"],

src/commands/onboard-custom.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,24 @@ type VerificationResult = {
8989
error?: unknown;
9090
};
9191

92+
const NON_JSON_VERIFICATION_HINT =
93+
"Verification response was not JSON. Check that the base URL includes the provider API path, for example /v1 for OpenAI-compatible servers.";
94+
95+
async function validateSuccessfulVerificationResponse(res: Response): Promise<VerificationResult> {
96+
if (!res.ok) {
97+
return { ok: false, status: res.status };
98+
}
99+
try {
100+
const payload: unknown = await res.json();
101+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
102+
return { ok: false, error: new Error(NON_JSON_VERIFICATION_HINT) };
103+
}
104+
} catch {
105+
return { ok: false, error: new Error(NON_JSON_VERIFICATION_HINT) };
106+
}
107+
return { ok: true, status: res.status };
108+
}
109+
92110
async function requestVerification(params: {
93111
endpoint: string;
94112
headers: Record<string, string>;
@@ -107,7 +125,7 @@ async function requestVerification(params: {
107125
},
108126
VERIFY_TIMEOUT_MS,
109127
);
110-
return { ok: res.ok, status: res.status };
128+
return await validateSuccessfulVerificationResponse(res);
111129
} catch (error) {
112130
return { ok: false, error };
113131
}

0 commit comments

Comments
 (0)