diff --git a/CHANGELOG.md b/CHANGELOG.md index 63d0c91..f1d17d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ ### Added +- Relay account support(#105):支持通过 API Key + Base URL 添加第三方中转站账号 + - `POST /auth/accounts/relay` 端点(apiKey, baseUrl, label, allowedModels) + - AccountPool 新增 `addRelayAccount()` 方法,按 baseUrl+apiKey 去重 + - `acquire()` 支持 relay 模型过滤(allowedModels 白名单,null = 全部模型) + - CodexApi 支持 `baseUrlOverride`:relay 模式使用简单 Bearer auth,跳过指纹/Cookie + - Relay 账号自动跳过 JWT 刷新、额度获取、Token 过期检测 + - Dashboard:Header 新增 Relay 按钮,AddRelayAccount 表单,AccountCard 适配 relay 显示 + - **Format 直通模式**:relay 支持 `format` 字段(codex/openai/anthropic/gemini) + - 非 codex format 的 relay 走直通路径:原始请求直接转发到 relay,不经过 Codex 翻译层 + - `handleDirectProxy()` 处理器:流式 SSE pipe + 非流式完整返回 + 429 限流 + - 各 route handler(chat/messages/gemini)翻译前优先匹配 format 兼容的 relay + - Dashboard 表单加 Format 下拉,AccountCard 显示 format 标签 + - 45 个新测试覆盖 pool/api/route/direct-proxy 层 - `POST /admin/refresh-models` 端点:手动触发模型列表刷新,解决 model-fetcher ~1h 缓存过时导致新模型不可用的问题;支持 Bearer auth(当配置 proxy_api_key 时) - Plan routing integration tests:通过 proxy handler 完整路径验证 free/team 账号的模型路由(7 cases),覆盖 plan map 更新后请求解除阻塞的场景 diff --git a/config/prompts/automation-response.md b/config/prompts/automation-response.md index fbd33fe..7297334 100644 --- a/config/prompts/automation-response.md +++ b/config/prompts/automation-response.md @@ -48,5 +48,4 @@ Response MUST end with a remark-directive block. - Waiting on user decision: - \`::inbox-item{title="Choose API shape for filters" summary="Two options drafted; pick A vs B"}\` - Status update with next step: - - \`::inbox-item{title="PR comments addressed" summary="Ready for re-review; focus on auth edge case"}\` -`; \ No newline at end of file + - \`::inbox-item{title="PR comments addressed" summary="Ready for re-review; focus on auth edge case"}\ \ No newline at end of file diff --git a/shared/types.ts b/shared/types.ts index a3630ab..1678c55 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -36,6 +36,11 @@ export interface Account { quotaFetchedAt?: string | null; proxyId?: string; proxyName?: string; + type?: "native" | "relay"; + label?: string | null; + baseUrl?: string | null; + allowedModels?: string[] | null; + format?: string | null; } export interface ProxyHealthInfo { diff --git a/src/auth/__tests__/account-pool-relay.test.ts b/src/auth/__tests__/account-pool-relay.test.ts new file mode 100644 index 0000000..652165e --- /dev/null +++ b/src/auth/__tests__/account-pool-relay.test.ts @@ -0,0 +1,423 @@ +/** + * Tests for relay account support in AccountPool. + * + * Relay accounts use a simple API key + custom base URL instead of + * ChatGPT JWT tokens. They skip JWT parsing, refresh, and quota fetch. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockGetModelPlanTypes = vi.fn<(id: string) => string[]>(() => []); + +vi.mock("../../models/model-store.js", () => ({ + getModelPlanTypes: (...args: unknown[]) => mockGetModelPlanTypes(args[0] as string), +})); + +vi.mock("../../config.js", () => ({ + getConfig: vi.fn(() => ({ + server: { account_strategy: "round_robin" }, + auth: { jwt_token: "", rotation_strategy: "least_used", rate_limit_backoff_seconds: 60 }, + })), +})); + +let profileForToken: Record = {}; + +vi.mock("../../auth/jwt-utils.js", () => ({ + isTokenExpired: vi.fn(() => false), + decodeJwtPayload: vi.fn(() => ({})), + extractChatGptAccountId: vi.fn((token: string) => `aid-${token}`), + extractUserProfile: vi.fn((token: string) => profileForToken[token] ?? null), +})); + +vi.mock("../../utils/jitter.js", () => ({ + jitter: vi.fn((val: number) => val), +})); + +vi.mock("fs", () => ({ + readFileSync: vi.fn(() => JSON.stringify({ accounts: [] })), + writeFileSync: vi.fn(), + existsSync: vi.fn(() => false), + mkdirSync: vi.fn(), + renameSync: vi.fn(), +})); + +import { AccountPool } from "../account-pool.js"; + +describe("account-pool relay accounts", () => { + beforeEach(() => { + vi.clearAllMocks(); + profileForToken = {}; + }); + + // ── addRelayAccount ────────────────────────────────────────── + + it("creates a relay account with correct fields", () => { + const pool = new AccountPool(); + const id = pool.addRelayAccount({ + apiKey: "sk-relay-123", + baseUrl: "https://relay.example.com/backend-api", + label: "My Relay", + }); + + const entry = pool.getEntry(id); + expect(entry).toBeDefined(); + expect(entry!.type).toBe("relay"); + expect(entry!.token).toBe("sk-relay-123"); + expect(entry!.baseUrl).toBe("https://relay.example.com/backend-api"); + expect(entry!.label).toBe("My Relay"); + expect(entry!.allowedModels).toBeNull(); + expect(entry!.email).toBeNull(); + expect(entry!.accountId).toBeNull(); + expect(entry!.planType).toBeNull(); + expect(entry!.refreshToken).toBeNull(); + expect(entry!.status).toBe("active"); + expect(entry!.proxyApiKey).toMatch(/^codex-proxy-/); + }); + + it("creates relay with allowedModels", () => { + const pool = new AccountPool(); + const id = pool.addRelayAccount({ + apiKey: "sk-relay-123", + baseUrl: "https://relay.example.com/backend-api", + label: "My Relay", + allowedModels: ["gpt-5.2-codex", "gpt-5.4"], + }); + + const entry = pool.getEntry(id); + expect(entry!.allowedModels).toEqual(["gpt-5.2-codex", "gpt-5.4"]); + }); + + it("deduplicates by baseUrl + apiKey", () => { + const pool = new AccountPool(); + const id1 = pool.addRelayAccount({ + apiKey: "sk-relay-123", + baseUrl: "https://relay.example.com/backend-api", + label: "Relay v1", + }); + const id2 = pool.addRelayAccount({ + apiKey: "sk-relay-123", + baseUrl: "https://relay.example.com/backend-api", + label: "Relay v2", + }); + + expect(id1).toBe(id2); + expect(pool.getEntry(id1)!.label).toBe("Relay v2"); // Updated + }); + + it("does NOT deduplicate when apiKey differs", () => { + const pool = new AccountPool(); + const id1 = pool.addRelayAccount({ + apiKey: "sk-relay-AAA", + baseUrl: "https://relay.example.com/backend-api", + label: "Relay A", + }); + const id2 = pool.addRelayAccount({ + apiKey: "sk-relay-BBB", + baseUrl: "https://relay.example.com/backend-api", + label: "Relay B", + }); + + expect(id1).not.toBe(id2); + }); + + // ── acquire ────────────────────────────────────────────────── + + it("acquires relay account when no model filter", () => { + const pool = new AccountPool(); + pool.addRelayAccount({ + apiKey: "sk-relay", + baseUrl: "https://relay.example.com", + label: "Relay", + }); + + const acquired = pool.acquire(); + expect(acquired).not.toBeNull(); + expect(acquired!.type).toBe("relay"); + expect(acquired!.baseUrl).toBe("https://relay.example.com"); + }); + + it("acquires relay account with null allowedModels for any model", () => { + mockGetModelPlanTypes.mockReturnValue(["team"]); + const pool = new AccountPool(); + pool.addRelayAccount({ + apiKey: "sk-relay", + baseUrl: "https://relay.example.com", + label: "Relay", + // no allowedModels → accepts all + }); + + const acquired = pool.acquire({ model: "gpt-5.4" }); + expect(acquired).not.toBeNull(); + expect(acquired!.type).toBe("relay"); + }); + + it("acquires relay account when model is in allowedModels", () => { + mockGetModelPlanTypes.mockReturnValue(["team"]); + const pool = new AccountPool(); + pool.addRelayAccount({ + apiKey: "sk-relay", + baseUrl: "https://relay.example.com", + label: "Relay", + allowedModels: ["gpt-5.4", "gpt-5.2-codex"], + }); + + const acquired = pool.acquire({ model: "gpt-5.4" }); + expect(acquired).not.toBeNull(); + expect(acquired!.type).toBe("relay"); + }); + + it("skips relay account when model is NOT in allowedModels", () => { + mockGetModelPlanTypes.mockReturnValue([]); + const pool = new AccountPool(); + pool.addRelayAccount({ + apiKey: "sk-relay", + baseUrl: "https://relay.example.com", + label: "Relay", + allowedModels: ["gpt-5.2-codex"], + }); + + const acquired = pool.acquire({ model: "gpt-5.4" }); + expect(acquired).toBeNull(); + }); + + it("mixed pool: relay + native rotate together", () => { + mockGetModelPlanTypes.mockReturnValue([]); + profileForToken = { "tok-native": { chatgpt_plan_type: "free", email: "test@test.com" } }; + + const pool = new AccountPool(); + pool.addAccount("tok-native"); + pool.addRelayAccount({ + apiKey: "sk-relay", + baseUrl: "https://relay.example.com", + label: "Relay", + }); + + // Both should be available (least-used picks the one with fewer requests) + const a1 = pool.acquire(); + expect(a1).not.toBeNull(); + const a2 = pool.acquire(); + expect(a2).not.toBeNull(); + expect(a1!.entryId).not.toBe(a2!.entryId); + + // One should be native, the other relay + const types = [a1!.type, a2!.type].sort(); + expect(types).toEqual(["native", "relay"]); + }); + + it("relay with allowedModels filtered out when model has plan requirements", () => { + // Model requires "team" plan, relay only allows gpt-5.2-codex + mockGetModelPlanTypes.mockReturnValue(["team"]); + + const pool = new AccountPool(); + pool.addRelayAccount({ + apiKey: "sk-relay", + baseUrl: "https://relay.example.com", + label: "Relay", + allowedModels: ["gpt-5.2-codex"], // does NOT include gpt-5.4 + }); + + const acquired = pool.acquire({ model: "gpt-5.4" }); + expect(acquired).toBeNull(); + }); + + // ── refreshStatus ──────────────────────────────────────────── + + it("relay accounts do NOT expire via JWT check", async () => { + const pool = new AccountPool(); + const id = pool.addRelayAccount({ + apiKey: "sk-relay", + baseUrl: "https://relay.example.com", + label: "Relay", + }); + + // Even though isTokenExpired is mocked to return false by default, + // if we change it to return true, relay should still be active + const { isTokenExpired } = await import("../../auth/jwt-utils.js"); + vi.mocked(isTokenExpired).mockReturnValue(true); + + // Trigger refreshStatus via acquire + const acquired = pool.acquire(); + expect(acquired).not.toBeNull(); + expect(pool.getEntry(id)!.status).toBe("active"); + + // Restore + vi.mocked(isTokenExpired).mockReturnValue(false); + }); + + it("relay accounts can still be rate limited and recover", () => { + const pool = new AccountPool(); + const id = pool.addRelayAccount({ + apiKey: "sk-relay", + baseUrl: "https://relay.example.com", + label: "Relay", + }); + + pool.markRateLimited(id, { retryAfterSec: 0.001 }); + expect(pool.getEntry(id)!.status).toBe("rate_limited"); + + // Wait for recovery + const entry = pool.getEntry(id)!; + entry.usage.rate_limit_until = new Date(Date.now() - 1000).toISOString(); + + const acquired = pool.acquire(); + expect(acquired).not.toBeNull(); + expect(pool.getEntry(id)!.status).toBe("active"); + }); + + // ── toInfo ─────────────────────────────────────────────────── + + it("toInfo returns relay fields correctly", () => { + const pool = new AccountPool(); + pool.addRelayAccount({ + apiKey: "sk-relay", + baseUrl: "https://relay.example.com", + label: "My Relay", + allowedModels: ["gpt-5.4"], + }); + + const accounts = pool.getAccounts(); + const relay = accounts.find((a) => a.type === "relay"); + expect(relay).toBeDefined(); + expect(relay!.label).toBe("My Relay"); + expect(relay!.baseUrl).toBe("https://relay.example.com"); + expect(relay!.allowedModels).toEqual(["gpt-5.4"]); + expect(relay!.expiresAt).toBeNull(); // No JWT expiry + expect(relay!.email).toBeNull(); + }); + + // ── getDistinctPlanAccounts ────────────────────────────────── + + it("getDistinctPlanAccounts excludes relay accounts", () => { + profileForToken = { "tok-team": { chatgpt_plan_type: "team", email: "team@test.com" } }; + + const pool = new AccountPool(); + pool.addAccount("tok-team"); + pool.addRelayAccount({ + apiKey: "sk-relay", + baseUrl: "https://relay.example.com", + label: "Relay", + }); + + const planAccounts = pool.getDistinctPlanAccounts(); + expect(planAccounts.length).toBe(1); + expect(planAccounts[0].planType).toBe("team"); + + // Release locks + for (const pa of planAccounts) pool.releaseWithoutCounting(pa.entryId); + }); + + // ── native accounts have correct type ──────────────────────── + + it("native accounts added via addAccount have type='native'", () => { + profileForToken = { "tok-1": { chatgpt_plan_type: "free", email: "test@test.com" } }; + const pool = new AccountPool(); + const id = pool.addAccount("tok-1"); + + const entry = pool.getEntry(id); + expect(entry!.type).toBe("native"); + expect(entry!.baseUrl).toBeNull(); + expect(entry!.label).toBeNull(); + expect(entry!.allowedModels).toBeNull(); + expect(entry!.format).toBeNull(); + + const acquired = pool.acquire(); + expect(acquired!.type).toBe("native"); + expect(acquired!.baseUrl).toBeNull(); + expect(acquired!.format).toBeNull(); + }); + + // ── format support ────────────────────────────────────────── + + it("creates relay with format field", () => { + const pool = new AccountPool(); + const id = pool.addRelayAccount({ + apiKey: "sk-relay", + baseUrl: "https://api.example.com/v1", + label: "OpenAI Relay", + format: "openai", + }); + + const entry = pool.getEntry(id); + expect(entry!.format).toBe("openai"); + }); + + it("defaults format to codex when not specified", () => { + const pool = new AccountPool(); + const id = pool.addRelayAccount({ + apiKey: "sk-relay", + baseUrl: "https://relay.example.com", + label: "Default Relay", + }); + + const entry = pool.getEntry(id); + expect(entry!.format).toBe("codex"); + }); + + it("acquire({ format }) prefers matching relay", () => { + mockGetModelPlanTypes.mockReturnValue([]); + profileForToken = { "tok-native": { chatgpt_plan_type: "free", email: "test@test.com" } }; + + const pool = new AccountPool(); + pool.addAccount("tok-native"); + pool.addRelayAccount({ + apiKey: "sk-openai", + baseUrl: "https://api.example.com/v1", + label: "OpenAI", + format: "openai", + }); + + // With format preference → should pick the openai relay + const acquired = pool.acquire({ format: "openai" }); + expect(acquired).not.toBeNull(); + expect(acquired!.format).toBe("openai"); + expect(acquired!.type).toBe("relay"); + }); + + it("acquire({ format }) falls back when no format match", () => { + mockGetModelPlanTypes.mockReturnValue([]); + profileForToken = { "tok-native": { chatgpt_plan_type: "free", email: "test@test.com" } }; + + const pool = new AccountPool(); + pool.addAccount("tok-native"); + pool.addRelayAccount({ + apiKey: "sk-codex", + baseUrl: "https://relay.example.com", + label: "Codex Relay", + format: "codex", + }); + + // Request anthropic format but none exists → should still return an account + const acquired = pool.acquire({ format: "anthropic" }); + expect(acquired).not.toBeNull(); + // Falls back to native or codex relay + expect(acquired!.format).not.toBe("anthropic"); + }); + + it("acquire returns format in AcquiredAccount", () => { + mockGetModelPlanTypes.mockReturnValue([]); + const pool = new AccountPool(); + pool.addRelayAccount({ + apiKey: "sk-relay", + baseUrl: "https://api.example.com", + label: "Anthropic", + format: "anthropic", + }); + + const acquired = pool.acquire(); + expect(acquired!.format).toBe("anthropic"); + }); + + it("toInfo returns format field", () => { + const pool = new AccountPool(); + pool.addRelayAccount({ + apiKey: "sk-relay", + baseUrl: "https://api.example.com", + label: "OpenAI", + format: "openai", + }); + + const accounts = pool.getAccounts(); + const relay = accounts.find((a) => a.type === "relay"); + expect(relay!.format).toBe("openai"); + }); +}); diff --git a/src/auth/account-pool.ts b/src/auth/account-pool.ts index 4742aae..dc7d0bf 100644 --- a/src/auth/account-pool.ts +++ b/src/auth/account-pool.ts @@ -29,6 +29,7 @@ import type { AcquiredAccount, AccountsFile, CodexQuota, + RelayFormat, } from "./types.js"; function getAccountsFile(): string { @@ -69,9 +70,10 @@ export class AccountPool { * Returns null if no accounts are available. * * @param options.model - Prefer accounts whose planType matches this model's known plans + * @param options.format - Prefer relay accounts exposing this API format * @param options.excludeIds - Entry IDs to exclude (e.g. already tried) */ - acquire(options?: { model?: string; excludeIds?: string[] }): AcquiredAccount | null { + acquire(options?: { model?: string; format?: RelayFormat; excludeIds?: string[] }): AcquiredAccount | null { const now = new Date(); const nowMs = now.getTime(); @@ -100,17 +102,37 @@ export class AccountPool { // Model-aware selection: prefer accounts whose planType matches the model's known plans let candidates = available; if (options?.model) { - const preferredPlans = getModelPlanTypes(options.model); - if (preferredPlans.length > 0) { - const planSet = new Set(preferredPlans); - const matched = available.filter((a) => a.planType && planSet.has(a.planType)); - if (matched.length > 0) { - candidates = matched; - } else { - // No account matches the model's plan requirements — don't fallback to incompatible accounts - return null; + const model = options.model; + const preferredPlans = getModelPlanTypes(model); + const planSet = preferredPlans.length > 0 ? new Set(preferredPlans) : null; + + const matched = available.filter((a) => { + if (a.type === "relay") { + // Relay: check explicit model whitelist (null = all models OK) + return !a.allowedModels || a.allowedModels.includes(model); } + // Native: check plan type if model has requirements + return planSet ? (a.planType != null && planSet.has(a.planType)) : true; + }); + + if (matched.length > 0) { + candidates = matched; + } else { + // No account matches (plan requirements for native OR allowedModels for relay) + return null; + } + } + + // Format-aware filtering: if route specifies a format, prefer matching relays + if (options?.format) { + const formatMatched = candidates.filter( + (a) => a.type === "relay" && a.format === options.format, + ); + if (formatMatched.length > 0) { + candidates = formatMatched; } + // If no format match, candidates still contain native + codex relays + // → route handler checks acquired.format to decide direct vs translation path } const selected = this.selectByStrategy(candidates); @@ -119,6 +141,9 @@ export class AccountPool { entryId: selected.id, token: selected.token, accountId: selected.accountId, + type: selected.type, + baseUrl: selected.baseUrl, + format: selected.format, }; } @@ -159,7 +184,7 @@ export class AccountPool { } const available = [...this.accounts.values()].filter( - (a) => a.status === "active" && !this.acquireLocks.has(a.id) && a.planType, + (a) => a.status === "active" && !this.acquireLocks.has(a.id) && a.planType && a.type !== "relay", ); // Group by planType, pick least-used from each group @@ -307,6 +332,11 @@ export class AccountPool { addedAt: new Date().toISOString(), cachedQuota: null, quotaFetchedAt: null, + type: "native", + baseUrl: null, + label: null, + allowedModels: null, + format: null, }; this.accounts.set(id, entry); @@ -314,6 +344,68 @@ export class AccountPool { return id; } + /** + * Add a relay account (third-party API key + base URL). Returns the entry ID. + * Deduplicates by baseUrl + apiKey. + */ + addRelayAccount(params: { + apiKey: string; + baseUrl: string; + label: string; + format?: RelayFormat; + allowedModels?: string[]; + }): string { + // Deduplicate by baseUrl + apiKey + for (const existing of this.accounts.values()) { + if (existing.type === "relay" && existing.baseUrl === params.baseUrl && existing.token === params.apiKey) { + // Update existing entry + existing.label = params.label; + existing.allowedModels = params.allowedModels ?? null; + existing.format = params.format ?? "codex"; + existing.status = "active"; + this.persistNow(); + return existing.id; + } + } + + const id = randomBytes(8).toString("hex"); + const entry: AccountEntry = { + id, + token: params.apiKey, + refreshToken: null, + email: null, + accountId: null, + planType: null, + proxyApiKey: "codex-proxy-" + randomBytes(24).toString("hex"), + status: "active", + usage: { + request_count: 0, + input_tokens: 0, + output_tokens: 0, + empty_response_count: 0, + last_used: null, + rate_limit_until: null, + window_request_count: 0, + window_input_tokens: 0, + window_output_tokens: 0, + window_counters_reset_at: null, + limit_window_seconds: null, + }, + addedAt: new Date().toISOString(), + cachedQuota: null, + quotaFetchedAt: null, + type: "relay", + baseUrl: params.baseUrl, + label: params.label, + allowedModels: params.allowedModels ?? null, + format: params.format ?? "codex", + }; + + this.accounts.set(id, entry); + this.persistNow(); + return id; + } + /** * Record an empty response for an account (HTTP 200 but zero text deltas). */ @@ -548,8 +640,8 @@ export class AccountPool { } } - // Mark expired tokens - if (entry.status === "active" && isTokenExpired(entry.token)) { + // Mark expired tokens (relay accounts don't use JWT expiry) + if (entry.status === "active" && entry.type !== "relay" && isTokenExpired(entry.token)) { entry.status = "expired"; } @@ -576,8 +668,12 @@ export class AccountPool { } private toInfo(entry: AccountEntry): AccountInfo { - const payload = decodeJwtPayload(entry.token); - const exp = payload?.exp; + let expiresAt: string | null = null; + if (entry.type !== "relay") { + const payload = decodeJwtPayload(entry.token); + const exp = payload?.exp; + expiresAt = typeof exp === "number" ? new Date(exp * 1000).toISOString() : null; + } const info: AccountInfo = { id: entry.id, email: entry.email, @@ -586,10 +682,12 @@ export class AccountPool { status: entry.status, usage: { ...entry.usage }, addedAt: entry.addedAt, - expiresAt: - typeof exp === "number" - ? new Date(exp * 1000).toISOString() - : null, + expiresAt, + type: entry.type, + label: entry.label, + baseUrl: entry.baseUrl, + allowedModels: entry.allowedModels, + format: entry.format, }; if (entry.cachedQuota) { info.quota = entry.cachedQuota; @@ -637,7 +735,8 @@ export class AccountPool { for (const entry of data.accounts) { if (entry.id && entry.token) { // Backfill missing fields from JWT (e.g. planType was null before fix) - if (!entry.planType || !entry.email || !entry.accountId) { + // Skip for relay accounts — they don't use JWT + if (entry.type !== "relay" && (!entry.planType || !entry.email || !entry.accountId)) { const profile = extractUserProfile(entry.token); const accountId = extractChatGptAccountId(entry.token); if (!entry.planType && profile?.chatgpt_plan_type) { @@ -673,6 +772,20 @@ export class AccountPool { entry.quotaFetchedAt = null; needsPersist = true; } + // Backfill relay fields for old entries + if ((entry as unknown as Record).type === undefined) { + entry.type = "native"; + entry.baseUrl = null; + entry.label = null; + entry.allowedModels = null; + entry.format = null; + needsPersist = true; + } + // Backfill format field for old relay entries + if (entry.type === "relay" && (entry as unknown as Record).format === undefined) { + entry.format = "codex"; + needsPersist = true; + } this.accounts.set(entry.id, entry); } } @@ -726,6 +839,11 @@ export class AccountPool { addedAt: new Date().toISOString(), cachedQuota: null, quotaFetchedAt: null, + type: "native", + baseUrl: null, + label: null, + allowedModels: null, + format: null, }; this.accounts.set(id, entry); diff --git a/src/auth/refresh-scheduler.ts b/src/auth/refresh-scheduler.ts index d394121..4fcc16d 100644 --- a/src/auth/refresh-scheduler.ts +++ b/src/auth/refresh-scheduler.ts @@ -35,6 +35,7 @@ export class RefreshScheduler { /** Schedule refresh for all accounts in the pool. */ scheduleAll(): void { for (const entry of this.pool.getAllEntries()) { + if (entry.type === "relay") continue; // Relay accounts don't use JWT refresh if (entry.status === "active") { this.scheduleOne(entry.id, entry.token); } else if (entry.status === "refreshing") { @@ -57,8 +58,12 @@ export class RefreshScheduler { } } - /** Schedule refresh for a single account. */ + /** Schedule refresh for a single account. Relay accounts are skipped. */ scheduleOne(entryId: string, token: string): void { + // Relay accounts don't use JWT refresh + const entry = this.pool.getEntry(entryId); + if (entry?.type === "relay") return; + // Clear existing timer this.clearOne(entryId); diff --git a/src/auth/types.ts b/src/auth/types.ts index e7ccfd6..5609d2c 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -30,6 +30,9 @@ export interface AccountUsage { limit_window_seconds?: number | null; } +export type AccountType = "native" | "relay"; +export type RelayFormat = "codex" | "openai" | "anthropic" | "gemini"; + export interface AccountEntry { id: string; token: string; @@ -45,6 +48,16 @@ export interface AccountEntry { cachedQuota: CodexQuota | null; /** ISO timestamp of when cachedQuota was last updated. */ quotaFetchedAt: string | null; + /** Account type: native (ChatGPT JWT) or relay (third-party API key). */ + type: AccountType; + /** Relay-only: upstream base URL (e.g. "https://relay.example.com/backend-api"). */ + baseUrl: string | null; + /** Relay-only: user-friendly display name. */ + label: string | null; + /** Relay-only: restrict to specific models (null = all models). */ + allowedModels: string[] | null; + /** Relay-only: API format this relay exposes (null = native account or codex default). */ + format: RelayFormat | null; } /** Public info (no token) */ @@ -59,6 +72,11 @@ export interface AccountInfo { expiresAt: string | null; quota?: CodexQuota; quotaFetchedAt?: string | null; + type: AccountType; + label: string | null; + baseUrl: string | null; + allowedModels: string[] | null; + format: RelayFormat | null; } /** A single rate limit window (primary or secondary). */ @@ -92,6 +110,11 @@ export interface AcquiredAccount { entryId: string; token: string; accountId: string | null; + type: AccountType; + /** Relay-only: upstream base URL override. Null for native accounts. */ + baseUrl: string | null; + /** Relay-only: API format (null for native accounts). */ + format: RelayFormat | null; } /** Persistence format */ diff --git a/src/auth/usage-refresher.ts b/src/auth/usage-refresher.ts index 5e8e23f..10f4732 100644 --- a/src/auth/usage-refresher.ts +++ b/src/auth/usage-refresher.ts @@ -35,7 +35,7 @@ async function fetchQuotaForAllAccounts( ): Promise { if (!pool.isAuthenticated()) return; - const entries = pool.getAllEntries().filter((e) => e.status === "active"); + const entries = pool.getAllEntries().filter((e) => e.status === "active" && e.type !== "relay"); if (entries.length === 0) return; const config = getConfig(); diff --git a/src/proxy/__tests__/codex-api-relay.test.ts b/src/proxy/__tests__/codex-api-relay.test.ts new file mode 100644 index 0000000..2a2e40e --- /dev/null +++ b/src/proxy/__tests__/codex-api-relay.test.ts @@ -0,0 +1,162 @@ +/** + * Tests that CodexApi correctly switches between native (fingerprint) and + * relay (simple Bearer) header modes based on baseUrlOverride. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { CodexApi } from "../codex-api.js"; + +// Capture transport calls +const mockPost = vi.fn(); +const mockGet = vi.fn(); + +vi.mock("../../tls/transport.js", () => ({ + getTransport: () => ({ + post: mockPost, + get: mockGet, + isImpersonate: () => false, + }), +})); + +vi.mock("../../config.js", () => ({ + getConfig: () => ({ + api: { base_url: "https://chatgpt.com/backend-api" }, + client: { originator: "codex", app_version: "1.0.0", platform: "macOS", arch: "arm64", chromium_version: "136" }, + }), +})); + +vi.mock("../../fingerprint/manager.js", () => ({ + buildHeaders: vi.fn((token: string, accountId: string | null) => ({ + "Authorization": `Bearer ${token}`, + "ChatGPT-Account-Id": accountId ?? "", + "originator": "codex", + "User-Agent": "Mozilla/5.0 Chrome/136", + "sec-ch-ua": '"Chromium";v="136"', + })), + buildHeadersWithContentType: vi.fn((token: string, accountId: string | null) => ({ + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json", + "ChatGPT-Account-Id": accountId ?? "", + "originator": "codex", + "User-Agent": "Mozilla/5.0 Chrome/136", + "sec-ch-ua": '"Chromium";v="136"', + })), +})); + +describe("CodexApi relay mode", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("isRelay returns false for native (no baseUrlOverride)", () => { + const api = new CodexApi("jwt-token", "acct-123"); + expect(api.isRelay).toBe(false); + }); + + it("isRelay returns true when baseUrlOverride is set", () => { + const api = new CodexApi("sk-relay", null, null, null, null, "https://relay.example.com"); + expect(api.isRelay).toBe(true); + }); + + it("native mode uses fingerprint headers for GET", async () => { + mockGet.mockResolvedValue({ body: JSON.stringify({ rate_limit: { allowed: true, limit_reached: false, primary_window: null, secondary_window: null } }), status: 200 }); + + const api = new CodexApi("jwt-token", "acct-123"); + await api.getUsage(); + + expect(mockGet).toHaveBeenCalledTimes(1); + const [url, headers] = mockGet.mock.calls[0]; + expect(url).toBe("https://chatgpt.com/backend-api/codex/usage"); + // Native mode: includes fingerprint headers + expect(headers["ChatGPT-Account-Id"]).toBe("acct-123"); + expect(headers["originator"]).toBe("codex"); + expect(headers["User-Agent"]).toContain("Chrome"); + }); + + it("relay mode uses simple Bearer headers for GET", async () => { + mockGet.mockResolvedValue({ body: JSON.stringify({ rate_limit: { allowed: true, limit_reached: false, primary_window: null, secondary_window: null } }), status: 200 }); + + const api = new CodexApi("sk-relay-key", null, null, null, null, "https://relay.example.com"); + await api.getUsage(); + + expect(mockGet).toHaveBeenCalledTimes(1); + const [url, headers] = mockGet.mock.calls[0]; + // Uses relay base URL + expect(url).toBe("https://relay.example.com/codex/usage"); + // Simple Bearer auth — no fingerprint + expect(headers["Authorization"]).toBe("Bearer sk-relay-key"); + expect(headers["Accept"]).toBe("application/json"); + // Must NOT have fingerprint headers + expect(headers["ChatGPT-Account-Id"]).toBeUndefined(); + expect(headers["originator"]).toBeUndefined(); + expect(headers["User-Agent"]).toBeUndefined(); + expect(headers["sec-ch-ua"]).toBeUndefined(); + }); + + it("relay mode sends to relay base URL for HTTP SSE", async () => { + const mockStream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("event: response.completed\ndata: {}\n\n")); + controller.close(); + }, + }); + mockPost.mockResolvedValue({ + status: 200, + headers: {}, + body: mockStream, + setCookieHeaders: [], + }); + + const api = new CodexApi("sk-relay-key", null, null, null, null, "https://relay.example.com"); + await api.createResponse({ + model: "gpt-5.2-codex", + instructions: "test", + input: [], + stream: true, + store: false, + }); + + expect(mockPost).toHaveBeenCalledTimes(1); + const [url, headers] = mockPost.mock.calls[0]; + expect(url).toBe("https://relay.example.com/codex/responses"); + // Simple Bearer auth + expect(headers["Authorization"]).toBe("Bearer sk-relay-key"); + expect(headers["Content-Type"]).toBe("application/json"); + // No fingerprint headers + expect(headers["ChatGPT-Account-Id"]).toBeUndefined(); + expect(headers["originator"]).toBeUndefined(); + // No OpenAI-Beta header for relay + expect(headers["OpenAI-Beta"]).toBeUndefined(); + }); + + it("native mode uses config base URL", async () => { + const mockStream = new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + mockPost.mockResolvedValue({ + status: 200, + headers: {}, + body: mockStream, + setCookieHeaders: [], + }); + + const api = new CodexApi("jwt-token", "acct-123"); + await api.createResponse({ + model: "gpt-5.2-codex", + instructions: "test", + input: [], + stream: true, + store: false, + }); + + expect(mockPost).toHaveBeenCalledTimes(1); + const [url, headers] = mockPost.mock.calls[0]; + expect(url).toBe("https://chatgpt.com/backend-api/codex/responses"); + // Has fingerprint headers + expect(headers["ChatGPT-Account-Id"]).toBe("acct-123"); + expect(headers["originator"]).toBe("codex"); + expect(headers["OpenAI-Beta"]).toBe("responses_websockets=2026-02-06"); + }); +}); diff --git a/src/proxy/codex-api.ts b/src/proxy/codex-api.ts index 5484aa4..f70dff3 100644 --- a/src/proxy/codex-api.ts +++ b/src/proxy/codex-api.ts @@ -74,6 +74,8 @@ export class CodexApi { private cookieJar: CookieJar | null; private entryId: string | null; private proxyUrl: string | null | undefined; + /** When set, overrides config.api.base_url and uses simple Bearer auth (relay mode). */ + private baseUrlOverride: string | null; constructor( token: string, @@ -81,12 +83,34 @@ export class CodexApi { cookieJar?: CookieJar | null, entryId?: string | null, proxyUrl?: string | null, + baseUrlOverride?: string | null, ) { this.token = token; this.accountId = accountId; this.cookieJar = cookieJar ?? null; this.entryId = entryId ?? null; this.proxyUrl = proxyUrl; + this.baseUrlOverride = baseUrlOverride ?? null; + } + + /** Whether this client is operating in relay mode (third-party upstream). */ + get isRelay(): boolean { + return this.baseUrlOverride !== null; + } + + /** Resolve the effective base URL for upstream requests. */ + private getBaseUrl(): string { + return this.baseUrlOverride ?? getConfig().api.base_url; + } + + /** Build simple headers for relay mode (no fingerprint/cookies). */ + private buildRelayHeaders(contentType?: string): Record { + const headers: Record = { + "Authorization": `Bearer ${this.token}`, + "Accept": "application/json", + }; + if (contentType) headers["Content-Type"] = contentType; + return headers; } setToken(token: string): void { @@ -114,14 +138,16 @@ export class CodexApi { * GET /backend-api/codex/usage */ async getUsage(): Promise { - const config = getConfig(); const transport = getTransport(); - const url = `${config.api.base_url}/codex/usage`; - - const headers = this.applyHeaders( - buildHeaders(this.token, this.accountId), - ); - headers["Accept"] = "application/json"; + const url = `${this.getBaseUrl()}/codex/usage`; + + let headers: Record; + if (this.isRelay) { + headers = this.buildRelayHeaders(); + } else { + headers = this.applyHeaders(buildHeaders(this.token, this.accountId)); + headers["Accept"] = "application/json"; + } // When transport lacks Chrome TLS fingerprint, downgrade Accept-Encoding // to encodings system curl can always decompress. if (!transport.isImpersonate()) { @@ -157,7 +183,7 @@ export class CodexApi { async getModels(): Promise { const config = getConfig(); const transport = getTransport(); - const baseUrl = config.api.base_url; + const baseUrl = this.getBaseUrl(); // Endpoints to probe (most specific first) // /codex/models now requires ?client_version= query parameter @@ -168,10 +194,13 @@ export class CodexApi { `${baseUrl}/sentinel/chat-requirements`, ]; - const headers = this.applyHeaders( - buildHeaders(this.token, this.accountId), - ); - headers["Accept"] = "application/json"; + let headers: Record; + if (this.isRelay) { + headers = this.buildRelayHeaders(); + } else { + headers = this.applyHeaders(buildHeaders(this.token, this.accountId)); + headers["Accept"] = "application/json"; + } if (!transport.isImpersonate()) { headers["Accept-Encoding"] = "gzip, deflate"; } @@ -227,14 +256,16 @@ export class CodexApi { * Probe a backend endpoint and return raw JSON (for debug). */ async probeEndpoint(path: string): Promise | null> { - const config = getConfig(); const transport = getTransport(); - const url = `${config.api.base_url}${path}`; - - const headers = this.applyHeaders( - buildHeaders(this.token, this.accountId), - ); - headers["Accept"] = "application/json"; + const url = `${this.getBaseUrl()}${path}`; + + let headers: Record; + if (this.isRelay) { + headers = this.buildRelayHeaders(); + } else { + headers = this.applyHeaders(buildHeaders(this.token, this.accountId)); + headers["Accept"] = "application/json"; + } if (!transport.isImpersonate()) { headers["Accept-Encoding"] = "gzip, deflate"; } @@ -278,14 +309,16 @@ export class CodexApi { request: CodexResponsesRequest, signal?: AbortSignal, ): Promise { - const config = getConfig(); - const baseUrl = config.api.base_url; + const baseUrl = this.getBaseUrl(); const wsUrl = baseUrl.replace(/^https?:/, "wss:") + "/codex/responses"; // Build headers — same auth but no Content-Type (WebSocket upgrade) - const headers = this.applyHeaders( - buildHeaders(this.token, this.accountId), - ); + let headers: Record; + if (this.isRelay) { + headers = this.buildRelayHeaders(); + } else { + headers = this.applyHeaders(buildHeaders(this.token, this.accountId)); + } headers["OpenAI-Beta"] = "responses_websockets=2026-02-06"; headers["x-openai-internal-codex-residency"] = "us"; @@ -315,17 +348,21 @@ export class CodexApi { request: CodexResponsesRequest, signal?: AbortSignal, ): Promise { - const config = getConfig(); const transport = getTransport(); - const baseUrl = config.api.base_url; - const url = `${baseUrl}/codex/responses`; - - const headers = this.applyHeaders( - buildHeadersWithContentType(this.token, this.accountId), - ); - headers["Accept"] = "text/event-stream"; - // Codex Desktop sends this beta header to enable newer API features - headers["OpenAI-Beta"] = "responses_websockets=2026-02-06"; + const url = `${this.getBaseUrl()}/codex/responses`; + + let headers: Record; + if (this.isRelay) { + headers = this.buildRelayHeaders("application/json"); + headers["Accept"] = "text/event-stream"; + } else { + headers = this.applyHeaders( + buildHeadersWithContentType(this.token, this.accountId), + ); + headers["Accept"] = "text/event-stream"; + // Codex Desktop sends this beta header to enable newer API features + headers["OpenAI-Beta"] = "responses_websockets=2026-02-06"; + } // Strip non-API fields from body — not supported by HTTP SSE. const { service_tier: _st, previous_response_id: _pid, useWebSocket: _ws, ...bodyFields } = request; @@ -340,8 +377,10 @@ export class CodexApi { throw new CodexApiError(0, msg); } - // Capture cookies - this.captureCookies(transportRes.setCookieHeaders); + // Capture cookies (skip for relay — no cookie management needed) + if (!this.isRelay) { + this.captureCookies(transportRes.setCookieHeaders); + } if (transportRes.status < 200 || transportRes.status >= 300) { // Read the body for error details (cap at 1MB to prevent memory spikes) diff --git a/src/routes/__tests__/accounts-relay.test.ts b/src/routes/__tests__/accounts-relay.test.ts new file mode 100644 index 0000000..8aa9db4 --- /dev/null +++ b/src/routes/__tests__/accounts-relay.test.ts @@ -0,0 +1,294 @@ +/** + * Tests for relay account endpoints. + * POST /auth/accounts/relay — add relay account (API key + base URL) + * GET /auth/accounts — list includes relay accounts + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("fs", () => ({ + readFileSync: vi.fn(() => { throw new Error("ENOENT"); }), + writeFileSync: vi.fn(), + renameSync: vi.fn(), + existsSync: vi.fn(() => false), + mkdirSync: vi.fn(), +})); + +vi.mock("../../paths.js", () => ({ + getDataDir: vi.fn(() => "/tmp/test-data"), + getConfigDir: vi.fn(() => "/tmp/test-config"), +})); + +vi.mock("../../config.js", () => ({ + getConfig: vi.fn(() => ({ + auth: { + jwt_token: null, + rotation_strategy: "least_used", + rate_limit_backoff_seconds: 60, + }, + server: { proxy_api_key: null }, + })), +})); + +vi.mock("../../auth/jwt-utils.js", () => ({ + decodeJwtPayload: vi.fn(() => ({ exp: Math.floor(Date.now() / 1000) + 3600 })), + extractChatGptAccountId: vi.fn((token: string) => `acct-${token.slice(0, 8)}`), + extractUserProfile: vi.fn((token: string) => ({ + email: `${token.slice(0, 4)}@test.com`, + chatgpt_plan_type: "free", + })), + isTokenExpired: vi.fn(() => false), +})); + +vi.mock("../../utils/jitter.js", () => ({ + jitter: vi.fn((val: number) => val), +})); + +vi.mock("../../models/model-store.js", () => ({ + getModelPlanTypes: vi.fn(() => []), +})); + +import { Hono } from "hono"; +import { AccountPool } from "../../auth/account-pool.js"; +import { createAccountRoutes } from "../../routes/accounts.js"; + +const mockScheduler = { + scheduleOne: vi.fn(), + clearOne: vi.fn(), + start: vi.fn(), + stop: vi.fn(), +}; + +describe("relay account routes", () => { + let pool: AccountPool; + let app: Hono; + + beforeEach(() => { + vi.clearAllMocks(); + pool = new AccountPool(); + const routes = createAccountRoutes(pool, mockScheduler as never); + app = new Hono(); + app.route("/", routes); + }); + + // ── POST /auth/accounts/relay ──────────────────────────────── + + it("creates a relay account with valid input", async () => { + const res = await app.request("/auth/accounts/relay", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + apiKey: "sk-relay-test", + baseUrl: "https://relay.example.com/backend-api", + label: "Test Relay", + }), + }); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.account.type).toBe("relay"); + expect(data.account.label).toBe("Test Relay"); + expect(data.account.baseUrl).toBe("https://relay.example.com/backend-api"); + expect(data.account.allowedModels).toBeNull(); + expect(data.account.email).toBeNull(); + expect(data.account.expiresAt).toBeNull(); + }); + + it("creates relay with allowedModels", async () => { + const res = await app.request("/auth/accounts/relay", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + apiKey: "sk-relay-test", + baseUrl: "https://relay.example.com/backend-api", + label: "Test Relay", + allowedModels: ["gpt-5.2-codex"], + }), + }); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.account.allowedModels).toEqual(["gpt-5.2-codex"]); + }); + + it("does NOT call scheduler.scheduleOne for relay", async () => { + await app.request("/auth/accounts/relay", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + apiKey: "sk-relay-test", + baseUrl: "https://relay.example.com/backend-api", + label: "Test Relay", + }), + }); + + expect(mockScheduler.scheduleOne).not.toHaveBeenCalled(); + }); + + it("returns 400 for missing apiKey", async () => { + const res = await app.request("/auth/accounts/relay", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: "https://relay.example.com", + label: "Test Relay", + }), + }); + + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toBeDefined(); + }); + + it("returns 400 for missing baseUrl", async () => { + const res = await app.request("/auth/accounts/relay", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + apiKey: "sk-relay-test", + label: "Test Relay", + }), + }); + + expect(res.status).toBe(400); + }); + + it("returns 400 for missing label", async () => { + const res = await app.request("/auth/accounts/relay", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + apiKey: "sk-relay-test", + baseUrl: "https://relay.example.com", + }), + }); + + expect(res.status).toBe(400); + }); + + it("returns 400 for invalid baseUrl", async () => { + const res = await app.request("/auth/accounts/relay", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + apiKey: "sk-relay-test", + baseUrl: "not-a-url", + label: "Bad URL", + }), + }); + + expect(res.status).toBe(400); + }); + + // ── GET /auth/accounts ────────────────────────────────────── + + it("lists relay accounts alongside native accounts", async () => { + // Add a native account + pool.addAccount("jwt-native-token-1234"); + // Add a relay account + pool.addRelayAccount({ + apiKey: "sk-relay", + baseUrl: "https://relay.example.com", + label: "My Relay", + }); + + const res = await app.request("/auth/accounts"); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.accounts.length).toBe(2); + + const native = data.accounts.find((a: Record) => a.type === "native"); + const relay = data.accounts.find((a: Record) => a.type === "relay"); + + expect(native).toBeDefined(); + expect(native.email).toBeTruthy(); + + expect(relay).toBeDefined(); + expect(relay.label).toBe("My Relay"); + expect(relay.baseUrl).toBe("https://relay.example.com"); + expect(relay.email).toBeNull(); + expect(relay.expiresAt).toBeNull(); + }); + + // ── GET /auth/accounts/:id/quota ──────────────────────────── + + it("returns 400 for quota on relay account", async () => { + const id = pool.addRelayAccount({ + apiKey: "sk-relay", + baseUrl: "https://relay.example.com", + label: "Relay", + }); + + const res = await app.request(`/auth/accounts/${id}/quota`); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("relay"); + }); + + // ── DELETE /auth/accounts/:id ─────────────────────────────── + + it("deletes a relay account", async () => { + const id = pool.addRelayAccount({ + apiKey: "sk-relay", + baseUrl: "https://relay.example.com", + label: "Relay", + }); + + const res = await app.request(`/auth/accounts/${id}`, { method: "DELETE" }); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + + expect(pool.getEntry(id)).toBeUndefined(); + }); + + // ── Format support ────────────────────────────────────────── + + it("creates relay with format field", async () => { + const res = await app.request("/auth/accounts/relay", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + apiKey: "sk-relay-test", + baseUrl: "https://api.example.com/v1", + label: "OpenAI Relay", + format: "openai", + }), + }); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.account.format).toBe("openai"); + }); + + it("defaults format to codex", async () => { + const res = await app.request("/auth/accounts/relay", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + apiKey: "sk-relay-test", + baseUrl: "https://relay.example.com", + label: "Default", + }), + }); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.account.format).toBe("codex"); + }); + + it("returns format in account list", async () => { + pool.addRelayAccount({ + apiKey: "sk-relay", + baseUrl: "https://api.example.com/v1", + label: "OpenAI", + format: "openai", + }); + + const res = await app.request("/auth/accounts"); + const data = await res.json(); + const relay = data.accounts.find((a: Record) => a.type === "relay"); + expect(relay.format).toBe("openai"); + }); +}); diff --git a/src/routes/accounts.ts b/src/routes/accounts.ts index 8e6e744..bbc2951 100644 --- a/src/routes/accounts.ts +++ b/src/routes/accounts.ts @@ -35,6 +35,14 @@ const BulkImportSchema = z.object({ })).min(1), }); +const RelayAccountSchema = z.object({ + apiKey: z.string().min(1), + baseUrl: z.string().url(), + label: z.string().min(1), + format: z.enum(["codex", "openai", "anthropic", "gemini"]).default("codex"), + allowedModels: z.array(z.string()).optional(), +}); + export function createAccountRoutes( pool: AccountPool, scheduler: RefreshScheduler, @@ -44,9 +52,10 @@ export function createAccountRoutes( const app = new Hono(); /** Helper: build a CodexApi with cookie + proxy support. */ - function makeApi(entryId: string, token: string, accountId: string | null): CodexApi { + function makeApi(entryId: string, token: string, accountId: string | null, baseUrl?: string | null): CodexApi { const proxyUrl = proxyPool?.resolveProxyUrl(entryId); - return new CodexApi(token, accountId, cookieJar, entryId, proxyUrl); + const jar = baseUrl ? undefined : cookieJar; + return new CodexApi(token, accountId, jar, entryId, proxyUrl, baseUrl); } // Start OAuth flow to add a new account — 302 redirect to Auth0 @@ -113,6 +122,29 @@ export function createAccountRoutes( return c.json({ success: true, added, updated, failed, errors }); }); + // Add relay account (third-party API key + base URL) + app.post("/auth/accounts/relay", async (c) => { + let body: unknown; + try { + body = await c.req.json(); + } catch { + c.status(400); + return c.json({ error: "Malformed JSON request body" }); + } + + const parsed = RelayAccountSchema.safeParse(body); + if (!parsed.success) { + c.status(400); + return c.json({ error: "Invalid request", details: parsed.error.issues }); + } + + const entryId = pool.addRelayAccount(parsed.data); + // No scheduler.scheduleOne() — relay accounts don't use JWT refresh + const accounts = pool.getAccounts(); + const added = accounts.find((a) => a.id === entryId); + return c.json({ success: true, account: added }); + }); + // List all accounts // ?quota=true → return cached quota (fast, from background refresh) // ?quota=fresh → force live fetch from upstream (manual refresh button) @@ -125,7 +157,7 @@ export function createAccountRoutes( const accounts = pool.getAccounts(); const enriched: AccountInfo[] = await Promise.all( accounts.map(async (acct) => { - if (acct.status !== "active") { + if (acct.status !== "active" || acct.type === "relay") { return { ...acct, proxyId: proxyPool?.getAssignment(acct.id) ?? "global", @@ -143,7 +175,7 @@ export function createAccountRoutes( } try { - const api = makeApi(acct.id, entry.token, entry.accountId); + const api = makeApi(acct.id, entry.token, entry.accountId, entry.baseUrl); const usage = await api.getUsage(); const quota = toQuota(usage); // Cache the fresh quota @@ -240,6 +272,11 @@ export function createAccountRoutes( return c.json({ error: "Account not found" }); } + if (entry.type === "relay") { + c.status(400); + return c.json({ error: "Quota API is not available for relay accounts" }); + } + if (entry.status !== "active") { c.status(409); return c.json({ error: `Account is ${entry.status}, cannot query quota` }); @@ -248,7 +285,7 @@ export function createAccountRoutes( const hasCookies = !!(cookieJar?.getCookieHeader(id)); try { - const api = makeApi(id, entry.token, entry.accountId); + const api = makeApi(id, entry.token, entry.accountId, entry.baseUrl); const usage = await api.getUsage(); return c.json({ quota: toQuota(usage), raw: usage }); } catch (err) { diff --git a/src/routes/chat.ts b/src/routes/chat.ts index 8c7e243..d282703 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -10,6 +10,7 @@ import { } from "../translation/codex-to-openai.js"; import { getConfig } from "../config.js"; import { parseModelName, buildDisplayModelName } from "../models/model-store.js"; +import { handleDirectProxy } from "./shared/direct-proxy.js"; import { handleProxyRequest, type FormatAdapter, @@ -121,9 +122,22 @@ export function createChatRoutes( }); } const req = parsed.data; + const displayModel = buildDisplayModelName(parseModelName(req.model)); + + // Try direct proxy via format-compatible relay (skip Codex translation) + const directAcquired = accountPool.acquire({ model: displayModel, format: "openai" }); + if (directAcquired?.format === "openai") { + return handleDirectProxy({ + c, accountPool, acquired: directAcquired, + rawBody: JSON.stringify(body), + upstreamPath: "/chat/completions", + isStreaming: req.stream ?? false, + proxyPool, + }); + } + if (directAcquired) accountPool.releaseWithoutCounting(directAcquired.entryId); const { codexRequest, tupleSchema } = translateToCodexRequest(req); - const displayModel = buildDisplayModelName(parseModelName(req.model)); const wantReasoning = !!req.reasoning_effort; return handleProxyRequest( diff --git a/src/routes/gemini.ts b/src/routes/gemini.ts index 5572f07..4a1adfe 100644 --- a/src/routes/gemini.ts +++ b/src/routes/gemini.ts @@ -21,6 +21,7 @@ import { } from "../translation/codex-to-gemini.js"; import { getConfig } from "../config.js"; import { getModelCatalog } from "../models/model-store.js"; +import { handleDirectProxy } from "./shared/direct-proxy.js"; import { handleProxyRequest, type FormatAdapter, @@ -144,6 +145,19 @@ export function createGeminiRoutes( } const req = validationResult.data; + // Try direct proxy via format-compatible relay (skip Codex translation) + const directAcquired = accountPool.acquire({ model: geminiModel, format: "gemini" }); + if (directAcquired?.format === "gemini") { + return handleDirectProxy({ + c, accountPool, acquired: directAcquired, + rawBody: JSON.stringify(body), + upstreamPath: `/models/${modelActionParam}`, + isStreaming, + proxyPool, + }); + } + if (directAcquired) accountPool.releaseWithoutCounting(directAcquired.entryId); + const { codexRequest, tupleSchema } = translateGeminiToCodexRequest( req, geminiModel, diff --git a/src/routes/messages.ts b/src/routes/messages.ts index aa54161..198f231 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -17,6 +17,7 @@ import { } from "../translation/codex-to-anthropic.js"; import { getConfig } from "../config.js"; import { parseModelName, buildDisplayModelName } from "../models/model-store.js"; +import { handleDirectProxy } from "./shared/direct-proxy.js"; import { handleProxyRequest, type FormatAdapter, @@ -95,6 +96,20 @@ export function createMessagesRoutes( ); } const req = parsed.data; + const displayModel = buildDisplayModelName(parseModelName(req.model)); + + // Try direct proxy via format-compatible relay (skip Codex translation) + const directAcquired = accountPool.acquire({ model: displayModel, format: "anthropic" }); + if (directAcquired?.format === "anthropic") { + return handleDirectProxy({ + c, accountPool, acquired: directAcquired, + rawBody: JSON.stringify(body), + upstreamPath: "/messages", + isStreaming: req.stream ?? false, + proxyPool, + }); + } + if (directAcquired) accountPool.releaseWithoutCounting(directAcquired.entryId); const codexRequest = translateAnthropicToCodexRequest(req); const wantThinking = req.thinking?.type === "enabled" || req.thinking?.type === "adaptive"; diff --git a/src/routes/shared/__tests__/direct-proxy.test.ts b/src/routes/shared/__tests__/direct-proxy.test.ts new file mode 100644 index 0000000..07e3994 --- /dev/null +++ b/src/routes/shared/__tests__/direct-proxy.test.ts @@ -0,0 +1,189 @@ +/** + * Tests for the direct proxy handler (relay accounts with non-codex formats). + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; +import { handleDirectProxy } from "../direct-proxy.js"; +import type { AcquiredAccount } from "../../../auth/types.js"; + +const mockPost = vi.fn(); +const mockSimplePost = vi.fn(); + +vi.mock("../../../tls/transport.js", () => ({ + getTransport: () => ({ + post: mockPost, + simplePost: mockSimplePost, + isImpersonate: () => false, + }), +})); + +// Minimal AccountPool mock +function createMockPool() { + return { + release: vi.fn(), + releaseWithoutCounting: vi.fn(), + markRateLimited: vi.fn(), + }; +} + +function makeAcquired(overrides?: Partial): AcquiredAccount { + return { + entryId: "relay-1", + token: "sk-relay-key", + accountId: null, + type: "relay", + baseUrl: "https://api.example.com/v1", + format: "openai", + ...overrides, + }; +} + +describe("handleDirectProxy", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("streaming: pipes SSE response from relay to client", async () => { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("data: {\"id\":\"1\"}\n\n")); + controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")); + controller.close(); + }, + }); + mockPost.mockResolvedValue({ + status: 200, + headers: new Headers({ "content-type": "text/event-stream" }), + body, + setCookieHeaders: [], + }); + + const pool = createMockPool(); + const app = new Hono(); + app.post("/test", async (c) => { + return handleDirectProxy({ + c, + accountPool: pool as never, + acquired: makeAcquired(), + rawBody: '{"model":"gpt-4","messages":[]}', + upstreamPath: "/chat/completions", + isStreaming: true, + }); + }); + + const res = await app.request("/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + + expect(res.status).toBe(200); + expect(mockPost).toHaveBeenCalledTimes(1); + const [url, headers] = mockPost.mock.calls[0]; + expect(url).toBe("https://api.example.com/v1/chat/completions"); + expect(headers["Authorization"]).toBe("Bearer sk-relay-key"); + expect(headers["Content-Type"]).toBe("application/json"); + // No fingerprint headers + expect(headers["ChatGPT-Account-Id"]).toBeUndefined(); + expect(headers["originator"]).toBeUndefined(); + }); + + it("non-streaming: returns full response body", async () => { + mockSimplePost.mockResolvedValue({ + status: 200, + body: '{"id":"chatcmpl-1","choices":[]}', + }); + + const pool = createMockPool(); + const app = new Hono(); + app.post("/test", async (c) => { + return handleDirectProxy({ + c, + accountPool: pool as never, + acquired: makeAcquired(), + rawBody: '{"model":"gpt-4","messages":[],"stream":false}', + upstreamPath: "/chat/completions", + isStreaming: false, + }); + }); + + const res = await app.request("/test", { + method: "POST", + body: "{}", + }); + + expect(res.status).toBe(200); + const data = await res.text(); + expect(data).toContain("chatcmpl-1"); + expect(mockSimplePost).toHaveBeenCalledTimes(1); + expect(pool.release).toHaveBeenCalledWith("relay-1"); + }); + + it("429: marks account rate limited", async () => { + mockSimplePost.mockResolvedValue({ + status: 429, + body: '{"error":"rate limited"}', + }); + + const pool = createMockPool(); + const app = new Hono(); + app.post("/test", async (c) => { + return handleDirectProxy({ + c, + accountPool: pool as never, + acquired: makeAcquired(), + rawBody: "{}", + upstreamPath: "/chat/completions", + isStreaming: false, + }); + }); + + const res = await app.request("/test", { method: "POST", body: "{}" }); + + expect(res.status).toBe(429); + expect(pool.markRateLimited).toHaveBeenCalledWith("relay-1", { countRequest: true }); + }); + + it("error: releases account and returns 502", async () => { + mockSimplePost.mockRejectedValue(new Error("Connection refused")); + + const pool = createMockPool(); + const app = new Hono(); + app.post("/test", async (c) => { + return handleDirectProxy({ + c, + accountPool: pool as never, + acquired: makeAcquired(), + rawBody: "{}", + upstreamPath: "/chat/completions", + isStreaming: false, + }); + }); + + const res = await app.request("/test", { method: "POST", body: "{}" }); + + expect(res.status).toBe(502); + expect(pool.release).toHaveBeenCalledWith("relay-1"); + }); + + it("no baseUrl: returns 500", async () => { + const pool = createMockPool(); + const app = new Hono(); + app.post("/test", async (c) => { + return handleDirectProxy({ + c, + accountPool: pool as never, + acquired: makeAcquired({ baseUrl: null }), + rawBody: "{}", + upstreamPath: "/chat/completions", + isStreaming: false, + }); + }); + + const res = await app.request("/test", { method: "POST", body: "{}" }); + + expect(res.status).toBe(500); + expect(pool.releaseWithoutCounting).toHaveBeenCalled(); + }); +}); diff --git a/src/routes/shared/direct-proxy.ts b/src/routes/shared/direct-proxy.ts new file mode 100644 index 0000000..cddb4c6 --- /dev/null +++ b/src/routes/shared/direct-proxy.ts @@ -0,0 +1,129 @@ +/** + * Direct proxy handler — forwards raw requests to relay accounts + * without Codex translation. Used for relay accounts with non-codex + * formats (openai, anthropic, gemini). + */ + +import type { Context } from "hono"; +import { stream } from "hono/streaming"; +import { getTransport } from "../../tls/transport.js"; +import type { AccountPool } from "../../auth/account-pool.js"; +import type { AcquiredAccount } from "../../auth/types.js"; +import type { ProxyPool } from "../../proxy/proxy-pool.js"; + +export interface DirectProxyOptions { + c: Context; + accountPool: AccountPool; + acquired: AcquiredAccount; + rawBody: string; + /** Path appended to acquired.baseUrl (e.g. "/chat/completions"). */ + upstreamPath: string; + isStreaming: boolean; + proxyPool?: ProxyPool; +} + +/** + * Forward a raw request to a relay's upstream URL without translation. + * Handles streaming (SSE pipe) and non-streaming (collect + return). + */ +export async function handleDirectProxy(opts: DirectProxyOptions): Promise { + const { c, accountPool, acquired, rawBody, upstreamPath, isStreaming, proxyPool } = opts; + const { entryId, token, baseUrl } = acquired; + + if (!baseUrl) { + accountPool.releaseWithoutCounting(entryId); + c.status(500); + return c.json({ error: "Relay account has no baseUrl" }); + } + + const url = baseUrl + upstreamPath; + const proxyUrl = proxyPool?.resolveProxyUrl(entryId); + const transport = getTransport(); + + const headers: Record = { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json", + }; + + const tag = `[DirectProxy]`; + console.log(`${tag} Account ${entryId} → ${url} (stream=${isStreaming})`); + + try { + if (isStreaming) { + headers["Accept"] = "text/event-stream"; + const transportRes = await transport.post(url, headers, rawBody, undefined, undefined, proxyUrl); + + if (transportRes.status === 429) { + accountPool.markRateLimited(entryId, { countRequest: true }); + c.status(429); + // Read body for error detail + const reader = transportRes.body.getReader(); + const chunks: Uint8Array[] = []; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + return c.body(Buffer.concat(chunks), 429); + } + + if (transportRes.status < 200 || transportRes.status >= 300) { + accountPool.release(entryId); + const reader = transportRes.body.getReader(); + const chunks: Uint8Array[] = []; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + c.status(transportRes.status as 400); + return c.body(Buffer.concat(chunks)); + } + + // Stream response back to client + c.header("Content-Type", "text/event-stream"); + c.header("Cache-Control", "no-cache"); + c.header("Connection", "keep-alive"); + + return stream(c, async (s) => { + const reader = transportRes.body.getReader(); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + try { + await s.write(value); + } catch { + // Client disconnected — stop reading upstream + break; + } + } + } finally { + reader.releaseLock(); + accountPool.release(entryId); + } + }); + } else { + // Non-streaming: collect full response + headers["Accept"] = "application/json"; + const result = await transport.simplePost(url, headers, rawBody, 120, proxyUrl); + + if (result.status === 429) { + accountPool.markRateLimited(entryId, { countRequest: true }); + c.status(429); + return c.body(result.body); + } + + accountPool.release(entryId); + c.status(result.status as 200); + c.header("Content-Type", "application/json"); + return c.body(result.body); + } + } catch (err) { + accountPool.release(entryId); + const msg = err instanceof Error ? err.message : String(err); + console.error(`${tag} Error:`, msg); + c.status(502); + return c.json({ error: { message: `Direct proxy error: ${msg}`, type: "proxy_error" } }); + } +} diff --git a/src/routes/shared/proxy-handler.ts b/src/routes/shared/proxy-handler.ts index b875a34..a1c2474 100644 --- a/src/routes/shared/proxy-handler.ts +++ b/src/routes/shared/proxy-handler.ts @@ -106,9 +106,10 @@ export async function handleProxyRequest( return c.json(fmt.formatNoAccount()); } - const { entryId, token, accountId } = acquired; + const { entryId, token, accountId, type: accountType, baseUrl: accountBaseUrl } = acquired; const proxyUrl = proxyPool?.resolveProxyUrl(entryId); - let codexApi = new CodexApi(token, accountId, cookieJar, entryId, proxyUrl); + const jar = accountType === "relay" ? undefined : cookieJar; + let codexApi = new CodexApi(token, accountId, jar, entryId, proxyUrl, accountBaseUrl); // Tracks which account the outer catch should release (updated by retry loop) let activeEntryId = entryId; // Track tried accounts for model retry exclusion @@ -210,7 +211,8 @@ export async function handleProxyRequest( currentEntryId = newAcquired.entryId; activeEntryId = currentEntryId; const retryProxyUrl = proxyPool?.resolveProxyUrl(newAcquired.entryId); - currentCodexApi = new CodexApi(newAcquired.token, newAcquired.accountId, cookieJar, newAcquired.entryId, retryProxyUrl); + const retryJar = newAcquired.type === "relay" ? undefined : cookieJar; + currentCodexApi = new CodexApi(newAcquired.token, newAcquired.accountId, retryJar, newAcquired.entryId, retryProxyUrl, newAcquired.baseUrl); try { currentRawResponse = await withRetry( () => currentCodexApi.createResponse(req.codexRequest, abortController.signal), @@ -269,7 +271,8 @@ export async function handleProxyRequest( activeEntryId = retry.entryId; triedEntryIds.push(retry.entryId); const retryProxyUrl = proxyPool?.resolveProxyUrl(retry.entryId); - codexApi = new CodexApi(retry.token, retry.accountId, cookieJar, retry.entryId, retryProxyUrl); + const retryJar = retry.type === "relay" ? undefined : cookieJar; + codexApi = new CodexApi(retry.token, retry.accountId, retryJar, retry.entryId, retryProxyUrl, retry.baseUrl); console.log(`[${fmt.tag}] Retrying with account ${retry.entryId}`); continue; // re-enter model retry loop } @@ -302,7 +305,8 @@ export async function handleProxyRequest( activeEntryId = retry.entryId; triedEntryIds.push(retry.entryId); const retryProxyUrl = proxyPool?.resolveProxyUrl(retry.entryId); - codexApi = new CodexApi(retry.token, retry.accountId, cookieJar, retry.entryId, retryProxyUrl); + const retryJar = retry.type === "relay" ? undefined : cookieJar; + codexApi = new CodexApi(retry.token, retry.accountId, retryJar, retry.entryId, retryProxyUrl, retry.baseUrl); console.log(`[${fmt.tag}] 429 fallback → account ${retry.entryId}`); continue; } diff --git a/web/src/App.tsx b/web/src/App.tsx index b1f308f..fc40c01 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,7 @@ import { Header } from "./components/Header"; import { UpdateModal } from "./components/UpdateModal"; import { AccountList } from "./components/AccountList"; import { AddAccount } from "./components/AddAccount"; +import { AddRelayAccount } from "./components/AddRelayAccount"; import { ProxyPool } from "./components/ProxyPool"; import { ApiConfig } from "./components/ApiConfig"; import { AnthropicSetup } from "./components/AnthropicSetup"; @@ -93,6 +94,7 @@ function Dashboard() { <>
setShowModal(true)} checking={update.checking} @@ -110,6 +112,11 @@ function Dashboard() { addInfo={accounts.addInfo} addError={accounts.addError} /> +
-

{email}

+

{displayName}

{plan} - {windowDur && ( + {isRelay && account.baseUrl && ( + + {account.baseUrl.replace(/^https?:\/\//, "")} + + )} + {!isRelay && windowDur && ( {windowDur} )}

+ {isRelay && account.allowedModels && account.allowedModels.length > 0 && ( +

+ {account.allowedModels.join(", ")} +

+ )}
@@ -189,8 +203,8 @@ export function AccountCard({ account, index, onDelete, proxies, onProxyChange,
)} - {/* Quota bars */} - {(rl || srl) && ( + {/* Quota bars (not shown for relay accounts) */} + {!isRelay && (rl || srl) && (
{/* Primary window */} {rl && ( diff --git a/web/src/components/AccountList.tsx b/web/src/components/AccountList.tsx index 03093f4..eca0496 100644 --- a/web/src/components/AccountList.tsx +++ b/web/src/components/AccountList.tsx @@ -127,21 +127,42 @@ export function AccountList({ accounts, loading, onDelete, onRefresh, refreshing
)} -
- {loading ? ( -
- {t("loadingAccounts")} -
- ) : accounts.length === 0 ? ( -
- {t("noAccounts")} -
- ) : ( - accounts.map((acct, i) => ( - - )) - )} -
+ {loading ? ( +
+ {t("loadingAccounts")} +
+ ) : accounts.length === 0 ? ( +
+ {t("noAccounts")} +
+ ) : ( + <> + {/* Native accounts */} + {accounts.filter((a) => a.type !== "relay").length > 0 && ( +
+ {accounts.some((a) => a.type === "relay") && ( +

ChatGPT

+ )} +
+ {accounts.filter((a) => a.type !== "relay").map((acct, i) => ( + + ))} +
+
+ )} + {/* Relay accounts */} + {accounts.filter((a) => a.type === "relay").length > 0 && ( +
+

Relay

+
+ {accounts.filter((a) => a.type === "relay").map((acct, i) => ( + + ))} +
+
+ )} + + )} ); } diff --git a/web/src/components/AddRelayAccount.tsx b/web/src/components/AddRelayAccount.tsx new file mode 100644 index 0000000..22edb9a --- /dev/null +++ b/web/src/components/AddRelayAccount.tsx @@ -0,0 +1,99 @@ +import { useState, useCallback } from "preact/hooks"; + +interface AddRelayAccountProps { + visible: boolean; + onSubmit: (params: { apiKey: string; baseUrl: string; label: string; format?: string; allowedModels?: string[] }) => Promise; + onCancel: () => void; +} + +export function AddRelayAccount({ visible, onSubmit, onCancel }: AddRelayAccountProps) { + const [label, setLabel] = useState(""); + const [baseUrl, setBaseUrl] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [format, setFormat] = useState("codex"); + const [models, setModels] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(""); + + const handleSubmit = useCallback(async () => { + setError(""); + if (!label.trim() || !baseUrl.trim() || !apiKey.trim()) { + setError("Label, Base URL, and API Key are required"); + return; + } + setSubmitting(true); + const allowedModels = models.trim() + ? models.split(",").map((m) => m.trim()).filter(Boolean) + : undefined; + const err = await onSubmit({ apiKey: apiKey.trim(), baseUrl: baseUrl.trim(), label: label.trim(), format, allowedModels }); + setSubmitting(false); + if (err) { + setError(err); + } else { + setLabel(""); + setBaseUrl(""); + setApiKey(""); + setFormat("codex"); + setModels(""); + } + }, [label, baseUrl, apiKey, format, models, onSubmit]); + + if (!visible) return null; + + const inputCls = "w-full px-3 py-2 bg-slate-50 dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-sm font-mono text-slate-600 dark:text-text-main focus:ring-2 focus:ring-primary/50 focus:border-primary outline-none transition-colors"; + + return ( +
+
+

Add Relay Account

+ +
+ {error &&

{error}

} +
+
+
+ + setLabel((e.target as HTMLInputElement).value)} placeholder="My Relay" class={inputCls} /> +
+
+ + +
+
+
+ + setBaseUrl((e.target as HTMLInputElement).value)} placeholder={format === "codex" ? "https://relay.example.com/backend-api" : "https://api.example.com/v1"} class={inputCls} /> +
+
+ + setApiKey((e.target as HTMLInputElement).value)} placeholder="sk-..." class={inputCls} /> +
+
+ + setModels((e.target as HTMLInputElement).value)} placeholder="gpt-5.2-codex, gpt-5.4" class={inputCls} /> +
+ +
+
+ ); +} diff --git a/web/src/components/Header.tsx b/web/src/components/Header.tsx index 6af2d80..5a05321 100644 --- a/web/src/components/Header.tsx +++ b/web/src/components/Header.tsx @@ -30,6 +30,7 @@ function StableText({ tKey, children, class: cls }: { tKey: TranslationKey; chil interface HeaderProps { onAddAccount: () => void; + onAddRelay?: () => void; onCheckUpdate: () => void; onOpenUpdateModal?: () => void; checking: boolean; @@ -41,7 +42,7 @@ interface HeaderProps { hasUpdate?: boolean; } -export function Header({ onAddAccount, onCheckUpdate, onOpenUpdateModal, checking, updateStatusMsg, updateStatusColor, version, commit, isProxySettings, hasUpdate }: HeaderProps) { +export function Header({ onAddAccount, onAddRelay, onCheckUpdate, onOpenUpdateModal, checking, updateStatusMsg, updateStatusColor, version, commit, isProxySettings, hasUpdate }: HeaderProps) { const { lang, toggleLang, t } = useI18n(); const { isDark, toggle: toggleTheme } = useTheme(); @@ -141,6 +142,17 @@ export function Header({ onAddAccount, onCheckUpdate, onOpenUpdateModal, checkin {t("proxySettings")} + {onAddRelay && ( + + )}