+
{(
@@ -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..95dd57029 100644
--- a/proxy/handler_anthropic.go
+++ b/proxy/handler_anthropic.go
@@ -364,7 +364,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
From d03a515d54753a67dde36f828b80070917d5e0ee Mon Sep 17 00:00:00 2001
From: hu <187184415@qq.com>
Date: Mon, 31 Aug 2026 20:35:31 +0800
Subject: [PATCH 2/3] feat(claude): add secure credentials and native routing
---
auth/claude_oauth.go | 98 ++++++++++++-----
cmd/claude_login/main.go | 29 ++++-
database/credential_crypto.go | 165 +++++++++++++++++++++++++++++
database/credential_crypto_test.go | 162 ++++++++++++++++++++++++++++
database/data_migrations.go | 2 +-
database/grok_state.go | 10 +-
database/helpers.go | 2 +
database/postgres.go | 30 +++---
proxy/handler_anthropic.go | 24 +++++
9 files changed, 477 insertions(+), 45 deletions(-)
create mode 100644 database/credential_crypto.go
create mode 100644 database/credential_crypto_test.go
diff --git a/auth/claude_oauth.go b/auth/claude_oauth.go
index e106801b3..13c42dc6e 100644
--- a/auth/claude_oauth.go
+++ b/auth/claude_oauth.go
@@ -120,20 +120,77 @@ type claudeAuthCodeExchangeRequest struct {
}
// ClaudeAuth 封装 Claude OAuth 登录/刷新所需的 HTTP 客户端。
-// 通过 uTLS 指纹客户端出站,规避 Anthropic 域名上的 Cloudflare 指纹拦截。
+//
+// 采用主/备双客户端 + 自动回退:
+// - primary:uTLS 浏览器指纹客户端,规避 Anthropic 域名上的 Cloudflare 指纹拦截;
+// - fallback:标准 http 客户端(ALPN 自动协商 h1/h2,兼容性更好)。
+// 当 primary 出现传输错误或被判定为挑战(403)时,自动改用 fallback 重试。这样无论
+// 拦截来自指纹、强制 h2 还是网络层,都能提高登录/刷新成功率。
type ClaudeAuth struct {
- httpClient *http.Client
+ primary *http.Client
+ fallback *http.Client
}
// NewClaudeAuth 创建一个 Claude OAuth 客户端。proxyURL 为空时走直连。
func NewClaudeAuth(proxyURL string) *ClaudeAuth {
- client := buildUTLSHTTPClient(strings.TrimSpace(proxyURL))
- if client == nil {
- client = &http.Client{Timeout: claudeOAuthHTTPTimeout}
- } else if client.Timeout == 0 {
- client.Timeout = claudeOAuthHTTPTimeout
+ proxyURL = strings.TrimSpace(proxyURL)
+ primary := buildUTLSHTTPClient(proxyURL)
+ if primary == nil {
+ primary = buildPlainClaudeOAuthClient(proxyURL)
+ } else if primary.Timeout == 0 {
+ primary.Timeout = claudeOAuthHTTPTimeout
+ }
+ return &ClaudeAuth{primary: primary, fallback: buildPlainClaudeOAuthClient(proxyURL)}
+}
+
+// buildPlainClaudeOAuthClient 构建标准(非 uTLS)代理感知 HTTP 客户端,用作回退。
+func buildPlainClaudeOAuthClient(proxyURL string) *http.Client {
+ tr := http.DefaultTransport.(*http.Transport).Clone()
+ if strings.TrimSpace(proxyURL) != "" {
+ _ = ConfigureTransportProxy(tr, proxyURL, nil)
}
- return &ClaudeAuth{httpClient: client}
+ return &http.Client{Transport: tr, Timeout: claudeOAuthHTTPTimeout}
+}
+
+// doWithFallback 用 primary 发送请求;传输错误或 403 挑战时,用 fallback 以全新请求
+// 重试。bodyBytes 为请求体(GET 传 nil);decorate 用于附加 Authorization 等额外头。
+func (o *ClaudeAuth) doWithFallback(ctx context.Context, method, url string, bodyBytes []byte, decorate func(*http.Request)) (*http.Response, error) {
+ build := func() (*http.Request, error) {
+ var body io.Reader
+ if bodyBytes != nil {
+ body = bytes.NewReader(bodyBytes)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, url, body)
+ if err != nil {
+ return nil, err
+ }
+ applyClaudeOAuthAxiosHeaders(req)
+ if decorate != nil {
+ decorate(req)
+ }
+ return req, nil
+ }
+
+ req, err := build()
+ if err != nil {
+ return nil, err
+ }
+ resp, err := o.primary.Do(req)
+ if err == nil && resp.StatusCode != http.StatusForbidden {
+ return resp, nil
+ }
+ // primary 传输失败或被 403 挑战 → 用标准客户端重试。
+ if resp != nil {
+ _ = resp.Body.Close()
+ }
+ retryReq, buildErr := build()
+ if buildErr != nil {
+ if err != nil {
+ return nil, err
+ }
+ return nil, buildErr
+ }
+ return o.fallback.Do(retryReq)
}
// GenerateClaudePKCE 生成一对 PKCE 校验码(S256)。
@@ -337,14 +394,9 @@ func (o *ClaudeAuth) FetchProfile(ctx context.Context, accessToken string) (*cla
if ctx == nil {
ctx = context.Background()
}
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, ClaudeOAuthProfileURL, nil)
- if err != nil {
- return nil, fmt.Errorf("创建 profile 请求失败: %w", err)
- }
- applyClaudeOAuthAxiosHeaders(req)
- req.Header.Set("Authorization", "Bearer "+accessToken)
-
- resp, err := o.httpClient.Do(req)
+ resp, err := o.doWithFallback(ctx, http.MethodGet, ClaudeOAuthProfileURL, nil, func(req *http.Request) {
+ req.Header.Set("Authorization", "Bearer "+accessToken)
+ })
if err != nil {
return nil, fmt.Errorf("profile 请求失败: %w", err)
}
@@ -369,13 +421,7 @@ func (o *ClaudeAuth) FetchProfile(ctx context.Context, accessToken string) (*cla
// doClaudeOAuthPost 发送一个 axios 伪装的 OAuth POST,返回解码后的响应体与状态码。
func (o *ClaudeAuth) doClaudeOAuthPost(ctx context.Context, endpoint string, jsonBody []byte) ([]byte, int, error) {
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(jsonBody))
- if err != nil {
- return nil, 0, fmt.Errorf("创建 OAuth 请求失败: %w", err)
- }
- applyClaudeOAuthAxiosHeaders(req)
-
- resp, err := o.httpClient.Do(req)
+ resp, err := o.doWithFallback(ctx, http.MethodPost, endpoint, jsonBody, nil)
if err != nil {
return nil, 0, fmt.Errorf("OAuth 请求失败: %w", err)
}
@@ -398,8 +444,10 @@ func applyClaudeOAuthAxiosHeaders(req *http.Request) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "axios/1.15.2")
req.Header.Set("Accept-Encoding", "gzip, compress, deflate, br")
- req.Header.Set("Connection", "close")
- req.Close = true
+ // 注意:本模块的 HTTP 客户端是 HTTP/2(buildUTLSHTTPClient 强制 h2)。HTTP/2
+ // 协议禁止 Connection / Keep-Alive 等逐跳头,设置它们会让 Go 的 http2 transport
+ // 直接以 "invalid Connection request header" 拒发请求(登录/刷新全失败)。因此这里
+ // 不设置 Connection: close 也不置 req.Close——h2 本就不携带这些头。
}
// readClaudeOAuthResponseBody 读取并按 Content-Encoding 解码响应体。
diff --git a/cmd/claude_login/main.go b/cmd/claude_login/main.go
index 6893d4088..8c1d7ed67 100644
--- a/cmd/claude_login/main.go
+++ b/cmd/claude_login/main.go
@@ -69,9 +69,12 @@ func runStart(sessionPath string) {
fmt.Println(" " + session.AuthURL)
fmt.Println()
fmt.Println("授权后浏览器会跳转到 http://localhost:54545/callback?code=...(页面打不开属正常)。")
- fmt.Println("复制【整条地址栏 URL】或只复制 code 值,然后执行第二步:")
+ fmt.Println("【推荐】只复制 code= 与 &state= 之间那段纯 code 值(无特殊字符,最省事):")
fmt.Println()
- fmt.Println(" go run ./cmd/claude_login -code \"把整条回调URL或code粘这里\"")
+ fmt.Println(" go run ./cmd/claude_login -code '这里粘 code 值'")
+ fmt.Println()
+ fmt.Println("若要粘整条回调 URL,务必用【单引号】包住(否则 zsh 会把 ? & 当通配符报 no matches found):")
+ fmt.Println(" go run ./cmd/claude_login -code 'http://localhost:54545/callback?code=...&state=...'")
fmt.Println()
fmt.Printf("(session 已存到 %s)\n", sessionPath)
fmt.Println("========================================================")
@@ -108,6 +111,7 @@ func runExchange(sessionPath, rawCode, proxy, outPath string) {
td, err := client.ExchangeCode(ctx, code, state, session.Verifier)
if err != nil {
fmt.Fprintf(os.Stderr, "换取 token 失败: %v\n", err)
+ diagnoseClaudeLoginError(err)
os.Exit(1)
}
fmt.Println(">> 登录成功!账号身份:")
@@ -174,6 +178,27 @@ func extractCode(input string) (code, stateOverride string) {
return input, ""
}
+// diagnoseClaudeLoginError 按报错内容给出可能原因,便于快速定位。
+func diagnoseClaudeLoginError(err error) {
+ msg := strings.ToLower(err.Error())
+ fmt.Fprintln(os.Stderr, "\n—— 诊断提示 ——")
+ switch {
+ case strings.Contains(msg, "cloudflare") || strings.Contains(msg, "just a moment") || strings.Contains(msg, "\" -proxy http://127.0.0.1:7890")
+ case strings.Contains(msg, "invalid_grant") || strings.Contains(msg, "code") && strings.Contains(msg, "expired"):
+ fmt.Fprintln(os.Stderr, "授权码无效或已过期(常见:重复运行了第一步导致 session/verifier 与 code 不匹配,或 code 用过一次)。")
+ fmt.Fprintln(os.Stderr, "请重新执行第一步 `go run ./cmd/claude_login` 生成新 URL,授权后立刻用新 code 执行第二步。")
+ case strings.Contains(msg, "invalid_client") || strings.Contains(msg, "unauthorized_client") || strings.Contains(msg, "redirect_uri"):
+ fmt.Fprintln(os.Stderr, "client_id / redirect_uri 被拒。若确认参数无误,可能是 Anthropic 侧调整,请反馈完整报错。")
+ case strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline") || strings.Contains(msg, "no such host") || strings.Contains(msg, "connection refused") || strings.Contains(msg, "tls"):
+ fmt.Fprintln(os.Stderr, "网络/TLS 层失败。请检查能否直连 platform.claude.com,或加 -proxy 走代理重试。")
+ default:
+ fmt.Fprintln(os.Stderr, "未能自动归类。请把上面这行完整报错发给我以便定位。")
+ }
+ fmt.Fprintln(os.Stderr, "————————————")
+}
+
func safePrefix(s string, n int) string {
if len(s) <= n {
return s
diff --git a/database/credential_crypto.go b/database/credential_crypto.go
new file mode 100644
index 000000000..9d8f8a685
--- /dev/null
+++ b/database/credential_crypto.go
@@ -0,0 +1,165 @@
+package database
+
+// 账号凭据落库加密(可选,默认关闭)。
+//
+// 设计目标:把 credentials JSONB 里的敏感字段(access_token / refresh_token /
+// session_token / api_key / id_token / agent_private_key / client_secret)在写库时
+// 加密、读出时解密,而**不改动任何上层调用**,也不破坏平台既有的两类 SQL 依赖:
+// 1. 调度 outbox 触发器按 OLD/NEW 的 access_token 等做**变更检测**;
+// 2. 账号列表投影按 `<> ''` 做**存在性检查**。
+// 为此采用**确定性 AEAD**(nonce 由 HMAC(key, field||plaintext) 派生):同一明文恒
+// 得同一密文 → 变更检测语义不变;密文非空 → 存在性检查不变。
+//
+// 开关:环境变量 CODEX_CRED_ENCRYPTION_KEY。未设置时所有函数是 no-op,行为与不加密
+// 完全一致(存量明文账号照常工作)。设置后:新写入的敏感字段加密,读取端透明解密;
+// 存量明文行因无 enc: 前缀被原样返回,继续可用(渐进迁移,改写时自动转密文)。
+//
+// 注意:密钥一旦丢失,已加密的凭据无法解密,相关账号需重新导入——这是加密的固有代价。
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/base64"
+ "os"
+ "strings"
+ "sync"
+)
+
+const credEncPrefix = "enc:v1:"
+
+// sensitiveCredentialKeys 是需要加密的凭据字段。仅这些字段加密;upstream_type /
+// email / plan_type / models 等参与 SQL 过滤的字段保持明文。
+var sensitiveCredentialKeys = map[string]struct{}{
+ "access_token": {},
+ "refresh_token": {},
+ "session_token": {},
+ "api_key": {},
+ "id_token": {},
+ "agent_private_key": {},
+ "client_secret": {},
+}
+
+var (
+ credKeyOnce sync.Once
+ credKey []byte // 32 字节;nil 表示未启用
+)
+
+// credCipherKey 惰性读取并派生密钥(SHA-256(env 值)→ 32 字节)。未设置返回 nil。
+func credCipherKey() []byte {
+ credKeyOnce.Do(func() {
+ if v := strings.TrimSpace(os.Getenv("CODEX_CRED_ENCRYPTION_KEY")); v != "" {
+ sum := sha256.Sum256([]byte(v))
+ credKey = sum[:]
+ }
+ })
+ return credKey
+}
+
+// setCredEncryptionKeyForTest 仅供测试注入/清空密钥。
+func setCredEncryptionKeyForTest(raw string) {
+ credKeyOnce.Do(func() {}) // 标记 once 已触发,避免后续 env 覆盖
+ if strings.TrimSpace(raw) == "" {
+ credKey = nil
+ return
+ }
+ sum := sha256.Sum256([]byte(raw))
+ credKey = sum[:]
+}
+
+// encryptCredentialValue 加密单个字段值。已加密 / 空值 / 未启用时原样返回。
+func encryptCredentialValue(field, plaintext string) string {
+ key := credCipherKey()
+ if key == nil || plaintext == "" || strings.HasPrefix(plaintext, credEncPrefix) {
+ return plaintext
+ }
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return plaintext
+ }
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return plaintext
+ }
+ // 确定性 nonce:HMAC(key, field || 0x00 || plaintext) 截断到 nonce 长度。
+ // 同明文恒得同 nonce/密文(保变更检测);不同明文几乎必得不同 nonce(GCM 安全)。
+ mac := hmac.New(sha256.New, key)
+ mac.Write([]byte(field))
+ mac.Write([]byte{0})
+ mac.Write([]byte(plaintext))
+ nonce := mac.Sum(nil)[:gcm.NonceSize()]
+ // AAD=field,把密文绑定到字段,防止跨字段搬运。
+ ct := gcm.Seal(nil, nonce, []byte(plaintext), []byte(field))
+ buf := make([]byte, 0, len(nonce)+len(ct))
+ buf = append(buf, nonce...)
+ buf = append(buf, ct...)
+ return credEncPrefix + base64.RawURLEncoding.EncodeToString(buf)
+}
+
+// decryptCredentialValue 解密单个字段值。无前缀 / 未启用 / 解密失败时原样返回。
+func decryptCredentialValue(field, value string) string {
+ if !strings.HasPrefix(value, credEncPrefix) {
+ return value
+ }
+ key := credCipherKey()
+ if key == nil {
+ return value
+ }
+ raw, err := base64.RawURLEncoding.DecodeString(value[len(credEncPrefix):])
+ if err != nil {
+ return value
+ }
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return value
+ }
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return value
+ }
+ if len(raw) < gcm.NonceSize() {
+ return value
+ }
+ nonce, ct := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
+ pt, err := gcm.Open(nil, nonce, ct, []byte(field))
+ if err != nil {
+ return value
+ }
+ return string(pt)
+}
+
+// encryptSensitiveCredentials 返回一份浅拷贝,其中敏感字段被加密。未启用时原样返回入参。
+// 在每个写库函数 marshal 之前调用。
+func encryptSensitiveCredentials(m map[string]interface{}) map[string]interface{} {
+ if credCipherKey() == nil || m == nil {
+ return m
+ }
+ out := make(map[string]interface{}, len(m))
+ for k, v := range m {
+ if _, ok := sensitiveCredentialKeys[k]; ok {
+ if s, isStr := v.(string); isStr {
+ out[k] = encryptCredentialValue(k, s)
+ continue
+ }
+ }
+ out[k] = v
+ }
+ return out
+}
+
+// decryptSensitiveCredentialsInPlace 就地解密 map 里的敏感字段。在 decodeCredentials
+// 里调用,使所有 Go 读取端(GetCredential / 各处 map 直读)统一见明文。
+func decryptSensitiveCredentialsInPlace(m map[string]interface{}) {
+ if credCipherKey() == nil || m == nil {
+ return
+ }
+ for k, v := range m {
+ if _, ok := sensitiveCredentialKeys[k]; !ok {
+ continue
+ }
+ if s, isStr := v.(string); isStr {
+ m[k] = decryptCredentialValue(k, s)
+ }
+ }
+}
diff --git a/database/credential_crypto_test.go b/database/credential_crypto_test.go
new file mode 100644
index 000000000..3a87d0a54
--- /dev/null
+++ b/database/credential_crypto_test.go
@@ -0,0 +1,162 @@
+package database
+
+import (
+ "context"
+ "encoding/json"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestCredentialCrypto_RoundTrip(t *testing.T) {
+ setCredEncryptionKeyForTest("test-master-key-123")
+ defer setCredEncryptionKeyForTest("")
+
+ m := map[string]interface{}{
+ "upstream_type": "claude",
+ "access_token": "sk-at-secret",
+ "refresh_token": "rt-secret",
+ "email": "user@example.com",
+ "plan_type": "claude",
+ }
+ enc := encryptSensitiveCredentials(m)
+ // 敏感字段应被加密(带前缀),非敏感字段原样。
+ if !strings.HasPrefix(enc["access_token"].(string), credEncPrefix) {
+ t.Fatalf("access_token 未加密: %v", enc["access_token"])
+ }
+ if !strings.HasPrefix(enc["refresh_token"].(string), credEncPrefix) {
+ t.Fatalf("refresh_token 未加密")
+ }
+ if enc["email"] != "user@example.com" || enc["upstream_type"] != "claude" {
+ t.Fatal("非敏感字段不应改动")
+ }
+ // 原 map 不应被 mutate(返回副本)。
+ if strings.HasPrefix(m["access_token"].(string), credEncPrefix) {
+ t.Fatal("encryptSensitiveCredentials 不应 mutate 入参")
+ }
+
+ // 模拟落库→读出:marshal(enc) 再 decodeCredentials 应还原明文。
+ raw, _ := json.Marshal(enc)
+ decoded := decodeCredentials(raw)
+ if decoded["access_token"] != "sk-at-secret" || decoded["refresh_token"] != "rt-secret" {
+ t.Fatalf("解密还原失败: at=%v rt=%v", decoded["access_token"], decoded["refresh_token"])
+ }
+}
+
+func TestCredentialCrypto_Deterministic(t *testing.T) {
+ setCredEncryptionKeyForTest("k")
+ defer setCredEncryptionKeyForTest("")
+ // 同明文两次加密应得同密文(保 outbox 变更检测语义)。
+ a := encryptCredentialValue("access_token", "same-token")
+ b := encryptCredentialValue("access_token", "same-token")
+ if a != b {
+ t.Fatalf("确定性加密应产生相同密文: %s vs %s", a, b)
+ }
+ // 不同明文应得不同密文。
+ c := encryptCredentialValue("access_token", "other-token")
+ if a == c {
+ t.Fatal("不同明文不应同密文")
+ }
+ // 不同字段(AAD)同明文应得不同密文。
+ d := encryptCredentialValue("refresh_token", "same-token")
+ if a == d {
+ t.Fatal("不同字段应绑定不同密文")
+ }
+}
+
+func TestCredentialCrypto_Disabled_NoOp(t *testing.T) {
+ setCredEncryptionKeyForTest("") // 未启用
+ defer setCredEncryptionKeyForTest("")
+ m := map[string]interface{}{"access_token": "plain", "refresh_token": "plain2"}
+ enc := encryptSensitiveCredentials(m)
+ if enc["access_token"] != "plain" {
+ t.Fatal("未启用时应原样返回(no-op)")
+ }
+ raw, _ := json.Marshal(enc)
+ decoded := decodeCredentials(raw)
+ if decoded["access_token"] != "plain" {
+ t.Fatal("未启用时解密应原样")
+ }
+}
+
+func TestCredentialCrypto_BackwardCompat_PlaintextRows(t *testing.T) {
+ // 存量明文行:即使启用密钥,无 enc: 前缀的值应原样读出(渐进迁移)。
+ setCredEncryptionKeyForTest("k")
+ defer setCredEncryptionKeyForTest("")
+ raw := []byte(`{"access_token":"legacy-plain","refresh_token":"legacy-rt","upstream_type":"codex"}`)
+ decoded := decodeCredentials(raw)
+ if decoded["access_token"] != "legacy-plain" || decoded["refresh_token"] != "legacy-rt" {
+ t.Fatalf("存量明文应原样读出: %v", decoded)
+ }
+}
+
+func TestCredentialCrypto_WrongKey_FailsClosed(t *testing.T) {
+ setCredEncryptionKeyForTest("key-A")
+ enc := encryptCredentialValue("access_token", "secret")
+ // 换密钥后解密失败,返回原密文(而非明文),账号需重导——不误当明文用。
+ setCredEncryptionKeyForTest("key-B")
+ defer setCredEncryptionKeyForTest("")
+ got := decryptCredentialValue("access_token", enc)
+ if got == "secret" {
+ t.Fatal("错误密钥不应解出明文")
+ }
+ if !strings.HasPrefix(got, credEncPrefix) {
+ t.Fatal("解密失败应返回原密文")
+ }
+}
+
+func TestCredentialCrypto_DBRoundTrip_AtRestEncrypted(t *testing.T) {
+ setCredEncryptionKeyForTest("db-master-key")
+ defer setCredEncryptionKeyForTest("")
+
+ db, err := New("sqlite", filepath.Join(t.TempDir(), "cred-crypto.db"))
+ if err != nil {
+ t.Fatalf("New sqlite: %v", err)
+ }
+ defer db.Close()
+ ctx := context.Background()
+
+ id, err := db.InsertAccountWithUpstream(ctx, "claude", "anthropic", "oauth", map[string]interface{}{
+ "upstream_type": "claude",
+ "access_token": "at-plain-secret",
+ "refresh_token": "rt-plain-secret",
+ "email": "u@example.com",
+ }, "")
+ if err != nil {
+ t.Fatalf("insert: %v", err)
+ }
+
+ // 读回:GetCredential 应见明文。
+ row, err := db.GetAccountByID(ctx, id)
+ if err != nil {
+ t.Fatalf("get: %v", err)
+ }
+ if row.GetCredential("access_token") != "at-plain-secret" || row.GetCredential("refresh_token") != "rt-plain-secret" {
+ t.Fatalf("读回应为明文: at=%q rt=%q", row.GetCredential("access_token"), row.GetCredential("refresh_token"))
+ }
+
+ // 底层存储应为密文(enc: 前缀)。
+ var rawCred string
+ if err := db.conn.QueryRowContext(ctx, "SELECT credentials FROM accounts WHERE id = ?", id).Scan(&rawCred); err != nil {
+ t.Fatalf("raw select: %v", err)
+ }
+ if strings.Contains(rawCred, "at-plain-secret") || strings.Contains(rawCred, "rt-plain-secret") {
+ t.Fatalf("底层不应含明文 token: %s", rawCred)
+ }
+ if !strings.Contains(rawCred, credEncPrefix) {
+ t.Fatalf("底层应为密文(含 %s 前缀): %s", credEncPrefix, rawCred)
+ }
+ // email(非敏感)应仍是明文,供 SQL 过滤。
+ if !strings.Contains(rawCred, "u@example.com") {
+ t.Fatalf("非敏感字段应保持明文: %s", rawCred)
+ }
+
+ // UpdateCredentials 往返:刷新 token 后读回仍明文。
+ if err := db.UpdateCredentials(ctx, id, map[string]interface{}{"access_token": "at-refreshed"}); err != nil {
+ t.Fatalf("update: %v", err)
+ }
+ row2, _ := db.GetAccountByID(ctx, id)
+ if row2.GetCredential("access_token") != "at-refreshed" {
+ t.Fatalf("更新后读回应为新明文, got %q", row2.GetCredential("access_token"))
+ }
+}
diff --git a/database/data_migrations.go b/database/data_migrations.go
index 617f2b3e9..990b80461 100644
--- a/database/data_migrations.go
+++ b/database/data_migrations.go
@@ -267,7 +267,7 @@ func (db *DB) migrateWorkspaceIdentityV3(ctx context.Context, tx *sql.Tx) error
)
if workspaceID != "" && strings.EqualFold(tokenEmail, email) {
account.credentials["workspace_id"] = workspaceID
- encoded, err := json.Marshal(account.credentials)
+ encoded, err := json.Marshal(encryptSensitiveCredentials(account.credentials))
if err != nil {
return err
}
diff --git a/database/grok_state.go b/database/grok_state.go
index 0f85cd5d6..2ce40e2dc 100644
--- a/database/grok_state.go
+++ b/database/grok_state.go
@@ -551,7 +551,7 @@ func (db *DB) InsertGrokAccountIfAbsent(ctx context.Context, name string, creden
if len(identityKeys) == 0 {
return 0, 0, errors.New("grok credential has no stable identity")
}
- encoded, err := json.Marshal(credentialCopy)
+ encoded, err := json.Marshal(encryptSensitiveCredentials(credentialCopy))
if err != nil {
return 0, 0, err
}
@@ -665,7 +665,7 @@ func (db *DB) ReauthGrokAccount(ctx context.Context, accountID int64, credential
}
}
- encoded, marshalErr := json.Marshal(merged)
+ encoded, marshalErr := json.Marshal(encryptSensitiveCredentials(merged))
if marshalErr != nil {
return marshalErr
}
@@ -848,7 +848,7 @@ func (db *DB) UpdateAccountCredentialsCAS(ctx context.Context, accountID, expect
// Keep the compatibility JSON field synchronized with the canonical
// column in the same write that publishes the rotated credential.
merged["credential_family_id"] = familyID
- encoded, marshalErr := json.Marshal(merged)
+ encoded, marshalErr := json.Marshal(encryptSensitiveCredentials(merged))
if marshalErr != nil {
return marshalErr
}
@@ -928,7 +928,7 @@ func (db *DB) ReplaceAccountCredentialsCAS(ctx context.Context, accountID, expec
familyID = "cf_" + strings.ReplaceAll(uuid.NewString(), "-", "")
}
merged["credential_family_id"] = familyID
- encoded, marshalErr := json.Marshal(merged)
+ encoded, marshalErr := json.Marshal(encryptSensitiveCredentials(merged))
if marshalErr != nil {
return marshalErr
}
@@ -1006,7 +1006,7 @@ func (db *DB) MergeAccountCredentialsForGeneration(ctx context.Context, accountI
if current != expectedGeneration {
return nil
}
- encoded, marshalErr := json.Marshal(mergeCredentialMaps(decodeCredentials(raw), filtered))
+ encoded, marshalErr := json.Marshal(encryptSensitiveCredentials(mergeCredentialMaps(decodeCredentials(raw), filtered)))
if marshalErr != nil {
return marshalErr
}
diff --git a/database/helpers.go b/database/helpers.go
index 31f9218d7..42dab36cd 100644
--- a/database/helpers.go
+++ b/database/helpers.go
@@ -146,6 +146,8 @@ func decodeCredentials(raw interface{}) map[string]interface{} {
if out == nil {
return map[string]interface{}{}
}
+ // 统一读扼要点:解密敏感字段,使所有 Go 读取端见明文(密钥未设时为 no-op)。
+ decryptSensitiveCredentialsInPlace(out)
return out
}
diff --git a/database/postgres.go b/database/postgres.go
index 94c344052..62ec585c2 100644
--- a/database/postgres.go
+++ b/database/postgres.go
@@ -6704,7 +6704,7 @@ func (db *DB) UpdateAccountSchedulerConfig(ctx context.Context, id int64, scoreB
merged := mergeCredentialMaps(decodeCredentials(currentRaw), map[string]interface{}{
"allowed_api_key_ids": normalizePositiveInt64Slice(allowedAPIKeyIDs.Values),
})
- credJSON, err := json.Marshal(merged)
+ credJSON, err := json.Marshal(encryptSensitiveCredentials(merged))
if err != nil {
return fmt.Errorf("序列化 credentials 失败: %w", err)
}
@@ -6785,7 +6785,7 @@ func (db *DB) UpdateAccountSchedulerMetadata(ctx context.Context, id int64, scor
current := decodeCredentials(currentRaw)
merged := mergeCredentialMaps(cloneCredentialUpdates(current), credentialUpdates)
identityChanged := grokIdentityCredentialChanged(current, merged)
- credJSON, err := json.Marshal(merged)
+ credJSON, err := json.Marshal(encryptSensitiveCredentials(merged))
if err != nil {
return fmt.Errorf("序列化 credentials 失败: %w", err)
}
@@ -7004,7 +7004,7 @@ func (db *DB) batchUpdateAccountCredentials(ctx context.Context, tx *sql.Tx, cur
// generation bump.
merged := mergeCredentialMaps(cloneCredentialUpdates(credentials), updates)
identityChanged := grokIdentityCredentialChanged(credentials, merged)
- credJSON, err := json.Marshal(merged)
+ credJSON, err := json.Marshal(encryptSensitiveCredentials(merged))
if err != nil {
return fmt.Errorf("序列化 credentials 失败: %w", err)
}
@@ -7188,7 +7188,7 @@ func (db *DB) updateCredentialsReadMerge(ctx context.Context, id int64, credenti
merged := mergeCredentialMaps(decodeCredentials(currentRaw), credentials)
identityChanged := grokIdentityCredentialChanged(decodeCredentials(currentRaw), merged)
- credJSON, err := json.Marshal(merged)
+ credJSON, err := json.Marshal(encryptSensitiveCredentials(merged))
if err != nil {
return fmt.Errorf("序列化 credentials 失败: %w", err)
}
@@ -7223,6 +7223,12 @@ func (db *DB) updateCredentialsSQLite(ctx context.Context, id int64, credentials
if !sqliteJSONSetKeySupported(key) {
return db.updateCredentialsReadMergeSQLiteUnlocked(ctx, id, credentials)
}
+ // SQLite 逐键写:敏感字段在此处按键加密(密钥未设时 no-op)。
+ if _, sensitive := sensitiveCredentialKeys[key]; sensitive {
+ if s, isStr := value.(string); isStr {
+ value = encryptCredentialValue(key, s)
+ }
+ }
valueJSON, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("序列化 credentials 失败: %w", err)
@@ -7273,7 +7279,7 @@ func (db *DB) updateCredentialsReadMergeSQLiteUnlocked(ctx context.Context, id i
current := decodeCredentials(currentRaw)
merged := mergeCredentialMaps(decodeCredentials(currentRaw), credentials)
identityChanged := grokIdentityCredentialChanged(current, merged)
- credJSON, err := json.Marshal(merged)
+ credJSON, err := json.Marshal(encryptSensitiveCredentials(merged))
if err != nil {
return fmt.Errorf("序列化 credentials 失败: %w", err)
}
@@ -7358,7 +7364,7 @@ func (db *DB) UpdateOpenAIResponsesAccount(ctx context.Context, id int64, name s
current := decodeCredentials(currentRaw)
merged := mergeCredentialMaps(cloneCredentialUpdates(current), credentials)
identityChanged := openAIResponsesIdentityCredentialChanged(current, merged)
- credJSON, err := json.Marshal(merged)
+ credJSON, err := json.Marshal(encryptSensitiveCredentials(merged))
if err != nil {
return fmt.Errorf("序列化 credentials 失败: %w", err)
}
@@ -7408,7 +7414,7 @@ func (db *DB) UpdateOAuthAccountCredentials(ctx context.Context, id int64, crede
}
merged := mergeCredentialMaps(decodeCredentials(currentRaw), credentials)
- credJSON, err := json.Marshal(merged)
+ credJSON, err := json.Marshal(encryptSensitiveCredentials(merged))
if err != nil {
return fmt.Errorf("序列化 credentials 失败: %w", err)
}
@@ -7817,7 +7823,7 @@ func (db *DB) InsertAccount(ctx context.Context, name string, refreshToken strin
credentials := map[string]interface{}{
"refresh_token": refreshToken,
}
- credJSON, err := json.Marshal(credentials)
+ credJSON, err := json.Marshal(encryptSensitiveCredentials(credentials))
if err != nil {
return 0, err
}
@@ -7863,7 +7869,7 @@ func (db *DB) InsertATAccount(ctx context.Context, name string, accessToken stri
credentials := map[string]interface{}{
"access_token": accessToken,
}
- credJSON, err := json.Marshal(credentials)
+ credJSON, err := json.Marshal(encryptSensitiveCredentials(credentials))
if err != nil {
return 0, err
}
@@ -7880,7 +7886,7 @@ func (db *DB) InsertAccountWithCredentials(ctx context.Context, name string, cre
if credentials == nil {
credentials = map[string]interface{}{}
}
- credJSON, err := json.Marshal(credentials)
+ credJSON, err := json.Marshal(encryptSensitiveCredentials(credentials))
if err != nil {
return 0, err
}
@@ -7897,7 +7903,7 @@ func (db *DB) InsertOpenAIResponsesAccount(ctx context.Context, name string, cre
if credentials == nil {
credentials = map[string]interface{}{}
}
- credJSON, err := json.Marshal(credentials)
+ credJSON, err := json.Marshal(encryptSensitiveCredentials(credentials))
if err != nil {
return 0, err
}
@@ -7922,7 +7928,7 @@ func (db *DB) InsertAccountWithUpstream(ctx context.Context, name, platform, acc
if strings.TrimSpace(accountType) == "" {
accountType = "api"
}
- credJSON, err := json.Marshal(credentials)
+ credJSON, err := json.Marshal(encryptSensitiveCredentials(credentials))
if err != nil {
return 0, err
}
diff --git a/proxy/handler_anthropic.go b/proxy/handler_anthropic.go
index 95dd57029..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 + `"}`)
From 611bb17d951b25ef4be001a800dc2b173f0dda4e Mon Sep 17 00:00:00 2001
From: hu <187184415@qq.com>
Date: Mon, 31 Aug 2026 21:53:03 +0800
Subject: [PATCH 3/3] test(auth): tolerate async dispatch reconciliation
---
auth/dispatch_reconcile_test.go | 25 +++++++++++++++++--------
auth/openai_responses_identity_test.go | 10 +++++-----
2 files changed, 22 insertions(+), 13 deletions(-)
diff --git a/auth/dispatch_reconcile_test.go b/auth/dispatch_reconcile_test.go
index da8cedd18..2307f3cff 100644
--- a/auth/dispatch_reconcile_test.go
+++ b/auth/dispatch_reconcile_test.go
@@ -15,12 +15,15 @@ func TestReconcileDispatchStateLoadsAccountAddedAfterStartup(t *testing.T) {
if err != nil {
t.Fatalf("database.New: %v", err)
}
- t.Cleanup(func() { _ = db.Close() })
store := NewStore(db, nil, &database.SystemSettings{
MaxConcurrency: 1,
FastSchedulerEnabled: true,
})
+ t.Cleanup(func() {
+ store.Stop()
+ _ = db.Close()
+ })
if err := store.Init(ctx); err != nil {
t.Fatalf("Store.Init: %v", err)
}
@@ -39,13 +42,10 @@ func TestReconcileDispatchStateLoadsAccountAddedAfterStartup(t *testing.T) {
t.Fatalf("InsertOpenAIResponsesAccount: %v", err)
}
- changed, err := store.ReconcileDispatchState(ctx)
+ _, err = store.ReconcileDispatchState(ctx)
if err != nil {
t.Fatalf("ReconcileDispatchState: %v", err)
}
- if !changed {
- t.Fatal("ReconcileDispatchState reported no change for a newly added account")
- }
got := store.Next()
if got == nil {
t.Fatal("Next() returned nil after dispatch reconciliation")
@@ -62,12 +62,15 @@ func TestTriggerDispatchStateReconcileAsyncLoadsAccount(t *testing.T) {
if err != nil {
t.Fatalf("database.New: %v", err)
}
- t.Cleanup(func() { _ = db.Close() })
store := NewStore(db, nil, &database.SystemSettings{
MaxConcurrency: 1,
FastSchedulerEnabled: true,
})
+ t.Cleanup(func() {
+ store.Stop()
+ _ = db.Close()
+ })
if err := store.Init(ctx); err != nil {
t.Fatalf("Store.Init: %v", err)
}
@@ -136,12 +139,15 @@ func TestAsyncReconcileCoalescesOntoActiveRunCompletion(t *testing.T) {
if err != nil {
t.Fatalf("database.New: %v", err)
}
- t.Cleanup(func() { _ = db.Close() })
store := NewStore(db, nil, &database.SystemSettings{
MaxConcurrency: 1,
FastSchedulerEnabled: true,
})
+ t.Cleanup(func() {
+ store.Stop()
+ _ = db.Close()
+ })
if err := store.Init(ctx); err != nil {
t.Fatalf("Store.Init: %v", err)
}
@@ -199,9 +205,12 @@ func TestTriggerDispatchStateReconcileAsyncThrottledReturnsNil(t *testing.T) {
if err != nil {
t.Fatalf("database.New: %v", err)
}
- t.Cleanup(func() { _ = db.Close() })
store := NewStore(db, nil, &database.SystemSettings{MaxConcurrency: 1})
+ t.Cleanup(func() {
+ store.Stop()
+ _ = db.Close()
+ })
if err := store.Init(ctx); err != nil {
t.Fatalf("Store.Init: %v", err)
}
diff --git a/auth/openai_responses_identity_test.go b/auth/openai_responses_identity_test.go
index 010bbef2d..068347bbf 100644
--- a/auth/openai_responses_identity_test.go
+++ b/auth/openai_responses_identity_test.go
@@ -63,7 +63,6 @@ func TestReconcileDispatchStateReloadsChangedResponsesIdentity(t *testing.T) {
if err != nil {
t.Fatalf("database.New: %v", err)
}
- t.Cleanup(func() { _ = db.Close() })
accountID, err := db.InsertOpenAIResponsesAccount(ctx, "relay", map[string]interface{}{
"upstream_type": UpstreamOpenAIResponses,
@@ -75,6 +74,10 @@ func TestReconcileDispatchStateReloadsChangedResponsesIdentity(t *testing.T) {
t.Fatalf("InsertOpenAIResponsesAccount: %v", err)
}
store := NewStore(db, nil, &database.SystemSettings{MaxConcurrency: 1, FastSchedulerEnabled: true})
+ t.Cleanup(func() {
+ store.Stop()
+ _ = db.Close()
+ })
if err := store.Init(ctx); err != nil {
t.Fatalf("Store.Init: %v", err)
}
@@ -88,13 +91,10 @@ func TestReconcileDispatchStateReloadsChangedResponsesIdentity(t *testing.T) {
t.Fatalf("UpdateOpenAIResponsesAccount: %v", err)
}
- changed, err := store.ReconcileDispatchState(ctx)
+ _, err = store.ReconcileDispatchState(ctx)
if err != nil {
t.Fatalf("ReconcileDispatchState: %v", err)
}
- if !changed {
- t.Fatal("ReconcileDispatchState reported no change for corrected endpoint identity")
- }
baseURL, apiKey := acc.OpenAIResponsesCredentials()
if baseURL != "https://relay.example" || apiKey != "sk-new" {
t.Fatalf("reconciled credentials = (%q, %q), want corrected endpoint", baseURL, apiKey)