mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 13:42:09 +03:00
Merge release/v3.8.50 (7f5275ed) into fix/qdrant-health-badge
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -513,6 +513,8 @@ Pix copia-e-cola:
|
||||
|
||||
<br/>
|
||||
|
||||
<p><strong>Developer notes:</strong> The project may generate a local <code>.env</code> file during npm install/postinstall for developer convenience. This file is intentionally ignored via <code>.gitignore</code> (see <code>.gitignore</code>) and must never be committed — if accidentally committed, rotate any exposed secrets and remove the file from history. See <a href="docs/DEVELOPER-ENVIRONMENT.md">docs/DEVELOPER-ENVIRONMENT.md</a> for guidance on managing local environment files and secrets.</p>
|
||||
|
||||
## 📡 OmniRoute Radar
|
||||
|
||||
The main free-tier headline remains **~1.53B tokens/month** from the documented,
|
||||
|
||||
1
changelog.d/fixes/10293-windows-tailscale-branches.md
Normal file
1
changelog.d/fixes/10293-windows-tailscale-branches.md
Normal file
@@ -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).
|
||||
1
changelog.d/fixes/10348-default-logs-redact-client.md
Normal file
1
changelog.d/fixes/10348-default-logs-redact-client.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(backend): redact client IPs and account prefixes from default proxy logs (#10348)
|
||||
@@ -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
|
||||
1
changelog.d/fixes/10465-gemini-cached-tokens.md
Normal file
1
changelog.d/fixes/10465-gemini-cached-tokens.md
Normal file
@@ -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
|
||||
1
changelog.d/fixes/10482-docker-images-and-basepath.md
Normal file
1
changelog.d/fixes/10482-docker-images-and-basepath.md
Normal file
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
- fix(combo): recovery hint for all_targets_skipped now points at provider quota/availability instead of 'transient, just retry' (#9303)
|
||||
1
changelog.d/fixes/forward-codex-quota-headers.md
Normal file
1
changelog.d/fixes/forward-codex-quota-headers.md
Normal file
@@ -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`)
|
||||
@@ -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}"
|
||||
|
||||
29
docs/DEVELOPER-ENVIRONMENT.md
Normal file
29
docs/DEVELOPER-ENVIRONMENT.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>): void {
|
||||
const projectCache = new Map<string, string>();
|
||||
|
||||
/** Per-key lock to prevent concurrent onboard attempts for the same token. */
|
||||
const onboardLocks = new Map<string, Promise<boolean>>();
|
||||
const onboardLocks = new Map<string, Promise<void>>();
|
||||
|
||||
/**
|
||||
* 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<string>();
|
||||
|
||||
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<Response>;
|
||||
|
||||
@@ -138,7 +173,7 @@ async function tryOnboardUser(
|
||||
clientProfile: AntigravityClientProfile,
|
||||
tierId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<boolean> {
|
||||
): Promise<AntigravityOnboardStatus> {
|
||||
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<string>();
|
||||
/**
|
||||
* 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<string, number>();
|
||||
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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 */
|
||||
});
|
||||
}
|
||||
|
||||
11
src/lib/db/migrations/154_call_logs_response_id.sql
Normal file
11
src/lib/db/migrations/154_call_logs_response_id.sql
Normal file
@@ -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);
|
||||
75
src/lib/db/responsesContinuationStore.ts
Normal file
75
src/lib/db/responsesContinuationStore.ts
Normal file
@@ -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<string, unknown> {
|
||||
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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown> | null = null;
|
||||
let lastFetch = 0;
|
||||
@@ -25,17 +26,22 @@ async function loadAliases(): Promise<Record<string, unknown>> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string | null | undefined> {
|
||||
if (!model) return model;
|
||||
|
||||
const aliases = await loadAliases();
|
||||
const target = aliases[model];
|
||||
const target = aliases[model] ?? (DEFAULT_MODEL_ALIAS_SEED as Record<string, unknown>)[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<string, unknown> | null | undefined
|
||||
): Promise<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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<BinaryResolution> {
|
||||
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<string> {
|
||||
}
|
||||
|
||||
// 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<string> {
|
||||
|
||||
/** 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<TailscaleCheckStatus> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string, unknown>
|
||||
);
|
||||
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, () =>
|
||||
|
||||
31
tests/unit/9303-recovery-hint-all-targets-skipped.test.ts
Normal file
31
tests/unit/9303-recovery-hint-all-targets-skipped.test.ts
Normal file
@@ -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"
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -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<Response> => {
|
||||
@@ -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<Response> => {
|
||||
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<Response> => {
|
||||
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 () => {
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
|
||||
63
tests/unit/chat-previous-response-id-preserve-mode.test.ts
Normal file
63
tests/unit/chat-previous-response-id-preserve-mode.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
@@ -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<string, unknown>, onSave = vi.fn()) {
|
||||
act(() => {
|
||||
root.render(
|
||||
<EditConnectionModal
|
||||
isOpen={true}
|
||||
connection={connection}
|
||||
providerId={connection.provider as string}
|
||||
onSave={onSave}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
};
|
||||
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<string, unknown>;
|
||||
};
|
||||
expect(updates.providerSpecificData?.isProjectIdManual).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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"'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")));
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
66
tests/unit/model-alias-seed-fallback.test.ts
Normal file
66
tests/unit/model-alias-seed-fallback.test.ts
Normal file
@@ -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<void>) {
|
||||
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");
|
||||
});
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
60
tests/unit/proxy-10348-log-redaction.test.ts
Normal file
60
tests/unit/proxy-10348-log-redaction.test.ts
Normal file
@@ -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"
|
||||
);
|
||||
});
|
||||
159
tests/unit/responses-continuation-store.test.ts
Normal file
159
tests/unit/responses-continuation-store.test.ts
Normal file
@@ -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<string, unknown>) {
|
||||
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);
|
||||
});
|
||||
@@ -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, {});
|
||||
});
|
||||
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, {});
|
||||
});
|
||||
|
||||
57
tests/unit/tailscaleTunnel-anti-fold-10293.test.ts
Normal file
57
tests/unit/tailscaleTunnel-anti-fold-10293.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user