diff --git a/.env.example b/.env.example index bb2dd4e5..4c152e9a 100644 --- a/.env.example +++ b/.env.example @@ -689,6 +689,9 @@ NEXT_PUBLIC_CLOUD_URL= # NEXT_PUBLIC_BASE_URL. Only enable if direct client access to AgentProxy is blocked # and your proxy strips/rebuilds incoming forwarded headers. # Values: true/loopback (trust loopback proxy peers), private/lan (also trust LAN peers). +# When using private/lan for the API bridge, list each trusted private proxy peer explicitly. +# Literal IPv4/IPv6 addresses only; CIDR and DNS names are intentionally rejected. +# AGENTPROXY_TRUSTED_PROXY_PEERS=192.168.1.10,fd00::10 # Proxy addresses whose X-Forwarded-For / X-Real-IP the IP allow/deny list may believe, on top # of loopback, private-network addresses and Cloudflare edges, which are always trusted. Needed diff --git a/docs/ops/VM_DEPLOYMENT_GUIDE.md b/docs/ops/VM_DEPLOYMENT_GUIDE.md index 1ab6f1da..52627697 100644 --- a/docs/ops/VM_DEPLOYMENT_GUIDE.md +++ b/docs/ops/VM_DEPLOYMENT_GUIDE.md @@ -251,6 +251,8 @@ for setting the explicit public URL when OAuth or generated browser links need o `AGENTPROXY_TRUST_PROXY` if AgentProxy is not directly reachable by clients and your proxy strips/rebuilds incoming forwarded headers. +For a private-LAN reverse proxy forwarding through the API bridge, also set `AGENTPROXY_TRUSTED_PROXY_PEERS` to the comma-separated literal IP addresses of your trusted proxy peers. The bridge ignores forwarding headers from all other LAN clients, even with `AGENTPROXY_TRUST_PROXY=private`. + ### 3.3 Enable and Test ```bash @@ -421,7 +423,7 @@ See also [TUNNELS_GUIDE.md](./TUNNELS_GUIDE.md) for the in-repo Cloudflare Tunne | 22 | SSH | Public (with fail2ban) | | 80 | nginx HTTP | Redirect → HTTPS | | 443 | nginx HTTPS | Via Cloudflare Proxy | -| 20128 | AgentProxy | Localhost only (via nginx) | +| 20128 | AgentProxy | Localhost only (via nginx) | ## Low-Memory / Small VPS Optimization diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index b33f9f16..065083d7 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -332,6 +332,7 @@ AgentProxy provides a two-layer defense: request-side injection scanning and res | `AGENTPROXY_PROVIDER_MANIFEST_URL` | _(unset)_ | `open-sse/config/providerPluginManifestUrl.ts` | Absolute provider plugin manifest URL advertised to sidecar clients. When unset, AgentProxy derives `/api/v1/provider-plugin-manifest` from request origin or HOST/PORT. | | `AGENTPROXY_PUBLIC_PROTOCOL` | `http` | `open-sse/config/providerPluginManifestUrl.ts` | Protocol used when deriving the provider plugin manifest URL from HOST/PORT without a request origin. Set to `https` behind a TLS-terminating public proxy when no explicit `AGENTPROXY_PROVIDER_MANIFEST_URL` is set. | | `AGENTPROXY_TRUST_PROXY` | _(unset)_ | `src/server/origin/publicOrigin.ts` | Optional trust mode for forwarded public-origin headers. Unset = do not trust `Forwarded` / `X-Forwarded-*` for security decisions. `true` / `loopback` trusts forwarded host/proto only from a token-stamped loopback proxy. `private` / `lan` also trusts private-LAN proxy peers. Prefer explicit `NEXT_PUBLIC_BASE_URL` in production. | +| `AGENTPROXY_TRUSTED_PROXY_PEERS` | _(unset)_ | `src/lib/apiBridgeServer.ts` | Comma-separated literal private proxy IPs (IPv4/IPv6, no CIDR/DNS). Required in addition to `AGENTPROXY_TRUST_PROXY=private` or `lan` before the API bridge trusts forwarding headers from a LAN peer. Unlisted direct LAN clients are treated as remote and their supplied forwarding headers are dropped. | | `KIE_CALLBACK_URL` | _(unset)_ | `open-sse/utils/kieTask.ts` | Public callback URL for asynchronous kie.ai jobs. Highest-priority override before `AGENTPROXY_KIE_CALLBACK_URL` and `AGENTPROXY_PUBLIC_URL`. | | `AGENTPROXY_KIE_CALLBACK_URL` | _(unset)_ | `open-sse/utils/kieTask.ts` | Alternate spelling of `KIE_CALLBACK_URL`. Falls back when the primary variable is unset. | | `AGENTPROXY_PUBLIC_URL` | _(unset)_ | `open-sse/utils/kieTask.ts` | Public origin used to compose async callback URLs. Lowest-priority fallback for kie.ai callbacks; also used as a generic public URL for other relays. | diff --git a/src/app/api/models/test-all/route.ts b/src/app/api/models/test-all/route.ts index 92814458..06217f01 100644 --- a/src/app/api/models/test-all/route.ts +++ b/src/app/api/models/test-all/route.ts @@ -40,7 +40,7 @@ const testAllSchema = z.object({ }); export interface BatchTestResultEntry { - status: "ok" | "error" | "slow"; + status: "ok" | "error" | "slow" | "skipped"; latencyMs: number; responseText?: string; error?: string; @@ -56,7 +56,14 @@ function toBatchEntry( result: Awaited> ): BatchTestResultEntry { const entry: BatchTestResultEntry = { - status: result.status === "ok" ? "ok" : result.status === "slow" ? "slow" : "error", + status: + result.status === "ok" + ? "ok" + : result.status === "slow" + ? "slow" + : result.status === "skipped" + ? "skipped" + : "error", latencyMs: result.latencyMs, }; if (result.responseText !== undefined) entry.responseText = result.responseText; diff --git a/src/app/api/models/test/route.ts b/src/app/api/models/test/route.ts index 876a09bb..00ea4f58 100644 --- a/src/app/api/models/test/route.ts +++ b/src/app/api/models/test/route.ts @@ -80,6 +80,13 @@ export async function POST(request: Request) { responseText: result.responseText, }); } + if (result.status === "skipped") { + return NextResponse.json({ + status: "skipped", + latencyMs: result.latencyMs, + reason: result.error, + }); + } const body: Record = { status: "error", diff --git a/src/app/api/services/openwa/_lib.ts b/src/app/api/services/openwa/_lib.ts index 34560226..e1766bea 100644 --- a/src/app/api/services/openwa/_lib.ts +++ b/src/app/api/services/openwa/_lib.ts @@ -27,6 +27,7 @@ export async function getOrInitSupervisor(): Promise { // on first pairing. See bootstrap.ts's SERVICES[] "openwa" entry comment // for the full verified explanation (must stay in sync with this value). healthIntervalMs: 60_000, + healthFailureDuringStartup: "tolerate", stopTimeoutMs: 30_000, logsBufferBytes: 5_242_880, // #6205: mirrors bootstrap.ts's own supervisor construction — adopt a diff --git a/src/lib/agentSkills/generator.ts b/src/lib/agentSkills/generator.ts index f2e65b3b..0b0fdcaa 100644 --- a/src/lib/agentSkills/generator.ts +++ b/src/lib/agentSkills/generator.ts @@ -163,14 +163,24 @@ function buildApiDocuments(skill: AgentSkill, sources: BuildSources): ApiSkillDo return { body: inlineBody, references: [] }; } + // GitHub Markdown suffixes duplicate heading slugs; both indexes must match. + const usedAnchors = new Set(); + const endpointAnchors = endpointBlocks.map((block) => { + const base = markdownAnchor(block.title); + let anchor = base; + let suffix = 1; + while (usedAnchors.has(anchor)) anchor = `${base}-${suffix++}`; + usedAnchors.add(anchor); + return anchor; + }); const endpointIndex = endpointBlocks.map( - (block) => `- [\`${block.title}\`](references/endpoints.md#${markdownAnchor(block.title)})` + (block, index) => `- [\`${block.title}\`](references/endpoints.md#${endpointAnchors[index]})` ); const indexedLines = [...lines, "## Endpoints\n", ...endpointIndex, ...payloadLines]; const referencePreamble = [GENERATED_COMMENT, "", "# Endpoint reference", ""]; const tableOfContents = endpointBlocks.map( - (block) => `- [\`${block.title}\`](#${markdownAnchor(block.title)})` + (block, index) => `- [\`${block.title}\`](#${endpointAnchors[index]})` ); const needsTableOfContents = referencePreamble.length + tableOfContents.length + 2 + endpointLines.length > diff --git a/src/lib/agentSkills/types.ts b/src/lib/agentSkills/types.ts index 18559abd..d5498604 100644 --- a/src/lib/agentSkills/types.ts +++ b/src/lib/agentSkills/types.ts @@ -61,7 +61,7 @@ export interface AgentSkill { area: SkillArea; endpoints?: string[]; // e.g. ["POST /api/providers", "GET /api/providers/:id"] (api only) cliCommands?: string[]; // e.g. ["providers list", "providers test", "providers rotate"] (cli only) - cliBinary?: string; // generated CLI package/binary override; defaults to legacy "omniroute" + cliBinary?: string; // generated CLI package/binary override; defaults to "agentproxy" icon?: string; // Material symbol name isEntry?: boolean; // "start here" tag isNew?: boolean; // "new" tag diff --git a/src/lib/api/modelTestRunner.ts b/src/lib/api/modelTestRunner.ts index 07ca42c4..cf1a474e 100644 --- a/src/lib/api/modelTestRunner.ts +++ b/src/lib/api/modelTestRunner.ts @@ -354,7 +354,7 @@ export interface RunSingleModelTestOptions { export interface SingleModelTestResult { modelId: string; - status: "ok" | "error" | "rate_limited" | "slow"; + status: "ok" | "error" | "rate_limited" | "slow" | "skipped"; latencyMs: number; responseText?: string; statusCode?: number; @@ -484,9 +484,9 @@ export async function runSingleModelTest( if (isNonChatGeneration) { return { modelId: fullModelStr, - status: "error", + status: "skipped", latencyMs: 0, - httpStatus: 422, + httpStatus: 200, error: "Skipped: non-chat generation model (images/music/video) — use the corresponding generation endpoint instead", }; diff --git a/src/lib/apiBridgeServer.ts b/src/lib/apiBridgeServer.ts index 4990c2ea..84dae83e 100644 --- a/src/lib/apiBridgeServer.ts +++ b/src/lib/apiBridgeServer.ts @@ -68,11 +68,27 @@ const FORWARDING_HEADERS = new Set([ "x-client-ip", ]); +function isExplicitTrustedPrivateProxy(peer: string): boolean { + const configured = process.env.AGENTPROXY_TRUSTED_PROXY_PEERS; + if (!configured) return false; + return configured.split(",").some((entry) => { + const candidate = entry.trim(); + return ( + net.isIP(candidate) !== 0 && + candidate.replace(/^::ffff:/, "").toLowerCase() === peer.toLowerCase() + ); + }); +} + function resolveClientPolicy(remoteAddress: string | undefined): ClientPolicy { const scope = classifyIpScope(remoteAddress); if (scope === "loopback") return { kind: "local" }; const peer = remoteAddress ? remoteAddress.replace(/^::ffff:/, "") : "unknown"; - if (scope === "private" && getTrustProxyMode() === "private") { + if ( + scope === "private" && + getTrustProxyMode() === "private" && + isExplicitTrustedPrivateProxy(peer) + ) { return { kind: "trusted-proxy", peer }; } return { kind: "remote", peer }; diff --git a/src/lib/arenaEloSync.ts b/src/lib/arenaEloSync.ts index 16becf20..e758a6e8 100644 --- a/src/lib/arenaEloSync.ts +++ b/src/lib/arenaEloSync.ts @@ -20,7 +20,6 @@ import { backupDbFile } from "./db/backup"; import { applyArenaEloRefresh, deleteModelIntelligenceBySource, - getLatestSyncedAt, type ModelIntelligenceEntry, } from "./db/modelIntelligence"; @@ -200,6 +199,24 @@ function writeLastFailedAt(ts: number): void { } } +function readLastCompleteRefreshAt(): number | null { + try { + const db = getDbInstance(); + const row = db.prepare(KV_READ_SQL).get("arena_elo", "lastCompleteRefreshAt") as + { value: string } | undefined; + if (!row?.value) return null; + const ts = Date.parse(row.value); + return Number.isFinite(ts) ? ts : null; + } catch { + return null; + } +} + +function writeLastCompleteRefreshAt(ts: number): void { + const db = getDbInstance(); + db.prepare(KV_WRITE_SQL).run("arena_elo", "lastCompleteRefreshAt", new Date(ts).toISOString()); +} + function clearLastFailedAt(): void { try { const db = getDbInstance(); @@ -495,12 +512,12 @@ export async function syncArenaElo(dryRun = false): Promise { } if (!dryRun) { - // Freshness guard: a non-empty dataset synced within the interval is - // still good — skip the fetch entirely (cheap fast path on boot). - const latest = getLatestSyncedAt("arena_elo"); - if (latest) { - const age = Date.now() - Date.parse(latest); - if (Number.isFinite(age) && age >= 0 && age < SYNC_INTERVAL_MS) { + // Freshness is advanced only after a complete all-category refresh. + // Row timestamps alone can describe a partial historical dataset. + const latestCompleteRefresh = readLastCompleteRefreshAt(); + if (latestCompleteRefresh !== null) { + const age = Date.now() - latestCompleteRefresh; + if (age >= 0 && age < SYNC_INTERVAL_MS) { return { success: true, modelCount: lastSyncModelCount, @@ -527,11 +544,25 @@ export async function syncArenaElo(dryRun = false): Promise { // sync while the upstream was rate-limiting; remediation 2026-09-12). const leaderboards = await fetchArenaLeaderboards(); const entries = transformToModelIntelligence(leaderboards); + const completeRefresh = FETCH_CATEGORIES.every((category) => { + const models = leaderboards[category]?.models; + return Array.isArray(models) && models.length > 0; + }); + if (!completeRefresh) { + if (!dryRun) writeLastFailedAt(Date.now()); + return { + success: false, + modelCount: 0, + source: "arena_elo", + error: "Incomplete Arena refresh; preserving previous dataset", + }; + } - if (!dryRun && entries.length > 0) { + if (!dryRun) { try { // Atomic replace: upsert all + prune not-in-refreshed-set in one tx. applyArenaEloRefresh(entries); + writeLastCompleteRefreshAt(Date.now()); clearLastFailedAt(); } catch (err) { const message = getErrorMessage(err); diff --git a/src/lib/memory/embedding/index.ts b/src/lib/memory/embedding/index.ts index 6b88d4ca..02b2d537 100644 --- a/src/lib/memory/embedding/index.ts +++ b/src/lib/memory/embedding/index.ts @@ -280,7 +280,7 @@ export async function embed( return { source: "remote", model: resolution.model, - reason: "request_failed", + reason: "invalid_config", message: error instanceof Error ? error.message @@ -318,6 +318,13 @@ export async function embedWithRetry( for (let attempt = 1; attempt <= Math.max(1, maxAttempts); attempt++) { const result = await embedFn(text, settings); if ("vector" in result) return result; + if ( + result.reason === "no_key" || + result.reason === "unknown" || + result.reason === "invalid_config" + ) { + return result; + } last = result; } return last as EmbeddingResult | EmbeddingError; diff --git a/src/lib/memory/embedding/nodeModalityListings.ts b/src/lib/memory/embedding/nodeModalityListings.ts index 6addae3f..7425bf41 100644 --- a/src/lib/memory/embedding/nodeModalityListings.ts +++ b/src/lib/memory/embedding/nodeModalityListings.ts @@ -121,7 +121,11 @@ function nodeListing( ): EmbeddingProviderListing | null { const apiType = str(node.apiType).trim().toLowerCase(); const nodeId = str(node.id); - const rows = mergeNodeRows(apiType, syncedByNodeId[nodeId], customByNodeId[nodeId]); + const rows = mergeNodeRows( + apiType, + syncedByNodeId[prefix] ?? syncedByNodeId[nodeId], + customByNodeId[prefix] ?? customByNodeId[nodeId] + ); const typed = [...rows.values()].filter((row) => row.endpoints.includes(modality)); if (typed.length === 0 && !IMPLICIT_API_TYPES[modality].has(apiType)) return null; return { diff --git a/src/lib/memory/embedding/remote.ts b/src/lib/memory/embedding/remote.ts index 7477a6ff..b18af204 100644 --- a/src/lib/memory/embedding/remote.ts +++ b/src/lib/memory/embedding/remote.ts @@ -55,7 +55,7 @@ export async function embedRemote( return { source: "remote", model, - reason: "request_failed", + reason: [400, 404, 422].includes(status) ? "invalid_config" : "request_failed", message: sanitizeErrorMessage(`Embedding provider returned HTTP ${status}`), }; } diff --git a/src/lib/memory/embedding/types.ts b/src/lib/memory/embedding/types.ts index 0118dd51..febfab19 100644 --- a/src/lib/memory/embedding/types.ts +++ b/src/lib/memory/embedding/types.ts @@ -38,6 +38,12 @@ export interface EmbeddingError { source: "remote" | "static" | "transformers"; model: string | null; reason: - "no_key" | "model_load_failed" | "request_failed" | "rate_limited" | "timeout" | "unknown"; + | "no_key" + | "invalid_config" + | "model_load_failed" + | "request_failed" + | "rate_limited" + | "timeout" + | "unknown"; message: string; // ALWAYS via sanitizeErrorMessage() } diff --git a/src/lib/monitoring/observability.ts b/src/lib/monitoring/observability.ts index c2b3fdc2..96c1c789 100644 --- a/src/lib/monitoring/observability.ts +++ b/src/lib/monitoring/observability.ts @@ -661,6 +661,8 @@ export async function probeDeepHealth( }); const response = await Promise.race([request, timeout]); const status = response.status; + // Release the unread body/connection without waiting for upstream cancellation. + void response.body?.cancel().catch(() => {}); if (status >= 200 && status < 300) return verdict(true, false, status); return verdict(false, status === 502 || status === 503, status); } catch { diff --git a/src/lib/providers/xai/translators/openai-chat.ts b/src/lib/providers/xai/translators/openai-chat.ts index 2c6d6f7e..b157095a 100644 --- a/src/lib/providers/xai/translators/openai-chat.ts +++ b/src/lib/providers/xai/translators/openai-chat.ts @@ -39,6 +39,7 @@ interface OpenAiMessage { tool_calls?: OpenAiToolCall[]; tool_call_id?: string; function_call?: { name?: string; arguments?: string }; + name?: string; } interface OpenAiChatRequest { @@ -174,6 +175,7 @@ export function chatRequestToXaiResponses(req: OpenAiChatRequest): XaiResponsesR const messages: OpenAiMessage[] = Array.isArray(req.messages) ? req.messages : []; const instructionsParts: string[] = []; const input: XaiInputItem[] = []; + const legacyCallIdsByName = new Map(); for (const m of messages) { if (!m) continue; @@ -198,6 +200,18 @@ export function chatRequestToXaiResponses(req: OpenAiChatRequest): XaiResponsesR }); continue; } + if (m.role === "function") { + const ids = m.name ? legacyCallIdsByName.get(m.name) : undefined; + const callId = ids?.shift(); + if (callId) { + input.push({ + type: "function_call_output", + call_id: callId, + output: typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? ""), + }); + } + continue; + } if (m.role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.length > 0) { // Emit any pre-existing assistant text first if (m.content) { @@ -218,9 +232,13 @@ export function chatRequestToXaiResponses(req: OpenAiChatRequest): XaiResponsesR if (m.content) { input.push({ role: "assistant", content: messageContentToXaiBlocks(m.content) }); } + const callId = genId("call"); + const ids = legacyCallIdsByName.get(m.function_call.name) ?? []; + ids.push(callId); + legacyCallIdsByName.set(m.function_call.name, ids); input.push({ type: "function_call", - call_id: genId("call"), + call_id: callId, name: m.function_call.name, arguments: m.function_call.arguments ?? "", }); diff --git a/src/lib/quota/boundedMap.ts b/src/lib/quota/boundedMap.ts index 4c42be2e..decd7c7c 100644 --- a/src/lib/quota/boundedMap.ts +++ b/src/lib/quota/boundedMap.ts @@ -156,9 +156,11 @@ export function boundedMap( inner.clear(); }, get size(): number { + if (expires) sweepExpired(Date.now()); return inner.size; }, keys(): IterableIterator { + if (expires) sweepExpired(Date.now()); return inner.keys(); }, [Symbol.iterator](): IterableIterator<[string, V]> { diff --git a/src/lib/quota/enforce.ts b/src/lib/quota/enforce.ts index b6310cd6..c13cc587 100644 --- a/src/lib/quota/enforce.ts +++ b/src/lib/quota/enforce.ts @@ -112,7 +112,7 @@ export async function enforceQuotaShare(input: EnforceInput): Promise { this.health = h; + if ( + h === "healthy" && + this.state === "starting" && + this.config.healthFailureDuringStartup === "tolerate" + ) { + this.lastError = null; + this.setState("running"); + void setToolStatus(this.config.tool, "running", this.pid ?? undefined); + return; + } this.emit("stateChange", this.getStatus()); // A service that fails FAILURE_THRESHOLD consecutive health probes will // not recover by itself. Stop the poller and surface an explicit error @@ -68,6 +78,9 @@ export class ServiceSupervisor extends EventEmitter { // fires a full ProxyFetch dispatcher+native fetch pair (e.g. against a // CLIProxyAPI binary that cannot execute on this platform). if (h === "unhealthy" && (this.state === "running" || this.state === "starting")) { + if (this.state === "starting" && this.config.healthFailureDuringStartup === "tolerate") { + return; + } this.checker.stop(); this.lastError = sanitizeErrorMessage( `Health probe failed for ${this.config.tool} (port ${this.config.port})` @@ -220,7 +233,18 @@ export class ServiceSupervisor extends EventEmitter { return this.getStatus(); } - this.setState("running"); + // Interactive services such as OpenWA may legitimately remain unreachable + // while waiting for a QR pairing. Keep them in "starting" until a later + // healthy probe promotes them through the HealthChecker callback above. + if ( + this.config.healthFailureDuringStartup === "tolerate" && + this.checker.getHealth() !== "healthy" + ) { + return this.getStatus(); + } + + const stateAfterHealthWait = this.getStatus().state; + if (stateAfterHealthWait !== "running") this.setState("running"); await setToolStatus(this.config.tool, "running", this.pid ?? undefined); return this.getStatus(); diff --git a/src/lib/services/bootstrap.ts b/src/lib/services/bootstrap.ts index 3bf18b7d..d2ffc42e 100644 --- a/src/lib/services/bootstrap.ts +++ b/src/lib/services/bootstrap.ts @@ -42,6 +42,7 @@ type ServiceEntry = { port: number; healthPath: string; healthIntervalMs: number; + healthFailureDuringStartup?: "error" | "tolerate"; stopTimeoutMs: number; logsBufferBytes: number; needsApiKey: boolean; @@ -115,18 +116,14 @@ const SERVICES: ServiceEntry[] = [ // HealthChecker's FAILURE_THRESHOLD (3, src/lib/services/healthCheck.ts) // means the supervisor would otherwise declare "error" ~3×healthIntervalMs // after every legitimate start — including a normal, successful one. - // healthIntervalMs is set high (vs. the 5s every other service uses) so - // that grace period (3×healthIntervalMs, ServiceSupervisor.waitForHealthy) - // is generous enough for a human to notice and scan the QR - // (~3 minutes) instead of always racing to "error". This is a - // ServiceSupervisor framework limitation (no separate "startup grace" - // knob distinct from the steady-state poll interval) — a real fix - // belongs in ServiceSupervisor/HealthChecker as a follow-up affecting - // all 5 services, not scoped here. + // OpenWA is explicitly allowed to remain unhealthy while starting so the + // QR pairing window is not converted into a terminal service error. Once + // it has reached running/healthy state, ordinary health failures are fatal. tool: "openwa", port: OPENWA_PORT, healthPath: "/api-docs/", healthIntervalMs: 60_000, + healthFailureDuringStartup: "tolerate", stopTimeoutMs: 30_000, logsBufferBytes: 5_242_880, needsApiKey: true, @@ -177,6 +174,7 @@ export async function bootstrapEmbeddedServices(): Promise { spawnArgs: buildSpawnArgsFactory(cfg, apiKey), healthUrl: () => `http://127.0.0.1:${cfg.port}${cfg.healthPath}`, healthIntervalMs: cfg.healthIntervalMs, + healthFailureDuringStartup: cfg.healthFailureDuringStartup, stopTimeoutMs: cfg.stopTimeoutMs, logsBufferBytes: cfg.logsBufferBytes, // #6205: embedded services bind a fixed port — probe before spawning so diff --git a/src/lib/services/installers/openwa.ts b/src/lib/services/installers/openwa.ts index ec93c1b9..fc9e66a1 100644 --- a/src/lib/services/installers/openwa.ts +++ b/src/lib/services/installers/openwa.ts @@ -140,7 +140,7 @@ export async function install(version = "latest"): Promise { installedVersion, binaryPath: getServerPath(), status: "stopped", - port: OPENWA_DEFAULT_PORT, + port: parseInt(process.env.OPENWA_SERVICE_PORT ?? String(OPENWA_DEFAULT_PORT), 10), }); // Invalidate cache so next getLatestVersion() re-fetches diff --git a/src/lib/services/types.ts b/src/lib/services/types.ts index 8bcea4bc..202d1532 100644 --- a/src/lib/services/types.ts +++ b/src/lib/services/types.ts @@ -11,6 +11,8 @@ export interface ServiceConfig { }; healthUrl: () => string; healthIntervalMs: number; + /** Keep polling instead of failing while an interactive service is still starting. */ + healthFailureDuringStartup?: "error" | "tolerate"; stopTimeoutMs: number; logsBufferBytes: number; /** diff --git a/tests/unit/13601-embed-retry.test.ts b/tests/unit/13601-embed-retry.test.ts index b5e38a26..e8d08a14 100644 --- a/tests/unit/13601-embed-retry.test.ts +++ b/tests/unit/13601-embed-retry.test.ts @@ -41,3 +41,30 @@ test("#13601.3: a persistent failure surfaces the last error after one retry", a assert.ok(!("vector" in result)); if (!("vector" in result)) assert.equal(result.message, "boom-2"); }); + +test("deterministic embedding errors are returned without repeated attempts", async () => { + for (const reason of ["no_key", "unknown", "invalid_config"] as const) { + let calls = 0; + const result = (await embedWithRetry( + "hello", + settings, + async () => { + calls += 1; + return { reason, message: "configuration unavailable" }; + }, + 3 + )) as EmbedResult; + assert.equal(calls, 1, reason); + assert.ok(!("vector" in result)); + } +}); + +test("invalid custom embedding configuration fails without a remote request", async () => { + const result = await embedWithRetry("hello", { + embeddingSource: "remote", + customBaseUrl: "not-a-url", + customModelId: "test-model", + } as Parameters[1]); + assert.ok(!("vector" in result)); + if (!("vector" in result)) assert.equal(result.reason, "invalid_config"); +}); diff --git a/tests/unit/agentSkills-generator.test.ts b/tests/unit/agentSkills-generator.test.ts index b92291c6..93e58aad 100644 --- a/tests/unit/agentSkills-generator.test.ts +++ b/tests/unit/agentSkills-generator.test.ts @@ -600,6 +600,24 @@ test("large generated API skills split endpoint details into indexed references" ); }); +test("large API skill indexes disambiguate colliding Markdown heading anchors", () => { + refreshCatalog(); + const sources = realApiSources(); + const ops = sources.openapi.areas.get("inference"); + assert.ok(ops); + ops.push( + { method: "GET", path: "/a-b", summary: "First", tags: [] }, + { method: "GET", path: "/a b", summary: "Second", tags: [] } + ); + const result = buildSkillMarkdown("omni-inference", sources); + assert.equal(result.references?.length, 1); + assert.ok(result.body.includes("[`GET /a-b`](references/endpoints.md#get-a-b)")); + assert.ok(result.body.includes("[`GET /a b`](references/endpoints.md#get-a-b-1)")); + const reference = result.references[0].content; + assert.ok(reference.includes("[`GET /a-b`](#get-a-b)")); + assert.ok(reference.includes("[`GET /a b`](#get-a-b-1)")); +}); + test("buildSkillMarkdown CLI skill body contains expected sections", () => { refreshCatalog(); const sources = emptySources(); diff --git a/tests/unit/api-bridge-client-address.test.ts b/tests/unit/api-bridge-client-address.test.ts index 2571980f..f431b923 100644 --- a/tests/unit/api-bridge-client-address.test.ts +++ b/tests/unit/api-bridge-client-address.test.ts @@ -18,7 +18,11 @@ const { stampPeerIp, PEER_IP_HEADER, VIA_PROXY_HEADER } = const { classifyStampedPeerLocality } = await import("../../src/server/authz/peerStamp.ts"); const STAMP_TOKEN = "api-bridge-client-address-token"; -const ENV_KEYS = ["AGENTPROXY_PEER_STAMP_TOKEN", "AGENTPROXY_TRUST_PROXY"]; +const ENV_KEYS = [ + "AGENTPROXY_PEER_STAMP_TOKEN", + "AGENTPROXY_TRUST_PROXY", + "AGENTPROXY_TRUSTED_PROXY_PEERS", +]; const originalEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); function restoreEnv() { @@ -31,6 +35,7 @@ function restoreEnv() { test.beforeEach(() => { restoreEnv(); delete process.env.AGENTPROXY_TRUST_PROXY; + delete process.env.AGENTPROXY_TRUSTED_PROXY_PEERS; }); test.after(restoreEnv); @@ -128,8 +133,44 @@ test("a loopback client keeps every forwarding header it sent", () => { assert.deepEqual({ ...relayed, host: undefined }, { ...sent, host: undefined }); }); +test("private trust mode does not trust an unlisted LAN peer's forwarding headers", () => { + process.env.AGENTPROXY_TRUST_PROXY = "private"; + process.env.AGENTPROXY_TRUSTED_PROXY_PEERS = "10.0.0.7"; + const relayed = bridge.buildBridgeRequestHeaders( + { "x-forwarded-for": "127.0.0.1", "x-real-ip": "127.0.0.1" }, + "10.0.0.8", + 20128 + ); + assert.equal(relayed["x-forwarded-for"], "10.0.0.8"); + assert.equal(relayed["x-real-ip"], undefined); + const upgrade = bridge.buildBridgeUpgradeHeaderLines( + ["Host", "api.example", "X-Forwarded-For", "127.0.0.1"], + "10.0.0.8", + 20128 + ); + assert.ok(upgrade.includes("X-Forwarded-For: 10.0.0.8")); + assert.ok( + !upgrade.some((line) => line.includes("127.0.0.1") && line.startsWith("X-Forwarded-For:")) + ); +}); + +test("private trust mode requires literal proxy IPs, not missing or CIDR wildcard entries", () => { + process.env.AGENTPROXY_TRUST_PROXY = "private"; + for (const value of [undefined, "10.0.0.0/8", "not-an-ip"]) { + if (value === undefined) delete process.env.AGENTPROXY_TRUSTED_PROXY_PEERS; + else process.env.AGENTPROXY_TRUSTED_PROXY_PEERS = value; + const headers = bridge.buildBridgeRequestHeaders( + { "x-forwarded-for": "127.0.0.1" }, + "10.0.0.7", + 20128 + ); + assert.equal(headers["x-forwarded-for"], "10.0.0.7"); + } +}); + test("with AGENTPROXY_TRUST_PROXY=private a private-LAN proxy keeps its chain and is appended to it", () => { process.env.AGENTPROXY_TRUST_PROXY = "private"; + process.env.AGENTPROXY_TRUSTED_PROXY_PEERS = "172.18.0.5"; const relayed = bridge.buildBridgeRequestHeaders( { "x-forwarded-for": "198.51.100.4", "x-forwarded-proto": "https" }, "172.18.0.5", @@ -142,6 +183,7 @@ test("with AGENTPROXY_TRUST_PROXY=private a private-LAN proxy keeps its chain an test("a trusted private-LAN proxy that sends no chain is still reported by its own address", () => { process.env.AGENTPROXY_TRUST_PROXY = "lan"; + process.env.AGENTPROXY_TRUSTED_PROXY_PEERS = "10.0.0.7"; const relayed = bridge.buildBridgeRequestHeaders({}, "10.0.0.7", 20128); assert.equal(relayed["x-forwarded-for"], "10.0.0.7"); }); @@ -203,6 +245,7 @@ test("the upgrade headers of a loopback client pass through unchanged", () => { test("the upgrade headers of a trusted private-LAN proxy carry its chain plus its own address", () => { process.env.AGENTPROXY_TRUST_PROXY = "private"; + process.env.AGENTPROXY_TRUSTED_PROXY_PEERS = "192.168.1.20"; const lines = bridge.buildBridgeUpgradeHeaderLines( ["Host", "api.example", "X-Forwarded-For", "198.51.100.4", "X-Forwarded-For", "203.0.113.3"], "192.168.1.20", diff --git a/tests/unit/arena-elo-sync-redesign.test.ts b/tests/unit/arena-elo-sync-redesign.test.ts index 1e266bc2..1ee169a2 100644 --- a/tests/unit/arena-elo-sync-redesign.test.ts +++ b/tests/unit/arena-elo-sync-redesign.test.ts @@ -38,10 +38,7 @@ import { tryOpenSync } from "../../src/lib/db/adapters/driverFactory"; import type { SqliteAdapter } from "../../src/lib/db/adapters/types"; const core = await import("../../src/lib/db/core.ts"); const { syncArenaElo, stopArenaEloSync } = await import("../../src/lib/arenaEloSync.ts"); -import type { - ArenaLeaderboardData, - ArenaModelEntry, -} from "../../src/lib/arenaEloSync.ts"; +import type { ArenaLeaderboardData, ArenaModelEntry } from "../../src/lib/arenaEloSync.ts"; const originalFetch = globalThis.fetch; function mockFetch(impl: (url: string, opts?: RequestInit) => Promise): void { @@ -178,7 +175,7 @@ describe("arenaEloSync redesign", () => { mockFetch( fetchServing({ text: [makeModelEntry({ model: "fresh-model" })], - code: [], + code: [makeModelEntry({ model: "fresh-code-model" })], }) ); const result = await syncArenaElo(); @@ -190,8 +187,36 @@ describe("arenaEloSync redesign", () => { assert.equal(getLastFailedAt(), null, "success must clear lastFailedAt"); }); - it("freshness guard: skips sync when table non-empty and synced_at within interval", async () => { + it("empty category fetch preserves the previous dataset and is not marked fresh", async () => { + seedEntry("existing-model"); + mockFetch( + fetchServing({ + text: [makeModelEntry({ model: "partial-new-model" })], + code: [], + }) + ); + + const result = await syncArenaElo(); + assert.equal(result.success, false); + assert.deepEqual( + getAllEntries().map((r) => r.model), + ["existing-model"] + ); + const marker = testAdapter + .prepare( + "SELECT value FROM key_value WHERE namespace='arena_elo' AND key='lastCompleteRefreshAt'" + ) + .get(); + assert.equal(marker, undefined); + }); + + it("freshness guard: skips only after a recorded complete refresh", async () => { seedEntry("recent-model", { syncedAt: new Date(Date.now() - 60_000).toISOString() }); // 1 min ago + testAdapter + .prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('arena_elo','lastCompleteRefreshAt',?)" + ) + .run(new Date(Date.now() - 60_000).toISOString()); let fetchCalled = false; mockFetch(async () => { @@ -204,6 +229,51 @@ describe("arenaEloSync redesign", () => { assert.equal(result.success, true, "skip is a success (fast path)"); }); + it("fresh rows without a complete-refresh marker do not suppress recovery fetches", async () => { + seedEntry("partial-model", { syncedAt: new Date(Date.now() - 60_000).toISOString() }); + let fetchCalled = false; + mockFetch( + fetchServing({ + text: [makeModelEntry({ model: "fresh-model" })], + code: [makeModelEntry({ model: "fresh-code-model" })], + }) + ); + const wrapped = globalThis.fetch; + globalThis.fetch = (async (...args: Parameters) => { + fetchCalled = true; + return wrapped(...args); + }) as typeof fetch; + + const result = await syncArenaElo(); + assert.equal(fetchCalled, true); + assert.equal(result.success, true); + }); + + it("partial category fetch preserves the previous dataset and is not marked fresh", async () => { + seedEntry("existing-model"); + mockFetch(async (url) => { + if (url.includes("name=text")) { + return jsonResponse( + makeLeaderboardData([makeModelEntry({ model: "partial-new-model" })], "text") + ); + } + throw new Error("code leaderboard unavailable"); + }); + + const first = await syncArenaElo(); + assert.equal(first.success, false); + assert.deepEqual( + getAllEntries().map((r) => r.model), + ["existing-model"] + ); + const marker = testAdapter + .prepare( + "SELECT value FROM key_value WHERE namespace='arena_elo' AND key='lastCompleteRefreshAt'" + ) + .get(); + assert.equal(marker, undefined); + }); + it("backoff wins even on an empty table: fresh lastFailedAt skips the fetch", async () => { // Empty table + fresh failure stamp = the exact boot-loop condition that // drained the live table (restart → sync → 429 → repeat). Backoff must diff --git a/tests/unit/arena-elo-sync.test.ts b/tests/unit/arena-elo-sync.test.ts index 1dccedf4..0ab70b58 100644 --- a/tests/unit/arena-elo-sync.test.ts +++ b/tests/unit/arena-elo-sync.test.ts @@ -672,7 +672,10 @@ describe("syncArenaElo()", () => { mockFetch(async (url: string) => { if (url.includes("name=text")) return jsonResponse(textData); - if (url.includes("name=code")) return jsonResponse(makeLeaderboardData([], "code")); + if (url.includes("name=code")) + return jsonResponse( + makeLeaderboardData([makeModelEntry({ model: "code-test-model" })], "code") + ); return new Response("Not found", { status: 404 }); }); @@ -695,7 +698,10 @@ describe("syncArenaElo()", () => { mockFetch(async (url: string) => { if (url.includes("name=text")) return jsonResponse(textData); - if (url.includes("name=code")) return jsonResponse(makeLeaderboardData([], "code")); + if (url.includes("name=code")) + return jsonResponse( + makeLeaderboardData([makeModelEntry({ model: "code-test-model" })], "code") + ); return new Response("Not found", { status: 404 }); }); @@ -722,7 +728,10 @@ describe("syncArenaElo()", () => { mockFetch(async (url: string) => { if (url.includes("name=text")) return jsonResponse(textData); - if (url.includes("name=code")) return jsonResponse(makeLeaderboardData([], "code")); + if (url.includes("name=code")) + return jsonResponse( + makeLeaderboardData([makeModelEntry({ model: "code-test-model" })], "code") + ); return new Response("Not found", { status: 404 }); }); @@ -750,7 +759,7 @@ describe("syncArenaElo()", () => { ); }); - it("calls deleteExpiredIntelligence before writing new entries", async () => { + it("complete refresh atomically prunes stale entries", async () => { testAdapter .prepare( "INSERT INTO model_intelligence (model, source, category, score, elo_raw, confidence, synced_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" @@ -775,7 +784,10 @@ describe("syncArenaElo()", () => { mockFetch(async (url: string) => { if (url.includes("name=text")) return jsonResponse(textData); - if (url.includes("name=code")) return jsonResponse(makeLeaderboardData([], "code")); + if (url.includes("name=code")) + return jsonResponse( + makeLeaderboardData([makeModelEntry({ model: "code-test-model" })], "code") + ); return new Response("Not found", { status: 404 }); }); @@ -788,17 +800,21 @@ describe("syncArenaElo()", () => { assert.strictEqual(expiredEntry, undefined); }); - it("handles empty leaderboard gracefully (0 entries, no DB write)", async () => { + it("empty leaderboard is incomplete and preserves an empty DB", async () => { mockFetch(async (url: string) => { if (url.includes("name=text")) return jsonResponse(makeLeaderboardData([], "text")); - if (url.includes("name=code")) return jsonResponse(makeLeaderboardData([], "code")); + if (url.includes("name=code")) + return jsonResponse( + makeLeaderboardData([makeModelEntry({ model: "code-test-model" })], "code") + ); return new Response("Not found", { status: 404 }); }); const result = await syncArenaElo(); - assert.strictEqual(result.success, true); + assert.strictEqual(result.success, false); assert.strictEqual(result.modelCount, 0); + assert.match(result.error ?? "", /Incomplete Arena refresh/); assert.strictEqual(countArenaEloEntries(), 0); }); @@ -810,7 +826,10 @@ describe("syncArenaElo()", () => { mockFetch(async (url: string) => { if (url.includes("name=text")) return jsonResponse(textData); - if (url.includes("name=code")) return jsonResponse(makeLeaderboardData([], "code")); + if (url.includes("name=code")) + return jsonResponse( + makeLeaderboardData([makeModelEntry({ model: "code-test-model" })], "code") + ); return new Response("Not found", { status: 404 }); }); @@ -836,7 +855,10 @@ describe("syncArenaElo()", () => { mockFetch(async (url: string) => { if (url.includes("name=text")) return jsonResponse(textData); - if (url.includes("name=code")) return jsonResponse(makeLeaderboardData([], "code")); + if (url.includes("name=code")) + return jsonResponse( + makeLeaderboardData([makeModelEntry({ model: "code-test-model" })], "code") + ); return new Response("Not found", { status: 404 }); }); diff --git a/tests/unit/memory-node-modality-listings.test.ts b/tests/unit/memory-node-modality-listings.test.ts index 3a57f0ee..2d0f9111 100644 --- a/tests/unit/memory-node-modality-listings.test.ts +++ b/tests/unit/memory-node-modality-listings.test.ts @@ -102,6 +102,25 @@ test.describe("buildProviderNodeModalityListings (pure)", () => { } }); + test("catalog rows keyed by the public provider prefix survive a different node id", () => { + const out = buildProviderNodeModalityListings( + "embeddings", + [ + { + id: "openai-compatible-uuid-123", + prefix: "skilled-prefix", + baseUrl: "http://127.0.0.1:8888/v1", + apiType: "embeddings", + }, + ], + { "skilled-prefix": [{ id: "bge-m3", name: "bge-m3" }] }, + {} + ); + assert.deepEqual(out[0]?.models, [ + { id: "skilled-prefix/bge-m3", name: "bge-m3", dimensions: null }, + ]); + }); + test("invalid prefixes are skipped", () => { const out = buildProviderNodeModalityListings("rerank", nodes, synced, custom); assert.equal( diff --git a/tests/unit/model-test-runner.test.ts b/tests/unit/model-test-runner.test.ts index cafaf8c3..df441a1a 100644 --- a/tests/unit/model-test-runner.test.ts +++ b/tests/unit/model-test-runner.test.ts @@ -440,11 +440,16 @@ test("runSingleModelTest preserves trusted local limiter HTTP statuses", async ( } }); -test("non-chat generation models are skipped with 422 before dispatch", async () => { +test("non-chat generation models are skipped without reporting a connection failure", async () => { const { addCustomModel } = await import("@/lib/db/models"); - await addCustomModel("openai", "image-only-ap0130", "Image only", "manual", "images-generations", [ - "images", - ]); + await addCustomModel( + "openai", + "image-only-ap0130", + "Image only", + "manual", + "images-generations", + ["images"] + ); const originalFetch = globalThis.fetch; let dispatched = false; @@ -464,8 +469,8 @@ test("non-chat generation models are skipped with 422 before dispatch", async () }); assert.equal(dispatched, false); - assert.equal(result.status, "error"); - assert.equal(result.httpStatus, 422); + assert.equal(result.status, "skipped"); + assert.equal(result.httpStatus, 200); } finally { globalThis.fetch = originalFetch; } diff --git a/tests/unit/monitoring-deep-health-14236.test.ts b/tests/unit/monitoring-deep-health-14236.test.ts index 3905ef1f..79127c47 100644 --- a/tests/unit/monitoring-deep-health-14236.test.ts +++ b/tests/unit/monitoring-deep-health-14236.test.ts @@ -64,6 +64,28 @@ test("deep probe marks only 502/503 as failover", async () => { assert.equal(DEEP_HEALTH_VERDICT_TTL_MS, 30_000); }); +test("deep probe cancels unread response streams for both healthy and failed status", async () => { + for (const status of [200, 503]) { + let cancelled = 0; + let read = 0; + const stream = new ReadableStream({ + pull(controller) { + read += 1; + controller.enqueue(new Uint8Array([123])); + }, + cancel() { + cancelled += 1; + }, + }); + const verdict = await probeDeepHealth("http://health.example/v1/chat/completions", { + fetcher: (async () => new Response(stream, { status })) as typeof fetch, + }); + assert.equal(verdict.status, status); + assert.equal(cancelled, 1, "unread response stream must be released"); + assert.ok(read <= 1, "deep probe must not consume the body"); + } +}); + test("deep probe posts one-token non-streaming body and optional bearer token", async () => { let seenUrl = ""; let seenInit: RequestInit | undefined; diff --git a/tests/unit/quota-bounded-map.test.ts b/tests/unit/quota-bounded-map.test.ts index 5781fb52..df7531e6 100644 --- a/tests/unit/quota-bounded-map.test.ts +++ b/tests/unit/quota-bounded-map.test.ts @@ -59,6 +59,20 @@ test("ttl: entries expire after ttlMs; lru entries never expire", () => { assert.equal(lru.get("a", 10_000_000), 1); }); +test("ttl: size and keys omit expired entries without a prior get or set", () => { + const m = boundedMap("ttl-observation", 10, "ttl", 100); + const now = Date.now(); + m.set("expired", 1, now - 1000); + m.set("fresh", 2, now); + assert.deepEqual([...m.keys()], ["fresh"]); + assert.equal(m.size, 1); + assert.deepEqual([...m], [["fresh", 2]]); + + const bySize = boundedMap("ttl-size", 10, "ttl", 100); + bySize.set("expired", 1, now - 1000); + assert.equal(bySize.size, 0, "size must sweep even without keys() or get()"); +}); + test("ttl: expired entries are swept before any fresh entry is evicted", () => { const { log } = captureLog(); const m = boundedMap("t", 3, "ttl", 100, { log }); diff --git a/tests/unit/quota-pool-primary-plan-13876.test.ts b/tests/unit/quota-pool-primary-plan-13876.test.ts index 01d6e594..030f0929 100644 --- a/tests/unit/quota-pool-primary-plan-13876.test.ts +++ b/tests/unit/quota-pool-primary-plan-13876.test.ts @@ -3,16 +3,13 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; -test("quota sharing resolves plan from the pool primary connection in both paths", () => { - const source = fs.readFileSync( - path.join(process.cwd(), "src/lib/quota/enforce.ts"), - "utf8" - ); +test("quota sharing resolves per-connection manual plans from the serving pool member", () => { + const source = fs.readFileSync(path.join(process.cwd(), "src/lib/quota/enforce.ts"), "utf8"); const primaryMatches = source.match(/resolvePlan\((?:pool|matchedPool)\.connectionId, input\.provider\)/g) ?? []; const servingMatches = source.match(/resolvePlan\(input\.connectionId, input\.provider\)/g) ?? []; - assert.equal(primaryMatches.length, 2); - assert.equal(servingMatches.length, 0); + assert.equal(primaryMatches.length, 0); + assert.equal(servingMatches.length, 2); }); diff --git a/tests/unit/reasoning-routing.test.ts b/tests/unit/reasoning-routing.test.ts index 5286553f..876098a0 100644 --- a/tests/unit/reasoning-routing.test.ts +++ b/tests/unit/reasoning-routing.test.ts @@ -173,6 +173,32 @@ test("default does not override a budget-only signal and force replaces discrete assert.equal(policy.applyReasoningRuleDirective(untouched), untouched); }); +test("format-specific default routing drops stale incompatible reasoning carriers", () => { + const directive = { + id: "default-high", + effortMode: "default", + targetEffort: "high", + budgetAction: "preserve", + budgetTokens: null, + }; + const input = { + reasoning: { effort: "low", summary: "auto" }, + output_config: { effort: "low", format: "compact" }, + _agentproxyReasoningRule: directive, + }; + const claude = policy.applyReasoningRuleDirective(input, "claude") as Record; + assert.equal(claude.reasoning, undefined); + assert.deepEqual(claude.output_config, { effort: "high", format: "compact" }); + + const responses = policy.applyReasoningRuleDirective(input, "openai-responses") as Record< + string, + unknown + >; + assert.deepEqual(responses.reasoning, { effort: "high", summary: "auto" }); + assert.equal(responses.output_config, undefined); + assert.deepEqual(input.reasoning, { effort: "low", summary: "auto" }); +}); + test("CRUD validates references, invalidates cache, and cascades deleted owners", async () => { const key = await apiKeysDb.createApiKey("Owner", "reasoning-owner-machine"); const combo = await combosDb.createCombo({ diff --git a/tests/unit/semantic-cache.test.ts b/tests/unit/semantic-cache.test.ts index 6df3415c..1d04b882 100644 --- a/tests/unit/semantic-cache.test.ts +++ b/tests/unit/semantic-cache.test.ts @@ -131,6 +131,15 @@ describe("Semantic Cache", () => { assert.equal(isCacheableForRead({ temperature: 0 }, headers), false); }); + it("rejects Cache-Control: no-store for reads, including mixed-case directives", () => { + for (const header of ["no-store", "private, No-Store", "max-age=0, no-store"]) { + assert.equal( + isCacheableForRead({ temperature: 0 }, new Headers({ "cache-control": header })), + false + ); + } + }); + it("returns true when no-cache header is absent", () => { const headers = new Headers({}); assert.equal(isCacheableForRead({ temperature: 0 }, headers), true); @@ -154,5 +163,14 @@ describe("Semantic Cache", () => { const headers = new Headers({ "x-agentproxy-no-cache": "true" }); assert.equal(isCacheableForWrite({ temperature: 0 }, headers), false); }); + + it("rejects Cache-Control: no-store for writes", () => { + for (const header of ["no-store", "private, No-Store", "max-age=0, no-store"]) { + assert.equal( + isCacheableForWrite({ temperature: 0 }, new Headers({ "cache-control": header })), + false + ); + } + }); }); }); diff --git a/tests/unit/services/installers/openwa.test.ts b/tests/unit/services/installers/openwa.test.ts index debcb4d0..c534b10b 100644 --- a/tests/unit/services/installers/openwa.test.ts +++ b/tests/unit/services/installers/openwa.test.ts @@ -93,6 +93,21 @@ test("install creates package.json structure", async () => { assert.ok(result.durationMs >= 0); }); +test("install persists the configured service port rather than the default", async () => { + const original = process.env.OPENWA_SERVICE_PORT; + process.env.OPENWA_SERVICE_PORT = "8462"; + try { + await install(MOCK_OPENWA_VERSION); + const { getVersionManagerTool } = await import("../../../../src/lib/db/versionManager.ts"); + const row = await getVersionManagerTool("openwa"); + assert.equal(row?.port, 8462); + } finally { + if (original === undefined) delete process.env.OPENWA_SERVICE_PORT; + else process.env.OPENWA_SERVICE_PORT = original; + await install(MOCK_OPENWA_VERSION); + } +}); + test("install captures real version from node_modules/@open-wa/wa-automate/package.json", async () => { const ver = await getInstalledVersion(); assert.equal(ver, MOCK_OPENWA_VERSION, "should read version from installed package"); diff --git a/tests/unit/services/openwa-startup-tolerance-0201.test.ts b/tests/unit/services/openwa-startup-tolerance-0201.test.ts new file mode 100644 index 00000000..f740727d --- /dev/null +++ b/tests/unit/services/openwa-startup-tolerance-0201.test.ts @@ -0,0 +1,97 @@ +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 http from "node:http"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "agentproxy-openwa-startup-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.NODE_ENV = "test"; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; + +const core = await import("../../../src/lib/db/core.ts"); +const db = core.getDbInstance(); +db.prepare( + `INSERT OR IGNORE INTO version_manager (tool, status, port, auto_start, auto_update, provider_expose) + VALUES ('openwa-startup-test', 'stopped', 29983, 0, 0, 0)` +).run(); +const { ServiceSupervisor } = await import("../../../src/lib/services/ServiceSupervisor.ts"); + +const OPENWA_ROUTE_LIB_SOURCE = fs.readFileSync( + new URL("../../../src/app/api/services/openwa/_lib.ts", import.meta.url), + "utf8" +); + +async function waitFor( + predicate: () => boolean, + timeoutMs = 1_000 +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.fail("condition did not become true before timeout"); +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("on-demand OpenWA supervisor preserves startup health tolerance", () => { + assert.match( + OPENWA_ROUTE_LIB_SOURCE, + /healthFailureDuringStartup:\s*["']tolerate["']/, + "direct OpenWA API startup must keep the same QR-pairing tolerance as bootstrap" + ); +}); + +test("startup health failures keep an interactive service starting until pairing completes", async () => { + const sup = new ServiceSupervisor({ + tool: "openwa-startup-test", + port: 29983, + spawnArgs: () => ({ + command: process.execPath, + args: ["-e", "setInterval(() => {}, 1000)"], + env: { ...process.env }, + cwd: process.cwd(), + }), + healthUrl: () => "http://127.0.0.1:29983/health", + healthIntervalMs: 25, + healthFailureDuringStartup: "tolerate", + stopTimeoutMs: 500, + logsBufferBytes: 1024, + }); + + let healthServer: http.Server | null = null; + try { + const status = await sup.start(); + assert.equal(status.state, "starting"); + assert.equal(status.health, "unhealthy"); + + healthServer = http.createServer((_req, res) => { + res.writeHead(200, { "content-type": "application/json" }); + res.end("{}"); + }); + await new Promise((resolve, reject) => { + healthServer!.once("error", reject); + healthServer!.listen(29983, "127.0.0.1", resolve); + }); + + await waitFor(() => { + const recovered = sup.getStatus(); + return recovered.state === "running" && recovered.health === "healthy"; + }); + + const recovered = sup.getStatus(); + assert.equal(recovered.state, "running"); + assert.equal(recovered.health, "healthy"); + } finally { + if (healthServer) { + await new Promise((resolve) => healthServer!.close(() => resolve())); + } + await sup.stop(); + } +}); diff --git a/tests/unit/xai-translator-legacy-function-call-13753.test.ts b/tests/unit/xai-translator-legacy-function-call-13753.test.ts index 554efced..b23b738b 100644 --- a/tests/unit/xai-translator-legacy-function-call-13753.test.ts +++ b/tests/unit/xai-translator-legacy-function-call-13753.test.ts @@ -13,14 +13,71 @@ test("xAI chat translator preserves legacy assistant function_call", () => { messages: [ { role: "assistant", - function_call: { name: "lookup", arguments: "{\"q\":\"x\"}" }, + function_call: { name: "lookup", arguments: '{"q":"x"}' }, }, ], } as never); const call = out.input.find((entry) => entry.type === "function_call"); assert.equal(call?.name, "lookup"); - assert.equal(call?.arguments, "{\"q\":\"x\"}"); + assert.equal(call?.arguments, '{"q":"x"}'); +}); + +test("xAI chat translator pairs legacy function output with the generated call id", () => { + const out = chatRequestToXaiResponses({ + model: "grok-4.1-fast", + messages: [ + { + role: "assistant", + function_call: { name: "lookup", arguments: '{"q":"x"}' }, + }, + { role: "function", name: "lookup", content: '{"answer":42}' }, + ], + } as never); + + const call = out.input.find((entry) => entry.type === "function_call"); + const output = out.input.find((entry) => entry.type === "function_call_output"); + assert.ok(call?.call_id); + assert.equal(output?.call_id, call.call_id); + assert.equal(output?.output, '{"answer":42}'); +}); + +test("xAI chat translator drops an orphan legacy function output", () => { + const out = chatRequestToXaiResponses({ + model: "grok-4.1-fast", + messages: [{ role: "function", name: "lookup", content: '{"answer":42}' }], + } as never); + + assert.equal( + out.input.some((entry) => entry.role === "function"), + false + ); + assert.equal( + out.input.some((entry) => entry.type === "function_call_output"), + false + ); +}); + +test("xAI chat translator drops a mismatched legacy function output", () => { + const out = chatRequestToXaiResponses({ + model: "grok-4.1-fast", + messages: [ + { + role: "assistant", + function_call: { name: "lookup", arguments: '{"q":"x"}' }, + }, + { role: "function", name: "other", content: '{"answer":42}' }, + ], + } as never); + + assert.equal( + out.input.some((entry) => entry.role === "function"), + false + ); + assert.equal( + out.input.some((entry) => entry.type === "function_call_output"), + false + ); }); test("xAI reverse translators derive total tokens from prompt/completion aliases", () => {