Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,14 @@ APP_LOG_TO_FILE=true
# Default: 512
# CALL_LOG_PIPELINE_MAX_SIZE_KB=512

# Call log payload truncation limits — controls how much of request/response
# bodies is retained in the database.
# Used by: open-sse/handlers/chatCore.ts — cloneBoundedChatLogPayload()
# CHAT_LOG_TEXT_LIMIT=65536 # Max string length before truncation (default: 64 KB)
# CHAT_LOG_ARRAY_TAIL_ITEMS=24 # Number of array items retained from tail (default: 24)
# CHAT_LOG_MAX_DEPTH=6 # Max nesting depth before truncation (default: 6)
# CHAT_LOG_MAX_OBJECT_KEYS=80 # Max object keys retained (default: 80, 0 = no limit)

# Maximum rows in the proxy_logs SQLite table.
# Default: 100000
# PROXY_LOGS_TABLE_MAX_ROWS=100000
Expand Down Expand Up @@ -777,6 +785,12 @@ APP_LOG_TO_FILE=true
# Log Responses API SSE-to-JSON translation details.
# DEBUG_RESPONSES_SSE_TO_JSON=true

# Write raw (untruncated) request/response JSON in call log artifacts.
# When enabled, serializeArtifactForStorage skips size-based truncation.
# Also enabled automatically when APP_LOG_LEVEL=debug.
# WARNING: produces large files — use only for temporary debugging.
# CHAT_DEBUG_FILE=true

# Enable E2E test mode — relaxes auth and enables test harness hooks.
# NEXT_PUBLIC_OMNIROUTE_E2E_MODE=true

Expand Down
44 changes: 44 additions & 0 deletions .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Claude Code Review

on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"

jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'

runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1

- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options

50 changes: 50 additions & 0 deletions .github/workflows/claude.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Claude Code

on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]

jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1

- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}

# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'

# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr *)'

39 changes: 22 additions & 17 deletions open-sse/handlers/chatCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,13 @@ import {
} from "../services/errorClassifier.ts";
import { updateProviderConnection } from "@/lib/db/providers";
import { isDetailedLoggingEnabled } from "@/lib/db/detailedLogs";
import { getCallLogPipelineCaptureStreamChunks } from "@/lib/logEnv";
import {
getCallLogPipelineCaptureStreamChunks,
getChatLogTextLimit,
getChatLogArrayTailItems,
getChatLogMaxDepth,
getChatLogMaxObjectKeys,
} from "@/lib/logEnv";
import { logAuditEvent } from "@/lib/compliance";
import { extractProviderWarnings } from "@/lib/compliance/providerAudit";
import { adaptBodyForCompression } from "../services/compression/bodyAdapter.ts";
Expand Down Expand Up @@ -180,39 +186,37 @@ import { fetchLiveProviderLimits } from "@/lib/usage/providerLimits";
import { isClaudeExtraUsageBlockEnabled } from "@/lib/providers/claudeExtraUsage";

const MEMORY_EXTRACTION_TEXT_LIMIT = 64 * 1024;
const CHAT_LOG_TEXT_LIMIT = 64 * 1024;
const CHAT_LOG_ARRAY_TAIL_ITEMS = 24;
const CHAT_LOG_MAX_DEPTH = 6;
const CHAT_LOG_MAX_OBJECT_KEYS = 80;

function capMemoryExtractionText(value: string): string {
if (value.length <= MEMORY_EXTRACTION_TEXT_LIMIT) return value;
return value.slice(-MEMORY_EXTRACTION_TEXT_LIMIT);
}

function truncateChatLogText(value: string): string {
if (value.length <= CHAT_LOG_TEXT_LIMIT) return value;
const head = value.slice(0, Math.floor(CHAT_LOG_TEXT_LIMIT / 2));
const tail = value.slice(-Math.ceil(CHAT_LOG_TEXT_LIMIT / 2));
return `${head}\n[...truncated ${value.length - CHAT_LOG_TEXT_LIMIT} chars...]\n${tail}`;
const limit = getChatLogTextLimit();
if (value.length <= limit) return value;
const head = value.slice(0, Math.floor(limit / 2));
const tail = value.slice(-Math.ceil(limit / 2));
return `${head}\n[...truncated ${value.length - limit} chars...]\n${tail}`;
}

