mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-22 23:02:18 +03:00
Compare commits
3 Commits
fix/13452-
...
fix/13232-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bd058824b | ||
|
|
a24562ece3 | ||
|
|
f0d2d34ee5 |
@@ -0,0 +1 @@
|
||||
- **fix(sse):** classify a missing Playwright Chromium install on the Z.ai web transport as an actionable 503 host/config cooldown instead of a generic 502 that trips the provider circuit breaker (#13232) — thanks @oleksandr1811
|
||||
@@ -1 +0,0 @@
|
||||
- **fix(sse):** stop an unhydrated `openai-compatible-*`/`anthropic-compatible-*` connection from silently routing chat requests (and its stored credential) to the real OpenAI/Anthropic API instead of the operator's configured provider-node endpoint (#13452) — thanks @DenXio101
|
||||
@@ -319,24 +319,3 @@ export function getClaudeCodeDefaultModels(): {
|
||||
haiku: find(/haiku/i),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* #13452: shared guard for `*-compatible-*` executors' `buildUrl()`.
|
||||
* `BaseExecutor`/`DefaultExecutor` used to default an `openai-compatible-*`
|
||||
* / `anthropic-compatible-*` connection to the real OpenAI/Anthropic API
|
||||
* when `credentials.providerSpecificData.baseUrl` was absent — silently
|
||||
* shipping the connection's own stored "API key" as a Bearer/x-api-key
|
||||
* token to a public third party instead of the operator's intended
|
||||
* local/self-hosted endpoint. `provider` is embedded in the thrown error
|
||||
* only for operator debuggability — it is never sent upstream.
|
||||
*/
|
||||
export function requireCompatibleBaseUrl(
|
||||
provider: string | null | undefined,
|
||||
providerSpecificData: { baseUrl?: unknown } | null | undefined
|
||||
): string {
|
||||
const baseUrl = providerSpecificData?.baseUrl;
|
||||
if (typeof baseUrl === "string" && baseUrl) return baseUrl;
|
||||
throw new Error(
|
||||
`provider node "${provider}" has no baseUrl — node missing or connection not hydrated`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts";
|
||||
import { getRegistryEntry, requireCompatibleBaseUrl } from "../config/providerRegistry.ts";
|
||||
import { getRegistryEntry } from "../config/providerRegistry.ts";
|
||||
import { resolveFetchStartTimeout } from "../utils/fetchStartTimeoutPolicy.ts";
|
||||
import {
|
||||
resolveAlternateFormat,
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
addParamToBlocklist,
|
||||
isAutoLearnGloballyEnabled,
|
||||
} from "@/lib/db/paramFilters";
|
||||
import { applyFingerprint, isCliCompatEnabled, stripInternalBodyFields } from "../config/cliFingerprints.ts"; // prettier-ignore
|
||||
import { applyFingerprint, isCliCompatEnabled, stripInternalBodyFields } from "../config/cliFingerprints.ts";
|
||||
import { supportsClaudeMaxEffort, supportsXHighEffort } from "../config/providerModels.ts";
|
||||
import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts";
|
||||
import {
|
||||
@@ -380,7 +380,7 @@ export class BaseExecutor {
|
||||
void stream;
|
||||
if (this.provider?.startsWith?.("openai-compatible-")) {
|
||||
const psd = credentials?.providerSpecificData;
|
||||
const baseUrl = requireCompatibleBaseUrl(this.provider, psd); // #13452
|
||||
const baseUrl = typeof psd?.baseUrl === "string" ? psd.baseUrl : "https://api.openai.com/v1";
|
||||
const normalized = baseUrl.replace(/\/$/, "");
|
||||
// Sanitize custom path: must start with '/', no path traversal, no null bytes
|
||||
const rawPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null;
|
||||
|
||||
18
open-sse/executors/browserExecutableCheck.ts
Normal file
18
open-sse/executors/browserExecutableCheck.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Shared classification for browser-backed executors: distinguishes a missing Playwright
|
||||
* Chromium binary (`chromium.launch: Executable doesn't exist at ...`) from a transient upstream
|
||||
* fault. This is a host/config problem, not something a retry loop can fix, so executors must
|
||||
* NOT surface it as a plain retryable 5xx (which marks the account unavailable / trips the
|
||||
* provider circuit breaker). Originally added for `gemini-web.ts` (#3516); extracted here so
|
||||
* every browser-backed executor (Gemini Web, Z.ai Web, ...) can share the same detection.
|
||||
*/
|
||||
export function isMissingBrowserExecutable(message: string): boolean {
|
||||
if (!message) return false;
|
||||
const lower = message.toLowerCase();
|
||||
return (
|
||||
lower.includes("executable doesn't exist") ||
|
||||
lower.includes("executablenotfound") ||
|
||||
lower.includes("playwright install") ||
|
||||
(lower.includes("chromium") && lower.includes("download"))
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
joinClaudeCodeCompatibleUrl,
|
||||
} from "../services/claudeCodeCompatible.ts";
|
||||
import { getGigachatAccessToken } from "../services/gigachatAuth.ts";
|
||||
import { getRegistryEntry, requireCompatibleBaseUrl } from "../config/providerRegistry.ts";
|
||||
import { getRegistryEntry } from "../config/providerRegistry.ts";
|
||||
import { getModelTargetFormat } from "../config/providerModels.ts";
|
||||
import {
|
||||
mergeClientAnthropicBeta,
|
||||
@@ -231,7 +231,7 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
void urlIndex;
|
||||
if (this.provider?.startsWith?.("openai-compatible-")) {
|
||||
const psd = credentials?.providerSpecificData;
|
||||
const baseUrl = requireCompatibleBaseUrl(this.provider, psd); // #13452
|
||||
const baseUrl = psd?.baseUrl || "https://api.openai.com/v1";
|
||||
const normalized = baseUrl.replace(/\/$/, "");
|
||||
const customPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null;
|
||||
if (customPath) return `${normalized}${customPath}`;
|
||||
@@ -244,7 +244,7 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
}
|
||||
if (this.provider?.startsWith?.("anthropic-compatible-")) {
|
||||
const psd = credentials?.providerSpecificData;
|
||||
const baseUrl = requireCompatibleBaseUrl(this.provider, psd); // #13452
|
||||
const baseUrl = psd?.baseUrl || "https://api.anthropic.com/v1";
|
||||
const customPath = typeof psd?.chatPath === "string" && psd.chatPath ? psd.chatPath : null;
|
||||
if (isClaudeCodeCompatible(this.provider)) {
|
||||
return joinClaudeCodeCompatibleUrl(
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
import { BaseExecutor, type ExecuteInput } from "./base.ts";
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "../utils/error.ts";
|
||||
import { isMissingBrowserExecutable } from "./browserExecutableCheck.ts";
|
||||
import { normalizeGeminiCookieInput } from "../utils/geminiCookies.ts";
|
||||
import { prepareToolMessages } from "../translator/webTools.ts";
|
||||
import { buildToolModeResponse } from "./chatgptWebTools.ts";
|
||||
@@ -27,22 +28,12 @@ import {
|
||||
|
||||
const GEMINI_URL = "https://gemini.google.com/app";
|
||||
|
||||
/**
|
||||
* Whether an error came from Playwright failing to launch because the browser binary is not
|
||||
* installed (`chromium.launch: Executable doesn't exist at ...`). This is a host/config
|
||||
* problem, not a transient upstream fault, so the executor must NOT surface it as a retryable
|
||||
* 500 (which marks the account unavailable and loops / trips the provider breaker). See #3516.
|
||||
*/
|
||||
export function isMissingBrowserExecutable(message: string): boolean {
|
||||
if (!message) return false;
|
||||
const lower = message.toLowerCase();
|
||||
return (
|
||||
lower.includes("executable doesn't exist") ||
|
||||
lower.includes("executablenotfound") ||
|
||||
lower.includes("playwright install") ||
|
||||
(lower.includes("chromium") && lower.includes("download"))
|
||||
);
|
||||
}
|
||||
// Re-exported for backward compatibility: some tests/callers import this classification helper
|
||||
// from gemini-web.ts, its original home (#3516). The implementation now lives in
|
||||
// browserExecutableCheck.ts so other browser-backed executors (e.g. zai-web.ts, #13232) can
|
||||
// share it without importing this whole executor module.
|
||||
export { isMissingBrowserExecutable } from "./browserExecutableCheck.ts";
|
||||
|
||||
const GEMINI_USER_AGENT =
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
makeZaiChunkEmitter,
|
||||
} from "./zai-web/stream.ts";
|
||||
import { browserBackedChat } from "../services/browserBackedChat.ts";
|
||||
import { isMissingBrowserExecutable } from "./browserExecutableCheck.ts";
|
||||
import { CursorImageError, resolveCursorImages } from "../utils/cursorImages.ts";
|
||||
import {
|
||||
makeExecutorErrorResult as makeErrorResult,
|
||||
@@ -424,9 +425,26 @@ export class ZaiWebExecutor extends BaseExecutor {
|
||||
try {
|
||||
result = await browserBackedChat(buildZaiBrowserChatOptions({ ...input, attachments }));
|
||||
} catch (error) {
|
||||
const message = sanitizeErrorMessage(
|
||||
error instanceof Error ? error.message : "browser transport unavailable"
|
||||
);
|
||||
const rawMessage = error instanceof Error ? error.message : "browser transport unavailable";
|
||||
// #13232: a missing Playwright browser binary is a host/config problem, not a transient
|
||||
// upstream fault (same class as #3516 in gemini-web.ts). Surface an actionable message and
|
||||
// tag it with the connection-cooldown hint so accountFallback skips the whole-provider
|
||||
// circuit breaker (502/500 would trip it) and applies a short, non-exponential cooldown
|
||||
// instead.
|
||||
if (isMissingBrowserExecutable(rawMessage)) {
|
||||
return {
|
||||
errorResult: makeErrorResult(
|
||||
503,
|
||||
"Z.ai requires the Playwright Chromium browser, which is not installed. " +
|
||||
"Run `npx playwright install chromium` on the host (or rebuild the Docker image " +
|
||||
"with browsers).",
|
||||
input.body,
|
||||
ZAI_CHAT_URL,
|
||||
{ "X-Omni-Fallback-Hint": "connection_cooldown" }
|
||||
),
|
||||
};
|
||||
}
|
||||
const message = sanitizeErrorMessage(rawMessage);
|
||||
return {
|
||||
errorResult: makeErrorResult(
|
||||
502,
|
||||
|
||||
@@ -1134,7 +1134,8 @@ export function makeExecutorErrorResult(
|
||||
status: number,
|
||||
message: string,
|
||||
body: unknown,
|
||||
url: string
|
||||
url: string,
|
||||
extraResponseHeaders?: Record<string, string>
|
||||
) {
|
||||
return {
|
||||
response: new Response(
|
||||
@@ -1145,7 +1146,10 @@ export function makeExecutorErrorResult(
|
||||
code: `HTTP_${status}`,
|
||||
},
|
||||
}),
|
||||
{ status, headers: { "Content-Type": "application/json" } }
|
||||
{
|
||||
status,
|
||||
headers: { "Content-Type": "application/json", ...extraResponseHeaders },
|
||||
}
|
||||
),
|
||||
url,
|
||||
headers: {} as Record<string, string>,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { nodeTypeFromId } from "@/lib/db/providerNodeSelect";
|
||||
import { hydrateConnectionProviderSpecificData } from "./compatibleNodeBaseUrl.ts"; // #13452
|
||||
import { extractGoogApiKeyHeader } from "./googApiKeyAuth.ts";
|
||||
import { describeUpstreamFailure } from "@/shared/utils/upstreamError";
|
||||
import { buildAllExpiredCredentials } from "./authExpiredCredentials.ts";
|
||||
@@ -1040,6 +1039,33 @@ function planLastUsedCommit(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Proxy Pool references on a real connection row at the same boundary
|
||||
* where credentials become request-ready. The synthetic no-auth fallback above
|
||||
* already performs this hydration, but a persisted connection (for example the
|
||||
* OpenCode card's `opencode` row selected through the `opencode-zen` alias)
|
||||
* bypasses that fallback. Keep inline/legacy entries untouched and only incur a
|
||||
* registry lookup when at least one by-id reference is present.
|
||||
*/
|
||||
async function hydrateAccountProxyReferences(
|
||||
providerSpecificData: JsonRecord
|
||||
): Promise<JsonRecord> {
|
||||
const entries = providerSpecificData.accountProxies;
|
||||
if (!Array.isArray(entries)) return providerSpecificData;
|
||||
|
||||
const containsProxyReference = entries.some((entry) => {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
|
||||
const proxyId = (entry as Record<string, unknown>).proxyId;
|
||||
return typeof proxyId === "string" && proxyId.trim().length > 0;
|
||||
});
|
||||
if (!containsProxyReference) return providerSpecificData;
|
||||
|
||||
return {
|
||||
...providerSpecificData,
|
||||
accountProxies: await resolveAccountProxiesFromRegistry(entries),
|
||||
};
|
||||
}
|
||||
|
||||
async function materializeConnection(
|
||||
connection: ProviderConnectionView,
|
||||
options: CredentialSelectionOptions,
|
||||
@@ -1048,7 +1074,7 @@ async function materializeConnection(
|
||||
reactivatedFromInactive?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
const providerSpecificData = await hydrateConnectionProviderSpecificData(connection);
|
||||
const providerSpecificData = await hydrateAccountProxyReferences(connection.providerSpecificData);
|
||||
const apiKeyHealth = providerSpecificData.apiKeyHealth as Record<string, KeyHealth> | undefined;
|
||||
if (apiKeyHealth) syncHealthFromDB(connection.id, apiKeyHealth);
|
||||
const releaseOAuthSession =
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* #13452: self-heal a `*-compatible-*` connection whose `providerSpecificData`
|
||||
* never received the write-time `baseUrl` copy (`POST /api/providers`'s
|
||||
* hydration branch, or the `PUT /api/provider-nodes/{id}` backfill loop are
|
||||
* the only two places that stamp it). A connection created any other way —
|
||||
* a direct DB insert, a row that predates the hydration logic, or a race
|
||||
* with node update/deletion — has no `providerSpecificData.baseUrl` at all
|
||||
* and, left unfixed, `DefaultExecutor.buildUrl()`/`BaseExecutor.buildUrl()`
|
||||
* silently defaulted to the REAL OpenAI/Anthropic API, shipping the
|
||||
* connection's own stored credential there as a Bearer/x-api-key token.
|
||||
*
|
||||
* Extracted out of `auth.ts` into its own module (`file-size-baseline.json`
|
||||
* freezes `auth.ts`'s line count).
|
||||
*
|
||||
* @module sse/services/compatibleNodeBaseUrl
|
||||
*/
|
||||
|
||||
import { getCachedProviderNodes } from "@/lib/db/readCache";
|
||||
import { selectProviderNodeForConnection } from "@/lib/db/providerNodeSelect";
|
||||
import { isCompatibleProviderConnectionId } from "@/shared/utils/compatibleProviderId";
|
||||
import { resolveAccountProxiesFromRegistry } from "./noAuthProxyResolution";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Composes both boundary-hydration steps a persisted connection's
|
||||
* `providerSpecificData` needs before it becomes request-ready credentials:
|
||||
* Proxy Pool by-id references (moved here from `auth.ts` verbatim — was
|
||||
* `hydrateAccountProxyReferences`), then the `*-compatible-*` baseUrl
|
||||
* self-heal below. Keep inline/legacy `accountProxies` entries untouched and
|
||||
* only incur a proxy-registry lookup when at least one by-id reference is
|
||||
* present.
|
||||
*/
|
||||
export async function hydrateConnectionProviderSpecificData(connection: {
|
||||
provider: string;
|
||||
providerSpecificData: JsonRecord;
|
||||
}): Promise<JsonRecord> {
|
||||
const { providerSpecificData } = connection;
|
||||
const entries = providerSpecificData.accountProxies;
|
||||
const containsProxyReference =
|
||||
Array.isArray(entries) &&
|
||||
entries.some((entry) => {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
|
||||
const proxyId = (entry as Record<string, unknown>).proxyId;
|
||||
return typeof proxyId === "string" && proxyId.trim().length > 0;
|
||||
});
|
||||
const proxyHydrated = containsProxyReference
|
||||
? { ...providerSpecificData, accountProxies: await resolveAccountProxiesFromRegistry(entries) }
|
||||
: providerSpecificData;
|
||||
return hydrateCompatibleNodeBaseUrl(connection.provider, proxyHydrated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-joins `provider_nodes` (via the existing 5s-TTL cache, so this is not
|
||||
* an extra DB read on the hot path) and stamps the same fields the write-time
|
||||
* hydration branch stamps. Returns `providerSpecificData` unchanged when the
|
||||
* connection is not a compatible-node connection, already carries a
|
||||
* `baseUrl`, or no matching node can be resolved (the executor's fail-loud
|
||||
* fallback then reports the true "node missing" condition instead of
|
||||
* silently routing to a public third party).
|
||||
*/
|
||||
async function hydrateCompatibleNodeBaseUrl(
|
||||
provider: string,
|
||||
providerSpecificData: JsonRecord
|
||||
): Promise<JsonRecord> {
|
||||
if (typeof providerSpecificData.baseUrl === "string" && providerSpecificData.baseUrl) {
|
||||
return providerSpecificData;
|
||||
}
|
||||
if (!isCompatibleProviderConnectionId(provider)) return providerSpecificData;
|
||||
|
||||
try {
|
||||
const nodes = (await getCachedProviderNodes()) as JsonRecord[];
|
||||
const node = selectProviderNodeForConnection(provider, nodes);
|
||||
if (!node || typeof node.baseUrl !== "string" || !node.baseUrl) return providerSpecificData;
|
||||
|
||||
return {
|
||||
...providerSpecificData,
|
||||
prefix: providerSpecificData.prefix ?? node.prefix,
|
||||
apiType: providerSpecificData.apiType ?? node.apiType,
|
||||
baseUrl: node.baseUrl,
|
||||
nodeName: providerSpecificData.nodeName ?? node.name,
|
||||
...(node.chatPath && !providerSpecificData.chatPath ? { chatPath: node.chatPath } : {}),
|
||||
...(node.modelsPath && !providerSpecificData.modelsPath
|
||||
? { modelsPath: node.modelsPath }
|
||||
: {}),
|
||||
...(node.customHeaders && !providerSpecificData.customHeaders
|
||||
? { customHeaders: node.customHeaders }
|
||||
: {}),
|
||||
};
|
||||
} catch {
|
||||
// Best-effort self-heal only — a transient DB/cache read failure must
|
||||
// not throw here; the executor's own fail-loud fallback is the backstop.
|
||||
return providerSpecificData;
|
||||
}
|
||||
}
|
||||
@@ -601,8 +601,9 @@ test("DefaultExecutor.execute uses CC-compatible connection defaults to append 1
|
||||
stream: false,
|
||||
credentials: {
|
||||
apiKey: "cc-key",
|
||||
// #13452: buildUrl() now requires a hydrated baseUrl.
|
||||
providerSpecificData: { ccSessionId: "session-1", baseUrl: "https://cc.test/v1" },
|
||||
providerSpecificData: {
|
||||
ccSessionId: "session-1",
|
||||
},
|
||||
},
|
||||
clientHeaders: {
|
||||
"x-app": "cli",
|
||||
@@ -622,7 +623,6 @@ test("DefaultExecutor.execute uses CC-compatible connection defaults to append 1
|
||||
apiKey: "cc-key",
|
||||
providerSpecificData: {
|
||||
ccSessionId: "session-1",
|
||||
baseUrl: "https://cc.test/v1",
|
||||
requestDefaults: { context1m: true, redactThinking: true },
|
||||
},
|
||||
},
|
||||
@@ -658,7 +658,6 @@ test("DefaultExecutor.execute uses CC-compatible connection defaults to append 1
|
||||
apiKey: "cc-key",
|
||||
providerSpecificData: {
|
||||
ccSessionId: "session-1",
|
||||
baseUrl: "https://cc-proxy.example.test/v1",
|
||||
requestDefaults: { context1m: true },
|
||||
},
|
||||
},
|
||||
@@ -735,7 +734,9 @@ test("DefaultExecutor.execute reports the exact serialized provider request befo
|
||||
stream: false,
|
||||
credentials: {
|
||||
apiKey: "cc-key",
|
||||
providerSpecificData: { ccSessionId: "session-1", baseUrl: "https://cc.test/v1" }, // #13452
|
||||
providerSpecificData: {
|
||||
ccSessionId: "session-1",
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { DefaultExecutor } from "../../open-sse/executors/default.ts";
|
||||
import { BaseExecutor } from "../../open-sse/executors/base.ts";
|
||||
|
||||
// Issue #13452 — Bug 2: a provider-node ("openai-compatible-*" /
|
||||
// "anthropic-compatible-*") connection whose credentials carry no
|
||||
// providerSpecificData.baseUrl (e.g. a connection created any way other than
|
||||
// the exact POST /api/providers hydration branch, or the node-update
|
||||
// backfill loop) used to silently fall back to the literal
|
||||
// "https://api.openai.com/v1" / "https://api.anthropic.com/v1" instead of
|
||||
// erroring or re-resolving the node's configured baseUrl. Real traffic meant
|
||||
// for a local OpenAI-compatible endpoint (Ollama/vLLM/LM Studio) was instead
|
||||
// sent to the real OpenAI API, carrying whatever string was stored as the
|
||||
// "API key" as a Bearer token to a public third party.
|
||||
//
|
||||
// Fix: buildUrl() now fails loudly (throws) instead of defaulting, and the
|
||||
// credential-selection read path (src/sse/services/auth.ts) self-heals by
|
||||
// re-joining provider_nodes before a request ever reaches buildUrl().
|
||||
|
||||
test("issue #13452: DefaultExecutor openai-compatible buildUrl must not silently fall back to the real OpenAI API when providerSpecificData.baseUrl is absent", () => {
|
||||
const nodeId = "openai-compatible-chat-test-node";
|
||||
const executor = new DefaultExecutor(nodeId);
|
||||
|
||||
// Simulates the credentials row a hand-created / non-hydrated connection
|
||||
// produces: no providerSpecificData.baseUrl at all, even though the node
|
||||
// itself (in provider_nodes) has baseUrl = "http://localhost:11434/v1".
|
||||
const credentialsWithoutHydration = { apiKey: "ollama" };
|
||||
|
||||
assert.throws(
|
||||
() => executor.buildUrl("qwen3.6:35b-a3b", false, 0, credentialsWithoutHydration),
|
||||
/baseUrl/,
|
||||
"buildUrl() must fail loudly instead of silently defaulting an unhydrated openai-compatible " +
|
||||
"connection to the real OpenAI API — this WAS the reported bug (#13452)"
|
||||
);
|
||||
});
|
||||
|
||||
test("issue #13452: BaseExecutor openai-compatible buildUrl must not silently fall back to the real OpenAI API when providerSpecificData.baseUrl is absent", () => {
|
||||
const executor = new BaseExecutor("openai-compatible-responses-test-node", {});
|
||||
|
||||
assert.throws(() => executor.buildUrl("gpt-5.4", true, 0, { apiKey: "local-key" }), /baseUrl/);
|
||||
});
|
||||
|
||||
test("issue #13452: DefaultExecutor anthropic-compatible buildUrl must not silently fall back to the real Anthropic API when providerSpecificData.baseUrl is absent", () => {
|
||||
const executor = new DefaultExecutor("anthropic-compatible-test-node");
|
||||
|
||||
assert.throws(
|
||||
() => executor.buildUrl("claude-sonnet-4-6", true, 0, { apiKey: "local-key" }),
|
||||
/baseUrl/
|
||||
);
|
||||
});
|
||||
|
||||
test("issue #13452 (control): providing providerSpecificData.baseUrl routes correctly (confirms the fallback, not buildUrl() itself, was the defect)", () => {
|
||||
const nodeId = "openai-compatible-chat-test-node-2";
|
||||
const executor = new DefaultExecutor(nodeId);
|
||||
|
||||
const hydratedCredentials = {
|
||||
apiKey: "ollama",
|
||||
providerSpecificData: { baseUrl: "http://localhost:11434/v1" },
|
||||
};
|
||||
|
||||
const url = executor.buildUrl("qwen3.6:35b-a3b", false, 0, hydratedCredentials);
|
||||
|
||||
assert.equal(url, "http://localhost:11434/v1/chat/completions");
|
||||
});
|
||||
83
tests/unit/zai-web-missing-browser-executable-13232.test.ts
Normal file
83
tests/unit/zai-web-missing-browser-executable-13232.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Regression for GitHub issue #13232 — "[BUG] Z.ai web error".
|
||||
*
|
||||
* The Z.ai web transport drives a real headed Chromium browser (via Playwright) to get past
|
||||
* Z.ai's CAPTCHA. When the local Playwright Chromium binary is missing,
|
||||
* `browserType.launch()` throws "Executable doesn't exist at ...". Before this fix, zai-web.ts
|
||||
* had no classification for that failure and surfaced it as a plain 502 with no fallback hint —
|
||||
* a status that trips the whole-provider circuit breaker (`AGENTS.md` → "Provider Circuit
|
||||
* Breaker") as if the upstream itself were failing, instead of applying the intended
|
||||
* host/config connection cooldown. This mirrors the exact failure class already handled for
|
||||
* Gemini Web in #3516 (`isMissingBrowserExecutable`, now shared via
|
||||
* `open-sse/executors/browserExecutableCheck.ts`).
|
||||
*/
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
const mod = await import("../../open-sse/executors/zai-web.ts");
|
||||
|
||||
const TEST_TOKEN = `e30.${Buffer.from(JSON.stringify({ id: "user-123" })).toString("base64url")}.sig`;
|
||||
|
||||
describe("issue #13232 — Z.ai browser transport classifies a missing Chromium install", () => {
|
||||
let emptyBrowsersDir: string;
|
||||
let originalBrowsersPath: string | undefined;
|
||||
|
||||
before(() => {
|
||||
emptyBrowsersDir = fs.mkdtempSync(path.join(os.tmpdir(), "playwright-empty-"));
|
||||
originalBrowsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH;
|
||||
// Force chromium.launch() to genuinely fail with the exact class of error the reporter hit
|
||||
// ("Executable doesn't exist at ..."), without touching any real ~/.cache/ms-playwright
|
||||
// install.
|
||||
process.env.PLAYWRIGHT_BROWSERS_PATH = emptyBrowsersDir;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (originalBrowsersPath === undefined) {
|
||||
delete process.env.PLAYWRIGHT_BROWSERS_PATH;
|
||||
} else {
|
||||
process.env.PLAYWRIGHT_BROWSERS_PATH = originalBrowsersPath;
|
||||
}
|
||||
fs.rmSync(emptyBrowsersDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it(
|
||||
"returns a classified 503 + X-Omni-Fallback-Hint: connection_cooldown instead of a bare " +
|
||||
"502 (contrast: gemini-web.ts isMissingBrowserExecutable, #3516)",
|
||||
async () => {
|
||||
const executor = new mod.ZaiWebExecutor();
|
||||
const body = { model: "glm-5.3-flash", messages: [{ role: "user", content: "hi" }] };
|
||||
const result = await executor.execute({
|
||||
model: "glm-5.3-flash",
|
||||
body,
|
||||
stream: false,
|
||||
credentials: { apiKey: TEST_TOKEN },
|
||||
signal: null,
|
||||
});
|
||||
|
||||
assert.ok("response" in result, "expected an error Response, not a stream result");
|
||||
const response = (result as { response: Response }).response;
|
||||
const payload = (await response.json()) as { error?: { message?: string } };
|
||||
|
||||
assert.equal(
|
||||
response.status,
|
||||
503,
|
||||
"zai-web must classify a missing local Chromium install as a host/config error (503), " +
|
||||
"not a generic retryable 502 that trips the whole-provider circuit breaker."
|
||||
);
|
||||
assert.equal(
|
||||
response.headers.get("X-Omni-Fallback-Hint"),
|
||||
"connection_cooldown",
|
||||
"the connection-cooldown hint must be set so accountFallback applies a short cooldown " +
|
||||
"instead of tripping the provider circuit breaker."
|
||||
);
|
||||
assert.match(
|
||||
payload.error?.message ?? "",
|
||||
/Playwright Chromium browser.*not installed.*npx playwright install chromium/s
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user