diff --git a/.gitignore b/.gitignore
index 88bfda3ece..b91ffc9f01 100644
--- a/.gitignore
+++ b/.gitignore
@@ -70,6 +70,8 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
+# Local gitleaks artifacts (do not commit)
+gitleaks-local.json
!.env.example
!.env.homolog.example
!.env.devin-bridge.example
diff --git a/README.md b/README.md
index ba0cf4d2ba..dad0af6aa7 100644
--- a/README.md
+++ b/README.md
@@ -513,6 +513,8 @@ Pix copia-e-cola:
+
Developer notes: The project may generate a local .env file during npm install/postinstall for developer convenience. This file is intentionally ignored via .gitignore (see .gitignore) and must never be committed β if accidentally committed, rotate any exposed secrets and remove the file from history. See docs/DEVELOPER-ENVIRONMENT.md for guidance on managing local environment files and secrets.
+
## π‘ OmniRoute Radar
The main free-tier headline remains **~1.53B tokens/month** from the documented,
diff --git a/changelog.d/fixes/10293-windows-tailscale-branches.md b/changelog.d/fixes/10293-windows-tailscale-branches.md
new file mode 100644
index 0000000000..2ee9f0d1d8
--- /dev/null
+++ b/changelog.d/fixes/10293-windows-tailscale-branches.md
@@ -0,0 +1 @@
+- **fix(build):** stop Turbopack from dead-code-eliminating the Windows Tailscale branches of `src/lib/tailscaleTunnel.ts` in the published build (#10293). The release `dist` is bundled on a Linux runner, and the bundler constant-folds `process.platform`, pruning every non-Linux branch β the Windows installers shipped with no `where` lookup, an always-injected `--socket`, and a lost `net start Tailscale`/windows-default-binary path. The module now reads the platform at runtime via `os.platform()` (a function call a bundler cannot fold), so the Windows branches survive on any build machine; a vitest regression test mocking `os.platform()` β `win32` guards the anti-fold invariant (RED before, GREEN after).
\ No newline at end of file
diff --git a/changelog.d/fixes/10348-default-logs-redact-client.md b/changelog.d/fixes/10348-default-logs-redact-client.md
new file mode 100644
index 0000000000..4c3aa0a00f
--- /dev/null
+++ b/changelog.d/fixes/10348-default-logs-redact-client.md
@@ -0,0 +1 @@
+- fix(backend): redact client IPs and account prefixes from default proxy logs (#10348)
diff --git a/changelog.d/fixes/10424-antigravity-project-autocreate.md b/changelog.d/fixes/10424-antigravity-project-autocreate.md
new file mode 100644
index 0000000000..81fc6734c4
--- /dev/null
+++ b/changelog.d/fixes/10424-antigravity-project-autocreate.md
@@ -0,0 +1,2 @@
+- **fix(antigravity):** accounts with an empty Cloud Code `projectId` now heal themselves β failed auto-onboarding (`onboardUser`) attempts are retried after a short backoff instead of being memoized forever, so the missing Google project is created without user action on a later request or token refresh ([#10424](https://github.com/diegosouzapw/OmniRoute/pull/10424)) β thanks @rqzbeh
+- **fix(antigravity):** Google deprecated automatic project creation for standard-tier (personal) accounts β when `onboardUser` completes without a project id the account now fails fast with a clear `403 GCP_PROJECT_REQUIRED` message (no more generic 422 or delayed 429 RESOURCE_EXHAUSTED), and a manual GCP Project ID override is available in the connection editor so operators can enter their own project id ([#10424](https://github.com/diegosouzapw/OmniRoute/pull/10424)) β thanks @rqzbeh
diff --git a/changelog.d/fixes/10465-gemini-cached-tokens.md b/changelog.d/fixes/10465-gemini-cached-tokens.md
new file mode 100644
index 0000000000..0acd31720a
--- /dev/null
+++ b/changelog.d/fixes/10465-gemini-cached-tokens.md
@@ -0,0 +1 @@
+- **fix(usage):** surface Gemini `cachedContentTokenCount` into `cached_tokens` for non-streaming requests so cache-hit accounting matches the OpenAI/Claude/Responses branches and the streaming path (follow-up to the #10430 envelope fix) ([#10465](https://github.com/diegosouzapw/OmniRoute/pull/10465)) β thanks @rqzbeh
diff --git a/changelog.d/fixes/10482-docker-images-and-basepath.md b/changelog.d/fixes/10482-docker-images-and-basepath.md
new file mode 100644
index 0000000000..c85ae91937
--- /dev/null
+++ b/changelog.d/fixes/10482-docker-images-and-basepath.md
@@ -0,0 +1 @@
+- **fix(docker):** point the bifrost sidecar at the real `ghcr.io/maximhq/bifrost:v1.6.11` tag and the cliproxyapi sidecar at the official `docker.io/eceasy/cli-proxy-api:v6.9.7` image (the previously pinned tags never existed), and complete the runtime `OMNIROUTE_BASE_PATH` subpath patch for Next 16 standalone (assetPrefix + client env + baked asset URLs) so prebuilt images respect the webpath env var ([#10482](https://github.com/diegosouzapw/OmniRoute/pull/10482))
diff --git a/changelog.d/fixes/9303-recovery-hint-all-targets-skipped.md b/changelog.d/fixes/9303-recovery-hint-all-targets-skipped.md
new file mode 100644
index 0000000000..78649d651f
--- /dev/null
+++ b/changelog.d/fixes/9303-recovery-hint-all-targets-skipped.md
@@ -0,0 +1 @@
+- fix(combo): recovery hint for all_targets_skipped now points at provider quota/availability instead of 'transient, just retry' (#9303)
diff --git a/changelog.d/fixes/forward-codex-quota-headers.md b/changelog.d/fixes/forward-codex-quota-headers.md
new file mode 100644
index 0000000000..86aa1e7df4
--- /dev/null
+++ b/changelog.d/fixes/forward-codex-quota-headers.md
@@ -0,0 +1 @@
+- **fix(sse):** keep Codex/Anthropic quota headers under the upstream forwarding budget; drop `x-codex-turn-state` and raise the 768-byte cap (`open-sse/handlers/chatCore/responseHeaders.ts`)
diff --git a/docker-compose.yml b/docker-compose.yml
index 522ca3bc1c..d2cf960caa 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -247,7 +247,7 @@ services:
# fall back to the chatCore path with zero code changes. See
# docs/architecture/cluster-decisions.md for the activation plan.
bifrost:
- image: ghcr.io/maximhq/bifrost:1.5.21
+ image: ghcr.io/maximhq/bifrost:v1.6.11
container_name: omniroute-bifrost
restart: unless-stopped
ports:
@@ -266,9 +266,12 @@ services:
- bifrost
# ββ Profile: cliproxyapi (CLIProxyAPI as sidecar) βββββββββββββββββ
+ # Official pre-built image lives on Docker Hub (eceasy/cli-proxy-api);
+ # ghcr.io/router-for-me/* is not publicly pullable. v6.9.7 is the pinned
+ # version the sidecar integration (port 8317, /v1/models healthcheck) targets.
cliproxyapi:
container_name: cliproxyapi
- image: ghcr.io/router-for-me/cliproxyapi:v6.9.7
+ image: docker.io/eceasy/cli-proxy-api:v6.9.7
restart: unless-stopped
ports:
- "${CLIPROXYAPI_PORT:-8317}:${CLIPROXYAPI_PORT:-8317}"
diff --git a/docs/DEVELOPER-ENVIRONMENT.md b/docs/DEVELOPER-ENVIRONMENT.md
new file mode 100644
index 0000000000..0b70014bd4
--- /dev/null
+++ b/docs/DEVELOPER-ENVIRONMENT.md
@@ -0,0 +1,29 @@
+# Developer environment notes
+
+This page explains the project's local `.env` behavior and how to handle environment files and secrets when developing OmniRoute.
+
+## .env postinstall behavior
+
+The project may generate a local `.env` file during `npm install` / `postinstall` for developer convenience. This file is intended only for local development and testing and must never be committed to version control.
+
+Key points:
+
+- The repository's `.gitignore` already ignores `.env*` files (see the `.gitignore` entry). Do not remove or alter that rule unless you deliberately intend to commit a specific example file and have a documented process for it.
+- If a real secret is accidentally committed to the repo, rotate/revoke the credential immediately and remove it from the repository history (for example, using `git filter-repo` or an equivalent remediation workflow). Contact the security/contact owner if you need help.
+- For CI and production, use the CI secrets or a secrets manager (GitHub Actions Secrets, Azure Key Vault, HashiCorp Vault, etc.) rather than committing secrets to files.
+
+## Recommended local workflow
+
+- Keep `.env` in your local workspace only. Use `.env.example` (already tracked) to document required variables and acceptable example values.
+- When running tests locally that require secret-like values, prefer synthetic placeholders or runtime-generated ephemeral keys rather than real credentials.
+- Add a short comment in tests that use placeholders so reviewers understand the fixture is synthetic.
+
+## Scanner notes
+
+- Some compiled or binary assets (e.g., embedded base64 WASM blobs) can contain ASCII substrings that look like credentials and may trigger text-based secret scanners. If these assets are legitimate, either mark them in the scanner's allowlist or exclude the directories in the scanner config.
+
+## If you find a leak
+
+1. Rotate/revoke the key immediately.
+2. Remove the secret from the history and force-push a cleaned branch if necessary.
+3. Notify maintainers and follow your org's incident response checklist.
diff --git a/docs/architecture/cluster-decisions.md b/docs/architecture/cluster-decisions.md
index c306cbd072..d3f28e1cde 100644
--- a/docs/architecture/cluster-decisions.md
+++ b/docs/architecture/cluster-decisions.md
@@ -55,9 +55,9 @@ The two profiles here are **scale-out options for deployments that hit the SQLit
**What it adds:**
-| Service | Image | Ports | Notes |
-| --------- | -------------------------------- | ------ | ----------------------------------------------------------------------- |
-| `bifrost` | `ghcr.io/maximhq/bifrost:1.5.21` | `8080` | Go-based Tier-1 router; persistent logs volume `omniroute_bifrost_logs` |
+| Service | Image | Ports | Notes |
+| --------- | --------------------------------- | ------ | ----------------------------------------------------------------------- |
+| `bifrost` | `ghcr.io/maximhq/bifrost:v1.6.11` | `8080` | Go-based Tier-1 router; persistent logs volume `omniroute_bifrost_logs` |
**Activation:** set `BIFROST_BASE_URL=http://bifrost:8080` in `.env.example`. The existing sidecar proxy route at [`src/app/api/v1/relay/chat/completions/bifrost/route.ts`](../../src/app/api/v1/relay/chat/completions/bifrost/route.ts) (added in PR #4381) will pick this up automatically.
diff --git a/docs/guides/DOCKER_GUIDE.md b/docs/guides/DOCKER_GUIDE.md
index 2fb1b75f79..6503ac343b 100644
--- a/docs/guides/DOCKER_GUIDE.md
+++ b/docs/guides/DOCKER_GUIDE.md
@@ -285,8 +285,12 @@ Next.js `basePath` is compiled into the standalone bundle. OmniRoute records the
value in a sentinel file at the app root (written during `npm run build`; read by
`scripts/docker/ensure-docker-base-path.mjs`) and compares it with
`OMNIROUTE_BASE_PATH` when the container starts. When they differ and the image was
-built for the domain root, the entrypoint rewrites the standalone manifests and embedded
-`basePath` literals before `node dev/run-standalone.mjs` runs.
+built for the domain root, the entrypoint rewrites the standalone manifests, the
+embedded `basePath`/`assetPrefix` literals (Next 16 renders SSR asset URLs from
+`assetPrefix` alone β the patcher mirrors the subpath into it), the baked
+`/_next/static` asset URLs (client-reference manifests, media imports, prerendered
+error pages) and the client `process.env` shim before `node dev/run-standalone.mjs`
+runs.
### Compose build (recommended)
diff --git a/next.config.mjs b/next.config.mjs
index 34be60c02e..2f22b8aebd 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -113,6 +113,12 @@ const nextConfig = {
// keeps operating on un-prefixed paths β see src/server/authz/pipeline.ts for
// the two redirect call sites that re-add it via `request.nextUrl.basePath`.
basePath: normalizeBasePath(process.env.OMNIROUTE_BASE_PATH),
+ // Next 16 (both webpack and Turbopack) app-router renders SSR asset URLs from
+ // `assetPrefix` ALONE β basePath only affects routing/links. Without mirroring
+ // it here, a subpath build emits /_next/static shell references that 404
+ // behind a reverse proxy. The Docker runtime patcher (ensure-docker-base-path)
+ // rewrites the same knob for prebuilt root-path images.
+ assetPrefix: normalizeBasePath(process.env.OMNIROUTE_BASE_PATH) || undefined,
// Client-visible mirror of basePath for fetch/EventSource rewriting under reverse
// proxies (installBasePathFetch), and for client display helpers (useDisplayBaseUrl)
// that append the subpath to window.location.origin when building curl/endpoint
diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts
index 137b996920..dda321c266 100644
--- a/open-sse/executors/antigravity.ts
+++ b/open-sse/executors/antigravity.ts
@@ -28,7 +28,10 @@ import {
resolveAntigravityOutputCap,
} from "./antigravityOutputCap.ts";
export { MAX_ANTIGRAVITY_OUTPUT_TOKENS } from "./antigravityOutputCap.ts";
-import { ensureAntigravityProjectAssigned } from "../services/antigravityProjectBootstrap.ts";
+import {
+ ensureAntigravityProjectAssigned,
+ ANTIGRAVITY_REQUIRES_MANUAL_PROJECT,
+} from "../services/antigravityProjectBootstrap.ts";
import { persistDiscoveredAntigravityProjectId } from "../services/antigravityProjectPersist.ts";
import { markAntigravityMissingCloudCodeProject } from "../services/antigravityProjectPersistence.ts";
import {
@@ -577,6 +580,7 @@ export class AntigravityExecutor extends BaseExecutor {
// its Google account already owns a Cloud Code project (the OAuth-time loadCodeAssist
// returned empty/transiently failed). Mirror the Cloud Code bootstrap to recover it
// here β the helper memoizes per access-token, so this is a one-time round-trip.
+ let requiresManualProject = false;
if (!projectId && credentials?.accessToken) {
const discovered = await ensureAntigravityProjectAssigned(
credentials.accessToken,
@@ -584,7 +588,7 @@ export class AntigravityExecutor extends BaseExecutor {
getAntigravityClientProfile(credentials),
signal
);
- if (discovered) {
+ if (discovered && discovered !== ANTIGRAVITY_REQUIRES_MANUAL_PROJECT) {
projectId = discovered;
// #8491: persist the recovered id so it survives the next token refresh
// or process restart instead of being silently rediscovered every time.
@@ -594,10 +598,40 @@ export class AntigravityExecutor extends BaseExecutor {
credentials.providerSpecificData
);
}
+ requiresManualProject = discovered === ANTIGRAVITY_REQUIRES_MANUAL_PROJECT;
}
if (!projectId) {
markAntigravityMissingCloudCodeProject(credentials?.connectionId);
+ if (requiresManualProject) {
+ // Google no longer auto-creates GCP projects for standard-tier
+ // accounts (tracked in #8491): fail fast with a clear instruction
+ // instead of the generic 422 β a fabricated/omitted id only earns a
+ // delayed 429 RESOURCE_EXHAUSTED from Google's quota check.
+ const errorBody = {
+ error: {
+ message:
+ "GCP_PROJECT_REQUIRED: Google Antigravity now requires a free GCP Project ID. " +
+ "Create one at console.cloud.google.com and enter it in Providers β Antigravity " +
+ "(connection settings β Project ID). Automatic project creation is no longer " +
+ "available for personal accounts.",
+ type: "gcp_project_required",
+ code: "gcp_project_required",
+ },
+ };
+ // 422, not 403: chatCore's generic "401/403 β refresh credentials and
+ // retry" path would otherwise hit Google's OAuth token endpoint on
+ // every request from an affected account β pointless, since refreshing
+ // the token cannot create a GCP project. 422 also matches the sibling
+ // missing_project_id error, which the client already maps to a clear
+ // "action needed" prompt.
+ const resp = new Response(JSON.stringify(errorBody), {
+ status: 422,
+ headers: { "Content-Type": "application/json" },
+ });
+ // Returning a Response object signals the executor to stop and forward it
+ return resp as unknown as never;
+ }
// (#489) Return a structured error instead of throwing β gives the client a clear signal
// to show a "Reconnect OAuth" prompt rather than an opaque "Internal Server Error".
const errorMsg =
diff --git a/open-sse/handlers/chatCore/attemptLogging.ts b/open-sse/handlers/chatCore/attemptLogging.ts
index 245aafdcc3..bbe049cd63 100644
--- a/open-sse/handlers/chatCore/attemptLogging.ts
+++ b/open-sse/handlers/chatCore/attemptLogging.ts
@@ -15,9 +15,24 @@ import { logAuditEvent } from "@/lib/compliance";
import { emit } from "@/lib/events/eventBus";
import type { RequestCompletedPayload, RequestFailedPayload } from "@/lib/events/types";
import { saveCallLog } from "@/lib/usageDb";
+import { FORMATS } from "../../translator/formats.ts";
import { cloneBoundedChatLogPayload, truncateForLog } from "./logTruncation.ts";
import { attachLogMeta } from "./cacheUsageMeta.ts";
+/**
+ * Extract the OpenAI Responses API response id this attempt produced, so it
+ * can be indexed for OmniRoute-native `previous_response_id` continuation
+ * (see src/lib/db/responsesContinuationStore.ts). Only meaningful when the
+ * client actually used the Responses endpoint -- a Chat Completions
+ * `chatcmpl-*` id must never be mistaken for a Responses response id.
+ */
+function extractResponsesId(sourceFormat: unknown, clientResponse: unknown): string | null {
+ if (sourceFormat !== FORMATS.OPENAI_RESPONSES) return null;
+ if (!clientResponse || typeof clientResponse !== "object") return null;
+ const id = (clientResponse as { id?: unknown }).id;
+ return typeof id === "string" && id.length > 0 ? id : null;
+}
+
export type PersistAttemptLogsArgs = {
status: number;
tokens?: unknown;
@@ -276,6 +291,7 @@ export function persistAttemptLogs(args: PersistAttemptLogsArgs, ctx: PersistAtt
correlationId,
modelPinned: modelPinned || false,
sessionTag: sessionTag || null,
+ responseId: extractResponsesId(sourceFormat, clientResponse),
}).catch(() => {});
// Emit the terminal request-lifecycle event to the live dashboard bus. `request.started`
diff --git a/open-sse/handlers/chatCore/responseHeaders.ts b/open-sse/handlers/chatCore/responseHeaders.ts
index 1a6602f506..59c45ba829 100644
--- a/open-sse/handlers/chatCore/responseHeaders.ts
+++ b/open-sse/handlers/chatCore/responseHeaders.ts
@@ -28,6 +28,9 @@ const STREAMING_RESPONSE_HEADER_DENYLIST = new Set([
"x-amz-security-token",
"x-auth-token",
"x-accel-buffering",
+ // 314-byte Codex session blob. It is not a client rate-limit signal and
+ // alone ate ~40% of the old 768-byte budget, evicting x-codex-*-used-percent.
+ "x-codex-turn-state",
]);
const DEFAULT_FORWARDED_HEADER_BUDGET_BYTES = 768;
@@ -105,6 +108,30 @@ function getForwardingPriority(headerName: string): number {
}
if (normalized === "retry-after") return 1;
if (normalized.includes("ratelimit") || normalized.includes("rate-limit")) return 2;
+ // Codex quota / reset / credits do not contain "ratelimit" in the name,
+ // so they used to fall through to priority 3 and lose to date/csp/cf-ray.
+ if (
+ normalized.startsWith("x-codex-") &&
+ (normalized.includes("used-percent") ||
+ normalized.includes("reset") ||
+ normalized.includes("window") ||
+ normalized.includes("credits") ||
+ normalized.includes("over-secondary") ||
+ normalized.includes("plan-type"))
+ ) {
+ return 2;
+ }
+ if (
+ normalized === "date" ||
+ normalized === "vary" ||
+ normalized === "x-robots-tag" ||
+ normalized === "content-security-policy" ||
+ normalized.startsWith("cf-") ||
+ normalized.endsWith("-organization-id") ||
+ normalized.endsWith("-workspace-id")
+ ) {
+ return 4;
+ }
return 3;
}
diff --git a/open-sse/handlers/usageExtractor.ts b/open-sse/handlers/usageExtractor.ts
index 313d1ff0e4..f424996ca4 100644
--- a/open-sse/handlers/usageExtractor.ts
+++ b/open-sse/handlers/usageExtractor.ts
@@ -104,6 +104,7 @@ export function extractUsageFromResponse(responseBody, provider) {
return {
prompt_tokens: usageMetadata.promptTokenCount || 0,
completion_tokens: (usageMetadata.candidatesTokenCount || 0) + thoughts,
+ cached_tokens: usageMetadata.cachedContentTokenCount || 0,
reasoning_tokens: thoughts,
};
}
diff --git a/open-sse/services/antigravityProjectBootstrap.ts b/open-sse/services/antigravityProjectBootstrap.ts
index 7d69216f1e..95d7aaf570 100644
--- a/open-sse/services/antigravityProjectBootstrap.ts
+++ b/open-sse/services/antigravityProjectBootstrap.ts
@@ -20,7 +20,10 @@ import {
} from "./antigravityHeaders.ts";
import { extractCodeAssistOnboardTierId } from "./codeAssistSubscription.ts";
import type { AntigravityClientProfile } from "./antigravityClientProfile.ts";
-import { ANTIGRAVITY_BOOTSTRAP_BASE_URLS, getAntigravityOnboardUrls } from "../config/antigravityUpstream.ts";
+import {
+ ANTIGRAVITY_BOOTSTRAP_BASE_URLS,
+ getAntigravityOnboardUrls,
+} from "../config/antigravityUpstream.ts";
const LOAD_CODE_ASSIST_PATH = "/v1internal:loadCodeAssist";
const BOOTSTRAP_TIMEOUT_MS = 8_000;
@@ -47,7 +50,39 @@ function evictOldest(cache: Map): void {
const projectCache = new Map();
/** Per-key lock to prevent concurrent onboard attempts for the same token. */
-const onboardLocks = new Map>();
+const onboardLocks = new Map>();
+
+/**
+ * Sentinel returned by ensureAntigravityProjectAssigned when Google's
+ * onboardUser completed but did NOT return a project id β no automatic
+ * project creation for standard-tier (personal) accounts (tracked in #8491),
+ * so Google requires a user-defined GCP project (BYOP). The
+ * caller must fail fast with a clear "enter your GCP project id" error
+ * instead of retrying (a fabricated id gets a delayed 429 RESOURCE_EXHAUSTED).
+ */
+export const ANTIGRAVITY_REQUIRES_MANUAL_PROJECT = "__REQUIRES_GCP_PROJECT__";
+
+/**
+ * Per-token cache of accounts Google told us to Bring Your Own Project.
+ * Permanent for the process lifetime (LRU-capped): re-running onboardUser
+ * for such an account is a pointless ~18s quota-check round-trip that
+ * always comes back empty. Cleared by clearAntigravityProjectCache(); a
+ * manually-entered project id (stored on the connection) short-circuits
+ * before this is consulted.
+ */
+const requiresManualProjectCache = new Set();
+
+function markRequiresManualProject(key: string): void {
+ if (requiresManualProjectCache.size >= MAX_CACHE_SIZE) {
+ const oldest = requiresManualProjectCache.values().next().value;
+ if (oldest !== undefined) requiresManualProjectCache.delete(oldest);
+ }
+ requiresManualProjectCache.add(key);
+}
+
+/** Outcome of an onboardUser attempt β three-way so the caller can distinguish
+ * "transient failure (retry later)" from "Google says bring your own project". */
+type AntigravityOnboardStatus = "onboarded" | "requires_manual_project" | "failed";
type FetchLike = (url: string, init?: RequestInit) => Promise;
@@ -138,7 +173,7 @@ async function tryOnboardUser(
clientProfile: AntigravityClientProfile,
tierId: string,
signal?: AbortSignal
-): Promise {
+): Promise {
const urls = getAntigravityOnboardUrls();
const headers = getAntigravityContentHeaders(clientProfile, accessToken);
@@ -157,7 +192,20 @@ async function tryOnboardUser(
});
if (response.ok) {
- return true;
+ // Accounts Google expects to Bring Their Own Project: onboardUser
+ // returns 200 without a `cloudaicompanionProject` in the body β no
+ // automatic project creation for standard-tier/personal accounts
+ // (tracked in #8491). Detect that so we can fail fast with a clear
+ // instruction instead of retrying forever or fabricating an id that
+ // Google later rejects with a delayed 429 RESOURCE_EXHAUSTED.
+ const body = await response.text().catch(() => "");
+ if (body && !/cloudaicompanionProject/.test(body)) {
+ console.warn(
+ `[models] antigravity onboardUser done but no project in response at ${url} β Google BYOP (user-defined GCP project) required`
+ );
+ return "requires_manual_project";
+ }
+ return "onboarded";
}
console.warn(
@@ -171,18 +219,40 @@ async function tryOnboardUser(
console.warn(`[models] antigravity onboardUser threw for ${url}: ${msg} β trying next`);
}
}
- return false;
+ return "failed";
}
-/** Per-token memoization for accounts we already tried onboarding (avoid repeated calls). */
-const onboardAttemptedCache = new Set();
+/**
+ * Per-token failure backoff for the onboardUser creation path.
+ *
+ * A FAILED onboard attempt must never be memoized as "done": a transient
+ * upstream/network error would otherwise poison the account for the whole
+ * process lifetime, so every later request 422s with "Missing Google
+ * projectId" even though onboarding would succeed on retry. Instead we record
+ * WHEN a failure happened and only skip re-attempts while the short backoff
+ * window is open β the account heals itself on the next request after it
+ * expires. Successful discoveries are memoized in `projectCache` (with LRU
+ * eviction) and clear any pending failure marker.
+ */
+const onboardFailureAt = new Map();
+const ONBOARD_RETRY_BACKOFF_MS = 5 * 60 * 1000;
-function addToOnboardAttemptedCache(key: string): void {
- if (onboardAttemptedCache.size >= MAX_CACHE_SIZE) {
- const oldest = onboardAttemptedCache.values().next().value;
- if (oldest !== undefined) onboardAttemptedCache.delete(oldest);
+function markOnboardFailure(key: string): void {
+ if (onboardFailureAt.size >= MAX_CACHE_SIZE) {
+ const oldest = onboardFailureAt.keys().next().value;
+ if (oldest !== undefined) onboardFailureAt.delete(oldest);
}
- onboardAttemptedCache.add(key);
+ onboardFailureAt.set(key, Date.now());
+}
+
+function isOnboardOnBackoff(key: string): boolean {
+ const failedAt = onboardFailureAt.get(key);
+ if (failedAt === undefined) return false;
+ if (Date.now() - failedAt >= ONBOARD_RETRY_BACKOFF_MS) {
+ onboardFailureAt.delete(key);
+ return false;
+ }
+ return true;
}
/**
@@ -212,49 +282,71 @@ export async function ensureAntigravityProjectAssigned(
}
const { projectId: initialProjectId, tierId } = await tryLoadCodeAssist(
- accessToken, fetchImpl, clientProfile, signal
+ accessToken,
+ fetchImpl,
+ clientProfile,
+ signal
);
let projectId = initialProjectId;
+ // Google told us this account must Bring Its Own Project β fail fast with
+ // the sentinel instead of repeating the pointless ~18s onboard round-trip.
+ if (!projectId && requiresManualProjectCache.has(cacheKey)) {
+ return ANTIGRAVITY_REQUIRES_MANUAL_PROJECT;
+ }
+
// loadCodeAssist is read-only β if the account was never onboarded, it returns
// empty. Call onboardUser to create the project, then retry discovery.
- if (!projectId && !onboardAttemptedCache.has(cacheKey)) {
+ // Re-attempts are bounded by a short failure backoff (not a permanent memo),
+ // so a transient onboard failure heals on the next request. Accounts Google
+ // marks BYOP are cached permanently and short-circuit above.
+ if (!projectId && !isOnboardOnBackoff(cacheKey)) {
// Per-key lock: concurrent calls for the same token share one onboard attempt.
let lock = onboardLocks.get(cacheKey);
if (!lock) {
lock = (async () => {
let aborted = false;
+ let succeeded = false;
+ let requiresManual = false;
try {
- const onboarded = await tryOnboardUser(
- accessToken, fetchImpl, clientProfile, tierId, signal
+ const status = await tryOnboardUser(
+ accessToken,
+ fetchImpl,
+ clientProfile,
+ tierId,
+ signal
);
- if (onboarded) {
- const retry = await tryLoadCodeAssist(
- accessToken, fetchImpl, clientProfile, signal
- );
+ if (status === "requires_manual_project") {
+ markRequiresManualProject(cacheKey);
+ requiresManual = true;
+ return;
+ }
+ if (status === "onboarded") {
+ const retry = await tryLoadCodeAssist(accessToken, fetchImpl, clientProfile, signal);
if (retry.projectId) {
evictOldest(projectCache);
projectCache.set(cacheKey, retry.projectId);
- return true;
+ succeeded = true;
+ return;
}
}
- return false;
} catch (e) {
aborted = signal?.aborted === true;
- return false;
+ return;
} finally {
onboardLocks.delete(cacheKey);
- if (!aborted) addToOnboardAttemptedCache(cacheKey);
+ if (!aborted && !requiresManual) {
+ if (succeeded) onboardFailureAt.delete(cacheKey);
+ else markOnboardFailure(cacheKey);
+ }
}
})();
onboardLocks.set(cacheKey, lock);
}
- const success = await lock;
- if (success) {
- const cached = projectCache.get(cacheKey);
- if (cached) return cached;
- }
+ await lock;
+ if (projectCache.has(cacheKey)) return projectCache.get(cacheKey);
+ if (requiresManualProjectCache.has(cacheKey)) return ANTIGRAVITY_REQUIRES_MANUAL_PROJECT;
}
if (projectId) {
@@ -268,10 +360,17 @@ export async function ensureAntigravityProjectAssigned(
/** Exported for tests. */
export function clearAntigravityProjectCache(): void {
projectCache.clear();
- onboardAttemptedCache.clear();
+ onboardFailureAt.clear();
+ requiresManualProjectCache.clear();
onboardLocks.clear();
}
+/** Test-only: clear the onboard failure backoff (simulates backoff expiry). */
+export function clearAntigravityOnboardBackoff(key?: string): void {
+ if (key) onboardFailureAt.delete(key);
+ else onboardFailureAt.clear();
+}
+
/** Exported for tests β inspect cache state. */
export function getAntigravityProjectFromCache(
accessToken: string,
diff --git a/open-sse/services/combo/pinRecovery.ts b/open-sse/services/combo/pinRecovery.ts
index 6531edc15a..d8cd728443 100644
--- a/open-sse/services/combo/pinRecovery.ts
+++ b/open-sse/services/combo/pinRecovery.ts
@@ -53,6 +53,12 @@ export function buildRecoveryHint(
next_step:
"Strict context requirements removed every target (known context windows are below minContextWindow). Lower minContextWindow, switch contextFilterMode to lenient, or add larger-context models.",
};
+ case "all_targets_skipped":
+ return {
+ action: "switch-combo",
+ next_step:
+ "Every target was skipped before dispatch (capability pre-filter narrowed the pool and the remaining targets were all quota-exhausted/unavailable). Check the provider's quota in /dashboard/providers, reconnect or top up the account, or switch to a combo/model that has a healthy capability-matching target.",
+ };
default:
return {
action: "retry",
diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts
index 7e5e86be1a..6b8b018a04 100755
--- a/open-sse/services/tokenRefresh.ts
+++ b/open-sse/services/tokenRefresh.ts
@@ -336,6 +336,7 @@ async function _getAccessTokenInternal(provider, credentials, log, proxyConfig:
if (
result?.accessToken &&
(provider === "antigravity" || provider === "agy") &&
+ !credentials.providerSpecificData?.isProjectIdManual &&
!(credentials.projectId || credentials.providerSpecificData?.projectId)
) {
try {
diff --git a/scripts/docker/patch-standalone-base-path.mjs b/scripts/docker/patch-standalone-base-path.mjs
index e0d2185245..27ef53ec7c 100644
--- a/scripts/docker/patch-standalone-base-path.mjs
+++ b/scripts/docker/patch-standalone-base-path.mjs
@@ -67,21 +67,76 @@ export function patchJsonManifestFile(filePath, basePath) {
}
const BASE_PATH_LITERAL_RE =
- /basePath\s*:\s*(?:""|''|`{2})|basePath\s*:\s*void 0|"basePath"\s*:\s*""/g;
+ /(?:basePath|assetPrefix)\s*:\s*(?:""|''|``)|(?:basePath|assetPrefix)\s*:\s*void 0|"(?:basePath|assetPrefix)"\s*:\s*""|"NEXT_PUBLIC_OMNIROUTE_BASE_PATH"\s*:\s*""|NEXT_PUBLIC_OMNIROUTE_BASE_PATH\s*:\s*""/g;
/**
+ * Rewrite the bare config literals Next bakes into the standalone output:
+ * - `basePath` (routing + server-rendered links) β the original scope;
+ * - `assetPrefix` (Next 16 app-router renders SSR asset URLs from
+ * `assetPrefix` ALONE β basePath only affects routing, so a subpath
+ * deploy must mirror it or every `/_next/static` shell reference 404s);
+ * - the `NEXT_PUBLIC_OMNIROUTE_BASE_PATH` env mirror in the inline
+ * nextConfig (server.js) so server-side env reads stay consistent.
+ *
* @param {string} content
* @param {string} basePath
*/
export function patchBasePathLiterals(content, basePath) {
const escaped = basePath.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
return content.replace(BASE_PATH_LITERAL_RE, (match) => {
- if (match.startsWith('"basePath"')) return `"basePath":"${escaped}"`;
- if (match.includes("void 0")) return `basePath:"${escaped}"`;
- return `basePath:"${escaped}"`;
+ if (match.startsWith('"NEXT_PUBLIC_OMNIROUTE_BASE_PATH"')) {
+ return `"NEXT_PUBLIC_OMNIROUTE_BASE_PATH":"${escaped}"`;
+ }
+ if (match.startsWith("NEXT_PUBLIC_OMNIROUTE_BASE_PATH")) {
+ return `NEXT_PUBLIC_OMNIROUTE_BASE_PATH:"${escaped}"`;
+ }
+ if (match.startsWith('"')) {
+ // `"basePath":""` / `"assetPrefix":""` (JSON-ish inline config)
+ const key = match.slice(1, match.indexOf('"', 1));
+ return `"${key}":"${escaped}"`;
+ }
+ // `basePath:""` / `basePath:void 0` / `assetPrefix:""` (minified code)
+ const key = match.slice(0, match.indexOf(":")).trim();
+ return `${key}:"${escaped}"`;
});
}
+/**
+ * Turbopack's client `process` shim ships an empty env object (`.env={}`).
+ * Next 16's client code reads NEXT_PUBLIC_* / OMNIROUTE_BASE_PATH from it at
+ * runtime, so without this the client never learns the subpath and the
+ * dashboard's fetch/EventSource rewriting (basePathFetch) silently stays on
+ * the root path. Populate the two keys the app reads.
+ *
+ * @param {string} content
+ * @param {string} basePath
+ */
+export function patchProcessEnvShim(content, basePath) {
+ const escaped = basePath.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
+ return content.replace(/\.env=\{\}/g, () => {
+ const keys = `OMNIROUTE_BASE_PATH:"${escaped}",NEXT_PUBLIC_OMNIROUTE_BASE_PATH:"${escaped}"`;
+ return `.env={${keys}}`;
+ });
+}
+
+/**
+ * Rewrite baked absolute asset URLs (`"/_next/static/..."`) to the subpath.
+ * Covers the client-reference-manifest chunk lists (they are serialized into
+ * the RSC flight payload verbatim) and the client/server chunk media imports
+ * β every `/ _next/static` reference must be prefixed because the standalone
+ * server only serves assets under basePath.
+ *
+ * @param {string} content
+ * @param {string} basePath
+ */
+export function patchBakedAssetUrls(content, basePath) {
+ const escaped = basePath.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
+ return content.replace(
+ /(["'`])\/_next\/static/g,
+ (_match, quote) => `${quote}${escaped}/_next/static`
+ );
+}
+
/**
* @param {string} rootDir
* @param {string} basePath
@@ -98,9 +153,12 @@ function walkAndPatchTextFiles(rootDir, basePath) {
stack.push(full);
continue;
}
- if (!/\.(?:js|json|cjs|mjs)$/.test(entry.name)) continue;
+ if (!/\.(?:js|json|cjs|mjs|html)$/.test(entry.name)) continue;
const before = fs.readFileSync(full, "utf8");
- const after = patchBasePathLiterals(before, basePath);
+ const after = [patchBasePathLiterals, patchProcessEnvShim, patchBakedAssetUrls].reduce(
+ (content, patch) => patch(content, basePath),
+ before
+ );
if (after !== before) {
fs.writeFileSync(full, after);
patchedFiles += 1;
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx
index a698ee628f..4c33d2882a 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx
+++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx
@@ -649,6 +649,12 @@ export default function EditConnectionModal({
clientProfile: normalizeAntigravityClientProfileSetting(
formData.antigravityClientProfile
),
+ // A manually-entered project id must not be overwritten by
+ // auto-discovery (loadCodeAssist) on later token refreshes. This
+ // merge is the single surviving write of providerSpecificData for
+ // antigravity (both OAuth and API-key branches rebuild the object
+ // above), so the flag has to land here to actually persist.
+ isProjectIdManual: !!trimmedCloudCodeProjectId,
};
}
if (updates.providerSpecificData) {
diff --git a/src/app/api/v1/chat/completions/route.ts b/src/app/api/v1/chat/completions/route.ts
index d181c4fd3d..a7e02842c6 100644
--- a/src/app/api/v1/chat/completions/route.ts
+++ b/src/app/api/v1/chat/completions/route.ts
@@ -26,7 +26,7 @@ import {
readCompressionRequestHeader,
withCompressionHeaderEcho,
} from "@/shared/utils/compressionHeaderEcho";
-import { resolveModelAliasOnBody } from "@/lib/modelAliasResolver";
+import { resolveModelAliasWithSeedFallbackOnBody } from "@/lib/modelAliasResolver";
let initPromise = null;
@@ -161,7 +161,7 @@ export async function POST(request) {
// Resolve model alias before forwarding to handleChat
if (parsedBody && typeof parsedBody === "object") {
- await resolveModelAliasOnBody(parsedBody).catch(() => {
+ await resolveModelAliasWithSeedFallbackOnBody(parsedBody).catch(() => {
/* swallow β fall through with original model */
});
}
diff --git a/src/lib/db/migrations/154_call_logs_response_id.sql b/src/lib/db/migrations/154_call_logs_response_id.sql
new file mode 100644
index 0000000000..c03f117037
--- /dev/null
+++ b/src/lib/db/migrations/154_call_logs_response_id.sql
@@ -0,0 +1,11 @@
+-- 154_call_logs_response_id.sql
+-- Index a completed OpenAI Responses API call by the response id it returned
+-- to the client (real upstream id, or OmniRoute's own synthesized `resp_`
+-- id β see normalizeResponsesId in open-sse/handlers/responseSanitizer.ts).
+-- Lets a later request's `previous_response_id` resolve back to this row's
+-- already-captured call-log artifact (full, untruncated request/response
+-- pipeline payloads) instead of duplicating conversation content into a
+-- second store. See src/lib/db/responsesContinuationStore.ts.
+
+ALTER TABLE call_logs ADD COLUMN response_id TEXT DEFAULT NULL;
+CREATE INDEX IF NOT EXISTS idx_cl_response_id ON call_logs(response_id);
diff --git a/src/lib/db/responsesContinuationStore.ts b/src/lib/db/responsesContinuationStore.ts
new file mode 100644
index 0000000000..a3b846fb95
--- /dev/null
+++ b/src/lib/db/responsesContinuationStore.ts
@@ -0,0 +1,75 @@
+/**
+ * responsesContinuationStore.ts β OmniRoute-native `previous_response_id`
+ * virtualization for the OpenAI Responses API.
+ *
+ * Exposes `previous_response_id` continuation to clients unconditionally,
+ * regardless of whether the actual upstream provider for a connection
+ * supports Responses-API state at all: OmniRoute resolves the response id
+ * back to the full input/output it produced and reconstructs the full
+ * request server-side before forwarding upstream (full history, exactly as
+ * today) -- the client only ever has to resend the new delta.
+ *
+ * Storage: reuses the existing call-log pipeline artifact (full, untruncated
+ * request/response payloads, already gated by `call_log_pipeline_enabled`
+ * and already retained/cleaned up by the existing call-log lifecycle)
+ * instead of duplicating conversation content into a second store. Only a
+ * lightweight `call_logs.response_id` index (154_call_logs_response_id.sql)
+ * is new. Every lookup is scoped by `api_key_id` -- one client can never
+ * resolve another client's stored conversation.
+ */
+
+import { getDbInstance } from "./core";
+import { readCallArtifact } from "../usage/callLogArtifacts";
+
+type ResponsesContinuationState = {
+ input: unknown[];
+ output: unknown[];
+};
+
+function isPlainRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+/**
+ * Resolve the full input + output a prior Responses API call produced, so
+ * the caller can reconstruct `full_input = stored.input + stored.output +
+ * new_delta`. Returns null on any lookup/read/shape failure (unknown id,
+ * wrong tenant, artifact missing, or an artifact whose pipeline payload was
+ * size-limit-omitted -- see MAX_CALL_LOG_ARTIFACT_BYTES in
+ * callLogArtifacts.ts) so the caller can fail closed and ask the client to
+ * resend full history, exactly like a real `previous_response_not_found`
+ * from OpenAI itself.
+ */
+export function resolvePreviousResponseState(
+ responseId: string,
+ apiKeyId: string | null | undefined
+): ResponsesContinuationState | null {
+ if (!responseId) return null;
+
+ const db = getDbInstance();
+ const row = db
+ .prepare(
+ `SELECT artifact_relpath, api_key_id FROM call_logs
+ WHERE response_id = ? AND detail_state = 'ready'
+ ORDER BY timestamp DESC LIMIT 1`
+ )
+ .get(responseId) as { artifact_relpath: string | null; api_key_id: string | null } | undefined;
+
+ if (!row || !row.artifact_relpath) return null;
+ // Tenant isolation: a response id is only ever handed back to the API key
+ // that created it. A stored row with no api_key_id at all (no-log/legacy)
+ // can never be resolved by any key -- fail closed rather than guess.
+ if (!apiKeyId || row.api_key_id !== apiKeyId) return null;
+
+ const { artifact, state } = readCallArtifact(row.artifact_relpath);
+ if (state !== "ready" || !artifact?.pipeline) return null;
+
+ const providerRequest = artifact.pipeline.providerRequest as { body?: unknown } | undefined;
+ const clientResponse = artifact.pipeline.clientResponse as { output?: unknown } | undefined;
+
+ const input = isPlainRecord(providerRequest?.body) ? providerRequest.body.input : undefined;
+ const output = clientResponse?.output;
+ if (!Array.isArray(input) || !Array.isArray(output)) return null;
+
+ return { input, output };
+}
diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts
index 70e7eb04c3..81c8ff9ae5 100755
--- a/src/lib/localDb.ts
+++ b/src/lib/localDb.ts
@@ -96,6 +96,7 @@ export * from "./db/compressionContextBudget";
export * from "./db/compressionRunTelemetry";
export * from "./db/jobRegistryDb";
export * from "./db/modelContextOverrides";
+export * from "./db/responsesContinuationStore";
export {
getApiKeys,
diff --git a/src/lib/modelAliasResolver.ts b/src/lib/modelAliasResolver.ts
index 8872019e15..7e0a079521 100644
--- a/src/lib/modelAliasResolver.ts
+++ b/src/lib/modelAliasResolver.ts
@@ -9,6 +9,7 @@
* `src/lib/modelAliasSeed.ts`.
*/
import { getModelAliases } from "@/lib/db/models/aliases";
+import { DEFAULT_MODEL_ALIAS_SEED } from "@/lib/modelAliasSeed";
let cachedAliases: Record | null = null;
let lastFetch = 0;
@@ -25,17 +26,22 @@ async function loadAliases(): Promise> {
}
/**
- * Resolve a model alias to its target provider model ID.
+ * Resolve a model alias to its target provider model ID, falling back to the
+ * static DEFAULT_MODEL_ALIAS_SEED when the alias is not in the database.
* If the alias maps to an array, returns the first element.
* If no alias is found, returns the original model name unchanged.
+ *
+ * Named distinctly from `resolveModelAlias` (modelDeprecation.ts /
+ * modelSpecs.ts, sync stringβstring) to avoid export collisions when both
+ * modules are imported together.
*/
-export async function resolveModelAlias(
+export async function resolveModelAliasWithSeedFallback(
model: string | null | undefined
): Promise {
if (!model) return model;
const aliases = await loadAliases();
- const target = aliases[model];
+ const target = aliases[model] ?? (DEFAULT_MODEL_ALIAS_SEED as Record)[model];
if (target === undefined) return model;
@@ -58,11 +64,11 @@ export async function resolveModelAlias(
* Resolve model alias on a parsed request body in-place.
* Mutates `body.model` if an alias is found.
*/
-export async function resolveModelAliasOnBody(
+export async function resolveModelAliasWithSeedFallbackOnBody(
body: Record | null | undefined
): Promise {
if (!body || typeof body !== "object") return;
- body.model = await resolveModelAlias(body.model as string | null | undefined);
+ body.model = await resolveModelAliasWithSeedFallback(body.model as string | null | undefined);
}
/**
diff --git a/src/lib/modelAliasSeed.ts b/src/lib/modelAliasSeed.ts
index ed4b0c770f..7edf62e9ae 100644
--- a/src/lib/modelAliasSeed.ts
+++ b/src/lib/modelAliasSeed.ts
@@ -3,6 +3,9 @@ import { deleteModelAlias, getModelAliases, setModelAlias } from "@/lib/db/model
export const DEFAULT_MODEL_ALIAS_SEED = Object.freeze({
"gemini-3.1-pro": "agy/gemini-pro-agent",
"gemini-3.1-flash-lite-preview": "gemini/gemini-3.1-flash-lite",
+ "claude-sonnet-4-6": "agy/claude-sonnet-4-6",
+ "claude-opus-4-6-thinking": "agy/claude-opus-4-6-thinking",
+ "gemini-3.6-flash-low": "agy/gemini-3.6-flash-low",
});
// Remove only aliases that still match a default value previously shipped by OmniRoute.
diff --git a/src/lib/proxyLogger.ts b/src/lib/proxyLogger.ts
index 327d1d9b4f..79bb4e5af4 100644
--- a/src/lib/proxyLogger.ts
+++ b/src/lib/proxyLogger.ts
@@ -105,6 +105,45 @@ function loadFromDb() {
loadFromDb();
+// Default-off override that restores the verbose [ProxyEgress] console line (raw
+// client/egress IPs + account prefix). Kept OFF by default so the process log leaks
+// neither IPs nor the account prefix. Deliberately NOT coupled to debugMode
+// (src/lib/db/settings.ts defaults debugMode to true) β this verbosity is opt-in only.
+// Storage (in-memory ring buffer + SQLite) is untouched and always keeps full IPs.
+const PROXY_LOG_INCLUDE_IPS =
+ process.env.PROXY_LOG_INCLUDE_IPS === "true" ||
+ process.env.PROXY_LOG_INCLUDE_IPS === "1";
+
+/**
+ * Pure formatter for the [ProxyEgress] process-log line (#10348). At the default level it
+ * emits a short, IP/prefix-free summary; when details are opted in it restores the full
+ * verbose line including client/egress IPs and the account. Extracted as a separate
+ * function so it is unit-testable without patching console.log and so the change never
+ * grows logProxyEvent itself.
+ */
+export function formatProxyEgressConsoleLine(params: {
+ provider: string | null;
+ account: string | null;
+ clientIp: string | null;
+ egressIp: string | null;
+ level: string;
+ proxyHost: string | null | undefined;
+ status: string;
+ includeDetails?: boolean;
+}): string {
+ const provider = params.provider || "-";
+ const status = params.status;
+ if (!params.includeDetails) {
+ return `[ProxyEgress] ${provider} status=${status}`;
+ }
+ const proxy = params.proxyHost ? `:${params.proxyHost}` : "";
+ return (
+ `[ProxyEgress] ${provider}/${params.account || "-"} ` +
+ `in=${params.clientIp || "?"} out=${params.egressIp || "?"} ` +
+ `proxy=${params.level}${proxy} status=${status}`
+ );
+}
+
// ββββββββββββββββ Log a proxy event ββββββββββββββββ
export function logProxyEvent(entry: ProxyLogInput) {
@@ -131,9 +170,16 @@ export function logProxyEvent(entry: ProxyLogInput) {
// IP each account is entering (clientIp) and leaving (egressIp) by.
if (log.proxy || log.egressIp) {
console.log(
- `[ProxyEgress] ${log.provider || "-"}/${log.account || "-"} ` +
- `in=${log.clientIp || "?"} out=${log.egressIp || "?"} ` +
- `proxy=${log.level}${log.proxy ? `:${log.proxy.host}` : ""} status=${log.status}`
+ formatProxyEgressConsoleLine({
+ provider: log.provider,
+ account: log.account,
+ clientIp: log.clientIp,
+ egressIp: log.egressIp,
+ level: log.level,
+ proxyHost: log.proxy?.host,
+ status: log.status,
+ includeDetails: PROXY_LOG_INCLUDE_IPS,
+ })
);
}
diff --git a/src/lib/tailscaleTunnel.ts b/src/lib/tailscaleTunnel.ts
index 54747daab8..e98e2f0fc6 100644
--- a/src/lib/tailscaleTunnel.ts
+++ b/src/lib/tailscaleTunnel.ts
@@ -15,9 +15,15 @@ const execFileAsync = promisify(execFile);
const WINDOWS_TAILSCALE_BIN = "C:\\Program Files\\Tailscale\\tailscale.exe";
const WINDOWS_TAILSCALED_BIN = "C:\\Program Files\\Tailscale\\tailscaled.exe";
-const IS_MAC = process.platform === "darwin";
-const IS_LINUX = process.platform === "linux";
-const IS_WINDOWS = process.platform === "win32";
+
+// Runtime platform getter. A bundler (Turbopack in `next build`) constant-folds
+// `process.platform` to the BUILD machine's value on a non-Windows runner and prunes
+// the other branches as dead code (#10293). `os.platform()` is a runtime call a
+// bundler cannot fold, so Windows/macOS/Linux branches survive on any build machine.
+function getCurrentPlatform(): NodeJS.Platform {
+ return os.platform();
+}
+
const EXTENDED_PATH = `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:${process.env.PATH || ""}`;
const LOGIN_TIMEOUT_MS = 15000;
const FUNNEL_TIMEOUT_MS = 30000;
@@ -35,12 +41,7 @@ type JsonRecord = Record;
export type TailscaleTunnelInstallSource = "managed" | "path" | "env" | "windows-default";
export type TailscaleTunnelPhase =
- | "unsupported"
- | "not_installed"
- | "needs_login"
- | "stopped"
- | "running"
- | "error";
+ "unsupported" | "not_installed" | "needs_login" | "stopped" | "running" | "error";
type PersistedTailscaleState = {
binaryPath?: string | null;
@@ -61,8 +62,7 @@ type BinaryResolution = {
type TailscaleLoginResult = { alreadyLoggedIn: true } | { authUrl: string };
type TailscaleFunnelResult =
- | { tunnelUrl: string }
- | { funnelNotEnabled: true; enableUrl: string | null };
+ { tunnelUrl: string } | { funnelNotEnabled: true; enableUrl: string | null };
export type TailscaleCheckStatus = {
supported: boolean;
@@ -124,7 +124,7 @@ function shellEscape(value: string) {
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
}
-function isSupportedPlatform(platform = process.platform) {
+function isSupportedPlatform(platform = os.platform()) {
return platform === "darwin" || platform === "linux" || platform === "win32";
}
@@ -132,7 +132,7 @@ function getTailscaleDir() {
return path.join(resolveDataDir(), "tailscale");
}
-function getManagedBinaryPath(platform = process.platform) {
+function getManagedBinaryPath(platform = os.platform()) {
return path.join(getTailscaleDir(), "bin", platform === "win32" ? "tailscale.exe" : "tailscale");
}
@@ -212,7 +212,7 @@ function getTailscaleApiUrl(tunnelUrl: string | null) {
}
async function resolvePathCommand(command: string) {
- const lookupCommand = process.platform === "win32" ? "where" : "which";
+ const lookupCommand = os.platform() === "win32" ? "where" : "which";
try {
const { stdout } = await execFileAsync(lookupCommand, [command], {
timeout: 3000,
@@ -248,7 +248,7 @@ async function resolveBinary(): Promise {
return { binaryPath: pathBinary, installSource: "path", managedInstall: false };
}
- if (IS_WINDOWS && fs.existsSync(WINDOWS_TAILSCALE_BIN)) {
+ if (getCurrentPlatform() === "win32" && fs.existsSync(WINDOWS_TAILSCALE_BIN)) {
return {
binaryPath: WINDOWS_TAILSCALE_BIN,
installSource: "windows-default",
@@ -263,7 +263,7 @@ async function resolveDaemonBinary(tailscaleBinaryPath: string | null) {
const envPath = toNonEmptyString(process.env.TAILSCALED_BIN);
if (envPath && fs.existsSync(envPath)) return envPath;
- const daemonFilename = process.platform === "win32" ? "tailscaled.exe" : "tailscaled";
+ const daemonFilename = os.platform() === "win32" ? "tailscaled.exe" : "tailscaled";
const siblingDir = tailscaleBinaryPath ? path.dirname(tailscaleBinaryPath) : null;
// path.format avoids the path.join/resolve pattern flagged by CWE-22 linters;
// siblingDir is path.dirname of a trusted system binary from resolveBinary(), not user input.
@@ -273,7 +273,8 @@ async function resolveDaemonBinary(tailscaleBinaryPath: string | null) {
const pathBinary = await resolvePathCommand("tailscaled");
if (pathBinary) return pathBinary;
- if (IS_WINDOWS && fs.existsSync(WINDOWS_TAILSCALED_BIN)) return WINDOWS_TAILSCALED_BIN;
+ if (getCurrentPlatform() === "win32" && fs.existsSync(WINDOWS_TAILSCALED_BIN))
+ return WINDOWS_TAILSCALED_BIN;
return null;
}
@@ -298,7 +299,9 @@ async function getActiveSocketPath(): Promise {
}
// Check system sockets first
- const systemSocket = IS_LINUX ? SYSTEM_SOCKET_LINUX : IS_MAC ? SYSTEM_SOCKET_MAC : null;
+ const platform = getCurrentPlatform();
+ const systemSocket =
+ platform === "linux" ? SYSTEM_SOCKET_LINUX : platform === "darwin" ? SYSTEM_SOCKET_MAC : null;
if (systemSocket && fs.existsSync(systemSocket)) {
_cachedActiveSocket = systemSocket;
_cachedActiveSocketTimestamp = now;
@@ -314,7 +317,9 @@ async function getActiveSocketPath(): Promise {
/** Synchronous check: is the system daemon socket available? */
function isSystemDaemonAvailable(): boolean {
- const systemSocket = IS_LINUX ? SYSTEM_SOCKET_LINUX : IS_MAC ? SYSTEM_SOCKET_MAC : null;
+ const platform = getCurrentPlatform();
+ const systemSocket =
+ platform === "linux" ? SYSTEM_SOCKET_LINUX : platform === "darwin" ? SYSTEM_SOCKET_MAC : null;
return Boolean(systemSocket && fs.existsSync(systemSocket));
}
@@ -341,19 +346,20 @@ export function tailscaleUpArgs(hostname?: string, authKey?: string): string[] {
}
async function buildTailscaleArgs(...args: string[]) {
- if (IS_WINDOWS) return args;
+ if (getCurrentPlatform() === "win32") return args;
const socket = await getActiveSocketPath();
return ["--socket", socket, ...args];
}
/** Synchronous variant for places that cannot await */
function buildTailscaleArgsSync(...args: string[]) {
- if (IS_WINDOWS) return args;
+ if (getCurrentPlatform() === "win32") return args;
// Use cached socket or default to system socket if available
+ const platform = getCurrentPlatform();
const socket =
_cachedActiveSocket ||
(isSystemDaemonAvailable()
- ? IS_LINUX
+ ? platform === "linux"
? SYSTEM_SOCKET_LINUX
: SYSTEM_SOCKET_MAC
: getTailscaleSocketPath());
@@ -443,7 +449,7 @@ function getLastError(state: PersistedTailscaleState) {
}
async function hasBrew() {
- if (!IS_MAC) return false;
+ if (getCurrentPlatform() !== "darwin") return false;
try {
await execFileAsync("which", ["brew"], {
timeout: 3000,
@@ -487,7 +493,7 @@ export async function getTailscaleCheckStatus(): Promise {
running: isFunnelRunning(funnelPayload),
tunnelUrl,
apiUrl: getTailscaleApiUrl(tunnelUrl),
- platform: process.platform,
+ platform: os.platform(),
brewAvailable,
lastError: getLastError(state),
pid: await readPidFile(),
@@ -561,7 +567,7 @@ export async function startTailscaleDaemon({
return { started: false };
}
- if (IS_WINDOWS) {
+ if (getCurrentPlatform() === "win32") {
try {
await execFileAsync("net", ["start", "Tailscale"], {
timeout: 10000,
@@ -816,7 +822,7 @@ export async function stopTailscaleDaemon({
}
}
- if (!IS_WINDOWS) {
+ if (getCurrentPlatform() !== "win32") {
try {
await execFileAsync("pkill", ["-x", "tailscaled"], {
timeout: 3000,
@@ -1155,7 +1161,7 @@ export async function installTailscale({
onProgress?: (message: string) => void;
} = {}) {
if (!isSupportedPlatform()) {
- throw new Error(`Unsupported platform for Tailscale install: ${process.platform}`);
+ throw new Error(`Unsupported platform for Tailscale install: ${os.platform()}`);
}
const password = toNonEmptyString(sudoPassword) || getCachedPassword() || "";
@@ -1167,13 +1173,13 @@ export async function installTailscale({
const existingBinary = await resolveBinary();
if (existingBinary.binaryPath) {
onProgress?.("Tailscale is already installed.");
- } else if (IS_WINDOWS) {
+ } else if (getCurrentPlatform() === "win32") {
onProgress?.("Downloading and installing Tailscale for Windows...");
await installTailscaleWindows(onProgress);
- } else if (IS_MAC) {
+ } else if (getCurrentPlatform() === "darwin") {
onProgress?.("Installing Tailscale on macOS...");
await installTailscaleMac(password, onProgress);
- } else if (IS_LINUX) {
+ } else if (getCurrentPlatform() === "linux") {
onProgress?.("Installing Tailscale on Linux...");
await installTailscaleLinux(password, onProgress);
}
diff --git a/src/lib/usage/callLogs.ts b/src/lib/usage/callLogs.ts
index 77f1a60a76..7f2ffbc820 100644
--- a/src/lib/usage/callLogs.ts
+++ b/src/lib/usage/callLogs.ts
@@ -496,6 +496,11 @@ export async function saveCallLog(entry: any) {
correlationId: entry.correlationId || null,
modelPinned: entry.modelPinned ? 1 : 0,
sessionTag: entry.sessionTag || null,
+ // OpenAI Responses API response id, when this attempt produced one --
+ // indexed so a later request's `previous_response_id` can resolve
+ // this row's artifact for OmniRoute-native continuation. See
+ // src/lib/db/responsesContinuationStore.ts.
+ responseId: typeof entry.responseId === "string" ? entry.responseId : null,
};
const requestSummary = noLogEnabled
@@ -544,7 +549,7 @@ export async function saveCallLog(entry: any) {
combo_name, combo_step_id, combo_execution_key, error_summary, detail_state,
artifact_relpath, artifact_size_bytes, artifact_sha256,
has_request_body, has_response_body, has_pipeline_details, request_summary,
- correlation_id, model_pinned, session_tag
+ correlation_id, model_pinned, session_tag, response_id
)
VALUES (
@id, @timestamp, @method, @path, @status, @model, @requestedModel, @provider,
@@ -555,7 +560,7 @@ export async function saveCallLog(entry: any) {
@comboName, @comboStepId, @comboExecutionKey, @errorSummary, @detailState,
@artifactRelPath, @artifactSizeBytes, @artifactSha256,
@hasRequestBody, @hasResponseBody, @hasPipelineDetails, @requestSummary,
- @correlationId, @modelPinned, @sessionTag
+ @correlationId, @modelPinned, @sessionTag, @responseId
)
`
).run({
diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts
index 2e0d426722..6362433cd8 100644
--- a/src/sse/handlers/chat.ts
+++ b/src/sse/handlers/chat.ts
@@ -4,6 +4,10 @@ import * as chatAdmission from "./chatAdmission.ts";
import { buildClientRawRequest, resolveDispatchClientRawRequest } from "./chat/clientRawRequest.ts";
export { buildClientRawRequest, resolveDispatchClientRawRequest };
import { normalizeReasoningRequest } from "@/shared/reasoning/effortStandardization";
+import { isDetailedLoggingEnabled } from "@/lib/db/detailedLogs";
+import { resolvePreviousResponseState } from "@/lib/db/responsesContinuationStore";
+import { normalizeResponsesPreviousResponseIdMode } from "@omniroute/open-sse/utils/responsesStatePolicy.ts";
+import { FORMATS } from "@omniroute/open-sse/translator/formats.ts";
import { resolveRoutingModel, RoutingModelOps } from "./resolveRoutingModel";
import {
getProviderCredentialsWithQuotaPreflight,
@@ -517,6 +521,66 @@ async function handleChatImplementation(
const bypassProviderQuotaPolicy = hasProviderQuotaBypassScope(apiKeyInfo?.scopes);
telemetry.endPhase();
+ // OmniRoute-native `previous_response_id` continuation: reconstruct the
+ // full input server-side before ANY downstream validation/translation
+ // sees this request, so everything after this point (message-shape
+ // guards, token-budget checks, provider translation) treats it exactly
+ // like an ordinary full-history request. This works regardless of
+ // whether the eventually-selected upstream provider itself understands
+ // Responses-API state -- OmniRoute always forwards the full reconstructed
+ // history upstream, exactly as it does today for a non-continued request.
+ // Client<->OmniRoute traffic shrinks to the new delta; OmniRoute<->
+ // provider traffic is unchanged. See src/lib/db/responsesContinuationStore.ts.
+ //
+ // Skipped entirely when the operator has set responsesPreviousResponseIdMode
+ // to "preserve": that mode is the explicit, connection-independent contract
+ // for "never touch previous_response_id, let the upstream resolve it
+ // natively" (see applyResponsesPreviousResponseIdPolicy in chatCore.ts,
+ // which enforces it per-target once a connection is selected). Codex's own
+ // executor relies on an untouched previous_response_id to delegate history
+ // resolution upstream (stripOrphanedCodexFunctionCallOutputs in codex.ts);
+ // reconstructing and deleting the field here would make that downstream
+ // "preserve" enforcement a no-op since the field would already be gone.
+ const settingsForContinuation = await getCachedSettings().catch(
+ () => ({}) as Record
+ );
+ const previousResponseIdMode = normalizeResponsesPreviousResponseIdMode(
+ (settingsForContinuation as { responsesPreviousResponseIdMode?: unknown })
+ .responsesPreviousResponseIdMode
+ );
+ if (
+ previousResponseIdMode !== "preserve" &&
+ sourceFormat === FORMATS.OPENAI_RESPONSES &&
+ typeof (body as { previous_response_id?: unknown }).previous_response_id === "string"
+ ) {
+ const previousResponseId = (body as { previous_response_id: string }).previous_response_id;
+ const detailedLoggingEnabled = await isDetailedLoggingEnabled();
+ const stored = detailedLoggingEnabled
+ ? resolvePreviousResponseState(previousResponseId, apiKeyInfo?.id ?? null)
+ : null;
+ if (!stored) {
+ // Matches OpenAI's own `previous_response_not_found` contract (missing
+ // or expired server-side state) so a client with the matching retry
+ // behavior -- resend the full request, same turn -- recovers exactly
+ // as it would against the real OpenAI backend.
+ return new Response(
+ JSON.stringify({
+ error: {
+ message: "Previous response not found.",
+ type: "invalid_request_error",
+ code: "previous_response_not_found",
+ },
+ }),
+ { status: 400, headers: { "Content-Type": "application/json" } }
+ );
+ }
+ const deltaInput = Array.isArray((body as { input?: unknown }).input)
+ ? (body as { input: unknown[] }).input
+ : [];
+ body = { ...body, input: [...stored.input, ...stored.output, ...deltaInput] };
+ delete (body as { previous_response_id?: unknown }).previous_response_id;
+ }
+
const admissionRejection = await admissionContext.acquire(apiKeyInfo?.id, request, body);
if (admissionRejection) return admissionRejection;
clientRawRequest = chatAdmission.resolveClientRawAfterAdmission(clientRawRequest, () =>
diff --git a/tests/unit/9303-recovery-hint-all-targets-skipped.test.ts b/tests/unit/9303-recovery-hint-all-targets-skipped.test.ts
new file mode 100644
index 0000000000..7b7319eab1
--- /dev/null
+++ b/tests/unit/9303-recovery-hint-all-targets-skipped.test.ts
@@ -0,0 +1,31 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+const { buildRecoveryHint } = await import("../../open-sse/services/combo/pinRecovery.ts");
+
+test(
+ "#9303: buildRecoveryHint('all_targets_skipped') must return an actionable " +
+ "hint, not the generic 'transient, just retry' default",
+ () => {
+ const hint = buildRecoveryHint("all_targets_skipped");
+
+ assert.notEqual(
+ hint.action,
+ "retry",
+ "the pre-dispatch full-exhaustion terminal reason must not be classified as a " +
+ "generically 'retry'-able transient failure β the reporter's log shows the " +
+ "identical exhaustion recurring across ~9 consecutive requests with no recovery"
+ );
+ assert.doesNotMatch(
+ hint.next_step,
+ /failed transiently/i,
+ "must not tell the client this was transient when the whole target pool was " +
+ "pre-filtered/quota-exhausted before a single dispatch attempt was made"
+ );
+ assert.match(
+ hint.next_step,
+ /quota|availability|provider/s,
+ "the hint must point at the provider quota/availability as the actionable next step"
+ );
+ }
+);
\ No newline at end of file
diff --git a/tests/unit/antigravity-discovery-bootstrap.test.ts b/tests/unit/antigravity-discovery-bootstrap.test.ts
index d2f5617dfe..43914cf571 100644
--- a/tests/unit/antigravity-discovery-bootstrap.test.ts
+++ b/tests/unit/antigravity-discovery-bootstrap.test.ts
@@ -21,8 +21,10 @@ import assert from "node:assert/strict";
import {
ensureAntigravityProjectAssigned,
clearAntigravityProjectCache,
+ clearAntigravityOnboardBackoff,
getAntigravityProjectFromCache,
getAntigravityLoadCodeAssistUrls,
+ ANTIGRAVITY_REQUIRES_MANUAL_PROJECT,
} from "../../open-sse/services/antigravityProjectBootstrap.ts";
// Reset the module-level memoization cache between tests.
@@ -261,10 +263,15 @@ describe("onboardUser fallback", () => {
}
if (url.endsWith(":onboardUser")) {
onboardCalls++;
- return new Response(JSON.stringify({ done: true }), {
- status: 200,
- headers: { "Content-Type": "application/json" },
- });
+ // Google's LRO returns the created project inside the response β a body
+ // WITHOUT cloudaicompanionProject means BYOP (manual project required).
+ return new Response(
+ JSON.stringify({ done: true, cloudaicompanionProject: "proj-onboarded" }),
+ {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }
+ );
}
return new Response("Not Found", { status: 404 });
};
@@ -294,7 +301,7 @@ describe("onboardUser fallback", () => {
assert.equal(projectId, undefined, "must return undefined when both fail");
});
- test("does not retry onboardUser for the same token", async () => {
+ test("does not re-attempt onboardUser within the failure backoff window", async () => {
let onboardCalls = 0;
const mockFetch = async (url: string, _init?: RequestInit): Promise => {
@@ -306,8 +313,10 @@ describe("onboardUser fallback", () => {
}
if (url.endsWith(":onboardUser")) {
onboardCalls++;
- return new Response(JSON.stringify({ done: true }), {
- status: 200,
+ // Transient upstream failure (500) β NOT the BYOP signal, so the
+ // failure-backoff semantics are what is under test here.
+ return new Response("Upstream error", {
+ status: 500,
headers: { "Content-Type": "application/json" },
});
}
@@ -317,7 +326,94 @@ describe("onboardUser fallback", () => {
await ensureAntigravityProjectAssigned("dedup-token", mockFetch);
await ensureAntigravityProjectAssigned("dedup-token", mockFetch);
- assert.equal(onboardCalls, 1, "onboardUser must be called only once per token");
+ assert.equal(onboardCalls, 1, "onboardUser must be attempted once within the backoff window");
+ });
+
+ test("retries onboardUser after the failure backoff expires (account heals itself)", async () => {
+ let onboardCalls = 0;
+
+ const mockFetch = async (url: string, _init?: RequestInit): Promise => {
+ if (url.endsWith(":loadCodeAssist")) {
+ // Only the retry AFTER the second (healed) onboard attempt yields a project.
+ if (onboardCalls >= 2) {
+ return new Response(JSON.stringify({ cloudaicompanionProject: "proj-healed" }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ return new Response(JSON.stringify({}), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ if (url.endsWith(":onboardUser")) {
+ onboardCalls++;
+ if (onboardCalls === 1) {
+ // First attempt: transient upstream failure -> failure backoff.
+ return new Response("Upstream error", {
+ status: 500,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ // Second (healed) attempt: Google returns the created project.
+ return new Response(
+ JSON.stringify({ done: true, cloudaicompanionProject: "proj-healed-onboard" }),
+ {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }
+ );
+ }
+ return new Response("Not Found", { status: 404 });
+ };
+
+ // First attempt: onboard fails transiently -> failure recorded.
+ const first = await ensureAntigravityProjectAssigned("heal-token", mockFetch);
+ assert.equal(first, undefined);
+ assert.equal(onboardCalls, 1);
+
+ // Immediately after: backoff blocks a re-attempt.
+ const second = await ensureAntigravityProjectAssigned("heal-token", mockFetch);
+ assert.equal(second, undefined);
+ assert.equal(onboardCalls, 1, "no re-attempt inside the backoff window");
+
+ // Simulate the backoff expiring: the next request heals the account.
+ clearAntigravityOnboardBackoff();
+ const healed = await ensureAntigravityProjectAssigned("heal-token", mockFetch);
+ assert.equal(healed, "proj-healed");
+ assert.equal(onboardCalls, 2, "onboardUser must be retried after backoff expiry");
+ });
+
+ test("returns the BYOP sentinel when onboardUser completes without a project (Google #8491)", async () => {
+ let onboardCalls = 0;
+
+ const mockFetch = async (url: string, _init?: RequestInit): Promise => {
+ if (url.endsWith(":loadCodeAssist")) {
+ return new Response(JSON.stringify({}), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ if (url.endsWith(":onboardUser")) {
+ onboardCalls++;
+ // 200 done WITHOUT cloudaicompanionProject = BYOP: Google deprecated
+ // automatic project creation for standard-tier personal accounts.
+ return new Response(JSON.stringify({ done: true }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ return new Response("Not Found", { status: 404 });
+ };
+
+ const first = await ensureAntigravityProjectAssigned("byop-token", mockFetch);
+ assert.equal(first, ANTIGRAVITY_REQUIRES_MANUAL_PROJECT);
+
+ // The account is cached as BYOP β a second call must NOT re-run the
+ // pointless ~18s onboard round-trip (no extra fetch, same sentinel).
+ const second = await ensureAntigravityProjectAssigned("byop-token", mockFetch);
+ assert.equal(second, ANTIGRAVITY_REQUIRES_MANUAL_PROJECT);
+ assert.equal(onboardCalls, 1, "onboardUser must not be re-attempted for a cached BYOP account");
});
test("skips onboardUser when loadCodeAssist succeeds on first try", async () => {
diff --git a/tests/unit/antigravity-missing-project-chat.test.ts b/tests/unit/antigravity-missing-project-chat.test.ts
index 341846cd3e..5238427a65 100644
--- a/tests/unit/antigravity-missing-project-chat.test.ts
+++ b/tests/unit/antigravity-missing-project-chat.test.ts
@@ -84,3 +84,71 @@ test("Antigravity missing-project 422 stays fail-closed without account cooldown
assert.equal(persisted?.lastErrorType, "oauth_missing_project_id");
assert.match(String(persisted?.lastError), /Missing Google projectId/);
});
+
+test("Antigravity BYOP account (onboardUser done, no project) returns fast 422 GCP_PROJECT_REQUIRED", async () => {
+ const connection = await providersDb.createProviderConnection({
+ provider: "antigravity",
+ authType: "oauth",
+ name: "antigravity-byop",
+ email: "antigravity-byop@example.test",
+ accessToken: "fake-antigravity-byop-token",
+ refreshToken: "fake-antigravity-byop-refresh",
+ expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
+ providerSpecificData: {},
+ isActive: true,
+ testStatus: "active",
+ });
+ assert(connection && typeof connection.id === "string");
+
+ let onboardCalls = 0;
+ globalThis.fetch = async (input, init) => {
+ const request = input instanceof Request ? input : new Request(input, init);
+ if (request.url.startsWith("https://oauth2.googleapis.com/token")) {
+ // Token refresh during the attempt β answer it so the test focuses on BYOP.
+ return new Response(
+ JSON.stringify({ access_token: "fake-antigravity-byop-token", expires_in: 3600 }),
+ {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }
+ );
+ }
+ if (request.url.endsWith(":loadCodeAssist")) {
+ // Empty loadCodeAssist β account never onboarded.
+ return new Response("{}", {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ if (request.url.endsWith(":onboardUser")) {
+ onboardCalls += 1;
+ // 200 done WITHOUT cloudaicompanionProject β Google BYOP (#8491):
+ // no automatic project creation for standard-tier accounts.
+ return new Response(JSON.stringify({ done: true }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ throw new Error(`Unexpected external fetch: ${request.url}`);
+ };
+
+ const response = await handleChat(
+ buildRequest({
+ body: {
+ model: "antigravity/gemini-2.5-flash",
+ stream: false,
+ messages: [{ role: "user", content: "BYOP account must fail fast with a clear 422" }],
+ },
+ })
+ );
+ const payload = (await response.json()) as {
+ error?: { code?: string; type?: string; message?: string };
+ };
+
+ assert.equal(response.status, 422);
+ // 422 is outside chatCore's 401/403 refresh-retry set, so the executor's
+ // error body passes through untouched β the actionable message must survive.
+ assert.match(String(payload.error?.message), /GCP_PROJECT_REQUIRED/);
+ assert.match(String(payload.error?.message), /console\.cloud\.google\.com/);
+ assert.equal(onboardCalls, 1, "onboardUser must be attempted exactly once (BYOP is cached)");
+});
diff --git a/tests/unit/chat-previous-response-id-preserve-mode.test.ts b/tests/unit/chat-previous-response-id-preserve-mode.test.ts
new file mode 100644
index 0000000000..2ae96b62fe
--- /dev/null
+++ b/tests/unit/chat-previous-response-id-preserve-mode.test.ts
@@ -0,0 +1,63 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import { createChatPipelineHarness } from "../integration/_chatPipelineHarness.ts";
+
+// Regression guard: the OmniRoute-native previous_response_id virtualization
+// in chat.ts (see src/lib/db/responsesContinuationStore.ts) used to run
+// unconditionally for every OpenAI-Responses-source request, before target
+// selection and before applyResponsesPreviousResponseIdPolicy (chatCore.ts)
+// ever got a chance to enforce responsesPreviousResponseIdMode. That made
+// mode="preserve" -- the explicit, connection-independent contract for "let
+// the upstream resolve previous_response_id natively" -- a no-op: the field
+// was already deleted and replaced with a reconstructed `input` before the
+// policy ever ran, hard-rejecting any previous_response_id that OmniRoute's
+// own call-log store never captured, instead of forwarding it upstream like
+// a real Codex/ChatGPT-store-enabled connection expects.
+
+const harness = await createChatPipelineHarness("chat-prev-resp-id-preserve");
+const { buildRequest, handleChat, resetStorage, settingsDb } = harness;
+
+test.beforeEach(async () => {
+ await resetStorage();
+});
+
+test.after(async () => {
+ await harness.cleanup();
+});
+
+async function postResponses(previousResponseId: string) {
+ const response = await handleChat(
+ buildRequest({
+ url: "http://localhost/v1/responses",
+ body: {
+ model: "nonexistent-provider/nonexistent-model",
+ stream: false,
+ previous_response_id: previousResponseId,
+ input: [{ type: "message", role: "user", content: "continue" }],
+ },
+ })
+ );
+ const payload = (await response.json()) as { error?: { code?: string; message?: string } };
+ return { status: response.status, payload };
+}
+
+test("mode=auto (default): unknown previous_response_id is virtualized and fails closed with previous_response_not_found", async () => {
+ const { status, payload } = await postResponses("resp_never_seen_by_omniroute");
+ assert.equal(status, 400);
+ assert.equal(payload.error?.code, "previous_response_not_found");
+});
+
+test("mode=preserve: previous_response_id is left untouched, request proceeds to normal routing instead of local virtualization", async () => {
+ await settingsDb.updateSettings({ responsesPreviousResponseIdMode: "preserve" });
+
+ const { status, payload } = await postResponses("resp_never_seen_by_omniroute");
+
+ // Virtualization is skipped entirely: the id is not looked up against
+ // OmniRoute's own store, so this must NOT be the virtualization's
+ // previous_response_not_found rejection. It falls through to ordinary
+ // model routing, which 404s because the test model doesn't exist --
+ // exactly like a request with no previous_response_id at all would.
+ assert.notEqual(payload.error?.code, "previous_response_not_found");
+ assert.equal(status, 404);
+});
diff --git a/tests/unit/dashboard/edit-connection-modal-antigravity-project-manual.test.tsx b/tests/unit/dashboard/edit-connection-modal-antigravity-project-manual.test.tsx
new file mode 100644
index 0000000000..e6f1ae3fe6
--- /dev/null
+++ b/tests/unit/dashboard/edit-connection-modal-antigravity-project-manual.test.tsx
@@ -0,0 +1,132 @@
+// @vitest-environment jsdom
+//
+// Regression guard for the review on #10424: EditConnectionModal set
+// providerSpecificData.isProjectIdManual right after the project-id field,
+// but the OAuth connection path (Antigravity is always OAuth) rebuilt
+// providerSpecificData from connection.providerSpecificData before the save
+// request went out, discarding the flag. tokenRefresh.ts guards auto-discovery
+// with `!credentials.providerSpecificData?.isProjectIdManual`, so without this
+// fix a manually-entered GCP Project ID was silently overwritten on the next
+// token refresh.
+import React, { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+vi.mock("next-intl", () => ({
+ useTranslations: () => (key: string) => key,
+}));
+
+vi.mock("@/store/notificationStore", () => ({
+ useNotificationStore: () => ({ notify: vi.fn() }),
+}));
+
+vi.mock("@/store/emailPrivacyStore", () => ({
+ default: () => ({ hidden: false, toggle: vi.fn() }),
+}));
+
+const { default: EditConnectionModal } =
+ await import("../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx");
+
+let container: HTMLDivElement;
+let root: Root;
+
+beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ vi.clearAllMocks();
+});
+
+function renderModal(connection: Record, onSave = vi.fn()) {
+ act(() => {
+ root.render(
+
+ );
+ });
+}
+
+function findProjectIdInput(): HTMLInputElement | null {
+ return container.querySelector('input[placeholder="antigravityProjectIdPlaceholder"]');
+}
+
+function clickSave() {
+ const button = Array.from(container.querySelectorAll("button")).find(
+ (b) => b.textContent === "save"
+ );
+ expect(button).toBeTruthy();
+ button!.click();
+}
+
+describe("EditConnectionModal β antigravity isProjectIdManual persistence (#10424 review)", () => {
+ it("persists isProjectIdManual=true on save when a GCP Project ID is entered manually", async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ renderModal(
+ {
+ id: "conn-ag-1",
+ provider: "antigravity",
+ authType: "oauth",
+ name: "Antigravity account",
+ providerSpecificData: {},
+ },
+ onSave
+ );
+
+ const input = findProjectIdInput();
+ expect(input).not.toBeNull();
+ // React controlled input: use the native setter so the value change is
+ // seen by the onChange handler, then dispatch an input event.
+ const setter = Object.getOwnPropertyDescriptor(
+ window.HTMLInputElement.prototype,
+ "value"
+ )!.set!;
+ await act(async () => {
+ setter.call(input, "gcp-proj-10424");
+ input!.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+
+ await act(async () => {
+ clickSave();
+ });
+
+ expect(onSave).toHaveBeenCalledTimes(1);
+ const updates = onSave.mock.calls[0][0] as {
+ providerSpecificData: Record;
+ };
+ expect(updates.providerSpecificData?.isProjectIdManual).toBe(true);
+ });
+
+ it("persists isProjectIdManual=false when the project id field is left empty", async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ renderModal(
+ {
+ id: "conn-ag-2",
+ provider: "antigravity",
+ authType: "oauth",
+ name: "Antigravity account 2",
+ providerSpecificData: {},
+ },
+ onSave
+ );
+
+ await act(async () => {
+ clickSave();
+ });
+
+ expect(onSave).toHaveBeenCalledTimes(1);
+ const updates = onSave.mock.calls[0][0] as {
+ providerSpecificData: Record;
+ };
+ expect(updates.providerSpecificData?.isProjectIdManual).toBe(false);
+ });
+});
diff --git a/tests/unit/docker-base-path-patch.test.ts b/tests/unit/docker-base-path-patch.test.ts
index f62ce84ea4..a71a922bd6 100644
--- a/tests/unit/docker-base-path-patch.test.ts
+++ b/tests/unit/docker-base-path-patch.test.ts
@@ -7,6 +7,8 @@ import {
patchBasePathLiterals,
patchJsonManifestFile,
patchStandaloneBasePath,
+ patchProcessEnvShim,
+ patchBakedAssetUrls,
} from "../../scripts/docker/patch-standalone-base-path.mjs";
test("patchBasePathLiterals rewrites empty basePath literals", () => {
@@ -19,10 +21,7 @@ test("patchBasePathLiterals rewrites empty basePath literals", () => {
test("patchJsonManifestFile updates nested basePath fields", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-basepath-"));
const filePath = path.join(dir, "routes-manifest.json");
- fs.writeFileSync(
- filePath,
- JSON.stringify({ basePath: "", nested: { basePath: "" } }, null, 2)
- );
+ fs.writeFileSync(filePath, JSON.stringify({ basePath: "", nested: { basePath: "" } }, null, 2));
assert.equal(patchJsonManifestFile(filePath, "/omniroute"), true);
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
assert.equal(parsed.basePath, "/omniroute");
@@ -33,14 +32,8 @@ test("patchStandaloneBasePath rewrites a root-path standalone tree", () => {
const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-standalone-"));
const distRoot = path.join(appRoot, ".build", "next");
fs.mkdirSync(path.join(distRoot, "server"), { recursive: true });
- fs.writeFileSync(
- path.join(distRoot, "routes-manifest.json"),
- JSON.stringify({ basePath: "" })
- );
- fs.writeFileSync(
- path.join(distRoot, "server", "chunk.js"),
- 'export const config={basePath:""};'
- );
+ fs.writeFileSync(path.join(distRoot, "routes-manifest.json"), JSON.stringify({ basePath: "" }));
+ fs.writeFileSync(path.join(distRoot, "server", "chunk.js"), 'export const config={basePath:""};');
fs.writeFileSync(path.join(appRoot, "BUILD_OMNIROUTE_BASE_PATH"), "\n");
const result = patchStandaloneBasePath({
@@ -68,3 +61,56 @@ test("patchStandaloneBasePath rejects mismatched non-root builds", () => {
/does not match the image build/
);
});
+
+test("patchBasePathLiterals rewrites assetPrefix literals (Next 16 SSR asset URLs)", () => {
+ // Next 16 app-router renders SSR asset URLs from assetPrefix ALONE.
+ assert.equal(
+ patchBasePathLiterals('{"assetPrefix":""}', "/omniroute"),
+ '{"assetPrefix":"/omniroute"}'
+ );
+ assert.equal(patchBasePathLiterals('assetPrefix:""', "/omniroute"), 'assetPrefix:"/omniroute"');
+ assert.equal(
+ patchBasePathLiterals("assetPrefix:void 0", "/omniroute"),
+ 'assetPrefix:"/omniroute"'
+ );
+ // Asset prefix must mirror the basePath so both routing and assets align.
+ const mixed = patchBasePathLiterals('{"basePath":"","assetPrefix":""}', "/omniroute");
+ assert.match(mixed, /"basePath":"\/omniroute"/);
+ assert.match(mixed, /"assetPrefix":"\/omniroute"/);
+});
+
+test("patchBasePathLiterals rewrites the NEXT_PUBLIC env mirror", () => {
+ assert.equal(
+ patchBasePathLiterals('{"env":{"NEXT_PUBLIC_OMNIROUTE_BASE_PATH":""}}', "/omniroute"),
+ '{"env":{"NEXT_PUBLIC_OMNIROUTE_BASE_PATH":"/omniroute"}}'
+ );
+ assert.equal(
+ patchBasePathLiterals('NEXT_PUBLIC_OMNIROUTE_BASE_PATH:""', "/omniroute"),
+ 'NEXT_PUBLIC_OMNIROUTE_BASE_PATH:"/omniroute"'
+ );
+});
+
+test("patchProcessEnvShim populates the Turbopack client process env", () => {
+ assert.equal(
+ patchProcessEnvShim("o.env={},o.argv=[]", "/omniroute"),
+ 'o.env={OMNIROUTE_BASE_PATH:"/omniroute",NEXT_PUBLIC_OMNIROUTE_BASE_PATH:"/omniroute"},o.argv=[]'
+ );
+ // Non-empty env objects are left untouched (never clobber baked values).
+ assert.equal(patchProcessEnvShim("o.env={A:1}", "/omniroute"), "o.env={A:1}");
+});
+
+test("patchBakedAssetUrls prefixes absolute _next/static URLs", () => {
+ assert.equal(
+ patchBakedAssetUrls('"/_next/static/chunks/a.js"', "/omniroute"),
+ '"/omniroute/_next/static/chunks/a.js"'
+ );
+ assert.equal(
+ patchBakedAssetUrls("'/_next/static/media/m.png'", "/omniroute"),
+ "'/omniroute/_next/static/media/m.png'"
+ );
+ // Already-prefixed URLs are stable.
+ assert.equal(
+ patchBakedAssetUrls('"/omniroute/_next/static/a.js"', "/omniroute"),
+ '"/omniroute/_next/static/a.js"'
+ );
+});
diff --git a/tests/unit/executor-antigravity.test.ts b/tests/unit/executor-antigravity.test.ts
index abdd7474a5..1bce1a6f59 100644
--- a/tests/unit/executor-antigravity.test.ts
+++ b/tests/unit/executor-antigravity.test.ts
@@ -281,9 +281,11 @@ test("AntigravityExecutor.transformRequest auto-discovers a missing projectId vi
}
});
-// #2334: when loadCodeAssist also finds no project (truly un-onboarded account), the
-// structured 422 must still be returned so the dashboard can prompt a reconnect.
-test("AntigravityExecutor.transformRequest still 422s when loadCodeAssist finds no project (#2334)", async () => {
+// #8491: when loadCodeAssist also finds no project and Google marks the
+// account BYOP (no automatic project creation for standard-tier accounts),
+// the fast 422 GCP_PROJECT_REQUIRED must be returned so the dashboard can
+// prompt the user to enter a GCP Project ID.
+test("AntigravityExecutor.transformRequest fast-422s with GCP_PROJECT_REQUIRED when loadCodeAssist finds no project (#8491)", async () => {
clearAntigravityProjectCache();
seedAntigravityIdeVersionCache("2.1.1");
const executor = new AntigravityExecutor();
@@ -305,7 +307,8 @@ test("AntigravityExecutor.transformRequest still 422s when loadCodeAssist finds
if (!(result instanceof Response)) throw new Error("Expected a 422 Response");
assert.equal(result.status, 422);
const payload = (await result.json()) as ErrorPayload;
- assert.equal(payload.error.code, "missing_project_id");
+ assert.equal(payload.error.code, "gcp_project_required");
+ assert.match(payload.error.message, /GCP_PROJECT_REQUIRED/);
} finally {
globalThis.fetch = originalFetch;
clearAntigravityProjectCache();
diff --git a/tests/unit/github-collector.test.ts b/tests/unit/github-collector.test.ts
index e61a4e0d0f..17c725be6c 100644
--- a/tests/unit/github-collector.test.ts
+++ b/tests/unit/github-collector.test.ts
@@ -129,7 +129,7 @@ void test("scanText: detects eval(base64) pattern", () => {
void test("scanText: detects hardcoded private keys", () => {
const content =
- "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA...\n-----END RSA PRIVATE KEY-----";
+ "-----BEGIN RSA PRIVATE KEY-----\nTEST_RSA_PRIVATE_KEY_PLACEHOLDER_DO_NOT_USE\n-----END RSA PRIVATE KEY-----";
const findings = scanText(content, "leaked.md");
assert.ok(findings.some((f) => f.pattern.includes("Private key")));
});
diff --git a/tests/unit/middleware-header-strip-5849.test.ts b/tests/unit/middleware-header-strip-5849.test.ts
index 7fbe9b18e3..ed9ea6be4a 100644
--- a/tests/unit/middleware-header-strip-5849.test.ts
+++ b/tests/unit/middleware-header-strip-5849.test.ts
@@ -100,6 +100,25 @@ test("streaming path bounds the aggregate size of many small upstream response h
assert.equal(getHeaderValue(out, "x-request-id"), "req-many-small-headers");
});
+test("streaming path keeps Codex quota headers and drops x-codex-turn-state", () => {
+ const upstream = new Headers();
+ upstream.set("x-codex-turn-state", "s".repeat(300));
+ upstream.set("content-security-policy", "default-src 'none'");
+ upstream.set("cf-ray", "abcdefghijklmnopqrstuvwxyz");
+ upstream.set("date", "Thu, 13 Aug 2026 21:00:00 GMT");
+ upstream.set("x-codex-primary-used-percent", "41");
+ upstream.set("x-codex-primary-reset-after-seconds", "120");
+ upstream.set("x-codex-credits-has-credits", "true");
+ upstream.set("x-request-id", "req-codex-quota");
+
+ const out = buildStreamingResponseHeaders(upstream, {}, null);
+ assert.equal(getHeaderValue(out, "x-request-id"), "req-codex-quota");
+ assert.equal(getHeaderValue(out, "x-codex-primary-used-percent"), "41");
+ assert.equal(getHeaderValue(out, "x-codex-primary-reset-after-seconds"), "120");
+ assert.equal(getHeaderValue(out, "x-codex-credits-has-credits"), "true");
+ assert.equal(getHeaderValue(out, "x-codex-turn-state"), undefined);
+});
+
test("streaming path prioritizes request and rate-limit headers over diagnostics", () => {
const upstream = new Headers();
for (let index = 0; index < 20; index += 1) {
diff --git a/tests/unit/model-alias-seed-fallback.test.ts b/tests/unit/model-alias-seed-fallback.test.ts
new file mode 100644
index 0000000000..8a3ff1ff11
--- /dev/null
+++ b/tests/unit/model-alias-seed-fallback.test.ts
@@ -0,0 +1,66 @@
+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 { resolveModelAliasWithSeedFallback } from "../../src/lib/modelAliasResolver";
+
+// Hermetic test: isolate DATA_DIR so the alias lookup reads an EMPTY
+// modelAliases namespace (fresh install state) instead of the operator's live
+// DB. This is the exact scenario the 401 fix targets β aliases unmapped in
+// the DB must fall back to the static seed.
+async function withEmptyAliasDb(fn: () => Promise) {
+ const prevDataDir = process.env.DATA_DIR;
+ const prevKey = process.env.STORAGE_ENCRYPTION_KEY;
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "alias-seed-fallback-"));
+ process.env.DATA_DIR = dataDir;
+ delete process.env.STORAGE_ENCRYPTION_KEY;
+
+ try {
+ // Reset the module-level DB singleton so it binds to the temp dir.
+ const { resetDbInstance } = await import("../../src/lib/db/core");
+ resetDbInstance?.();
+ await fn();
+ } finally {
+ fs.rmSync(dataDir, { recursive: true, force: true });
+ const { resetDbInstance } = await import("../../src/lib/db/core");
+ resetDbInstance?.();
+ if (prevDataDir === undefined) delete process.env.DATA_DIR;
+ else process.env.DATA_DIR = prevDataDir;
+ if (prevKey === undefined) delete process.env.STORAGE_ENCRYPTION_KEY;
+ else process.env.STORAGE_ENCRYPTION_KEY = prevKey;
+ }
+}
+
+test("resolveModelAliasWithSeedFallback: falls back to DEFAULT_MODEL_ALIAS_SEED for unmapped models", async () => {
+ await withEmptyAliasDb(async () => {
+ const opus = await resolveModelAliasWithSeedFallback("claude-opus-4-6-thinking");
+ assert.equal(opus, "agy/claude-opus-4-6-thinking");
+
+ const flash = await resolveModelAliasWithSeedFallback("gemini-3.6-flash-low");
+ assert.equal(flash, "agy/gemini-3.6-flash-low");
+
+ const unknown = await resolveModelAliasWithSeedFallback("unknown-custom-model-999");
+ assert.equal(unknown, "unknown-custom-model-999");
+ });
+});
+
+// Regression for the 401 the PR fixes: a client sends a model alias that is
+// NOT in the database alias table (empty modelAliases namespace = fresh
+// install / wiped aliases) but IS in the static seed. Before the fix the
+// alias resolved to itself β upstream rejects with 401 "no such model"; after
+// the fix it maps to the seed target (agy/...), which routes to a real model.
+test("resolveModelAliasWithSeedFallback: unmapped-but-seeded alias resolves (401 regression)", async () => {
+ await withEmptyAliasDb(async () => {
+ const resolved = await resolveModelAliasWithSeedFallback("claude-opus-4-6-thinking");
+ assert.equal(resolved, "agy/claude-opus-4-6-thinking");
+ });
+});
+
+// The exported name must not collide with the sync resolveModelAlias in
+// modelDeprecation.ts / modelSpecs.ts (maintainer review note on PR #10124).
+test("resolveModelAliasWithSeedFallback: export name is distinct from the sync resolveModelAlias", async () => {
+ const mod = await import("../../src/lib/modelAliasResolver");
+ assert.equal(typeof mod.resolveModelAliasWithSeedFallback, "function");
+ assert.equal(mod.resolveModelAlias, undefined, "must not export the colliding sync name");
+});
diff --git a/tests/unit/modelsDevSync-extended.test.ts b/tests/unit/modelsDevSync-extended.test.ts
index acdd1306f9..d08dd840fa 100644
--- a/tests/unit/modelsDevSync-extended.test.ts
+++ b/tests/unit/modelsDevSync-extended.test.ts
@@ -671,7 +671,7 @@ test("the usual truthy spellings all start the sync, and nothing else does", asy
// then never fetched anything; pin the fetch actually having run
// for each truthy spelling, not just the first one.
assert.ok(
- await waitFor(() => modelsDev.getSyncStatus().lastSync !== null),
+ await waitFor(() => modelsDev.getSyncStatus().lastSync !== null, 2000),
`MODELS_DEV_SYNC_ENABLED=${JSON.stringify(value)} should have completed a sync`
);
}
diff --git a/tests/unit/piiSanitizer.test.ts b/tests/unit/piiSanitizer.test.ts
index 76db2557e4..8a035b358d 100644
--- a/tests/unit/piiSanitizer.test.ts
+++ b/tests/unit/piiSanitizer.test.ts
@@ -147,7 +147,7 @@ test("sanitizePII detects AWS access key", async () => {
delete process.env.PII_RESPONSE_SANITIZATION_MODE;
const { sanitizePII } = await import("@/lib/piiSanitizer");
- const input = "Key: AKIAIOSFODNN7EXAMPLE";
+ const input = "Key: AKIAEXAMPLE123456789";
const result = sanitizePII(input);
assert.ok(result.text.includes("[AWS_KEY_REDACTED]"), "AWS access key should be redacted");
diff --git a/tests/unit/proxy-10348-log-redaction.test.ts b/tests/unit/proxy-10348-log-redaction.test.ts
new file mode 100644
index 0000000000..615cbc3adf
--- /dev/null
+++ b/tests/unit/proxy-10348-log-redaction.test.ts
@@ -0,0 +1,60 @@
+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";
+
+// Regression guard for #10348 β default process logs must not leak client/egress IPs
+// or the raw account prefix. Storage (in-memory ring buffer + SQLite) stays intact;
+// only the process-log emission changes.
+const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-proxy-10348-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+
+const core = await import("../../src/lib/db/core.ts");
+const proxyLogger = await import("../../src/lib/proxyLogger.ts");
+
+function resetStorage() {
+ proxyLogger.clearProxyLogs();
+ core.closeDbInstance();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
+ fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
+}
+
+test.beforeEach(() => resetStorage());
+test.after(() => resetStorage());
+
+test("[10348] default ProxyEgress console line redacts client IP, egress IP, and account prefix", () => {
+ const captured: string[] = [];
+ const origConsole = console.log;
+ console.log = (...args: unknown[]) => {
+ captured.push(args.map(String).join(" "));
+ };
+ try {
+ proxyLogger.logProxyEvent({
+ status: "error",
+ provider: "codex",
+ clientIp: "198.51.100.7",
+ egressIp: "203.0.113.9",
+ account: "aabbccdd",
+ level: "account",
+ });
+ } finally {
+ console.log = origConsole;
+ }
+ const line = captured.find((l) => l.includes("[ProxyEgress]"));
+ assert.ok(line, "expected a [ProxyEgress] console line");
+ assert.ok(line!.includes("codex"), "expected provider in the line");
+ assert.ok(line!.includes("status=error"), "expected status=error in the line");
+ assert.ok(
+ !line!.includes("198.51.100.7"),
+ "client IP must be redacted from the console line by default"
+ );
+ assert.ok(
+ !line!.includes("203.0.113.9"),
+ "egress IP must be redacted from the console line by default"
+ );
+ assert.ok(
+ !line!.includes("aabbccdd"),
+ "account prefix must be redacted from the console line by default"
+ );
+});
\ No newline at end of file
diff --git a/tests/unit/responses-continuation-store.test.ts b/tests/unit/responses-continuation-store.test.ts
new file mode 100644
index 0000000000..fee45e4290
--- /dev/null
+++ b/tests/unit/responses-continuation-store.test.ts
@@ -0,0 +1,159 @@
+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";
+
+// OmniRoute-native `previous_response_id` virtualization: resolvePreviousResponseState
+// resolves a response id back to the full input/output a prior call produced by
+// reading the already-persisted call-log artifact, so a later request can be
+// reconstructed to full history server-side without duplicating conversation
+// content into a second store.
+
+const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-responses-continuation-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+
+const core = await import("../../src/lib/db/core.ts");
+const store = await import("../../src/lib/db/responsesContinuationStore.ts");
+
+test.after(() => {
+ core.resetDbInstance();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
+});
+
+function insertCallLog(row: {
+ id: string;
+ responseId: string | null;
+ apiKeyId: string | null;
+ detailState: string;
+ artifactRelPath: string | null;
+}) {
+ const db = core.getDbInstance();
+ db.prepare(
+ `INSERT INTO call_logs
+ (id, timestamp, method, path, status, model, provider, account, duration,
+ tokens_in, tokens_out, api_key_id, detail_state, artifact_relpath, response_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
+ ).run(
+ row.id,
+ new Date().toISOString(),
+ "POST",
+ "/v1/responses",
+ 200,
+ "gpt-5.4-pro",
+ "openai",
+ "acc1",
+ 100,
+ 10,
+ 20,
+ row.apiKeyId,
+ row.detailState,
+ row.artifactRelPath,
+ row.responseId
+ );
+}
+
+function writeArtifact(relPath: string, pipeline: Record) {
+ const absPath = path.join(TEST_DATA_DIR, "call_logs", relPath);
+ fs.mkdirSync(path.dirname(absPath), { recursive: true });
+ fs.writeFileSync(
+ absPath,
+ JSON.stringify({
+ schemaVersion: 5,
+ summary: {},
+ requestBody: null,
+ responseBody: null,
+ error: null,
+ pipeline,
+ })
+ );
+}
+
+test("resolvePreviousResponseState reconstructs input/output from the call-log artifact", () => {
+ insertCallLog({
+ id: "log-1",
+ responseId: "resp_abc",
+ apiKeyId: "key-1",
+ detailState: "ready",
+ artifactRelPath: "2026-01-01/log-1.json",
+ });
+ writeArtifact("2026-01-01/log-1.json", {
+ providerRequest: { body: { input: [{ type: "message", role: "user", content: "hi" }] } },
+ clientResponse: {
+ id: "resp_abc",
+ output: [{ type: "message", role: "assistant", content: "hello" }],
+ },
+ });
+
+ const result = store.resolvePreviousResponseState("resp_abc", "key-1");
+ assert.deepEqual(result, {
+ input: [{ type: "message", role: "user", content: "hi" }],
+ output: [{ type: "message", role: "assistant", content: "hello" }],
+ });
+});
+
+test("resolvePreviousResponseState returns null for an unknown response id", () => {
+ const result = store.resolvePreviousResponseState("resp_does_not_exist", "key-1");
+ assert.equal(result, null);
+});
+
+test("resolvePreviousResponseState never crosses tenants (scoped by api_key_id)", () => {
+ insertCallLog({
+ id: "log-2",
+ responseId: "resp_tenant_a",
+ apiKeyId: "key-a",
+ detailState: "ready",
+ artifactRelPath: "2026-01-01/log-2.json",
+ });
+ writeArtifact("2026-01-01/log-2.json", {
+ providerRequest: { body: { input: [{ role: "user", content: "secret" }] } },
+ clientResponse: { id: "resp_tenant_a", output: [{ role: "assistant", content: "reply" }] },
+ });
+
+ assert.equal(store.resolvePreviousResponseState("resp_tenant_a", "key-b"), null);
+ assert.equal(store.resolvePreviousResponseState("resp_tenant_a", null), null);
+ assert.notEqual(store.resolvePreviousResponseState("resp_tenant_a", "key-a"), null);
+});
+
+test("resolvePreviousResponseState returns null when the artifact is missing on disk", () => {
+ insertCallLog({
+ id: "log-3",
+ responseId: "resp_missing_file",
+ apiKeyId: "key-1",
+ detailState: "ready",
+ artifactRelPath: "2026-01-01/does-not-exist.json",
+ });
+
+ assert.equal(store.resolvePreviousResponseState("resp_missing_file", "key-1"), null);
+});
+
+test("resolvePreviousResponseState fails closed when the pipeline payload was size-limit-omitted", () => {
+ insertCallLog({
+ id: "log-4",
+ responseId: "resp_omitted",
+ apiKeyId: "key-1",
+ detailState: "ready",
+ artifactRelPath: "2026-01-01/log-4.json",
+ });
+ // A size-limit-omitted payload is replaced with a placeholder string, not
+ // an object -- resolvePreviousResponseState must never try to reconstruct
+ // from it and silently drop history.
+ writeArtifact("2026-01-01/log-4.json", {
+ providerRequest: { body: "[omitted: call log artifact size limit exceeded]" },
+ clientResponse: { id: "resp_omitted", output: [] },
+ });
+
+ assert.equal(store.resolvePreviousResponseState("resp_omitted", "key-1"), null);
+});
+
+test("resolvePreviousResponseState returns null when detail logging was never captured for this row", () => {
+ insertCallLog({
+ id: "log-5",
+ responseId: "resp_no_detail",
+ apiKeyId: "key-1",
+ detailState: "none",
+ artifactRelPath: null,
+ });
+
+ assert.equal(store.resolvePreviousResponseState("resp_no_detail", "key-1"), null);
+});
diff --git a/tests/unit/sre-redact-logs.test.ts b/tests/unit/sre-redact-logs.test.ts
index 4d628fbb2c..6c180fe792 100644
--- a/tests/unit/sre-redact-logs.test.ts
+++ b/tests/unit/sre-redact-logs.test.ts
@@ -1,256 +1,259 @@
-import test from "node:test";
-import assert from "node:assert/strict";
-import { Writable } from "node:stream";
-import { redactString, redact, RedactTransform } from "../../scripts/sre/redact-logs.mjs";
-
-// βββ 1. email ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
-test("redactString: standard email is replaced", () => {
- const { output, counts } = redactString("contact alice@example.com for details");
- assert.equal(output, "contact [REDACTED_EMAIL] for details");
- assert.equal(counts.EMAIL, 1);
-});
-
-test("redactString: sub-domain email is replaced", () => {
- const { output } = redactString("ping ops+sre@mail.omniroute.dev today");
- assert.equal(output, "ping [REDACTED_EMAIL] today");
-});
-
-test("redactString: email-like but missing TLD is preserved", () => {
- // "user@host" is not a valid email; should NOT match.
- const { output, counts } = redactString("note user@host is mentioned");
- assert.equal(output, "note user@host is mentioned");
- assert.equal(counts.EMAIL ?? 0, 0);
-});
-
-// βββ 2. IPv4 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
-test("redactString: IPv4 address is redacted", () => {
- const { output, counts } = redactString("client connected from 192.168.1.42");
- assert.equal(output, "client connected from [REDACTED_IPV4]");
- assert.equal(counts.IPV4, 1);
-});
-
-test("redactString: IPv4 with port is redacted including port", () => {
- const { output } = redactString("connect 10.0.0.1:5432 succeeded");
- assert.equal(output, "connect [REDACTED_IPV4] succeeded");
-});
-
-test("redactString: invalid octet (256) is NOT redacted", () => {
- const { output, counts } = redactString("value 256.300.1.1 invalid");
- // The "256.300.1.1" should NOT match (octets > 255).
- // It's possible that a partial substring like "56.300" might still match
- // through other regex runs; assert that no full-IP redaction appears.
- assert.equal(output.includes("[REDACTED_IPV4]"), false);
- assert.equal((counts.IPV4 ?? 0), 0);
-});
-
-test("redactString: 127.0.0.1 is redacted (loopback is still PII for log shipping)", () => {
- const { output } = redactString("local check from 127.0.0.1 ok");
- assert.equal(output, "local check from [REDACTED_IPV4] ok");
-});
-
-// βββ 3. IPv6 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
-test("redactString: full IPv6 is redacted", () => {
- const { output, counts } = redactString("peer 2001:0db8:85a3:0000:0000:8a2e:0370:7334 connected");
- assert.equal(output, "peer [REDACTED_IPV6] connected");
- assert.equal(counts.IPV6, 1);
-});
-
-test("redactString: compressed IPv6 is redacted", () => {
- const { output } = redactString("from fe80::1 to ::1");
- // Both addresses should be replaced.
- assert.match(output, /from \[REDACTED_IPV6\] to \[REDACTED_IPV6\]/);
-});
-
-test("redactString: ::1 loopback is redacted", () => {
- const { output } = redactString("traffic from ::1 only");
- assert.match(output, /traffic from \[REDACTED_IPV6\] only/);
-});
-
-// βββ 4. Bearer tokens βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
-test("redactString: Bearer token in Authorization header is redacted", () => {
- const { output, counts } = redactString("Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345");
- assert.match(output, /\[REDACTED_BEARER\]/);
- assert.equal(counts.BEARER, 1);
-});
-
-test("redactString: 'Bearer' word without a token is preserved", () => {
- const { output, counts } = redactString("the bearer of bad news");
- // "bad news" is too short to match (needs 16+ chars).
- assert.equal(output, "the bearer of bad news");
- assert.equal((counts.BEARER ?? 0), 0);
-});
-
-// βββ 5. OpenAI keys βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
-test("redactString: sk- prefix key is redacted", () => {
- const { output, counts } = redactString("OPENAI_KEY=sk-proj-abc123XYZ456def789GHI012jkl");
- assert.match(output, /\[REDACTED_API_KEY\]/);
- assert.ok((counts.OPENAI_KEY ?? 0) >= 1 || (counts.GENERIC_KEY ?? 0) >= 1);
-});
-
-test("redactString: sk- short token (too short) is NOT redacted", () => {
- // 18 chars after "sk-" β minimum is 20.
- const { output } = redactString("noise: sk-abcdefghijklmnopqr here");
- assert.equal(output, "noise: sk-abcdefghijklmnopqr here");
-});
-
-test("redactString: anthropic sk-ant- key is redacted", () => {
- const { output, counts } = redactString("key: sk-ant-api03-abcdefghij1234567890ABCD");
- assert.match(output, /\[REDACTED_API_KEY\]/);
- assert.equal(counts.ANTHROPIC_KEY, 1);
-});
-
-test("redactString: Google AIza key is redacted", () => {
- // Total 39 chars: "AIza" (4) + 35 alnum/hyphen/underscore.
- const key = "AIzaSyD-1234567890abcdefghijklmnopqrstu";
- assert.equal(key.length, 39);
- const { output, counts } = redactString(`google_key=${key}`);
- assert.match(output, /\[REDACTED_API_KEY\]/);
- assert.equal(counts.GOOGLE_KEY, 1);
-});
-
-// βββ 6. GitHub tokens βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
-test("redactString: ghp_ token is redacted", () => {
- const token = "ghp_" + "a".repeat(36);
- const { output, counts } = redactString(`token=${token}`);
- assert.match(output, /\[REDACTED_API_KEY\]/);
- assert.equal(counts.GITHUB_TOKEN, 1);
-});
-
-test("redactString: github_pat_ token is redacted", () => {
- const token = "github_pat_" + "B".repeat(40);
- const { output } = redactString(`pat is ${token} end`);
- assert.match(output, /pat is \[REDACTED_API_KEY\] end/);
-});
-
-// βββ 7. AWS keys ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
-test("redactString: AKIA access key is redacted", () => {
- const key = "AKIAIOSFODNN7EXAMPLE"; // 20 chars
- const { output, counts } = redactString(`aws_access_key_id=${key}`);
- assert.match(output, /\[REDACTED_AWS_KEY\]/);
- assert.equal(counts.AWS_KEY, 1);
-});
-
-test("redactString: ASIA (temporary) key is redacted", () => {
- const key = "ASIAIOSFODNN7EXAMPLE";
- const { output, counts } = redactString(`temp=${key}`);
- assert.match(output, /\[REDACTED_AWS_KEY\]/);
- assert.equal(counts.AWS_KEY, 1);
-});
-
-// βββ 8. Generic api_key=value βββββββββββββββββββββββββββββββββββββββββββββββ
-
-test("redactString: api_key=value pair is redacted", () => {
- const { output, counts } = redactString(`api_key=${"x".repeat(20)}`);
- assert.match(output, /\[REDACTED_API_KEY\]/);
- assert.equal(counts.GENERIC_KEY, 1);
-});
-
-test("redactString: password=... is redacted", () => {
- const { output, counts } = redactString(`password: ${"hunter2hunter2hunter2"}`);
- assert.match(output, /\[REDACTED_API_KEY\]/);
- assert.equal(counts.GENERIC_KEY, 1);
-});
-
-test("redactString: short value (< 12 chars) is NOT redacted", () => {
- const { output, counts } = redactString("password: short");
- assert.equal(output, "password: short");
- assert.equal((counts.GENERIC_KEY ?? 0), 0);
-});
-
-// βββ 9. Combined / order of operations ββββββββββββββββββββββββββββββββββββββ
-
-test("redactString: line with email AND ip AND key redacts all three", () => {
- const line = `2026-06-25T07:00:00Z ERROR user=alice@example.com ip=10.0.0.5 key=sk-proj-${"A".repeat(30)}`;
- const { output, counts } = redactString(line);
- assert.match(output, /\[REDACTED_EMAIL\]/);
- assert.match(output, /\[REDACTED_IPV4\]/);
- assert.match(output, /\[REDACTED_API_KEY\]/);
- // Counts should be at least one of each category.
- assert.ok((counts.EMAIL ?? 0) >= 1);
- assert.ok((counts.IPV4 ?? 0) >= 1);
- assert.ok((counts.OPENAI_KEY ?? 0) >= 1);
-});
-
-test("redactString: empty string yields empty output", () => {
- const { output, counts } = redactString("");
- assert.equal(output, "");
- assert.deepEqual(counts, {});
-});
-
-test("redactString: non-PII log line is unchanged", () => {
- const line = '2026-06-25T07:00:00Z INFO request_id=req_abc123 method=GET path=/v1/models';
- const { output } = redactString(line);
- assert.equal(output, line);
-});
-
-test("redactString: key inside larger word (not at boundary) is not matched", () => {
- // `task-abc123XYZ456def789GHI012jkl345` is not preceded by 'sk-' so should
- // not match the OPENAI_KEY pattern.
- const { output, counts } = redactString("some task-abcdefghij1234567890KL here");
- assert.equal(output, "some task-abcdefghij1234567890KL here");
- assert.equal((counts.OPENAI_KEY ?? 0), 0);
-});
-
-// βββ 10. Stable markers across runs βββββββββββββββββββββββββββββββββββββββββ
-
-test("redactString: same input twice yields the same redacted output", () => {
- const line = `ip=192.168.0.1 user=${"a".repeat(40)}@example.com`;
- const first = redactString(line).output;
- const second = redactString(line).output;
- assert.equal(first, second);
-});
-
-// βββ 11. redact() shorthand βββββββββββββββββββββββββββββββββββββββββββββββββ
-
-test("redact(): shorthand returns just the output", () => {
- assert.equal(redact("email bob@example.com here"), "email [REDACTED_EMAIL] here");
-});
-
-// βββ 12. RedactTransform stream βββββββββββββββββββββββββββββββββββββββββββββ
-
-test("RedactTransform: streams input chunks to output, redacting as it goes", async () => {
- const t = new RedactTransform();
- const out = [];
- const sink = new Writable({
- write(chunk, _enc, cb) {
- out.push(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
- cb();
- },
- });
- const src = new ReadableStream({
- start(controller) {
- controller.enqueue(new TextEncoder().encode("email a@b.com "));
- controller.enqueue(new TextEncoder().encode("ip=1.2.3.4 "));
- controller.enqueue(new TextEncoder().encode("end\n"));
- controller.close();
- },
- });
- await src.pipeThrough(new TextDecoderStream()).pipeThrough(t).pipeTo(
- new WritableStream({
- write(chunk) {
- sink.write(chunk, "utf8", () => {});
- },
- }),
- );
- const joined = out.join("");
- assert.match(joined, /\[REDACTED_EMAIL\]/);
- assert.match(joined, /\[REDACTED_IPV4\]/);
- // Counts accumulated on the transform.
- assert.equal(t.counts.EMAIL ?? 0, 1);
- assert.equal(t.counts.IPV4 ?? 0, 1);
-});
-
-// βββ 13. Counts are independent between calls βββββββββββββββββββββββββββββββ
-
-test("redactString: counts do not bleed across calls", () => {
- redactString("a@b.com");
- const { counts } = redactString("no pii here at all");
- assert.deepEqual(counts, {});
-});
\ No newline at end of file
+import test from "node:test";
+import assert from "node:assert/strict";
+import { Writable } from "node:stream";
+import { redactString, redact, RedactTransform } from "../../scripts/sre/redact-logs.mjs";
+
+// βββ 1. email ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+test("redactString: standard email is replaced", () => {
+ const { output, counts } = redactString("contact alice@example.com for details");
+ assert.equal(output, "contact [REDACTED_EMAIL] for details");
+ assert.equal(counts.EMAIL, 1);
+});
+
+test("redactString: sub-domain email is replaced", () => {
+ const { output } = redactString("ping ops+sre@mail.omniroute.dev today");
+ assert.equal(output, "ping [REDACTED_EMAIL] today");
+});
+
+test("redactString: email-like but missing TLD is preserved", () => {
+ // "user@host" is not a valid email; should NOT match.
+ const { output, counts } = redactString("note user@host is mentioned");
+ assert.equal(output, "note user@host is mentioned");
+ assert.equal(counts.EMAIL ?? 0, 0);
+});
+
+// βββ 2. IPv4 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+test("redactString: IPv4 address is redacted", () => {
+ const { output, counts } = redactString("client connected from 192.168.1.42");
+ assert.equal(output, "client connected from [REDACTED_IPV4]");
+ assert.equal(counts.IPV4, 1);
+});
+
+test("redactString: IPv4 with port is redacted including port", () => {
+ const { output } = redactString("connect 10.0.0.1:5432 succeeded");
+ assert.equal(output, "connect [REDACTED_IPV4] succeeded");
+});
+
+test("redactString: invalid octet (256) is NOT redacted", () => {
+ const { output, counts } = redactString("value 256.300.1.1 invalid");
+ // The "256.300.1.1" should NOT match (octets > 255).
+ // It's possible that a partial substring like "56.300" might still match
+ // through other regex runs; assert that no full-IP redaction appears.
+ assert.equal(output.includes("[REDACTED_IPV4]"), false);
+ assert.equal(counts.IPV4 ?? 0, 0);
+});
+
+test("redactString: 127.0.0.1 is redacted (loopback is still PII for log shipping)", () => {
+ const { output } = redactString("local check from 127.0.0.1 ok");
+ assert.equal(output, "local check from [REDACTED_IPV4] ok");
+});
+
+// βββ 3. IPv6 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+test("redactString: full IPv6 is redacted", () => {
+ const { output, counts } = redactString("peer 2001:0db8:85a3:0000:0000:8a2e:0370:7334 connected");
+ assert.equal(output, "peer [REDACTED_IPV6] connected");
+ assert.equal(counts.IPV6, 1);
+});
+
+test("redactString: compressed IPv6 is redacted", () => {
+ const { output } = redactString("from fe80::1 to ::1");
+ // Both addresses should be replaced.
+ assert.match(output, /from \[REDACTED_IPV6\] to \[REDACTED_IPV6\]/);
+});
+
+test("redactString: ::1 loopback is redacted", () => {
+ const { output } = redactString("traffic from ::1 only");
+ assert.match(output, /traffic from \[REDACTED_IPV6\] only/);
+});
+
+// βββ 4. Bearer tokens βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+test("redactString: Bearer token in Authorization header is redacted", () => {
+ const { output, counts } = redactString("Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345");
+ assert.match(output, /\[REDACTED_BEARER\]/);
+ assert.equal(counts.BEARER, 1);
+});
+
+test("redactString: 'Bearer' word without a token is preserved", () => {
+ const { output, counts } = redactString("the bearer of bad news");
+ // "bad news" is too short to match (needs 16+ chars).
+ assert.equal(output, "the bearer of bad news");
+ assert.equal(counts.BEARER ?? 0, 0);
+});
+
+// βββ 5. OpenAI keys βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+test("redactString: sk- prefix key is redacted", () => {
+ const { output, counts } = redactString("OPENAI_KEY=sk-proj-abc123XYZ456def789GHI012jkl");
+ assert.match(output, /\[REDACTED_API_KEY\]/);
+ assert.ok((counts.OPENAI_KEY ?? 0) >= 1 || (counts.GENERIC_KEY ?? 0) >= 1);
+});
+
+test("redactString: sk- short token (too short) is NOT redacted", () => {
+ // 18 chars after "sk-" β minimum is 20.
+ const { output } = redactString("noise: sk-abcdefghijklmnopqr here");
+ assert.equal(output, "noise: sk-abcdefghijklmnopqr here");
+});
+
+test("redactString: anthropic sk-ant- key is redacted", () => {
+ const { output, counts } = redactString("key: sk-ant-api03-abcdefghij1234567890ABCD");
+ assert.match(output, /\[REDACTED_API_KEY\]/);
+ assert.equal(counts.ANTHROPIC_KEY, 1);
+});
+
+test("redactString: Google AIza key is redacted", () => {
+ // Total 39 chars: "AIza" (4) + 35 alnum/hyphen/underscore.
+ const key = "AIzaSyD-1234567890abcdefghijklmnopqrstu";
+ assert.equal(key.length, 39);
+ const { output, counts } = redactString(`google_key=${key}`);
+ assert.match(output, /\[REDACTED_API_KEY\]/);
+ assert.equal(counts.GOOGLE_KEY, 1);
+});
+
+// βββ 6. GitHub tokens βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+test("redactString: ghp_ token is redacted", () => {
+ const token = "ghp_" + "a".repeat(36);
+ const { output, counts } = redactString(`token=${token}`);
+ assert.match(output, /\[REDACTED_API_KEY\]/);
+ assert.equal(counts.GITHUB_TOKEN, 1);
+});
+
+test("redactString: github_pat_ token is redacted", () => {
+ const token = "github_pat_" + "B".repeat(40);
+ const { output } = redactString(`pat is ${token} end`);
+ assert.match(output, /pat is \[REDACTED_API_KEY\] end/);
+});
+
+// βββ 7. AWS keys ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+test("redactString: AKIA access key is redacted", () => {
+ const key = "AKIAEXAMPLE123456789"; // 20 chars
+ const { output, counts } = redactString(`aws_access_key_id=${key}`);
+ assert.match(output, /\[REDACTED_AWS_KEY\]/);
+ assert.equal(counts.AWS_KEY, 1);
+});
+
+test("redactString: ASIA (temporary) key is redacted", () => {
+ const key = "ASIAIOSFODNN7EXAMPLE";
+ const { output, counts } = redactString(`temp=${key}`);
+ assert.match(output, /\[REDACTED_AWS_KEY\]/);
+ assert.equal(counts.AWS_KEY, 1);
+});
+
+// βββ 8. Generic api_key=value βββββββββββββββββββββββββββββββββββββββββββββββ
+
+test("redactString: api_key=value pair is redacted", () => {
+ const { output, counts } = redactString(`api_key=${"x".repeat(20)}`);
+ assert.match(output, /\[REDACTED_API_KEY\]/);
+ assert.equal(counts.GENERIC_KEY, 1);
+});
+
+test("redactString: password=... is redacted", () => {
+ const { output, counts } = redactString(`password: ${"hunter2hunter2hunter2"}`);
+ assert.match(output, /\[REDACTED_API_KEY\]/);
+ assert.equal(counts.GENERIC_KEY, 1);
+});
+
+test("redactString: short value (< 12 chars) is NOT redacted", () => {
+ const { output, counts } = redactString("password: short");
+ assert.equal(output, "password: short");
+ assert.equal(counts.GENERIC_KEY ?? 0, 0);
+});
+
+// βββ 9. Combined / order of operations ββββββββββββββββββββββββββββββββββββββ
+
+test("redactString: line with email AND ip AND key redacts all three", () => {
+ const line = `2026-06-25T07:00:00Z ERROR user=alice@example.com ip=10.0.0.5 key=sk-proj-${"A".repeat(30)}`;
+ const { output, counts } = redactString(line);
+ assert.match(output, /\[REDACTED_EMAIL\]/);
+ assert.match(output, /\[REDACTED_IPV4\]/);
+ assert.match(output, /\[REDACTED_API_KEY\]/);
+ // Counts should be at least one of each category.
+ assert.ok((counts.EMAIL ?? 0) >= 1);
+ assert.ok((counts.IPV4 ?? 0) >= 1);
+ assert.ok((counts.OPENAI_KEY ?? 0) >= 1);
+});
+
+test("redactString: empty string yields empty output", () => {
+ const { output, counts } = redactString("");
+ assert.equal(output, "");
+ assert.deepEqual(counts, {});
+});
+
+test("redactString: non-PII log line is unchanged", () => {
+ const line = "2026-06-25T07:00:00Z INFO request_id=req_abc123 method=GET path=/v1/models";
+ const { output } = redactString(line);
+ assert.equal(output, line);
+});
+
+test("redactString: key inside larger word (not at boundary) is not matched", () => {
+ // `task-abc123XYZ456def789GHI012jkl345` is not preceded by 'sk-' so should
+ // not match the OPENAI_KEY pattern.
+ const { output, counts } = redactString("some task-abcdefghij1234567890KL here");
+ assert.equal(output, "some task-abcdefghij1234567890KL here");
+ assert.equal(counts.OPENAI_KEY ?? 0, 0);
+});
+
+// βββ 10. Stable markers across runs βββββββββββββββββββββββββββββββββββββββββ
+
+test("redactString: same input twice yields the same redacted output", () => {
+ const line = `ip=192.168.0.1 user=${"a".repeat(40)}@example.com`;
+ const first = redactString(line).output;
+ const second = redactString(line).output;
+ assert.equal(first, second);
+});
+
+// βββ 11. redact() shorthand βββββββββββββββββββββββββββββββββββββββββββββββββ
+
+test("redact(): shorthand returns just the output", () => {
+ assert.equal(redact("email bob@example.com here"), "email [REDACTED_EMAIL] here");
+});
+
+// βββ 12. RedactTransform stream βββββββββββββββββββββββββββββββββββββββββββββ
+
+test("RedactTransform: streams input chunks to output, redacting as it goes", async () => {
+ const t = new RedactTransform();
+ const out = [];
+ const sink = new Writable({
+ write(chunk, _enc, cb) {
+ out.push(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
+ cb();
+ },
+ });
+ const src = new ReadableStream({
+ start(controller) {
+ controller.enqueue(new TextEncoder().encode("email a@b.com "));
+ controller.enqueue(new TextEncoder().encode("ip=1.2.3.4 "));
+ controller.enqueue(new TextEncoder().encode("end\n"));
+ controller.close();
+ },
+ });
+ await src
+ .pipeThrough(new TextDecoderStream())
+ .pipeThrough(t)
+ .pipeTo(
+ new WritableStream({
+ write(chunk) {
+ sink.write(chunk, "utf8", () => {});
+ },
+ })
+ );
+ const joined = out.join("");
+ assert.match(joined, /\[REDACTED_EMAIL\]/);
+ assert.match(joined, /\[REDACTED_IPV4\]/);
+ // Counts accumulated on the transform.
+ assert.equal(t.counts.EMAIL ?? 0, 1);
+ assert.equal(t.counts.IPV4 ?? 0, 1);
+});
+
+// βββ 13. Counts are independent between calls βββββββββββββββββββββββββββββββ
+
+test("redactString: counts do not bleed across calls", () => {
+ redactString("a@b.com");
+ const { counts } = redactString("no pii here at all");
+ assert.deepEqual(counts, {});
+});
diff --git a/tests/unit/tailscaleTunnel-anti-fold-10293.test.ts b/tests/unit/tailscaleTunnel-anti-fold-10293.test.ts
new file mode 100644
index 0000000000..85149f8fdd
--- /dev/null
+++ b/tests/unit/tailscaleTunnel-anti-fold-10293.test.ts
@@ -0,0 +1,57 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+// #10293 β anti-fold regression guard.
+//
+// The reported defect: Turbopack constant-folds module-load `process.platform` to the
+// BUILD machine's value (a non-Windows runner) and prunes every Windows branch as dead
+// code, so `dist` builds ship a tailscaleTunnel where the Windows paths are unreachable.
+// That cannot be reproduced in a unit test (no published `dist`, no Windows runner), so
+// this guard enforces the SOURCE invariant that makes the fold impossible: platform reads
+// go through the runtime call `os.platform()` (a bundler cannot fold an arbitrary function
+// call), never a module-load `process.platform` constant.
+//
+// If a future edit re-introduces `const IS_WINDOWS = process.platform === "win32"` (or any
+// module-scope direct `process.platform` read), the folded-build failure returns β this test
+// turns RED.
+
+const modulePath = fileURLToPath(new URL("../../src/lib/tailscaleTunnel.ts", import.meta.url));
+const source = fs.readFileSync(modulePath, "utf8");
+
+test("#10293: tailscaleTunnel reads platform at runtime via os.platform(), never a module-load process.platform constant", () => {
+ const lines = source.split("\n");
+
+ // Any module-scope (non-function) direct read of process.platform is the foldable pattern.
+ const foldable = lines.filter((line, idx) => {
+ if (/process\.platform/.test(line) && !/^\s*\/\//.test(line)) {
+ // allow it only inside a function body (runtime read β but prefer os.platform there too);
+ // a module-load constant assignment at top level with process.platform is the defect.
+ return line.includes("= process.platform") && idx < 60;
+ }
+ return false;
+ });
+ assert.deepEqual(
+ foldable,
+ [],
+ `module-load constant(s) reading process.platform reintroduced the foldable pattern: ${foldable.join(" | ")}`
+ );
+
+ // The runtime getter must exist and delegate to os.platform (the anti-fold call).
+ assert.match(source, /function getCurrentPlatform\(\):\s*NodeJS\.Platform\s*\{\s*return os\.platform\(\);?\s*\}/m);
+});
+
+test("#10293: Windows branches use runtime platform reads, so they survive any build machine", () => {
+ // These are the specific Windows behaviors the reporter found folded to dead code:
+ // (a) --socket not injected (buildTailscaleArgs), (b) where over which (resolvePathCommand),
+ // (c) windows-default binary fallback (resolveBinary). Each must read platform at runtime
+ // through os.platform()/getCurrentPlatform().
+ const socketBranch = /getCurrentPlatform\(\) === "win32"[\s\S]{0,80}return args/.test(source);
+ const whereBranch = /os\.platform\(\) === "win32" \? "where" : "which"/.test(source);
+ const windowsDefaultBranch = /getCurrentPlatform\(\) === "win32" && fs\.existsSync\(WINDOWS_TAILSCALE_BIN\)/.test(source);
+ assert.ok(socketBranch, "buildTailscaleArgs must not inject --socket on win32 (runtime platform read)");
+ assert.ok(whereBranch, "resolvePathCommand must select 'where' when os.platform() === 'win32'");
+ assert.ok(windowsDefaultBranch, "resolveBinary must reach the Windows default binary fallback via runtime platform read");
+});
\ No newline at end of file
diff --git a/tests/unit/usage-extractor.test.ts b/tests/unit/usage-extractor.test.ts
index 851032690c..af328e52b6 100644
--- a/tests/unit/usage-extractor.test.ts
+++ b/tests/unit/usage-extractor.test.ts
@@ -228,6 +228,7 @@ test("extractUsageFromResponse reads Gemini usageMetadata and thinking tokens",
assert.deepEqual(usage, {
prompt_tokens: 11,
completion_tokens: 7,
+ cached_tokens: 0,
reasoning_tokens: 2,
});
});
@@ -252,6 +253,7 @@ test("extractUsageFromResponse reads Gemini usageMetadata from the antigravity r
assert.deepEqual(usage, {
prompt_tokens: 42,
completion_tokens: 17,
+ cached_tokens: 7,
reasoning_tokens: 4,
});
});
@@ -268,10 +270,34 @@ test("extractUsageFromResponse prefers top-level usageMetadata over the envelope
assert.deepEqual(usage, {
prompt_tokens: 1,
completion_tokens: 2,
+ cached_tokens: 0,
reasoning_tokens: 0,
});
});
+test("extractUsageFromResponse surfaces Gemini cachedContentTokenCount as cached_tokens", () => {
+ // Review follow-up on #10430: match the OpenAI/Claude/Responses branches and
+ // the streaming path (usageTracking.ts) by surfacing Gemini cache-hit tokens.
+ const usage = extractUsageFromResponse(
+ {
+ usageMetadata: {
+ promptTokenCount: 30,
+ candidatesTokenCount: 10,
+ thoughtsTokenCount: 3,
+ cachedContentTokenCount: 12,
+ },
+ },
+ "gemini"
+ );
+
+ assert.deepEqual(usage, {
+ prompt_tokens: 30,
+ completion_tokens: 13,
+ cached_tokens: 12,
+ reasoning_tokens: 3,
+ });
+});
+
test("extractUsageFromResponse returns null when usage is missing", () => {
const usage = extractUsageFromResponse(
{