# Tích hợp OpenAI Codex, Claude Code, kiểm tra quota và fallback AI

Tài liệu này mô tả kiến trúc đang dùng trong project và cách tái sử dụng cho project khác. Mục tiêu là kết nối nhiều tài khoản AI bằng OAuth PKCE, theo dõi quota, chọn provider mặc định và tự động chuyển sang tài khoản/provider dự phòng khi request lỗi.

## 1. Phạm vi và nguyên tắc

- Audio được chuyển thành transcript bởi Whisper server riêng. OpenAI/Claude chỉ xử lý bước tóm tắt hoặc các tác vụ văn bản.
- Không lưu API key dạng plaintext. OAuth access token, refresh token và ID token phải được mã hóa ở tầng model/database.
- Một lớp điều phối duy nhất (`AiResponseService`) quyết định provider và connection; các service provider chỉ biết cách gọi API của provider đó.
- Fallback chỉ xảy ra với lỗi có khả năng tạm thời hoặc lỗi xác thực/quota. Lỗi dữ liệu đầu vào nên trả thẳng để tránh tạo nhiều request vô ích.
- Khi tóm tắt thất bại sau toàn bộ fallback, giữ nguyên transcript để command/job có thể chạy lại.

## 2. Kiến trúc tổng thể

```text
Webhook cuộc gọi
    -> lưu recording_url
    -> queue transcribe
    -> Whisper server /inference
    -> transcript_text
    -> AiResponseService
         -> provider mặc định
              -> account 1 (round-robin)
              -> account 2
         -> provider dự phòng
              -> account 1...
    -> qc_summary
```

Các thành phần nên tách riêng:

| Thành phần | Trách nhiệm |
|---|---|
| `CodexOAuthService` | OAuth PKCE, đổi authorization code, refresh token |
| `ClaudeCodeOAuthService` | OAuth PKCE và refresh token Claude Code |
| `CodexResponseService` | Gọi backend Codex, parse SSE, lưu quota/error |
| `ClaudeCodeResponseService` | Gọi Anthropic Messages API bằng OAuth token |
| `AiResponseService` | Chọn provider, round-robin và fallback |
| Connection model | Lưu token mã hóa, trạng thái, quota snapshot, cooldown |
| Controller/UI | Kết nối tài khoản, chọn model/provider, xem quota |

## 3. OAuth PKCE dùng chung

### 3.1. Tạo authorization URL

Khi người dùng bấm **Kết nối**:

1. Sinh `state` ngẫu nhiên.
2. Sinh `code_verifier` ngẫu nhiên.
3. Tạo `code_challenge = BASE64URL(SHA256(code_verifier))`.
4. Lưu `state`, `code_verifier`, `redirect_uri`, `expires_at` vào bảng OAuth state tạm (TTL 10–30 phút).
5. Redirect người dùng tới authorization URL của provider với `response_type=code`, `client_id`, `scope`, `state`, `code_challenge`, `code_challenge_method=S256`.

Không lưu `code_verifier` trong session trình duyệt nếu có thể tránh; lưu server-side giúp callback có thể được hoàn tất an toàn.

### 3.2. Hoàn tất callback

Provider trả về `code` và `state`. Backend phải:

1. Parse URL callback, hỗ trợ cả query string và fragment nếu provider dùng fragment.
2. Tìm state tương ứng, kiểm tra chưa hết hạn.
3. Gửi `grant_type=authorization_code`, `code`, `redirect_uri`, `client_id`, `code_verifier` tới token endpoint.
4. Lưu access token, refresh token, expires-in, scope và thông tin account.
5. Xóa state ngay cả khi đổi token thành công hay thất bại vì authorization code chỉ dùng một lần.

### 3.3. Refresh token

Trước mỗi request AI:

```text
expires_at <= now + refresh_leeway
    -> grant_type=refresh_token
    -> cập nhật access_token/refresh_token mới
    -> tiếp tục request
```

Nếu refresh thất bại, đánh dấu connection `expired`, lưu `last_error` và yêu cầu kết nối lại. Không gửi request model bằng access token đã hết hạn.

## 4. Tích hợp OpenAI Codex

Trong project này Codex dùng tài khoản ChatGPT qua OAuth, không dùng OpenAI API key.

Các thông tin cần cấu hình:

```env
CODEX_CLIENT_ID=...
CODEX_AUTHORIZE_URL=https://auth.openai.com/oauth/authorize
CODEX_TOKEN_URL=https://auth.openai.com/oauth/token
CODEX_REDIRECT_URI=http://localhost:1455/auth/callback
CODEX_SCOPE=openid profile email offline_access
CODEX_BACKEND_URL=https://chatgpt.com/backend-api/codex/responses
CODEX_MODELS_URL=https://chatgpt.com/backend-api/codex/models
CODEX_USAGE_URL=https://chatgpt.com/backend-api/wham/usage
CODEX_MODEL=...
```

Backend Codex yêu cầu các header quan trọng:

