Skip to content

Latest commit

 

History

History
802 lines (624 loc) · 26.8 KB

File metadata and controls

802 lines (624 loc) · 26.8 KB

OpenGate Development Guide

A practical guide for developers working on OpenGate. Covers the codebase structure, development workflow, and common tasks.

Table of Contents


Prerequisites

  • Bun 1.3+
  • A Qwen account at chat.qwen.ai (for actual development/testing)

On first setup:

bun install
bun run scripts/setup.js   # interactive config wizard

The wizard creates config.json with your settings. Configuration is managed entirely via config.json (generated by bun run scripts/setup.js or the install script).


Project Structure

project-root/
├── src/
│   ├── cli.ts                   CLI entry (opengate command parser)
│   ├── cluster.ts               Multi-core cluster mode
│   ├── index.tsx                Hono server, routing, CORS, auth
│   ├── models.json              Model definitions
│   ├── middleware/
│   │   └── rateLimit.ts         Token bucket rate limiter
│   ├── routes/                  API handlers + streaming logic
│   │   ├── accounts.ts          Account CRUD API
│   │   ├── chat.ts              Chat completions dispatch
│   │   ├── chatHelpersCore.ts   Core chat response handling
│   │   ├── chatNonStreaming.ts  Non-streaming responses
│   │   ├── chatStreaming.ts     Streaming SSE logic
│   │   ├── chatStreamingHelpers.ts Streaming helper utilities
│   │   ├── cleanupHelpers.ts    Cleanup logic
│   │   ├── compressToolResult.ts Tool result compression
│   │   ├── config.ts            Config read/write API
│   │   ├── streamLoop.ts        Streaming loop with idle timeout
│   │   ├── writeHelpers.ts      Write helper utilities
│   │   └── dashboard/           Web dashboard
│   │       ├── dashboardRoutes.ts Routing hub
│   │       ├── monitor.ts       Real-time monitoring
│   │       ├── sidebar.ts       Sidebar navigation
│   │       └── public/          Static assets (JS/CSS/SVG)
│   ├── services/                Business logic
│   │   ├── accountManager.ts    Account CRUD, round-robin
│   │   ├── auth.test.ts         Auth tests
│   │   ├── auth.ts              Auth orchestration
│   │   ├── browserProfiles.ts   Browser profile management
│   │   ├── browserlessFetch.ts  Browserless TLS/HTTP2 fetch
│   │   ├── bxUaGenerator.test.ts bx-ua generator tests
│   │   ├── bxUaGenerator.ts     bx-ua token generation
│   │   ├── bxTokenExtractor.ts  bx-umidtoken extraction
│   │   ├── configService.test.ts Config service tests
│   │   ├── configService.ts     Config loader
│   │   ├── defaultSystemPrompt.ts Default system prompt
│   │   ├── fireyejsRunner.ts    Token generation infrastructure
│   │   ├── logStore.test.ts     Log store tests
│   │   ├── logStore.ts          In-memory log store + SSE
│   │   ├── loginHelpers.ts      Login helpers
│   │   ├── loginService.ts      Login orchestration
│   │   ├── modelHealth.ts       Model health tracking
│   │   ├── modelRouter.ts       Model routing & fallback
│   │   ├── monitorStore.ts      Monitoring data store
│   │   ├── networkDebug.ts      Outbound call capture
│   │   ├── playwright.ts        Browser init & management
│   │   ├── qwen.ts              Qwen API interaction
│   │   ├── qwenFileUpload.ts    File upload handling
│   │   ├── qwenLogger.ts        Qwen-specific logging
│   │   ├── qwenModels.ts        Model fetching & mapping
│   │   ├── sessionPool.ts       Session pool with autoscaling
│   │   ├── systemLogger.ts      System-wide logger
│   │   ├── tokenCache.ts        Token TTL cache
│   │   └── tokenRefresh.ts      Token refresh logic
│   ├── tests/                   Integration tests
│   │   ├── helpers.ts           Test helpers
│   │   ├── index.test.ts        Server startup test
│   │   ├── contentFilter.test.ts Content filter tests
│   │   └── largeBuffer.test.ts  Buffer overflow tests
│   ├── tools/                   Tool calling system
│   │   ├── guard.ts             Spam/abuse guard
│   │   ├── registry.ts          Tool registry
│   │   ├── schema.ts            JSON Schema validation
│   │   ├── schemaValidators.ts  Schema validation helpers
│   │   └── xmlToolParser.ts     XML tool call parsing
│   ├── types/
│   │   └── openai.ts            OpenAI-compatible types
│   └── utils/                   Shared utilities
│       ├── auth.ts              Auth utilities
│       ├── contentFilter.ts     Content filter helpers
│       ├── paths.ts             Path utilities
│       ├── retry.ts             Exponential backoff
│       ├── tagNames.ts          Centralized tag names
│       ├── thinkTagStripper.ts  Think tag stripping
│       ├── tokenEstimator.ts    Token estimation
│       ├── version.ts           Version information
│       ├── xmlStripper.ts       XML/tool call artifact removal
│       └── xmlStripper.test.ts  XML stripper tests
├── docs/                        Documentation
│   ├── ARCHITECTURE.md          System design
│   ├── API.md                   Full endpoint reference
│   ├── DEPLOYMENT.md            Production deployment
│   └── DEVELOPMENT.md           This file
├── scripts/
│   ├── setup.js                 Config generation
│   └── generate-config-example.ts  (if exists)
├── config.json                  Runtime config (gitignored)
├── install.sh                   Linux/macOS installer
├── install.ps1                  Windows installer
├── package.json
├── tsconfig.json
└── README.md

