A practical guide for developers working on OpenGate. Covers the codebase structure, development workflow, and common tasks.
- Prerequisites
- Project Structure
- Development Workflow
- Testing
- Code Structure
- Adding Features
- Code Style
- Configuration
- Verification Checklist
- 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 wizardThe 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-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
| 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. |
bun devUses 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:
- Loads accounts from persistent storage
- Bootstraps Qwen auth headers (via Playwright login if needed)
- Starts the Hono HTTP server
- Opens the dashboard at
/dashboard
bun startRuns bun src/index.tsx directly (no build step needed — Bun runs TypeScript natively).
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| 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.
OpenGate uses Bun's built-in test runner (bun test) with Bun's assertion library. No external test framework is needed.
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
bun testThis uses Bun's built-in test runner which handles TypeScript compilation transparently.
bun test src/tools/guard.test.ts
bun test src/utils/xmlStripper.test.tsYou can also filter by test name:
bun test --test-name-pattern="xml"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);
});- Place unit tests next to the source file (e.g.
guard.test.tsnext toguard.ts) - Place integration tests in
src/tests/ - Mock
globalThis.fetchfor HTTP-level tests - Use
app.fetch()from Hono to test endpoints without starting a server - Use
describeandit/testfrombun:test
import { test, expect } from 'bun:test';
test('feature works as expected', async () => {
const result = await someFunction();
expect(result).toBe(expectedValue);
});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 requestsbearerAuth()for API key auth on/v1/*- Custom body size limiting on
/v1/chat/completions
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.
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:
- Qwen response text is parsed for
<tool_call>XML tags byxmlToolParser.ts - Extracted tool calls are validated by
guard.ts(structural validation) - Each call is looked up in the
registryand validated against its registered JSON Schema - The handler function is invoked via
registry.execute() - 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().
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 |
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
chatIdandparentId. - 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.
- 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 });
});- 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);-
Add business logic to a service if the endpoint does meaningful work beyond CRUD.
-
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!');
});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.
- 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>`;- Register the route in
src/routes/dashboard/dashboardRoutes.ts:
import { analyticsHtml } from './analytics.ts';
// Add a new route entry
dashboardRouter.get('/analytics', serveHtml(analyticsHtml));- 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.
- 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);
}
);- 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';-
The tool is now available. When a request includes
toolsin the body, Qwen can callmy_custom_tooland the system will:- Parse the call from the response
- Validate arguments against the schema
- Execute the handler
- Return the result to the client
-
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
});- Module system: ES modules (
"type": "module"in package.json). Useimport/export, neverrequire. - Import extensions: Use
.tsextension in imports (Hono + tsx resolve these). - Strict mode:
strict: truein tsconfig. Noas anycasts (disabled only in rare justified cases). No// @ts-ignore. - Naming:
camelCasefor variables, functions, methodsPascalCasefor classes, types, interfacesUPPER_CASEfor constants- Types use
PascalCaseand are defined insrc/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 tooRoute 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
}The project uses two logger services instead of a generic createLogger utility:
src/services/systemLogger.ts—SystemLoggerclass for server-level diagnostics (startup, errors, warnings). Accessed vialogStore.systemLog().src/services/qwenLogger.ts—QwenLoggerfor Qwen API interaction logs (request/response capture).
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 });import { qwenLogger } from '../services/qwenLogger.ts';
qwenLogger.logRequest(chatId, body);
qwenLogger.logResponse(chatId, response);logStore.systemLog('info', ...)— lifecycle events, request start/completion, normal operationslogStore.systemLog('warn', ...)— recoverable issues, rate limits, retries, degraded stateslogStore.systemLog('error', ...)— unrecoverable failures, caught exceptions, upstream errorsqwenLogger— Qwen-specific API interaction logging (requests, responses, timing)
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.
The configuration system (src/services/configService.ts) resolves values in priority order:
- Environment variables (highest)
- config.json file (persistent runtime config)
- 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.
Before submitting changes, verify:
-
bun testpasses (all tests green) - No TypeScript errors (
bun src/index.tsxtype-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
ConfigSchemainconfigService.ts - No
as anycasts or// @ts-ignorecomments - No hardcoded secrets or credentials
For architecture overview, see ARCHITECTURE.md. For API reference, see API.md. For deployment, see DEPLOYMENT.md.