- `Authorization: Bearer <access_token>`
- `chatgpt-account-id: <account_id>`
- `OpenAI-Beta: responses=experimental`
- `originator`, `User-Agent`, `session_id`
- `Accept: text/event-stream`

Response có thể là SSE. Service phải đọc các dòng `data:`, cộng dồn event delta và ưu tiên nội dung ở event hoàn tất.

Một số flow Codex redirect về `localhost:1455`. Nếu web app không sở hữu cổng này, UI cho phép người dùng copy nguyên URL callback rồi dán lại vào form. Backend vẫn kiểm tra `state` và `code_verifier` như OAuth PKCE bình thường.

## 5. Tích hợp Claude Code

Claude Code cũng dùng OAuth token, không dùng Anthropic API key.

Cấu hình điển hình:

```env
CLAUDE_CODE_CLIENT_ID=...
CLAUDE_CODE_AUTHORIZE_URL=https://claude.ai/oauth/authorize
CLAUDE_CODE_TOKEN_URL=https://platform.claude.com/v1/oauth/token
CLAUDE_CODE_REDIRECT_URI=http://localhost:443/callback
CLAUDE_CODE_SCOPE=org:create_api_key user:profile user:inference
CLAUDE_CODE_API_URL=https://api.anthropic.com
CLAUDE_CODE_API_VERSION=2023-06-01
```

Request model dùng endpoint Messages:

```json
{
  "model": "<claude-model>",
  "max_tokens": 1024,
  "system": "<system prompt>",
  "messages": [
    {"role": "user", "content": "<input>"}
  ]
}
```

Header chính:

```text
Authorization: Bearer <oauth_token>
anthropic-version: 2023-06-01
Content-Type: application/json
```

Claude trả nội dung trong `content[].text`. Quota thường nằm trong các header `anthropic-ratelimit-*`; service chuẩn hóa chúng về cùng format quota với Codex để UI dùng chung.

## 6. Thiết kế bảng connection

Nên có hai bảng riêng hoặc một bảng dùng cột `provider`. Nếu dùng hai bảng như project này, cả hai cần các field tương đương:

```text
id
label
account_id
access_token/oauth_token (encrypted)
refresh_token (encrypted)
scope
expires_at
status                 active|expired
last_error
last_used_at
quota_snapshot         JSON/text
quota_updated_at
fallback_cooldown_until nullable
created_by
```

Token phải nằm trong `$hidden`, không trả ra API/UI. `APP_KEY` hoặc encryption key đổi sẽ làm token cũ không giải mã được; khi đó connection phải chuyển sang `expired` và yêu cầu kết nối lại.

`quota_snapshot` nên có format chung:

```json
{
  "provider": "openai_codex",
  "source": "usage_api|response_headers",
  "quotas": [
    {
      "key": "primary_window",
      "label": "Codex · 5 giờ",
      "percent_used": 92.5,
      "percent_remaining": 7.5,
      "reset_at": "2026-09-14T12:00:00Z"
    }
  ]
}
```

## 7. Backend kiểm tra và lưu quota

Có hai nguồn quota:

1. Endpoint usage riêng (phù hợp Codex), dùng khi người dùng bấm **Làm mới quota**.
2. Rate-limit headers trong mỗi request model (phù hợp cả Codex và Claude).

Service cần:

- Chuẩn hóa tên window, used, remaining, reset time.
- Lưu snapshot vào đúng connection vừa gọi.
- Lưu `quota_updated_at`.
- Nếu response lỗi 429 nhưng body chứa usage, vẫn cố parse và lưu snapshot.
- Không coi HTTP 200 là thành công quota nếu payload không có trường quota hợp lệ.

## 8. Giao diện quản trị

Nên có một màn hình gồm ba khu vực:

### Danh sách OpenAI Codex

Mỗi dòng hiển thị:

- Label/email/account ID đã che bớt.
- Plan (nếu có).
- `active`, `expired` hoặc `cooldown`.
- Phần trăm còn lại cho từng window.
- Thời điểm reset.
- Lần sử dụng gần nhất và lỗi gần nhất.
- Nút **Làm mới token**, **Làm mới quota**, **Xóa**.

### Danh sách Claude Code

Hiển thị tương tự. Model list lấy từ endpoint `/v1/models`, cho phép chọn `claude_code_model_default`.

### Cấu hình provider

Cho phép chọn:

```text
openai_codex
claude_code
```

Không cho đặt provider Claude làm mặc định nếu chưa có connection Claude `active`. Có thể bổ sung thứ tự ưu tiên từng account nếu không muốn dùng round-robin thuần túy.

API route tham khảo:

```text
POST /codex-connections/authorize-url
POST /codex-connections
POST /codex-connections/{id}/refresh
GET  /codex-connections/{id}/models
POST /codex-connections/{id}/quota

POST /claude-code-connections/authorize-url
POST /claude-code-connections
GET  /claude-code-connections/{id}/models
POST /claude-code-connections/{id}/quota

POST /ai-provider-default
```

## 9. Thuật toán fallback