Key Directories at a Glance

Directory Purpose
src/routes/ HTTP route handlers. Each file exports Hono route definitions.
src/routes/dashboard/ Dashboard pages and components (template strings, sidebar, routing hub).
src/services/ Core business logic: auth, session pool, Qwen API transport, config, logging.
src/tools/ Tool calling system: registry, parser, guard, schema validation.
src/utils/ Shared utilities: retry, content filter, token estimator, XML stripping.
src/types/ Central TypeScript type definitions.
src/tests/ Server-level integration tests.

Development Workflow

Start Dev Server (Hot Reload)

bun dev

Uses bun --watch src/index.tsx to run TypeScript directly with hot reload. The server starts on http://localhost:26405 (configurable via PORT).

The dev command:

  1. Loads accounts from persistent storage
  2. Bootstraps Qwen auth headers (via Playwright login if needed)
  3. Starts the Hono HTTP server
  4. Opens the dashboard at /dashboard

Production Start

bun start

Runs bun src/index.tsx directly (no build step needed — Bun runs TypeScript natively).

Other Commands

bun run scripts/setup.js   # Run the interactive setup wizard
bun run src/cli.ts         # Launch the CLI (opengate)
bun src/cli.ts restart     # CLI restart
bun src/cli.ts help        # CLI help
bun cluster                # Multi-core cluster mode

Important Environment Variables

Variable Default Purpose
PORT 26405 Server port
HOST (empty) Bind address
API_KEY (empty) Bearer token for API auth
BROWSER chromium Browser engine for login (not API calls)
STREAMING_MODE auto Streaming behavior (auto, true, false)
SAVE_REQUEST_LOGS false Persist request/response logs to disk

See ConfigSchema in src/services/configService.ts for the full list of configurable keys.


Testing

Test Framework

OpenGate uses Bun's built-in test runner (bun test) with Bun's assertion library. No external test framework is needed.

Test File Naming

Tests live alongside their source files using the *.test.ts convention:

src/tools/guard.ts          # source
src/tools/guard.test.ts     # tests
src/tools/xmlToolParser.ts  # source
src/tools/xmlToolParser.test.ts # tests

Server-level integration tests live in src/tests/:

