Skip to content

Latest commit

 

History

History
633 lines (509 loc) · 18.5 KB

File metadata and controls

633 lines (509 loc) · 18.5 KB

AGENTS.md

This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.

Project Overview

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.

Build Commands

# 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 preview

Architecture

src/
├── 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

Key Concepts

Provider Adapters

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:

  1. Create config in src/main/providers/builtin/<provider>.ts
  2. Create OAuth adapter in src/main/oauth/adapters/<provider>.ts
  3. Create proxy adapter in src/main/proxy/adapters/<provider>.ts
  4. Create stream handler in src/main/proxy/adapters/<provider>-stream.ts
  5. Register in src/main/providers/builtin/index.ts and src/main/proxy/adapters/index.ts

IPC Communication

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).

Session Management

Multi-turn conversations are managed by sessionManager.ts:

  • single mode: Session deleted after each chat
  • multi mode: Session persists with parent message IDs for context

Tool Prompt Injection

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.

Session Management Flow

  1. Client sends request with sessionId
  2. sessionManager.ts retrieves session or creates new one
  3. For multi mode: parentMessageId is used to fetch conversation history
  4. Adapter creates/uses provider-specific session
  5. Response is returned with new parentMessageId for context continuation

Data Storage

Application data is stored in ~/.chat2api/:

  • config.json - Application configuration
  • providers.json - Provider settings
  • accounts.json - Account credentials (encrypted)
  • logs/ - Request logs

Tech Stack

Component Technology
Framework Electron 33+
Frontend React 18 + TypeScript
Styling Tailwind CSS
State Zustand
Build Vite + electron-vite
Server Koa

Coding Guidelines

Immutability (CRITICAL)

ALWAYS create new objects, NEVER mutate existing ones. Use update functions that return new copies.

Error Handling

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

Input Validation

Validate at system boundaries (user input, external APIs). Use schema-based validation where available.

Security

  • 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

macOS Development Note

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

Overview

Adding a new provider requires modifications across 4 layers: Provider Config, OAuth Authentication, Proxy Adapter, and UI. The following guide covers all necessary steps.

Core File Modification Checklist

1. Provider Config Layer (Required)

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

2. OAuth Authentication Layer (Required)

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)

3. Proxy Adapter Layer (Required)

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

4. UI Layer (Required)

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

Step-by-Step Implementation

Step 1: Provider Configuration

// 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

Step 2: Register Provider

// 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.

Step 3: OAuth Adapter

// 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

Step 4: Register OAuth Adapter

// 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']
  }
}

Step 5: Proxy Adapter

// 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 }

Step 6: Stream Handler

// 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`
  }
}

Step 7: Register Proxy Adapter

// src/main/proxy/adapters/index.ts
export { ProviderAdapter, ProviderStreamHandler, providerAdapter } from './provider'

Step 8: Add Forwarder Method

// 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
}

Step 9: Add UI Translations

// 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"
    }
  }
}

Step 10: Add Icon Mapping

// src/renderer/src/components/providers/ProviderCard.tsx
import providerIcon from '@/assets/providers/provider.svg'

const providerIcons: Record<string, string> = {
  // ...existing
  'provider-id': providerIcon,
}

AuthType Reference

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

Web Search Mode Implementation

Three ways to enable web search:

  1. Model Mapping: Auto-enable via model name
const modelLower = request.model.toLowerCase()
if (modelLower.includes('search')) {
  searchEnabled = true
}
  1. Custom Parameter: Via web_search parameter
if (request.web_search) {
  searchEnabled = true
}
  1. Custom Header: Via request header
if (headers['X-Enable-Search']) {
  searchEnabled = true
}

Thinking Mode Implementation

Three ways to enable thinking mode:

  1. Model Mapping: Auto-enable via model name
const modelLower = request.model.toLowerCase()
if (modelLower.includes('r1') || modelLower.includes('think')) {
  thinkingEnabled = true
}
  1. Custom Parameter: Via reasoning_effort parameter
if (request.reasoning_effort) {
  thinkingEnabled = true
}
  1. Custom Header: Via request header
if (headers['X-Enable-Thinking']) {
  thinkingEnabled = true
}

Thinking Content Handling

In stream handler, output thinking content to reasoning_content field:

if (path === 'thinking') {
  delta.reasoning_content = processedContent
} else {
  delta.content = processedContent
}

Model List Synchronization

CRITICAL: Model list must be defined in TWO locations:

  1. src/main/providers/builtin/<provider>.ts - supportedModels array
  2. src/main/store/types.ts - BUILTIN_PROVIDERS array

Both must be identical, otherwise configuration won't take effect.

Testing Checklist

  • 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

Updating Provider Configuration

When updating provider configuration (e.g., model list, description, help text), you MUST update both locations:

  1. src/main/providers/builtin/<provider>.ts - Provider config module
  2. src/main/store/types.ts - BUILTIN_PROVIDERS array

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.