Giả sử provider mặc định là Codex:

```text
Codex account 1
 -> Codex account 2
 -> ...
 -> Claude Code account 1
 -> ...
 -> lỗi toàn bộ
```

Nếu mặc định là Claude Code thì đảo thứ tự provider.

Pseudo-code:

```php
$providers = $default === 'claude_code'
    ? ['claude_code', 'openai_codex']
    : ['openai_codex', 'claude_code'];

foreach ($providers as $provider) {
    foreach (availableConnections($provider) as $connection) {
        try {
            return callProvider($provider, $connection, $system, $input);
        } catch (Throwable $e) {
            if (!isFallbackable($e)) {
                throw $e;
            }
            putOnCooldownOrExpire($connection, $e);
        }
    }
}

throw new RuntimeException('Tất cả tài khoản AI đều không khả dụng');
```

### Round-robin

Sắp xếp connection theo `last_used_at ASC`, null trước, sau đó theo `id`. Sau request thành công, cập nhật `last_used_at`. Với hai Codex account, lần lượt sẽ là A → B → A → B khi cả hai đều khả dụng.

Trong môi trường nhiều worker, nên bổ sung lock ngắn hoặc cơ chế atomic reservation nếu cần bảo đảm tuyệt đối không có hai worker cùng chọn một account tại cùng thời điểm.

### Phân loại lỗi

Nên fallback với:

- `401`, `403`: token/quyền không hợp lệ; đánh dấu `expired`.
- `429`: hết quota/rate limit; cooldown tới `reset_at`.
- `500–504`: lỗi dịch vụ; cooldown ngắn, ví dụ 60 giây.
- Timeout và lỗi kết nối: cooldown ngắn.

Không fallback với lỗi validation, transcript rỗng hoặc lỗi code không liên quan đến provider.

### Cooldown và quota reset

- `429` có `reset_at`: đặt `fallback_cooldown_until = reset_at`.
- `429` không có reset time: dùng cooldown an toàn tạm thời, sau đó thử lại.
- `5xx`/timeout: cooldown khoảng 1 phút.
- Query connection chỉ lấy `status=active` và `fallback_cooldown_until IS NULL OR <= now()`.
- Vì vậy khi quota reset, account tự quay lại danh sách ngay ở request kế tiếp, không cần cron hay thao tác thủ công.

## 10. Luồng transcript và retry

Job tóm tắt không được xóa transcript trước khi AI trả thành công:

```text
Whisper thành công
  -> transcript_status = done
  -> gọi AiResponseService
  -> fallback nếu cần
  -> thành công: lưu qc_summary, có thể xóa transcript_text
  -> thất bại toàn bộ: giữ transcript_text, lưu transcript_error
```

Command backfill có thể xử lý hai trường hợp:

- Đã có `transcript_text`: chỉ gọi lại bước summary.
- Chưa có transcript: dispatch lại job tải audio và Whisper.

Điều kiện query nên có `qc_summary IS NULL` để không ghi đè kết quả QC đã tồn tại.

## 11. Bảo mật

- Mã hóa access token, refresh token và ID token bằng key ứng dụng.
- Không ghi token vào log, exception, debug bar hoặc JSON response.
- Dùng `state` chống CSRF và PKCE chống đánh cắp authorization code.
- Xóa OAuth state sau khi dùng.
- Giới hạn quyền truy cập màn hình connection/quota theo role.
- Che email/account ID trong UI nếu không cần hiển thị đầy đủ.
- Không coi OAuth token của ChatGPT/Claude là API key thông thường; endpoint, header và scope phải đúng loại token.

## 12. Checklist triển khai project mới

1. Tạo bảng connection và bảng OAuth state.
2. Viết OAuth service dùng PKCE và refresh token.
3. Viết model accessor/mutator mã hóa token.
4. Viết provider response service, parser response và quota.
5. Chuẩn hóa quota snapshot giữa các provider.
6. Viết `AiResponseService` điều phối provider/account.
7. Thêm `last_used_at` và cooldown để round-robin/fallback.
8. Thêm controller/API và UI quản lý connection, model, quota.
9. Bảo đảm job transcript giữ text khi summary thất bại.
10. Viết test cho OAuth state, token refresh, 429, 5xx, timeout, quota reset và fallback ngược provider.

## 13. Kịch bản kiểm thử bắt buộc

- Codex account 1 thành công: không gọi account 2.
- Codex account 1 trả 429: gọi account 2.
- Cả Codex trả lỗi: chuyển Claude.
- Claude mặc định và Claude lỗi: chuyển Codex.
- 5xx: fallback, nhưng account được thử lại sau cooldown ngắn.
- 401/403: connection thành `expired`, không được chọn lại.
- 429 có reset time: không chọn lại trước reset; chọn lại ngay sau reset.
- Tất cả provider lỗi: `transcript_text` vẫn còn và command chạy lại được.
- Hai worker chạy đồng thời: không tạo summary trùng hoặc ghi đè summary đã có.