src/tests/index.test.ts      # server integration
src/tests/largeBuffer.test.ts
src/tests/contentFilter.test.ts

Running Tests

bun test

This uses Bun's built-in test runner which handles TypeScript compilation transparently.

Running Specific Tests

bun test src/tools/guard.test.ts
bun test src/utils/xmlStripper.test.ts

You can also filter by test name:

bun test --test-name-pattern="xml"

TEST_MOCK_PLAYWRIGHT

The test suite sets process.env.TEST_MOCK_PLAYWRIGHT = 'true' at the top of the test entry point. When this flag is present:

  • SessionPool.acquire() returns a mock session immediately
  • Playwright initialization becomes a no-op
  • initPlaywright(false) can be called without a real browser

This lets integration tests run without a real Qwen account or browser. The mock session ID can be customized with TEST_SESSION_ID.

// Example: setting up mock Playwright in a test
process.env.TEST_MOCK_PLAYWRIGHT = 'true';
process.env.API_KEY = '';

import { app } from '../index.tsx';
import { initPlaywright } from '../services/playwright.ts';

test('health check', async () => {
  const req = new Request('http://localhost/health');
  const res = await app.fetch(req);
  assert.strictEqual(res.status, 503);
});

Writing Tests

  • Place unit tests next to the source file (e.g. guard.test.ts next to guard.ts)
  • Place integration tests in src/tests/
  • Mock globalThis.fetch for HTTP-level tests
  • Use app.fetch() from Hono to test endpoints without starting a server
  • Use describe and it / test from bun:test
import { test, expect } from 'bun:test';

test('feature works as expected', async () => {
  const result = await someFunction();
  expect(result).toBe(expectedValue);
});

Code Structure

Routes (Hono Framework)

The server uses the Hono web framework. Routes are defined in src/routes/ and registered in src/index.tsx.

Route Registration Pattern:

// src/index.tsx
import { Hono } from 'hono';
export const app = new Hono();

// Simple inline handler
app.get('/health', (c) => {
  return c.json({ status: 'ok' });
});

// Imported route handler
app.post('/v1/chat/completions', chatCompletions);

// Routers (groups of routes)
import { accountsRouter } from './routes/accounts.ts';
app.route('/api/accounts', accountsRouter);

Router Pattern (for grouped routes):

// src/routes/accounts.ts
import { Hono } from 'hono';
export const accountsRouter = new Hono();

accountsRouter.get('/', (c) => {
  return c.json(getAccounts());
});

accountsRouter.post('/', async (c) => {
  const body = await c.req.json();
  // ...
  return c.json({ success: true }, 201);
});

accountsRouter.delete('/:email', (c) => {
  removeAccount(c.req.param('email'));
  return c.json({ success: true });
});

Middleware:

