Merge pull request #183 from CJackHwang/dev

Merge pull request #182 from CJackHwang/codex/investigate-potential-issues

Fix account pool starvation when only one managed account has token
This commit is contained in:
CJACK.
2026-04-02 20:17:35 +08:00
committed by GitHub
32 changed files with 990 additions and 227 deletions

View File

@@ -166,8 +166,9 @@ go run ./cmd/ds2api
### 方式二Docker 运行
```bash
# 1. 准备环境变量文件
# 1. 准备环境变量和配置文件
cp .env.example .env
cp config.example.json config.json
# 2. 编辑 .env至少设置 DS2API_ADMIN_KEY
# DS2API_ADMIN_KEY=请替换为强密码

View File

@@ -166,8 +166,9 @@ Default URL: `http://localhost:5001`
### Option 2: Docker
```bash
# 1. Prepare env file
# 1. Prepare env file and config file
cp .env.example .env
cp config.example.json config.json
# 2. Edit .env (at least set DS2API_ADMIN_KEY)
# DS2API_ADMIN_KEY=replace-with-a-strong-secret

View File

@@ -111,8 +111,9 @@ go build -o ds2api ./cmd/ds2api
### 2.1 Basic Steps
```bash
# Copy env template
# Copy env template and config file
cp .env.example .env
cp config.example.json config.json
# Edit .env and set at least:
# DS2API_ADMIN_KEY=your-admin-key

View File

