+
{(
@@ -5845,6 +5850,7 @@ export default function Accounts() {
["codex", t("accounts.providerViewCodex")],
["grok", t("accounts.providerViewGrok")],
["antigravity", t("accounts.providerViewAntigravity")],
+ ["claude", t("accounts.providerViewClaude")],
] as const
).map(([key, label]) => (
+ );
+ }
+
return (
void;
+ proxies?: ProxyRow[];
groupIds: number[];
onGroupIdsChange: (value: number[]) => void;
groups: AccountGroup[];
@@ -500,6 +504,8 @@ function AccountMetadataFields({
onChange={(event) => onProxyUrlChange(event.target.value)}
placeholder={t("antigravity.proxyUrlPlaceholder")}
/>
+ {/* 从代理池选择:展示每条代理已绑定账号数/空闲,选中写入上面的输入框。 */}
+
@@ -905,6 +911,22 @@ function AntigravityAccounts({ headerSlot }: { headerSlot?: ReactNode } = {}) {
const [accounts, setAccounts] = useState([]);
const [allGroups, setAllGroups] = useState([]);
+ // 代理池:账号弹窗"从代理池选择"下拉的数据源,随页面加载一次;失败静默留空。
+ const [proxyPool, setProxyPool] = useState([]);
+ useEffect(() => {
+ let cancelled = false;
+ void api
+ .listProxies()
+ .then((res) => {
+ if (!cancelled) setProxyPool(res.proxies ?? []);
+ })
+ .catch(() => {
+ if (!cancelled) setProxyPool([]);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
const antigravityGroups = useMemo(
() => allGroups.filter((group) => group.channel === "antigravity"),
[allGroups],
@@ -2207,6 +2229,7 @@ function AntigravityAccounts({ headerSlot }: { headerSlot?: ReactNode } = {}) {
setOAuthDraft((current) => ({ ...current, proxyUrl }))
}
@@ -2533,6 +2556,7 @@ function AntigravityAccounts({ headerSlot }: { headerSlot?: ReactNode } = {}) {
setImportDraft((current) => ({ ...current, proxyUrl }))
}
@@ -2738,6 +2762,7 @@ function AntigravityAccounts({ headerSlot }: { headerSlot?: ReactNode } = {}) {
)}
setEditDraft((current) => ({ ...current, proxyUrl }))
}
diff --git a/frontend/src/pages/ClaudeAccounts.tsx b/frontend/src/pages/ClaudeAccounts.tsx
new file mode 100644
index 000000000..827e750f0
--- /dev/null
+++ b/frontend/src/pages/ClaudeAccounts.tsx
@@ -0,0 +1,415 @@
+import { useCallback, useEffect, useState } from "react";
+import type { ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+
+import { api } from "../api";
+import type { ProxyRow } from "../api";
+import type { AccountRow, ClaudeImportTokenRequest } from "../types";
+import { ProxyPoolSelect } from "../components/ProxyPoolSelect";
+import ChannelLogo from "../components/ChannelLogo";
+import Modal from "../components/Modal";
+import PageHeader from "../components/PageHeader";
+import StatusBadge from "../components/StatusBadge";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { useToast } from "../hooks/useToast";
+import { useConfirmDialog } from "../hooks/useConfirmDialog";
+import { getErrorMessage } from "../utils/error";
+
+// extractCode 从粘贴内容里取授权码:支持整条回调 URL、code#state、或纯 code。
+// 与 cmd/claude_login 的解析保持一致(后端 exchange 端点只收 code)。
+function extractCode(input: string): string {
+ const raw = input.trim();
+ if (!raw) return "";
+ if (raw.startsWith("http://") || raw.startsWith("https://")) {
+ try {
+ const u = new URL(raw);
+ const code = u.searchParams.get("code");
+ if (code) return code.trim();
+ } catch {
+ // fall through
+ }
+ }
+ return raw;
+}
+
+export default function ClaudeAccounts({
+ headerSlot,
+}: {
+ headerSlot?: ReactNode;
+} = {}) {
+ const { t } = useTranslation();
+ const { showToast } = useToast();
+ const { confirm, confirmDialog } = useConfirmDialog();
+
+ const [accounts, setAccounts] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [proxyPool, setProxyPool] = useState([]);
+ const [showAdd, setShowAdd] = useState(false);
+
+ const reload = useCallback(async () => {
+ setLoading(true);
+ try {
+ const res = await api.getAccountsPage({
+ channel: "claude",
+ page: 1,
+ pageSize: 100,
+ sort: "updated_at",
+ order: "desc",
+ });
+ setAccounts(res.accounts ?? []);
+ } catch (error) {
+ showToast(getErrorMessage(error), "error");
+ } finally {
+ setLoading(false);
+ }
+ }, [showToast]);
+
+ useEffect(() => {
+ void reload();
+ }, [reload]);
+
+ useEffect(() => {
+ let cancelled = false;
+ void api
+ .listProxies()
+ .then((res) => {
+ if (!cancelled) setProxyPool(res.proxies ?? []);
+ })
+ .catch(() => {
+ if (!cancelled) setProxyPool([]);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const handleDelete = useCallback(
+ async (acc: AccountRow) => {
+ const ok = await confirm({
+ title: t("claude.deleteConfirm"),
+ description: acc.email || acc.name || `#${acc.id}`,
+ });
+ if (!ok) return;
+ try {
+ await api.deleteAccount(acc.id);
+ void reload();
+ } catch (error) {
+ showToast(getErrorMessage(error), "error");
+ }
+ },
+ [confirm, reload, showToast, t],
+ );
+
+ const handleRefresh = useCallback(
+ async (acc: AccountRow) => {
+ try {
+ await api.refreshAccount(acc.id);
+ void reload();
+ } catch (error) {
+ showToast(getErrorMessage(error), "error");
+ }
+ },
+ [reload, showToast],
+ );
+
+ return (
+
+
void reload()}
+ actions={
+
+ }
+ />
+
+ {loading ? (
+
+ {t("common.loading")}
+
+ ) : accounts.length === 0 ? (
+
+ {t("claude.empty")}
+
+ ) : (
+
+ {accounts.map((acc) => (
+
+
+
+
+
+ {acc.email || acc.name || `#${acc.id}`}
+
+
+ {acc.plan_type || "claude"}
+ {acc.proxy_url ? ` · ${acc.proxy_url}` : ""}
+
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ {showAdd ? (
+ setShowAdd(false)}
+ onAdded={() => {
+ setShowAdd(false);
+ void reload();
+ }}
+ />
+ ) : null}
+ {confirmDialog}
+
+ );
+}
+
+// ClaudeAddModal 提供两种添加方式:网页 OAuth 两步式 / 导入 token JSON。
+function ClaudeAddModal({
+ proxies,
+ onClose,
+ onAdded,
+}: {
+ proxies: ProxyRow[];
+ onClose: () => void;
+ onAdded: () => void;
+}) {
+ const { t } = useTranslation();
+ const { showToast } = useToast();
+ const [tab, setTab] = useState<"oauth" | "import">("oauth");
+
+ // 公共:代理选择 + 时区
+ const [proxyUrl, setProxyUrl] = useState("");
+ const [useProxyPool, setUseProxyPool] = useState(false);
+ const [name, setName] = useState("");
+ const [timezone, setTimezone] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+
+ // OAuth 两步
+ const [authUrl, setAuthUrl] = useState("");
+ const [state, setState] = useState("");
+ const [callback, setCallback] = useState("");
+
+ // Import
+ const [tokenJson, setTokenJson] = useState("");
+
+ const genAuthUrl = useCallback(async () => {
+ try {
+ const res = await api.generateClaudeAuthURL();
+ setAuthUrl(res.auth_url);
+ setState(res.state);
+ window.open(res.auth_url, "_blank", "noopener,noreferrer");
+ } catch (error) {
+ showToast(t("claude.authUrlFailed") + ": " + getErrorMessage(error), "error");
+ }
+ }, [showToast, t]);
+
+ const submitOAuth = useCallback(async () => {
+ const code = extractCode(callback);
+ if (!state || !code) {
+ showToast(t("claude.exchangeFailed"), "error");
+ return;
+ }
+ setSubmitting(true);
+ try {
+ await api.exchangeClaudeOAuthCode({
+ state,
+ code,
+ name: name.trim() || undefined,
+ proxy_url: useProxyPool ? undefined : proxyUrl.trim() || undefined,
+ use_proxy_pool: useProxyPool || undefined,
+ timezone: timezone.trim() || undefined,
+ });
+ showToast(t("claude.added"), "success");
+ onAdded();
+ } catch (error) {
+ showToast(t("claude.exchangeFailed") + ": " + getErrorMessage(error), "error");
+ } finally {
+ setSubmitting(false);
+ }
+ }, [callback, name, onAdded, proxyUrl, showToast, state, t, timezone, useProxyPool]);
+
+ const submitImport = useCallback(async () => {
+ let parsed: Partial;
+ try {
+ parsed = JSON.parse(tokenJson) as Partial;
+ } catch {
+ showToast(t("claude.invalidJson"), "error");
+ return;
+ }
+ if (!parsed.access_token || !parsed.refresh_token) {
+ showToast(t("claude.invalidJson"), "error");
+ return;
+ }
+ setSubmitting(true);
+ try {
+ await api.importClaudeToken({
+ access_token: parsed.access_token,
+ refresh_token: parsed.refresh_token,
+ email: parsed.email,
+ account_id: parsed.account_id,
+ expires_at: parsed.expires_at,
+ name: name.trim() || undefined,
+ proxy_url: useProxyPool ? undefined : proxyUrl.trim() || undefined,
+ use_proxy_pool: useProxyPool || undefined,
+ timezone: timezone.trim() || undefined,
+ });
+ showToast(t("claude.added"), "success");
+ onAdded();
+ } catch (error) {
+ showToast(getErrorMessage(error), "error");
+ } finally {
+ setSubmitting(false);
+ }
+ }, [name, onAdded, proxyUrl, showToast, t, timezone, tokenJson, useProxyPool]);
+
+ const proxyFields = (
+
+ );
+
+ return (
+
+
+ {tab === "oauth" ? (
+
+ ) : (
+
+ )}
+
+ }
+ >
+
+
+
+
+
+
+ {tab === "oauth" ? (
+
+
{t("claude.step1")}
+
+
{t("claude.step2")}
+
setCallback(e.target.value)}
+ placeholder={t("claude.callbackPlaceholder")}
+ />
+ {proxyFields}
+
+ ) : (
+
+
{t("claude.importHint")}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index bfab3ad2d..bcfb12636 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -1,6 +1,41 @@
export type ToastType = 'success' | 'error' | 'warning' | 'info'
export type ISODateString = string
-export type UpstreamChannel = 'codex' | 'grok' | 'antigravity'
+export type UpstreamChannel = 'codex' | 'grok' | 'antigravity' | 'claude'
+
+/** Claude Code OAuth:第一步返回授权 URL 与 state。 */
+export interface ClaudeAuthURLResponse {
+ auth_url: string
+ state: string
+}
+
+/** Claude Code OAuth:第二步用 state+code 换取 token 并入库。 */
+export interface ClaudeExchangeCodeRequest {
+ state: string
+ code: string
+ name?: string
+ proxy_url?: string
+ use_proxy_pool?: boolean
+ timezone?: string
+}
+
+/** Claude Code:直接导入 cmd/claude_login 产出的 token JSON。 */
+export interface ClaudeImportTokenRequest {
+ access_token: string
+ refresh_token: string
+ email?: string
+ account_id?: string
+ expires_at?: string
+ name?: string
+ proxy_url?: string
+ use_proxy_pool?: boolean
+ timezone?: string
+}
+
+export interface ClaudeAddAccountResponse {
+ message: string
+ id: number
+ email?: string
+}
export interface ToastState {
msg: string
diff --git a/proxy/claude_upstream.go b/proxy/claude_upstream.go
new file mode 100644
index 000000000..c04ce4ab6
--- /dev/null
+++ b/proxy/claude_upstream.go
@@ -0,0 +1,304 @@
+package proxy
+
+// Claude Code(Anthropic)OAuth 账号的上游透传。
+//
+// 与其它 relay 账号不同:Grok / OpenAI-Responses 中转都会把请求翻译成 Codex
+// "Responses" 协议再出站,而 Claude 账号本身就说 Anthropic Messages API,因此这里
+// 采用近乎透传——把入站的原始 Anthropic body 直接发往 api.anthropic.com/v1/messages,
+// 仅注入 OAuth 凭据要求的三件套:
+// - Authorization: Bearer
+// - anthropic-beta: oauth-2025-04-20(与入站已声明的 beta 合并去重)
+// - system 数组首块必须是 "You are Claude Code, Anthropic's official CLI for Claude."
+// 否则 Anthropic 会拒绝 OAuth token 的推理请求。
+//
+// 返回原始 *http.Response 交由调用方按 SSE 流式回传,响应本身已是 Anthropic 格式,
+// 无需再做协议翻译。
+
+import (
+ "bytes"
+ "context"
+ "net/http"
+ "strings"
+
+ "github.com/codex2api/auth"
+ "github.com/tidwall/gjson"
+ "github.com/tidwall/sjson"
+ "golang.org/x/text/unicode/norm"
+)
+
+const (
+ // claudeMessagesEndpoint 是 Anthropic 官方 Messages API 端点。
+ claudeMessagesEndpoint = "https://api.anthropic.com/v1/messages"
+ // claudeAnthropicVersion 是 Messages API 版本头。
+ claudeAnthropicVersion = "2023-06-01"
+ // claudeCodeSystemPreamble 是 OAuth 凭据要求的首个 system 块文本。
+ claudeCodeSystemPreamble = "You are Claude Code, Anthropic's official CLI for Claude."
+)
+
+// claudeCodeSystemBlockJSON 是注入到 system 数组首位的块(带 ephemeral 缓存标记,
+// 与官方客户端一致)。
+const claudeCodeSystemBlockJSON = `{"type":"text","text":"You are Claude Code, Anthropic's official CLI for Claude.","cache_control":{"type":"ephemeral"}}`
+
+// claudeAccountSupportsModel 判断 Claude Code OAuth 账号能否服务指定模型。
+// 若账号设置了显式 Models 白名单,以白名单为准;否则默认放行 claude-* 模型。
+func claudeAccountSupportsModel(account *auth.Account, model string) bool {
+ if account == nil {
+ return false
+ }
+ model = strings.TrimSpace(model)
+ if model == "" {
+ return false
+ }
+ account.Mu().RLock()
+ whitelist := append([]string(nil), account.Models...)
+ account.Mu().RUnlock()
+ if len(whitelist) > 0 {
+ for _, m := range whitelist {
+ if strings.EqualFold(strings.TrimSpace(m), model) {
+ return true
+ }
+ }
+ return false
+ }
+ return strings.HasPrefix(strings.ToLower(model), "claude")
+}
+
+// markClaudeNativeRoute 给 Claude 上游响应打上原生路由标记,复用 handler 里既有的
+// 原生 Anthropic Messages SSE 透传路径(forwardGrokNativeResponseTo),无需新写流式
+// 处理。标记头名沿用现有常量,语义为"上游已是原生目标协议,直接转发不再翻译"。
+func markClaudeNativeRoute(resp *http.Response) {
+ if resp != nil && resp.Header != nil {
+ resp.Header.Set(grokNativeRouteHeader, "1")
+ }
+}
+
+// ExecuteClaudeMessagesRequest 把入站 Anthropic Messages 请求透传给 Claude Code
+// OAuth 账号对应的上游,返回原始上游响应。
+func ExecuteClaudeMessagesRequest(ctx context.Context, account *auth.Account, requestBody []byte, proxyOverride string, headers http.Header) (*http.Response, error) {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ if account == nil {
+ return nil, ErrNoAvailableAccount()
+ }
+
+ account.Mu().RLock()
+ accessToken := strings.TrimSpace(account.AccessToken)
+ proxyURL := account.ProxyURL
+ // 该账号绑定的稳定指纹(导入时生成,存于 credentials.custom_headers)。
+ fingerprint := cloneStringMap(account.CustomHeaders)
+ account.Mu().RUnlock()
+ if proxyOverride != "" {
+ proxyURL = proxyOverride
+ }
+ if accessToken == "" {
+ return nil, ErrNoAvailableAccount()
+ }
+
+ // 安全净化:去零宽/控制字符 + NFC 归一。不改变可见文字与语义,只让请求更"正常"。
+ body := sanitizeClaudeRequestText(requestBody)
+ body = injectClaudeCodeSystemPrompt(body)
+ stream := gjson.GetBytes(body, "stream").Bool()
+
+ client := getPooledClient(account, proxyURL)
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, claudeMessagesEndpoint, bytes.NewReader(body))
+ if err != nil {
+ return nil, ErrInternalError("创建 Claude 请求失败", err)
+ }
+ applyClaudeMessagesHeaders(req, accessToken, headers, stream, fingerprint)
+
+ resp, err := client.Do(req)
+ if err != nil {
+ if shouldRecyclePooledClient(err) {
+ recyclePooledClient(account, proxyURL)
+ }
+ return nil, ErrUpstream(0, "请求 Anthropic Messages API 失败", err)
+ }
+ return resp, nil
+}
+
+// applyClaudeMessagesHeaders 设置透传请求头。
+//
+// 指纹一致性策略:
+// - 若入站是**真实 Claude Code 客户端**(自带 user-agent / x-stainless-* 身份头),
+// 原样保留其身份——它本身就是一致的,伪造反而破坏一致性。
+// - 若入站缺该身份头(如 OpenAI SDK 等非原生客户端),用该账号绑定的稳定指纹补齐,
+// 使这个账号对外始终呈现同一套 Claude Code 身份。
+//
+// fingerprint 为账号绑定指纹头(规范化头名→值),来自 credentials.custom_headers。
+func applyClaudeMessagesHeaders(req *http.Request, accessToken string, incoming http.Header, stream bool, fingerprint map[string]string) {
+ req.Header.Set("Authorization", "Bearer "+accessToken)
+ req.Header.Set("Content-Type", "application/json")
+ // anthropic-version:优先保留入站真实客户端的值。
+ if v := strings.TrimSpace(incoming.Get("anthropic-version")); v != "" {
+ req.Header.Set("anthropic-version", v)
+ } else {
+ req.Header.Set("anthropic-version", claudeAnthropicVersion)
+ }
+ req.Header.Set("anthropic-beta", mergeAnthropicBeta(incoming))
+ // OAuth 凭据不带 x-api-key;若入站客户端塞了,务必剔除避免冲突。
+ req.Header.Del("x-api-key")
+ if stream {
+ req.Header.Set("Accept", "text/event-stream")
+ } else {
+ req.Header.Set("Accept", "application/json")
+ }
+
+ // 指纹 map 键大小写不定(来自 custom_headers),统一小写后按小写头名查。
+ fpLower := make(map[string]string, len(fingerprint))
+ for k, v := range fingerprint {
+ fpLower[strings.ToLower(strings.TrimSpace(k))] = v
+ }
+ // 身份头:入站有则保留,无则用账号指纹补齐。
+ for _, name := range auth.ClaudeIdentityHeaderNames {
+ if v := strings.TrimSpace(incoming.Get(name)); v != "" {
+ req.Header.Set(name, v)
+ continue
+ }
+ if v := strings.TrimSpace(fpLower[name]); v != "" {
+ req.Header.Set(name, v)
+ }
+ }
+ // 保底:连指纹都没有(老账号未生成指纹)时,给一个稳定的默认 UA,避免空 UA 破绽。
+ if strings.TrimSpace(req.Header.Get("User-Agent")) == "" {
+ req.Header.Set("User-Agent", "claude-cli/2.1.220 (external, cli)")
+ }
+}
+
+func cloneStringMap(m map[string]string) map[string]string {
+ if len(m) == 0 {
+ return nil
+ }
+ out := make(map[string]string, len(m))
+ for k, v := range m {
+ out[k] = v
+ }
+ return out
+}
+
+// claudeInvisibleRunes 是应从请求文字中剔除的不可见/格式字符:零宽、词连接符、
+// BOM、以及会误导审核/看起来像规避手段的双向控制符。剔除它们让请求更"正常"、
+// 反而降低被标记概率,且不改变可见文字与语义。
+func claudeInvisibleRune(r rune) bool {
+ switch r {
+ case 0x200B, 0x200C, 0x200D, // zero-width space / non-joiner / joiner
+ 0x2060, 0xFEFF, // word joiner / BOM (zero-width no-break space)
+ 0x180E, // mongolian vowel separator
+ 0x202A, 0x202B, 0x202C, 0x202D, 0x202E, // bidi embedding / override / pop
+ 0x2066, 0x2067, 0x2068, 0x2069: // bidi isolates
+ return true
+ }
+ return false
+}
+
+// sanitizeClaudeRequestText 对请求体做安全净化:Unicode NFC 归一 + 剔除不可见/双向
+// 控制字符。JSON 的结构字符与键均为 ASCII,不受影响;仅规范化字符串值内的文字。
+// 净化后若不再是合法 JSON(理论上不会),回退原始体。
+func sanitizeClaudeRequestText(body []byte) []byte {
+ if len(body) == 0 || !gjson.ValidBytes(body) {
+ return body
+ }
+ normalized := norm.NFC.String(string(body))
+ var b strings.Builder
+ b.Grow(len(normalized))
+ changed := len(normalized) != len(body)
+ for _, r := range normalized {
+ if claudeInvisibleRune(r) {
+ changed = true
+ continue
+ }
+ b.WriteRune(r)
+ }
+ if !changed {
+ return body
+ }
+ out := []byte(b.String())
+ if !gjson.ValidBytes(out) {
+ return body
+ }
+ return out
+}
+
+// mergeAnthropicBeta 把入站声明的 anthropic-beta 与 OAuth 必需的 oauth-2025-04-20
+// 合并去重,保证 OAuth 头始终在列。
+func mergeAnthropicBeta(incoming http.Header) string {
+ seen := map[string]struct{}{}
+ ordered := make([]string, 0, 4)
+ add := func(raw string) {
+ for _, part := range strings.Split(raw, ",") {
+ v := strings.TrimSpace(part)
+ if v == "" {
+ continue
+ }
+ key := strings.ToLower(v)
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ ordered = append(ordered, v)
+ }
+ }
+ if incoming != nil {
+ add(strings.Join(incoming.Values("anthropic-beta"), ","))
+ }
+ add(auth.ClaudeOAuthBeta)
+ return strings.Join(ordered, ",")
+}
+
+// injectClaudeCodeSystemPrompt 保证请求的 system 数组首块是 Claude Code 声明块。
+// 兼容三种入站形态:无 system / system 为字符串 / system 为块数组;若首块已是该声明
+// 则原样返回,避免重复注入。
+func injectClaudeCodeSystemPrompt(body []byte) []byte {
+ if !gjson.ValidBytes(body) {
+ return body
+ }
+ system := gjson.GetBytes(body, "system")
+
+ switch {
+ case !system.Exists() || system.Type == gjson.Null:
+ out, err := sjson.SetRawBytes(body, "system", []byte("["+claudeCodeSystemBlockJSON+"]"))
+ if err != nil {
+ return body
+ }
+ return out
+
+ case system.Type == gjson.String:
+ // 字符串 system → [声明块, {原文本块}]
+ orig := system.String()
+ if strings.HasPrefix(strings.TrimSpace(orig), claudeCodeSystemPreamble) {
+ return body // 已以声明开头,转成数组即可但无需重复
+ }
+ textBlock, err := sjson.SetBytes([]byte(`{"type":"text"}`), "text", orig)
+ if err != nil {
+ return body
+ }
+ raw := "[" + claudeCodeSystemBlockJSON + "," + string(textBlock) + "]"
+ out, err := sjson.SetRawBytes(body, "system", []byte(raw))
+ if err != nil {
+ return body
+ }
+ return out
+
+ case system.IsArray():
+ arr := system.Array()
+ if len(arr) > 0 && strings.HasPrefix(strings.TrimSpace(arr[0].Get("text").String()), claudeCodeSystemPreamble) {
+ return body // 首块已是声明,不重复注入
+ }
+ raw := system.Raw
+ inner := strings.TrimSpace(raw)
+ inner = strings.TrimPrefix(inner, "[")
+ inner = strings.TrimSuffix(inner, "]")
+ var newArr string
+ if strings.TrimSpace(inner) == "" {
+ newArr = "[" + claudeCodeSystemBlockJSON + "]"
+ } else {
+ newArr = "[" + claudeCodeSystemBlockJSON + "," + inner + "]"
+ }
+ out, err := sjson.SetRawBytes(body, "system", []byte(newArr))
+ if err != nil {
+ return body
+ }
+ return out
+ }
+ return body
+}
diff --git a/proxy/claude_upstream_test.go b/proxy/claude_upstream_test.go
new file mode 100644
index 000000000..e8d0bec1c
--- /dev/null
+++ b/proxy/claude_upstream_test.go
@@ -0,0 +1,152 @@
+package proxy
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/tidwall/gjson"
+)
+
+func TestInjectClaudeCodeSystemPrompt_Absent(t *testing.T) {
+ body := []byte(`{"model":"claude-x","messages":[]}`)
+ out := injectClaudeCodeSystemPrompt(body)
+ sys := gjson.GetBytes(out, "system")
+ if !sys.IsArray() || sys.Array()[0].Get("text").String() != claudeCodeSystemPreamble {
+ t.Fatalf("首块应为 Claude Code 声明, got=%s", sys.Raw)
+ }
+}
+
+func TestInjectClaudeCodeSystemPrompt_String(t *testing.T) {
+ body := []byte(`{"system":"be helpful","messages":[]}`)
+ out := injectClaudeCodeSystemPrompt(body)
+ sys := gjson.GetBytes(out, "system")
+ arr := sys.Array()
+ if len(arr) != 2 {
+ t.Fatalf("应为 [声明块, 原文本块], got len=%d raw=%s", len(arr), sys.Raw)
+ }
+ if arr[0].Get("text").String() != claudeCodeSystemPreamble {
+ t.Errorf("首块应为声明, got=%s", arr[0].Raw)
+ }
+ if arr[1].Get("text").String() != "be helpful" {
+ t.Errorf("次块应保留原文本, got=%s", arr[1].Raw)
+ }
+}
+
+func TestInjectClaudeCodeSystemPrompt_Array(t *testing.T) {
+ body := []byte(`{"system":[{"type":"text","text":"custom"}],"messages":[]}`)
+ out := injectClaudeCodeSystemPrompt(body)
+ arr := gjson.GetBytes(out, "system").Array()
+ if len(arr) != 2 || arr[0].Get("text").String() != claudeCodeSystemPreamble || arr[1].Get("text").String() != "custom" {
+ t.Fatalf("应在数组首位插入声明块, got=%s", gjson.GetBytes(out, "system").Raw)
+ }
+}
+
+func TestInjectClaudeCodeSystemPrompt_AlreadyPresent(t *testing.T) {
+ body := []byte(`{"system":[{"type":"text","text":"You are Claude Code, Anthropic's official CLI for Claude.","cache_control":{"type":"ephemeral"}},{"type":"text","text":"x"}],"messages":[]}`)
+ out := injectClaudeCodeSystemPrompt(body)
+ arr := gjson.GetBytes(out, "system").Array()
+ if len(arr) != 2 {
+ t.Fatalf("首块已是声明,不应重复注入, got len=%d", len(arr))
+ }
+}
+
+func TestInjectClaudeCodeSystemPrompt_PreservesOtherFields(t *testing.T) {
+ body := []byte(`{"model":"claude-x","max_tokens":100,"messages":[{"role":"user","content":"hi"}]}`)
+ out := injectClaudeCodeSystemPrompt(body)
+ if gjson.GetBytes(out, "model").String() != "claude-x" || gjson.GetBytes(out, "max_tokens").Int() != 100 {
+ t.Fatal("注入不应破坏其它字段")
+ }
+ if gjson.GetBytes(out, "messages.0.content").String() != "hi" {
+ t.Fatal("messages 应保留")
+ }
+}
+
+func TestMergeAnthropicBeta(t *testing.T) {
+ h := http.Header{}
+ h.Set("anthropic-beta", "foo-1, bar-2")
+ got := mergeAnthropicBeta(h)
+ // 必须包含 oauth beta 且入站的两个 beta 都在
+ for _, want := range []string{"oauth-2025-04-20", "foo-1", "bar-2"} {
+ if !strings.Contains(got, want) {
+ t.Errorf("合并结果缺少 %s: %s", want, got)
+ }
+ }
+}
+
+func TestMergeAnthropicBeta_Dedup(t *testing.T) {
+ h := http.Header{}
+ h.Set("anthropic-beta", "oauth-2025-04-20")
+ got := mergeAnthropicBeta(h)
+ if strings.Count(got, "oauth-2025-04-20") != 1 {
+ t.Fatalf("oauth beta 应去重, got=%s", got)
+ }
+}
+
+func TestMergeAnthropicBeta_Empty(t *testing.T) {
+ got := mergeAnthropicBeta(nil)
+ if got != "oauth-2025-04-20" {
+ t.Fatalf("空入站时应仅有 oauth beta, got=%s", got)
+ }
+}
+
+func TestSanitizeClaudeRequestText_StripsZeroWidth(t *testing.T) {
+ // 把字面 UTF-8 零宽空格(U+200B)与 BOM(U+FEFF)直接拼进 JSON 字符串值,
+ // 模拟真实客户端发送的未转义不可见字符(runtime 构造,源码不含 BOM)。
+ content := "he" + string(rune(0x200B)) + "llo" + string(rune(0xFEFF)) + " world"
+ body := []byte(`{"messages":[{"role":"user","content":"` + content + `"}]}`)
+ out := sanitizeClaudeRequestText(body)
+ got := gjson.GetBytes(out, "messages.0.content").String()
+ if got != "hello world" {
+ t.Fatalf("零宽/BOM 未被清理: %q", got)
+ }
+ if !gjson.ValidBytes(out) {
+ t.Fatal("净化后应仍是合法 JSON")
+ }
+}
+
+func TestSanitizeClaudeRequestText_KeepsNormal(t *testing.T) {
+ body := []byte(`{"model":"claude-x","messages":[{"role":"user","content":"正常中文与English混排"}]}`)
+ out := sanitizeClaudeRequestText(body)
+ if gjson.GetBytes(out, "messages.0.content").String() != "正常中文与English混排" {
+ t.Fatal("正常文字不应被改动")
+ }
+}
+
+func TestApplyClaudeMessagesHeaders_PreservesIncoming(t *testing.T) {
+ req, _ := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", nil)
+ incoming := http.Header{}
+ incoming.Set("user-agent", "claude-cli/9.9.9 (external, cli)")
+ incoming.Set("x-stainless-os", "MacOS")
+ fp := map[string]string{"User-Agent": "claude-cli/1.0.0 (external, cli)", "X-Stainless-OS": "Linux"}
+ applyClaudeMessagesHeaders(req, "tok", incoming, false, fp)
+ // 入站真实客户端头应优先保留,不被指纹覆盖。
+ if req.Header.Get("User-Agent") != "claude-cli/9.9.9 (external, cli)" {
+ t.Fatalf("应保留入站 UA, got %s", req.Header.Get("User-Agent"))
+ }
+ if req.Header.Get("X-Stainless-Os") != "MacOS" {
+ t.Fatalf("应保留入站 x-stainless-os, got %s", req.Header.Get("X-Stainless-Os"))
+ }
+ if req.Header.Get("Authorization") != "Bearer tok" {
+ t.Fatal("Authorization 应被设置")
+ }
+}
+
+func TestApplyClaudeMessagesHeaders_UsesFingerprintWhenAbsent(t *testing.T) {
+ req, _ := http.NewRequest("POST", "https://api.anthropic.com/v1/messages", nil)
+ fp := map[string]string{
+ "User-Agent": "claude-cli/2.1.220 (external, cli)",
+ "X-App": "cli",
+ "X-Stainless-OS": "Linux",
+ }
+ applyClaudeMessagesHeaders(req, "tok", http.Header{}, false, fp)
+ if req.Header.Get("User-Agent") != "claude-cli/2.1.220 (external, cli)" {
+ t.Fatalf("缺入站头时应用指纹 UA, got %s", req.Header.Get("User-Agent"))
+ }
+ if req.Header.Get("X-App") != "cli" {
+ t.Fatalf("应用指纹 x-app, got %s", req.Header.Get("X-App"))
+ }
+ if req.Header.Get("Anthropic-Beta") == "" || !strings.Contains(req.Header.Get("Anthropic-Beta"), "oauth-2025-04-20") {
+ t.Fatal("anthropic-beta 应含 oauth")
+ }
+}
diff --git a/proxy/handler.go b/proxy/handler.go
index ca91b958e..2024b426b 100644
--- a/proxy/handler.go
+++ b/proxy/handler.go
@@ -493,6 +493,11 @@ func relayAccountSupportsModel(account *auth.Account, model string) bool {
if account == nil {
return false
}
+ // Claude Code OAuth 账号服务 claude-* 模型;显式 Models 白名单优先收窄。
+ // 该分支对所有非 claude 账号恒不进入,保持既有准入行为不变。
+ if account.IsClaudeOAuth() {
+ return claudeAccountSupportsModel(account, model)
+ }
if account.IsAntigravityAPI() {
if !account.AntigravityDispatchEnabled() {
return false
diff --git a/proxy/handler_anthropic.go b/proxy/handler_anthropic.go
index b5a7091dd..699caaafa 100644
--- a/proxy/handler_anthropic.go
+++ b/proxy/handler_anthropic.go
@@ -103,6 +103,24 @@ func (h *Handler) applyMessagesModelMapping(codexBody []byte, supportedModels []
return codexBody
}
+// hasNativeClaudeAccountForModel 判断池中是否有能服务该模型的 Claude Code OAuth
+// 账号(据此决定 /v1/messages 是走原生 claude 透传还是 Codex 翻译兜底)。
+func (h *Handler) hasNativeClaudeAccountForModel(model string) bool {
+ if h == nil || h.store == nil {
+ return false
+ }
+ model = strings.TrimSpace(model)
+ if model == "" {
+ return false
+ }
+ for _, account := range h.store.Accounts() {
+ if account != nil && account.IsClaudeOAuth() && claudeAccountSupportsModel(account, model) {
+ return true
+ }
+ }
+ return false
+}
+
// resolveMessagesRoutingBody 用廉价 stub 完成模型映射与 effort/tier 提取,
// 避免在选号前把整段 Anthropic messages 转成有损 Codex Responses。
func (h *Handler) resolveMessagesRoutingBody(rawBody []byte, requestedModel string, supportedModels []string) []byte {
@@ -111,6 +129,12 @@ func (h *Handler) resolveMessagesRoutingBody(rawBody []byte, requestedModel stri
mappingJSON = h.store.GetModelMapping()
}
mapped := resolveAnthropicModel(requestedModel, mappingJSON, supportedModels)
+ // 原生 Claude 路由:若存在能服务该模型的 Claude Code OAuth 账号,则保持原生
+ // 模型 ID,交由 claude 账号原生透传;否则维持既有 Codex 翻译兜底(claude-* →
+ // gpt-5.4),不影响没有 claude 账号、靠 Codex 服务 /v1/messages 的用户。
+ if h.hasNativeClaudeAccountForModel(requestedModel) {
+ mapped = strings.TrimSpace(requestedModel)
+ }
stub, err := sjson.SetBytes([]byte(`{}`), "model", mapped)
if err != nil {
stub = []byte(`{"model":"` + mapped + `"}`)
@@ -364,7 +388,18 @@ func (h *Handler) Messages(c *gin.Context) {
ttftGuard := newFirstTokenTimeoutGuard(currentFirstTokenTimeout(), upstreamCancel)
var resp *http.Response
var reqErr error
- if isRelayAccount {
+ if account.IsClaudeOAuth() {
+ // Claude Code OAuth 账号本身说 Anthropic Messages API:不翻译成 Codex,
+ // 直接把原始入站 body 透传到 api.anthropic.com/v1/messages;返回的响应
+ // 已是原生 Anthropic SSE,打上原生路由标记复用既有透传链路。
+ resp, reqErr = executeHTTPWithContinuousRetryKeepalive(upstreamCtx, func() (*http.Response, error) {
+ r, e := ExecuteClaudeMessagesRequest(upstreamCtx, account, rawBody, proxyURL, downstreamHeaders)
+ if e == nil {
+ markClaudeNativeRoute(r)
+ }
+ return r, e
+ })
+ } else if isRelayAccount {
upstreamBody := routingBody
if !account.IsGrokAPI() {
var translateErr error