function cloneBoundedChatLogPayload(value: unknown, depth = 0): unknown {
if (value === null || value === undefined) return value;
if (typeof value === "string") return truncateChatLogText(value);
if (typeof value !== "object") return value;
if (depth >= CHAT_LOG_MAX_DEPTH) return "[MaxDepth]";
if (depth >= getChatLogMaxDepth()) return "[MaxDepth]";

const maxTailItems = getChatLogArrayTailItems();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The call to getChatLogArrayTailItems() is performed even if the value is not an array. Since this function is called recursively for every node in the payload, moving this lookup inside the Array.isArray(value) block will avoid unnecessary environment variable lookups and parsing for non-array objects.

Suggested change
const maxTailItems = getChatLogArrayTailItems();
if (Array.isArray(value)) {
const maxTailItems = getChatLogArrayTailItems();
const retained = value.length > maxTailItems ? value.slice(-maxTailItems) : value;


if (Array.isArray(value)) {
const retained =
value.length > CHAT_LOG_ARRAY_TAIL_ITEMS ? value.slice(-CHAT_LOG_ARRAY_TAIL_ITEMS) : value;
const retained = value.length > maxTailItems ? value.slice(-maxTailItems) : value;
const cloned = retained.map((item) => cloneBoundedChatLogPayload(item, depth + 1));
if (value.length > CHAT_LOG_ARRAY_TAIL_ITEMS) {
if (value.length > maxTailItems) {
return [
{
_omniroute_truncated_array: true,
originalLength: value.length,
retainedTailItems: CHAT_LOG_ARRAY_TAIL_ITEMS,
retainedTailItems: maxTailItems,
},
...cloned,
];
Expand All @@ -222,11 +226,12 @@ function cloneBoundedChatLogPayload(value: unknown, depth = 0): unknown {

const result: Record<string, unknown> = {};
const entries = Object.entries(value as Record<string, unknown>);
for (const [key, item] of entries.slice(0, CHAT_LOG_MAX_OBJECT_KEYS)) {
const maxKeys = getChatLogMaxObjectKeys();
for (const [key, item] of maxKeys > 0 ? entries.slice(0, maxKeys) : entries) {
result[key] = cloneBoundedChatLogPayload(item, depth + 1);
}
if (entries.length > CHAT_LOG_MAX_OBJECT_KEYS) {
result._omniroute_truncated_keys = entries.length - CHAT_LOG_MAX_OBJECT_KEYS;
if (maxKeys > 0 && entries.length > maxKeys) {
result._omniroute_truncated_keys = entries.length - maxKeys;
}
return result;
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@
"express": "^5.2.1",
"fetch-socks": "^1.3.3",
"fuse.js": "^7.3.0",
"gray-matter": "^4.0.3",
"http-proxy-middleware": "^3.0.5",
"https-proxy-agent": "^9.0.0",
"ioredis": "^5.10.1",
Expand Down Expand Up @@ -180,7 +181,6 @@
"eslint": "^9.39.4",
"eslint-config-next": "16.2.6",
"glob": "^13.0.6",
"gray-matter": "^4.0.3",
"husky": "^9.1.7",
"jsdom": "^29.1.1",
"lint-staged": "^16.4.0",
Expand Down
28 changes: 25 additions & 3 deletions src/lib/cloudflaredTunnel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ let tunnelProcess: ReturnType<typeof spawn> | null = null;
let tunnelPid: number | null = null;
let installPromise: Promise<string> | null = null;
let startPromise: Promise<CloudflaredTunnelStatus> | null = null;
let stateFileQueue: Promise<void> = Promise.resolve();
const NON_ACTIONABLE_CLOUDFLARED_WARNING_PATTERNS = [
/failed to sufficiently increase receive buffer size/i,
] as const;
Expand Down Expand Up @@ -205,14 +206,35 @@ async function readStateFile(): Promise<PersistedTunnelState> {
}
}

async function writeStateFile(state: PersistedTunnelState) {
async function writeStateFileNow(state: PersistedTunnelState) {
await ensureTunnelDir();
await fs.writeFile(getStateFilePath(), JSON.stringify(state, null, 2) + "\n", "utf8");
}

async function withStateFileLock<T>(operation: () => Promise<T>): Promise<T> {
const previous = stateFileQueue;
let release!: () => void;
stateFileQueue = new Promise<void>((resolve) => {
release = resolve;
});

await previous;
try {
return await operation();
} finally {
release();
}
}
Comment on lines +214 to +227

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The withStateFileLock implementation is not re-entrant. If an operation passed to this function attempts to call another function that also acquires the same lock (such as updateStateFile or writeStateFile), it will result in a deadlock. While the current implementation of updateStateFile avoids this by calling writeStateFileNow directly, this is a fragile pattern that could lead to bugs during future refactoring. Consider adding a check or using a re-entrant lock implementation if nested calls are possible.


async function writeStateFile(state: PersistedTunnelState) {
await withStateFileLock(() => writeStateFileNow(state));
}

async function updateStateFile(patch: PersistedTunnelState) {
const current = await readStateFile();
await writeStateFile({ ...current, ...patch });
await withStateFileLock(async () => {
const current = await readStateFile();
await writeStateFileNow({ ...current, ...patch });
});
}

async function clearPidFile() {
Expand Down
29 changes: 29 additions & 0 deletions src/lib/logEnv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ function parsePositiveInt(value: string | undefined, fallback: number): number {
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}

function parseNonNegativeInt(value: string | undefined, fallback: number): number {
if (value === undefined || value === "") return fallback;
const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
}

function parseBoolean(value: string | undefined, fallback: boolean): boolean {
if (!value) return fallback;
const normalized = value.trim().toLowerCase();
Expand Down Expand Up @@ -101,3 +107,26 @@ export function getAppLogLevel(defaultLevel: string): string {
export function getAppLogFormat(defaultFormat: string): string {
return process.env.APP_LOG_FORMAT || defaultFormat;
}

// ─── Chat log truncation limits ─────────────────────────────────────────────

export function getChatLogTextLimit(): number {
return parsePositiveInt(process.env.CHAT_LOG_TEXT_LIMIT, 64 * 1024);
}

export function getChatLogArrayTailItems(): number {
return parsePositiveInt(process.env.CHAT_LOG_ARRAY_TAIL_ITEMS, 24);
}

export function getChatLogMaxDepth(): number {
return parsePositiveInt(process.env.CHAT_LOG_MAX_DEPTH, 6);
}

export function getChatLogMaxObjectKeys(): number {
return parseNonNegativeInt(process.env.CHAT_LOG_MAX_OBJECT_KEYS, 80);
}

export function isChatDebugFileEnabled(): boolean {
if (parseBoolean(process.env.CHAT_DEBUG_FILE, false)) return true;
return process.env.APP_LOG_LEVEL?.trim().toLowerCase() === "debug";
}
Comment on lines +113 to +132

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

These getters for chat log limits are called frequently within recursive functions (e.g., cloneBoundedChatLogPayload in chatCore.ts). Accessing process.env and parsing strings repeatedly can be inefficient in high-throughput scenarios. It is recommended to cache these values after the first lookup since environment variables are typically static for the duration of the process.

let _chatLogTextLimit: number | undefined;
export function getChatLogTextLimit(): number {
  if (_chatLogTextLimit === undefined) {
    _chatLogTextLimit = parsePositiveInt(process.env.CHAT_LOG_TEXT_LIMIT, 64 * 1024);
  }
  return _chatLogTextLimit;
}

let _chatLogArrayTailItems: number | undefined;
export function getChatLogArrayTailItems(): number {
  if (_chatLogArrayTailItems === undefined) {
    _chatLogArrayTailItems = parsePositiveInt(process.env.CHAT_LOG_ARRAY_TAIL_ITEMS, 24);
  }
  return _chatLogArrayTailItems;
}

let _chatLogMaxDepth: number | undefined;
export function getChatLogMaxDepth(): number {
  if (_chatLogMaxDepth === undefined) {
    _chatLogMaxDepth = parsePositiveInt(process.env.CHAT_LOG_MAX_DEPTH, 6);
  }
  return _chatLogMaxDepth;
}

let _chatLogMaxObjectKeys: number | undefined;
export function getChatLogMaxObjectKeys(): number {
  if (_chatLogMaxObjectKeys === undefined) {
    _chatLogMaxObjectKeys = parseNonNegativeInt(process.env.CHAT_LOG_MAX_OBJECT_KEYS, 80);
  }
  return _chatLogMaxObjectKeys;
}

let _isChatDebugFileEnabled: boolean | undefined;
export function isChatDebugFileEnabled(): boolean {
  if (_isChatDebugFileEnabled === undefined) {
    if (parseBoolean(process.env.CHAT_DEBUG_FILE, false)) {
      _isChatDebugFileEnabled = true;
    } else {
      _isChatDebugFileEnabled = process.env.APP_LOG_LEVEL?.trim().toLowerCase() === "debug";
    }
  }
  return _isChatDebugFileEnabled;
}

7 changes: 6 additions & 1 deletion src/lib/usage/callLogArtifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import type { RequestPipelinePayloads } from "@omniroute/open-sse/utils/requestLogger.ts";
import { resolveDataDir } from "../dataPaths";
import { getCallLogPipelineMaxSizeBytes } from "../logEnv";
import { getCallLogPipelineMaxSizeBytes, isChatDebugFileEnabled } from "../logEnv";

const isCloud = typeof globalThis.caches === "object" && globalThis.caches !== null;
const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build";
Expand Down Expand Up @@ -151,6 +151,11 @@ function serializeFinalSizeLimitFallback(artifact: CallLogArtifact, maxBytes: nu
}

function serializeArtifactForStorage(artifact: CallLogArtifact): string {
// Debug mode: write full untruncated payload
if (isChatDebugFileEnabled()) {
return JSON.stringify(artifact, null, 2);
}

const maxBytes = getArtifactMaxBytes(artifact);
const serialized = JSON.stringify(artifact, null, 2);
if (Buffer.byteLength(serialized) <= maxBytes) {
Expand Down