@@ -111,8 +111,9 @@ go build -o ds2api ./cmd/ds2api
### 2.1 基本步骤
```bash
# 复制环境变量模板
# 复制环境变量模板和配置文件
cp .env.example .env
cp config.example.json config.json
# 编辑 .env请改成你的强密码至少设置
# DS2API_ADMIN_KEY=your-admin-key

View File

@@ -60,16 +60,10 @@ func (p *Pool) acquireLocked(target string, exclude map[string]bool) (config.Acc
return acc, true
}
if acc, ok := p.tryAcquire(exclude, true); ok {
return acc, true
}
if acc, ok := p.tryAcquire(exclude, false); ok {
return acc, true
}
return config.Account{}, false
return p.tryAcquire(exclude)
}
func (p *Pool) tryAcquire(exclude map[string]bool, requireToken bool) (config.Account, bool) {
func (p *Pool) tryAcquire(exclude map[string]bool) (config.Account, bool) {
for i := 0; i < len(p.queue); i++ {
id := p.queue[i]
if exclude[id] || !p.canAcquireIDLocked(id) {
@@ -79,9 +73,6 @@ func (p *Pool) tryAcquire(exclude map[string]bool, requireToken bool) (config.Ac
if !ok {
continue
}
if requireToken && acc.Token == "" {
continue
}
p.inUse[id]++
p.bumpQueue(id)
return acc, true

View File

@@ -215,6 +215,33 @@ func TestPoolDropsLegacyTokenOnlyAccountOnLoad(t *testing.T) {
}
}
func TestPoolAcquireRotatesIntoTokenlessAccounts(t *testing.T) {
t.Setenv("DS2API_ACCOUNT_MAX_INFLIGHT", "1")
t.Setenv("DS2API_ACCOUNT_CONCURRENCY", "")
t.Setenv("DS2API_ACCOUNT_MAX_QUEUE", "")
t.Setenv("DS2API_ACCOUNT_QUEUE_SIZE", "")
t.Setenv("DS2API_CONFIG_JSON", `{
"keys":["k1"],
"accounts":[
{"email":"acc1@example.com","token":"token1"},
{"email":"acc2@example.com","token":""},
{"email":"acc3@example.com","token":""}
]
}`)
pool := NewPool(config.LoadStore())
for i, want := range []string{"acc1@example.com", "acc2@example.com", "acc3@example.com"} {
acc, ok := pool.Acquire("", nil)
if !ok {
t.Fatalf("expected acquire success at step %d", i+1)
}
if got := acc.Identifier(); got != want {
t.Fatalf("unexpected account at step %d: got %q want %q", i+1, got, want)
}
pool.Release(acc.Identifier())
}
}
func TestPoolAcquireWaitQueuesAndSucceedsAfterRelease(t *testing.T) {
pool := newSingleAccountPoolForTest(t, "1")
first, ok := pool.Acquire("", nil)

View File

@@ -0,0 +1,97 @@
package claude
import (
"fmt"
"strings"
)
func hasSystemMessage(messages []any) bool {
for _, m := range messages {
msg, ok := m.(map[string]any)
if ok && msg["role"] == "system" {
return true
}
}
return false
}
func extractClaudeToolNames(tools []any) []string {
out := make([]string, 0, len(tools))
for _, t := range tools {
m, ok := t.(map[string]any)
if !ok {
continue
}
name, _, _ := extractClaudeToolMeta(m)
if name != "" {
out = append(out, name)
}
}
return out
}
func extractClaudeToolMeta(m map[string]any) (string, string, any) {
name, _ := m["name"].(string)
desc, _ := m["description"].(string)
schemaObj := m["input_schema"]
if schemaObj == nil {
schemaObj = m["parameters"]
}
if fn, ok := m["function"].(map[string]any); ok {
if strings.TrimSpace(name) == "" {
name, _ = fn["name"].(string)
}
if strings.TrimSpace(desc) == "" {
desc, _ = fn["description"].(string)
}
if schemaObj == nil {
if v, ok := fn["input_schema"]; ok {
schemaObj = v
}
}
if schemaObj == nil {
if v, ok := fn["parameters"]; ok {
schemaObj = v
}
}
}
return strings.TrimSpace(name), strings.TrimSpace(desc), schemaObj
}
func toMessageMaps(v any) []map[string]any {
arr, ok := v.([]any)
if !ok {
return nil
}
out := make([]map[string]any, 0, len(arr))
for _, item := range arr {
if m, ok := item.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
func extractMessageContent(v any) string {
switch x := v.(type) {
case string:
return x
case []any:
parts := make([]string, 0, len(x))
for _, it := range x {
parts = append(parts, fmt.Sprintf("%v", it))
}
return strings.Join(parts, "\n")
default:
return fmt.Sprintf("%v", x)
}
}
func cloneMap(in map[string]any) map[string]any {
out := make(map[string]any, len(in))
for k, v := range in {
out[k] = v
}
return out
}

View File

@@ -225,6 +225,47 @@ func TestNormalizeClaudeMessagesToolResultNonTextPayloadStringified(t *testing.T
}
}
func TestNormalizeClaudeMessagesBackfillsToolResultCallIDByName(t *testing.T) {
msgs := []any{
map[string]any{
"role": "assistant",
"content": []any{
map[string]any{
"type": "tool_use",
"name": "search_web",
"input": map[string]any{"query": "latest"},
},
},
},
map[string]any{
"role": "user",
"content": []any{
map[string]any{
"type": "tool_result",
"name": "search_web",
"content": "ok",
},
},
},
}
got := normalizeClaudeMessages(msgs)
if len(got) != 2 {
t.Fatalf("expected 2 messages, got %#v", got)
}
assistant, _ := got[0].(map[string]any)
tc, _ := assistant["tool_calls"].([]any)
call, _ := tc[0].(map[string]any)
callID, _ := call["id"].(string)
if !strings.HasPrefix(callID, "call_claude_") {
t.Fatalf("expected generated call id, got %#v", call)
}
toolMsg, _ := got[1].(map[string]any)
if toolMsg["tool_call_id"] != callID {
t.Fatalf("expected tool_result to reuse generated id, got %#v", toolMsg)
}
}
// ─── buildClaudeToolPrompt ───────────────────────────────────────────
func TestBuildClaudeToolPromptSingleTool(t *testing.T) {

View File

@@ -11,6 +11,11 @@ import (
func normalizeClaudeMessages(messages []any) []any {
out := make([]any, 0, len(messages))
state := &claudeToolCallState{
nameByID: map[string]string{},
lastIDByName: map[string]string{},
callIDSequence: 0,
}
for _, m := range messages {
msg, ok := m.(map[string]any)
if !ok {
@@ -44,7 +49,7 @@ func normalizeClaudeMessages(messages []any) []any {
case "tool_use":
if role == "assistant" {
flushText()
if toolMsg := normalizeClaudeToolUseToAssistant(b); toolMsg != nil {
if toolMsg := normalizeClaudeToolUseToAssistant(b, state); toolMsg != nil {
out = append(out, toolMsg)
}
continue
@@ -54,7 +59,7 @@ func normalizeClaudeMessages(messages []any) []any {
}
case "tool_result":
flushText()
if toolMsg := normalizeClaudeToolResultToToolMessage(b); toolMsg != nil {
if toolMsg := normalizeClaudeToolResultToToolMessage(b, state); toolMsg != nil {
out = append(out, toolMsg)
}
default:
@@ -119,7 +124,7 @@ func formatClaudeToolResultForPrompt(block map[string]any) string {
return string(b)
}
func normalizeClaudeToolUseToAssistant(block map[string]any) map[string]any {
func normalizeClaudeToolUseToAssistant(block map[string]any, state *claudeToolCallState) map[string]any {
if block == nil {
return nil
}
@@ -127,13 +132,15 @@ func normalizeClaudeToolUseToAssistant(block map[string]any) map[string]any {
if name == "" {
return nil
}
callID := strings.TrimSpace(fmt.Sprintf("%v", block["id"]))
callID := safeStringValue(block["id"])
if callID == "" {
callID = strings.TrimSpace(fmt.Sprintf("%v", block["tool_use_id"]))
callID = safeStringValue(block["tool_use_id"])
}
if callID == "" {
callID = "call_claude"
callID = state.nextID()
}
state.nameByID[callID] = name
state.lastIDByName[strings.ToLower(name)] = callID
arguments := block["input"]
if arguments == nil {
arguments = map[string]any{}
@@ -159,24 +166,34 @@ func normalizeClaudeToolUseToAssistant(block map[string]any) map[string]any {
}
}
func normalizeClaudeToolResultToToolMessage(block map[string]any) map[string]any {
func normalizeClaudeToolResultToToolMessage(block map[string]any, state *claudeToolCallState) map[string]any {
if block == nil {
return nil
}
toolCallID := strings.TrimSpace(fmt.Sprintf("%v", block["tool_use_id"]))
name := safeStringValue(block["name"])
toolCallID := safeStringValue(block["tool_use_id"])
if toolCallID == "" {
toolCallID = strings.TrimSpace(fmt.Sprintf("%v", block["tool_call_id"]))
toolCallID = safeStringValue(block["tool_call_id"])
}
if toolCallID == "" {
toolCallID = "call_claude"
if name != "" {
toolCallID = strings.TrimSpace(state.lastIDByName[strings.ToLower(name)])
}
}
if toolCallID == "" {
toolCallID = state.nextID()
}
out := map[string]any{
"role": "tool",
"tool_call_id": toolCallID,
"content": normalizeClaudeToolResultContent(block["content"]),
}
if name := strings.TrimSpace(fmt.Sprintf("%v", block["name"])); name != "" {
if name != "" {
out["name"] = name
state.nameByID[toolCallID] = name
state.lastIDByName[strings.ToLower(name)] = toolCallID
} else if inferred := strings.TrimSpace(state.nameByID[toolCallID]); inferred != "" {
out["name"] = inferred
}
return out
}
@@ -206,94 +223,3 @@ func formatClaudeBlockRaw(block map[string]any) string {
}
return string(b)
}
func hasSystemMessage(messages []any) bool {
for _, m := range messages {
msg, ok := m.(map[string]any)
if ok && msg["role"] == "system" {
return true
}
}
return false
}
func extractClaudeToolNames(tools []any) []string {
out := make([]string, 0, len(tools))
for _, t := range tools {
m, ok := t.(map[string]any)
if !ok {
continue
}
name, _, _ := extractClaudeToolMeta(m)
if name != "" {
out = append(out, name)
}
}
return out
}
func extractClaudeToolMeta(m map[string]any) (string, string, any) {
name, _ := m["name"].(string)
desc, _ := m["description"].(string)
schemaObj := m["input_schema"]
if schemaObj == nil {
schemaObj = m["parameters"]
}
if fn, ok := m["function"].(map[string]any); ok {
if strings.TrimSpace(name) == "" {
name, _ = fn["name"].(string)
}
if strings.TrimSpace(desc) == "" {
desc, _ = fn["description"].(string)
}
if schemaObj == nil {
if v, ok := fn["input_schema"]; ok {
schemaObj = v
}
}
if schemaObj == nil {
if v, ok := fn["parameters"]; ok {
schemaObj = v
}
}
}
return strings.TrimSpace(name), strings.TrimSpace(desc), schemaObj
}
func toMessageMaps(v any) []map[string]any {
arr, ok := v.([]any)
if !ok {
return nil
}
out := make([]map[string]any, 0, len(arr))
for _, item := range arr {
if m, ok := item.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
func extractMessageContent(v any) string {
switch x := v.(type) {
case string:
return x
case []any:
parts := make([]string, 0, len(x))
for _, it := range x {
parts = append(parts, fmt.Sprintf("%v", it))
}
return strings.Join(parts, "\n")
default:
return fmt.Sprintf("%v", x)
}
}
func cloneMap(in map[string]any) map[string]any {
out := make(map[string]any, len(in))
for k, v := range in {
out[k] = v
}
return out
}

View File

@@ -0,0 +1,25 @@
package claude
import (
"fmt"
"strings"
)
type claudeToolCallState struct {
nameByID map[string]string
lastIDByName map[string]string
callIDSequence int
}
func (s *claudeToolCallState) nextID() string {
s.callIDSequence++
return fmt.Sprintf("call_claude_%d", s.callIDSequence)
}
func safeStringValue(v any) string {
s, ok := v.(string)
if !ok {
return ""
}
return strings.TrimSpace(s)
}

View File

@@ -1,11 +1,20 @@
package gemini
import "strings"
import (
"fmt"
"strings"
)
const maxGeminiRawPromptChars = 1024
func geminiMessagesFromRequest(req map[string]any) []any {
out := make([]any, 0, 8)
toolCallCounter := 0
nextToolCallID := func() string {
toolCallCounter++
return fmt.Sprintf("call_gemini_%d", toolCallCounter)
}
lastToolCallIDByName := map[string]string{}
if sys := normalizeGeminiSystemInstruction(req["systemInstruction"]); strings.TrimSpace(sys) != "" {
out = append(out, map[string]any{
"role": "system",
@@ -61,8 +70,11 @@ func geminiMessagesFromRequest(req map[string]any) []any {
if name := strings.TrimSpace(asString(fnCall["name"])); name != "" {
callID := strings.TrimSpace(asString(fnCall["id"]))
if callID == "" {
callID = "call_gemini"
if callID = strings.TrimSpace(asString(fnCall["call_id"])); callID == "" {
callID = nextToolCallID()
}
}
lastToolCallIDByName[strings.ToLower(name)] = callID
out = append(out, map[string]any{
"role": "assistant",
"tool_calls": []any{
@@ -91,7 +103,10 @@ func geminiMessagesFromRequest(req map[string]any) []any {
callID = strings.TrimSpace(asString(fnResp["tool_call_id"]))
}
if callID == "" {
callID = "call_gemini"
callID = strings.TrimSpace(lastToolCallIDByName[strings.ToLower(name)])
}
if callID == "" {
callID = nextToolCallID()
}
content := fnResp["response"]
if content == nil {

View File

@@ -82,3 +82,48 @@ func TestGeminiMessagesFromRequestPreservesUnknownPartAsRawJSONText(t *testing.T
t.Fatalf("expected raw base64 payload not to be embedded, got %q", content)
}
}
func TestGeminiMessagesFromRequestBackfillsFunctionResponseCallIDByName(t *testing.T) {
req := map[string]any{
"contents": []any{
map[string]any{
"role": "model",
"parts": []any{
map[string]any{
"functionCall": map[string]any{
"name": "search_web",
"args": map[string]any{"query": "docs"},
},
},
},
},
map[string]any{
"role": "user",
"parts": []any{
map[string]any{
"functionResponse": map[string]any{
"name": "search_web",
"response": map[string]any{"ok": true},
},
},
},
},
},
}
got := geminiMessagesFromRequest(req)
if len(got) != 2 {
t.Fatalf("expected two normalized messages, got %#v", got)
}
assistant, _ := got[0].(map[string]any)
tc, _ := assistant["tool_calls"].([]any)
call, _ := tc[0].(map[string]any)
callID, _ := call["id"].(string)
if !strings.HasPrefix(callID, "call_gemini_") {
t.Fatalf("expected generated call id prefix, got %#v", call)
}
toolMsg, _ := got[1].(map[string]any)
if toolMsg["tool_call_id"] != callID {
t.Fatalf("expected tool response to inherit generated call id, tool=%#v call=%#v", toolMsg, call)
}
}

View File

@@ -183,7 +183,7 @@ func findToolSegmentStart(s string) int {
return -1
}
lower := strings.ToLower(s)
keywords := []string{"tool_calls", "\"function\"", "function.name:"}
keywords := []string{"tool_calls", "\"function\"", "function.name:", "\"tool_use\""}
bestKeyIdx := -1
for _, kw := range keywords {
idx := strings.Index(lower, kw)
@@ -191,6 +191,9 @@ func findToolSegmentStart(s string) int {
bestKeyIdx = idx
}
}
if fnKeyIdx := findQuotedFunctionCallKeyStart(s); fnKeyIdx >= 0 && (bestKeyIdx < 0 || fnKeyIdx < bestKeyIdx) {
bestKeyIdx = fnKeyIdx
}
// Also detect XML tool call tags.
for _, tag := range xmlToolTagsToDetect {
idx := strings.Index(lower, tag)
@@ -240,13 +243,16 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix
lower := strings.ToLower(captured)
keyIdx := -1
keywords := []string{"tool_calls", "\"function\"", "function.name:"}
keywords := []string{"tool_calls", "\"function\"", "function.name:", "\"tool_use\""}
for _, kw := range keywords {
idx := strings.Index(lower, kw)
if idx >= 0 && (keyIdx < 0 || idx < keyIdx) {
keyIdx = idx
}
}
if fnKeyIdx := findQuotedFunctionCallKeyStart(captured); fnKeyIdx >= 0 && (keyIdx < 0 || fnKeyIdx < keyIdx) {
keyIdx = fnKeyIdx
}
if keyIdx < 0 {
return "", nil, "", false

View File

@@ -0,0 +1,100 @@
package openai
import "strings"
func findQuotedFunctionCallKeyStart(s string) int {
lower := strings.ToLower(s)
quotedIdx := findFunctionCallKeyStart(lower, `"functioncall"`)
bareIdx := findFunctionCallKeyStart(lower, "functioncall")
// Prefer the quoted JSON key whenever we have a structural match.
// Bare-key detection is only for loose payloads where the quoted form
// is absent.
if quotedIdx >= 0 {
return quotedIdx
}
return bareIdx
}
func findFunctionCallKeyStart(lower, key string) int {
for from := 0; from < len(lower); {
rel := strings.Index(lower[from:], key)
if rel < 0 {
return -1
}
idx := from + rel
if isInsideJSONString(lower, idx) {
from = idx + 1
continue
}
if !hasJSONObjectContextPrefix(lower[:idx]) {
from = idx + 1
continue
}
if !hasJSONKeyBoundary(lower, idx, len(key)) {
from = idx + 1
continue
}
j := idx + len(key)
for j < len(lower) && (lower[j] == ' ' || lower[j] == '\t' || lower[j] == '\r' || lower[j] == '\n') {
j++
}
if j < len(lower) && lower[j] == ':' {
k := j + 1
for k < len(lower) && (lower[k] == ' ' || lower[k] == '\t' || lower[k] == '\r' || lower[k] == '\n') {
k++
}
if k < len(lower) && lower[k] != '{' {
from = idx + 1
continue
}
return idx
}
from = idx + 1
}
return -1
}
func isInsideJSONString(s string, idx int) bool {
inString := false
escaped := false
for i := 0; i < idx; i++ {
c := s[i]
if escaped {
escaped = false
continue
}
if c == '\\' && inString {
escaped = true
continue
}
if c == '"' {
inString = !inString
}
}
return inString
}
func hasJSONObjectContextPrefix(prefix string) bool {
return strings.LastIndex(prefix, "{") >= 0
}
func hasJSONKeyBoundary(s string, idx, keyLen int) bool {
if idx > 0 {
prev := s[idx-1]
if isLowerAlphaNumeric(prev) {
return false
}
}
if end := idx + keyLen; end < len(s) {
next := s[end]
if isLowerAlphaNumeric(next) {
return false
}
}
return true
}
func isLowerAlphaNumeric(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') || b == '_'
}

View File

@@ -0,0 +1,23 @@
package openai
import "testing"
func TestFindQuotedFunctionCallKeyStart_PrefersEarlierBareKey(t *testing.T) {
input := `{functionCall:{"name":"a","arguments":"{}"},"message":"literal text: \"functionCall\": not a key"}`
got := findQuotedFunctionCallKeyStart(input)
want := 1
if got != want {
t.Fatalf("findQuotedFunctionCallKeyStart() = %d, want %d", got, want)
}
}
func TestFindQuotedFunctionCallKeyStart_PrefersEarlierQuotedKey(t *testing.T) {
input := `{"functionCall":{"name":"a","arguments":"{}"},"note":"functionCall appears in prose"}`
got := findQuotedFunctionCallKeyStart(input)
want := 1
if got != want {
t.Fatalf("findQuotedFunctionCallKeyStart() = %d, want %d", got, want)
}
}

View File

@@ -104,6 +104,7 @@ func TestFindToolSegmentStartDetectsXMLToolCalls(t *testing.T) {
want int
}{
{"tool_calls_tag", "some text <tool_calls>\n", 10},
{"gemini_function_call_json", `some text {"functionCall":{"name":"search","args":{"q":"latest"}}}`, 10},
{"tool_call_tag", "prefix <tool_call>\n", 7},
{"invoke_tag", "text <invoke name=\"foo\">body</invoke>", 5},
{"function_call_tag", "<function_call name=\"foo\">body</function_call>", 0},
@@ -119,6 +120,81 @@ func TestFindToolSegmentStartDetectsXMLToolCalls(t *testing.T) {
}
}
func TestFindToolSegmentStartIgnoresFunctionCallProse(t *testing.T) {
input := "Please explain the functionCall API field and how clients should parse it."
if got := findToolSegmentStart(input); got != -1 {
t.Fatalf("expected no tool segment start for prose, got %d", got)
}
}
func TestFindToolSegmentStartDetectsQuotedFunctionCallKey(t *testing.T) {
input := `prefix {"functionCall": {"name":"search_web","args":{"query":"x"}}}`
want := strings.Index(input, "{")
if got := findToolSegmentStart(input); got != want {
t.Fatalf("expected JSON object start %d, got %d", want, got)
}
}
func TestFindToolSegmentStartDetectsLooseFunctionCallKey(t *testing.T) {
input := `prefix {functionCall: {"name":"search_web","args":{"query":"x"}}}`
want := strings.Index(input, "{")
if got := findToolSegmentStart(input); got != want {
t.Fatalf("expected JSON object start %d, got %d", want, got)
}
}
func TestFindToolSegmentStartPrefersQuotedFunctionCallOverEarlierBareProse(t *testing.T) {
input := `prefix {note} functionCall: docs hint {"functionCall":{"name":"search_web","args":{"query":"x"}}}`
want := strings.Index(input, `{"functionCall"`)
if got := findToolSegmentStart(input); got != want {
t.Fatalf("expected quoted functionCall JSON start %d, got %d", want, got)
}
}
func TestFindToolSegmentStartIgnoresLooseFunctionCallProse(t *testing.T) {
input := "Please explain why functionCall: is used in documentation examples."
if got := findToolSegmentStart(input); got != -1 {
t.Fatalf("expected no tool segment start for prose, got %d", got)
}
}
func TestProcessToolSieveDoesNotBufferFunctionCallProse(t *testing.T) {
var state toolStreamSieveState
chunk := "Please explain the functionCall API field and keep streaming this sentence."
events := processToolSieveChunk(&state, chunk, []string{"search_web"})
var text string
for _, evt := range events {
text += evt.Content
if len(evt.ToolCalls) > 0 {
t.Fatalf("expected no tool calls for prose, got %#v", evt.ToolCalls)
}
}
if text != chunk {
t.Fatalf("expected prose to pass through immediately, got %q", text)
}
}
func TestProcessToolSieveDetectsGeminiFunctionCallPayload(t *testing.T) {
var state toolStreamSieveState
events := processToolSieveChunk(&state, `{"functionCall":{"name":"search_web","args":{"query":"latest"}}}`, []string{"search_web"})
events = append(events, flushToolSieve(&state, []string{"search_web"})...)
var textContent string
var toolCalls int
for _, evt := range events {
if evt.Content != "" {
textContent += evt.Content
}
toolCalls += len(evt.ToolCalls)
}
if toolCalls != 1 {
t.Fatalf("expected one tool call from functionCall payload, got events=%#v", events)
}
if strings.Contains(strings.ToLower(textContent), "functioncall") {
t.Fatalf("functionCall json leaked into text content: %q", textContent)
}
}
func TestFindPartialXMLToolTagStart(t *testing.T) {
cases := []struct {
name string

View File

@@ -204,6 +204,45 @@ func TestSwitchAccountNilTriedAccounts(t *testing.T) {
r.Release(a)
}
func TestSwitchAccountSkipsLoginFailureAndContinues(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{
"keys":["managed-key"],
"accounts":[
{"email":"acc1@test.com","password":"pwd","token":"t1"},
{"email":"acc2@test.com","password":"pwd"},
{"email":"acc3@test.com","password":"pwd","token":"t3"}
]
}`)
store := config.LoadStore()
pool := account.NewPool(store)
r := NewResolver(store, pool, func(_ context.Context, acc config.Account) (string, error) {
if acc.Email == "acc2@test.com" {
return "", errors.New("login failed")
}
return "new-token", nil
})
req, _ := http.NewRequest("POST", "/", nil)
req.Header.Set("Authorization", "Bearer managed-key")
a, err := r.Determine(req)
if err != nil {
t.Fatalf("determine failed: %v", err)
}
defer r.Release(a)
if a.AccountID != "acc1@test.com" {
t.Fatalf("expected first account, got %q", a.AccountID)
}
if !r.SwitchAccount(context.Background(), a) {
t.Fatal("expected switch to succeed after skipping failed account")
}
if a.AccountID != "acc3@test.com" {
t.Fatalf("expected fallback to third account, got %q", a.AccountID)
}
if !a.TriedAccounts["acc2@test.com"] {
t.Fatalf("expected failed account to be marked as tried")
}
}
// ─── Release edge cases ─────────────────────────────────────────────
func TestReleaseNilAuth(t *testing.T) {

View File

@@ -70,25 +70,53 @@ func (r *Resolver) Determine(req *http.Request) (*RequestAuth, error) {
}, nil
}
target := strings.TrimSpace(req.Header.Get("X-Ds2-Target-Account"))
acc, ok := r.Pool.AcquireWait(ctx, target, nil)
if !ok {
return nil, ErrNoAccount
}
a := &RequestAuth{
UseConfigToken: true,
CallerID: callerID,
AccountID: acc.Identifier(),
Account: acc,
TriedAccounts: map[string]bool{},
resolver: r,
}
if err := r.ensureManagedToken(ctx, a); err != nil {
r.Pool.Release(a.AccountID)
a, err := r.acquireManagedRequestAuth(ctx, callerID, target)
if err != nil {
return nil, err
}
return a, nil
}
func (r *Resolver) acquireManagedRequestAuth(ctx context.Context, callerID, target string) (*RequestAuth, error) {
tried := map[string]bool{}
var lastEnsureErr error
for {
if target == "" && len(tried) >= len(r.Store.Accounts()) {
if lastEnsureErr != nil {
return nil, lastEnsureErr
}
return nil, ErrNoAccount
}
acc, ok := r.Pool.AcquireWait(ctx, target, tried)
if !ok {
if lastEnsureErr != nil {
return nil, lastEnsureErr
}
return nil, ErrNoAccount
}
a := &RequestAuth{
UseConfigToken: true,
CallerID: callerID,
AccountID: acc.Identifier(),
Account: acc,
TriedAccounts: tried,
resolver: r,
}
if err := r.ensureManagedToken(ctx, a); err != nil {
lastEnsureErr = err
tried[a.AccountID] = true
r.Pool.Release(a.AccountID)
if target != "" {
return nil, err
}
continue
}
return a, nil
}
}
// DetermineCaller resolves caller identity without acquiring any pooled account.
// Use this for local-cache lookup routes that only need tenant isolation.
func (r *Resolver) DetermineCaller(req *http.Request) (*RequestAuth, error) {
@@ -164,16 +192,20 @@ func (r *Resolver) SwitchAccount(ctx context.Context, a *RequestAuth) bool {
a.TriedAccounts[a.AccountID] = true
r.Pool.Release(a.AccountID)
}
acc, ok := r.Pool.Acquire("", a.TriedAccounts)
if !ok {
return false
for {
acc, ok := r.Pool.Acquire("", a.TriedAccounts)
if !ok {
return false
}
a.Account = acc
a.AccountID = acc.Identifier()
if err := r.ensureManagedToken(ctx, a); err != nil {
a.TriedAccounts[a.AccountID] = true
r.Pool.Release(a.AccountID)
continue
}
return true
}
a.Account = acc
a.AccountID = acc.Identifier()
if err := r.ensureManagedToken(ctx, a); err != nil {
return false
}
return true
}
func (r *Resolver) Release(a *RequestAuth) {

View File

@@ -2,6 +2,7 @@ package auth
import (
"context"
"errors"
"net/http"
"sync/atomic"
"testing"
@@ -301,3 +302,96 @@ func TestDetermineManagedAccountUsesUpdatedRefreshInterval(t *testing.T) {
t.Fatalf("expected exactly one login after runtime update, got %d", got)
}
}
func TestDetermineManagedAccountRetriesOtherAccountOnLoginFailure(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{
"keys":["managed-key"],
"accounts":[
{"email":"bad@example.com","password":"pwd"},
{"email":"good@example.com","password":"pwd","token":"good-token"}
]
}`)
store := config.LoadStore()
pool := account.NewPool(store)
resolver := NewResolver(store, pool, func(_ context.Context, acc config.Account) (string, error) {
if acc.Email == "bad@example.com" {
return "", errors.New("stale account")
}
return "fresh-good-token", nil
})
req, _ := http.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
req.Header.Set("x-api-key", "managed-key")
a, err := resolver.Determine(req)
if err != nil {
t.Fatalf("determine failed: %v", err)
}
defer resolver.Release(a)
if a.AccountID != "good@example.com" {
t.Fatalf("expected fallback to good account, got %q", a.AccountID)
}
if a.DeepSeekToken == "" {
t.Fatal("expected non-empty token from fallback account")
}
if !a.TriedAccounts["bad@example.com"] {
t.Fatalf("expected bad account to be tracked as tried")
}
}
func TestDetermineTargetAccountDoesNotFallbackOnLoginFailure(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{
"keys":["managed-key"],
"accounts":[
{"email":"bad@example.com","password":"pwd"},
{"email":"good@example.com","password":"pwd","token":"good-token"}
]
}`)
store := config.LoadStore()
pool := account.NewPool(store)
resolver := NewResolver(store, pool, func(_ context.Context, acc config.Account) (string, error) {
if acc.Email == "bad@example.com" {
return "", errors.New("stale account")
}
return "fresh-good-token", nil
})
req, _ := http.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
req.Header.Set("x-api-key", "managed-key")
req.Header.Set("X-Ds2-Target-Account", "bad@example.com")
_, err := resolver.Determine(req)
if err == nil {
t.Fatal("expected determine to fail for broken target account")
}
}
func TestDetermineManagedAccountReturnsLastEnsureErrorWhenAllFail(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{
"keys":["managed-key"],
"accounts":[
{"email":"bad1@example.com","password":"pwd"},
{"email":"bad2@example.com","password":"pwd"}
]
}`)
store := config.LoadStore()
pool := account.NewPool(store)
ensureErr := errors.New("all credentials stale")
resolver := NewResolver(store, pool, func(_ context.Context, _ config.Account) (string, error) {
return "", ensureErr
})
req, _ := http.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
req.Header.Set("x-api-key", "managed-key")
_, err := resolver.Determine(req)
if err == nil {
t.Fatal("expected determine to fail")
}
if !errors.Is(err, ensureErr) {
t.Fatalf("expected ensure error, got %v", err)
}
if errors.Is(err, ErrNoAccount) {
t.Fatalf("expected auth-style ensure error, got ErrNoAccount")
}
}

View File

@@ -237,7 +237,10 @@ function isLikelyJSONToolPayloadCandidate(text) {
return false;
}
const lower = trimmed.toLowerCase();
return lower.includes('tool_calls') || lower.includes('"function"');
return lower.includes('tool_calls')
|| lower.includes('"function"')
|| lower.includes('functioncall')
|| lower.includes('"tool_use"');
}
module.exports = {

View File

@@ -85,6 +85,8 @@ function extractToolCallObjects(text) {
while (true) {
const idxToolCalls = lower.indexOf('tool_calls', offset);
const idxFunction = lower.indexOf('"function"', offset);
const idxFunctionCall = lower.indexOf('functioncall', offset);
const idxToolUse = lower.indexOf('"tool_use"', offset);
let idx = -1;
let matched = '';
if (idxToolCalls >= 0 && (idxFunction < 0 || idxToolCalls <= idxFunction)) {
@@ -94,6 +96,14 @@ function extractToolCallObjects(text) {
idx = idxFunction;
matched = '"function"';
}
if (idxFunctionCall >= 0 && (idx < 0 || idxFunctionCall < idx)) {
idx = idxFunctionCall;
matched = 'functioncall';
}
if (idxToolUse >= 0 && (idx < 0 || idxToolUse < idx)) {
idx = idxToolUse;
matched = '"tool_use"';
}
if (idx < 0) {
break;
}
@@ -327,6 +337,20 @@ function parseToolCallItem(m) {
let name = toStringSafe(m.name);
let inputRaw = m.input;
let hasInput = Object.prototype.hasOwnProperty.call(m, 'input');
const fnCall = m.functionCall && typeof m.functionCall === 'object' ? m.functionCall : null;
if (fnCall) {
if (!name) {
name = toStringSafe(fnCall.name);
}
if (!hasInput && Object.prototype.hasOwnProperty.call(fnCall, 'args')) {
inputRaw = fnCall.args;
hasInput = true;
}
if (!hasInput && Object.prototype.hasOwnProperty.call(fnCall, 'arguments')) {
inputRaw = fnCall.arguments;
hasInput = true;
}
}
const fn = m.function && typeof m.function === 'object' ? m.function : null;
if (fn) {

View File

@@ -4,6 +4,8 @@ const TOOL_SEGMENT_KEYWORDS = [
'tool_calls',
'"function"',
'function.name:',
'functioncall',
'"tool_use"',
];
const XML_TOOL_SEGMENT_TAGS = [

View File

@@ -1,5 +1,7 @@
package util
import "strings"
// BuildToolCallInstructions generates the unified tool-calling instruction block
// used by all adapters (OpenAI, Claude, Gemini). It uses attention-optimized
// structure: rules → negative examples → positive examples → anchor.
@@ -19,7 +21,7 @@ func BuildToolCallInstructions(toolNames []string) string {
ex1 = n
used["ex1"] = true
// Write/execute-type tools
case !used["ex2"] && matchAny(n, "write_to_file", "apply_diff", "execute_command", "Write", "Edit", "MultiEdit", "Bash"):
case !used["ex2"] && matchAny(n, "write_to_file", "apply_diff", "execute_command", "exec_command", "Write", "Edit", "MultiEdit", "Bash"):
ex2 = n
used["ex2"] = true
// Interactive/meta tools
@@ -28,10 +30,13 @@ func BuildToolCallInstructions(toolNames []string) string {
used["ex3"] = true
}
}
ex1Params := exampleReadParams(ex1)
ex2Params := exampleWriteOrExecParams(ex2)
ex3Params := exampleInteractiveParams(ex3)
return `TOOL CALL FORMAT — FOLLOW EXACTLY:
When calling tools, emit ONLY raw XML. No text before, no text after, no markdown fences.
When calling tools, emit ONLY raw XML at the very end of your response. No text before, no text after, no markdown fences.
<tool_calls>
<tool_call>
@@ -47,6 +52,7 @@ RULES:
4) Do NOT wrap the XML in markdown code fences (no triple backticks).
5) After receiving a tool result, use it directly. Only call another tool if the result is insufficient.
6) If you want to say something AND call a tool, output text first, then the XML block on its own.
7) Parameters MUST use the exact field names from the selected tool schema.
❌ WRONG — Do NOT do these:
Wrong 1 — mixed text and XML:
@@ -62,7 +68,7 @@ Example A — Single tool:
<tool_calls>
<tool_call>
<tool_name>` + ex1 + `</tool_name>
<parameters>{"path":"src/main.go"}</parameters>
<parameters>` + ex1Params + `</parameters>
</tool_call>
</tool_calls>
@@ -70,11 +76,11 @@ Example B — Two tools in parallel:
<tool_calls>
<tool_call>
<tool_name>` + ex1 + `</tool_name>
<parameters>{"path":"config.json"}</parameters>
<parameters>` + ex1Params + `</parameters>
</tool_call>
<tool_call>
<tool_name>` + ex2 + `</tool_name>
<parameters>{"path":"output.txt","content":"Hello world"}</parameters>
<parameters>` + ex2Params + `</parameters>
</tool_call>
</tool_calls>
@@ -82,7 +88,7 @@ Example C — Tool with complex nested JSON parameters:
<tool_calls>
<tool_call>
<tool_name>` + ex3 + `</tool_name>
<parameters>{"question":"Which approach do you prefer?","follow_up":[{"text":"Option A"},{"text":"Option B"}]}</parameters>
<parameters>` + ex3Params + `</parameters>
</tool_call>
</tool_calls>
@@ -97,3 +103,38 @@ func matchAny(name string, candidates ...string) bool {
}
return false
}
func exampleReadParams(name string) string {
switch strings.TrimSpace(name) {
case "Read":
return `{"file_path":"README.md"}`
case "Glob":
return `{"pattern":"**/*.go","path":"."}`
default:
return `{"path":"src/main.go"}`
}
}
func exampleWriteOrExecParams(name string) string {
switch strings.TrimSpace(name) {
case "Bash", "execute_command":
return `{"command":"pwd"}`
case "exec_command":
return `{"cmd":"pwd"}`
case "Edit":
return `{"file_path":"README.md","old_string":"foo","new_string":"bar"}`
case "MultiEdit":
return `{"file_path":"README.md","edits":[{"old_string":"foo","new_string":"bar"}]}`
default:
return `{"path":"output.txt","content":"Hello world"}`
}
}
func exampleInteractiveParams(name string) string {
switch strings.TrimSpace(name) {
case "Task":
return `{"description":"Investigate flaky tests","prompt":"Run targeted tests and summarize failures"}`
default:
return `{"question":"Which approach do you prefer?","follow_up":[{"text":"Option A"},{"text":"Option B"}]}`
}
}

