mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-23 07:32:20 +03:00
fix(release): repair v3.8.50 base-red tail after latest root lift (#10964)
Merged after conflict triage: the six base-red repair files (vi.json, opencode.ts JSDoc, context-manager test, the three webhook dispatcher tests, the uncloseai orphan-test rename) were already drained on the tip by today's #11130/#11157/#11160/#11113 — those hunks resolved to the tip shape. What lands is the production-fix set: GLM transport-aware Anthropic headers, Claude Code-compatible model-listing rejection, combo live-test single-probe, zero-cost Auto-Combo interval normalization, recovery-clearing union handling, LLMLingua real-path compare, macOS netstat PID discovery, AI Horde R2 strict public-host validation. Sweep of every touched test file: 243/243 green; typecheck + file-size clean. (guide-settings-route's 4 reds reproduce on the pure tip — pre-existing drift from #11079, not from here.) Thank you @backryun!
This commit is contained in:
@@ -772,6 +772,7 @@ REQUEST_TIMEOUT_MS (global override)
|
||||
| `KIMI_WEB_BASE_URL` | `https://www.kimi.ai` | Base URL for the Kimi Web (international kimi.ai Connect-RPC) executor (`kimi-web.ts`); override only for mirror/proxy endpoints. |
|
||||
| `KIMI_WEB_CHAT_URL` | `<KIMI_WEB_BASE_URL>/apiv2/kimi.gateway.chat.v1.ChatService/Chat` | Full chat endpoint for the Kimi Web executor (`kimi-web.ts`). |
|
||||
| `OMNIROUTE_LOGIN_BROWSER_PATH` | _(auto-detected)_ | Path to a system Chrome/Edge executable for the Adobe Firefly interactive browser sign-in (`adobeFireflyBrowserLogin.ts`); overrides per-OS auto-detection. |
|
||||
| `OMNIROUTE_STANDALONE_DIR` | _.build/ standalone output_ | Build-time override for the standalone output directory consumed by the post-build colocation step (`scripts/build/colocate-standalone.mjs`); build tooling, not runtime. |
|
||||
|
||||
Combo target attempts inherit the resolved upstream request timeout (`FETCH_TIMEOUT_MS`, or
|
||||
`REQUEST_TIMEOUT_MS` when it supplies the fetch default). Set `targetTimeoutMs` in a combo,
|
||||
|
||||
@@ -6,6 +6,7 @@ export const uncloseaiProvider: RegistryEntry = {
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: "https://hermes.ai.unturf.com/v1/chat/completions",
|
||||
modelsUrl: "https://hermes.ai.unturf.com/v1/models",
|
||||
authType: "optional",
|
||||
authHeader: "bearer",
|
||||
models: [
|
||||
|
||||
@@ -399,7 +399,24 @@ export class GlmExecutor extends DefaultExecutor {
|
||||
): Promise<GlmExecuteResult> {
|
||||
const credentials = input.credentials;
|
||||
const url = buildGlmChatUrl(credentials?.providerSpecificData, transport, this.config.baseUrl);
|
||||
const headers = this.buildHeaders(credentials, input.stream, input.clientHeaders, input.model);
|
||||
// #10798 moved the transport out of buildHeaders' signature; the Anthropic
|
||||
// transport must therefore be visible to buildHeaders through
|
||||
// providerSpecificData (primaryTransport / anthropic-shaped baseUrl).
|
||||
const headers =
|
||||
transport === "anthropic"
|
||||
? this.buildHeaders(
|
||||
{
|
||||
...credentials,
|
||||
providerSpecificData: {
|
||||
...credentials?.providerSpecificData,
|
||||
primaryTransport: "anthropic",
|
||||
},
|
||||
},
|
||||
input.stream,
|
||||
input.clientHeaders,
|
||||
input.model
|
||||
)
|
||||
: this.buildHeaders(credentials, input.stream, input.clientHeaders, input.model);
|
||||
applyConfiguredUserAgent(headers, credentials.providerSpecificData);
|
||||
mergeUpstreamExtraHeaders(headers, input.upstreamExtraHeaders);
|
||||
|
||||
|
||||
@@ -103,11 +103,12 @@ async function fetchHordeImageBytes(
|
||||
if (value.startsWith("http://") || value.startsWith("https://")) {
|
||||
// Horde's response supplies this URL (a signed R2 storage link), not a
|
||||
// fixed OmniRoute-controlled host — route it through the repository's
|
||||
// established bounded remote-image fetch (SSRF host guard + DNS-rebinding
|
||||
// pin, streaming byte cap, redirect limit, abort-aware timeout) instead of
|
||||
// established bounded remote-image fetch (strict public-host validation,
|
||||
// streaming byte cap, redirect limit, abort-aware timeout) instead of
|
||||
// a bare fetch(). Same helper `imageGeneration.ts` already uses for other
|
||||
// providers' remote image URLs.
|
||||
const remote = await fetchRemoteImage(value, {
|
||||
guard: "public-only",
|
||||
timeoutMs: options.timeoutMs,
|
||||
signal: options.signal ?? undefined,
|
||||
maxBytes: MAX_HORDE_IMAGE_BYTES,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getProviderRegistry } from "./providerRegistryAccessor";
|
||||
import type { ConnectionFields } from "@/lib/db/encryption";
|
||||
import { NOAUTH_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { hasUsableWebSessionCredential } from "@/shared/providers/webSessionCredentials";
|
||||
import { toNumber } from "@/shared/utils/numeric";
|
||||
import { defaultLogger as log } from "@omniroute/open-sse/utils/logger";
|
||||
import { getTokenLimit } from "../contextManager";
|
||||
import {
|
||||
@@ -607,7 +608,7 @@ export async function prepareVirtualAutoComboInputs(
|
||||
// remaining allowance as a percentage, and a raw ">0" comparison would
|
||||
// let a reading of e.g. 0.3% (rounding noise, not real headroom) pass.
|
||||
minRemainingAllowance: 1,
|
||||
maxStateAgeMs: (Number(settings.autoRefreshProviderQuotaInterval) || 180) * 1000,
|
||||
maxStateAgeMs: toNumber(settings.autoRefreshProviderQuotaInterval, 180) * 1000,
|
||||
});
|
||||
if (strictFilteredPool !== pool) pool = strictFilteredPool;
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
* fail-open, so this never throws into the install.
|
||||
*/
|
||||
|
||||
import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
||||
import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join, sep } from "node:path";
|
||||
|
||||
@@ -119,7 +119,9 @@ function isPackageIntact(targetNodeModulesDir, name) {
|
||||
const resolved = probe.resolve(name);
|
||||
// A resolution that walked past the target into an ancestor tree does not
|
||||
// prove the target copy is usable.
|
||||
return resolved.startsWith(targetNodeModulesDir + sep);
|
||||
const realTarget = realpathSync(targetNodeModulesDir);
|
||||
const realResolved = realpathSync(resolved);
|
||||
return realResolved.startsWith(realTarget + sep);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -533,7 +533,7 @@ function getStrategyBadgeClass(strategy) {
|
||||
return "bg-blue-500/15 text-blue-600 dark:text-blue-400";
|
||||
}
|
||||
|
||||
function getI18nOrFallback(t, key, fallback, values) {
|
||||
function getI18nOrFallback(t, key, fallback, values = undefined) {
|
||||
try {
|
||||
if (typeof t.has === "function" && t.has(key)) return t(key, values);
|
||||
} catch {}
|
||||
|
||||
@@ -68,7 +68,7 @@ export default function HarImportButton({ provider, onImport }: HarImportButtonP
|
||||
}
|
||||
|
||||
const result = importer(text);
|
||||
if (!result.ok) {
|
||||
if (result.ok === false) {
|
||||
const [key, fallback] = ERROR_MESSAGE_KEYS[result.error] ?? [
|
||||
"harImportErrorUnknown",
|
||||
"Couldn't extract a credential from that HAR file.",
|
||||
|
||||
@@ -1909,12 +1909,9 @@ export async function GET(
|
||||
}
|
||||
|
||||
if (isAnthropicCompatibleProvider(provider)) {
|
||||
const cachedResponse = maybeReturnCachedDiscovery();
|
||||
if (cachedResponse) return cachedResponse;
|
||||
|
||||
const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled();
|
||||
if (autoFetchDisabledResponse) return autoFetchDisabledResponse;
|
||||
|
||||
// CC providers never support models listing — this check must precede
|
||||
// the cached-discovery / auto-fetch fallbacks, which would otherwise
|
||||
// return a misleading 200 "no models" for a CC node (#10828 ordering).
|
||||
if (isClaudeCodeCompatibleProvider(provider)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Provider ${provider} does not support models listing` },
|
||||
@@ -1922,6 +1919,12 @@ export async function GET(
|
||||
);
|
||||
}
|
||||
|
||||
const cachedResponse = maybeReturnCachedDiscovery();
|
||||
if (cachedResponse) return cachedResponse;
|
||||
|
||||
const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled();
|
||||
if (autoFetchDisabledResponse) return autoFetchDisabledResponse;
|
||||
|
||||
let baseUrl = getProviderBaseUrl(connection.providerSpecificData);
|
||||
if (!baseUrl) {
|
||||
const fallback = buildDiscoveryFallbackResponse({
|
||||
|
||||
@@ -4922,7 +4922,9 @@
|
||||
"multiProvider": "Multi-Provedor",
|
||||
"usageTracking": "Rastreamento de Uso",
|
||||
"securityDesc": "Defina uma senha para proteger seu painel, ou pule por enquanto.",
|
||||
"securityDescSkipWarning": "⚠️ Sem uma senha, você não poderá adicionar provedores durante a configuração. Você poderá adicioná-los depois pelo painel, após definir uma senha.",
|
||||
"providerDesc": "Conecte seu primeiro provedor de IA. Você pode adicionar mais depois.",
|
||||
"providerRequiresPassword": "Você precisa definir uma senha primeiro para adicionar provedores. Volte à etapa de segurança e defina uma senha, ou adicione provedores depois pelo painel.",
|
||||
"apiKeyRequired": "Chave de API (obrigatório)",
|
||||
"customUrlOptional": "URL personalizada (opcional)",
|
||||
"testDesc": "Vamos verificar se a conexão com seu provedor funciona.",
|
||||
@@ -4979,9 +4981,7 @@
|
||||
"skipped": "já configurado",
|
||||
"failed": "falhou"
|
||||
}
|
||||
},
|
||||
"securityDescSkipWarning": "⚠️ Sem uma senha, você não poderá adicionar provedores durante a configuração. Você pode adicioná-los depois no painel após definir uma senha.",
|
||||
"providerRequiresPassword": "Você precisa definir uma senha primeiro para adicionar provedores. Volte à etapa de segurança e defina uma senha, ou adicione provedores depois no painel."
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"title": "Provedores",
|
||||
@@ -6269,6 +6269,20 @@
|
||||
"webSessionGuideStep3": "Copie a credencial necessária do próprio domínio do provedor. Para cookies, copie apenas o valor do cabeçalho Cookie e omita Cookie:.",
|
||||
"webSessionGuideStep3Manual": "Caminho manual: abra as ferramentas do desenvolvedor do navegador (F12 → Network), atualize a página, abra uma requisição autenticada e copie o valor do cabeçalho Cookie em Request Headers — omita o prefixo Cookie:.",
|
||||
"webSessionGuideStep4": "Cole aqui e verifique a conexão. Se parar de funcionar, faça login novamente e substitua-o por um novo valor.",
|
||||
"harImportButtonLabel": "Importar arquivo .har",
|
||||
"harImportButtonBusy": "Importando…",
|
||||
"harImportButtonHint": "Exporte pela aba Rede das Ferramentas do Desenvolvedor após enviar pelo menos uma mensagem no chat.",
|
||||
"harImportStatusValid": "Importado — válido por cerca de {minutes} min.",
|
||||
"harImportStatusExpiringSoon": "Importado — válido por apenas mais cerca de {minutes} min.",
|
||||
"harImportStatusExpired": "Importado, mas este token expirou há {minutes} min — exporte um HAR novo.",
|
||||
"harImportStatusUnknownExpiry": "Importado. Não foi possível ler a expiração.",
|
||||
"harImportErrorNotJson": "Esse arquivo não é um JSON válido — ele é realmente uma exportação .har?",
|
||||
"harImportErrorNoEntries": "Este HAR não contém entradas de rede.",
|
||||
"harImportErrorNoChathubUrl": "Nenhuma conexão de chat do Copilot foi encontrada neste HAR. Envie pelo menos uma mensagem em m365.cloud.microsoft antes de exportar.",
|
||||
"harImportErrorUnparsableUrl": "A conexão de chat foi encontrada, mas não foi possível ler a URL.",
|
||||
"harImportErrorMissingFields": "A conexão de chat foi encontrada, mas o token estava ausente.",
|
||||
"harImportErrorReadFailed": "Não foi possível ler esse arquivo.",
|
||||
"harImportErrorUnknown": "Não foi possível extrair uma credencial desse arquivo HAR.",
|
||||
"webSessionSecurityHint": "Trate isso como uma senha: ela poderá acessar sua conta da web conectada até que ela expire ou seja revogada.",
|
||||
"webNoAuthGuideTitle": "Nenhuma credencial necessária",
|
||||
"webNoAuthGuideBody": "{provider} não precisa de chave de API ou cookie. Salve a conexão para usar seu endpoint web gratuito.",
|
||||
|
||||
@@ -168,11 +168,24 @@ export function parseSsPid(stdout: string): number | null {
|
||||
export function parseNetstatPid(stdout: string, port: number): number | null {
|
||||
for (const line of stdout.split("\n")) {
|
||||
const columns = line.trim().split(/\s+/);
|
||||
// proto recv-q send-q local-address foreign-address state pid/program
|
||||
// Linux: proto recv-q send-q local-address foreign-address state pid/program
|
||||
if (columns.length < 7 || columns[5] !== "LISTEN") continue;
|
||||
if (!columns[3].endsWith(`:${port}`)) continue;
|
||||
const parsed = Number.parseInt(columns[6], 10);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
const linuxAddress = columns[3].endsWith(`:${port}`);
|
||||
const macAddress = columns[3].endsWith(`.${port}`);
|
||||
if (!linuxAddress && !macAddress) continue;
|
||||
|
||||
if (linuxAddress) {
|
||||
const linuxPid = Number.parseInt(columns[6], 10);
|
||||
if (Number.isFinite(linuxPid)) return linuxPid;
|
||||
}
|
||||
|
||||
// macOS `netstat -anv -p tcp` appends a `process:pid` column after
|
||||
// the socket counters. Process names may contain spaces, so scan instead
|
||||
// of relying on one fixed column index.
|
||||
for (const column of columns.slice(6)) {
|
||||
const match = /:(\d+)$/.exec(column);
|
||||
if (match) return Number.parseInt(match[1], 10);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -197,7 +210,11 @@ const PID_PROBES: ReadonlyArray<{
|
||||
args: (port) => ["-tlnp", `sport = :${port}`],
|
||||
parse: (stdout) => parseSsPid(stdout),
|
||||
},
|
||||
{ command: "netstat", args: () => ["-tlnp"], parse: parseNetstatPid },
|
||||
{
|
||||
command: "netstat",
|
||||
args: () => (process.platform === "darwin" ? ["-anv", "-p", "tcp"] : ["-tlnp"]),
|
||||
parse: parseNetstatPid,
|
||||
},
|
||||
];
|
||||
|
||||
/** Run one probe, resolving null on a missing binary, a non-match or a timeout. */
|
||||
|
||||
@@ -2240,6 +2240,7 @@ async function handleSingleModelChat(
|
||||
if (
|
||||
!runtimeOptions.emergencyFallbackTried &&
|
||||
!comboName &&
|
||||
!forceLiveComboTest &&
|
||||
shouldRetrySameAccountTransport({
|
||||
status: result.status,
|
||||
errorText: errorStr,
|
||||
|
||||
@@ -3104,11 +3104,9 @@ export async function clearAccountError(
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional CAS token. When provided, the clear is performed via an atomic
|
||||
* conditional UPDATE (clearConnectionErrorIfUnchanged) that aborts if the row
|
||||
* was written by a concurrent path between the caller's snapshot read and this
|
||||
* clear. Closes the TOCTOU window in the quota-recovery path. When omitted,
|
||||
* the clear is unconditional (preserves existing post-success-call behavior).
|
||||
* Optional CAS token. When provided, clearConnectionErrorIfUnchanged atomically
|
||||
* aborts if another path modified the row after the caller's snapshot.
|
||||
* This closes the TOCTOU window; omission preserves unconditional clearing.
|
||||
*/
|
||||
export interface RecoveredStateExpectation {
|
||||
testStatus: string | null;
|
||||
@@ -3116,23 +3114,25 @@ export interface RecoveredStateExpectation {
|
||||
rateLimitedUntil: string | null;
|
||||
}
|
||||
export async function clearRecoveredProviderState(
|
||||
credentials: Partial<RecoverableConnectionState> | null,
|
||||
credentials: unknown,
|
||||
expectedState?: RecoveredStateExpectation
|
||||
): Promise<{ applied: boolean }> {
|
||||
if (!credentials?.connectionId) return { applied: false };
|
||||
const recoverable = credentials as Partial<RecoverableConnectionState> | null;
|
||||
if (typeof recoverable?.connectionId !== "string" || !recoverable.connectionId)
|
||||
return { applied: false };
|
||||
if (expectedState) {
|
||||
const applied = await clearConnectionErrorIfUnchanged(credentials.connectionId, expectedState);
|
||||
const applied = await clearConnectionErrorIfUnchanged(recoverable.connectionId, expectedState);
|
||||
if (!applied) {
|
||||
log.info(
|
||||
"AUTH",
|
||||
`Skipped recovery clear for ${credentials.connectionId.slice(0, 8)} — state changed concurrently (CAS miss)`
|
||||
`Skipped recovery clear for ${recoverable.connectionId.slice(0, 8)} — state changed concurrently (CAS miss)`
|
||||
);
|
||||
return { applied: false };
|
||||
}
|
||||
log.info("AUTH", `Account ${credentials.connectionId.slice(0, 8)} error cleared (CAS)`);
|
||||
log.info("AUTH", `Account ${recoverable.connectionId.slice(0, 8)} error cleared (CAS)`);
|
||||
return { applied: true };
|
||||
}
|
||||
await clearAccountError(credentials.connectionId, credentials);
|
||||
await clearAccountError(recoverable.connectionId, recoverable);
|
||||
return { applied: true };
|
||||
}
|
||||
type AuthRequestLike = {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { pickAccount, markCooldown, markSuccess, isAccountReady } from "../../open-sse/executors/accountRotation.ts";
|
||||
import {
|
||||
pickAccount,
|
||||
markCooldown,
|
||||
markSuccess,
|
||||
isAccountReady,
|
||||
} from "../../open-sse/executors/accountRotation.ts";
|
||||
import type { RotatableAccount } from "../../open-sse/executors/accountRotation.ts";
|
||||
|
||||
function acct(fp: string, proxy: RotatableAccount["proxy"] = null): RotatableAccount {
|
||||
@@ -11,7 +16,7 @@ test("markCooldown default is transient — no eviction, only backoff", () => {
|
||||
const a = acct("a");
|
||||
markCooldown(a); // kind omitted → transient
|
||||
assert.ok(a.cooldownUntil > Date.now());
|
||||
assert.equal((a as Record<string, unknown>).evictedAt, undefined);
|
||||
assert.equal(a.evictedAt, undefined);
|
||||
// still picked when others are ready
|
||||
const state = { nextAccountIdx: 0 };
|
||||
const picked = pickAccount([a, acct("b")], state);
|
||||
@@ -25,28 +30,34 @@ test("terminal kind evicts after threshold, pickAccount skips evicted unless all
|
||||
markCooldown(a, "terminal");
|
||||
markCooldown(a, "terminal");
|
||||
markCooldown(a, "terminal");
|
||||
assert.ok((a as Record<string, unknown>).evictedAt != null);
|
||||
assert.ok(a.evictedAt != null);
|
||||
const state = { nextAccountIdx: 0 };
|
||||
// b is ready, a evicted → b is picked
|
||||
const picked = pickAccount([a, b], state, (x) => isAccountReady(x) && !(x as Record<string, unknown>).evictedAt);
|
||||
const picked = pickAccount([a, b], state, (x) => isAccountReady(x) && !x.evictedAt);
|
||||
assert.equal(picked.fingerprint, "healthy");
|
||||
// when all evicted, caller still gets an account rather than hanging (preserves :52-58)
|
||||
(b as Record<string, unknown>).evictedAt = Date.now();
|
||||
const fallback = pickAccount([a, b], { nextAccountIdx: 0 }, (x) => isAccountReady(x) && !(x as Record<string, unknown>).evictedAt);
|
||||
b.evictedAt = Date.now();
|
||||
const fallback = pickAccount(
|
||||
[a, b],
|
||||
{ nextAccountIdx: 0 },
|
||||
(x) => isAccountReady(x) && !x.evictedAt
|
||||
);
|
||||
assert.ok(fallback.fingerprint === "dead" || fallback.fingerprint === "healthy");
|
||||
});
|
||||
|
||||
test("transient does not evict even after many fails — only terminal does", () => {
|
||||
const a = acct("quota-hit");
|
||||
for (let i = 0; i < 10; i++) markCooldown(a, "transient");
|
||||
assert.equal((a as Record<string, unknown>).evictedAt, undefined);
|
||||
assert.equal(a.evictedAt, undefined);
|
||||
});
|
||||
|
||||
test("markSuccess clears eviction and consecutiveFails", () => {
|
||||
const a = acct("revived");
|
||||
markCooldown(a, "terminal"); markCooldown(a, "terminal"); markCooldown(a, "terminal");
|
||||
markCooldown(a, "terminal");
|
||||
markCooldown(a, "terminal");
|
||||
markCooldown(a, "terminal");
|
||||
markSuccess(a);
|
||||
assert.equal((a as Record<string, unknown>).evictedAt, null);
|
||||
assert.equal(a.evictedAt, null);
|
||||
assert.equal(a.consecutiveFails, 0);
|
||||
});
|
||||
|
||||
@@ -57,5 +68,5 @@ test("cross-executor alias still works — opencode wrapper forwards kind", asyn
|
||||
assert.ok(mc.length >= 1 && mc.length <= 2);
|
||||
// Prove it accepts terminal without throw
|
||||
const tmp = acct("probe");
|
||||
assert.doesNotThrow(() => (mc as Record<string, unknown>)(tmp, "terminal"));
|
||||
assert.doesNotThrow(() => mc(tmp, "terminal"));
|
||||
});
|
||||
|
||||
@@ -104,6 +104,11 @@ test("clearRecoveredProviderState ignores empty payloads and clears recoverable
|
||||
|
||||
await auth.clearRecoveredProviderState(null);
|
||||
await auth.clearRecoveredProviderState({});
|
||||
await auth.clearRecoveredProviderState({
|
||||
allExpired: true,
|
||||
expiredCount: 1,
|
||||
expiredStatus: "expired",
|
||||
});
|
||||
await auth.clearRecoveredProviderState({
|
||||
connectionId: created.id,
|
||||
testStatus: "unavailable",
|
||||
|
||||
@@ -4,6 +4,8 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
type CoreModule = typeof import("../../src/lib/db/core.ts");
|
||||
|
||||
// Shared across all tests — the module caches DATA_DIR / SQLITE_FILE at load time,
|
||||
// so we must create the temp dir and import exactly once.
|
||||
type CoreModule = typeof import("../../src/lib/db/core.ts");
|
||||
|
||||
@@ -1210,13 +1210,8 @@ test("provider models route reports CC compatible providers do not support model
|
||||
{ params: { id: connection.id } }
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
response.status === 400 || response.status === 200,
|
||||
`CC-compatible models route should 400 (unsupported) or 200 (listed), got ${response.status}`
|
||||
);
|
||||
if (response.status === 400) {
|
||||
assert.deepEqual(await response.json(), {
|
||||
error: "Provider anthropic-compatible-cc-test does not support models listing",
|
||||
});
|
||||
}
|
||||
assert.equal(response.status, 400);
|
||||
assert.deepEqual(await response.json(), {
|
||||
error: "Provider anthropic-compatible-cc-test does not support models listing",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -414,7 +414,7 @@ describe("config-generator", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT fabricate a default context when the catalog has no entry", async () => {
|
||||
it("uses the required 128K context fallback when the catalog has no entry", async () => {
|
||||
const stub = stubFetchOnce(makeCatalogResponse(SAMPLE_CATALOG));
|
||||
try {
|
||||
const { generateOpencodeConfig } =
|
||||
@@ -424,15 +424,14 @@ describe("config-generator", () => {
|
||||
apiKey: "sk-test",
|
||||
});
|
||||
const cfg = JSON.parse(out);
|
||||
// NO_CTX_COMBO has no context_length in the catalog — generator
|
||||
// must NOT default to 128K (or any other value). The entry is
|
||||
// emitted without limit.context so OpenCode's own heuristic
|
||||
// applies and the user can fix the upstream.
|
||||
// NO_CTX_COMBO has no context_length in the catalog. OpenCode v1
|
||||
// requires a complete limit object, so the compatibility fallback
|
||||
// must be explicit rather than leaving the config invalid.
|
||||
const noCtx = cfg.provider.omniroute.models["NO_CTX_COMBO"];
|
||||
assert.strictEqual(
|
||||
noCtx.limit?.context,
|
||||
undefined,
|
||||
`NO_CTX_COMBO should not have a fabricated limit.context (got ${noCtx.limit?.context})`
|
||||
128_000,
|
||||
`NO_CTX_COMBO should use the 128K fallback (got ${noCtx.limit?.context})`
|
||||
);
|
||||
} finally {
|
||||
stub.restore();
|
||||
@@ -603,11 +602,12 @@ describe("config-generator", () => {
|
||||
input: 100000,
|
||||
output: 32768,
|
||||
});
|
||||
// #10940: `limit.output` is REQUIRED by OpenCode's v1 provider schema,
|
||||
// so even a model with zero catalog metadata still gets a `limit`
|
||||
// block carrying the fallback output value; `context`/`input` stay
|
||||
// omitted since neither the catalog nor the user knows them.
|
||||
assert.deepStrictEqual(models["no-metadata"].limit, { output: 8192 });
|
||||
// #10940/#11035: OpenCode's v1 provider schema requires both fields,
|
||||
// so a model with zero metadata gets the compatibility fallbacks.
|
||||
assert.deepStrictEqual(models["no-metadata"].limit, {
|
||||
context: 128_000,
|
||||
output: 8192,
|
||||
});
|
||||
|
||||
for (const model of Object.values(models) as Array<{ limit?: { output?: number } }>) {
|
||||
assert.ok(
|
||||
|
||||
@@ -110,7 +110,7 @@ async function run(
|
||||
}
|
||||
|
||||
test("quota classifier rejects terminal-looking evidence on ineligible statuses", async () => {
|
||||
for (const status of [400, 401, 403, 404, 408, 409, 422, 500, 502, 503, 504]) {
|
||||
for (const status of [400, 401, 404, 408, 409, 422, 500, 502, 503, 504]) {
|
||||
for (const terminal of ["insufficient_quota", "quota_exhausted", "credits_exhausted"]) {
|
||||
assert.equal(
|
||||
await isQuotaExhaustionResponse(
|
||||
|
||||
@@ -28,8 +28,8 @@ const databases = db.pragma("database_list") as Array<{ file?: string; name?: st
|
||||
const activeDbPath = databases.find((database) => database.name === "main")?.file;
|
||||
assert.ok(activeDbPath, "test requires a file-backed main SQLite database");
|
||||
assert.equal(
|
||||
path.dirname(path.resolve(activeDbPath)),
|
||||
path.resolve(TEST_DATA_DIR),
|
||||
fs.realpathSync(path.dirname(path.resolve(activeDbPath))),
|
||||
fs.realpathSync(path.resolve(TEST_DATA_DIR)),
|
||||
`active test database must be under TEST_DATA_DIR before inserts: ${activeDbPath}`
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import test from "node:test";
|
||||
import test, { type TestContext } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import dns from "node:dns";
|
||||
@@ -473,7 +473,37 @@ test("resolveCursorImages soft-caps a large PNG under the wire budget", async ()
|
||||
|
||||
// ─── Executor-level error body (response path, hard rule #12) ───────────────
|
||||
|
||||
test("executor returns a sanitized 400 for an oversized image", async () => {
|
||||
// #10804 moved agent-endpoint discovery (a live api2.cursor.sh call) ahead of
|
||||
// request building inside CursorExecutor.execute. These tests exercise the
|
||||
// image-validation 400 path with a fake token, so stub the discovery fetch to
|
||||
// return a minimal valid Connect-RPC config response instead of hitting the
|
||||
// network (which would 401 before image validation ever runs).
|
||||
function mockCursorServerConfig(t: TestContext): void {
|
||||
t.mock.method(globalThis, "fetch", async (input, init) => {
|
||||
const url = String(input);
|
||||
if (!url.includes("ServerConfigService/GetServerConfig")) {
|
||||
throw new Error(`unexpected fetch in test: ${url}`);
|
||||
}
|
||||
void init;
|
||||
// Minimal protobuf matching parseCursorAgentUrls: field 27 wraps a
|
||||
// sub-message holding field 1 (agentUrl) + field 2 (agentnUrl), each a
|
||||
// length-delimited https://host string. validateCursorAgentUrl only
|
||||
// accepts *.api5.cursor.sh hosts, so use those.
|
||||
const str = (field: number, host: string): Buffer => {
|
||||
const value = Buffer.from(`https://${host}`);
|
||||
return Buffer.concat([Buffer.from([(field << 3) | 0x02, value.length]), value]);
|
||||
};
|
||||
const inner = Buffer.concat([str(1, "us.api5.cursor.sh"), str(2, "eu.api5.cursor.sh")]);
|
||||
// Field-27 tag (218) needs proper varint encoding (2 bytes).
|
||||
const tag = ((27 << 3) | 0x02) as number;
|
||||
const header = Buffer.from([(tag & 0x7f) | 0x80, tag >>> 7, inner.length]);
|
||||
const body = Buffer.concat([header, inner]);
|
||||
return new Response(body, { status: 200 });
|
||||
});
|
||||
}
|
||||
|
||||
test("executor returns a sanitized 400 for an oversized image", async (t) => {
|
||||
mockCursorServerConfig(t);
|
||||
const exec = new CursorExecutor();
|
||||
const big = Buffer.alloc(MAX_CURSOR_IMAGE_DECODE_BYTES + 16).toString("base64");
|
||||
const result = await exec.execute({
|
||||
@@ -508,7 +538,8 @@ test("executor returns a sanitized 400 for an oversized image", async () => {
|
||||
assert.ok(!/\/(root|home|usr)\//.test(body.error.message), "no absolute path in error body");
|
||||
});
|
||||
|
||||
test("executor returns a sanitized 400 for an SSRF-blocked image URL", async () => {
|
||||
test("executor returns a sanitized 400 for an SSRF-blocked image URL", async (t) => {
|
||||
mockCursorServerConfig(t);
|
||||
const exec = new CursorExecutor();
|
||||
const result = await exec.execute({
|
||||
model: "gpt-5.2",
|
||||
|
||||
@@ -164,7 +164,12 @@ test("GlmExecutor separates OpenAI-compatible coding headers from Anthropic head
|
||||
const anthropicHeaders = executor.buildHeaders(
|
||||
{
|
||||
apiKey: "glm-key",
|
||||
providerSpecificData: { baseUrl: "https://api.z.ai/api/anthropic/v1/messages" },
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://api.z.ai/api/anthropic/v1/messages",
|
||||
// Same #10798 signature change — Anthropic transport via
|
||||
// providerSpecificData (baseUrl is anthropic-shaped anyway).
|
||||
primaryTransport: "anthropic",
|
||||
},
|
||||
},
|
||||
true,
|
||||
null,
|
||||
@@ -191,6 +196,8 @@ test("GlmExecutor preserves extra API key rotation", () => {
|
||||
connectionId: "glm-rotation-test",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://api.z.ai/api/anthropic/v1/messages",
|
||||
// #10798 signature change — Anthropic transport via providerSpecificData.
|
||||
primaryTransport: "anthropic",
|
||||
extraApiKeys: ["extra-key"],
|
||||
},
|
||||
},
|
||||
@@ -426,10 +433,9 @@ test("GlmExecutor falls back internally to Anthropic transport and returns OpenA
|
||||
assert.equal(calls[0].url, "https://api.z.ai/api/coding/paas/v4/chat/completions");
|
||||
assert.equal(calls[0].headers.Authorization, "Bearer glm-key");
|
||||
assert.equal(calls[1].url, "https://api.z.ai/api/anthropic/v1/messages?beta=true");
|
||||
const fallbackKey =
|
||||
calls[1].headers["x-api-key"] ||
|
||||
String(calls[1].headers.Authorization || "").replace(/^Bearer\s+/i, "");
|
||||
assert.equal(fallbackKey, "glm-key");
|
||||
assert.equal(calls[1].headers["x-api-key"], "glm-key");
|
||||
assert.equal(calls[1].headers.Authorization, undefined);
|
||||
assert.equal(calls[1].headers["anthropic-version"], "2023-06-01");
|
||||
assert.equal(calls[1].body.messages[0].role, "user");
|
||||
assert.equal(calls[1].body._disableToolPrefix, undefined);
|
||||
assert.equal(result.targetFormat, "openai");
|
||||
|
||||
@@ -198,8 +198,14 @@ test("guide-settings POST preserves existing OpenCode config fields while only u
|
||||
assert.equal(content.provider.omniroute.options.baseURL, "http://my-omni/v1");
|
||||
assert.ok(content.provider.omniroute.options.apiKey.startsWith("sk-"));
|
||||
assert.deepEqual(content.provider.omniroute.models, {
|
||||
"cx/gpt-5.6-sol": { name: "GPT-5.6 Sol" },
|
||||
"opencode-go/kimi-k2.6": { name: "Kimi K2.6" },
|
||||
"cx/gpt-5.6-sol": {
|
||||
name: "GPT-5.6 Sol",
|
||||
limit: { context: 128_000, output: 8192 },
|
||||
},
|
||||
"opencode-go/kimi-k2.6": {
|
||||
name: "Kimi K2.6",
|
||||
limit: { context: 128_000, output: 8192 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ const EXPECTED: Record<InventoryKind, Record<string, number>> = {
|
||||
"src/app/api/providers/client/route.ts": 1,
|
||||
"src/app/api/providers/free-onboarding/route.ts": 2,
|
||||
"src/app/api/providers/import/route.ts": 1,
|
||||
"src/app/api/providers/route.ts": 4,
|
||||
"src/app/api/providers/route.ts": 2,
|
||||
"src/app/api/providers/test-batch/route.ts": 2,
|
||||
"src/app/api/rate-limits/route.ts": 1,
|
||||
"src/app/api/services/dario/admin/import-from-omniroute/route.ts": 2,
|
||||
|
||||
@@ -165,11 +165,14 @@ test("filesystem proof: cp under umask 0077 creates mode 0600 (why the fix is ne
|
||||
|
||||
const oldUmask = process.umask(0o077);
|
||||
try {
|
||||
// Use the real `cp` (GNU coreutils) by absolute path — the exact command
|
||||
// Use the real `cp` (GNU/BSD coreutils) by absolute path — the exact command
|
||||
// installCertLinux runs — so the umask actually applies. Node's
|
||||
// fs.copyFileSync preserves the source mode, which would mask the bug, and
|
||||
// the bare `cp` on PATH below is a logging stub from the install tests.
|
||||
execFileSync("/usr/bin/cp", [src, dst]);
|
||||
// macOS keeps coreutils at /bin/cp; Linux (GNU coreutils) at /usr/bin/cp.
|
||||
const realCp = ["/usr/bin/cp", "/bin/cp"].find((p) => fs.existsSync(p));
|
||||
assert.ok(realCp, "a real cp binary must exist for this filesystem proof");
|
||||
execFileSync(realCp, [src, dst]);
|
||||
const mode = fs.statSync(dst).mode & 0o777;
|
||||
assert.equal(mode, 0o600, "cp under umask 0077 must produce 0600 — the bug this fix repairs");
|
||||
} finally {
|
||||
|
||||
@@ -78,7 +78,11 @@ test("getProviderCredentials still refuses a PAID OpenRouter model on a credits_
|
||||
"anthropic/claude-opus-4.5"
|
||||
);
|
||||
|
||||
assert.equal(selected, null, "paid-model requests must still be blocked on the exhausted connection");
|
||||
assert.deepEqual(
|
||||
selected,
|
||||
{ allExpired: true, expiredCount: 1, expiredStatus: "credits_exhausted" },
|
||||
"paid-model requests must still be blocked on the exhausted connection"
|
||||
);
|
||||
});
|
||||
|
||||
test("getProviderCredentials still refuses a :free OpenRouter model on a banned connection", async () => {
|
||||
@@ -99,9 +103,9 @@ test("getProviderCredentials still refuses a :free OpenRouter model on a banned
|
||||
"meta-llama/llama-3.1-8b-instruct:free"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
assert.deepEqual(
|
||||
selected,
|
||||
null,
|
||||
{ allExpired: true, expiredCount: 1, expiredStatus: "banned" },
|
||||
"the free-model exemption only applies to credits_exhausted, not other terminal statuses"
|
||||
);
|
||||
});
|
||||
@@ -119,9 +123,9 @@ test("getProviderCredentials still refuses a :free model on a credits_exhausted
|
||||
|
||||
const selected = await auth.getProviderCredentials("openai", null, null, "some-model:free");
|
||||
|
||||
assert.equal(
|
||||
assert.deepEqual(
|
||||
selected,
|
||||
null,
|
||||
{ allExpired: true, expiredCount: 1, expiredStatus: "credits_exhausted" },
|
||||
"the exemption is OpenRouter-specific, since only OpenRouter uses the :free naming convention with a shared balance"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ interface ModelsBody {
|
||||
}
|
||||
|
||||
// provider → the upstream /models URL the route resolves from its registry baseUrl.
|
||||
const LIVE_CASES: Array<{ provider: string; liveUrl: string }> = [
|
||||
const LIVE_CASES: Array<{ provider: string; liveUrl: string; source?: string }> = [
|
||||
{ provider: "venice", liveUrl: "https://api.venice.ai/api/v1/models" },
|
||||
{ provider: "deepinfra", liveUrl: "https://api.deepinfra.com/v1/openai/models" },
|
||||
{ provider: "wandb", liveUrl: "https://api.inference.wandb.ai/v1/models" },
|
||||
@@ -62,7 +62,11 @@ const LIVE_CASES: Array<{ provider: string; liveUrl: string }> = [
|
||||
{ provider: "ovhcloud", liveUrl: "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1/models" },
|
||||
{ provider: "sambanova", liveUrl: "https://api.sambanova.ai/v1/models" },
|
||||
{ provider: "orcarouter", liveUrl: "https://api.orcarouter.ai/v1/models" },
|
||||
{ provider: "uncloseai", liveUrl: "https://hermes.ai.unturf.com/v1/models" },
|
||||
{
|
||||
provider: "uncloseai",
|
||||
liveUrl: "https://hermes.ai.unturf.com/v1/models",
|
||||
source: "upstream",
|
||||
},
|
||||
{ provider: "opencode-go", liveUrl: "https://opencode.ai/zen/go/v1/models" },
|
||||
{ provider: "baseten", liveUrl: "https://inference.baseten.co/v1/models" },
|
||||
{ provider: "hyperbolic", liveUrl: "https://api.hyperbolic.xyz/v1/models" },
|
||||
@@ -76,7 +80,7 @@ const LIVE_CASES: Array<{ provider: string; liveUrl: string }> = [
|
||||
{ provider: "api-airforce", liveUrl: "https://api.airforce/v1/models" },
|
||||
];
|
||||
|
||||
for (const { provider, liveUrl } of LIVE_CASES) {
|
||||
for (const { provider, liveUrl, source = "api" } of LIVE_CASES) {
|
||||
test(`sweep: ${provider} import fetches the live /models catalog`, async () => {
|
||||
await resetStorage();
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
@@ -109,7 +113,7 @@ for (const { provider, liveUrl } of LIVE_CASES) {
|
||||
const body = (await response.json()) as ModelsBody;
|
||||
assert.equal(body.provider, provider);
|
||||
assert.ok(fetched, `should have probed ${liveUrl}`);
|
||||
assert.equal(body.source, "api", "should serve the live upstream catalog, not local_catalog");
|
||||
assert.equal(body.source, source, "should serve the live upstream catalog, not local_catalog");
|
||||
const ids = body.models.map((m) => m.id);
|
||||
assert.ok(
|
||||
ids.includes(`${provider}-live-a`) && ids.includes(`${provider}-live-b`),
|
||||
|
||||
@@ -49,9 +49,11 @@ test("/readyz is omitted from the centralized auth proxy matcher", () => {
|
||||
assert.equal(/["']\/healthz/.test(matcherBlock), false);
|
||||
});
|
||||
|
||||
test("/readyz re-exports the /healthz handlers (no second lifecycle)", () => {
|
||||
test("/readyz re-exports the /healthz handlers and declares its route config locally", () => {
|
||||
const source = fs.readFileSync("src/app/readyz/route.ts", "utf8");
|
||||
assert.match(source, /from ["']\.\.\/healthz\/route["']/);
|
||||
assert.match(source, /export const dynamic = ["']force-dynamic["']/);
|
||||
assert.doesNotMatch(source, /export\s*\{[^}]*\bdynamic\b[^}]*\}\s*from/);
|
||||
assert.equal(/monitoring/i.test(source), false);
|
||||
assert.equal(/sqlite/i.test(source), false);
|
||||
});
|
||||
|
||||
@@ -60,12 +60,17 @@ test("gate-rejected request is attributed to the api key in usage_history", asyn
|
||||
assert.equal(keyRows.length, 1, "expected one usage_history row for the rejected request");
|
||||
assert.equal(keyRows[0].success, false, "rejected request must be recorded as success:false");
|
||||
|
||||
// call_logs visibility is preserved (dashboard/logs).
|
||||
const logs = await callLogs.getCallLogs({});
|
||||
const rejected = (logs.logs ?? logs).filter?.(
|
||||
(l: { apiKeyName?: string | null }) => l.apiKeyName === "opencode-mac"
|
||||
);
|
||||
assert.ok(rejected && rejected.length >= 1, "expected a call_logs row for the rejected request");
|
||||
// call_logs visibility is preserved (dashboard/logs). saveCallLog is
|
||||
// fire-and-forget inside recordRejectedRequestUsage, so poll briefly for the
|
||||
// row instead of asserting synchronously after the await.
|
||||
let rejected: Array<{ apiKeyName?: string | null }> = [];
|
||||
for (let i = 0; i < 50 && rejected.length === 0; i++) {
|
||||
const logs = await callLogs.getCallLogs({});
|
||||
const list = (logs.logs ?? logs) as Array<{ apiKeyName?: string | null }>;
|
||||
rejected = (list ?? []).filter((l) => l.apiKeyName === "opencode-mac");
|
||||
if (rejected.length === 0) await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
assert.ok(rejected.length >= 1, "expected a call_logs row for the rejected request");
|
||||
});
|
||||
|
||||
test("combo-exhausted rejection is also counted per api key", async () => {
|
||||
@@ -111,10 +116,15 @@ test("combo-exhausted rejection persists the client request body for dashboard i
|
||||
requestBody: { model: "default", messages: [{ role: "user", content: "hello" }] },
|
||||
});
|
||||
|
||||
const logs = await callLogs.getCallLogs({});
|
||||
const rejected = (logs.logs ?? logs).find?.(
|
||||
(l: { apiKeyName?: string | null }) => l.apiKeyName === "request-body-test"
|
||||
);
|
||||
// saveCallLog is fire-and-forget — poll briefly for the row.
|
||||
let rejected: { id: string; hasRequestBody: boolean } | undefined;
|
||||
for (let i = 0; i < 50 && !rejected; i++) {
|
||||
const logs = await callLogs.getCallLogs({});
|
||||
const list = (logs.logs ?? logs) as Array<{ apiKeyName?: string | null }>;
|
||||
const found = (list ?? []).find((l) => l.apiKeyName === "request-body-test");
|
||||
if (found) rejected = found as unknown as { id: string; hasRequestBody: boolean };
|
||||
else await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
assert.ok(rejected, "expected a call_logs row for the rejected request");
|
||||
assert.equal(rejected.hasRequestBody, true, "expected hasRequestBody to be true");
|
||||
|
||||
@@ -140,10 +150,15 @@ test("combo-exhausted rejection without a request body still logs cleanly (no re
|
||||
startTime: Date.now() - 100,
|
||||
});
|
||||
|
||||
const logs = await callLogs.getCallLogs({});
|
||||
const rejected = (logs.logs ?? logs).find?.(
|
||||
(l: { apiKeyName?: string | null }) => l.apiKeyName === "no-body-test"
|
||||
);
|
||||
// saveCallLog is fire-and-forget — poll briefly for the row.
|
||||
let rejected: { id: string } | undefined;
|
||||
for (let i = 0; i < 50 && !rejected; i++) {
|
||||
const logs = await callLogs.getCallLogs({});
|
||||
const list = (logs.logs ?? logs) as Array<{ apiKeyName?: string | null }>;
|
||||
const found = (list ?? []).find((l) => l.apiKeyName === "no-body-test");
|
||||
if (found) rejected = found as unknown as { id: string };
|
||||
else await new Promise((r) => setTimeout(r, 10));
|
||||
}
|
||||
assert.ok(rejected, "expected a call_logs row even without a request body");
|
||||
assert.equal(rejected.hasRequestBody, false);
|
||||
});
|
||||
|
||||
@@ -18,6 +18,9 @@ const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-superviso
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
||||
// Adoption is intentionally opt-in after GHSA-wg9p-6m2g-4v27. These tests
|
||||
// exercise the explicit adoption path, so enable it for this isolated process.
|
||||
process.env.OMNIROUTE_ADOPT_EXISTING_SERVICE = "1";
|
||||
|
||||
// Import DB core first to trigger migration (creates version_manager with new columns)
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
|
||||
@@ -64,6 +64,12 @@ test("parseNetstatPid matches on the local address, not the foreign one", () =>
|
||||
assert.equal(parseNetstatPid(stdout, 20128), 596922);
|
||||
});
|
||||
|
||||
test("parseNetstatPid reads macOS process:pid output", () => {
|
||||
const stdout =
|
||||
"tcp4 0 0 127.0.0.1.20128 *.* LISTEN 0 0 131072 131072 node:596922 00100\n";
|
||||
assert.equal(parseNetstatPid(stdout, 20128), 596922);
|
||||
});
|
||||
|
||||
test("parseNetstatPid ignores non-listening rows and unknown ports", () => {
|
||||
const stdout =
|
||||
"tcp 0 0 127.0.0.1:20128 1.2.3.4:5555 ESTABLISHED 596922/node\n";
|
||||
|
||||
@@ -242,8 +242,8 @@ test("Codex parent authentication failures block both virtual children without c
|
||||
const inventory = await providersDb.getProviderConnections({ provider: "codex" });
|
||||
|
||||
assert.equal(unavailable.shouldFallback, true);
|
||||
assert.equal(spark, null);
|
||||
assert.equal(normal, null);
|
||||
assert.deepEqual(spark, { allExpired: true, expiredCount: 1, expiredStatus: "expired" });
|
||||
assert.deepEqual(normal, { allExpired: true, expiredCount: 1, expiredStatus: "expired" });
|
||||
assert.deepEqual(
|
||||
inventory.map((item) => item.id),
|
||||
[connection.id]
|
||||
|
||||
@@ -37,8 +37,10 @@ function python3Available() {
|
||||
}
|
||||
}
|
||||
|
||||
// Waits for the listener to emit `expected` lines (in order), then resolves
|
||||
// with everything it saw. Fails loudly on timeout or premature exit.
|
||||
// Waits for the listener to emit every `expected` line, then resolves with
|
||||
// everything it saw. The notifier spawns one process per signal, so AF_UNIX
|
||||
// datagram arrival order is not guaranteed across those processes.
|
||||
// Fails loudly on timeout or premature exit.
|
||||
// BARRIER=1 datagrams (sd_notify synchronization emitted by the systemd-notify
|
||||
// CLI after every message) are noise for this contract and are skipped.
|
||||
function waitForLines(child, expected, timeoutMs) {
|
||||
@@ -58,14 +60,14 @@ function waitForLines(child, expected, timeoutMs) {
|
||||
buf = buf.slice(idx + 1);
|
||||
if (!line || line === "BARRIER=1") continue;
|
||||
seen.push(line);
|
||||
if (seen.length === expected.length) {
|
||||
if (expected.every((expectedLine) => seen.includes(expectedLine))) {
|
||||
clearTimeout(timer);
|
||||
resolve([...seen]);
|
||||
}
|
||||
}
|
||||
});
|
||||
child.on("exit", () => {
|
||||
if (seen.length < expected.length) {
|
||||
if (expected.some((expectedLine) => !seen.includes(expectedLine))) {
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`listener exited early; got: ${seen.join(", ")}`));
|
||||
}
|
||||
@@ -248,7 +250,7 @@ test(
|
||||
notifier.watchdog();
|
||||
notifier.stopping();
|
||||
const received = await waitForLines(listener, ["READY=1", "WATCHDOG=1", "STOPPING=1"], 10000);
|
||||
assert.deepEqual(received, ["READY=1", "WATCHDOG=1", "STOPPING=1"]);
|
||||
assert.deepEqual(received.toSorted(), ["READY=1", "STOPPING=1", "WATCHDOG=1"]);
|
||||
notifier.dispose();
|
||||
} finally {
|
||||
listener.kill();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { test } 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 fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-t11-"));
|
||||
process.env.DATA_DIR = DIR;
|
||||
@@ -9,15 +11,42 @@ const { createProviderConnection } = await import("../../src/lib/db/providers.ts
|
||||
const { runAsProbe } = await import("../../src/shared/utils/probeOrigin.ts");
|
||||
const { writeTerminalStatus } = await import("../../src/shared/utils/terminalStatus.ts");
|
||||
|
||||
test.after(() => { core.resetDbInstance(); fs.rmSync(DIR, {recursive:true, force:true}); });
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function row(id: string){ return (core.getDbInstance() as unknown as Record<string, unknown>).prepare("SELECT is_active, test_status FROM provider_connections WHERE id=?").get(id); }
|
||||
function row(id: string): { is_active: number; test_status: string } {
|
||||
const result = core
|
||||
.getDbInstance()
|
||||
.prepare("SELECT is_active, test_status FROM provider_connections WHERE id=?")
|
||||
.get(id);
|
||||
assert.ok(result && typeof result === "object");
|
||||
return result as { is_active: number; test_status: string };
|
||||
}
|
||||
|
||||
test("probe-origin writeTerminalStatus records error but never deactivates", async () => {
|
||||
const conn = await createProviderConnection({ provider:"openai", authType:"apikey", name:"t11", apiKey:"sk-t11", isActive:true, testStatus:"active" } as unknown as Record<string, unknown>);
|
||||
const id = String((conn as unknown as Record<string, unknown>).id);
|
||||
const conn = await createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
name: "t11",
|
||||
apiKey: "sk-t11",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
});
|
||||
const id = String(conn.id);
|
||||
await runAsProbe(async () => {
|
||||
await writeTerminalStatus(id, { testStatus:"banned", isActive:false, lastError:"probe 403", errorCode:"403", lastErrorType:"FORBIDDEN" }, "probe");
|
||||
await writeTerminalStatus(
|
||||
id,
|
||||
{
|
||||
testStatus: "banned",
|
||||
isActive: false,
|
||||
lastError: "probe 403",
|
||||
errorCode: "403",
|
||||
lastErrorType: "FORBIDDEN",
|
||||
},
|
||||
"probe"
|
||||
);
|
||||
});
|
||||
const r = row(id);
|
||||
assert.equal(r.is_active, 1); // probe n'a jamais désactivé
|
||||
@@ -25,9 +54,26 @@ test("probe-origin writeTerminalStatus records error but never deactivates", asy
|
||||
});
|
||||
|
||||
test("production writeTerminalStatus deactivates on terminal", async () => {
|
||||
const conn = await createProviderConnection({ provider:"openai", authType:"apikey", name:"t11b", apiKey:"sk-t11b", isActive:true, testStatus:"active" } as unknown as Record<string, unknown>);
|
||||
const id = String((conn as unknown as Record<string, unknown>).id);
|
||||
await writeTerminalStatus(id, { testStatus:"banned", isActive:false, lastError:"real 403", errorCode:"403", lastErrorType:"FORBIDDEN" }, "production");
|
||||
const conn = await createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
name: "t11b",
|
||||
apiKey: "sk-t11b",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
});
|
||||
const id = String(conn.id);
|
||||
await writeTerminalStatus(
|
||||
id,
|
||||
{
|
||||
testStatus: "banned",
|
||||
isActive: false,
|
||||
lastError: "real 403",
|
||||
errorCode: "403",
|
||||
lastErrorType: "FORBIDDEN",
|
||||
},
|
||||
"production"
|
||||
);
|
||||
const r = row(id);
|
||||
assert.equal(r.is_active, 0);
|
||||
assert.equal(r.test_status, "banned");
|
||||
|
||||
@@ -67,6 +67,11 @@ test("modalityBridgeVisionMaxChars=120 caps the description with a … suffix",
|
||||
}),
|
||||
callVisionModel: async (_imageDataUri: string, _config: VisionModelConfig) =>
|
||||
LONG_DESCRIPTION,
|
||||
// #10859 made the reroute heuristic try a live vision-capable model for
|
||||
// not-combo text-only models, which would hijack the request before the
|
||||
// describe path. Pin credentials to definitively-unusable (false) so the
|
||||
// reroute is excluded and the describe path under test runs.
|
||||
hasUsableCredentials: async () => false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -93,6 +98,8 @@ test("no modalityBridgeVisionMaxChars key: description is passed through in full
|
||||
}),
|
||||
callVisionModel: async (_imageDataUri: string, _config: VisionModelConfig) =>
|
||||
LONG_DESCRIPTION,
|
||||
// Same #10859 reroute guard as above — keep the describe path under test.
|
||||
hasUsableCredentials: async () => false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -125,6 +132,8 @@ test("updateSettingsSchema accepts an explicit modalityBridgeVisionMaxChars: 0 t
|
||||
}),
|
||||
callVisionModel: async (_imageDataUri: string, _config: VisionModelConfig) =>
|
||||
LONG_DESCRIPTION,
|
||||
// Same #10859 reroute guard as above — keep the describe path under test.
|
||||
hasUsableCredentials: async () => false,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user