From 7ff42817c9cc9087e40031dcaa17958ee9a69acc Mon Sep 17 00:00:00 2001 From: hu <187184415@qq.com> Date: Wed, 26 Aug 2026 12:05:59 +0800 Subject: [PATCH 01/84] fix(grok): normalize non-object tool parameter schema roots Grok's strict tool deserializer rejects function tools whose parameter schema root is not a single object ('tool parameter root must be an object type'), which hard-400s whole conversations when a client bridges MCP tools with anyOf/oneOf union roots (seen with Codex App's mcp__codex_app__automation_update). Merge union roots into one object schema (properties union; required keeps only keys mandatory in every object branch, allOf keeps the union per its all-must-hold semantics), collapse type-array roots that include 'object', and degrade anything else to a permissive object that keeps the description. Compliant object roots and nested unions stay byte-identical so upstream prefix caching is not disturbed. --- proxy/grok_namespace_tools.go | 157 +++++++++++++++++++++++++++++ proxy/grok_namespace_tools_test.go | 73 ++++++++++++++ 2 files changed, 230 insertions(+) diff --git a/proxy/grok_namespace_tools.go b/proxy/grok_namespace_tools.go index 447a1b2c..6e2999ca 100644 --- a/proxy/grok_namespace_tools.go +++ b/proxy/grok_namespace_tools.go @@ -8,6 +8,7 @@ import ( "encoding/json" "io" "reflect" + "sort" "strconv" "strings" ) @@ -125,6 +126,157 @@ func grokFunctionToolForCustom(tool map[string]any, name string) map[string]any return converted } +// grokPermissiveObjectSchema 是无法保真转换时的降级形态:宽松 object,仅保留 +// 原 schema 的 description。宁可让模型自由发挥、由客户端做最终参数校验,也不能 +// 让整段对话因 schema 形态被上游 400 掐死。 +func grokPermissiveObjectSchema(root map[string]any) map[string]any { + out := map[string]any{"type": "object", "additionalProperties": true} + if description, ok := root["description"].(string); ok && strings.TrimSpace(description) != "" { + out["description"] = description + } + return out +} + +// grokObjectishSchemaBranch 判断联合分支是否可并入 object 根:显式 object,或 +// 未声明 type 但带 properties。 +func grokObjectishSchemaBranch(branch map[string]any) bool { + if kind, ok := branch["type"].(string); ok { + return kind == "object" + } + if _, ok := branch["type"]; ok { + return false + } + _, hasProperties := branch["properties"] + return hasProperties +} + +// mergeGrokUnionRootSchema 把 anyOf/oneOf/allOf 根合并成单一 object: +// properties 取各 object 分支的并集(先到先得),required 按语义收敛—— +// 联合(任一分支成立)取交集,allOf(全部成立)取并集;非 object 分支丢弃。 +// 没有任何 object 分支时整体降级为宽松 object。 +func mergeGrokUnionRootSchema(root map[string]any, branches []any, requireAll bool) map[string]any { + merged := map[string]any{"type": "object"} + if description, ok := root["description"].(string); ok && strings.TrimSpace(description) != "" { + merged["description"] = description + } + properties := map[string]any{} + var required map[string]bool + objectBranches := 0 + for _, rawBranch := range branches { + branch, ok := rawBranch.(map[string]any) + if !ok || !grokObjectishSchemaBranch(branch) { + continue + } + objectBranches++ + if _, has := merged["description"]; !has { + if description, ok := branch["description"].(string); ok && strings.TrimSpace(description) != "" { + merged["description"] = description + } + } + if props, ok := branch["properties"].(map[string]any); ok { + for key, value := range props { + if _, exists := properties[key]; !exists { + properties[key] = value + } + } + } + branchRequired := map[string]bool{} + if list, ok := branch["required"].([]any); ok { + for _, item := range list { + if key, ok := item.(string); ok { + branchRequired[key] = true + } + } + } + if required == nil { + required = branchRequired + } else if requireAll { + for key := range branchRequired { + required[key] = true + } + } else { + for key := range required { + if !branchRequired[key] { + delete(required, key) + } + } + } + } + if objectBranches == 0 { + return grokPermissiveObjectSchema(root) + } + if len(properties) > 0 { + merged["properties"] = properties + } + if len(required) > 0 { + keys := make([]string, 0, len(required)) + for key := range required { + keys = append(keys, key) + } + sort.Strings(keys) + values := make([]any, len(keys)) + for index, key := range keys { + values[index] = key + } + merged["required"] = values + } + return merged +} + +// normalizeGrokToolParameterSchema 把函数工具参数 schema 归一成 Grok 上游接受 +// 的形态:根节点必须是单一 object(上游对联合/非 object 根返回 400 +// "tool parameter root must be an object type")。返回 (归一后 schema, 是否改写)。 +// 合规的 object 根保持原样,嵌套结构一律不动,避免扰动上游缓存前缀。 +func normalizeGrokToolParameterSchema(schema map[string]any) (map[string]any, bool) { + if schema == nil { + return schema, false + } + if typeList, ok := schema["type"].([]any); ok { + hasObject := false + for _, item := range typeList { + if kind, ok := item.(string); ok && kind == "object" { + hasObject = true + break + } + } + if !hasObject { + return grokPermissiveObjectSchema(schema), true + } + out := make(map[string]any, len(schema)) + for key, value := range schema { + out[key] = value + } + out["type"] = "object" + return out, true + } + if kind, ok := schema["type"].(string); ok { + if kind == "object" { + return schema, false + } + return grokPermissiveObjectSchema(schema), true + } + for _, key := range []string{"anyOf", "oneOf"} { + if branches, ok := schema[key].([]any); ok && len(branches) > 0 { + return mergeGrokUnionRootSchema(schema, branches, false), true + } + } + if branches, ok := schema["allOf"].([]any); ok && len(branches) > 0 { + return mergeGrokUnionRootSchema(schema, branches, true), true + } + if _, ok := schema["properties"]; ok { + out := make(map[string]any, len(schema)+1) + for key, value := range schema { + out[key] = value + } + out["type"] = "object" + return out, true + } + if len(schema) == 0 { + return map[string]any{"type": "object"}, true + } + return grokPermissiveObjectSchema(schema), true +} + func normalizeGrokFunctionTool(tool map[string]any, name string) map[string]any { converted := make(map[string]any, len(tool)) for key, value := range tool { @@ -146,6 +298,11 @@ func normalizeGrokFunctionTool(tool map[string]any, name string) map[string]any } { delete(converted, key) } + if params, ok := converted["parameters"].(map[string]any); ok { + if normalized, changed := normalizeGrokToolParameterSchema(params); changed { + converted["parameters"] = normalized + } + } return converted } diff --git a/proxy/grok_namespace_tools_test.go b/proxy/grok_namespace_tools_test.go index 1db412d0..761157be 100644 --- a/proxy/grok_namespace_tools_test.go +++ b/proxy/grok_namespace_tools_test.go @@ -627,3 +627,76 @@ func TestGrokToolSearchAvoidsReservedUpstreamFunctionName(t *testing.T) { t.Fatalf("habitual tool_search call type = %q; body=%s", got, habitual) } } + +// Grok 上游要求函数工具参数 schema 根节点必须是单一 object(联合根会 400 +// "tool parameter root must be an object type")。桥接层必须把联合根合并、 +// 非 object 根降级,且不得改动本就合规的 schema 与嵌套联合。 +func TestGrokFunctionToolRootSchemaNormalizedForUpstream(t *testing.T) { + body := []byte(`{ + "model":"grok-4.6", + "tools":[ + {"type":"function","name":"mcp__codex_app__automation_update","parameters":{ + "description":"update automation", + "anyOf":[ + {"type":"object","properties":{"action":{"type":"string"},"shared":{"type":"integer"}},"required":["action","shared"]}, + {"type":"object","properties":{"batch":{"type":"array"},"shared":{"type":"integer"}},"required":["batch","shared"]}, + {"type":"null"} + ] + }}, + {"type":"function","name":"string_root","parameters":{"type":"string","description":"raw text"}}, + {"type":"function","name":"type_list_root","parameters":{"type":["object","null"],"properties":{"a":{"type":"string"}}}}, + {"type":"function","name":"nested_union_ok","parameters":{"type":"object","properties":{"choice":{"anyOf":[{"type":"string"},{"type":"integer"}]}}}} + ], + "input":[{"type":"message","role":"user","content":"hi"}] + }`) + result := prepareGrokUpstreamBody(body) + + union := gjson.GetBytes(result.Body, `tools.#(name=="mcp__codex_app__automation_update").parameters`) + if union.Get("type").String() != "object" { + t.Fatalf("union root not normalized to object: %s", union.Raw) + } + if union.Get("anyOf").Exists() { + t.Fatalf("anyOf must not survive at root: %s", union.Raw) + } + if !union.Get("properties.action").Exists() || !union.Get("properties.batch").Exists() { + t.Fatalf("merged properties missing: %s", union.Raw) + } + required := union.Get("required").Array() + if len(required) != 1 || required[0].String() != "shared" { + t.Fatalf("required must keep only keys mandatory in every object branch, got %s", union.Get("required").Raw) + } + if union.Get("description").String() != "update automation" { + t.Fatalf("root description lost: %s", union.Raw) + } + + stringRoot := gjson.GetBytes(result.Body, `tools.#(name=="string_root").parameters`) + if stringRoot.Get("type").String() != "object" { + t.Fatalf("non-object root not degraded to object: %s", stringRoot.Raw) + } + if stringRoot.Get("description").String() != "raw text" { + t.Fatalf("degraded schema must keep description: %s", stringRoot.Raw) + } + + typeList := gjson.GetBytes(result.Body, `tools.#(name=="type_list_root").parameters`) + if typeList.Get("type").String() != "object" { + t.Fatalf("type-array root not collapsed to object: %s", typeList.Raw) + } + if !typeList.Get("properties.a").Exists() { + t.Fatalf("type-array root must keep properties: %s", typeList.Raw) + } + + nested := gjson.GetBytes(result.Body, `tools.#(name=="nested_union_ok").parameters`) + if !nested.Get("properties.choice.anyOf").Exists() { + t.Fatalf("nested anyOf must stay untouched: %s", nested.Raw) + } +} + +// 合规的纯 object schema 不应被改写(改写会破坏上游缓存前缀稳定性)。 +func TestGrokFunctionToolObjectRootSchemaUntouched(t *testing.T) { + body := []byte(`{"model":"grok-4.6","tools":[{"type":"function","name":"plain","parameters":{"type":"object","properties":{"q":{"type":"string"}},"required":["q"]}}],"input":[{"type":"message","role":"user","content":"hi"}]}`) + result := prepareGrokUpstreamBody(body) + schema := gjson.GetBytes(result.Body, `tools.0.parameters`) + if schema.Get("type").String() != "object" || !schema.Get("properties.q").Exists() || schema.Get("required.0").String() != "q" { + t.Fatalf("compliant schema was altered: %s", schema.Raw) + } +} From c284771e2cf6bf2dcfe0930e049cff4645182ea0 Mon Sep 17 00:00:00 2001 From: hu <187184415@qq.com> Date: Wed, 26 Aug 2026 12:05:59 +0800 Subject: [PATCH 02/84] fix(errors): surface Grok string-form upstream error bodies Grok/xAI error bodies carry the explanation in a top-level string field ({"code":"...","error":"..."}). The extractor only knew the object form, so these failures reached clients and usage logs as a bare 'Upstream returned status 400' and diagnosis required container logs. Adopt the string form only when 'error' is a JSON string, keeping object-form handling and the HTML/plain-text fallback unchanged. --- proxy/handler.go | 8 ++++++++ proxy/handler_test.go | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/proxy/handler.go b/proxy/handler.go index eb5c242e..3004992b 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -571,6 +571,14 @@ func usageLogErrorMessageImpl(statusCode int, body []byte, trustedText bool) str break } } + if message == "" { + // Grok/xAI 风格错误体把说明放在顶层字符串 error 字段: + // {"code":"invalid-argument","error":"..."}。仅在 error 是字符串时采用, + // 避免把对象形态的整段 JSON 打进 message。 + if errField := gjson.GetBytes(body, "error"); errField.Type == gjson.String { + message = strings.TrimSpace(errField.String()) + } + } codeCandidates := []string{ gjson.GetBytes(body, "error.code").String(), diff --git a/proxy/handler_test.go b/proxy/handler_test.go index 647ddc6e..8adc28b3 100644 --- a/proxy/handler_test.go +++ b/proxy/handler_test.go @@ -5814,3 +5814,24 @@ func TestResponsesCompactSuffixOnlyRequestLogsBaseModel(t *testing.T) { t.Fatalf("x-model = %q, want base gpt-5.6-sol (suffix stripped for display)", got) } } + +// Grok/xAI 的错误体用顶层字符串 error 字段({"code":"...","error":"文本"})。 +// 解析器此前只认 error.message 对象形态,导致这类 400 对用户只显示裸的 +// "Upstream returned status 400",根因全靠翻容器日志。 +func TestUsageLogErrorMessageGrokStringErrorField(t *testing.T) { + got := usageLogErrorMessage(400, []byte(`{"code":"invalid-argument","error":"The function name tool_search is reserved for the tool_search tool"}`)) + want := "invalid-argument · The function name tool_search is reserved for the tool_search tool" + if got != want { + t.Fatalf("grok string error not extracted: got %q want %q", got, want) + } + + // 对象形态不受影响。 + if got := usageLogErrorMessage(400, []byte(`{"error":{"message":"object form","code":"bad"}}`)); got != "bad · object form" { + t.Fatalf("object error form regressed: %q", got) + } + + // error 为对象但无 message 时不得把整个 JSON 打进 message。 + if got := usageLogErrorMessage(400, []byte(`{"error":{"foo":"bar"}}`)); got != "HTTP 400" { + t.Fatalf("object error without message must fall back: %q", got) + } +} From ab83539b0ed416ab78df149a8782e8cd4c19d212 Mon Sep 17 00:00:00 2001 From: hu <187184415@qq.com> Date: Wed, 26 Aug 2026 12:22:41 +0800 Subject: [PATCH 03/84] fix(grok): normalize union roots even when type is object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production follow-up: the real Codex App schema declares type:"object" together with a root-level anyOf, and Grok still rejects it as a union root. Detect anyOf/oneOf/allOf before the type check, use the root's own properties/required as the merge base, and stop importing branch-side required keys whenever a non-object branch (typically null, meaning the tool may be called with no arguments) was dropped — hardening those keys would forbid calls the original schema allowed. allOf keeps the union of required per its all-must-hold semantics. --- proxy/grok_namespace_tools.go | 53 +++++++++++++++++++++++------- proxy/grok_namespace_tools_test.go | 41 +++++++++++++++++++++-- 2 files changed, 80 insertions(+), 14 deletions(-) diff --git a/proxy/grok_namespace_tools.go b/proxy/grok_namespace_tools.go index 6e2999ca..2adf19e4 100644 --- a/proxy/grok_namespace_tools.go +++ b/proxy/grok_namespace_tools.go @@ -159,12 +159,29 @@ func mergeGrokUnionRootSchema(root map[string]any, branches []any, requireAll bo if description, ok := root["description"].(string); ok && strings.TrimSpace(description) != "" { merged["description"] = description } + // 根自身可能就是 object schema(type:"object" 且额外挂了 anyOf/oneOf):它的 + // properties/required 在任何分支下都成立,作为合并基底且 required 恒保留。 properties := map[string]any{} + rootRequired := map[string]bool{} + if rootProps, ok := root["properties"].(map[string]any); ok { + for key, value := range rootProps { + properties[key] = value + } + } + if list, ok := root["required"].([]any); ok { + for _, item := range list { + if key, ok := item.(string); ok { + rootRequired[key] = true + } + } + } var required map[string]bool objectBranches := 0 + droppedNonObject := false for _, rawBranch := range branches { branch, ok := rawBranch.(map[string]any) if !ok || !grokObjectishSchemaBranch(branch) { + droppedNonObject = true continue } objectBranches++ @@ -202,15 +219,27 @@ func mergeGrokUnionRootSchema(root map[string]any, branches []any, requireAll bo } } } - if objectBranches == 0 { + if objectBranches == 0 && len(properties) == 0 { return grokPermissiveObjectSchema(root) } if len(properties) > 0 { merged["properties"] = properties } - if len(required) > 0 { - keys := make([]string, 0, len(required)) + finalRequired := map[string]bool{} + for key := range rootRequired { + finalRequired[key] = true + } + // 联合里丢弃过非 object 分支(常见是 null,表示"可空调用")时,分支侧的 + // required 不再并入:null 分支已不可表达,再强加必填会让模型无法发出 + // 原本合法的空参调用。allOf(全部成立)不受此影响。 + if requireAll || !droppedNonObject { for key := range required { + finalRequired[key] = true + } + } + if len(finalRequired) > 0 { + keys := make([]string, 0, len(finalRequired)) + for key := range finalRequired { keys = append(keys, key) } sort.Strings(keys) @@ -231,6 +260,16 @@ func normalizeGrokToolParameterSchema(schema map[string]any) (map[string]any, bo if schema == nil { return schema, false } + // 联合关键字优先于 type 判断:Grok 只要在根上看到 anyOf/oneOf 就按联合根 + // 拒绝,即使同时声明了 type:"object"(2026-08-26 线上实测)。 + for _, key := range []string{"anyOf", "oneOf"} { + if branches, ok := schema[key].([]any); ok && len(branches) > 0 { + return mergeGrokUnionRootSchema(schema, branches, false), true + } + } + if branches, ok := schema["allOf"].([]any); ok && len(branches) > 0 { + return mergeGrokUnionRootSchema(schema, branches, true), true + } if typeList, ok := schema["type"].([]any); ok { hasObject := false for _, item := range typeList { @@ -255,14 +294,6 @@ func normalizeGrokToolParameterSchema(schema map[string]any) (map[string]any, bo } return grokPermissiveObjectSchema(schema), true } - for _, key := range []string{"anyOf", "oneOf"} { - if branches, ok := schema[key].([]any); ok && len(branches) > 0 { - return mergeGrokUnionRootSchema(schema, branches, false), true - } - } - if branches, ok := schema["allOf"].([]any); ok && len(branches) > 0 { - return mergeGrokUnionRootSchema(schema, branches, true), true - } if _, ok := schema["properties"]; ok { out := make(map[string]any, len(schema)+1) for key, value := range schema { diff --git a/proxy/grok_namespace_tools_test.go b/proxy/grok_namespace_tools_test.go index 761157be..a57d68e9 100644 --- a/proxy/grok_namespace_tools_test.go +++ b/proxy/grok_namespace_tools_test.go @@ -661,9 +661,9 @@ func TestGrokFunctionToolRootSchemaNormalizedForUpstream(t *testing.T) { if !union.Get("properties.action").Exists() || !union.Get("properties.batch").Exists() { t.Fatalf("merged properties missing: %s", union.Raw) } - required := union.Get("required").Array() - if len(required) != 1 || required[0].String() != "shared" { - t.Fatalf("required must keep only keys mandatory in every object branch, got %s", union.Get("required").Raw) + // 联合中含被丢弃的 null 分支(可空调用),分支侧 required 不得并入。 + if union.Get("required").Exists() { + t.Fatalf("required must be dropped when a non-object branch was discarded, got %s", union.Get("required").Raw) } if union.Get("description").String() != "update automation" { t.Fatalf("root description lost: %s", union.Raw) @@ -700,3 +700,38 @@ func TestGrokFunctionToolObjectRootSchemaUntouched(t *testing.T) { t.Fatalf("compliant schema was altered: %s", schema.Raw) } } + +// 真实事故形态(2026-08-26 用户回报):schema 根同时带 type:"object" 与 anyOf, +// Grok 仍按联合根拒绝。归一必须以"根上出现 anyOf/oneOf/allOf"为准,不能因为 +// type 已是 object 就放行;根自身的 properties/required 要并进合并结果。 +func TestGrokFunctionToolObjectTypeWithUnionRootStillNormalized(t *testing.T) { + body := []byte(`{ + "model":"grok-4.6", + "tools":[{"type":"function","name":"mcp__codex_app__automation_update","parameters":{ + "type":"object", + "description":"update automation", + "properties":{"id":{"type":"string"}}, + "required":["id"], + "anyOf":[ + {"type":"object","properties":{"action":{"type":"string"}},"required":["action"]}, + {"type":"null"} + ] + }}], + "input":[{"type":"message","role":"user","content":"hi"}] + }`) + result := prepareGrokUpstreamBody(body) + schema := gjson.GetBytes(result.Body, `tools.0.parameters`) + if schema.Get("anyOf").Exists() || schema.Get("oneOf").Exists() { + t.Fatalf("union keyword survived at object-typed root: %s", schema.Raw) + } + if schema.Get("type").String() != "object" { + t.Fatalf("root type must stay object: %s", schema.Raw) + } + if !schema.Get("properties.id").Exists() || !schema.Get("properties.action").Exists() { + t.Fatalf("root and branch properties must both survive: %s", schema.Raw) + } + required := schema.Get("required").Array() + if len(required) != 1 || required[0].String() != "id" { + t.Fatalf("root required must be kept, branch-only keys dropped from union intersection: %s", schema.Get("required").Raw) + } +} From 036dc61174e3f72b2f10144b9bfe2e628848f224 Mon Sep 17 00:00:00 2001 From: hu <187184415@qq.com> Date: Fri, 28 Aug 2026 17:01:17 +0800 Subject: [PATCH 04/84] feat(claude): add Claude Code OAuth provider with fingerprint stabilization Adds Claude Code (Anthropic) OAuth subscription accounts as a fourth upstream provider alongside codex/grok/antigravity, reusing the existing account pool, scheduler, refresh, usage-window and proxy infrastructure. All changes are additive, claude-guarded branches. Backend: - auth/claude_oauth.go: OAuth2+PKCE login, code exchange, token refresh, profile lookup over a uTLS (Cloudflare-resistant) client - auth/claude_account.go: UpstreamClaude, IsClaudeOAuth, refreshClaudeAccount - auth/claude_fingerprint.go: per-account stable Claude Code CLI fingerprint (UA / x-app / x-stainless-*) + timezone, persisted in credentials - proxy/claude_upstream.go: near-passthrough to api.anthropic.com/v1/messages (Bearer + anthropic-beta oauth + mandatory Claude Code system block); preserves a real client's identity headers, else synthesizes from the account fingerprint; NFC + invisible-char request sanitization; reuses native SSE path - admin/claude_accounts.go: OAuth two-step + token-JSON import endpoints, proxy-pool selection, dedup under mergeDuplicateMu - database: UpstreamChannelClaude channel constant + filter - cmd/claude_login: standalone non-interactive login/refresh self-test CLI Frontend: - new ClaudeAccounts page + provider switcher tab, routing, ChannelLogo, api, i18n - ProxyPoolSelect shows per-proxy bound-account count / idle, unified across all providers (Antigravity adopted the shared picker) Verified: go build ./..., go vet ./..., Claude + relay/messages/grok regression tests, frontend tsc + vite build all pass. --- admin/claude_accounts.go | 270 +++++++++++ admin/claude_accounts_test.go | 21 + admin/handler.go | 3 + auth/claude_account.go | 136 ++++++ auth/claude_fingerprint.go | 126 +++++ auth/claude_fingerprint_test.go | 58 +++ auth/claude_oauth.go | 458 ++++++++++++++++++ auth/claude_oauth_test.go | 118 +++++ auth/grok_account.go | 2 +- auth/store.go | 5 + cmd/claude_login/main.go | 182 +++++++ database/postgres.go | 7 +- frontend/src/App.tsx | 1 + frontend/src/api.ts | 25 + frontend/src/components/ChannelLogo.tsx | 17 +- frontend/src/components/ProxyPoolSelect.tsx | 15 +- frontend/src/locales/en.json | 500 +++++++++++++------- frontend/src/locales/zh-TW.json | 462 +++++++++++++----- frontend/src/locales/zh.json | 480 +++++++++++++------ frontend/src/pages/Accounts.tsx | 24 +- frontend/src/pages/AntigravityAccounts.tsx | 25 + frontend/src/pages/ClaudeAccounts.tsx | 415 ++++++++++++++++ frontend/src/types.ts | 37 +- proxy/claude_upstream.go | 304 ++++++++++++ proxy/claude_upstream_test.go | 152 ++++++ proxy/handler.go | 5 + proxy/handler_anthropic.go | 13 +- 27 files changed, 3400 insertions(+), 461 deletions(-) create mode 100644 admin/claude_accounts.go create mode 100644 admin/claude_accounts_test.go create mode 100644 auth/claude_account.go create mode 100644 auth/claude_fingerprint.go create mode 100644 auth/claude_fingerprint_test.go create mode 100644 auth/claude_oauth.go create mode 100644 auth/claude_oauth_test.go create mode 100644 cmd/claude_login/main.go create mode 100644 frontend/src/pages/ClaudeAccounts.tsx create mode 100644 proxy/claude_upstream.go create mode 100644 proxy/claude_upstream_test.go diff --git a/admin/claude_accounts.go b/admin/claude_accounts.go new file mode 100644 index 00000000..b02c938b --- /dev/null +++ b/admin/claude_accounts.go @@ -0,0 +1,270 @@ +package admin + +// Claude Code(Anthropic)OAuth 账号的后台导入端点。 +// +// 提供两条导入路径: +// 1. 网页 OAuth 两步式: +// POST /accounts/claude/oauth/auth-url → 返回授权 URL + state +// POST /accounts/claude/oauth/exchange-code → 用 state+code 换 token 并入库 +// 服务端用一个带 TTL 的内存表按 state 暂存 verifier。 +// 2. CLI 直导: +// POST /accounts/claude/import → 直接吃 cmd/claude_login -out 产出的 +// token JSON(access_token/refresh_token/...)入库,无需服务端 OAuth 往返。 + +import ( + "context" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/codex2api/auth" + "github.com/codex2api/database" + "github.com/codex2api/security" + "github.com/gin-gonic/gin" +) + +// claudeOAuthPending 暂存一次登录的 state→verifier(带 TTL)。 +type claudeOAuthPending struct { + verifier string + createdAt time.Time +} + +var ( + claudeOAuthMu sync.Mutex + claudeOAuthPendMap = map[string]claudeOAuthPending{} +) + +const claudeOAuthSessionTTL = 15 * time.Minute + +func claudeOAuthPut(state, verifier string) { + claudeOAuthMu.Lock() + defer claudeOAuthMu.Unlock() + // 顺带清理过期项,避免内存无限增长。 + now := time.Now() + for k, v := range claudeOAuthPendMap { + if now.Sub(v.createdAt) > claudeOAuthSessionTTL { + delete(claudeOAuthPendMap, k) + } + } + claudeOAuthPendMap[state] = claudeOAuthPending{verifier: verifier, createdAt: now} +} + +func claudeOAuthTake(state string) (string, bool) { + claudeOAuthMu.Lock() + defer claudeOAuthMu.Unlock() + p, ok := claudeOAuthPendMap[state] + if !ok { + return "", false + } + delete(claudeOAuthPendMap, state) + if time.Since(p.createdAt) > claudeOAuthSessionTTL { + return "", false + } + return p.verifier, true +} + +// GenerateClaudeAuthURL 发起一次 Claude OAuth 登录,返回授权 URL 与 state。 +func (h *Handler) GenerateClaudeAuthURL(c *gin.Context) { + session, err := auth.StartClaudeLogin() + if err != nil { + writeInternalError(c, err) + return + } + claudeOAuthPut(session.State, session.Verifier) + c.JSON(http.StatusOK, gin.H{ + "auth_url": session.AuthURL, + "state": session.State, + }) +} + +type exchangeClaudeCodeReq struct { + State string `json:"state"` + Code string `json:"code"` + Name string `json:"name"` + // ProxyURL 指定固定代理;留空且 UseProxyPool=true 时从代理池自动取一个。 + ProxyURL string `json:"proxy_url"` + UseProxyPool bool `json:"use_proxy_pool"` + // Timezone 账号绑定的 IANA 时区(如 Asia/Shanghai),用于指纹一致性;空=不指定。 + Timezone string `json:"timezone"` +} + +// resolveClaudeLoginProxy 决定本次登录/导入使用并固定到账号的代理: +// 显式 proxy_url 优先;否则若 use_proxy_pool=true 则从代理池轮询取一个。 +// 返回的代理会同时用于 OAuth 交换、后续刷新与推理出站,保证 IP 一致(防风控)。 +func (h *Handler) resolveClaudeLoginProxy(rawURL string, usePool bool) (string, error) { + rawURL = strings.TrimSpace(rawURL) + if rawURL != "" { + if err := security.ValidateProxyURL(rawURL); err != nil { + return "", err + } + return rawURL, nil + } + if usePool && h.store != nil { + return strings.TrimSpace(h.store.NextProxy()), nil + } + return "", nil +} + +// ExchangeClaudeOAuthCode 用 state+code 换取 token 并把账号写入池子。 +func (h *Handler) ExchangeClaudeOAuthCode(c *gin.Context) { + var req exchangeClaudeCodeReq + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, http.StatusBadRequest, "请求格式错误") + return + } + req.Name = security.SanitizeInput(req.Name) + req.ProxyURL = security.SanitizeInput(req.ProxyURL) + req.State = strings.TrimSpace(req.State) + req.Code = strings.TrimSpace(req.Code) + if req.State == "" || req.Code == "" { + writeError(c, http.StatusBadRequest, "state 与 code 均为必填") + return + } + proxyURL, err := h.resolveClaudeLoginProxy(req.ProxyURL, req.UseProxyPool) + if err != nil { + writeError(c, http.StatusBadRequest, "代理URL无效") + return + } + verifier, ok := claudeOAuthTake(req.State) + if !ok { + writeError(c, http.StatusBadRequest, "登录会话已过期或不存在,请重新获取授权 URL") + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second) + defer cancel() + + client := auth.NewClaudeAuth(proxyURL) + td, err := client.ExchangeCode(ctx, req.Code, req.State, verifier) + if err != nil { + writeError(c, http.StatusBadGateway, "换取 token 失败: "+err.Error()) + return + } + h.insertClaudeAccount(c, ctx, req.Name, proxyURL, req.Timezone, td, "manual_claude_oauth") +} + +type importClaudeTokenReq struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + Email string `json:"email"` + AccountID string `json:"account_id"` + ExpiresAt string `json:"expires_at"` + Name string `json:"name"` + ProxyURL string `json:"proxy_url"` + UseProxyPool bool `json:"use_proxy_pool"` + Timezone string `json:"timezone"` +} + +// ImportClaudeToken 直接吃 cmd/claude_login -out 产出的 token JSON 入库。 +func (h *Handler) ImportClaudeToken(c *gin.Context) { + var req importClaudeTokenReq + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, http.StatusBadRequest, "请求格式错误") + return + } + req.Name = security.SanitizeInput(req.Name) + req.ProxyURL = security.SanitizeInput(req.ProxyURL) + req.AccessToken = strings.TrimSpace(req.AccessToken) + req.RefreshToken = strings.TrimSpace(req.RefreshToken) + if req.AccessToken == "" || req.RefreshToken == "" { + writeError(c, http.StatusBadRequest, "access_token 与 refresh_token 均为必填") + return + } + proxyURL, err := h.resolveClaudeLoginProxy(req.ProxyURL, req.UseProxyPool) + if err != nil { + writeError(c, http.StatusBadRequest, "代理URL无效") + return + } + expiresAt := time.Now().Add(30 * time.Minute) + if strings.TrimSpace(req.ExpiresAt) != "" { + if parsed, perr := time.Parse(time.RFC3339, strings.TrimSpace(req.ExpiresAt)); perr == nil { + expiresAt = parsed + } + } + td := &auth.ClaudeTokenData{ + AccessToken: req.AccessToken, + RefreshToken: req.RefreshToken, + Email: strings.TrimSpace(req.Email), + AccountUUID: strings.TrimSpace(req.AccountID), + ExpiresAt: expiresAt, + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + h.insertClaudeAccount(c, ctx, req.Name, proxyURL, req.Timezone, td, "manual_claude_import") +} + +// insertClaudeAccount 把一份 Claude token 落库并加载进运行时池子(去重按 account_id)。 +// timezone 为空时不指定时区。会为该账号生成一套稳定的 Claude Code 指纹并随凭据落库, +// 之后每次上游请求原样套用(见 proxy/claude_upstream.go)。 +func (h *Handler) insertClaudeAccount(c *gin.Context, ctx context.Context, name, proxyURL, timezone string, td *auth.ClaudeTokenData, source string) { + email := strings.TrimSpace(td.Email) + accountUUID := strings.TrimSpace(td.AccountUUID) + + if name == "" { + name = email + } + if name == "" { + name = "claude" + } + + // 生成稳定指纹(UA / x-app / x-stainless-*),存进 custom_headers 供请求期套用。 + fingerprint := auth.GenerateClaudeFingerprint(timezone) + customHeaders := fingerprint.Headers() + + credentials := map[string]interface{}{ + "upstream_type": auth.UpstreamClaude, + "access_token": td.AccessToken, + "refresh_token": td.RefreshToken, + "expires_at": td.ExpiresAt.Format(time.RFC3339), + "email": email, + "account_id": accountUUID, + "plan_type": "claude", + "custom_headers": customHeaders, + "timezone": fingerprint.Timezone, + } + // 查重与插入置于同一临界区,避免并发导入同一账号各插一条(TOCTOU)。 + // 复用 antigravity/grok 相同的合并去重锁,跨 provider 一致。 + h.mergeDuplicateMu.Lock() + if accountUUID != "" { + if rows, listErr := h.db.ListActiveByChannel(ctx, database.UpstreamChannelClaude); listErr == nil { + for _, row := range rows { + if strings.EqualFold(strings.TrimSpace(row.GetCredential("account_id")), accountUUID) { + h.mergeDuplicateMu.Unlock() + writeError(c, http.StatusConflict, fmt.Sprintf("Claude 账号已存在 (id=%d)", row.ID)) + return + } + } + } + } + id, err := h.db.InsertAccountWithUpstream(ctx, name, "anthropic", auth.UpstreamClaude, credentials, proxyURL) + h.mergeDuplicateMu.Unlock() + if err != nil { + writeInternalError(c, err) + return + } + + h.store.AddAccount(&auth.Account{ + DBID: id, + ProxyURL: proxyURL, + HealthTier: auth.HealthTierHealthy, + UpstreamType: auth.UpstreamClaude, + AccessToken: td.AccessToken, + RefreshToken: td.RefreshToken, + ExpiresAt: td.ExpiresAt, + AccountID: accountUUID, + Email: email, + PlanType: "claude", + CustomHeaders: customHeaders, + }) + + h.db.InsertAccountEventAsync(id, "added", source) + security.SecurityAuditLog("CLAUDE_ACCOUNT_ADDED", fmt.Sprintf("account_id=%d ip=%s", id, c.ClientIP())) + c.JSON(http.StatusOK, gin.H{ + "message": "成功添加 Claude 账号", + "id": id, + "email": email, + }) +} diff --git a/admin/claude_accounts_test.go b/admin/claude_accounts_test.go new file mode 100644 index 00000000..1edc5adc --- /dev/null +++ b/admin/claude_accounts_test.go @@ -0,0 +1,21 @@ +package admin + +import "testing" + +func TestClaudeOAuthPutTake_OneTimeUse(t *testing.T) { + claudeOAuthPut("state-a", "verifier-a") + v, ok := claudeOAuthTake("state-a") + if !ok || v != "verifier-a" { + t.Fatalf("首次 take 应成功返回 verifier, got=(%q,%v)", v, ok) + } + // 一次性:再次 take 应失败。 + if _, ok := claudeOAuthTake("state-a"); ok { + t.Fatal("同一 state 不应被 take 两次") + } +} + +func TestClaudeOAuthTake_Missing(t *testing.T) { + if _, ok := claudeOAuthTake("no-such-state"); ok { + t.Fatal("不存在的 state 应返回 false") + } +} diff --git a/admin/handler.go b/admin/handler.go index 1e399821..998e8b82 100644 --- a/admin/handler.go +++ b/admin/handler.go @@ -1044,6 +1044,9 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) { api.POST("/accounts/grok/import", h.BatchImportGrokAccounts) api.POST("/accounts/grok/oauth/auth-url", h.GenerateGrokAuthURL) // 兼容旧客户端 api.POST("/accounts/grok/oauth/exchange-code", h.ExchangeGrokOAuthCode) // 兼容旧客户端 + api.POST("/accounts/claude/oauth/auth-url", h.GenerateClaudeAuthURL) + api.POST("/accounts/claude/oauth/exchange-code", h.ExchangeClaudeOAuthCode) + api.POST("/accounts/claude/import", h.ImportClaudeToken) api.POST("/accounts/antigravity", h.AddAntigravityAccount) api.POST("/accounts/antigravity/models", h.FetchAntigravityModels) api.POST("/accounts/antigravity/batch-models", h.BatchUpdateAntigravityModels) diff --git a/auth/claude_account.go b/auth/claude_account.go new file mode 100644 index 00000000..1631202f --- /dev/null +++ b/auth/claude_account.go @@ -0,0 +1,136 @@ +package auth + +// Claude Code(Anthropic)账号在账号池中的运行时接线。 +// +// 设计原则:尽量复用现有通用 OAuth 加载/调度框架,只新增 Claude 独有的部分。 +// - 加载:带 access_token + refresh_token 的 Claude 账号(upstream_type=claude) +// 直接走 buildAccountFromRow 的通用分支,无需改动那段 CRITICAL 代码。 +// - 刷新:Claude 的 RT 刷新端点与请求体和 ChatGPT/Codex 不同,故在 +// refreshAccountWithOptions 顶部按 IsClaudeOAuth() 早返回到这里的专用流程。 + +import ( + "context" + "fmt" + "strings" + "sync/atomic" + "time" +) + +// UpstreamClaude 是 Claude Code OAuth 账号的 upstream_type 判别值。 +const UpstreamClaude = "claude" + +// isClaudeOAuthLocked 判断账号是否为 Claude Code OAuth 账号。调用方需持有 a.mu。 +func (a *Account) isClaudeOAuthLocked() bool { + return strings.EqualFold(strings.TrimSpace(a.UpstreamType), UpstreamClaude) +} + +// IsClaudeOAuth 判断账号是否为 Claude Code OAuth 账号。 +func (a *Account) IsClaudeOAuth() bool { + if a == nil { + return false + } + a.mu.RLock() + defer a.mu.RUnlock() + return a.isClaudeOAuthLocked() +} + +// refreshClaudeAccount 刷新一个 Claude Code OAuth 账号的 access token。 +// +// 与 Grok/Codex 相比刷新逻辑刻意从简(自用场景账号数不多):复用跨实例共享的 +// OAuth 刷新租约避免并发抢刷 + RT 轮换竞争,拿到新 token 后原子合并落库并更新 +// 内存态与调度器。 +func (s *Store) refreshClaudeAccount(ctx context.Context, acc *Account, forceRefresh bool) error { + acc.mu.RLock() + rt := strings.TrimSpace(acc.RefreshToken) + dbID := acc.DBID + proxyURL := strings.TrimSpace(acc.ProxyURL) + lockedAccessToken := acc.AccessToken + cooldownActive := acc.Status == StatusCooldown && time.Now().Before(acc.CooldownUtil) + acc.mu.RUnlock() + + if rt == "" { + return fmt.Errorf("claude refresh_token 为空") + } + + // 跨实例共享刷新租约:等待期间别的实例可能已经轮换过 RT,拿到锁后重新读库, + // 若已被刷新且可用则直接复用,避免第二次刷新消费掉刚轮换出来的新 RT。 + lease, lockErr := s.acquireOAuthRefreshLease(ctx, rt) + if lockErr != nil { + return lockErr + } + defer lease.Release() + ctx = lease.Context() + + if changed, usable, reloadErr := s.reloadOAuthCredentialsAfterLock(ctx, acc, rt, lockedAccessToken); reloadErr != nil { + // 读库失败不阻断刷新,继续用入口快照的 rt 尝试。 + } else if changed && usable && !forceRefresh { + s.finishReloadedOAuthRefresh(ctx, acc) + return nil + } else if changed { + acc.mu.RLock() + rt = strings.TrimSpace(acc.RefreshToken) + acc.mu.RUnlock() + if rt == "" { + return fmt.Errorf("claude refresh_token 为空") + } + } + + client := NewClaudeAuth(proxyURL) + td, err := client.RefreshTokens(ctx, rt) + if err != nil { + return fmt.Errorf("claude token 刷新失败: %w", err) + } + if strings.TrimSpace(td.AccessToken) == "" { + return fmt.Errorf("claude 刷新响应缺少 access_token") + } + + // 原子合并落库(JSONB ||,不覆盖其他字段)。 + updates := map[string]interface{}{ + "access_token": td.AccessToken, + "expires_at": td.ExpiresAt.Format(time.RFC3339), + } + if strings.TrimSpace(td.RefreshToken) != "" { + updates["refresh_token"] = td.RefreshToken + } + if strings.TrimSpace(td.Email) != "" { + updates["email"] = td.Email + } + if strings.TrimSpace(td.AccountUUID) != "" { + updates["account_id"] = td.AccountUUID + } + if s.db != nil { + if err := s.db.UpdateCredentials(ctx, dbID, updates); err != nil { + return fmt.Errorf("claude 刷新结果落库失败: %w", err) + } + } + + // 更新内存态与调度器。冷却中的账号保留冷却状态,仅刷新令牌。 + acc.mu.Lock() + acc.AccessToken = td.AccessToken + if strings.TrimSpace(td.RefreshToken) != "" { + acc.RefreshToken = td.RefreshToken + } + acc.ExpiresAt = td.ExpiresAt + if strings.TrimSpace(td.Email) != "" { + acc.Email = td.Email + } + if strings.TrimSpace(td.AccountUUID) != "" { + acc.AccountID = td.AccountUUID + } + if !cooldownActive { + acc.Status = StatusReady + acc.CooldownUtil = time.Time{} + acc.CooldownReason = "" + } + if acc.Status != StatusError { + acc.HealthTier = HealthTierHealthy + } + acc.recomputeSchedulerLocked(atomic.LoadInt64(&s.maxConcurrency)) + acc.mu.Unlock() + + s.fastSchedulerUpdate(acc) + if !cooldownActive && s.db != nil { + _ = s.db.ClearError(ctx, dbID) + } + return nil +} diff --git a/auth/claude_fingerprint.go b/auth/claude_fingerprint.go new file mode 100644 index 00000000..23383e2c --- /dev/null +++ b/auth/claude_fingerprint.go @@ -0,0 +1,126 @@ +package auth + +// Claude Code 客户端指纹。 +// +// 目的:让每个 Claude 账号对外呈现一套**稳定且各不相同**的真实 Claude Code CLI +// 身份(UA / x-app / x-stainless-*),对抗 Anthropic 的一致性风控——最容易被标记的 +// 不是某个具体值,而是"同一账号身份忽变"。指纹在导入账号时生成一次并持久化到 +// credentials.custom_headers,之后每次上游请求原样套用。 +// +// 值域取自真实 Claude Code / @anthropic-ai SDK 在链路上出现过的组合,随机挑选但一旦 +// 落库即固定。真实 Claude Code 客户端直连时,其自带的这些头会被优先保留(见 +// proxy 层 applyClaudeMessagesHeaders),仅在缺失时才用这里合成的指纹补齐。 + +import ( + "crypto/rand" + "math/big" + "strings" + "time" +) + +// 真实取值池(保持精简、贴近近期版本)。 +var ( + claudeCLIVersions = []string{"2.1.220", "2.1.219", "2.1.205", "2.0.14"} + claudeSDKVersions = []string{"0.68.0", "0.65.0", "0.63.1", "0.60.0"} + claudeNodeRuntime = []string{"v22.14.0", "v22.11.0", "v20.18.1", "v20.17.0"} + claudeStainlessOS = []string{"MacOS", "Linux", "Windows"} + claudeArchByOS = map[string][]string{ + "MacOS": {"arm64", "x64"}, + "Linux": {"x64", "arm64"}, + "Windows": {"x64"}, + } +) + +// ClaudeFingerprint 是一套稳定的 Claude Code CLI 身份。 +type ClaudeFingerprint struct { + UserAgent string `json:"user_agent"` + XApp string `json:"x_app"` + StainlessLang string `json:"x_stainless_lang"` + StainlessPackageVersion string `json:"x_stainless_package_version"` + StainlessOS string `json:"x_stainless_os"` + StainlessArch string `json:"x_stainless_arch"` + StainlessRuntime string `json:"x_stainless_runtime"` + StainlessRuntimeVersion string `json:"x_stainless_runtime_version"` + // Timezone 是账号绑定的 IANA 时区(如 Asia/Shanghai),用于身份一致性; + // 空表示不指定。 + Timezone string `json:"timezone,omitempty"` +} + +func claudePick(pool []string) string { + if len(pool) == 0 { + return "" + } + n, err := rand.Int(rand.Reader, big.NewInt(int64(len(pool)))) + if err != nil { + return pool[0] + } + return pool[n.Int64()] +} + +// GenerateClaudeFingerprint 生成一套稳定指纹。timezone 为空时不设置(留给调用方决定 +// 是否用全局默认)。非空时会校验为合法 IANA 时区,非法则丢弃。 +func GenerateClaudeFingerprint(timezone string) ClaudeFingerprint { + cliVer := claudePick(claudeCLIVersions) + os := claudePick(claudeStainlessOS) + arch := claudePick(claudeArchByOS[os]) + fp := ClaudeFingerprint{ + UserAgent: "claude-cli/" + cliVer + " (external, cli)", + XApp: "cli", + StainlessLang: "js", + StainlessPackageVersion: claudePick(claudeSDKVersions), + StainlessOS: os, + StainlessArch: arch, + StainlessRuntime: "node", + StainlessRuntimeVersion: claudePick(claudeNodeRuntime), + } + if tz := strings.TrimSpace(timezone); tz != "" { + if _, err := time.LoadLocation(tz); err == nil { + fp.Timezone = tz + } + } + return fp +} + +// Headers 返回该指纹对应的请求头(键为规范化的头名)。仅返回 x-stainless / x-app / +// user-agent 这类身份头;Authorization / anthropic-* 由调用方另行设置。 +func (f ClaudeFingerprint) Headers() map[string]string { + h := map[string]string{} + if f.UserAgent != "" { + h["User-Agent"] = f.UserAgent + } + if f.XApp != "" { + h["X-App"] = f.XApp + } + if f.StainlessLang != "" { + h["X-Stainless-Lang"] = f.StainlessLang + } + if f.StainlessPackageVersion != "" { + h["X-Stainless-Package-Version"] = f.StainlessPackageVersion + } + if f.StainlessOS != "" { + h["X-Stainless-OS"] = f.StainlessOS + } + if f.StainlessArch != "" { + h["X-Stainless-Arch"] = f.StainlessArch + } + if f.StainlessRuntime != "" { + h["X-Stainless-Runtime"] = f.StainlessRuntime + } + if f.StainlessRuntimeVersion != "" { + h["X-Stainless-Runtime-Version"] = f.StainlessRuntimeVersion + } + return h +} + +// ClaudeIdentityHeaderNames 是"客户端身份"类头名(小写),用于在透传时判断入站真实 +// 客户端是否已自带身份、以及需要用指纹补齐哪些。 +var ClaudeIdentityHeaderNames = []string{ + "user-agent", + "x-app", + "x-stainless-lang", + "x-stainless-package-version", + "x-stainless-os", + "x-stainless-arch", + "x-stainless-runtime", + "x-stainless-runtime-version", +} diff --git a/auth/claude_fingerprint_test.go b/auth/claude_fingerprint_test.go new file mode 100644 index 00000000..28b017d1 --- /dev/null +++ b/auth/claude_fingerprint_test.go @@ -0,0 +1,58 @@ +package auth + +import ( + "strings" + "testing" +) + +func TestGenerateClaudeFingerprint_Fields(t *testing.T) { + fp := GenerateClaudeFingerprint("") + if !strings.HasPrefix(fp.UserAgent, "claude-cli/") || !strings.Contains(fp.UserAgent, "(external, cli)") { + t.Fatalf("UA 不像 Claude Code CLI: %s", fp.UserAgent) + } + if fp.XApp != "cli" { + t.Errorf("x-app 应为 cli, got %s", fp.XApp) + } + if fp.StainlessLang != "js" || fp.StainlessRuntime != "node" { + t.Errorf("stainless lang/runtime 不符: %s/%s", fp.StainlessLang, fp.StainlessRuntime) + } + if fp.StainlessOS == "" || fp.StainlessArch == "" || fp.StainlessRuntimeVersion == "" || fp.StainlessPackageVersion == "" { + t.Error("stainless os/arch/runtime-version/package-version 不应为空") + } +} + +func TestGenerateClaudeFingerprint_TimezoneValidation(t *testing.T) { + if fp := GenerateClaudeFingerprint("Asia/Shanghai"); fp.Timezone != "Asia/Shanghai" { + t.Errorf("合法时区应保留, got %q", fp.Timezone) + } + if fp := GenerateClaudeFingerprint("Not/A_Zone"); fp.Timezone != "" { + t.Errorf("非法时区应丢弃, got %q", fp.Timezone) + } +} + +func TestClaudeFingerprintHeaders(t *testing.T) { + fp := GenerateClaudeFingerprint("") + h := fp.Headers() + for _, k := range []string{"User-Agent", "X-App", "X-Stainless-Lang", "X-Stainless-OS", "X-Stainless-Arch", "X-Stainless-Runtime", "X-Stainless-Runtime-Version", "X-Stainless-Package-Version"} { + if strings.TrimSpace(h[k]) == "" { + t.Errorf("Headers() 缺少 %s", k) + } + } +} + +func TestGenerateClaudeFingerprint_ArchMatchesOS(t *testing.T) { + // Windows 只应出现 x64(池约束)。多次抽样验证不越界。 + for i := 0; i < 30; i++ { + fp := GenerateClaudeFingerprint("") + valid := claudeArchByOS[fp.StainlessOS] + found := false + for _, a := range valid { + if a == fp.StainlessArch { + found = true + } + } + if !found { + t.Fatalf("os=%s 的 arch=%s 不在允许集 %v", fp.StainlessOS, fp.StainlessArch, valid) + } + } +} diff --git a/auth/claude_oauth.go b/auth/claude_oauth.go new file mode 100644 index 00000000..e106801b --- /dev/null +++ b/auth/claude_oauth.go @@ -0,0 +1,458 @@ +package auth + +// Claude Code(Anthropic)OAuth 登录模块。 +// +// 本文件把 Claude Code 官方客户端的 OAuth2 + PKCE 登录流程移植进账号池,使得 +// 平台可以像管理 Codex / Grok / Antigravity 账号一样,纳管多个 Claude Pro/Max +// 订阅账号并统一调度。参数对齐 Claude Code 官方客户端(client_id / 端点 / scope / +// 强制 beta 头),逆向常量参考 CLIProxyAPI(router-for-me/CLIProxyAPI)。 +// +// 使用方式(服务器无本地回调场景,采用手动粘贴授权码): +// 1. StartClaudeLogin() 生成 AuthURL + State + Verifier,把 AuthURL 交给用户在 +// 浏览器打开授权;State/Verifier 由调用方短期缓存。 +// 2. 用户授权后浏览器跳转到 RedirectURI?code=...#state,用户复制 code 粘回后台。 +// 3. ExchangeCode() 用 code + Verifier 换取 access/refresh token 并回填账号身份。 +// 4. RefreshTokens() 在 access token 临期时用 refresh token 续期。 + +import ( + "bytes" + "compress/flate" + "compress/gzip" + "compress/zlib" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/andybalholm/brotli" +) + +// Claude OAuth 配置常量。对齐 Claude Code 官方客户端在链路上的取值。 +const ( + // ClaudeOAuthClientID 是 Claude Code 官方客户端的公开 OAuth client_id。 + ClaudeOAuthClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" + // ClaudeOAuthAuthURL 是授权页地址(用户浏览器打开处)。 + ClaudeOAuthAuthURL = "https://claude.ai/oauth/authorize" + // ClaudeOAuthTokenURL 同时用于授权码交换与刷新(Claude Code 走 platform.claude.com)。 + ClaudeOAuthTokenURL = "https://platform.claude.com/v1/oauth/token" + // ClaudeOAuthProfileURL 用 access token 换取账号身份(email / uuid / 组织)。 + ClaudeOAuthProfileURL = "https://api.anthropic.com/api/oauth/profile" + // ClaudeOAuthRedirectURI 是官方客户端使用的本地回调地址;服务器场景下仅用于 + // 拼装授权 URL,用户从跳转后的地址栏复制授权码即可,无需本机监听。 + ClaudeOAuthRedirectURI = "http://localhost:54545/callback" + // ClaudeOAuthScope 是 Claude Code 请求的权限范围,必须与官方一致。 + ClaudeOAuthScope = "user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload" + // ClaudeOAuthBeta 是 OAuth 凭据调用推理接口时必须声明的 anthropic-beta 值。 + ClaudeOAuthBeta = "oauth-2025-04-20" + + claudeOAuthHTTPTimeout = 30 * time.Second +) + +// ClaudePKCECodes 保存一对 PKCE 校验码(RFC 7636,S256)。 +type ClaudePKCECodes struct { + CodeVerifier string + CodeChallenge string +} + +// ClaudeLoginSession 是一次登录发起后需要短期保存的上下文。ExchangeCode 时回传。 +type ClaudeLoginSession struct { + AuthURL string `json:"auth_url"` + State string `json:"state"` + Verifier string `json:"verifier"` +} + +// ClaudeTokenData 是登录/刷新后得到的令牌与账号身份。 +type ClaudeTokenData struct { + AccessToken string + RefreshToken string + Email string + AccountUUID string + OrganizationUUID string + OrganizationName string + // ExpiresAt 是本次 access token 的过期时刻(本地时钟)。 + ExpiresAt time.Time +} + +// claudeTokenResponse 映射 Anthropic OAuth token 端点的响应体。 +type claudeTokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Organization struct { + UUID string `json:"uuid"` + Name string `json:"name"` + } `json:"organization"` + Account struct { + UUID string `json:"uuid"` + EmailAddress string `json:"email_address"` + } `json:"account"` +} + +// claudeOAuthProfile 映射 profile 端点的响应体。 +type claudeOAuthProfile struct { + Account struct { + UUID string `json:"uuid"` + Email string `json:"email"` + } `json:"account"` + Organization struct { + UUID string `json:"uuid"` + Name string `json:"name"` + } `json:"organization"` +} + +// claudeAuthCodeExchangeRequest 是授权码交换请求体。字段顺序刻意对齐官方客户端在 +// 链路上的键序(map 会被 encoding/json 按字母重排,可能触发风控),故用结构体固定。 +type claudeAuthCodeExchangeRequest struct { + GrantType string `json:"grant_type"` + Code string `json:"code"` + RedirectURI string `json:"redirect_uri"` + ClientID string `json:"client_id"` + CodeVerifier string `json:"code_verifier"` + State string `json:"state"` +} + +// ClaudeAuth 封装 Claude OAuth 登录/刷新所需的 HTTP 客户端。 +// 通过 uTLS 指纹客户端出站,规避 Anthropic 域名上的 Cloudflare 指纹拦截。 +type ClaudeAuth struct { + httpClient *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 + } + return &ClaudeAuth{httpClient: client} +} + +// GenerateClaudePKCE 生成一对 PKCE 校验码(S256)。 +func GenerateClaudePKCE() (*ClaudePKCECodes, error) { + verifierBytes := make([]byte, 96) + if _, err := rand.Read(verifierBytes); err != nil { + return nil, fmt.Errorf("生成 PKCE verifier 失败: %w", err) + } + verifier := base64.RawURLEncoding.EncodeToString(verifierBytes) + sum := sha256.Sum256([]byte(verifier)) + challenge := base64.RawURLEncoding.EncodeToString(sum[:]) + return &ClaudePKCECodes{CodeVerifier: verifier, CodeChallenge: challenge}, nil +} + +// generateClaudeOAuthState 生成用于防 CSRF 的随机 state。 +func generateClaudeOAuthState() (string, error) { + stateBytes := make([]byte, 32) + if _, err := rand.Read(stateBytes); err != nil { + return "", fmt.Errorf("生成 OAuth state 失败: %w", err) + } + return base64.RawURLEncoding.EncodeToString(stateBytes), nil +} + +// BuildAuthURL 用给定 state 与 PKCE 拼装授权 URL。 +func BuildClaudeAuthURL(state string, pkce *ClaudePKCECodes) (string, error) { + if pkce == nil { + return "", fmt.Errorf("缺少 PKCE 校验码") + } + params := url.Values{ + "code": {"true"}, + "client_id": {ClaudeOAuthClientID}, + "response_type": {"code"}, + "redirect_uri": {ClaudeOAuthRedirectURI}, + "scope": {ClaudeOAuthScope}, + "code_challenge": {pkce.CodeChallenge}, + "code_challenge_method": {"S256"}, + "state": {state}, + } + return ClaudeOAuthAuthURL + "?" + params.Encode(), nil +} + +// StartClaudeLogin 发起一次登录:生成 state + PKCE 并返回授权 URL 与需缓存的上下文。 +func StartClaudeLogin() (*ClaudeLoginSession, error) { + pkce, err := GenerateClaudePKCE() + if err != nil { + return nil, err + } + state, err := generateClaudeOAuthState() + if err != nil { + return nil, err + } + authURL, err := BuildClaudeAuthURL(state, pkce) + if err != nil { + return nil, err + } + return &ClaudeLoginSession{AuthURL: authURL, State: state, Verifier: pkce.CodeVerifier}, nil +} + +// parseClaudeCodeAndState 从回调里拿到的 code 中拆出可能附带的 state 片段 +// (官方回调形如 code#state)。 +func parseClaudeCodeAndState(code string) (parsedCode, parsedState string) { + splits := strings.Split(strings.TrimSpace(code), "#") + parsedCode = strings.TrimSpace(splits[0]) + if len(splits) > 1 { + parsedState = strings.TrimSpace(splits[1]) + } + return +} + +// ExchangeCode 用授权码 + PKCE verifier 换取 access/refresh token,并回填账号身份。 +// +// - code:用户从回调地址栏复制的授权码(可含 #state 片段)。 +// - state:StartClaudeLogin 返回的 state。 +// - verifier:StartClaudeLogin 返回的 verifier。 +func (o *ClaudeAuth) ExchangeCode(ctx context.Context, code, state, verifier string) (*ClaudeTokenData, error) { + if strings.TrimSpace(verifier) == "" { + return nil, fmt.Errorf("缺少 PKCE verifier") + } + if ctx == nil { + ctx = context.Background() + } + newCode, newState := parseClaudeCodeAndState(code) + if newCode == "" { + return nil, fmt.Errorf("授权码为空") + } + effectiveState := state + if newState != "" { + effectiveState = newState + } + + reqBody := claudeAuthCodeExchangeRequest{ + GrantType: "authorization_code", + Code: newCode, + RedirectURI: ClaudeOAuthRedirectURI, + ClientID: ClaudeOAuthClientID, + CodeVerifier: verifier, + State: effectiveState, + } + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("序列化授权码交换请求失败: %w", err) + } + + body, status, err := o.doClaudeOAuthPost(ctx, ClaudeOAuthTokenURL, jsonBody) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("授权码交换失败 (status %d): %s", status, string(body)) + } + + var tokenResp claudeTokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("解析 token 响应失败: %w", err) + } + if strings.TrimSpace(tokenResp.AccessToken) == "" { + return nil, fmt.Errorf("token 响应缺少 access_token") + } + + td := &ClaudeTokenData{ + AccessToken: tokenResp.AccessToken, + RefreshToken: tokenResp.RefreshToken, + Email: tokenResp.Account.EmailAddress, + AccountUUID: tokenResp.Account.UUID, + OrganizationUUID: tokenResp.Organization.UUID, + OrganizationName: tokenResp.Organization.Name, + ExpiresAt: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second), + } + // 用 profile 端点补齐 token 响应可能缺失的身份字段。 + if profile, errProfile := o.FetchProfile(ctx, tokenResp.AccessToken); errProfile == nil && profile != nil { + if v := strings.TrimSpace(profile.Account.UUID); v != "" { + td.AccountUUID = v + } + if v := strings.TrimSpace(profile.Account.Email); v != "" { + td.Email = v + } + if v := strings.TrimSpace(profile.Organization.UUID); v != "" { + td.OrganizationUUID = v + } + if v := strings.TrimSpace(profile.Organization.Name); v != "" { + td.OrganizationName = v + } + } + return td, nil +} + +// RefreshTokens 用 refresh token 续期。Anthropic 若未返回新的 refresh token,则沿用旧值。 +func (o *ClaudeAuth) RefreshTokens(ctx context.Context, refreshToken string) (*ClaudeTokenData, error) { + if strings.TrimSpace(refreshToken) == "" { + return nil, fmt.Errorf("缺少 refresh token") + } + if ctx == nil { + ctx = context.Background() + } + // 刷新请求体键序对齐官方客户端。 + reqBody := map[string]string{ + "client_id": ClaudeOAuthClientID, + "grant_type": "refresh_token", + "refresh_token": refreshToken, + "scope": ClaudeOAuthScope, + } + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("序列化刷新请求失败: %w", err) + } + + body, status, err := o.doClaudeOAuthPost(ctx, ClaudeOAuthTokenURL, jsonBody) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("token 刷新失败 (status %d): %s", status, string(body)) + } + + var tokenResp claudeTokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("解析刷新响应失败: %w", err) + } + if strings.TrimSpace(tokenResp.RefreshToken) == "" { + tokenResp.RefreshToken = refreshToken + } + td := &ClaudeTokenData{ + AccessToken: tokenResp.AccessToken, + RefreshToken: tokenResp.RefreshToken, + ExpiresAt: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second), + } + if profile, errProfile := o.FetchProfile(ctx, tokenResp.AccessToken); errProfile == nil && profile != nil { + td.Email = strings.TrimSpace(profile.Account.Email) + td.AccountUUID = strings.TrimSpace(profile.Account.UUID) + td.OrganizationUUID = strings.TrimSpace(profile.Organization.UUID) + td.OrganizationName = strings.TrimSpace(profile.Organization.Name) + } + return td, nil +} + +// FetchProfile 用 access token 拉取账号身份。 +func (o *ClaudeAuth) FetchProfile(ctx context.Context, accessToken string) (*claudeOAuthProfile, error) { + if strings.TrimSpace(accessToken) == "" { + return nil, fmt.Errorf("缺少 access token") + } + 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) + if err != nil { + return nil, fmt.Errorf("profile 请求失败: %w", err) + } + defer resp.Body.Close() + + body, err := readClaudeOAuthResponseBody(resp) + if err != nil { + return nil, fmt.Errorf("读取 profile 响应失败: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("获取 profile 失败 (status %d): %s", resp.StatusCode, string(body)) + } + var profile claudeOAuthProfile + if err := json.Unmarshal(body, &profile); err != nil { + return nil, fmt.Errorf("解析 profile 响应失败: %w", err) + } + if strings.TrimSpace(profile.Account.UUID) == "" { + return nil, fmt.Errorf("profile 响应缺少账号 UUID") + } + return &profile, nil +} + +// 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) + if err != nil { + return nil, 0, fmt.Errorf("OAuth 请求失败: %w", err) + } + defer resp.Body.Close() + + body, err := readClaudeOAuthResponseBody(resp) + if err != nil { + return nil, resp.StatusCode, fmt.Errorf("读取 OAuth 响应失败: %w", err) + } + return body, resp.StatusCode, nil +} + +// applyClaudeOAuthAxiosHeaders 复刻官方客户端 OAuth 控制面请求的 axios 头,降低被 +// Cloudflare 拦截的概率。 +func applyClaudeOAuthAxiosHeaders(req *http.Request) { + if req == nil { + return + } + req.Header.Set("Accept", "application/json, text/plain, */*") + 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 +} + +// readClaudeOAuthResponseBody 读取并按 Content-Encoding 解码响应体。 +// 因为我们手动设置了 Accept-Encoding,Go 的 transport 不会自动解压,需自行处理。 +func readClaudeOAuthResponseBody(resp *http.Response) ([]byte, error) { + if resp == nil || resp.Body == nil { + return nil, fmt.Errorf("响应体为空") + } + encoded, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + encodings := strings.Split(strings.Join(resp.Header.Values("Content-Encoding"), ","), ",") + for i := len(encodings) - 1; i >= 0; i-- { + encoding := strings.ToLower(strings.TrimSpace(encodings[i])) + if encoding == "" || encoding == "identity" { + continue + } + encoded, err = decodeClaudeOAuthEncoding(encoded, encoding) + if err != nil { + return nil, err + } + } + return encoded, nil +} + +func decodeClaudeOAuthEncoding(encoded []byte, encoding string) ([]byte, error) { + var reader io.ReadCloser + switch encoding { + case "gzip": + gz, err := gzip.NewReader(bytes.NewReader(encoded)) + if err != nil { + return nil, fmt.Errorf("解码 gzip 响应失败: %w", err) + } + reader = gz + case "deflate": + if zr, err := zlib.NewReader(bytes.NewReader(encoded)); err == nil { + reader = zr + } else { + reader = flate.NewReader(bytes.NewReader(encoded)) + } + case "br": + reader = io.NopCloser(brotli.NewReader(bytes.NewReader(encoded))) + default: + return nil, fmt.Errorf("不支持的 Content-Encoding: %q", encoding) + } + decoded, err := io.ReadAll(reader) + if err != nil { + _ = reader.Close() + return nil, fmt.Errorf("解码 %s 响应失败: %w", encoding, err) + } + if err := reader.Close(); err != nil { + return nil, fmt.Errorf("关闭 %s 解码器失败: %w", encoding, err) + } + return decoded, nil +} diff --git a/auth/claude_oauth_test.go b/auth/claude_oauth_test.go new file mode 100644 index 00000000..12f6cd5f --- /dev/null +++ b/auth/claude_oauth_test.go @@ -0,0 +1,118 @@ +package auth + +import ( + "crypto/sha256" + "encoding/base64" + "net/url" + "strings" + "testing" +) + +func TestGenerateClaudePKCE(t *testing.T) { + pkce, err := GenerateClaudePKCE() + if err != nil { + t.Fatalf("GenerateClaudePKCE 出错: %v", err) + } + if pkce.CodeVerifier == "" || pkce.CodeChallenge == "" { + t.Fatal("verifier/challenge 不应为空") + } + // verifier 应满足 RFC 7636 长度 43-128。 + if l := len(pkce.CodeVerifier); l < 43 || l > 128 { + t.Fatalf("verifier 长度 %d 不在 [43,128]", l) + } + // challenge 必须是 verifier 的 S256(RawURL 无填充)。 + sum := sha256.Sum256([]byte(pkce.CodeVerifier)) + want := base64.RawURLEncoding.EncodeToString(sum[:]) + if pkce.CodeChallenge != want { + t.Fatalf("challenge 不是 verifier 的 S256:\n got=%s\nwant=%s", pkce.CodeChallenge, want) + } + // 不应含有 base64 填充或非 URL 安全字符。 + if strings.ContainsAny(pkce.CodeVerifier+pkce.CodeChallenge, "=+/") { + t.Fatal("PKCE 值含有非 URL 安全字符或填充") + } +} + +func TestGenerateClaudePKCEUnique(t *testing.T) { + a, _ := GenerateClaudePKCE() + b, _ := GenerateClaudePKCE() + if a.CodeVerifier == b.CodeVerifier { + t.Fatal("两次生成的 verifier 不应相同") + } +} + +func TestBuildClaudeAuthURL(t *testing.T) { + pkce := &ClaudePKCECodes{CodeVerifier: "v", CodeChallenge: "challenge-xyz"} + raw, err := BuildClaudeAuthURL("state-123", pkce) + if err != nil { + t.Fatalf("BuildClaudeAuthURL 出错: %v", err) + } + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("生成的 URL 无法解析: %v", err) + } + if got := u.Scheme + "://" + u.Host + u.Path; got != ClaudeOAuthAuthURL { + t.Fatalf("授权端点错误: %s", got) + } + q := u.Query() + checks := map[string]string{ + "client_id": ClaudeOAuthClientID, + "response_type": "code", + "redirect_uri": ClaudeOAuthRedirectURI, + "scope": ClaudeOAuthScope, + "code_challenge": "challenge-xyz", + "code_challenge_method": "S256", + "state": "state-123", + "code": "true", + } + for k, want := range checks { + if got := q.Get(k); got != want { + t.Errorf("查询参数 %s = %q, 期望 %q", k, got, want) + } + } +} + +func TestBuildClaudeAuthURLNilPKCE(t *testing.T) { + if _, err := BuildClaudeAuthURL("s", nil); err == nil { + t.Fatal("PKCE 为 nil 时应报错") + } +} + +func TestParseClaudeCodeAndState(t *testing.T) { + cases := []struct { + in string + wantCode string + wantState string + }{ + {"abc", "abc", ""}, + {"abc#xyz", "abc", "xyz"}, + {" abc#xyz ", "abc", "xyz"}, + {"abc#xyz#extra", "abc", "xyz"}, + } + for _, c := range cases { + code, state := parseClaudeCodeAndState(c.in) + if code != c.wantCode || state != c.wantState { + t.Errorf("parseClaudeCodeAndState(%q) = (%q,%q), 期望 (%q,%q)", + c.in, code, state, c.wantCode, c.wantState) + } + } +} + +func TestStartClaudeLogin(t *testing.T) { + s, err := StartClaudeLogin() + if err != nil { + t.Fatalf("StartClaudeLogin 出错: %v", err) + } + if s.State == "" || s.Verifier == "" || s.AuthURL == "" { + t.Fatal("登录会话字段不应为空") + } + if !strings.Contains(s.AuthURL, url.QueryEscape(s.State)) { + t.Fatal("AuthURL 应包含 state") + } + // AuthURL 里的 challenge 必须与返回的 verifier 对得上。 + u, _ := url.Parse(s.AuthURL) + sum := sha256.Sum256([]byte(s.Verifier)) + wantChallenge := base64.RawURLEncoding.EncodeToString(sum[:]) + if u.Query().Get("code_challenge") != wantChallenge { + t.Fatal("AuthURL 中的 code_challenge 与 verifier 不匹配") + } +} diff --git a/auth/grok_account.go b/auth/grok_account.go index 0267b7ae..ad12dd10 100644 --- a/auth/grok_account.go +++ b/auth/grok_account.go @@ -136,7 +136,7 @@ func (a *Account) IsGrokAPI() bool { // isRelayStyleLocked:openai_responses 中转或 Grok —— 一切「非 Codex OAuth 官方上游」 // 的账号。这类账号不参与 Codex 专属行为(wham 探针、WS 上游、manifest、alpha search)。 func (a *Account) isRelayStyleLocked() bool { - return a.isOpenAIResponsesAPILocked() || a.isGrokAPILocked() || a.isAntigravityAPILocked() + return a.isOpenAIResponsesAPILocked() || a.isGrokAPILocked() || a.isAntigravityAPILocked() || a.isClaudeOAuthLocked() } // IsRelayStyle 判断账号是否为「非 Codex 官方」的外部上游账号。 diff --git a/auth/store.go b/auth/store.go index f1cea41b..2fff2215 100644 --- a/auth/store.go +++ b/auth/store.go @@ -10893,6 +10893,11 @@ func (s *Store) refreshAccountWithOptions(ctx context.Context, acc *Account, for if acc.IsGrokAPI() { return s.refreshGrokAccount(ctx, acc, forceRefresh) } + // Claude Code OAuth 账号走 platform.claude.com 的 RT 刷新,请求体与端点均与 + // ChatGPT 不同,单独处理。对所有非 claude 账号此分支恒不进入。 + if acc.IsClaudeOAuth() { + return s.refreshClaudeAccount(ctx, acc, forceRefresh) + } acc.mu.RLock() rt := acc.RefreshToken st := acc.SessionToken diff --git a/cmd/claude_login/main.go b/cmd/claude_login/main.go new file mode 100644 index 00000000..6893d408 --- /dev/null +++ b/cmd/claude_login/main.go @@ -0,0 +1,182 @@ +// 独立的 Claude Code OAuth 登录自测工具(非交互、两步式)。 +// +// 因为服务器/受限终端无法交互式粘贴,本工具拆成两步,各自是一条独立命令, +// 中间用一个临时 session 文件承接 state / verifier: +// +// 第一步(生成授权 URL): +// go run ./cmd/claude_login +// 打印授权 URL 并把 session 存到临时文件。在浏览器打开该 URL 用 Claude 账号授权。 +// +// 第二步(换取 token):授权后浏览器跳到 http://localhost:54545/callback?code=... +// (页面打不开属正常)。直接复制**整条地址栏 URL**,或只复制 code 值,然后: +// go run ./cmd/claude_login -code "把整条回调URL或code粘这里" +// 程序换取 access/refresh token、打印账号身份,并自动试刷新一次。 +// +// 可选参数: +// -proxy 出站代理,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080 +// -session 自定义 session 文件路径(默认系统临时目录) +// -out 把最终 token(JSON)另存到指定文件,便于后续导入账号池 +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/codex2api/auth" +) + +func defaultSessionPath() string { + return filepath.Join(os.TempDir(), "claude_login_session.json") +} + +func main() { + code := flag.String("code", "", "授权后的回调 URL 或 code 值;留空则进入第一步生成授权 URL") + proxy := flag.String("proxy", "", "出站代理 URL(可选)") + sessionPath := flag.String("session", defaultSessionPath(), "session 文件路径(承接 state/verifier)") + outPath := flag.String("out", "", "可选:把最终 token JSON 另存到该文件") + flag.Parse() + + if strings.TrimSpace(*code) == "" { + runStart(*sessionPath) + return + } + runExchange(*sessionPath, *code, *proxy, *outPath) +} + +// runStart 生成授权 URL 并把 session 落盘。 +func runStart(sessionPath string) { + session, err := auth.StartClaudeLogin() + if err != nil { + fmt.Fprintf(os.Stderr, "发起登录失败: %v\n", err) + os.Exit(1) + } + data, _ := json.MarshalIndent(session, "", " ") + if err := os.WriteFile(sessionPath, data, 0600); err != nil { + fmt.Fprintf(os.Stderr, "写入 session 文件失败: %v\n", err) + os.Exit(1) + } + + fmt.Println("========================================================") + fmt.Println("第一步:在浏览器打开下面的授权 URL,用你的 Claude 账号授权") + fmt.Println() + fmt.Println(" " + session.AuthURL) + fmt.Println() + fmt.Println("授权后浏览器会跳转到 http://localhost:54545/callback?code=...(页面打不开属正常)。") + fmt.Println("复制【整条地址栏 URL】或只复制 code 值,然后执行第二步:") + fmt.Println() + fmt.Println(" go run ./cmd/claude_login -code \"把整条回调URL或code粘这里\"") + fmt.Println() + fmt.Printf("(session 已存到 %s)\n", sessionPath) + fmt.Println("========================================================") +} + +// runExchange 读取 session、换取 token 并自测刷新。 +func runExchange(sessionPath, rawCode, proxy, outPath string) { + raw, err := os.ReadFile(sessionPath) + if err != nil { + fmt.Fprintf(os.Stderr, "读取 session 文件失败(%s): %v\n请先执行第一步:go run ./cmd/claude_login\n", sessionPath, err) + os.Exit(1) + } + var session auth.ClaudeLoginSession + if err := json.Unmarshal(raw, &session); err != nil { + fmt.Fprintf(os.Stderr, "解析 session 文件失败: %v\n", err) + os.Exit(1) + } + + code, stateOverride := extractCode(rawCode) + if code == "" { + fmt.Fprintln(os.Stderr, "未能从输入中解析出授权码。") + os.Exit(1) + } + state := session.State + if stateOverride != "" { + state = stateOverride + } + + client := auth.NewClaudeAuth(proxy) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + fmt.Println(">> 正在换取 token ...") + td, err := client.ExchangeCode(ctx, code, state, session.Verifier) + if err != nil { + fmt.Fprintf(os.Stderr, "换取 token 失败: %v\n", err) + os.Exit(1) + } + fmt.Println(">> 登录成功!账号身份:") + fmt.Printf(" Email : %s\n", td.Email) + fmt.Printf(" AccountUUID : %s\n", td.AccountUUID) + fmt.Printf(" Organization : %s (%s)\n", td.OrganizationName, td.OrganizationUUID) + fmt.Printf(" AccessToken : %s…(%d 字符)\n", safePrefix(td.AccessToken, 12), len(td.AccessToken)) + fmt.Printf(" RefreshToken : %s…(%d 字符)\n", safePrefix(td.RefreshToken, 12), len(td.RefreshToken)) + fmt.Printf(" 过期时刻 : %s(约 %s 后)\n", td.ExpiresAt.Format(time.RFC3339), time.Until(td.ExpiresAt).Round(time.Second)) + + if strings.TrimSpace(td.RefreshToken) != "" { + fmt.Println("\n>> 正在用 refresh token 试刷新一次 ...") + refreshed, rErr := client.RefreshTokens(ctx, td.RefreshToken) + if rErr != nil { + fmt.Fprintf(os.Stderr, "刷新失败: %v\n", rErr) + os.Exit(1) + } + fmt.Printf(">> 刷新成功!新 AccessToken: %s…(%d 字符),过期 %s\n", + safePrefix(refreshed.AccessToken, 12), len(refreshed.AccessToken), refreshed.ExpiresAt.Format(time.RFC3339)) + td = refreshed + } else { + fmt.Println("\n(!) 未返回 refresh token,跳过刷新自测。") + } + + if strings.TrimSpace(outPath) != "" { + out := map[string]any{ + "upstream_type": auth.UpstreamClaude, + "access_token": td.AccessToken, + "refresh_token": td.RefreshToken, + "email": td.Email, + "account_id": td.AccountUUID, + "expires_at": td.ExpiresAt.Format(time.RFC3339), + } + data, _ := json.MarshalIndent(out, "", " ") + if err := os.WriteFile(outPath, data, 0600); err != nil { + fmt.Fprintf(os.Stderr, "写入 token 文件失败: %v\n", err) + } else { + fmt.Printf("\ntoken 已另存到 %s\n", outPath) + } + } + fmt.Println("\n全链路验证通过:登录 + 身份 + 刷新均可用。") +} + +// extractCode 从输入中提取授权码。支持三种形态: +// 1. 整条回调 URL:http://localhost:54545/callback?code=XXX&state=YYY +// 2. 形如 code#state 的裸串 +// 3. 纯 code +// +// 返回 code 与(若能识别)state 覆盖值。 +func extractCode(input string) (code, stateOverride string) { + input = strings.TrimSpace(input) + if input == "" { + return "", "" + } + if strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://") { + if u, err := url.Parse(input); err == nil { + q := u.Query() + if c := strings.TrimSpace(q.Get("code")); c != "" { + return c, strings.TrimSpace(q.Get("state")) + } + } + } + // 裸串:交给 ExchangeCode 自行按 # 拆分 state。 + return input, "" +} + +func safePrefix(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} diff --git a/database/postgres.go b/database/postgres.go index 122797f9..53538796 100644 --- a/database/postgres.go +++ b/database/postgres.go @@ -1703,6 +1703,7 @@ const ( UpstreamChannelCodex = "codex" UpstreamChannelGrok = "grok" UpstreamChannelAntigravity = "antigravity" + UpstreamChannelClaude = "claude" ) // ResolveUpstreamChannel 归一 Key 的上游渠道限定;未知值一律视为不限(auto)。 @@ -1714,6 +1715,8 @@ func (l APIKeyLimits) ResolveUpstreamChannel() string { return UpstreamChannelGrok case UpstreamChannelAntigravity: return UpstreamChannelAntigravity + case UpstreamChannelClaude: + return UpstreamChannelClaude } return UpstreamChannelAuto } @@ -1726,9 +1729,11 @@ func accountChannelFilterSQL(channel, upstreamTypeExpr string) string { return ` AND ` + upstreamTypeExpr + ` = 'grok'` case UpstreamChannelAntigravity: return ` AND ` + upstreamTypeExpr + ` = 'antigravity'` + case UpstreamChannelClaude: + return ` AND ` + upstreamTypeExpr + ` = 'claude'` case UpstreamChannelCodex: // Blank legacy rows and OpenAI Responses relays remain in the Codex view. - return ` AND ` + upstreamTypeExpr + ` NOT IN ('grok', 'antigravity')` + return ` AND ` + upstreamTypeExpr + ` NOT IN ('grok', 'antigravity', 'claude')` default: return "" } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4f589c66..f95b9355 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -61,6 +61,7 @@ function AdminApp() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 6498400b..ea5f534a 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -79,6 +79,10 @@ import type { ModelsResponse, OAuthExchangeResponse, OAuthURLResponse, + ClaudeAuthURLResponse, + ClaudeExchangeCodeRequest, + ClaudeImportTokenRequest, + ClaudeAddAccountResponse, OpsErrorSummary, OpsOverviewResponse, PromptFilterLog, @@ -723,6 +727,27 @@ export const api = { request(`/accounts/antigravity/oauth/${encodeURIComponent(sessionId)}`, { method: 'DELETE', }), + // Claude Code OAuth:第一步取授权 URL(服务端暂存 state→verifier)。 + generateClaudeAuthURL: () => + request('/accounts/claude/oauth/auth-url', { + method: 'POST', + body: JSON.stringify({}), + timeoutMs: 15_000, + }), + // 第二步:用 state+code 换取 token 并入库(可选从代理池分配代理)。 + exchangeClaudeOAuthCode: (data: ClaudeExchangeCodeRequest) => + request('/accounts/claude/oauth/exchange-code', { + method: 'POST', + body: JSON.stringify(data), + timeoutMs: 90_000, + }), + // CLI 直导:吃 cmd/claude_login -out 产出的 token JSON。 + importClaudeToken: (data: ClaudeImportTokenRequest) => + request('/accounts/claude/import', { + method: 'POST', + body: JSON.stringify(data), + timeoutMs: 20_000, + }), batchUpdateGrokModels: (data: BatchUpdateGrokModelsRequest) => request('/accounts/grok/batch-models', { method: 'POST', diff --git a/frontend/src/components/ChannelLogo.tsx b/frontend/src/components/ChannelLogo.tsx index 976cedad..e7e7d363 100644 --- a/frontend/src/components/ChannelLogo.tsx +++ b/frontend/src/components/ChannelLogo.tsx @@ -16,6 +16,7 @@ const ICON_URLS = import.meta.glob( "../../node_modules/@lobehub/icons-static-svg/icons/codex-color.svg", "../../node_modules/@lobehub/icons-static-svg/icons/grok.svg", "../../node_modules/@lobehub/icons-static-svg/icons/antigravity-color.svg", + "../../node_modules/@lobehub/icons-static-svg/icons/claudecode-color.svg", ], { eager: true, query: "?url", import: "default" }, ) as Record; @@ -76,21 +77,27 @@ export default function ChannelLogo({ className?: string; title?: string; }) { - if (channel === "codex" || channel === "antigravity") { - const isAntigravity = channel === "antigravity"; - const src = URL_BY_FILE.get(isAntigravity ? "antigravity-color" : "codex-color"); + if (channel === "codex" || channel === "antigravity" || channel === "claude") { + const fileByChannel: Record = { + codex: { file: "codex-color", alt: "Codex" }, + antigravity: { file: "antigravity-color", alt: "Antigravity" }, + claude: { file: "claudecode-color", alt: "Claude" }, + }; + const meta = fileByChannel[channel]; + const src = URL_BY_FILE.get(meta.file); if (!src) return null; + const rounded = channel === "codex"; return ( {title { + // 空闲(bound_count=0)优先,其余按绑定数升序,让负载最轻的代理排在前面。 + const sorted = [...proxies].sort( + (a, b) => (a.bound_count ?? 0) - (b.bound_count ?? 0), + ); + const options: SelectOption[] = sorted.map((proxy) => { const label = proxy.label?.trim(); + const base = label ? `${label} — ${proxy.url}` : proxy.url; + const count = proxy.bound_count ?? 0; + const bindTag = count === 0 ? t("proxies.idle") : t("proxies.boundCount", { count }); return { value: proxy.url, - label: label ? `${label} — ${proxy.url}` : proxy.url, + // 绑定数/空闲放在最前,避免长 URL 被 truncate 截断后看不到负载信息。 + label: `[${bindTag}] ${base}`, triggerLabel: label || proxy.url, }; }); diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index e1824a5d..b5a4956c 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -1596,7 +1596,8 @@ "modelCooldownPolicySaved": "Account model cooldown policy saved", "modelCooldownPolicySaveFailed": "Failed to save model cooldown policy: {{error}}", "modelCooldownCleared": "Cleared model cooldown for {{model}}", - "allModelCooldownsCleared": "Cleared {{count}} model cooldowns" + "allModelCooldownsCleared": "Cleared {{count}} model cooldowns", + "providerViewClaude": "Claude" }, "invite": { "entry": "Codex Invite", @@ -1733,18 +1734,18 @@ "activeRequests": "Active Requests", "totalRequestsAccum": "Total accumulated {{count}}", "goroutines": "Goroutines", - "goroutinesPool": "Pool {{available}} / {{total}}", - "schedulerEngine": "Scheduler Engine", - "schedulerSelections": "{{count}} selections", - "schedulerShadowChecks": "{{checks}} sampled checks · {{mismatches}} mismatches", - "schedulerFastHit": "Indexed Hit Rate", - "schedulerSlowScans": "{{count}} accounts scanned by legacy path", - "schedulerWaiters": "Dispatch Waiters", - "schedulerWakeups": "{{count}} state wakeups", - "schedulerRoutingCache": "Routing Sub-pool Cache", - "schedulerRoutingCacheStats": "{{hits}} hits · {{accounts}} indexed accounts", - "schedulerOutbox": "Scheduler Outbox Backlog", - "schedulerOutboxLag": "{{ms}}ms lag · {{errors}} errors", + "goroutinesPool": "Pool {{available}} / {{total}}", + "schedulerEngine": "Scheduler Engine", + "schedulerSelections": "{{count}} selections", + "schedulerShadowChecks": "{{checks}} sampled checks · {{mismatches}} mismatches", + "schedulerFastHit": "Indexed Hit Rate", + "schedulerSlowScans": "{{count}} accounts scanned by legacy path", + "schedulerWaiters": "Dispatch Waiters", + "schedulerWakeups": "{{count}} state wakeups", + "schedulerRoutingCache": "Routing Sub-pool Cache", + "schedulerRoutingCacheStats": "{{hits}} hits · {{accounts}} indexed accounts", + "schedulerOutbox": "Scheduler Outbox Backlog", + "schedulerOutboxLag": "{{ms}}ms lag · {{errors}} errors", "qps": "QPS", "qpsPeak": "Peak {{value}}", "tps": "TPS", @@ -2106,22 +2107,32 @@ "cyberPolicyDetailTitle": "cyber_policy trigger details", "cyberPolicyRequestContent": "Request content that triggered the block", "cyberPolicyNoDetail": "No matching request content found (logs may be cleared, or timestamps too far apart)", - "cyberPolicyUnscored": "Not scored", - "cyberPolicyLegacyInferred": "Historical record: this link was inferred by timestamp and may not be exact.", - "cyberPolicyLegacyUnknown": "Historical record: the local decision cannot be reconstructed", - "cyberPolicyUpstreamResult": "Upstream result", - "cyberPolicyLocalResult": "Local result", - "cyberPolicyLocalMiss": "Local miss", - "cyberPolicyExecutionScore": "Enforcement score", - "cyberPolicyAuditScore": "Audit score", - "cyberPolicyTransport": "Protocol / transport", - "cyberPolicyAccountAttempt": "Account / attempt", - "cyberPolicyReview": "Secondary review", - "cyberPolicyCandidate": "Candidate status", - "cyberPolicyReason": "Reason", - "cyberPolicyMatches": "Matched rules and weights", - "cyberPolicyState": { "completed": "Completed", "not_run": "Not run", "unavailable": "Unavailable", "legacy_unknown": "Legacy unknown" }, - "cyberPolicyOutcome": { "no_hit": "No hit", "audit_hit": "Audit hit", "warn": "Warn", "block": "Block" }, + "cyberPolicyUnscored": "Not scored", + "cyberPolicyLegacyInferred": "Historical record: this link was inferred by timestamp and may not be exact.", + "cyberPolicyLegacyUnknown": "Historical record: the local decision cannot be reconstructed", + "cyberPolicyUpstreamResult": "Upstream result", + "cyberPolicyLocalResult": "Local result", + "cyberPolicyLocalMiss": "Local miss", + "cyberPolicyExecutionScore": "Enforcement score", + "cyberPolicyAuditScore": "Audit score", + "cyberPolicyTransport": "Protocol / transport", + "cyberPolicyAccountAttempt": "Account / attempt", + "cyberPolicyReview": "Secondary review", + "cyberPolicyCandidate": "Candidate status", + "cyberPolicyReason": "Reason", + "cyberPolicyMatches": "Matched rules and weights", + "cyberPolicyState": { + "completed": "Completed", + "not_run": "Not run", + "unavailable": "Unavailable", + "legacy_unknown": "Legacy unknown" + }, + "cyberPolicyOutcome": { + "no_hit": "No hit", + "audit_hit": "Audit hit", + "warn": "Warn", + "block": "Block" + }, "statusErrorEmpty": "No detailed error message recorded", "clearFilters": "Clear Filters", "showAnalysis": "Show Analysis", @@ -2194,7 +2205,7 @@ "identityOnly": "Identity directory only", "noAttributedRequests": "No attributable requests yet", "profileState": "Profile state", - "identitySource": "Identity import source", + "identitySource": "Identity source", "identityConfidence": "Identity confidence", "miss": "misses", "block": "blocks", @@ -2217,15 +2228,34 @@ "apiKeyProfiles": "API Key profiles", "upstreamAccountProfiles": "Upstream accounts", "activityState": "Activity", - "activityStates": { "active": "Has requests", "identityOnly": "Identity only" }, + "activityStates": { + "active": "Has requests", + "identityOnly": "Identity only" + }, "peopleProfilesHint": "Shows identities traceable to a signed NewAPI user ID by default.", "nonPersonHint": "Sessions, keys, networks, and upstream accounts are environment subjects used for tracing; they are not people.", - "identityKinds": { "newapi_user": "Verified person", "unverified_user": "Unverified identity", "session": "Session / fingerprint", "api_key": "API Key", "client_ip": "Client IP", "upstream_account": "Upstream account" }, - "identitySource": "Identity source", + "identityKinds": { + "newapi_user": "Verified person", + "unverified_user": "Unverified identity", + "session": "Session / fingerprint", + "api_key": "API Key", + "client_ip": "Client IP", + "upstream_account": "Upstream account" + }, "freezeStatus": "Freeze status", - "freezeStates": { "none": "Not frozen", "conversation": "Session lock", "user_cooldown": "User cooldown", "fingerprint_replay": "Fingerprint replay cooldown" }, + "freezeStates": { + "none": "Not frozen", + "conversation": "Session lock", + "user_cooldown": "User cooldown", + "fingerprint_replay": "Fingerprint replay cooldown" + }, "freezeHint": "No active restriction", - "summary": { "total": "Total matching", "frozen": "Frozen on this page", "cy": "CY on this page", "highCritical": "High / critical" }, + "summary": { + "total": "Total matching", + "frozen": "Frozen on this page", + "cy": "CY on this page", + "highCritical": "High / critical" + }, "conversationLock": { "title": "CYB conversation lock", "active": "Conversation locked", @@ -2269,7 +2299,10 @@ "bypassCount": "Synchronous reviews saved", "lastEvaluation": "Latest periodic evaluation", "source": "Policy source", - "sources": {"manual":"Administrator","automatic":"Automatic profile decision"}, + "sources": { + "manual": "Administrator", + "automatic": "Automatic profile decision" + }, "modelReviewCount": "Periodic model reviews", "lastModelReview": "Latest model review", "basisTitle": "Adaptive model-review decision basis", @@ -2284,7 +2317,15 @@ "nextForcedReview": "Next mandatory model review", "reviewDueNow": "Review required now", "basisFallbackReason": "The system continuously evaluates clean reviews, observation time, current risk, and periodic sampling.", - "decisions": {"disabled":"Adaptive review disabled","not_person":"Not a verified person","adaptive_active":"Adaptive review reduction active","suspended":"Full model review restored","eligible":"Eligible for adaptive review","building_history":"Building low-risk evidence","unavailable":"Decision basis unavailable"}, + "decisions": { + "disabled": "Adaptive review disabled", + "not_person": "Not a verified person", + "adaptive_active": "Adaptive review reduction active", + "suspended": "Full model review restored", + "eligible": "Eligible for adaptive review", + "building_history": "Building low-risk evidence", + "unavailable": "Decision basis unavailable" + }, "notEnabled": "Temporary trust is not enabled.", "personOnly": "Only signed person profiles can receive temporary trust.", "history": "Model-review decisions and operations", @@ -2297,13 +2338,66 @@ "duration": "Duration", "reasonHint": "Required for auditing who reduced first-token latency, why, and for how long.", "safetyHint": "Local high-risk rules still run. Only clean requests skip synchronous DS. The exemption ends on threshold breach, local high-risk decisions, or upstream CY.", - "status": {"active":"Temporarily trusted","suspended":"Automatically suspended","revoked":"Revoked","expired":"Expired"}, - "events": {"granted":"Enabled","auto_granted":"Automatically enabled","reactivated":"Re-enabled","suspended":"Suspended","auto_suspended":"Risk-triggered suspension","revoked":"Admin revoked","expired":"Expired","bypass_used":"Clean request skipped synchronous review","model_reviewed":"Periodic model review passed","evaluated":"Periodic evaluation"} + "status": { + "active": "Temporarily trusted", + "suspended": "Automatically suspended", + "revoked": "Revoked", + "expired": "Expired" + }, + "events": { + "granted": "Enabled", + "auto_granted": "Automatically enabled", + "reactivated": "Re-enabled", + "suspended": "Suspended", + "auto_suspended": "Risk-triggered suspension", + "revoked": "Admin revoked", + "expired": "Expired", + "bypass_used": "Clean request skipped synchronous review", + "model_reviewed": "Periodic model review passed", + "evaluated": "Periodic evaluation" + } + }, + "subjects": { + "newapi_user": "NewAPI user", + "session": "Session", + "api_key": "API key", + "client_ip": "Client network", + "upstream_account": "Upstream account" + }, + "levels": { + "low": "Low", + "observed": "Observed", + "elevated": "Elevated", + "high": "High", + "critical": "Critical" + }, + "actions": { + "observe": "Observe", + "monitor": "Monitor", + "enhanced_review": "Enhanced review", + "rate_limit": "Review rate limit", + "require_signed_identity": "Require signed identity", + "temporary_restriction_review": "Review temporary restriction", + "account_rotation_review": "Review account rotation", + "account_routing_review": "Review account routing" }, - "subjects": {"newapi_user":"NewAPI user","session":"Session","api_key":"API key","client_ip":"Client network","upstream_account":"Upstream account"}, - "levels": {"low":"Low","observed":"Observed","elevated":"Elevated","high":"High","critical":"Critical"}, - "actions": {"observe":"Observe","monitor":"Monitor","enhanced_review":"Enhanced review","rate_limit":"Review rate limit","require_signed_identity":"Require signed identity","temporary_restriction_review":"Review temporary restriction","account_rotation_review":"Review account rotation","account_routing_review":"Review account routing"}, - "events": {"review_cleared":"Review cleared","review_flagged_monitor":"Review flagged in monitor mode","local_block_strike":"Terminal local block","local_block":"Legacy unverified block","local_block_unverified":"Local block (unverified)","local_block_cleared":"Local block (cleared)","local_warn":"Local warning","local_audit_hit":"Local audit hit","local_security_context_observed":"Security context observed","upstream_cy_confirmed_miss":"Upstream CY: confirmed miss","upstream_cy_local_detected":"Upstream CY: locally detected","upstream_cy_upstream_only":"Upstream CY: upstream-only evidence","upstream_cy_evidence_unavailable":"Upstream CY: evidence unavailable","upstream_cy_not_comparable":"Upstream CY: not comparable","upstream_cy_legacy_unknown":"Upstream CY: legacy unknown"} + "events": { + "review_cleared": "Review cleared", + "review_flagged_monitor": "Review flagged in monitor mode", + "local_block_strike": "Terminal local block", + "local_block": "Legacy unverified block", + "local_block_unverified": "Local block (unverified)", + "local_block_cleared": "Local block (cleared)", + "local_warn": "Local warning", + "local_audit_hit": "Local audit hit", + "local_security_context_observed": "Security context observed", + "upstream_cy_confirmed_miss": "Upstream CY: confirmed miss", + "upstream_cy_local_detected": "Upstream CY: locally detected", + "upstream_cy_upstream_only": "Upstream CY: upstream-only evidence", + "upstream_cy_evidence_unavailable": "Upstream CY: evidence unavailable", + "upstream_cy_not_comparable": "Upstream CY: not comparable", + "upstream_cy_legacy_unknown": "Upstream CY: legacy unknown" + } }, "loadingTitle": "Loading Prompt Filter", "loadingDesc": "Syncing rule settings and hit logs.", @@ -2362,8 +2456,12 @@ "recommendedAppliedWithStrength": "Applied the “{{strength}}” recommended preset. Review and save.", "recommendedStrengthTitle": "Recommended protection strength", "recommendedStrength": { - "monitor": { "label": "Monitor only" }, - "block": { "label": "Block requests" } + "monitor": { + "label": "Monitor only" + }, + "block": { + "label": "Block requests" + } }, "simpleConfigHint": "The overview keeps only the master switch and mode. Thresholds, layers, prompts, and payloads are consolidated under Advanced settings.", "advancedTitle": "Prompt advanced settings", @@ -2885,7 +2983,12 @@ "executionScore": "Enforcement score", "conversationLockTTL": "Conversation lock lifetime (hours)", "executionScoreHint": "The enforcement score and current threshold determine whether this request enters the warning or blocking flow. A model review may also block when the enforcement score is 0.", - "decisionSource": {"model":"Model-review block","local":"Local-rule block","combined":"Local rules + model review","conversation":"CY conversation-lock block"}, + "decisionSource": { + "model": "Model-review block", + "local": "Local-rule block", + "combined": "Local rules + model review", + "conversation": "CY conversation-lock block" + }, "shadowAuditScore": "Shadow audit score", "shadowAuditScoreHint": "The shadow audit score does not participate in current blocking.", "colMatch": "Matches", @@ -2928,83 +3031,119 @@ "resetFilters": "Reset Filters", "recordsCount": "{{count}} records", "noLogs": "No hit logs yet", - "auditRecordsCount": "{{incidents}} CY incidents, {{reviews}} model reviews, {{logs}} local audit logs", - "cyberIncidentsTitle": "Upstream CY incidents", - "auditHealth": { - "action": "Audit-chain check", - "title": "CY audit-chain status", - "description": "Read-only verification of incident storage, model review, conversation locking, and the high-priority audit queue. It never creates a synthetic CY event.", - "healthy": "Healthy", - "degraded": "Attention required", - "incidentCount": "{{count}} CY incidents stored", - "storage": "Incident storage", - "promptFilter": "Prompt protection", - "modelReview": "Model review", - "reviewKeys": "Available review keys", - "fallbackLocal": "When all keys fail: retain local high-risk rules only (recommended)", - "fallbackBlock": "When all keys fail: block every request that requires review", - "reviewPoolDetail": "{{cooling}} cooling down, {{probing}} recovery probes in progress", - "nextRetry": "next recovery probe {{time}}", - "conversationLock": "CY conversation lock", - "queuePending": "Queue pending", - "queueFailures": "High-priority drops / failures", - "latestIncident": "Latest persisted incident", - "noIncident": "The audit chain is ready, but no CY incident has been persisted yet." - }, - "reviewHistoryTitle": "External model review history", - "reviewHistoryDesc": "Shows every completed model review. Only a redacted request preview and the parsed decision are stored; review keys, Authorization headers, and raw payloads are never persisted.", - "reviewResultFilter": "Review result", - "reviewHistoryEmpty": "No model review history", - "reviewRequest": "Review request", - "reviewResponse": "Model response", - "reviewFinalAction": "Final action", - "reviewHistoryScope": "Key / person / correlation ID", - "reviewRequestUnavailable": "Request preview unavailable", - "reviewResultError": "Review failed", - "notScored": "Not scored", - "localAuditLogsTitle": "Local filter and asynchronous audit logs", - "refreshAllLogs": "Refresh all logs", - "clearCyberIncidents": "Clear CY incidents", - "deleteCyberIncident": "Delete this CY record", - "deleteCyberIncidentConfirm": "Delete this CY record? Risk profiles and learning evidence will be retained.", - "clearReviewLogs": "Clear review history", - "clearLocalLogs": "Clear local logs", - "cyberIncidentsCleared": "Upstream CY incidents cleared; risk profiles were retained", - "cyberIncidentDeleted": "CY record deleted; risk profiles and learning evidence were retained", - "reviewLogsCleared": "External model review history cleared; risk profiles were retained", - "localLogsCleared": "Local filter and asynchronous audit logs cleared; risk profiles were retained", - "logSummaryRefreshFailed": "Logs were cleared, but the overview summary could not be refreshed", - "sectionRefreshHint": "Filters, pagination, and refresh update only this section.", - "noCyberIncidents": "No upstream CY incidents", - "cyberUpstream": "Upstream result", - "cyberLocalResult": "Local result", - "cyberComparison": "Local/upstream attribution", - "cyberSourceKey": "Source key", - "cyberAccount": "Account / groups", - "cyberAccountPlatform": "Account platform", - "cyberRoutingSource": "Account and group data source", - "cyberRoutingState": { "event_snapshot": "Snapshot at event time", "current_inferred": "Historical record: enriched from current account directory", "unavailable": "Unavailable" }, - "cyberGroups": "Account groups at event time", - "cyberKeyAllowedGroups": "Key-allowed groups at event time", - "cyberPromptAvailable": "Linked prompt available", - "cyberAttempt": "Transport / attempt", - "cyberDetail": "Details", - "cyberDetailTitle": "Upstream CY incident details", - "cyberLocalMiss": "Local miss", - "cyberUnscored": "Not scored", - "cyberLegacyUnknown": "Historical record: the local decision cannot be reconstructed", - "cyberProtocolTransport": "Protocol / transport", - "cyberAccountAttempt": "Account / attempt", - "cyberCandidate": "Candidate status", - "cyberReason": "Reason", - "cyberState": { "completed": "Completed", "not_run": "Not run", "unavailable": "Unavailable", "legacy_unknown": "Legacy unknown" }, - "cyberOutcome": { "no_hit": "No hit", "audit_hit": "Audit hit", "warn": "Warn", "block": "Block" }, - "cyberComparisonStatus": { "confirmed_miss": "Confirmed local miss", "upstream_only": "Upstream-only hit", "evidence_unavailable": "Insufficient evidence", "local_detected": "Detected locally", "not_comparable": "Not comparable", "legacy_unknown": "Legacy unknown" }, - "newapiPolicyStatus": { "unbound": "NewAPI unbound", "binding_disabled": "NewAPI binding disabled", "unsigned_request": "NewAPI request unsigned", "verification_failed": "NewAPI verification failed", "verified": "NewAPI verified", "signed_response": "NewAPI audit signed and forwarded" }, - "newapiUser": "User", - "newapiRequest": "Request", - "sources": { "local_filter": "Local filter", "upstream_cyber_policy": "Upstream cyber_policy (legacy)" }, - "labels": { "strike": "Strike eligible", "upstream": "Upstream", "reviewFlagged": "Review flagged", "reviewCleared": "Review cleared" }, + "auditRecordsCount": "{{incidents}} CY incidents, {{reviews}} model reviews, {{logs}} local audit logs", + "cyberIncidentsTitle": "Upstream CY incidents", + "auditHealth": { + "action": "Audit-chain check", + "title": "CY audit-chain status", + "description": "Read-only verification of incident storage, model review, conversation locking, and the high-priority audit queue. It never creates a synthetic CY event.", + "healthy": "Healthy", + "degraded": "Attention required", + "incidentCount": "{{count}} CY incidents stored", + "storage": "Incident storage", + "promptFilter": "Prompt protection", + "modelReview": "Model review", + "reviewKeys": "Available review keys", + "fallbackLocal": "When all keys fail: retain local high-risk rules only (recommended)", + "fallbackBlock": "When all keys fail: block every request that requires review", + "reviewPoolDetail": "{{cooling}} cooling down, {{probing}} recovery probes in progress", + "nextRetry": "next recovery probe {{time}}", + "conversationLock": "CY conversation lock", + "queuePending": "Queue pending", + "queueFailures": "High-priority drops / failures", + "latestIncident": "Latest persisted incident", + "noIncident": "The audit chain is ready, but no CY incident has been persisted yet." + }, + "reviewHistoryTitle": "External model review history", + "reviewHistoryDesc": "Shows every completed model review. Only a redacted request preview and the parsed decision are stored; review keys, Authorization headers, and raw payloads are never persisted.", + "reviewResultFilter": "Review result", + "reviewHistoryEmpty": "No model review history", + "reviewRequest": "Review request", + "reviewResponse": "Model response", + "reviewFinalAction": "Final action", + "reviewHistoryScope": "Key / person / correlation ID", + "reviewRequestUnavailable": "Request preview unavailable", + "reviewResultError": "Review failed", + "notScored": "Not scored", + "localAuditLogsTitle": "Local filter and asynchronous audit logs", + "refreshAllLogs": "Refresh all logs", + "clearCyberIncidents": "Clear CY incidents", + "deleteCyberIncident": "Delete this CY record", + "deleteCyberIncidentConfirm": "Delete this CY record? Risk profiles and learning evidence will be retained.", + "clearReviewLogs": "Clear review history", + "clearLocalLogs": "Clear local logs", + "cyberIncidentsCleared": "Upstream CY incidents cleared; risk profiles were retained", + "cyberIncidentDeleted": "CY record deleted; risk profiles and learning evidence were retained", + "reviewLogsCleared": "External model review history cleared; risk profiles were retained", + "localLogsCleared": "Local filter and asynchronous audit logs cleared; risk profiles were retained", + "logSummaryRefreshFailed": "Logs were cleared, but the overview summary could not be refreshed", + "sectionRefreshHint": "Filters, pagination, and refresh update only this section.", + "noCyberIncidents": "No upstream CY incidents", + "cyberUpstream": "Upstream result", + "cyberLocalResult": "Local result", + "cyberComparison": "Local/upstream attribution", + "cyberSourceKey": "Source key", + "cyberAccount": "Account / groups", + "cyberAccountPlatform": "Account platform", + "cyberRoutingSource": "Account and group data source", + "cyberRoutingState": { + "event_snapshot": "Snapshot at event time", + "current_inferred": "Historical record: enriched from current account directory", + "unavailable": "Unavailable" + }, + "cyberGroups": "Account groups at event time", + "cyberKeyAllowedGroups": "Key-allowed groups at event time", + "cyberPromptAvailable": "Linked prompt available", + "cyberAttempt": "Transport / attempt", + "cyberDetail": "Details", + "cyberDetailTitle": "Upstream CY incident details", + "cyberLocalMiss": "Local miss", + "cyberUnscored": "Not scored", + "cyberLegacyUnknown": "Historical record: the local decision cannot be reconstructed", + "cyberProtocolTransport": "Protocol / transport", + "cyberAccountAttempt": "Account / attempt", + "cyberCandidate": "Candidate status", + "cyberReason": "Reason", + "cyberState": { + "completed": "Completed", + "not_run": "Not run", + "unavailable": "Unavailable", + "legacy_unknown": "Legacy unknown" + }, + "cyberOutcome": { + "no_hit": "No hit", + "audit_hit": "Audit hit", + "warn": "Warn", + "block": "Block" + }, + "cyberComparisonStatus": { + "confirmed_miss": "Confirmed local miss", + "upstream_only": "Upstream-only hit", + "evidence_unavailable": "Insufficient evidence", + "local_detected": "Detected locally", + "not_comparable": "Not comparable", + "legacy_unknown": "Legacy unknown" + }, + "newapiPolicyStatus": { + "unbound": "NewAPI unbound", + "binding_disabled": "NewAPI binding disabled", + "unsigned_request": "NewAPI request unsigned", + "verification_failed": "NewAPI verification failed", + "verified": "NewAPI verified", + "signed_response": "NewAPI audit signed and forwarded" + }, + "newapiUser": "User", + "newapiRequest": "Request", + "sources": { + "local_filter": "Local filter", + "upstream_cyber_policy": "Upstream cyber_policy (legacy)" + }, + "labels": { + "strike": "Strike eligible", + "upstream": "Upstream", + "reviewFlagged": "Review flagged", + "reviewCleared": "Review cleared" + }, "rulesCatalogTitle": "Built-in Rules", "rulesCatalogDesc": "Rules shipped by the backend. Each built-in rule can be enabled or disabled.", "ruleHelp": "Rule Help", @@ -3518,17 +3657,17 @@ "showFullUsageNumbers": "Show full usage numbers", "showFullUsageNumbersDesc": "When enabled, Usage and API Keys → Token Usage show full request/token counts. When disabled, they use compact units like 1.2K and 3.4M.", "fastSchedulerEnabled": "Fast Scheduler", - "fastSchedulerEnabledDesc": "Uses an in-memory fast scheduling algorithm, significantly reducing dispatch latency under high concurrency. Recommended for large account pools (100+).", - "schedulerEngine": "Scheduler Engine", - "schedulerEngineDesc": "Controls how new requests select an account from the pool. Changes take effect immediately without a restart.", - "schedulerEngineCompatibilityTitle": "Legacy setting mapping", - "schedulerEngineCompatibilityNote": "The former Fast Scheduler switch is represented here: Off maps to Legacy, On maps to Indexed, and Shadow is a new validation stage. Scheduler Mode below still independently controls round-robin, remaining-quota, or fill-first allocation.", - "schedulerEngineLegacy": "Legacy", - "schedulerEngineLegacyDesc": "Scans the account snapshot and filters and scores candidates on every request. Best for compatibility and emergency rollback; CPU cost rises with pool size.", - "schedulerEngineShadow": "Shadow", - "schedulerEngineShadowDesc": "Legacy still selects the account while 1 in 64 requests samples indexed availability. Use it to validate parity before switching; it does not remove the main scan cost.", - "schedulerEngineIndexed": "Indexed", - "schedulerEngineIndexedDesc": "Uses event-driven priority and health indexes and usually checks only a few candidates instead of the full pool. Recommended for large account pools.", + "fastSchedulerEnabledDesc": "Uses an in-memory fast scheduling algorithm, significantly reducing dispatch latency under high concurrency. Recommended for large account pools (100+).", + "schedulerEngine": "Scheduler Engine", + "schedulerEngineDesc": "Controls how new requests select an account from the pool. Changes take effect immediately without a restart.", + "schedulerEngineCompatibilityTitle": "Legacy setting mapping", + "schedulerEngineCompatibilityNote": "The former Fast Scheduler switch is represented here: Off maps to Legacy, On maps to Indexed, and Shadow is a new validation stage. Scheduler Mode below still independently controls round-robin, remaining-quota, or fill-first allocation.", + "schedulerEngineLegacy": "Legacy", + "schedulerEngineLegacyDesc": "Scans the account snapshot and filters and scores candidates on every request. Best for compatibility and emergency rollback; CPU cost rises with pool size.", + "schedulerEngineShadow": "Shadow", + "schedulerEngineShadowDesc": "Legacy still selects the account while 1 in 64 requests samples indexed availability. Use it to validate parity before switching; it does not remove the main scan cost.", + "schedulerEngineIndexed": "Indexed", + "schedulerEngineIndexedDesc": "Uses event-driven priority and health indexes and usually checks only a few candidates instead of the full pool. Recommended for large account pools.", "codexWebsocket": "WebSocket (Codex Upstream)", "codexWebsocketDesc": "Controls the persistent WebSocket connection between the proxy and the Codex upstream. All options are off by default and do not affect the existing HTTP request path.", "codexForceWebsocket": "Force Codex Upstream WebSocket", @@ -3842,24 +3981,24 @@ "heroDesc": "Primary rates stay front and center. Advanced channels expand on demand. Remote sync never overwrites your custom prices.", "syncTitle": "Price Sync", "syncSubtitle": "Prefer official OpenAI/xAI rates. models.dev and JSON remain manual references and never overwrite custom prices.", - "officialTitle": "Official price sync", - "authoritative": "Authoritative", - "officialDesc": "Reads OpenAI and xAI official pricing, including standard, cached, output, long-context, and Fast (Priority) rates.", - "officialSyncNow": "Sync official rates", - "officialSyncDone": "Official price sync complete: {{applied}} applied, {{skipped}} custom retained", - "officialConfigSaved": "Official pricing poll settings saved", - "autoOfficialSync": "Poll official pricing", - "autoOfficialSyncHint": "Off by default. Database writes only begin after network parsing finishes.", - "intervalMinutes": "Interval (minutes)", - "intervalHour": "Every hour", - "intervalSixHours": "Every 6 hours", - "intervalTwelveHours": "Every 12 hours", - "intervalDay": "Daily", - "intervalThreeDays": "Every 3 days", - "intervalWeek": "Weekly", - "lastOfficialSuccess": "Last successful sync", - "lastWarning": "Latest warning", - "referenceTitle": "Reference JSON source (manual)", + "officialTitle": "Official price sync", + "authoritative": "Authoritative", + "officialDesc": "Reads OpenAI and xAI official pricing, including standard, cached, output, long-context, and Fast (Priority) rates.", + "officialSyncNow": "Sync official rates", + "officialSyncDone": "Official price sync complete: {{applied}} applied, {{skipped}} custom retained", + "officialConfigSaved": "Official pricing poll settings saved", + "autoOfficialSync": "Poll official pricing", + "autoOfficialSyncHint": "Off by default. Database writes only begin after network parsing finishes.", + "intervalMinutes": "Interval (minutes)", + "intervalHour": "Every hour", + "intervalSixHours": "Every 6 hours", + "intervalTwelveHours": "Every 12 hours", + "intervalDay": "Daily", + "intervalThreeDays": "Every 3 days", + "intervalWeek": "Weekly", + "lastOfficialSuccess": "Last successful sync", + "lastWarning": "Latest warning", + "referenceTitle": "Reference JSON source (manual)", "syncUrl": "Sync source URL", "presets": "Presets", "presetDefault": "Project default", @@ -3910,14 +4049,14 @@ "shortCached": "Cached", "shortOutput": "Output", "shortInputPriority": "Input · P", - "shortCachedInputPriority": "Cached · P", + "shortCachedInputPriority": "Cached · P", "shortOutputPriority": "Output · P", "shortInputLong": "Input · Long", - "shortCachedInputLong": "Cached · Long", + "shortCachedInputLong": "Cached · Long", "shortOutputLong": "Output · Long", - "shortInputLongPriority": "Input · Long P", - "shortCachedInputLongPriority": "Cached · Long P", - "shortOutputLongPriority": "Output · Long P", + "shortInputLongPriority": "Input · Long P", + "shortCachedInputLongPriority": "Cached · Long P", + "shortOutputLongPriority": "Output · Long P", "groupStandard": "Standard", "groupStandardHint": "Input / cached / output", "groupPriority": "Priority", @@ -3931,14 +4070,14 @@ "cached": "Cached input", "output": "Output", "inputPriority": "Input · Priority", - "cachedInputPriority": "Cached input · Priority/Fast", + "cachedInputPriority": "Cached input · Priority/Fast", "outputPriority": "Output · Priority", "inputLong": "Input · Long context", - "cachedInputLong": "Cached input · Long context", + "cachedInputLong": "Cached input · Long context", "outputLong": "Output · Long context", - "inputLongPriority": "Input · Long context · Fast", - "cachedInputLongPriority": "Cached input · Long context · Fast", - "outputLongPriority": "Output · Long context · Fast", + "inputLongPriority": "Input · Long context · Fast", + "cachedInputLongPriority": "Cached input · Long context · Fast", + "outputLongPriority": "Output · Long context · Fast", "source": { "custom": "Custom", "synced": "Synced", @@ -4149,7 +4288,8 @@ "testFailedUnknown": "Unknown error", "pagination": "{{total}} proxies, page {{page}}/{{totalPages}}", "showProxyUrl": "Show proxy URL", - "hideProxyUrl": "Hide proxy URL" + "hideProxyUrl": "Hide proxy URL", + "idle": "Idle" }, "apiKeys": { "title": "API Keys", @@ -4467,9 +4607,9 @@ "keyAccountsErrUnit": "err", "keyGroupsTitle": "Current group totals", "keyGroupsAccounts": "{{count}} accounts", - "keyGroupsAccountCost": "Upstream cost", - "keyGroupsBilled": "User billed", - "keyGroupsCurrentHint": "Active accounts use current groups; recycle-bin accounts use their last retained groups. Accounts in multiple groups count toward each group.", + "keyGroupsAccountCost": "Upstream cost", + "keyGroupsBilled": "User billed", + "keyGroupsCurrentHint": "Active accounts use current groups; recycle-bin accounts use their last retained groups. Accounts in multiple groups count toward each group.", "keyAccountDeleted": "Deleted", "keyAccountDeletedHint": "This upstream account is in the recycle bin; its historical usage is still included.", "keyAccountUngrouped": "Ungrouped", @@ -5214,5 +5354,31 @@ "s5": "Confirm Save succeeded; optionally audit the JSON source." } } + }, + "claude": { + "title": "Claude Accounts", + "subtitle": "Claude Code (Anthropic) OAuth subscription pool", + "addAccount": "Add Claude account", + "empty": "No Claude accounts yet", + "tabOAuth": "Web login", + "tabImport": "Import token", + "step1": "Step 1: generate the authorization link and complete login in your browser", + "genAuthUrl": "Generate auth link", + "openAuth": "Open auth page", + "step2": "Step 2: paste the callback URL (or just the code)", + "callbackPlaceholder": "http://localhost:54545/callback?code=... or the code", + "namePlaceholder": "Account note (optional)", + "proxyLabel": "Proxy (optional)", + "useProxyPool": "Auto-assign an idle proxy from the pool", + "exchange": "Finish login & add", + "importHint": "Paste the token JSON from cmd/claude_login -out", + "importPlaceholder": "{ \"access_token\": \"...\", \"refresh_token\": \"...\" }", + "import": "Import & add", + "added": "Claude account added", + "invalidJson": "Failed to parse token JSON", + "authUrlFailed": "Failed to generate auth link", + "exchangeFailed": "Token exchange failed", + "deleteConfirm": "Delete this Claude account?", + "timezonePlaceholder": "Timezone, e.g. Asia/Shanghai (empty = none)" } -} +} \ No newline at end of file diff --git a/frontend/src/locales/zh-TW.json b/frontend/src/locales/zh-TW.json index 60911b36..fa0df1dd 100644 --- a/frontend/src/locales/zh-TW.json +++ b/frontend/src/locales/zh-TW.json @@ -1,5 +1,10 @@ { - "common": {"success":"成功","failed":"失敗","enabled":"開啟","disabled":"關閉"}, + "common": { + "success": "成功", + "failed": "失敗", + "enabled": "開啟", + "disabled": "關閉" + }, "antigravity": { "authKind": "憑證類型", "authKindOAuth": "OAuth", @@ -96,7 +101,8 @@ "empty": "目前沒有待審核的自助提交", "loadFailed": "待審核列表載入失敗:{{error}}", "grokBanner": "{{count}} 條 Codex 自助提交待審核" - } + }, + "providerViewClaude": "Claude" }, "settings": { "pricing": { @@ -150,7 +156,10 @@ "schedulerEngineIndexedDesc": "使用事件驅動的優先順序與健康狀態索引,穩態只檢查少量候選帳號,避免全池掃描。建議大型帳號池使用。" }, "promptFilter": { - "views": {"profiles":"風險畫像","intelligence":"CY 學習審核"}, + "views": { + "profiles": "風險畫像", + "intelligence": "CY 學習審核" + }, "coverageTitle": "目前防護涵蓋", "coverageEveryRequest": "每個可擷取請求均進入本地防護鏈", "coverageLearning": "CY 經驗進入人工審核", @@ -193,8 +202,12 @@ "recommendedAppliedWithStrength": "已套用「{{strength}}」建議設定,請確認後儲存", "recommendedStrengthTitle": "建議防護力度", "recommendedStrength": { - "monitor": { "label": "僅監控" }, - "block": { "label": "攔截請求" } + "monitor": { + "label": "僅監控" + }, + "block": { + "label": "攔截請求" + } }, "simpleConfigHint": "首頁只保留開關與模式;門檻、層級、提示詞和 Payload 已集中到一個進階設定入口。", "advancedTitle": "Prompt 進階設定", @@ -349,8 +362,17 @@ "awaitingAttribution": "上游風險證據(待歸因)", "attributedEvidence": "上游風險證據(已歸因)", "attributed": "已歸因", - "aiDecision": {"no_change":"無需變更","rule":"建議規則","identity":"建議身分條款","both":"規則與身分條款"}, - "source": {"ai_analysis":"AI 歸因分析","ai_identity_update":"AI 身分版本","ai_identity_rollback":"身分版本回滾"} + "aiDecision": { + "no_change": "無需變更", + "rule": "建議規則", + "identity": "建議身分條款", + "both": "規則與身分條款" + }, + "source": { + "ai_analysis": "AI 歸因分析", + "ai_identity_update": "AI 身分版本", + "ai_identity_rollback": "身分版本回滾" + } }, "risk": { "title": "請求與身份風險畫像", @@ -380,7 +402,7 @@ "identityOnly": "僅身份目錄", "noAttributedRequests": "暫無可歸因請求", "profileState": "畫像狀態", - "identitySource": "身份匯入來源", + "identitySource": "身份來源", "identityConfidence": "身份可信度", "miss": "漏檢", "block": "攔截", @@ -401,15 +423,34 @@ "apiKeyProfiles": "API Key 畫像", "upstreamAccountProfiles": "上游帳號畫像", "activityState": "活動狀態", - "activityStates": { "active": "有請求記錄", "identityOnly": "僅身份記錄" }, + "activityStates": { + "active": "有請求記錄", + "identityOnly": "僅身份記錄" + }, "peopleProfilesHint": "預設只顯示可追溯至 NewAPI 使用者 ID 的人員畫像。", "nonPersonHint": "工作階段、Key、網路與上游帳號是環境對象,用於定位鏈路,不代表特定人員。", - "identityKinds": { "newapi_user": "已驗證人員", "unverified_user": "未驗證身份", "session": "會話 / 指紋", "api_key": "API Key", "client_ip": "客戶端 IP", "upstream_account": "上游帳號" }, - "identitySource": "身份來源", + "identityKinds": { + "newapi_user": "已驗證人員", + "unverified_user": "未驗證身份", + "session": "會話 / 指紋", + "api_key": "API Key", + "client_ip": "客戶端 IP", + "upstream_account": "上游帳號" + }, "freezeStatus": "凍結狀態", - "freezeStates": { "none": "未凍結", "conversation": "會話鎖", "user_cooldown": "使用者冷卻", "fingerprint_replay": "指紋重放冷卻" }, + "freezeStates": { + "none": "未凍結", + "conversation": "會話鎖", + "user_cooldown": "使用者冷卻", + "fingerprint_replay": "指紋重放冷卻" + }, "freezeHint": "目前沒有活動限制", - "summary": { "total": "符合條件總數", "frozen": "本頁凍結", "cy": "本頁 CY", "highCritical": "高危 / 嚴重" }, + "summary": { + "total": "符合條件總數", + "frozen": "本頁凍結", + "cy": "本頁 CY", + "highCritical": "高危 / 嚴重" + }, "conversationLock": { "title": "CYB 會話鎖", "active": "對話已鎖定", @@ -439,16 +480,115 @@ "trust": { "title": "臨時自適應信任", "description": "只對本地規則判定乾淨的請求略過同步 DS 複核;本地高風險規則、週期畫像和 CY 回饋始終生效。達到門檻、到期或再次出現 CY 會自動恢復同步審核。", - "enable": "啟用臨時信任","adjust":"調整臨時信任","revoke":"立即撤銷","saved":"臨時信任已儲存","revoked":"臨時信任已撤銷,已恢復同步模型審核", - "validUntil":"有效期至","threshold":"恢復審核門檻","bypassCount":"已節省同步複核","lastEvaluation":"最近週期評判","source":"策略來源","sources":{"manual":"管理員設定","automatic":"畫像自動啟用"},"modelReviewCount":"週期模型複核次數","lastModelReview":"最近模型複核","basisTitle":"自適應模型審核判斷依據","basisDescription":"顯示目前使用者為何進入或尚未進入模型審核降頻,以及週期抽檢與恢復完整審核的時間邊界。","cleanReviews":"乾淨模型複核","observationPeriod":"持續觀察時長","positiveEvidence":"視窗內風險證據","riskBoundary":"目前風險 / 恢復門檻","sampleRate":"週期抽檢比例","forceReviewInterval":"最長複核間隔","lastCleanReview":"最近乾淨複核","nextForcedReview":"下次最遲模型複核","reviewDueNow":"現在必須複核","basisFallbackReason":"系統會依乾淨複核數量、觀察時長、目前風險與週期抽檢規則持續評判。","decisions":{"disabled":"自適應審核未啟用","not_person":"非可信人員身份","adaptive_active":"自適應降頻中","suspended":"已恢復完整審核","eligible":"已符合啟用條件","building_history":"正在累積低風險證據","unavailable":"判斷依據不可用"},"notEnabled":"目前未啟用臨時信任。","personOnly":"只有經簽名確認的人員畫像可啟用臨時信任。", - "history":"模型審核決策與維運記錄","operation":"操作","requestAudit":"關聯請求稽核","noHistory":"暫無模型審核決策記錄","reason":"維運理由","dialogTitle":"設定臨時自適應信任","dialogDescription":"這不是永久白名單。系統會持續畫像,並在風險升高時自動撤銷。","duration":"有效時長","reasonHint":"必填;用於稽核降低首字延遲的原因與期限。","safetyHint":"可信期間仍執行本地高風險規則;只有乾淨請求略過同步 DS。達到門檻、出現本地高風險判定或上游 CY 時立即失效。", - "status":{"active":"臨時信任中","suspended":"已自動暫停","revoked":"已撤銷","expired":"已到期"}, - "events":{"granted":"已啟用","auto_granted":"畫像自動啟用","reactivated":"重新啟用","suspended":"已暫停","auto_suspended":"風險觸發自動暫停","revoked":"管理員撤銷","expired":"到期失效","bypass_used":"乾淨請求略過同步複核","model_reviewed":"週期模型複核通過","evaluated":"週期評判"} + "enable": "啟用臨時信任", + "adjust": "調整臨時信任", + "revoke": "立即撤銷", + "saved": "臨時信任已儲存", + "revoked": "臨時信任已撤銷,已恢復同步模型審核", + "validUntil": "有效期至", + "threshold": "恢復審核門檻", + "bypassCount": "已節省同步複核", + "lastEvaluation": "最近週期評判", + "source": "策略來源", + "sources": { + "manual": "管理員設定", + "automatic": "畫像自動啟用" + }, + "modelReviewCount": "週期模型複核次數", + "lastModelReview": "最近模型複核", + "basisTitle": "自適應模型審核判斷依據", + "basisDescription": "顯示目前使用者為何進入或尚未進入模型審核降頻,以及週期抽檢與恢復完整審核的時間邊界。", + "cleanReviews": "乾淨模型複核", + "observationPeriod": "持續觀察時長", + "positiveEvidence": "視窗內風險證據", + "riskBoundary": "目前風險 / 恢復門檻", + "sampleRate": "週期抽檢比例", + "forceReviewInterval": "最長複核間隔", + "lastCleanReview": "最近乾淨複核", + "nextForcedReview": "下次最遲模型複核", + "reviewDueNow": "現在必須複核", + "basisFallbackReason": "系統會依乾淨複核數量、觀察時長、目前風險與週期抽檢規則持續評判。", + "decisions": { + "disabled": "自適應審核未啟用", + "not_person": "非可信人員身份", + "adaptive_active": "自適應降頻中", + "suspended": "已恢復完整審核", + "eligible": "已符合啟用條件", + "building_history": "正在累積低風險證據", + "unavailable": "判斷依據不可用" + }, + "notEnabled": "目前未啟用臨時信任。", + "personOnly": "只有經簽名確認的人員畫像可啟用臨時信任。", + "history": "模型審核決策與維運記錄", + "operation": "操作", + "requestAudit": "關聯請求稽核", + "noHistory": "暫無模型審核決策記錄", + "reason": "維運理由", + "dialogTitle": "設定臨時自適應信任", + "dialogDescription": "這不是永久白名單。系統會持續畫像,並在風險升高時自動撤銷。", + "duration": "有效時長", + "reasonHint": "必填;用於稽核降低首字延遲的原因與期限。", + "safetyHint": "可信期間仍執行本地高風險規則;只有乾淨請求略過同步 DS。達到門檻、出現本地高風險判定或上游 CY 時立即失效。", + "status": { + "active": "臨時信任中", + "suspended": "已自動暫停", + "revoked": "已撤銷", + "expired": "已到期" + }, + "events": { + "granted": "已啟用", + "auto_granted": "畫像自動啟用", + "reactivated": "重新啟用", + "suspended": "已暫停", + "auto_suspended": "風險觸發自動暫停", + "revoked": "管理員撤銷", + "expired": "到期失效", + "bypass_used": "乾淨請求略過同步複核", + "model_reviewed": "週期模型複核通過", + "evaluated": "週期評判" + } }, - "subjects": {"newapi_user":"NewAPI 使用者","session":"工作階段","api_key":"API Key","client_ip":"客戶端網路","upstream_account":"上游帳號"}, - "levels": {"low":"低","observed":"觀察","elevated":"升高","high":"高","critical":"嚴重"}, - "actions": {"observe":"保持觀察","monitor":"持續監控","enhanced_review":"加強審核","rate_limit":"評估限速","require_signed_identity":"要求可信簽名身份","temporary_restriction_review":"評估臨時限制","account_rotation_review":"評估帳號輪換","account_routing_review":"檢查帳號路由"}, - "events": {"review_cleared":"複核通過","review_flagged_monitor":"模型命中(監控模式)","local_block_strike":"本地終局攔截","local_block":"歷史未確認攔截","local_block_unverified":"本地攔截(待確認)","local_block_cleared":"本地攔截(已修正)","local_warn":"本地警告","local_audit_hit":"本地審計命中","local_security_context_observed":"安全測試上下文觀察","upstream_cy_confirmed_miss":"上游 CY:確認漏檢","upstream_cy_local_detected":"上游 CY:本地已識別","upstream_cy_upstream_only":"上游 CY:僅上游證據","upstream_cy_evidence_unavailable":"上游 CY:證據不可用","upstream_cy_not_comparable":"上游 CY:不可比較","upstream_cy_legacy_unknown":"上游 CY:歷史未知"} + "subjects": { + "newapi_user": "NewAPI 使用者", + "session": "工作階段", + "api_key": "API Key", + "client_ip": "客戶端網路", + "upstream_account": "上游帳號" + }, + "levels": { + "low": "低", + "observed": "觀察", + "elevated": "升高", + "high": "高", + "critical": "嚴重" + }, + "actions": { + "observe": "保持觀察", + "monitor": "持續監控", + "enhanced_review": "加強審核", + "rate_limit": "評估限速", + "require_signed_identity": "要求可信簽名身份", + "temporary_restriction_review": "評估臨時限制", + "account_rotation_review": "評估帳號輪換", + "account_routing_review": "檢查帳號路由" + }, + "events": { + "review_cleared": "複核通過", + "review_flagged_monitor": "模型命中(監控模式)", + "local_block_strike": "本地終局攔截", + "local_block": "歷史未確認攔截", + "local_block_unverified": "本地攔截(待確認)", + "local_block_cleared": "本地攔截(已修正)", + "local_warn": "本地警告", + "local_audit_hit": "本地審計命中", + "local_security_context_observed": "安全測試上下文觀察", + "upstream_cy_confirmed_miss": "上游 CY:確認漏檢", + "upstream_cy_local_detected": "上游 CY:本地已識別", + "upstream_cy_upstream_only": "上游 CY:僅上游證據", + "upstream_cy_evidence_unavailable": "上游 CY:證據不可用", + "upstream_cy_not_comparable": "上游 CY:不可比較", + "upstream_cy_legacy_unknown": "上游 CY:歷史未知" + } }, "saveRefreshFailed": "設定已儲存,但規則或日誌重新整理失敗;可稍後單獨重新整理頁面", "advancedConfigInvalidTitle": "進階防護設定無法解析", @@ -479,88 +619,129 @@ "modeWarn": "警告", "modeBlock": "攔截", "actionAllow": "放行", - "auditRecordsCount": "CY 事件 {{incidents}} 筆,模型複核 {{reviews}} 筆,本機稽核 {{logs}} 筆", - "cyberIncidentsTitle": "上游 CY 事件", - "auditHealth": { - "action": "稽核鏈自檢", - "title": "CY 稽核鏈狀態", - "description": "唯讀檢查事件儲存、模型複核、會話鎖與高優先級稽核佇列;不會建立模擬 CY 事件。", - "healthy": "運作正常", - "degraded": "需要關注", - "incidentCount": "已保存 {{count}} 筆 CY 事件", - "storage": "事件儲存", - "promptFilter": "Prompt 防護", - "modelReview": "模型複核", - "reviewKeys": "可用審核 Key", - "fallbackLocal": "全部 Key 不可用時:僅保留本機高危規則(建議)", - "fallbackBlock": "全部 Key 不可用時:阻斷所有需複核請求", - "reviewPoolDetail": "冷卻中 {{cooling}} 個,恢復探測中 {{probing}} 個", - "nextRetry": "下次恢復探測 {{time}}", - "conversationLock": "CY 會話鎖", - "queuePending": "佇列待處理", - "queueFailures": "高優先級丟棄 / 失敗", - "latestIncident": "最近一次已落庫事件", - "noIncident": "稽核鏈已就緒,但目前尚無已落庫 CY 事件。" - }, - "reviewHistoryTitle": "外部模型複核歷史", - "reviewHistoryDesc": "記錄每次已執行的模型複核;只保存脫敏請求預覽與解析後的回傳判定,不保存審核 Key、Authorization 或原始 Payload。", - "reviewResultFilter": "複核結果", - "reviewHistoryEmpty": "暫無模型複核記錄", - "reviewRequest": "模型審核請求", - "reviewResponse": "模型回傳判定", - "reviewFinalAction": "最終動作", - "reviewHistoryScope": "Key / 人員 / 關聯 ID", - "reviewRequestUnavailable": "請求預覽不可用", - "reviewResultError": "複核失敗", - "notScored": "未評分", - "localAuditLogsTitle": "本機過濾與非同步稽核日誌", - "refreshAllLogs": "重新整理全部日誌", - "clearCyberIncidents": "清空 CY 事件", - "deleteCyberIncident": "刪除此 CY 記錄", - "deleteCyberIncidentConfirm": "確定刪除此 CY 記錄嗎?風險畫像與學習證據會保留。", - "clearReviewLogs": "清空複核歷史", - "clearLocalLogs": "清空本機日誌", - "cyberIncidentsCleared": "上游 CY 事件已清空,風險畫像已保留", - "cyberIncidentDeleted": "CY 記錄已刪除,風險畫像與學習證據已保留", - "reviewLogsCleared": "外部模型複核歷史已清空,風險畫像已保留", - "localLogsCleared": "本機過濾與非同步稽核日誌已清空,風險畫像已保留", - "logSummaryRefreshFailed": "日誌已清空,但概覽摘要重新整理失敗", - "sectionRefreshHint": "篩選、分頁與重新整理只更新目前區域。", - "noCyberIncidents": "暫無上游 CY 事件", - "cyberUpstream": "上游結果", - "cyberLocalResult": "本機結果", - "cyberComparison": "本機與上游歸因", - "cyberSourceKey": "來源 Key", - "cyberAccount": "作用帳號 / 群組", - "cyberAccountPlatform": "帳號平台", - "cyberRoutingSource": "帳號與群組資訊來源", - "cyberRoutingState": { "event_snapshot": "事件發生時快照", "current_inferred": "歷史記錄:依目前帳號目錄補全", "unavailable": "無法還原" }, - "cyberGroups": "事件發生時帳號群組", - "cyberKeyAllowedGroups": "事件發生時 Key 可用群組", - "cyberPromptAvailable": "關聯 Prompt 可用", - "cyberAttempt": "傳輸 / 序號", - "cyberDetail": "詳細資料", - "cyberDetailTitle": "上游 CY 事件詳細資料", - "cyberLocalMiss": "本機漏檢", - "cyberUnscored": "未評分", - "cyberLegacyUnknown": "歷史記錄:本機判定無法還原", - "cyberProtocolTransport": "協定 / 傳輸", - "cyberAccountAttempt": "帳號 / 重試序號", - "cyberCandidate": "候選狀態", - "cyberReason": "原因", - "cyberState": { "completed": "已完成", "not_run": "未執行", "unavailable": "無法擷取", "legacy_unknown": "歷史未知" }, - "cyberOutcome": { "no_hit": "未命中", "audit_hit": "稽核命中", "warn": "警告", "block": "攔截" }, - "cyberComparisonStatus": { "confirmed_miss": "確認本機漏檢", "upstream_only": "僅上游命中", "evidence_unavailable": "證據不足,無法判斷", "local_detected": "本機已檢測", "not_comparable": "不可比較", "legacy_unknown": "歷史無法還原" }, - "newapiPolicyStatus": { "unbound": "NewAPI 未綁定", "binding_disabled": "NewAPI 綁定已停用", "unsigned_request": "NewAPI 請求未簽名", "verification_failed": "NewAPI 驗簽失敗", "verified": "NewAPI 已驗簽", "signed_response": "NewAPI 稽核已簽名轉送" }, - "newapiUser": "使用者", - "newapiRequest": "請求", - "sources": { "local_filter": "本機過濾", "upstream_cyber_policy": "上游 cyber_policy(歷史)" }, - "labels": { "strike": "計入違規", "upstream": "上游", "reviewFlagged": "審查判定違規", "reviewCleared": "審查判定通過" }, + "auditRecordsCount": "CY 事件 {{incidents}} 筆,模型複核 {{reviews}} 筆,本機稽核 {{logs}} 筆", + "cyberIncidentsTitle": "上游 CY 事件", + "auditHealth": { + "action": "稽核鏈自檢", + "title": "CY 稽核鏈狀態", + "description": "唯讀檢查事件儲存、模型複核、會話鎖與高優先級稽核佇列;不會建立模擬 CY 事件。", + "healthy": "運作正常", + "degraded": "需要關注", + "incidentCount": "已保存 {{count}} 筆 CY 事件", + "storage": "事件儲存", + "promptFilter": "Prompt 防護", + "modelReview": "模型複核", + "reviewKeys": "可用審核 Key", + "fallbackLocal": "全部 Key 不可用時:僅保留本機高危規則(建議)", + "fallbackBlock": "全部 Key 不可用時:阻斷所有需複核請求", + "reviewPoolDetail": "冷卻中 {{cooling}} 個,恢復探測中 {{probing}} 個", + "nextRetry": "下次恢復探測 {{time}}", + "conversationLock": "CY 會話鎖", + "queuePending": "佇列待處理", + "queueFailures": "高優先級丟棄 / 失敗", + "latestIncident": "最近一次已落庫事件", + "noIncident": "稽核鏈已就緒,但目前尚無已落庫 CY 事件。" + }, + "reviewHistoryTitle": "外部模型複核歷史", + "reviewHistoryDesc": "記錄每次已執行的模型複核;只保存脫敏請求預覽與解析後的回傳判定,不保存審核 Key、Authorization 或原始 Payload。", + "reviewResultFilter": "複核結果", + "reviewHistoryEmpty": "暫無模型複核記錄", + "reviewRequest": "模型審核請求", + "reviewResponse": "模型回傳判定", + "reviewFinalAction": "最終動作", + "reviewHistoryScope": "Key / 人員 / 關聯 ID", + "reviewRequestUnavailable": "請求預覽不可用", + "reviewResultError": "複核失敗", + "notScored": "未評分", + "localAuditLogsTitle": "本機過濾與非同步稽核日誌", + "refreshAllLogs": "重新整理全部日誌", + "clearCyberIncidents": "清空 CY 事件", + "deleteCyberIncident": "刪除此 CY 記錄", + "deleteCyberIncidentConfirm": "確定刪除此 CY 記錄嗎?風險畫像與學習證據會保留。", + "clearReviewLogs": "清空複核歷史", + "clearLocalLogs": "清空本機日誌", + "cyberIncidentsCleared": "上游 CY 事件已清空,風險畫像已保留", + "cyberIncidentDeleted": "CY 記錄已刪除,風險畫像與學習證據已保留", + "reviewLogsCleared": "外部模型複核歷史已清空,風險畫像已保留", + "localLogsCleared": "本機過濾與非同步稽核日誌已清空,風險畫像已保留", + "logSummaryRefreshFailed": "日誌已清空,但概覽摘要重新整理失敗", + "sectionRefreshHint": "篩選、分頁與重新整理只更新目前區域。", + "noCyberIncidents": "暫無上游 CY 事件", + "cyberUpstream": "上游結果", + "cyberLocalResult": "本機結果", + "cyberComparison": "本機與上游歸因", + "cyberSourceKey": "來源 Key", + "cyberAccount": "作用帳號 / 群組", + "cyberAccountPlatform": "帳號平台", + "cyberRoutingSource": "帳號與群組資訊來源", + "cyberRoutingState": { + "event_snapshot": "事件發生時快照", + "current_inferred": "歷史記錄:依目前帳號目錄補全", + "unavailable": "無法還原" + }, + "cyberGroups": "事件發生時帳號群組", + "cyberKeyAllowedGroups": "事件發生時 Key 可用群組", + "cyberPromptAvailable": "關聯 Prompt 可用", + "cyberAttempt": "傳輸 / 序號", + "cyberDetail": "詳細資料", + "cyberDetailTitle": "上游 CY 事件詳細資料", + "cyberLocalMiss": "本機漏檢", + "cyberUnscored": "未評分", + "cyberLegacyUnknown": "歷史記錄:本機判定無法還原", + "cyberProtocolTransport": "協定 / 傳輸", + "cyberAccountAttempt": "帳號 / 重試序號", + "cyberCandidate": "候選狀態", + "cyberReason": "原因", + "cyberState": { + "completed": "已完成", + "not_run": "未執行", + "unavailable": "無法擷取", + "legacy_unknown": "歷史未知" + }, + "cyberOutcome": { + "no_hit": "未命中", + "audit_hit": "稽核命中", + "warn": "警告", + "block": "攔截" + }, + "cyberComparisonStatus": { + "confirmed_miss": "確認本機漏檢", + "upstream_only": "僅上游命中", + "evidence_unavailable": "證據不足,無法判斷", + "local_detected": "本機已檢測", + "not_comparable": "不可比較", + "legacy_unknown": "歷史無法還原" + }, + "newapiPolicyStatus": { + "unbound": "NewAPI 未綁定", + "binding_disabled": "NewAPI 綁定已停用", + "unsigned_request": "NewAPI 請求未簽名", + "verification_failed": "NewAPI 驗簽失敗", + "verified": "NewAPI 已驗簽", + "signed_response": "NewAPI 稽核已簽名轉送" + }, + "newapiUser": "使用者", + "newapiRequest": "請求", + "sources": { + "local_filter": "本機過濾", + "upstream_cyber_policy": "上游 cyber_policy(歷史)" + }, + "labels": { + "strike": "計入違規", + "upstream": "上游", + "reviewFlagged": "審查判定違規", + "reviewCleared": "審查判定通過" + }, "colScore": "執行 / 稽核", "executionScore": "執行分", "conversationLockTTL": "會話鎖有效期(小時)", "executionScoreHint": "執行分與目前門檻共同決定本次請求是否進入警告或攔截流程。模型複核也可能在執行分為 0 時直接攔截。", - "decisionSource": {"model":"模型複核攔截","local":"本地規則攔截","combined":"本地規則 + 模型複核","conversation":"CY 會話鎖攔截"}, + "decisionSource": { + "model": "模型複核攔截", + "local": "本地規則攔截", + "combined": "本地規則 + 模型複核", + "conversation": "CY 會話鎖攔截" + }, "shadowAuditScore": "影子稽核分", "shadowAuditScoreHint": "影子稽核分不參與目前攔截。", "strictTerminal": "嚴格規則終局攔截", @@ -872,25 +1053,64 @@ } }, "usage": { - "cyberPolicyViewContent": "查看 cyber_policy 事件詳細資料", - "cyberPolicyDetailTitle": "cyber_policy 觸發詳細資料", - "cyberPolicyRequestContent": "關聯 Prompt", - "cyberPolicyNoDetail": "找不到對應的事件詳細資料", - "cyberPolicyUnscored": "未評分", - "cyberPolicyLegacyInferred": "歷史記錄:此關聯由時間視窗推斷,可能不是精確比對。", - "cyberPolicyLegacyUnknown": "歷史記錄:本機判定無法還原", - "cyberPolicyUpstreamResult": "上游結果", - "cyberPolicyLocalResult": "本機結果", - "cyberPolicyLocalMiss": "本機漏檢", - "cyberPolicyExecutionScore": "執行分", - "cyberPolicyAuditScore": "稽核分", - "cyberPolicyTransport": "協定 / 傳輸", - "cyberPolicyAccountAttempt": "帳號 / 重試序號", - "cyberPolicyReview": "二次審查", - "cyberPolicyCandidate": "候選狀態", - "cyberPolicyReason": "原因", - "cyberPolicyMatches": "命中規則與權重", - "cyberPolicyState": { "completed": "已完成", "not_run": "未執行", "unavailable": "無法擷取", "legacy_unknown": "歷史未知" }, - "cyberPolicyOutcome": { "no_hit": "未命中", "audit_hit": "稽核命中", "warn": "警告", "block": "攔截" } + "cyberPolicyViewContent": "查看 cyber_policy 事件詳細資料", + "cyberPolicyDetailTitle": "cyber_policy 觸發詳細資料", + "cyberPolicyRequestContent": "關聯 Prompt", + "cyberPolicyNoDetail": "找不到對應的事件詳細資料", + "cyberPolicyUnscored": "未評分", + "cyberPolicyLegacyInferred": "歷史記錄:此關聯由時間視窗推斷,可能不是精確比對。", + "cyberPolicyLegacyUnknown": "歷史記錄:本機判定無法還原", + "cyberPolicyUpstreamResult": "上游結果", + "cyberPolicyLocalResult": "本機結果", + "cyberPolicyLocalMiss": "本機漏檢", + "cyberPolicyExecutionScore": "執行分", + "cyberPolicyAuditScore": "稽核分", + "cyberPolicyTransport": "協定 / 傳輸", + "cyberPolicyAccountAttempt": "帳號 / 重試序號", + "cyberPolicyReview": "二次審查", + "cyberPolicyCandidate": "候選狀態", + "cyberPolicyReason": "原因", + "cyberPolicyMatches": "命中規則與權重", + "cyberPolicyState": { + "completed": "已完成", + "not_run": "未執行", + "unavailable": "無法擷取", + "legacy_unknown": "歷史未知" + }, + "cyberPolicyOutcome": { + "no_hit": "未命中", + "audit_hit": "稽核命中", + "warn": "警告", + "block": "攔截" + } + }, + "proxies": { + "idle": "空閒" + }, + "claude": { + "title": "Claude 帳號", + "subtitle": "Claude Code(Anthropic)OAuth 訂閱帳號池", + "addAccount": "新增 Claude 帳號", + "empty": "暫無 Claude 帳號", + "tabOAuth": "網頁授權", + "tabImport": "匯入 Token", + "step1": "第一步:點擊產生授權連結並在瀏覽器完成授權", + "genAuthUrl": "產生授權連結", + "openAuth": "開啟授權頁", + "step2": "第二步:貼上回呼網址列的 URL 或 code", + "callbackPlaceholder": "http://localhost:54545/callback?code=... 或直接貼 code", + "namePlaceholder": "帳號備註(可選)", + "proxyLabel": "代理(可選)", + "useProxyPool": "從代理池自動分配一條空閒代理", + "exchange": "完成登入並新增", + "importHint": "貼上 cmd/claude_login -out 產生的 token JSON", + "importPlaceholder": "{ \"access_token\": \"...\", \"refresh_token\": \"...\" }", + "import": "匯入並新增", + "added": "已新增 Claude 帳號", + "invalidJson": "token JSON 解析失敗", + "authUrlFailed": "產生授權連結失敗", + "exchangeFailed": "換取 token 失敗", + "deleteConfirm": "確認刪除該 Claude 帳號?", + "timezonePlaceholder": "時區,如 Asia/Shanghai(留空=不指定)" } -} +} \ No newline at end of file diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index 073f891b..e57d049d 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -1596,7 +1596,8 @@ "modelCooldownPolicySaved": "账号模型冷却策略已保存", "modelCooldownPolicySaveFailed": "保存模型冷却策略失败:{{error}}", "modelCooldownCleared": "已清除 {{model}} 的模型冷却", - "allModelCooldownsCleared": "已清除 {{count}} 条模型冷却" + "allModelCooldownsCleared": "已清除 {{count}} 条模型冷却", + "providerViewClaude": "Claude" }, "invite": { "entry": "Codex 邀请", @@ -1733,18 +1734,18 @@ "activeRequests": "当前请求", "totalRequestsAccum": "运行期累计 {{count}}", "goroutines": "协程", - "goroutinesPool": "账号池 {{available}} / {{total}}", - "schedulerEngine": "调度引擎", - "schedulerSelections": "累计选号 {{count}}", - "schedulerShadowChecks": "抽样校验 {{checks}} 次 · 差异 {{mismatches}} 次", - "schedulerFastHit": "索引命中率", - "schedulerSlowScans": "旧路径扫描账号 {{count}} 次", - "schedulerWaiters": "等待选号请求", - "schedulerWakeups": "状态唤醒 {{count}} 次", - "schedulerRoutingCache": "路由子池缓存", - "schedulerRoutingCacheStats": "命中 {{hits}} 次 · 索引 {{accounts}} 个账号", - "schedulerOutbox": "调度事件积压", - "schedulerOutboxLag": "延迟 {{ms}}ms · 错误 {{errors}}", + "goroutinesPool": "账号池 {{available}} / {{total}}", + "schedulerEngine": "调度引擎", + "schedulerSelections": "累计选号 {{count}}", + "schedulerShadowChecks": "抽样校验 {{checks}} 次 · 差异 {{mismatches}} 次", + "schedulerFastHit": "索引命中率", + "schedulerSlowScans": "旧路径扫描账号 {{count}} 次", + "schedulerWaiters": "等待选号请求", + "schedulerWakeups": "状态唤醒 {{count}} 次", + "schedulerRoutingCache": "路由子池缓存", + "schedulerRoutingCacheStats": "命中 {{hits}} 次 · 索引 {{accounts}} 个账号", + "schedulerOutbox": "调度事件积压", + "schedulerOutboxLag": "延迟 {{ms}}ms · 错误 {{errors}}", "qps": "QPS", "qpsPeak": "峰值 {{value}}", "tps": "TPS", @@ -2106,22 +2107,32 @@ "cyberPolicyDetailTitle": "cyber_policy 触发详情", "cyberPolicyRequestContent": "触发拦截的请求内容", "cyberPolicyNoDetail": "未找到对应的请求内容记录(可能日志已清理,或时间相差过大)", - "cyberPolicyUnscored": "未评分", - "cyberPolicyLegacyInferred": "历史记录:该关联由时间窗口推断,可能不是精确匹配。", - "cyberPolicyLegacyUnknown": "历史记录:本地判定不可还原", - "cyberPolicyUpstreamResult": "上游结果", - "cyberPolicyLocalResult": "本地结果", - "cyberPolicyLocalMiss": "本地漏检", - "cyberPolicyExecutionScore": "执行分", - "cyberPolicyAuditScore": "审计分", - "cyberPolicyTransport": "协议 / 传输", - "cyberPolicyAccountAttempt": "账号 / 重试序号", - "cyberPolicyReview": "二次审查", - "cyberPolicyCandidate": "候选状态", - "cyberPolicyReason": "原因", - "cyberPolicyMatches": "命中规则及权重", - "cyberPolicyState": { "completed": "已完成", "not_run": "未运行", "unavailable": "无法提取", "legacy_unknown": "历史未知" }, - "cyberPolicyOutcome": { "no_hit": "未命中", "audit_hit": "审计命中", "warn": "警告", "block": "拦截" }, + "cyberPolicyUnscored": "未评分", + "cyberPolicyLegacyInferred": "历史记录:该关联由时间窗口推断,可能不是精确匹配。", + "cyberPolicyLegacyUnknown": "历史记录:本地判定不可还原", + "cyberPolicyUpstreamResult": "上游结果", + "cyberPolicyLocalResult": "本地结果", + "cyberPolicyLocalMiss": "本地漏检", + "cyberPolicyExecutionScore": "执行分", + "cyberPolicyAuditScore": "审计分", + "cyberPolicyTransport": "协议 / 传输", + "cyberPolicyAccountAttempt": "账号 / 重试序号", + "cyberPolicyReview": "二次审查", + "cyberPolicyCandidate": "候选状态", + "cyberPolicyReason": "原因", + "cyberPolicyMatches": "命中规则及权重", + "cyberPolicyState": { + "completed": "已完成", + "not_run": "未运行", + "unavailable": "无法提取", + "legacy_unknown": "历史未知" + }, + "cyberPolicyOutcome": { + "no_hit": "未命中", + "audit_hit": "审计命中", + "warn": "警告", + "block": "拦截" + }, "statusErrorEmpty": "未记录具体错误信息", "clearFilters": "清除筛选", "showAnalysis": "显示分析", @@ -2194,7 +2205,7 @@ "identityOnly": "仅身份目录", "noAttributedRequests": "暂无可归因请求", "profileState": "画像状态", - "identitySource": "身份导入来源", + "identitySource": "身份来源", "identityConfidence": "身份可信度", "miss": "漏检", "block": "拦截", @@ -2217,15 +2228,34 @@ "apiKeyProfiles": "API Key 画像", "upstreamAccountProfiles": "上游账号画像", "activityState": "活动状态", - "activityStates": { "active": "有请求记录", "identityOnly": "仅身份记录" }, + "activityStates": { + "active": "有请求记录", + "identityOnly": "仅身份记录" + }, "peopleProfilesHint": "默认只展示可追溯到 NewAPI 用户 ID 的人员画像。", "nonPersonHint": "会话、Key、网络和上游账号是环境对象,用于定位链路,不代表某个具体人员。", - "identityKinds": { "newapi_user": "已验证人员", "unverified_user": "未验证身份", "session": "会话 / 指纹", "api_key": "API Key", "client_ip": "客户端 IP", "upstream_account": "上游账号" }, - "identitySource": "身份来源", + "identityKinds": { + "newapi_user": "已验证人员", + "unverified_user": "未验证身份", + "session": "会话 / 指纹", + "api_key": "API Key", + "client_ip": "客户端 IP", + "upstream_account": "上游账号" + }, "freezeStatus": "冻结状态", - "freezeStates": { "none": "未冻结", "conversation": "会话锁", "user_cooldown": "人员冷却", "fingerprint_replay": "指纹重放冷却" }, + "freezeStates": { + "none": "未冻结", + "conversation": "会话锁", + "user_cooldown": "人员冷却", + "fingerprint_replay": "指纹重放冷却" + }, "freezeHint": "当前没有活动限制", - "summary": { "total": "符合条件总数", "frozen": "本页冻结", "cy": "本页 CY", "highCritical": "高危 / 严重" }, + "summary": { + "total": "符合条件总数", + "frozen": "本页冻结", + "cy": "本页 CY", + "highCritical": "高危 / 严重" + }, "conversationLock": { "title": "CYB 会话锁", "active": "对话已锁定", @@ -2269,7 +2299,10 @@ "bypassCount": "已节省同步复核", "lastEvaluation": "最近周期评判", "source": "策略来源", - "sources": {"manual":"管理员设置","automatic":"画像自动启用"}, + "sources": { + "manual": "管理员设置", + "automatic": "画像自动启用" + }, "modelReviewCount": "周期模型复核次数", "lastModelReview": "最近模型复核", "basisTitle": "自适应模型审核判断依据", @@ -2284,7 +2317,15 @@ "nextForcedReview": "下次最迟模型复核", "reviewDueNow": "现在必须复核", "basisFallbackReason": "系统会根据干净复核数量、观察时长、当前风险和周期抽检规则持续评判。", - "decisions": {"disabled":"自适应审核未启用","not_person":"非可信人员身份","adaptive_active":"自适应降频中","suspended":"已恢复完整审核","eligible":"已满足启用条件","building_history":"正在积累低风险证据","unavailable":"判断依据不可用"}, + "decisions": { + "disabled": "自适应审核未启用", + "not_person": "非可信人员身份", + "adaptive_active": "自适应降频中", + "suspended": "已恢复完整审核", + "eligible": "已满足启用条件", + "building_history": "正在积累低风险证据", + "unavailable": "判断依据不可用" + }, "notEnabled": "当前没有启用临时信任。", "personOnly": "只有经过签名确认的人员画像可以启用临时信任。", "history": "模型审核决策与运维记录", @@ -2297,13 +2338,66 @@ "duration": "有效时长", "reasonHint": "必填;用于审计是谁、为什么、在多长时间内降低首字延迟。", "safetyHint": "可信期间仍执行本地高危规则;只有干净请求跳过同步 DS。风险分达到阈值、出现本地高危判定或上游 CY 时立即失效。", - "status": {"active":"临时信任中","suspended":"已自动暂停","revoked":"已撤销","expired":"已到期"}, - "events": {"granted":"已启用","auto_granted":"画像自动启用","reactivated":"重新启用","suspended":"已暂停","auto_suspended":"风险触发自动暂停","revoked":"管理员撤销","expired":"到期失效","bypass_used":"干净请求跳过同步复核","model_reviewed":"周期模型复核通过","evaluated":"周期评判"} + "status": { + "active": "临时信任中", + "suspended": "已自动暂停", + "revoked": "已撤销", + "expired": "已到期" + }, + "events": { + "granted": "已启用", + "auto_granted": "画像自动启用", + "reactivated": "重新启用", + "suspended": "已暂停", + "auto_suspended": "风险触发自动暂停", + "revoked": "管理员撤销", + "expired": "到期失效", + "bypass_used": "干净请求跳过同步复核", + "model_reviewed": "周期模型复核通过", + "evaluated": "周期评判" + } + }, + "subjects": { + "newapi_user": "NewAPI 用户", + "session": "会话", + "api_key": "API Key", + "client_ip": "客户端网络", + "upstream_account": "上游账号" + }, + "levels": { + "low": "低", + "observed": "观察", + "elevated": "升高", + "high": "高", + "critical": "严重" }, - "subjects": {"newapi_user":"NewAPI 用户","session":"会话","api_key":"API Key","client_ip":"客户端网络","upstream_account":"上游账号"}, - "levels": {"low":"低","observed":"观察","elevated":"升高","high":"高","critical":"严重"}, - "actions": {"observe":"保持观察","monitor":"持续监控","enhanced_review":"加强审核","rate_limit":"评估限速","require_signed_identity":"要求可信签名身份","temporary_restriction_review":"评估临时限制","account_rotation_review":"评估账号轮换","account_routing_review":"检查账号路由"}, - "events": {"review_cleared":"复核通过","review_flagged_monitor":"模型命中(监控模式)","local_block_strike":"本地终局拦截","local_block":"历史未确认拦截","local_block_unverified":"本地拦截(待确认)","local_block_cleared":"本地拦截(已纠正)","local_warn":"本地警告","local_audit_hit":"本地审计命中","local_security_context_observed":"安全测试上下文观察","upstream_cy_confirmed_miss":"上游 CY:确认漏检","upstream_cy_local_detected":"上游 CY:本地已识别","upstream_cy_upstream_only":"上游 CY:仅上游证据","upstream_cy_evidence_unavailable":"上游 CY:证据不可用","upstream_cy_not_comparable":"上游 CY:不可比较","upstream_cy_legacy_unknown":"上游 CY:历史未知"} + "actions": { + "observe": "保持观察", + "monitor": "持续监控", + "enhanced_review": "加强审核", + "rate_limit": "评估限速", + "require_signed_identity": "要求可信签名身份", + "temporary_restriction_review": "评估临时限制", + "account_rotation_review": "评估账号轮换", + "account_routing_review": "检查账号路由" + }, + "events": { + "review_cleared": "复核通过", + "review_flagged_monitor": "模型命中(监控模式)", + "local_block_strike": "本地终局拦截", + "local_block": "历史未确认拦截", + "local_block_unverified": "本地拦截(待确认)", + "local_block_cleared": "本地拦截(已纠正)", + "local_warn": "本地警告", + "local_audit_hit": "本地审计命中", + "local_security_context_observed": "安全测试上下文观察", + "upstream_cy_confirmed_miss": "上游 CY:确认漏检", + "upstream_cy_local_detected": "上游 CY:本地已识别", + "upstream_cy_upstream_only": "上游 CY:仅上游证据", + "upstream_cy_evidence_unavailable": "上游 CY:证据不可用", + "upstream_cy_not_comparable": "上游 CY:不可比较", + "upstream_cy_legacy_unknown": "上游 CY:历史未知" + } }, "loadingTitle": "正在加载 Prompt 检查", "loadingDesc": "规则配置和触发日志正在同步。", @@ -2362,8 +2456,12 @@ "recommendedAppliedWithStrength": "已应用“{{strength}}”推荐配置,请确认后保存", "recommendedStrengthTitle": "推荐防护力度", "recommendedStrength": { - "monitor": { "label": "仅监控" }, - "block": { "label": "拦截请求" } + "monitor": { + "label": "仅监控" + }, + "block": { + "label": "拦截请求" + } }, "simpleConfigHint": "首页只保留开关与模式;阈值、层级、提示词和 Payload 已集中到一个高级配置入口。", "advancedTitle": "Prompt 高级配置", @@ -2885,7 +2983,12 @@ "executionScore": "执行分", "conversationLockTTL": "会话锁有效期(小时)", "executionScoreHint": "执行分与当前阈值共同决定本次请求是否进入警告或拦截流程。模型复核也可能在执行分为 0 时直接拦截。", - "decisionSource": {"model":"模型复核拦截","local":"本地规则拦截","combined":"本地规则 + 模型复核","conversation":"CY 会话锁拦截"}, + "decisionSource": { + "model": "模型复核拦截", + "local": "本地规则拦截", + "combined": "本地规则 + 模型复核", + "conversation": "CY 会话锁拦截" + }, "shadowAuditScore": "影子审计分", "shadowAuditScoreHint": "影子审计分不参与当前拦截。", "colMatch": "命中", @@ -2928,83 +3031,119 @@ "resetFilters": "重置筛选", "recordsCount": "共 {{count}} 条", "noLogs": "暂无触发记录", - "auditRecordsCount": "CY 事件 {{incidents}} 条,模型复核 {{reviews}} 条,本地审计 {{logs}} 条", - "cyberIncidentsTitle": "上游 CY 事件", - "auditHealth": { - "action": "审计链自检", - "title": "CY 审计链状态", - "description": "只读检查事件存储、模型复核、会话锁和高优先级审计队列;不会构造 CY 事件。", - "healthy": "运行正常", - "degraded": "需要关注", - "incidentCount": "已保存 {{count}} 条 CY 事件", - "storage": "事件存储", - "promptFilter": "Prompt 防护", - "modelReview": "模型复核", - "reviewKeys": "可用审核 Key", - "fallbackLocal": "全部 Key 不可用时:仅保留本地高危规则(推荐)", - "fallbackBlock": "全部 Key 不可用时:阻断所有需复核请求", - "reviewPoolDetail": "冷却中 {{cooling}} 个,恢复探测中 {{probing}} 个", - "nextRetry": "下次恢复探测 {{time}}", - "conversationLock": "CY 会话锁", - "queuePending": "队列待处理", - "queueFailures": "高优先级丢弃 / 失败", - "latestIncident": "最近一次已落库事件", - "noIncident": "审计链已就绪,但当前尚无已落库 CY 事件。" - }, - "reviewHistoryTitle": "外部模型复核历史", - "reviewHistoryDesc": "记录每次已执行的模型复核;仅保存脱敏请求预览和解析后的返回判定,不保存审核 Key、Authorization 或原始 Payload。", - "reviewResultFilter": "复核结果", - "reviewHistoryEmpty": "暂无模型复核记录", - "reviewRequest": "模型审核请求", - "reviewResponse": "模型返回判定", - "reviewFinalAction": "最终动作", - "reviewHistoryScope": "Key / 人员 / 关联 ID", - "reviewRequestUnavailable": "请求预览不可用", - "reviewResultError": "复核失败", - "notScored": "未评分", - "localAuditLogsTitle": "本地过滤与异步审计日志", - "refreshAllLogs": "刷新全部日志", - "clearCyberIncidents": "清空 CY 事件", - "deleteCyberIncident": "删除这条 CY 记录", - "deleteCyberIncidentConfirm": "确定删除这条 CY 记录吗?风险画像和学习证据会保留。", - "clearReviewLogs": "清空复核历史", - "clearLocalLogs": "清空本地日志", - "cyberIncidentsCleared": "上游 CY 事件已清空,风险画像已保留", - "cyberIncidentDeleted": "CY 记录已删除,风险画像和学习证据已保留", - "reviewLogsCleared": "外部模型复核历史已清空,风险画像已保留", - "localLogsCleared": "本地过滤与异步审计日志已清空,风险画像已保留", - "logSummaryRefreshFailed": "日志已清空,但概览摘要刷新失败", - "sectionRefreshHint": "筛选、翻页和刷新仅更新当前区域。", - "noCyberIncidents": "暂无上游 CY 事件", - "cyberUpstream": "上游结果", - "cyberLocalResult": "本地结果", - "cyberComparison": "本地与上游归因", - "cyberSourceKey": "来源 Key", - "cyberAccount": "作用账号 / 分组", - "cyberAccountPlatform": "账号平台", - "cyberRoutingSource": "账号与分组信息来源", - "cyberRoutingState": { "event_snapshot": "事件发生时快照", "current_inferred": "历史记录:按当前账号目录补全", "unavailable": "无法还原" }, - "cyberGroups": "事件时账号分组", - "cyberKeyAllowedGroups": "事件时 Key 可用分组", - "cyberPromptAvailable": "关联 Prompt 可用", - "cyberAttempt": "传输 / 序号", - "cyberDetail": "详情", - "cyberDetailTitle": "上游 CY 事件详情", - "cyberLocalMiss": "本地漏检", - "cyberUnscored": "未评分", - "cyberLegacyUnknown": "历史记录:本地判定不可还原", - "cyberProtocolTransport": "协议 / 传输", - "cyberAccountAttempt": "账号 / 重试序号", - "cyberCandidate": "候选状态", - "cyberReason": "原因", - "cyberState": { "completed": "已完成", "not_run": "未运行", "unavailable": "无法提取", "legacy_unknown": "历史未知" }, - "cyberOutcome": { "no_hit": "未命中", "audit_hit": "审计命中", "warn": "警告", "block": "拦截" }, - "cyberComparisonStatus": { "confirmed_miss": "确认本地漏检", "upstream_only": "仅上游命中", "evidence_unavailable": "证据不足,无法判断", "local_detected": "本地已检测", "not_comparable": "不可比较", "legacy_unknown": "历史不可还原" }, - "newapiPolicyStatus": { "unbound": "NewAPI 未绑定", "binding_disabled": "NewAPI 绑定已停用", "unsigned_request": "NewAPI 请求未签名", "verification_failed": "NewAPI 验签失败", "verified": "NewAPI 已验签", "signed_response": "NewAPI 审计已签名透传" }, - "newapiUser": "用户", - "newapiRequest": "请求", - "sources": { "local_filter": "本地过滤", "upstream_cyber_policy": "上游 cyber_policy(历史)" }, - "labels": { "strike": "计入违规", "upstream": "上游", "reviewFlagged": "审查判定违规", "reviewCleared": "审查判定通过" }, + "auditRecordsCount": "CY 事件 {{incidents}} 条,模型复核 {{reviews}} 条,本地审计 {{logs}} 条", + "cyberIncidentsTitle": "上游 CY 事件", + "auditHealth": { + "action": "审计链自检", + "title": "CY 审计链状态", + "description": "只读检查事件存储、模型复核、会话锁和高优先级审计队列;不会构造 CY 事件。", + "healthy": "运行正常", + "degraded": "需要关注", + "incidentCount": "已保存 {{count}} 条 CY 事件", + "storage": "事件存储", + "promptFilter": "Prompt 防护", + "modelReview": "模型复核", + "reviewKeys": "可用审核 Key", + "fallbackLocal": "全部 Key 不可用时:仅保留本地高危规则(推荐)", + "fallbackBlock": "全部 Key 不可用时:阻断所有需复核请求", + "reviewPoolDetail": "冷却中 {{cooling}} 个,恢复探测中 {{probing}} 个", + "nextRetry": "下次恢复探测 {{time}}", + "conversationLock": "CY 会话锁", + "queuePending": "队列待处理", + "queueFailures": "高优先级丢弃 / 失败", + "latestIncident": "最近一次已落库事件", + "noIncident": "审计链已就绪,但当前尚无已落库 CY 事件。" + }, + "reviewHistoryTitle": "外部模型复核历史", + "reviewHistoryDesc": "记录每次已执行的模型复核;仅保存脱敏请求预览和解析后的返回判定,不保存审核 Key、Authorization 或原始 Payload。", + "reviewResultFilter": "复核结果", + "reviewHistoryEmpty": "暂无模型复核记录", + "reviewRequest": "模型审核请求", + "reviewResponse": "模型返回判定", + "reviewFinalAction": "最终动作", + "reviewHistoryScope": "Key / 人员 / 关联 ID", + "reviewRequestUnavailable": "请求预览不可用", + "reviewResultError": "复核失败", + "notScored": "未评分", + "localAuditLogsTitle": "本地过滤与异步审计日志", + "refreshAllLogs": "刷新全部日志", + "clearCyberIncidents": "清空 CY 事件", + "deleteCyberIncident": "删除这条 CY 记录", + "deleteCyberIncidentConfirm": "确定删除这条 CY 记录吗?风险画像和学习证据会保留。", + "clearReviewLogs": "清空复核历史", + "clearLocalLogs": "清空本地日志", + "cyberIncidentsCleared": "上游 CY 事件已清空,风险画像已保留", + "cyberIncidentDeleted": "CY 记录已删除,风险画像和学习证据已保留", + "reviewLogsCleared": "外部模型复核历史已清空,风险画像已保留", + "localLogsCleared": "本地过滤与异步审计日志已清空,风险画像已保留", + "logSummaryRefreshFailed": "日志已清空,但概览摘要刷新失败", + "sectionRefreshHint": "筛选、翻页和刷新仅更新当前区域。", + "noCyberIncidents": "暂无上游 CY 事件", + "cyberUpstream": "上游结果", + "cyberLocalResult": "本地结果", + "cyberComparison": "本地与上游归因", + "cyberSourceKey": "来源 Key", + "cyberAccount": "作用账号 / 分组", + "cyberAccountPlatform": "账号平台", + "cyberRoutingSource": "账号与分组信息来源", + "cyberRoutingState": { + "event_snapshot": "事件发生时快照", + "current_inferred": "历史记录:按当前账号目录补全", + "unavailable": "无法还原" + }, + "cyberGroups": "事件时账号分组", + "cyberKeyAllowedGroups": "事件时 Key 可用分组", + "cyberPromptAvailable": "关联 Prompt 可用", + "cyberAttempt": "传输 / 序号", + "cyberDetail": "详情", + "cyberDetailTitle": "上游 CY 事件详情", + "cyberLocalMiss": "本地漏检", + "cyberUnscored": "未评分", + "cyberLegacyUnknown": "历史记录:本地判定不可还原", + "cyberProtocolTransport": "协议 / 传输", + "cyberAccountAttempt": "账号 / 重试序号", + "cyberCandidate": "候选状态", + "cyberReason": "原因", + "cyberState": { + "completed": "已完成", + "not_run": "未运行", + "unavailable": "无法提取", + "legacy_unknown": "历史未知" + }, + "cyberOutcome": { + "no_hit": "未命中", + "audit_hit": "审计命中", + "warn": "警告", + "block": "拦截" + }, + "cyberComparisonStatus": { + "confirmed_miss": "确认本地漏检", + "upstream_only": "仅上游命中", + "evidence_unavailable": "证据不足,无法判断", + "local_detected": "本地已检测", + "not_comparable": "不可比较", + "legacy_unknown": "历史不可还原" + }, + "newapiPolicyStatus": { + "unbound": "NewAPI 未绑定", + "binding_disabled": "NewAPI 绑定已停用", + "unsigned_request": "NewAPI 请求未签名", + "verification_failed": "NewAPI 验签失败", + "verified": "NewAPI 已验签", + "signed_response": "NewAPI 审计已签名透传" + }, + "newapiUser": "用户", + "newapiRequest": "请求", + "sources": { + "local_filter": "本地过滤", + "upstream_cyber_policy": "上游 cyber_policy(历史)" + }, + "labels": { + "strike": "计入违规", + "upstream": "上游", + "reviewFlagged": "审查判定违规", + "reviewCleared": "审查判定通过" + }, "rulesCatalogTitle": "内置规则", "rulesCatalogDesc": "这些规则来自后端内置规则集,可单独开启或关闭。", "ruleHelp": "规则说明", @@ -3518,7 +3657,7 @@ "showFullUsageNumbers": "显示完整用量数字", "showFullUsageNumbersDesc": "开启后 Usage 页与 API Keys「Token 用量」中的请求数、Tokens 等显示完整数字;关闭时使用 1.2K、3.4M 等紧凑单位。", "fastSchedulerEnabled": "快速调度器", - "fastSchedulerEnabledDesc": "启用后,账号选择将使用内存快速调度算法,大幅降低高并发场景下的调度延迟。适合大号池(100+ 账号)场景。", + "fastSchedulerEnabledDesc": "启用后,账号选择将使用内存快速调度算法,大幅降低高并发场景下的调度延迟。适合大号池(100+ 账号)场景。", "schedulerEngine": "调度引擎", "schedulerEngineDesc": "决定新请求如何从账号池中选择账号,切换后立即生效,无需重启。", "schedulerEngineCompatibilityTitle": "旧版配置对应关系", @@ -3842,24 +3981,24 @@ "heroDesc": "标准价一目了然,高级通道按需展开。同步远程源时不会覆盖你的自定义价格。", "syncTitle": "价格同步", "syncSubtitle": "优先同步 OpenAI/xAI 官方价格;models.dev 与 JSON 源保留为人工参考,不会覆盖自定义价格。", - "officialTitle": "官方价格同步", - "authoritative": "权威来源", - "officialDesc": "直接读取 OpenAI 与 xAI 官方价目,包含标准、缓存、输出、长上下文和 Fast(Priority)价格。", - "officialSyncNow": "立即同步官方价", - "officialSyncDone": "官方价格同步完成:写入 {{applied}},保留自定义 {{skipped}}", - "officialConfigSaved": "官方价格轮询设置已保存", - "autoOfficialSync": "定时获取官方价格", - "autoOfficialSyncHint": "默认关闭;网络获取完成后才短暂写入数据库。", - "intervalMinutes": "间隔(分钟)", - "intervalHour": "每 1 小时", - "intervalSixHours": "每 6 小时", - "intervalTwelveHours": "每 12 小时", - "intervalDay": "每天", - "intervalThreeDays": "每 3 天", - "intervalWeek": "每周", - "lastOfficialSuccess": "最近成功同步", - "lastWarning": "最近警告", - "referenceTitle": "参考 JSON 来源(手动)", + "officialTitle": "官方价格同步", + "authoritative": "权威来源", + "officialDesc": "直接读取 OpenAI 与 xAI 官方价目,包含标准、缓存、输出、长上下文和 Fast(Priority)价格。", + "officialSyncNow": "立即同步官方价", + "officialSyncDone": "官方价格同步完成:写入 {{applied}},保留自定义 {{skipped}}", + "officialConfigSaved": "官方价格轮询设置已保存", + "autoOfficialSync": "定时获取官方价格", + "autoOfficialSyncHint": "默认关闭;网络获取完成后才短暂写入数据库。", + "intervalMinutes": "间隔(分钟)", + "intervalHour": "每 1 小时", + "intervalSixHours": "每 6 小时", + "intervalTwelveHours": "每 12 小时", + "intervalDay": "每天", + "intervalThreeDays": "每 3 天", + "intervalWeek": "每周", + "lastOfficialSuccess": "最近成功同步", + "lastWarning": "最近警告", + "referenceTitle": "参考 JSON 来源(手动)", "syncUrl": "同步来源 URL", "presets": "预设来源", "presetDefault": "项目默认", @@ -3910,14 +4049,14 @@ "shortCached": "缓存", "shortOutput": "输出", "shortInputPriority": "输入 · P", - "shortCachedInputPriority": "缓存 · P", + "shortCachedInputPriority": "缓存 · P", "shortOutputPriority": "输出 · P", "shortInputLong": "输入 · 长", - "shortCachedInputLong": "缓存 · 长", + "shortCachedInputLong": "缓存 · 长", "shortOutputLong": "输出 · 长", - "shortInputLongPriority": "输入 · 长P", - "shortCachedInputLongPriority": "缓存 · 长P", - "shortOutputLongPriority": "输出 · 长P", + "shortInputLongPriority": "输入 · 长P", + "shortCachedInputLongPriority": "缓存 · 长P", + "shortOutputLongPriority": "输出 · 长P", "groupStandard": "标准价格", "groupStandardHint": "常规输入 / 缓存 / 输出", "groupPriority": "Priority", @@ -3931,14 +4070,14 @@ "cached": "缓存输入", "output": "输出", "inputPriority": "输入 · Priority", - "cachedInputPriority": "缓存输入 · Priority/Fast", + "cachedInputPriority": "缓存输入 · Priority/Fast", "outputPriority": "输出 · Priority", "inputLong": "输入 · 长上下文", - "cachedInputLong": "缓存输入 · 长上下文", + "cachedInputLong": "缓存输入 · 长上下文", "outputLong": "输出 · 长上下文", - "inputLongPriority": "输入 · 长上下文 · Fast", - "cachedInputLongPriority": "缓存输入 · 长上下文 · Fast", - "outputLongPriority": "输出 · 长上下文 · Fast", + "inputLongPriority": "输入 · 长上下文 · Fast", + "cachedInputLongPriority": "缓存输入 · 长上下文 · Fast", + "outputLongPriority": "输出 · 长上下文 · Fast", "source": { "custom": "自定义", "synced": "已同步", @@ -4149,7 +4288,8 @@ "testFailedUnknown": "未知错误", "pagination": "共 {{total}} 个代理,第 {{page}}/{{totalPages}} 页", "showProxyUrl": "显示代理地址", - "hideProxyUrl": "隐藏代理地址" + "hideProxyUrl": "隐藏代理地址", + "idle": "空闲" }, "apiKeys": { "title": "API 密钥", @@ -4467,9 +4607,9 @@ "keyAccountsErrUnit": "err", "keyGroupsTitle": "当前分组用量汇总", "keyGroupsAccounts": "{{count}} 个账号", - "keyGroupsAccountCost": "上游成本", - "keyGroupsBilled": "下游计费", - "keyGroupsCurrentHint": "活跃账号按当前分组、回收站账号按删除前保留的分组汇总;同一账号属于多个分组时会计入每个分组。", + "keyGroupsAccountCost": "上游成本", + "keyGroupsBilled": "下游计费", + "keyGroupsCurrentHint": "活跃账号按当前分组、回收站账号按删除前保留的分组汇总;同一账号属于多个分组时会计入每个分组。", "keyAccountDeleted": "已删除", "keyAccountDeletedHint": "该上游账号已进入回收站,但历史用量仍计入统计。", "keyAccountUngrouped": "未分组", @@ -5214,5 +5354,31 @@ "s5": "确认已保存;必要时在 JSON 模式核对最终配置。" } } + }, + "claude": { + "title": "Claude 账号", + "subtitle": "Claude Code(Anthropic)OAuth 订阅账号池", + "addAccount": "添加 Claude 账号", + "empty": "暂无 Claude 账号", + "tabOAuth": "网页授权", + "tabImport": "导入 Token", + "step1": "第一步:点击生成授权链接并在浏览器完成授权", + "genAuthUrl": "生成授权链接", + "openAuth": "打开授权页", + "step2": "第二步:粘贴回调地址栏里的 URL 或 code", + "callbackPlaceholder": "http://localhost:54545/callback?code=... 或直接粘 code", + "namePlaceholder": "账号备注(可选)", + "proxyLabel": "代理(可选)", + "useProxyPool": "从代理池自动分配一条空闲代理", + "exchange": "完成登录并添加", + "importHint": "粘贴 cmd/claude_login -out 生成的 token JSON", + "importPlaceholder": "{ \"access_token\": \"...\", \"refresh_token\": \"...\" }", + "import": "导入并添加", + "added": "已添加 Claude 账号", + "invalidJson": "token JSON 解析失败", + "authUrlFailed": "生成授权链接失败", + "exchangeFailed": "换取 token 失败", + "deleteConfirm": "确认删除该 Claude 账号?", + "timezonePlaceholder": "时区,如 Asia/Shanghai(留空=不指定)" } -} +} \ No newline at end of file diff --git a/frontend/src/pages/Accounts.tsx b/frontend/src/pages/Accounts.tsx index 5b8e9113..f26f40ca 100644 --- a/frontend/src/pages/Accounts.tsx +++ b/frontend/src/pages/Accounts.tsx @@ -12,6 +12,7 @@ import OperationResultsModal from "../components/OperationResultsModal"; import { cn } from "@/lib/utils"; import GrokAccounts from "./GrokAccounts"; import AntigravityAccounts from "./AntigravityAccounts"; +import ClaudeAccounts from "./ClaudeAccounts"; import { mergeAccountLiveState, useAccountLiveState } from "../hooks/useAccountLiveState"; import PageHeader from "../components/PageHeader"; import { CompactStat } from "../components/CompactStat"; @@ -1631,7 +1632,9 @@ export default function Accounts() { ? "grok" : normalizedPath.endsWith("/accounts/antigravity") ? "antigravity" - : "codex"; + : normalizedPath.endsWith("/accounts/claude") + ? "claude" + : "codex"; const setProviderView = useCallback( (view: UpstreamChannel) => { navigate( @@ -1639,7 +1642,9 @@ export default function Accounts() { ? "/accounts/grok" : view === "antigravity" ? "/accounts/antigravity" - : "/accounts", + : view === "claude" + ? "/accounts/claude" + : "/accounts", ); }, [navigate], @@ -5800,12 +5805,12 @@ export default function Accounts() { // 滑块动画 + 品牌 logo,与仪表盘渠道过滤器视觉一致。 // useMemo 保持引用稳定,否则每轮渲染的新元素会击穿独立账号页的 memo 边界。 const providerSwitcher = useMemo(() => ( -
+
{( @@ -5813,6 +5818,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[]; @@ -443,6 +447,8 @@ function AccountMetadataFields({ onChange={(event) => onProxyUrlChange(event.target.value)} placeholder={t("antigravity.proxyUrlPlaceholder")} /> + {/* 从代理池选择:展示每条代理已绑定账号数/空闲,选中写入上面的输入框。 */} +
@@ -848,6 +854,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], @@ -2149,6 +2171,7 @@ function AntigravityAccounts({ headerSlot }: { headerSlot?: ReactNode } = {}) { setOAuthDraft((current) => ({ ...current, proxyUrl })) } @@ -2475,6 +2498,7 @@ function AntigravityAccounts({ headerSlot }: { headerSlot?: ReactNode } = {}) { setImportDraft((current) => ({ ...current, proxyUrl })) } @@ -2680,6 +2704,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 00000000..827e750f --- /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 = ( +
+ + {t("claude.proxyLabel")} + + setProxyUrl(e.target.value)} + placeholder="http://127.0.0.1:7890" + disabled={useProxyPool} + /> + + + setName(e.target.value)} + placeholder={t("claude.namePlaceholder")} + /> + setTimezone(e.target.value)} + placeholder={t("claude.timezonePlaceholder")} + /> +
+ ); + + return ( + + + {tab === "oauth" ? ( + + ) : ( + + )} +
+ } + > +
+
+ + +
+ + {tab === "oauth" ? ( +
+

{t("claude.step1")}

+
+ + {authUrl ? ( + + {t("claude.openAuth")} + + ) : null} +
+

{t("claude.step2")}

+ setCallback(e.target.value)} + placeholder={t("claude.callbackPlaceholder")} + /> + {proxyFields} +
+ ) : ( +
+

{t("claude.importHint")}

+