Hono middleware is registered with app.use(). The server uses:

  • cors() for cross-origin requests
  • bearerAuth() for API key auth on /v1/*
  • Custom body size limiting on /v1/chat/completions

Services Layer

Services in src/services/ encapsulate all business logic. They are plain TypeScript modules (not classes in most cases) with singleton instances.

Pattern: Export a singleton instance + its type.

// services/configService.ts
export class ConfigService {
  get<K extends keyof ConfigSchema>(key: K, defaultValue?: string): string { ... }
  set<K extends keyof ConfigSchema>(key: K, value: string): void { ... }
  getAll(): ConfigSchema { ... }
  save(): void { ... }
}
export const config = new ConfigService();

Service responsibilities:

Service Responsibility
sessionPool.ts Acquire/release sessions, manage wait queue, autoscaling
auth.ts Re-exports from accountManager, loginHelpers, tokenRefresh
accountManager.ts Account CRUD, authentication state, rate limit tracking
playwright.ts Browser lifecycle, page management, request interception
configService.ts Config loading with env > config.json > defaults priority
logStore.ts In-memory log storage with SSE push subscriptions
qwen.ts Qwen backend API calls (models, headers, settings)
modelRouter.ts Map requested model to Qwen's internal model ID

Dependency flow is strictly one-directional:

routes → services → utils
                ↓
           playwright, auth, sessionPool

Routes import services; services import utils. Services should not import routes.

Tool System

The tool system in src/tools/ handles parsing, validating, and executing tool calls in the OpenAI-compatible format. It uses XML-based tool call injection (not JSON-in-text) — tool calls are embedded as <tool_call> XML tags in the Qwen response.

Core Components:

File Role
xmlToolParser.ts Parse <tool_call> XML tags from Qwen response text
registry.ts ToolRegistry class: register(), get(), execute(), toOpenAITools()
guard.ts Validate tool call structure, spam detection
schema.ts JSON Schema validation against registered parameters
schemaValidators.ts Built-in validators for common types

Tool Execution Flow:

  1. Qwen response text is parsed for <tool_call> XML tags by xmlToolParser.ts
  2. Extracted tool calls are validated by guard.ts (structural validation)
  3. Each call is looked up in the registry and validated against its registered JSON Schema
  4. The handler function is invoked via registry.execute()
  5. Results are serialized and returned as tool messages

Registering a tool programmatically:

import { registry } from '../tools/registry.ts';

registry.register(
  'get_weather',              // tool name (must match what the LLM emits)
  'Get weather for a city',  // description
  {                          // JSON Schema parameters
    type: 'object',
    properties: {
      city: { type: 'string', description: 'City name' },
    },
    required: ['city'],
  },
  async (args, context) => {  // handler
    const weather = await fetchWeather(args.city);
    return JSON.stringify(weather);
  }
);

Tools are registered at startup. The registry converts them to OpenAI-compatible format via toOpenAITools().

Utilities

Shared utilities live in src/utils/. They are stateless, pure functions or logger factories.

Utility Purpose
retry.ts withRetry() with exponential backoff, jitter, circuit breaker
contentFilter.ts Strip tool artifacts, XML leaks, streaming fragments
tokenEstimator.ts Count tokens, check context window limits
xmlStripper.ts Remove XML/tool call artifacts from output
xmlStripper.test.ts Tests for XML stripping
thinkTagStripper.ts Handle <think> blocks
tagNames.ts Centralized tag name constants
auth.ts Auth utility functions
paths.ts Path resolution utilities
version.ts Version information

Session Pool Management

The session pool (src/services/sessionPool.ts) manages Qwen account sessions across Qwen accounts.

Key concepts:

  • PoolEntry: A chat session bound to an account, identified by chatId and parentId.
  • Acquire: Get a session. Routes to a specific email if provided, otherwise picks the best account.
  • Release: Return a session to the pool or destroy it.
  • Queue: If all sessions are busy, callers wait in a FIFO queue (max 10, timeout 60s).
  • Autoscaling: Sessions are created on demand up to a configurable limit.
// Acquire a session
const entry = await sessionPool.acquire('user@example.com');
// entry.chatId, entry.parentId, entry.cachedHeaders

// Release when done
await sessionPool.release(entry.chatId);

The pool uses incrementInFlight / decrementInFlight from the account manager to track active usage per account. This prevents routing new requests to saturated accounts.


Adding Features

Add a New API Endpoint

  1. Create the route handler in src/routes/:
// src/routes/hello.ts
import { Hono } from 'hono';

export const helloRouter = new Hono();

helloRouter.get('/', (c) => {
  return c.json({ message: 'Hello from the new endpoint!' });
});

helloRouter.post('/echo', async (c) => {
  const body = await c.req.json();
  return c.json({ echo: body });
});
  1. Register it in src/index.tsx:
import { helloRouter } from './routes/hello.ts';

// With auth protection
app.use('/api/hello/*', async (c, next) => {
  const apiKey = config.get('API_KEY');
  if (!apiKey) return await next();
  return bearerAuth({ token: apiKey })(c, next);
});
app.route('/api/hello', helloRouter);
  1. Add business logic to a service if the endpoint does meaningful work beyond CRUD.

  2. Add tests:

// src/routes/hello.test.ts
import { test, expect } from 'bun:test';
import { helloRouter } from './hello.ts';

test('GET /api/hello returns greeting', async () => {
  const req = new Request('http://localhost/');
  const res = await helloRouter.fetch(req);
  expect(res.status).toBe(200);
  const body = await res.json();
  expect(body.message).toBe('Hello from the new endpoint!');
});

Add a Dashboard Page

Dashboard pages are server-rendered HTML. The dashboard uses a routing hub (dashboardRoutes.ts) and a shared sidebar component (sidebar.ts), so you don't need to manually edit every template's nav.

  1. Create the page template in src/routes/dashboard/:
// src/routes/dashboard/analytics.ts
export const analyticsHtml = `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>OpenGate — Analytics</title>
  <link rel="stylesheet" href="/dashboard/public/global.css">
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono&family=Montserrat:wght@400;500;600;700&family=Poppins:wght@400;500;600;700;800&display=swap" rel="stylesheet">
  <style>
    /* Page-specific styles */
  </style>
