Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
1e98432
[AI-000] strip unsupported effort for non-opus claude (#1)
datj9 May 31, 2026
4b74783
feat(usage): show api key on recent requests (#2)
datj9 May 31, 2026
89369d5
fix(codex): normalize reasoning effort (#3)
datj9 May 31, 2026
fbe1f68
[AI-000] per-project usage statistics (#4)
datj9 May 31, 2026
8691fca
[AI-000] usage: fix project/Untagged double-count + add Project colum…
datj9 May 31, 2026
7fd2a4b
[AI-000] usage: make /dashboard/projects project-first (#8)
datj9 May 31, 2026
af826e3
[AI-000] API key rotation + manager fields + email notify + usage cha…
datj9 May 31, 2026
103a8e8
fix(usage): [AI-000] backfill usageDaily project maps so project cost…
datj9 Jun 1, 2026
f6686c0
Merge branch 'decolua:master' into master
datj9 Jun 1, 2026
8f0aceb
fix(usage): [AI-000] share streaming detail id so request row updates…
datj9 Jun 1, 2026
1c8a688
[AI-000] fix: nodemailer in docker image + restore project model char…
datj9 Jun 1, 2026
003ffb9
fix(usage): [AI-000] finalize streaming detail row on disconnect/abor…
datj9 Jun 1, 2026
4375504
fix(usage): [AI-000] add SSE idle keepalive so proxied streams surviv…
datj9 Jun 2, 2026
17190a5
fix: stabilize usage tabs and auto-toggle quota accounts (#16)
datj9 Jun 2, 2026
2688f3d
[AI-001] fix proven auth header-trust bypasses (AUTH-VULN-01/02) (#9)
datj9 Jun 2, 2026
8afba29
Merge remote-tracking branch 'origin/master'
Jun 6, 2026
8957bb7
Keep proxied JSON + SSE connections alive through thinking gaps (#17)
datj9 Jun 7, 2026
44df2de
Merge remote-tracking branch 'fork/master'
Jun 7, 2026
594b0e7
Add zero-downtime deploy + rollback scripts and maintenance image (#18)
datj9 Jun 7, 2026
56de2bd
Merge remote-tracking branch 'fork/master'
Jun 7, 2026
e00cf2c
fix(projects): hide project bars with zero cost/tokens/requests (#19)
datj9 Jun 9, 2026
34d214a
Merge remote-tracking branch 'fork/master'
datj9 Jun 14, 2026
c2736b9
Merge remote-tracking branch 'origin/master'
datj9 Jun 14, 2026
97cd383
fix(claude): [AI-000] strip sampling params for opus 4.7/4.8/fable-5
datj9 Jun 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
# VCS
.git
**/.git
.github
.gitignore
.npmignore

# Editor
.vscode
**/.vscode

# Dependencies and build output
node_modules
**/node_modules
.next
**/.next
out
build
dist
Expand All @@ -30,3 +35,25 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*

# Devkit / tooling / agent scratch — never needed at build time
.claude
.devkit
.hermes
.codegraph
.codex-pentest

# Docs / reports / examples not consumed by the build
report
docs
gitbook
images
CHANGELOG.md
README.md
README.zh-CN.md
DOCKER.md
LICENSE

# Tests and test tooling
tests
tester
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,17 @@ package-lock.json
.github/instructions/codacy.instructions.md
README1.md
deploy*.sh
# Tracked deploy tooling (pairs with rollback.sh + scripts/maintenance/)
!/deploy.sh
ecosystem.config.*

scripts/agSniffer/*
gitbooks/*
gitbook/README.md

# Claude Code local harness
.claude/

# Local tool scratch (codegraph index, codex pentest artifacts — contains secrets)
.codegraph/
.codex-pentest/
29 changes: 17 additions & 12 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ FROM base AS builder

RUN apk --no-cache upgrade && apk --no-cache add python3 make g++ linux-headers

COPY package.json ./
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm install
npm ci

COPY . ./
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
RUN --mount=type=cache,target=/app/.next/cache \
npm run build

FROM ${NODE_IMAGE} AS runner
WORKDIR /app
Expand All @@ -26,19 +27,23 @@ ENV HOSTNAME=0.0.0.0
ENV NEXT_TELEMETRY_DISABLED=1
ENV DATA_DIR=/app/data

COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/custom-server.js ./custom-server.js
COPY --from=builder /app/open-sse ./open-sse
# --chown at copy time sets node:node ownership directly, avoiding a slow
# recursive `chown -R /app` over thousands of node_modules/next files each build.
COPY --from=builder --chown=node:node /app/public ./public
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/custom-server.js ./custom-server.js
COPY --from=builder --chown=node:node /app/open-sse ./open-sse
# Next file tracing can omit sibling files; MITM runs server.js as a separate process.
COPY --from=builder /app/src/mitm ./src/mitm
COPY --from=builder --chown=node:node /app/src/mitm ./src/mitm
# Standalone node_modules may omit deps only required by the MITM child process.
COPY --from=builder /app/node_modules/node-forge ./node_modules/node-forge
COPY --from=builder --chown=node:node /app/node_modules/node-forge ./node_modules/node-forge
# Ensure `next` is available at runtime in case tracing did not include it.
COPY --from=builder /app/node_modules/next ./node_modules/next
COPY --from=builder --chown=node:node /app/node_modules/next ./node_modules/next
# nodemailer is loaded via dynamic import for SMTP email; tracing can miss it.
COPY --from=builder --chown=node:node /app/node_modules/nodemailer ./node_modules/nodemailer

RUN mkdir -p /app/data && chown -R node:node /app && \
RUN mkdir -p /app/data && chown node:node /app /app/data && \
mkdir -p /app/data-home && chown node:node /app/data-home && \
ln -sf /app/data-home /root/.9router 2>/dev/null || true

Expand Down
5 changes: 4 additions & 1 deletion cli/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ const APP_NAME = pkg.name; // Use from package.json
const INSTALL_CMD_LATEST = `npm i -g ${APP_NAME}@latest --prefer-online`;

const DEFAULT_PORT = 20128;
const DEFAULT_HOST = "0.0.0.0";
// Bind to loopback by default so local no-auth conveniences are not reachable
// from the network. Network exposure is explicit opt-in via --host 0.0.0.0,
// which then correctly requires API-key / login auth (see dashboardGuard.js).
const DEFAULT_HOST = "127.0.0.1";
const MAX_PORT_ATTEMPTS = 10;
// Identifiers for killAllAppProcesses - only kill 9router specifically
const PROCESS_IDENTIFIERS = [
Expand Down
106 changes: 106 additions & 0 deletions deploy.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# Redeploy 9router with near-zero downtime.
#
# Sequence:
# 1. Build the new app image while the old container keeps serving.
# 2. Swap: stop app -> maintenance container holds port 20128 (503 + Retry-After)
# -> recreate app container -> health check -> stop maintenance.
# 3. On a failed health check the maintenance page comes back up and the old
# image is still tagged 9router:previous for manual rollback.
#
# Usage: ./deploy.sh [--no-pull]
# --no-pull skip `git pull` (deploy whatever is in the working tree)

set -euo pipefail

APP_NAME="9router"
IMAGE="9router:latest"
PREVIOUS_IMAGE="9router:previous"
MAINT_NAME="9router-maintenance"
MAINT_IMAGE="9router-maintenance:latest"
HOST_PORT=20128
DATA_VOLUME="9router-data"
HEALTH_URL="http://127.0.0.1:${HOST_PORT}/"
HEALTH_TIMEOUT_SECONDS=90
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

log() { printf '\033[1;36m[deploy]\033[0m %s\n' "$*"; }
fail() { printf '\033[1;31m[deploy] ERROR:\033[0m %s\n' "$*" >&2; exit 1; }

cd "${REPO_DIR}"

# --- 1. Update source -------------------------------------------------------
if [[ "${1:-}" != "--no-pull" ]]; then
log "Pulling latest master..."
git pull --ff-only origin master
fi

# --- 2. Snapshot the current image as a rollback target ---------------------
# Must happen BEFORE the build: the build overwrites the ${IMAGE} tag, and on the
# containerd image store the old layers are then garbage-collected — so tagging
# the running container's image digest afterwards fails with "content digest not
# found". Re-tag the existing ${IMAGE} tag (tag->tag) while it still points at the
# old image; that works on both the classic and containerd stores.
if docker image inspect "${IMAGE}" >/dev/null 2>&1; then
docker tag "${IMAGE}" "${PREVIOUS_IMAGE}"
log "Saved current ${IMAGE} as ${PREVIOUS_IMAGE} (rollback target)."
else
log "No existing ${IMAGE} to snapshot — skipping rollback tag (first deploy?)."
fi

# --- 3. Build images while old container still serves ------------------------
log "Building app image (old container keeps serving)..."
docker build -t "${IMAGE}" .

if ! docker image inspect "${MAINT_IMAGE}" >/dev/null 2>&1; then
log "Building maintenance image (first run only)..."
docker build -t "${MAINT_IMAGE}" scripts/maintenance
fi

start_maintenance() {
docker rm -f "${MAINT_NAME}" >/dev/null 2>&1 || true
docker run -d --name "${MAINT_NAME}" \
-p "${HOST_PORT}:${HOST_PORT}" \
-e "MAINTENANCE_PORT=${HOST_PORT}" \
"${MAINT_IMAGE}" >/dev/null
log "Maintenance page is holding port ${HOST_PORT}."
}

stop_maintenance() {
docker rm -f "${MAINT_NAME}" >/dev/null 2>&1 || true
}

# --- 4. Swap containers ------------------------------------------------------
log "Stopping ${APP_NAME}..."
docker stop "${APP_NAME}" >/dev/null 2>&1 || true
start_maintenance
docker rm "${APP_NAME}" >/dev/null 2>&1 || true

log "Releasing port and starting new ${APP_NAME} container..."
stop_maintenance
# DATA_DIR must stay /app/data and match the volume mount — otherwise usage
# data is written inside the container layer and wiped on `docker rm`.
docker run -d --name "${APP_NAME}" \
--restart unless-stopped \
-p "${HOST_PORT}:${HOST_PORT}" \
-v "${DATA_VOLUME}:/app/data" \
-e "DATA_DIR=/app/data" \
"${IMAGE}" >/dev/null

# --- 5. Health check ---------------------------------------------------------
log "Waiting for health check at ${HEALTH_URL} (timeout ${HEALTH_TIMEOUT_SECONDS}s)..."
deadline=$((SECONDS + HEALTH_TIMEOUT_SECONDS))
until curl -fsS -o /dev/null --max-time 3 "${HEALTH_URL}"; do
if (( SECONDS >= deadline )); then
log "Health check FAILED — bringing maintenance page back up."
docker logs --tail 50 "${APP_NAME}" || true
docker stop "${APP_NAME}" >/dev/null 2>&1 || true
start_maintenance
fail "New container unhealthy. Roll back with: docker rm -f ${APP_NAME} && docker run -d --name ${APP_NAME} --restart unless-stopped -p ${HOST_PORT}:${HOST_PORT} -v ${DATA_VOLUME}:/app/data -e DATA_DIR=/app/data ${PREVIOUS_IMAGE} && docker rm -f ${MAINT_NAME}"
fi
sleep 2
done

log "Deploy complete — ${APP_NAME} is healthy on port ${HOST_PORT}."
docker image prune -f >/dev/null 2>&1 || true
docker ps --filter "name=${APP_NAME}" --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'
19 changes: 17 additions & 2 deletions open-sse/config/runtimeConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,23 @@ export const MEMORY_CONFIG = {
proxyDispatchersMaxSize: 20,
};

// Stream stall timeout: abort if no chunk received within this duration
export const STREAM_STALL_TIMEOUT_MS = 60 * 1000;
function envPositiveInt(name, fallback) {
const raw = process.env[name];
if (raw == null || raw === "") return fallback;
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}

// Stream stall timeout: abort if no upstream bytes arrive within this duration.
// Reasoning providers can legally stay silent for 60s+ while thinking, so keep
// this well above common proxy read timeouts; downstream keepalives handle proxy
// idleness separately.
export const STREAM_STALL_TIMEOUT_MS = envPositiveInt("STREAM_STALL_TIMEOUT_MS", 5 * 60 * 1000);

// Downstream keepalive cadence. SSE uses comment events; JSON uses leading
// whitespace, which remains valid before the final JSON document.
export const STREAM_HEARTBEAT_INTERVAL_MS = envPositiveInt("STREAM_HEARTBEAT_INTERVAL_MS", 10 * 1000);
export const JSON_KEEPALIVE_INTERVAL_MS = envPositiveInt("JSON_KEEPALIVE_INTERVAL_MS", 10 * 1000);

// Fetch connect timeout: abort if upstream doesn't return response headers within this duration
export const FETCH_CONNECT_TIMEOUT_MS = 60 * 1000;
Expand Down
23 changes: 17 additions & 6 deletions open-sse/executors/codex.js
Original file line number Diff line number Diff line change
Expand Up @@ -427,17 +427,28 @@ export class CodexExecutor extends BaseExecutor {
}

// Priority: explicit reasoning.effort > reasoning_effort param > model suffix > default (medium)
if (!body.reasoning) {
const effort = body.reasoning_effort || modelEffort || 'low';
body.reasoning = { effort, summary: "auto" };
} else if (!body.reasoning.summary) {
body.reasoning.summary = "auto";
if (!body.reasoning || typeof body.reasoning !== 'object' || Array.isArray(body.reasoning)) {
const effort = body.reasoning_effort || modelEffort || 'medium';
body.reasoning = { effort, summary: 'auto' };
} else {
if (!body.reasoning.effort) {
body.reasoning.effort = body.reasoning_effort || modelEffort || 'medium';
}
if (!body.reasoning.summary) {
body.reasoning.summary = 'auto';
}
}
delete body.reasoning_effort;

// Include reasoning encrypted content (required by Codex backend for reasoning models)
const include = Array.isArray(body.include) ? body.include : [];
if (body.reasoning && body.reasoning.effort && body.reasoning.effort !== 'none') {
body.include = ["reasoning.encrypted_content"];
body.include = include.includes("reasoning.encrypted_content")
? include
: [...include, "reasoning.encrypted_content"];
} else if (include.length > 0) {
body.include = include.filter(item => item !== "reasoning.encrypted_content");
if (body.include.length === 0) delete body.include;
}

// Remove unsupported parameters for Codex API
Expand Down
24 changes: 24 additions & 0 deletions open-sse/executors/default.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ import { getCachedClaudeHeaders } from "../utils/claudeHeaderCache.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { injectReasoningContent } from "../utils/reasoningContentInjector.js";

// The `context-1m-2025-08-07` Anthropic-Beta flag (1M context window) requires a
// subscription entitlement. Accounts without it get 400 "The long context beta is
// not yet available for this subscription." on real (large-context) requests. The
// flag can reach the upstream from the static spoof header OR from cached real
// Claude Code client headers, so it is stripped from every claude request here.
const CONTEXT_1M_BETA_FLAG = "context-1m-2025-08-07";

export class DefaultExecutor extends BaseExecutor {
constructor(provider) {
super(provider, PROVIDERS[provider] || PROVIDERS.openai);
Expand Down Expand Up @@ -118,6 +125,23 @@ export class DefaultExecutor extends BaseExecutor {
credentials.apiKey
? (headers["x-api-key"] = credentials.apiKey)
: (headers["Authorization"] = `Bearer ${credentials.accessToken}`);

// Strip the 1M-context beta flag from every claude request — it requires a
// subscription entitlement this proxy can't assume, and rejected requests
// 400. Covers both the static spoof header and merged cached client headers.
for (const betaKey of ["anthropic-beta", "Anthropic-Beta"]) {
if (!headers[betaKey]) continue;
const filtered = headers[betaKey]
.split(",")
.map(flag => flag.trim())
.filter(flag => flag && flag !== CONTEXT_1M_BETA_FLAG)
.join(",");
if (filtered) {
headers[betaKey] = filtered;
} else {
delete headers[betaKey];
}
}
break;
}
case "glm":
Expand Down
Loading