This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
Chat2API Manager is an Electron desktop application that provides an OpenAI-compatible API proxy for multiple AI service providers (DeepSeek, GLM, Kimi, MiniMax, Qwen, Z.ai, Perplexity). It enables using any OpenAI-compatible client with these providers across macOS, Windows, and Linux.
# Development
npm run dev # Start dev server (macOS/Linux)
npm run dev:win # Start dev server (Windows)
# Build
npm run build # Build the application
npm run build:mac # Build for macOS (dmg, zip)
npm run build:win # Build for Windows (nsis)
npm run build:linux # Build for Linux (AppImage, deb)
npm run build:all # Build for all platforms
# Preview production build
npm run previewsrc/
├── main/ # Electron main process
│ ├── index.ts # App entry point
│ ├── ipc/ # IPC handlers (main ↔ renderer communication)
│ ├── proxy/ # Proxy server (Koa)
│ │ ├── server.ts # HTTP server with middleware
│ │ ├── forwarder.ts # Request forwarding logic & auth
│ │ ├── adapters/ # Provider-specific adapters
│ │ ├── routes.ts # Proxy routes registration
│ │ ├── sessionManager.ts # Multi-turn conversation management
│ │ └── services/ # Prompt injection & prompt generation
│ ├── oauth/ # OAuth authentication
│ │ ├── manager.ts # OAuth flow orchestration
│ │ ├── inAppLogin.ts # In-app browser login with token auto-extraction
│ │ └── adapters/ # Provider-specific OAuth adapters
│ ├── providers/ # Provider configurations
│ │ ├── builtin/ # Built-in provider configs (one file per provider)
│ │ └── custom.ts # Custom provider support
│ ├── store/ # Persistent storage (electron-store)
│ │ ├── store.ts # Main store manager with IPC bridge
│ │ ├── types.ts # Type definitions and default values
│ │ └── config.ts # Configuration management
│ └── tray/ # System tray integration
├── preload/ # Context bridge (IPC API exposure)
├── renderer/ # React frontend
│ ├── components/ # UI components
│ ├── pages/ # Page components
│ ├── stores/ # Zustand state management
│ └── i18n/ # Internationalization (en-US, zh-CN)
└── shared/ # Shared types between main and renderer
Each AI provider has a dedicated adapter in src/main/proxy/adapters/ that handles:
- Message format conversion (OpenAI format → provider-specific format)
- Authentication header construction
- Stream response parsing
- Multi-turn conversation context
To add a new provider:
- Create config in
src/main/providers/builtin/<provider>.ts - Create OAuth adapter in
src/main/oauth/adapters/<provider>.ts - Create proxy adapter in
src/main/proxy/adapters/<provider>.ts - Create stream handler in
src/main/proxy/adapters/<provider>-stream.ts - Register in
src/main/providers/builtin/index.tsandsrc/main/proxy/adapters/index.ts
All main-renderer communication uses IPC channels defined in src/main/ipc/channels.ts. The naming convention is domain:action (e.g., proxy:start, accounts:add).
Multi-turn conversations are managed by sessionManager.ts:
singlemode: Session deleted after each chatmultimode: Session persists with parent message IDs for context
For models without native function calling, prompts are injected via promptInjectionService.ts. This enables function calling compatibility with clients like Cherry Studio and Kilo Code.
- Client sends request with
sessionId sessionManager.tsretrieves session or creates new one- For
multimode: parentMessageId is used to fetch conversation history - Adapter creates/uses provider-specific session
- Response is returned with new parentMessageId for context continuation
Application data is stored in ~/.chat2api/:
config.json- Application configurationproviders.json- Provider settingsaccounts.json- Account credentials (encrypted)logs/- Request logs
| Component | Technology |
|---|---|
| Framework | Electron 33+ |
| Frontend | React 18 + TypeScript |
| Styling | Tailwind CSS |
| State | Zustand |
| Build | Vite + electron-vite |
| Server | Koa |
ALWAYS create new objects, NEVER mutate existing ones. Use update functions that return new copies.
Handle errors comprehensively:
- Validate all user input before processing
- Provide user-friendly error messages in UI-facing code
- Log detailed error context on the server side
- Never silently swallow errors
Validate at system boundaries (user input, external APIs). Use schema-based validation where available.
- Validate all API keys before use
- Sanitize all user inputs
- Never trust external data (API responses, user input, file content)
- Rotate any exposed secrets immediately
A workaround is applied for V8 JIT compiler crash on macOS ARM64 (Electron 33 bug):
app.commandLine.appendSwitch('js-flags', '--jitless --no-opt')This trades some performance for stability.
Adding a new provider requires modifications across 4 layers: Provider Config, OAuth Authentication, Proxy Adapter, and UI. The following guide covers all necessary steps.
| File | Purpose |
|---|---|
src/main/providers/builtin/<provider>.ts |
Provider configuration definition |
src/main/providers/builtin/index.ts |
Register provider in builtinProviders array |
src/main/store/types.ts |
Sync to BUILTIN_PROVIDERS array |
| File | Purpose |
|---|---|
src/main/oauth/adapters/<provider>.ts |
OAuth adapter implementation |
src/main/oauth/adapters/index.ts |
Register in createAdapter() and getSupportedAuthMethods() |
src/main/oauth/types.ts |
Add to MANUAL_TOKEN_CONFIGS (optional) |
| File | Purpose |
|---|---|
src/main/proxy/adapters/<provider>.ts |
Proxy adapter implementation |
src/main/proxy/adapters/<provider>-stream.ts |
Stream handler implementation |
src/main/proxy/adapters/index.ts |
Export adapter |
src/main/proxy/forwarder.ts |
Add forward<Provider>() method |
| File | Purpose |
|---|---|
src/renderer/src/i18n/locales/zh-CN.json |
Chinese translations |
src/renderer/src/i18n/locales/en-US.json |
English translations |
src/renderer/src/components/providers/ProviderCard.tsx |
Add icon mapping |
src/assets/providers/<provider>.svg |
Provider icon file |
// src/main/providers/builtin/<provider>.ts
import type { BuiltinProviderConfig } from '../../store/types'
export const providerConfig: BuiltinProviderConfig = {
id: 'provider-id',
name: 'Provider Name',
type: 'builtin',
authType: 'userToken', // See AuthType section below
apiEndpoint: 'https://api.example.com',
chatPath: '/chat/completions',
headers: {
'Content-Type': 'application/json',
'Accept': '*/*',
'Origin': 'https://example.com',
'Referer': 'https://example.com/',
},
enabled: true,
description: 'Provider description',
supportedModels: ['Model-1', 'Model-2'],
modelMappings: {
'Model-1': 'model-1-id',
'Model-2': 'model-2-id',
},
credentialFields: [
{
name: 'token',
label: 'Token',
type: 'password',
required: true,
placeholder: 'Enter token',
helpText: 'How to get token',
},
],
tokenCheckEndpoint: '/api/user', // Optional
tokenCheckMethod: 'GET', // Optional
}
export default providerConfig// src/main/providers/builtin/index.ts
import providerConfig from './provider'
export const builtinProviders: BuiltinProviderConfig[] = [
// ...existing
providerConfig,
]
export const builtinProviderMap: Record<string, BuiltinProviderConfig> = {
// ...existing
'provider-id': providerConfig,
}
export { providerConfig }CRITICAL: Must also update src/main/store/types.ts BUILTIN_PROVIDERS array with identical configuration.
// src/main/oauth/adapters/<provider>.ts
import axios from 'axios'
import { BaseOAuthAdapter } from './base'
import { OAuthResult, OAuthOptions, TokenValidationResult, AdapterConfig } from '../types'
const API_BASE = 'https://api.example.com'
export class ProviderAdapter extends BaseOAuthAdapter {
constructor(config: AdapterConfig) {
super({
...config,
providerType: 'provider-id',
authMethods: ['manual'],
loginUrl: API_BASE,
apiUrl: API_BASE,
})
}
async startLogin(options: OAuthOptions): Promise<OAuthResult> {
await shell.openExternal(API_BASE)
return {
success: false,
providerId: options.providerId,
error: 'Please log in via browser and enter Token manually',
}
}
async validateToken(credentials: Record<string, string>): Promise<TokenValidationResult> {
const token = credentials.token
if (!token) return { valid: false, error: 'Token cannot be empty' }
try {
const response = await axios.get(`${API_BASE}/api/user`, {
headers: { Authorization: `Bearer ${token}` },
timeout: 15000,
validateStatus: () => true,
})
if (response.status !== 200) {
return { valid: false, error: 'Token is invalid or expired' }
}
return {
valid: true,
tokenType: 'access',
accountInfo: {
userId: response.data.id,
email: response.data.email,
name: response.data.name,
},
}
} catch (error) {
return { valid: false, error: error instanceof Error ? error.message : 'Validation failed' }
}
}
async refreshToken(credentials: Record<string, string>) {
return null // Optional
}
}
export default ProviderAdapter// src/main/oauth/adapters/index.ts
export { ProviderAdapter } from './provider'
export function createAdapter(providerType: ProviderType, config: AdapterConfig): BaseOAuthAdapter {
switch (providerType) {
// ...existing
case 'provider-id':
return new ProviderAdapter(config)
default:
throw new Error(`Unsupported provider type: ${providerType}`)
}
}
export function getSupportedAuthMethods(providerType: ProviderType): string[] {
switch (providerType) {
// ...existing
case 'provider-id':
return ['manual']
default:
return ['manual']
}
}// src/main/proxy/adapters/<provider>.ts
import axios, { AxiosResponse } from 'axios'
import { Account, Provider } from '../../store/types'
const API_BASE = 'https://api.example.com'
export class ProviderAdapter {
private provider: Provider
private account: Account
private token: string
constructor(provider: Provider, account: Account) {
this.provider = provider
this.account = account
this.token = account.credentials.token || ''
}
async chatCompletion(request: ChatCompletionRequest): Promise<{
response: AxiosResponse
sessionId: string
}> {
// 1. Get/refresh token
// 2. Build request
// 3. Send request
// 4. Return response
}
async deleteSession(sessionId: string): Promise<boolean> {
return true
}
static isProviderProvider(provider: Provider): boolean {
return provider.id === 'provider-id' || provider.apiEndpoint.includes('example.com')
}
}
export const providerAdapter = { ProviderAdapter }// src/main/proxy/adapters/<provider>-stream.ts
import { PassThrough } from 'stream'
export class ProviderStreamHandler {
private model: string
private sessionId: string
private isFirstChunk: boolean = true
private created: number
constructor(model: string, sessionId: string, onEnd?: () => void) {
this.model = model
this.sessionId = sessionId
this.created = Math.floor(Date.now() / 1000)
}
async handleStream(stream: NodeJS.ReadableStream): Promise<NodeJS.ReadableStream> {
const transStream = new PassThrough()
stream.on('data', (chunk: Buffer) => {
// Parse SSE data
// Convert to OpenAI format
// Write to transStream
})
stream.on('end', () => {
transStream.write('data: [DONE]\n\n')
transStream.end()
})
return transStream
}
async handleNonStream(stream: NodeJS.ReadableStream): Promise<any> {
// Collect all data
// Return OpenAI format response
}
private createChunk(delta: any, finishReason?: string): string {
return `data: ${JSON.stringify({
id: this.sessionId,
model: this.model,
object: 'chat.completion.chunk',
choices: [{ index: 0, delta, finish_reason: finishReason || null }],
created: this.created,
})}\n\n`
}
}// src/main/proxy/adapters/index.ts
export { ProviderAdapter, ProviderStreamHandler, providerAdapter } from './provider'// src/main/proxy/forwarder.ts
import { ProviderAdapter } from './adapters/provider'
import { ProviderStreamHandler } from './adapters/provider-stream'
// In doForward method, add check:
if (ProviderAdapter.isProviderProvider(provider)) {
return this.forwardProvider(request, account, provider, actualModel, startTime, sessionContext)
}
// Add forward method:
private async forwardProvider(
request: ChatCompletionRequest,
account: Account,
provider: Provider,
actualModel: string,
startTime: number,
sessionContext: SessionContext
): Promise<ForwardResult> {
// Implementation
}// src/renderer/src/i18n/locales/zh-CN.json
{
"provider-id": {
"name": "供应商名称",
"description": "供应商描述",
"token": "Token",
"tokenPlaceholder": "请输入 Token",
"tokenHelp": "从网页版获取 Token",
"models": {
"Model-1": "模型 1 描述"
}
}
}// src/renderer/src/i18n/locales/en-US.json
{
"provider-id": {
"name": "Provider Name",
"description": "Provider description",
"token": "Token",
"tokenPlaceholder": "Enter token",
"tokenHelp": "Get token from web version",
"models": {
"Model-1": "Model 1 description"
}
}
}// src/renderer/src/components/providers/ProviderCard.tsx
import providerIcon from '@/assets/providers/provider.svg'
const providerIcons: Record<string, string> = {
// ...existing
'provider-id': providerIcon,
}| Type | Description | Providers | Credential Field |
|---|---|---|---|
userToken |
User Token | DeepSeek | token |
jwt |
JWT Token | Kimi, MiniMax, Qwen AI, Z.ai | token |
refresh_token |
Refresh Token | GLM | refresh_token |
cookie |
Cookie Auth | Perplexity | sessionToken |
tongyi_sso_ticket |
SSO Ticket | Qwen | ticket |
token |
Generic Token | Z.ai | token |
Three ways to enable web search:
- Model Mapping: Auto-enable via model name
const modelLower = request.model.toLowerCase()
if (modelLower.includes('search')) {
searchEnabled = true
}- Custom Parameter: Via
web_searchparameter
if (request.web_search) {
searchEnabled = true
}- Custom Header: Via request header
if (headers['X-Enable-Search']) {
searchEnabled = true
}Three ways to enable thinking mode:
- Model Mapping: Auto-enable via model name
const modelLower = request.model.toLowerCase()
if (modelLower.includes('r1') || modelLower.includes('think')) {
thinkingEnabled = true
}- Custom Parameter: Via
reasoning_effortparameter
if (request.reasoning_effort) {
thinkingEnabled = true
}- Custom Header: Via request header
if (headers['X-Enable-Thinking']) {
thinkingEnabled = true
}In stream handler, output thinking content to reasoning_content field:
if (path === 'thinking') {
delta.reasoning_content = processedContent
} else {
delta.content = processedContent
}CRITICAL: Model list must be defined in TWO locations:
src/main/providers/builtin/<provider>.ts-supportedModelsarraysrc/main/store/types.ts-BUILTIN_PROVIDERSarray
Both must be identical, otherwise configuration won't take effect.
- Provider displays correctly
- Account can be added
- Account validation works
- Streaming chat works
- Non-streaming chat works
- Web search mode works
- Thinking mode works
- Model mapping works
- Multi-turn conversation works
- Session deletion works
When updating provider configuration (e.g., model list, description, help text), you MUST update both locations:
src/main/providers/builtin/<provider>.ts- Provider config modulesrc/main/store/types.ts-BUILTIN_PROVIDERSarray
The initializeDefaultProviders() method in store.ts syncs configuration from BUILTIN_PROVIDERS to persistent storage on app startup. If only one location is updated, the changes will not be reflected in the UI.
Example: When updating Z.ai model list:
// 1. src/main/providers/builtin/zai.ts
supportedModels: ['GLM-5-Turbo', 'GLM-5', 'GLM-4.7', ...]
// 2. src/main/store/types.ts (BUILTIN_PROVIDERS array)
supportedModels: ['GLM-5-Turbo', 'GLM-5', 'GLM-4.7', ...]Important: Users must restart the app after configuration updates to see the changes.