</head>
<body>
  <div class="dashboard-layout">
    <!-- Sidebar is injected by the layout wrapper -->
    <main class="main-content">
      <h1>Analytics</h1>
      <!-- Your content -->
    </main>
  </div>
  <script>
    // Client-side logic
  </script>
</body>
</html>`;
  1. Register the route in src/routes/dashboard/dashboardRoutes.ts:
import { analyticsHtml } from './analytics.ts';

// Add a new route entry
dashboardRouter.get('/analytics', serveHtml(analyticsHtml));
  1. Add a sidebar entry in src/routes/dashboard/sidebar.ts — this is the shared navigation component, so the new link appears on all dashboard pages automatically.

Add a New Tool

  1. Define the tool using the registry (can be done anywhere that runs at startup):
// src/tools/myTool.ts
import { registry } from './registry.ts';

registry.register(
  'my_custom_tool',
  'Description of what this tool does',
  {
    type: 'object',
    properties: {
      input1: { type: 'string', description: 'First input' },
      input2: { type: 'number', description: 'Second input' },
    },
    required: ['input1'],
  },
  async (args, context) => {
    // args.input1, args.input2
    // context has request metadata
    const result = await doSomething(args.input1, args.input2);
    return JSON.stringify(result);
  }
);
  1. Import the module in src/index.tsx (or wherever it is needed) so the registration runs:
// This import ensures the tool is registered at startup
import './tools/myTool.ts';
  1. The tool is now available. When a request includes tools in the body, Qwen can call my_custom_tool and the system will:

    • Parse the call from the response
    • Validate arguments against the schema
    • Execute the handler
    • Return the result to the client
  2. Add tests:

// src/tools/myTool.test.ts
import { test, expect } from 'bun:test';
import { registry } from './registry.ts';

test('my_custom_tool does what it should', async () => {
  const result = await registry.execute('my_custom_tool', { input1: 'test' }, {} as any);
  expect(result).toBeTruthy();
  // ... more assertions
});

Code Style

TypeScript Conventions

  • Module system: ES modules ("type": "module" in package.json). Use import / export, never require.
  • Import extensions: Use .ts extension in imports (Hono + tsx resolve these).
  • Strict mode: strict: true in tsconfig. No as any casts (disabled only in rare justified cases). No // @ts-ignore.
  • Naming:
    • camelCase for variables, functions, methods
    • PascalCase for classes, types, interfaces
    • UPPER_CASE for constants
    • Types use PascalCase and are defined in src/types/openai.ts
  • Exports: Prefer named exports over default exports.
  • Null checks: Use ?? for nullish coalescing, ?. for optional chaining.
// Good
const value = config.get('KEY') ?? 'default';
const name = obj?.nested?.name ?? 'unknown';

// Good — use existing types
import type { OpenAIRequest } from '../types/openai.ts';

// Avoid
const value = config.get('KEY') || 'default'; // catches empty string too

Error Handling Patterns

Route handlers use try/catch with structured error responses:

try {
  const body = await c.req.json();
  // ...
  return c.json({ result }, 200);
} catch (err: any) {
  logStore.addError(logId, err.message);
  const status = err.upstreamStatus || 500;
  return c.json({ error: { message: err.message } }, status);
}

Error types follow the OpenAI error format:

{
  error: {
    message: "Human-readable message",
    type: "invalid_request_error",  // optional
    param: "messages",              // optional
    code: "context_window_exceeded" // optional
  }
}

Custom error classes are defined for distinct failure modes:

export class SessionPoolQueueFullError extends Error { ... }
export class SessionPoolWaitTimeoutError extends Error { ... }
export class SchemaValidationError extends Error { ... }
export class NonRetryableError extends Error { ... }
export class CircuitOpenError extends Error { ... }

Service functions throw typed errors rather than returning error objects. The route layer catches and translates them to HTTP responses.

Always handle async errors. Use .catch() on fire-and-forget promises:

disableNativeTools().catch(err =>
  console.warn('[Startup] disableNativeTools failed:', err.message)
);

Never swallow errors silently. If an error is intentionally ignored, leave a comment explaining why:

try { controller.close(); } catch {
  // intentional: stream close failure during abort is non-blocking, connection already lost
}

Logging

The project uses two logger services instead of a generic createLogger utility:

  • src/services/systemLogger.tsSystemLogger class for server-level diagnostics (startup, errors, warnings). Accessed via logStore.systemLog().
  • src/services/qwenLogger.tsQwenLogger for Qwen API interaction logs (request/response capture).

System Logging

import { logStore } from '../services/logStore.ts';

// System-level logging
logStore.systemLog('info', 'Server started on port 26405');
logStore.systemLog('error', 'Failed to connect', err);
logStore.systemLog('warn', 'Rate limit approaching', { account: email });

Qwen API Logging

import { qwenLogger } from '../services/qwenLogger.ts';

qwenLogger.logRequest(chatId, body);
qwenLogger.logResponse(chatId, response);

When to use what

  • logStore.systemLog('info', ...) — lifecycle events, request start/completion, normal operations
  • logStore.systemLog('warn', ...) — recoverable issues, rate limits, retries, degraded states
  • logStore.systemLog('error', ...) — unrecoverable failures, caught exceptions, upstream errors
  • qwenLogger — Qwen-specific API interaction logging (requests, responses, timing)

Important

Do NOT use console.log / console.error directly for structured diagnostics. Console logging is reserved for CLI output only. Always use the appropriate logger service.


Configuration

The configuration system (src/services/configService.ts) resolves values in priority order:

  1. Environment variables (highest)
  2. config.json file (persistent runtime config)
  3. Default values (hardcoded in ConfigService)
// Reading config — env takes precedence, then config.json, then default
const port = config.get('PORT');
const apiKey = config.get('API_KEY', 'fallback');

// Writing config — writes to config.json only
config.set('SAVE_REQUEST_LOGS', 'true');
config.save();

The ConfigSchema interface in configService.ts defines all 23+ known keys. Unknown keys in config.json are silently ignored.


Verification Checklist

Before submitting changes, verify:

  • bun test passes (all tests green)
  • No TypeScript errors (bun src/index.tsx type-checks without issues)
  • LSP diagnostics show no errors in changed files
  • New feature has test coverage
  • Error paths are handled (not just the happy path)
  • Logging uses the appropriate logger service, not console.log
  • New configuration keys are added to ConfigSchema in configService.ts
  • No as any casts or // @ts-ignore comments
  • No hardcoded secrets or credentials

For architecture overview, see ARCHITECTURE.md. For API reference, see API.md. For deployment, see DEPLOYMENT.md.