View File

@@ -0,0 +1,26 @@
package util
import (
"strings"
"testing"
)
func TestBuildToolCallInstructions_ExecCommandUsesCmdExample(t *testing.T) {
out := BuildToolCallInstructions([]string{"exec_command"})
if !strings.Contains(out, `<tool_name>exec_command</tool_name>`) {
t.Fatalf("expected exec_command in examples, got: %s", out)
}
if !strings.Contains(out, `<parameters>{"cmd":"pwd"}</parameters>`) {
t.Fatalf("expected cmd parameter example for exec_command, got: %s", out)
}
}
func TestBuildToolCallInstructions_ExecuteCommandUsesCommandExample(t *testing.T) {
out := BuildToolCallInstructions([]string{"execute_command"})
if !strings.Contains(out, `<tool_name>execute_command</tool_name>`) {
t.Fatalf("expected execute_command in examples, got: %s", out)
}
if !strings.Contains(out, `<parameters>{"command":"pwd"}</parameters>`) {
t.Fatalf("expected command parameter example for execute_command, got: %s", out)
}
}

View File

@@ -64,7 +64,7 @@ func extractToolCallObjects(text string) []string {
lower := strings.ToLower(text)
out := []string{}
offset := 0
keywords := []string{"tool_calls", "\"function\"", "function.name:"}
keywords := []string{"tool_calls", "\"function\"", "function.name:", "functioncall", "\"tool_use\""}
for {
bestIdx := -1
matchedKeyword := ""

View File

@@ -196,18 +196,6 @@ func parseToolCallsPayload(payload string) []ParsedToolCall {
return nil
}
func isLikelyJSONToolPayloadCandidate(candidate string) bool {
trimmed := strings.TrimSpace(candidate)
if trimmed == "" {
return false
}
if !(strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[")) {
return false
}
lower := strings.ToLower(trimmed)
return strings.Contains(lower, "tool_calls") || strings.Contains(lower, "\"function\"")
}
func isLikelyChatMessageEnvelope(v map[string]any) bool {
if v == nil {
return false
@@ -234,62 +222,11 @@ func looksLikeToolCallSyntax(text string) bool {
lower := strings.ToLower(text)
return strings.Contains(lower, "tool_calls") ||
strings.Contains(lower, "\"function\"") ||
strings.Contains(lower, "functioncall") ||
strings.Contains(lower, "\"tool_use\"") ||
strings.Contains(lower, "<tool_call") ||
strings.Contains(lower, "<function_call") ||
strings.Contains(lower, "<function_name") ||
strings.Contains(lower, "<invoke") ||
strings.Contains(lower, "function.name:")
}
func parseToolCallList(v any) []ParsedToolCall {
items, ok := v.([]any)
if !ok {
return nil
}
out := make([]ParsedToolCall, 0, len(items))
for _, item := range items {
m, ok := item.(map[string]any)
if !ok {
continue
}
if tc, ok := parseToolCallItem(m); ok {
out = append(out, tc)
}
}
if len(out) == 0 {
return nil
}
return out
}
func parseToolCallItem(m map[string]any) (ParsedToolCall, bool) {
name, _ := m["name"].(string)
inputRaw, hasInput := m["input"]
if fn, ok := m["function"].(map[string]any); ok {
if name == "" {
name, _ = fn["name"].(string)
}
if !hasInput {
if v, ok := fn["arguments"]; ok {
inputRaw = v
hasInput = true
}
}
}
if !hasInput {
for _, key := range []string{"arguments", "args", "parameters", "params"} {
if v, ok := m[key]; ok {
inputRaw = v
hasInput = true
break
}
}
}
if strings.TrimSpace(name) == "" {
return ParsedToolCall{}, false
}
return ParsedToolCall{
Name: strings.TrimSpace(name),
Input: parseToolCallInput(inputRaw),
}, true
}

View File

@@ -0,0 +1,88 @@
package util
import "strings"
func isLikelyJSONToolPayloadCandidate(candidate string) bool {
trimmed := strings.TrimSpace(candidate)
if trimmed == "" {
return false
}
if !(strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[")) {
return false
}
lower := strings.ToLower(trimmed)
return strings.Contains(lower, "tool_calls") ||
strings.Contains(lower, "\"function\"") ||
strings.Contains(lower, "functioncall") ||
strings.Contains(lower, "\"tool_use\"")
}
func parseToolCallList(v any) []ParsedToolCall {
items, ok := v.([]any)
if !ok {
return nil
}
out := make([]ParsedToolCall, 0, len(items))
for _, item := range items {
m, ok := item.(map[string]any)
if !ok {
continue
}
if tc, ok := parseToolCallItem(m); ok {
out = append(out, tc)
}
}
if len(out) == 0 {
return nil
}
return out
}
func parseToolCallItem(m map[string]any) (ParsedToolCall, bool) {
name, _ := m["name"].(string)
inputRaw, hasInput := m["input"]
if fnCall, ok := m["functionCall"].(map[string]any); ok {
if name == "" {
name, _ = fnCall["name"].(string)
}
if !hasInput {
if v, ok := fnCall["args"]; ok {
inputRaw = v
hasInput = true
}
}
if !hasInput {
if v, ok := fnCall["arguments"]; ok {
inputRaw = v
hasInput = true
}
}
}
if fn, ok := m["function"].(map[string]any); ok {
if name == "" {
name, _ = fn["name"].(string)
}
if !hasInput {
if v, ok := fn["arguments"]; ok {
inputRaw = v
hasInput = true
}
}
}
if !hasInput {
for _, key := range []string{"arguments", "args", "parameters", "params"} {
if v, ok := m[key]; ok {
inputRaw = v
hasInput = true
break
}
}
}
if strings.TrimSpace(name) == "" {
return ParsedToolCall{}, false
}
return ParsedToolCall{
Name: strings.TrimSpace(name),
Input: parseToolCallInput(inputRaw),
}, true
}

View File

@@ -271,6 +271,34 @@ func TestParseToolCallsSupportsInvokeFunctionCallStyle(t *testing.T) {
}
}
func TestParseToolCallsSupportsGeminiFunctionCallJSON(t *testing.T) {
text := `{"functionCall":{"name":"search_web","args":{"query":"latest"}}}`
calls := ParseToolCalls(text, []string{"search_web"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "search_web" {
t.Fatalf("expected search_web, got %q", calls[0].Name)
}
if calls[0].Input["query"] != "latest" {
t.Fatalf("expected query argument, got %#v", calls[0].Input)
}
}
func TestParseToolCallsSupportsClaudeToolUseJSON(t *testing.T) {
text := `{"type":"tool_use","name":"read_file","input":{"path":"README.md"}}`
calls := ParseToolCalls(text, []string{"read_file"})
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %#v", calls)
}
if calls[0].Name != "read_file" {
t.Fatalf("expected read_file, got %q", calls[0].Name)
}
if calls[0].Input["path"] != "README.md" {
t.Fatalf("expected path argument, got %#v", calls[0].Input)
}
}
func TestParseToolCallsSupportsToolUseFunctionParameterStyle(t *testing.T) {
text := `<tool_use><function name="search_web"><parameter name="query">test</parameter></function></tool_use>`
calls := ParseToolCalls(text, []string{"search_web"})

View File

@@ -108,6 +108,24 @@ test('parseToolCalls parses text-kv fallback payload', () => {
assert.equal(calls[0].input.command, 'cd scripts && python check_syntax.py example.py');
});
test('parseToolCalls supports Gemini functionCall JSON payload', () => {
const payload = JSON.stringify({
functionCall: { name: 'search_web', args: { query: 'latest' } },
});
const calls = parseToolCalls(payload, ['search_web']);
assert.deepEqual(calls, [{ name: 'search_web', input: { query: 'latest' } }]);
});
test('parseToolCalls supports Claude tool_use JSON payload', () => {
const payload = JSON.stringify({
type: 'tool_use',
name: 'read_file',
input: { path: 'README.md' },
});
const calls = parseToolCalls(payload, ['read_file']);
assert.deepEqual(calls, [{ name: 'read_file', input: { path: 'README.md' } }]);
});
test('parseToolCalls parses multiple text-kv fallback payloads', () => {
const text = [
'function.name: read_file',

View File

@@ -1,6 +1,31 @@
import { useState } from 'react'
import { Check, ChevronDown, Copy, Plus, Trash2 } from 'lucide-react'
import clsx from 'clsx'
function fallbackCopyText(text) {
const textArea = document.createElement('textarea')
textArea.value = text
textArea.setAttribute('readonly', '')
textArea.style.position = 'fixed'
textArea.style.top = '-9999px'
textArea.style.left = '-9999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
let copied = false
try {
copied = document.execCommand('copy')
} finally {
document.body.removeChild(textArea)
}
if (!copied) {
throw new Error('copy failed')
}
}
export default function ApiKeysPanel({
t,
config,
@@ -11,6 +36,31 @@ export default function ApiKeysPanel({
setCopiedKey,
onDeleteKey,
}) {
const [failedKey, setFailedKey] = useState(null)
const handleCopyKey = async (key) => {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(key)
} else {
fallbackCopyText(key)
}
setCopiedKey(key)
setFailedKey(null)
setTimeout(() => setCopiedKey(null), 2000)
} catch {
try {
fallbackCopyText(key)
setCopiedKey(key)
setFailedKey(null)
setTimeout(() => setCopiedKey(null), 2000)
} catch {
setFailedKey(key)
setTimeout(() => setFailedKey(null), 2500)
}
}
}
return (
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
<div
@@ -42,28 +92,31 @@ export default function ApiKeysPanel({
config.keys.map((key, i) => (
<div key={i} className="p-4 flex items-center justify-between hover:bg-muted/50 transition-colors group">
<div className="flex items-center gap-2">
<div className="font-mono text-sm bg-muted/50 px-3 py-1 rounded inline-block">
<button
onClick={() => handleCopyKey(key)}
className="font-mono text-sm bg-muted/50 px-3 py-1 rounded inline-block hover:bg-muted transition-colors"
title={t('accountManager.copyKeyTitle')}
>
{key.slice(0, 16)}****
</div>
</button>
{copiedKey === key && (
<span className="text-xs text-green-500 animate-pulse">{t('accountManager.copied')}</span>
)}
{failedKey === key && (
<span className="text-xs text-destructive">{t('accountManager.copyFailed')}</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => {
navigator.clipboard.writeText(key)
setCopiedKey(key)
setTimeout(() => setCopiedKey(null), 2000)
}}
className="p-2 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-md transition-colors opacity-0 group-hover:opacity-100"
onClick={() => handleCopyKey(key)}
className="p-2 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-md transition-colors"
title={t('accountManager.copyKeyTitle')}
>
{copiedKey === key ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</button>
<button
onClick={() => onDeleteKey(key)}
className="p-2 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors opacity-0 group-hover:opacity-100"
className="p-2 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors"
title={t('accountManager.deleteKeyTitle')}
>
<Trash2 className="w-4 h-4" />

View File

@@ -105,6 +105,7 @@
"apiKeysDesc": "Manage the API access key pool",
"addKey": "Add key",
"copied": "Copied",
"copyFailed": "Copy failed",
"copyKeyTitle": "Copy key",
"deleteKeyTitle": "Delete key",
"noApiKeys": "No API keys found.",

View File

@@ -105,6 +105,7 @@
"apiKeysDesc": "管理 API 访问密钥池",
"addKey": "添加密钥",
"copied": "已复制",
"copyFailed": "复制失败",
"copyKeyTitle": "复制密钥",
"deleteKeyTitle": "删除密钥",
"noApiKeys": "未找到 API 密钥",