mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-02 23:45:27 +08:00
Compare commits
54 Commits
v2.5.1_bet
...
v2.5.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d62c658f8 | ||
|
|
6a632ad9ef | ||
|
|
cd2f5ad3b0 | ||
|
|
1457b63a76 | ||
|
|
24655342a7 | ||
|
|
39f6e066d6 | ||
|
|
02d64c192e | ||
|
|
283aa304df | ||
|
|
02fe3e4bfc | ||
|
|
15bf77e044 | ||
|
|
add0d0cc06 | ||
|
|
a87ec3fd68 | ||
|
|
50ce88ca3f | ||
|
|
48a5f1c39e | ||
|
|
07578f9c56 | ||
|
|
5ebc33c347 | ||
|
|
cc74397edc | ||
|
|
1289e8afd8 | ||
|
|
e60738b084 | ||
|
|
f6cd541c6f | ||
|
|
1eb47147c2 | ||
|
|
da3fafb79a | ||
|
|
3900aaec47 | ||
|
|
8a74dbff9c | ||
|
|
bfca84c2c7 | ||
|
|
1cdfa9c05d | ||
|
|
fe8232bfc1 | ||
|
|
063599678a | ||
|
|
f55aa7564a | ||
|
|
3b60e3c8f9 | ||
|
|
efebe9ebad | ||
|
|
b54b418f96 | ||
|
|
1c5f022b06 | ||
|
|
836eaf5290 | ||
|
|
958e7a0d04 | ||
|
|
f3555ae9b0 | ||
|
|
d50d39e2e5 | ||
|
|
01393837be | ||
|
|
1fe1240240 | ||
|
|
c07736fbea | ||
|
|
775bf3b578 | ||
|
|
ab3943ebeb | ||
|
|
6efba7b2e4 | ||
|
|
765d0231cd | ||
|
|
aebf3e9119 | ||
|
|
535d9298a7 | ||
|
|
b790545d82 | ||
|
|
034c00f10e | ||
|
|
390f7580e5 | ||
|
|
586d31e556 | ||
|
|
c4a73e871a | ||
|
|
25b3292497 | ||
|
|
11f66db87d | ||
|
|
7131b06e26 |
@@ -587,6 +587,9 @@ Returns sanitized config.
|
||||
{
|
||||
"keys": ["k1", "k2"],
|
||||
"env_backed": false,
|
||||
"env_source_present": true,
|
||||
"env_writeback_enabled": true,
|
||||
"config_path": "/data/config.json",
|
||||
"accounts": [
|
||||
{
|
||||
"identifier": "user@example.com",
|
||||
|
||||
3
API.md
3
API.md
@@ -596,6 +596,9 @@ data: {"type":"message_stop"}
|
||||
{
|
||||
"keys": ["k1", "k2"],
|
||||
"env_backed": false,
|
||||
"env_source_present": true,
|
||||
"env_writeback_enabled": true,
|
||||
"config_path": "/data/config.json",
|
||||
"accounts": [
|
||||
{
|
||||
"identifier": "user@example.com",
|
||||
|
||||
@@ -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=请替换为强密码
|
||||
@@ -320,6 +321,7 @@ cp opencode.json.example opencode.json
|
||||
| `DS2API_CONFIG_PATH` | 配置文件路径 | `config.json` |
|
||||
| `DS2API_CONFIG_JSON` | 直接注入配置(JSON 或 Base64) | — |
|
||||
| `CONFIG_JSON` | 旧版兼容配置注入 | — |
|
||||
| `DS2API_ENV_WRITEBACK` | 环境变量模式下自动写回配置文件并切换文件模式(`1/true/yes/on`) | 关闭 |
|
||||
| `DS2API_WASM_PATH` | PoW WASM 文件路径 | 自动查找 |
|
||||
| `DS2API_STATIC_ADMIN_DIR` | 管理台静态文件目录 | `static/admin` |
|
||||
| `DS2API_AUTO_BUILD_WEBUI` | 启动时自动构建 WebUI | 本地开启,Vercel 关闭 |
|
||||
@@ -342,6 +344,8 @@ cp opencode.json.example opencode.json
|
||||
| `VERCEL_TEAM_ID` | Vercel 团队 ID | — |
|
||||
| `DS2API_VERCEL_PROTECTION_BYPASS` | Vercel 部署保护绕过密钥(内部 Node→Go 调用) | — |
|
||||
|
||||
> 提示:当检测到 `DS2API_CONFIG_JSON/CONFIG_JSON` 时,管理台会显示当前模式风险与自动持久化状态(含 `DS2API_CONFIG_PATH` 路径与模式切换说明)。
|
||||
|
||||
## 鉴权模式
|
||||
|
||||
调用业务接口(`/v1/*`、`/anthropic/*`、Gemini 路由)时支持两种模式:
|
||||
|
||||
@@ -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
|
||||
@@ -320,6 +321,7 @@ cp opencode.json.example opencode.json
|
||||
| `DS2API_CONFIG_PATH` | Config file path | `config.json` |
|
||||
| `DS2API_CONFIG_JSON` | Inline config (JSON or Base64) | — |
|
||||
| `CONFIG_JSON` | Legacy compatibility config input | — |
|
||||
| `DS2API_ENV_WRITEBACK` | Auto-write env-backed config to file and transition to file mode (`1/true/yes/on`) | Disabled |
|
||||
| `DS2API_WASM_PATH` | PoW WASM file path | Auto-detect |
|
||||
| `DS2API_STATIC_ADMIN_DIR` | Admin static assets dir | `static/admin` |
|
||||
| `DS2API_AUTO_BUILD_WEBUI` | Auto-build WebUI on startup | Enabled locally, disabled on Vercel |
|
||||
@@ -339,6 +341,8 @@ cp opencode.json.example opencode.json
|
||||
| `VERCEL_TEAM_ID` | Vercel team ID | — |
|
||||
| `DS2API_VERCEL_PROTECTION_BYPASS` | Vercel deployment protection bypass for internal Node→Go calls | — |
|
||||
|
||||
> Note: when `DS2API_CONFIG_JSON/CONFIG_JSON` is detected, the Admin UI shows mode risk and auto-persistence status (including `DS2API_CONFIG_PATH` and mode-transition hints).
|
||||
|
||||
## Authentication Modes
|
||||
|
||||
For business endpoints (`/v1/*`, `/anthropic/*`, Gemini routes), DS2API supports two modes:
|
||||
|
||||
@@ -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
|
||||
@@ -248,6 +249,7 @@ VERCEL_TEAM_ID=team_xxxxxxxxxxxx # optional for personal accounts
|
||||
| `DS2API_ACCOUNT_QUEUE_SIZE` | Alias (legacy compat) | — |
|
||||
| `DS2API_GLOBAL_MAX_INFLIGHT` | Global inflight limit | `recommended_concurrency` |
|
||||
| `DS2API_MAX_INFLIGHT` | Alias (legacy compat) | — |
|
||||
| `DS2API_ENV_WRITEBACK` | When `DS2API_CONFIG_JSON` is present, auto-write to `DS2API_CONFIG_PATH` and switch to file-backed mode after success (`1/true/yes/on`) | Disabled |
|
||||
| `DS2API_VERCEL_INTERNAL_SECRET` | Hybrid streaming internal auth | Falls back to `DS2API_ADMIN_KEY` |
|
||||
| `DS2API_VERCEL_STREAM_LEASE_TTL_SECONDS` | Stream lease TTL | `900` |
|
||||
| `VERCEL_TOKEN` | Vercel sync token | — |
|
||||
|
||||
@@ -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
|
||||
@@ -248,6 +249,7 @@ VERCEL_TEAM_ID=team_xxxxxxxxxxxx # 个人账号可留空
|
||||
| `DS2API_ACCOUNT_QUEUE_SIZE` | 同上(兼容别名) | — |
|
||||
| `DS2API_GLOBAL_MAX_INFLIGHT` | 全局并发上限 | `recommended_concurrency` |
|
||||
| `DS2API_MAX_INFLIGHT` | 同上(兼容别名) | — |
|
||||
| `DS2API_ENV_WRITEBACK` | 检测到 `DS2API_CONFIG_JSON` 时自动写入 `DS2API_CONFIG_PATH`,并在成功后转为文件模式(`1/true/yes/on`) | 关闭 |
|
||||
| `DS2API_VERCEL_INTERNAL_SECRET` | 混合流式内部鉴权 | 回退用 `DS2API_ADMIN_KEY` |
|
||||
| `DS2API_VERCEL_STREAM_LEASE_TTL_SECONDS` | 流式 lease TTL | `900` |
|
||||
| `VERCEL_TOKEN` | Vercel 同步 token | — |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
97
internal/adapter/claude/handler_helpers_misc.go
Normal file
97
internal/adapter/claude/handler_helpers_misc.go
Normal 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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
25
internal/adapter/claude/tool_call_state.go
Normal file
25
internal/adapter/claude/tool_call_state.go
Normal 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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ var leakedAgentXMLBlockPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?is)<new_task\b[^>]*>(.*?)</new_task>`),
|
||||
}
|
||||
|
||||
var leakedAgentWrapperTagPattern = regexp.MustCompile(`(?is)</?(?:attempt_completion|ask_followup_question|new_task)\b[^>]*>`)
|
||||
var leakedAgentWrapperPlusResultOpenPattern = regexp.MustCompile(`(?is)<(?:attempt_completion|ask_followup_question|new_task)\b[^>]*>\s*<result>`)
|
||||
var leakedAgentResultPlusWrapperClosePattern = regexp.MustCompile(`(?is)</result>\s*</(?:attempt_completion|ask_followup_question|new_task)\b[^>]*>`)
|
||||
var leakedAgentResultTagPattern = regexp.MustCompile(`(?is)</?result>`)
|
||||
|
||||
func sanitizeLeakedOutput(text string) string {
|
||||
@@ -50,5 +53,18 @@ func sanitizeLeakedAgentXMLBlocks(text string) string {
|
||||
return leakedAgentResultTagPattern.ReplaceAllString(submatches[1], "")
|
||||
})
|
||||
}
|
||||
// Fallback for truncated output streams: strip any dangling wrapper tags
|
||||
// that were not part of a complete block replacement. If we detect leaked
|
||||
// wrapper tags, strip only adjacent <result> tags to avoid exposing agent
|
||||
// markup without altering unrelated user-visible <result> examples.
|
||||
if leakedAgentWrapperTagPattern.MatchString(out) {
|
||||
out = leakedAgentWrapperPlusResultOpenPattern.ReplaceAllStringFunc(out, func(match string) string {
|
||||
return leakedAgentResultTagPattern.ReplaceAllString(match, "")
|
||||
})
|
||||
out = leakedAgentResultPlusWrapperClosePattern.ReplaceAllStringFunc(out, func(match string) string {
|
||||
return leakedAgentResultTagPattern.ReplaceAllString(match, "")
|
||||
})
|
||||
out = leakedAgentWrapperTagPattern.ReplaceAllString(out, "")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -41,3 +41,28 @@ func TestSanitizeLeakedOutputPreservesStandaloneResultTags(t *testing.T) {
|
||||
t.Fatalf("unexpected sanitize result for standalone result tag: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeLeakedOutputRemovesDanglingAgentXMLOpeningTags(t *testing.T) {
|
||||
raw := "Done.<attempt_completion><result>Some final answer"
|
||||
got := sanitizeLeakedOutput(raw)
|
||||
if got != "Done.Some final answer" {
|
||||
t.Fatalf("unexpected sanitize result for dangling opening tags: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeLeakedOutputRemovesDanglingAgentXMLClosingTags(t *testing.T) {
|
||||
raw := "Done.Some final answer</result></attempt_completion>"
|
||||
got := sanitizeLeakedOutput(raw)
|
||||
if got != "Done.Some final answer" {
|
||||
t.Fatalf("unexpected sanitize result for dangling closing tags: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeLeakedOutputPreservesUnrelatedResultTagsWhenWrapperLeaks(t *testing.T) {
|
||||
raw := "Done.<attempt_completion><result>Some final answer\nExample XML: <result>value</result>"
|
||||
got := sanitizeLeakedOutput(raw)
|
||||
want := "Done.Some final answer\nExample XML: <result>value</result>"
|
||||
if got != want {
|
||||
t.Fatalf("unexpected sanitize result for mixed leaked wrapper + xml example: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
100
internal/adapter/openai/tool_sieve_functioncall.go
Normal file
100
internal/adapter/openai/tool_sieve_functioncall.go
Normal 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 == '_'
|
||||
}
|
||||
23
internal/adapter/openai/tool_sieve_functioncall_test.go
Normal file
23
internal/adapter/openai/tool_sieve_functioncall_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,8 @@ type toolCallDelta struct {
|
||||
Arguments string
|
||||
}
|
||||
|
||||
const toolSieveContextTailLimit = 256
|
||||
// Keep in sync with JS TOOL_SIEVE_CONTEXT_TAIL_LIMIT.
|
||||
const toolSieveContextTailLimit = 2048
|
||||
|
||||
func (s *toolStreamSieveState) resetIncrementalToolState() {
|
||||
s.disableDeltas = false
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,6 +21,9 @@ type ConfigStore interface {
|
||||
Update(mutator func(*config.Config) error) error
|
||||
ExportJSONAndBase64() (string, string, error)
|
||||
IsEnvBacked() bool
|
||||
IsEnvWritebackEnabled() bool
|
||||
HasEnvConfigSource() bool
|
||||
ConfigPath() string
|
||||
SetVercelSync(hash string, ts int64) error
|
||||
AdminPasswordHash() string
|
||||
AdminJWTExpireHours() int
|
||||
|
||||
@@ -8,9 +8,12 @@ import (
|
||||
func (h *Handler) getConfig(w http.ResponseWriter, _ *http.Request) {
|
||||
snap := h.Store.Snapshot()
|
||||
safe := map[string]any{
|
||||
"keys": snap.Keys,
|
||||
"accounts": []map[string]any{},
|
||||
"env_backed": h.Store.IsEnvBacked(),
|
||||
"keys": snap.Keys,
|
||||
"accounts": []map[string]any{},
|
||||
"env_backed": h.Store.IsEnvBacked(),
|
||||
"env_source_present": h.Store.HasEnvConfigSource(),
|
||||
"env_writeback_enabled": h.Store.IsEnvWritebackEnabled(),
|
||||
"config_path": h.Store.ConfigPath(),
|
||||
"claude_mapping": func() map[string]string {
|
||||
if len(snap.ClaudeMapping) > 0 {
|
||||
return snap.ClaudeMapping
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -79,6 +80,111 @@ func TestLoadStorePreservesFileBackedTokensForRuntime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvBackedStoreWritebackBootstrapsMissingConfigFile(t *testing.T) {
|
||||
tmp, err := os.CreateTemp(t.TempDir(), "config-*.json")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp config: %v", err)
|
||||
}
|
||||
path := tmp.Name()
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(path)
|
||||
|
||||
t.Setenv("DS2API_CONFIG_JSON", `{"keys":["k1"],"accounts":[{"email":"seed@example.com","password":"p"}]}`)
|
||||
t.Setenv("CONFIG_JSON", "")
|
||||
t.Setenv("DS2API_CONFIG_PATH", path)
|
||||
t.Setenv("DS2API_ENV_WRITEBACK", "1")
|
||||
|
||||
store := LoadStore()
|
||||
if store.IsEnvBacked() {
|
||||
t.Fatalf("expected writeback bootstrap to become file-backed immediately")
|
||||
}
|
||||
if err := store.Update(func(c *Config) error {
|
||||
c.Accounts = append(c.Accounts, Account{Email: "new@example.com", Password: "p2"})
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("update failed: %v", err)
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read written config: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(content), "seed@example.com") {
|
||||
t.Fatalf("expected bootstrapped config to contain seed account, got: %s", content)
|
||||
}
|
||||
if !strings.Contains(string(content), "new@example.com") {
|
||||
t.Fatalf("expected persisted config to contain added account, got: %s", content)
|
||||
}
|
||||
|
||||
reloaded := LoadStore()
|
||||
if reloaded.IsEnvBacked() {
|
||||
t.Fatalf("expected reloaded store to prefer persisted config file")
|
||||
}
|
||||
accounts := reloaded.Accounts()
|
||||
if len(accounts) != 2 {
|
||||
t.Fatalf("expected 2 accounts after reload, got %d", len(accounts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvBackedStoreWritebackDoesNotBootstrapOnInvalidEnvJSON(t *testing.T) {
|
||||
tmp, err := os.CreateTemp(t.TempDir(), "config-*.json")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp config: %v", err)
|
||||
}
|
||||
path := tmp.Name()
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(path)
|
||||
|
||||
t.Setenv("DS2API_CONFIG_JSON", "{invalid-json")
|
||||
t.Setenv("CONFIG_JSON", "")
|
||||
t.Setenv("DS2API_CONFIG_PATH", path)
|
||||
t.Setenv("DS2API_ENV_WRITEBACK", "1")
|
||||
|
||||
cfg, fromEnv, loadErr := loadConfig()
|
||||
if loadErr == nil {
|
||||
t.Fatalf("expected loadConfig error for invalid env json")
|
||||
}
|
||||
if !fromEnv {
|
||||
t.Fatalf("expected fromEnv=true when parsing env config fails")
|
||||
}
|
||||
if len(cfg.Keys) != 0 || len(cfg.Accounts) != 0 {
|
||||
t.Fatalf("expected empty config on parse failure, got keys=%d accounts=%d", len(cfg.Keys), len(cfg.Accounts))
|
||||
}
|
||||
if _, statErr := os.Stat(path); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("expected no bootstrapped config file, stat err=%v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvBackedStoreWritebackFallsBackToPersistedFileOnInvalidEnvJSON(t *testing.T) {
|
||||
tmp, err := os.CreateTemp(t.TempDir(), "config-*.json")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp config: %v", err)
|
||||
}
|
||||
path := tmp.Name()
|
||||
if _, err := tmp.WriteString(`{"keys":["file-key"],"accounts":[{"email":"persisted@example.com","password":"p"}]}`); err != nil {
|
||||
t.Fatalf("write temp config: %v", err)
|
||||
}
|
||||
_ = tmp.Close()
|
||||
|
||||
t.Setenv("DS2API_CONFIG_JSON", "{invalid-json")
|
||||
t.Setenv("CONFIG_JSON", "")
|
||||
t.Setenv("DS2API_CONFIG_PATH", path)
|
||||
t.Setenv("DS2API_ENV_WRITEBACK", "1")
|
||||
|
||||
cfg, fromEnv, loadErr := loadConfig()
|
||||
if loadErr != nil {
|
||||
t.Fatalf("expected fallback to persisted file, got error: %v", loadErr)
|
||||
}
|
||||
if fromEnv {
|
||||
t.Fatalf("expected fallback to file-backed mode")
|
||||
}
|
||||
if len(cfg.Keys) != 1 || cfg.Keys[0] != "file-key" {
|
||||
t.Fatalf("unexpected keys after fallback: %#v", cfg.Keys)
|
||||
}
|
||||
if len(cfg.Accounts) != 1 || cfg.Accounts[0].Email != "persisted@example.com" {
|
||||
t.Fatalf("unexpected accounts after fallback: %#v", cfg.Accounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeTokenRefreshIntervalHoursDefaultsToSix(t *testing.T) {
|
||||
t.Setenv("DS2API_CONFIG_JSON", `{
|
||||
"keys":["k1"],
|
||||
|
||||
@@ -40,12 +40,38 @@ func loadConfig() (Config, bool, error) {
|
||||
}
|
||||
if rawCfg != "" {
|
||||
cfg, err := parseConfigString(rawCfg)
|
||||
if err != nil {
|
||||
if !IsVercel() && envWritebackEnabled() {
|
||||
if fileCfg, fileErr := loadConfigFromFile(ConfigPath()); fileErr == nil {
|
||||
return fileCfg, false, nil
|
||||
}
|
||||
}
|
||||
return cfg, true, err
|
||||
}
|
||||
cfg.ClearAccountTokens()
|
||||
cfg.DropInvalidAccounts()
|
||||
if IsVercel() || !envWritebackEnabled() {
|
||||
return cfg, true, err
|
||||
}
|
||||
content, fileErr := os.ReadFile(ConfigPath())
|
||||
if fileErr == nil {
|
||||
var fileCfg Config
|
||||
if unmarshalErr := json.Unmarshal(content, &fileCfg); unmarshalErr == nil {
|
||||
fileCfg.DropInvalidAccounts()
|
||||
return fileCfg, false, err
|
||||
}
|
||||
}
|
||||
if errors.Is(fileErr, os.ErrNotExist) {
|
||||
if writeErr := writeConfigFile(ConfigPath(), cfg.Clone()); writeErr == nil {
|
||||
return cfg, false, err
|
||||
} else {
|
||||
Logger.Warn("[config] env writeback bootstrap failed", "error", writeErr)
|
||||
}
|
||||
}
|
||||
return cfg, true, err
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(ConfigPath())
|
||||
cfg, err := loadConfigFromFile(ConfigPath())
|
||||
if err != nil {
|
||||
if IsVercel() {
|
||||
// Vercel one-click deploy may start without a writable/present config file.
|
||||
@@ -54,16 +80,6 @@ func loadConfig() (Config, bool, error) {
|
||||
}
|
||||
return Config{}, false, err
|
||||
}
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(content, &cfg); err != nil {
|
||||
return Config{}, false, err
|
||||
}
|
||||
cfg.DropInvalidAccounts()
|
||||
if strings.Contains(string(content), `"test_status"`) && !IsVercel() {
|
||||
if b, err := json.MarshalIndent(cfg, "", " "); err == nil {
|
||||
_ = os.WriteFile(ConfigPath(), b, 0o644)
|
||||
}
|
||||
}
|
||||
if IsVercel() {
|
||||
// Vercel filesystem is ephemeral/read-only for runtime writes; avoid save errors.
|
||||
return cfg, true, nil
|
||||
@@ -71,6 +87,24 @@ func loadConfig() (Config, bool, error) {
|
||||
return cfg, false, nil
|
||||
}
|
||||
|
||||
func loadConfigFromFile(path string) (Config, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(content, &cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.DropInvalidAccounts()
|
||||
if strings.Contains(string(content), `"test_status"`) && !IsVercel() {
|
||||
if b, err := json.MarshalIndent(cfg, "", " "); err == nil {
|
||||
_ = os.WriteFile(path, b, 0o644)
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *Store) Snapshot() Config {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
@@ -177,7 +211,7 @@ func (s *Store) Update(mutator func(*Config) error) error {
|
||||
func (s *Store) Save() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.fromEnv {
|
||||
if s.fromEnv && (IsVercel() || !envWritebackEnabled()) {
|
||||
Logger.Info("[save_config] source from env, skip write")
|
||||
return nil
|
||||
}
|
||||
@@ -187,11 +221,15 @@ func (s *Store) Save() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(s.path, b, 0o644)
|
||||
if err := writeConfigBytes(s.path, b); err != nil {
|
||||
return err
|
||||
}
|
||||
s.fromEnv = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) saveLocked() error {
|
||||
if s.fromEnv {
|
||||
if s.fromEnv && (IsVercel() || !envWritebackEnabled()) {
|
||||
Logger.Info("[save_config] source from env, skip write")
|
||||
return nil
|
||||
}
|
||||
@@ -201,7 +239,11 @@ func (s *Store) saveLocked() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(s.path, b, 0o644)
|
||||
if err := writeConfigBytes(s.path, b); err != nil {
|
||||
return err
|
||||
}
|
||||
s.fromEnv = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) IsEnvBacked() bool {
|
||||
|
||||
51
internal/config/store_env_writeback.go
Normal file
51
internal/config/store_env_writeback.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func envWritebackEnabled() bool {
|
||||
v := strings.ToLower(strings.TrimSpace(os.Getenv("DS2API_ENV_WRITEBACK")))
|
||||
return v == "1" || v == "true" || v == "yes" || v == "on"
|
||||
}
|
||||
|
||||
func (s *Store) IsEnvWritebackEnabled() bool {
|
||||
return envWritebackEnabled()
|
||||
}
|
||||
|
||||
func (s *Store) HasEnvConfigSource() bool {
|
||||
rawCfg := strings.TrimSpace(os.Getenv("DS2API_CONFIG_JSON"))
|
||||
if rawCfg == "" {
|
||||
rawCfg = strings.TrimSpace(os.Getenv("CONFIG_JSON"))
|
||||
}
|
||||
return rawCfg != ""
|
||||
}
|
||||
|
||||
func (s *Store) ConfigPath() string {
|
||||
return s.path
|
||||
}
|
||||
|
||||
func writeConfigFile(path string, cfg Config) error {
|
||||
persistCfg := cfg.Clone()
|
||||
persistCfg.ClearAccountTokens()
|
||||
b, err := json.MarshalIndent(persistCfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeConfigBytes(path, b)
|
||||
}
|
||||
|
||||
func writeConfigBytes(path string, b []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
if dir == "." || dir == "" {
|
||||
return os.WriteFile(path, b, 0o644)
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir config dir: %w", err)
|
||||
}
|
||||
return os.WriteFile(path, b, 0o644)
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -102,7 +112,10 @@ function extractToolCallObjects(text) {
|
||||
const obj = extractJSONObjectFrom(raw, start);
|
||||
if (obj.ok) {
|
||||
out.push(raw.slice(start, obj.end).trim());
|
||||
offset = obj.end;
|
||||
// Ensure forward progress even when the matched keyword is outside
|
||||
// the extracted JSON object (e.g. closing XML wrapper tags containing
|
||||
// "tool_calls" after an earlier JSON arguments object).
|
||||
offset = Math.max(obj.end, idx + matched.length);
|
||||
idx = -1;
|
||||
break;
|
||||
}
|
||||
@@ -324,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) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const TOOL_SIEVE_CONTEXT_TAIL_LIMIT = 4096;
|
||||
// Keep in sync with Go toolSieveContextTailLimit.
|
||||
const TOOL_SIEVE_CONTEXT_TAIL_LIMIT = 2048;
|
||||
|
||||
function createToolSieveState() {
|
||||
return {
|
||||
|
||||
@@ -4,6 +4,8 @@ const TOOL_SEGMENT_KEYWORDS = [
|
||||
'tool_calls',
|
||||
'"function"',
|
||||
'function.name:',
|
||||
'functioncall',
|
||||
'"tool_use"',
|
||||
];
|
||||
|
||||
const XML_TOOL_SEGMENT_TAGS = [
|
||||
|
||||
@@ -5,6 +5,12 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
var promptXMLTextEscaper = strings.NewReplacer(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
)
|
||||
|
||||
// FormatToolCallsForPrompt renders a tool_calls slice into the canonical
|
||||
// prompt-visible history block used across adapters.
|
||||
func FormatToolCallsForPrompt(raw any) string {
|
||||
@@ -82,8 +88,8 @@ func formatToolCallForPrompt(call map[string]any) string {
|
||||
}
|
||||
|
||||
return " <tool_call>\n" +
|
||||
" <tool_name>" + name + "</tool_name>\n" +
|
||||
" <parameters>" + StringifyToolCallArguments(argsRaw) + "</parameters>\n" +
|
||||
" <tool_name>" + escapeXMLText(name) + "</tool_name>\n" +
|
||||
" <parameters>" + escapeXMLText(StringifyToolCallArguments(argsRaw)) + "</parameters>\n" +
|
||||
" </tool_call>"
|
||||
}
|
||||
|
||||
@@ -122,3 +128,10 @@ func asString(v any) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func escapeXMLText(v string) string {
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
return promptXMLTextEscaper.Replace(v)
|
||||
}
|
||||
|
||||
@@ -26,3 +26,16 @@ func TestFormatToolCallsForPromptXML(t *testing.T) {
|
||||
t.Fatalf("unexpected formatted tool call XML: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatToolCallsForPromptEscapesXMLEntities(t *testing.T) {
|
||||
got := FormatToolCallsForPrompt([]any{
|
||||
map[string]any{
|
||||
"name": "search<&>",
|
||||
"arguments": `{"q":"a < b && c > d"}`,
|
||||
},
|
||||
})
|
||||
want := "<tool_calls>\n <tool_call>\n <tool_name>search<&></tool_name>\n <parameters>{\"q\":\"a < b && c > d\"}</parameters>\n </tool_call>\n</tool_calls>"
|
||||
if got != want {
|
||||
t.Fatalf("unexpected escaped tool call XML: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
31
internal/sse/content_filter_leak.go
Normal file
31
internal/sse/content_filter_leak.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package sse
|
||||
|
||||
import "strings"
|
||||
|
||||
func filterLeakedContentFilterParts(parts []ContentPart) []ContentPart {
|
||||
if len(parts) == 0 {
|
||||
return parts
|
||||
}
|
||||
out := make([]ContentPart, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
cleaned := stripLeakedContentFilterSuffix(p.Text)
|
||||
if strings.TrimSpace(cleaned) == "" {
|
||||
continue
|
||||
}
|
||||
p.Text = cleaned
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stripLeakedContentFilterSuffix(text string) string {
|
||||
if text == "" {
|
||||
return text
|
||||
}
|
||||
upperText := strings.ToUpper(text)
|
||||
idx := strings.Index(upperText, "CONTENT_FILTER")
|
||||
if idx < 0 {
|
||||
return text
|
||||
}
|
||||
return strings.TrimRight(text[:idx], " \t\r\n")
|
||||
}
|
||||
@@ -40,6 +40,7 @@ func ParseDeepSeekContentLine(raw []byte, thinkingEnabled bool, currentType stri
|
||||
}
|
||||
}
|
||||
parts, finished, nextType := ParseSSEChunkForContent(chunk, thinkingEnabled, currentType)
|
||||
parts = filterLeakedContentFilterParts(parts)
|
||||
return LineResult{
|
||||
Parsed: true,
|
||||
Stop: finished,
|
||||
|
||||
@@ -35,3 +35,33 @@ func TestParseDeepSeekContentLineContent(t *testing.T) {
|
||||
t.Fatalf("unexpected parts: %#v", res.Parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDeepSeekContentLineStripsLeakedContentFilterSuffix(t *testing.T) {
|
||||
res := ParseDeepSeekContentLine([]byte(`data: {"p":"response/content","v":"正常输出CONTENT_FILTER你好,这个问题我暂时无法回答"}`), false, "text")
|
||||
if !res.Parsed || res.Stop {
|
||||
t.Fatalf("expected parsed non-stop result: %#v", res)
|
||||
}
|
||||
if len(res.Parts) != 1 || res.Parts[0].Text != "正常输出" {
|
||||
t.Fatalf("unexpected parts after filter: %#v", res.Parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDeepSeekContentLineDropsPureLeakedContentFilterChunk(t *testing.T) {
|
||||
res := ParseDeepSeekContentLine([]byte(`data: {"p":"response/content","v":"CONTENT_FILTER你好,这个问题我暂时无法回答"}`), false, "text")
|
||||
if !res.Parsed || res.Stop {
|
||||
t.Fatalf("expected parsed non-stop result: %#v", res)
|
||||
}
|
||||
if len(res.Parts) != 0 {
|
||||
t.Fatalf("expected empty parts, got %#v", res.Parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDeepSeekContentLineTrimsFromContentFilterKeyword(t *testing.T) {
|
||||
res := ParseDeepSeekContentLine([]byte(`data: {"p":"response/content","v":"模型会在命中 CONTENT_FILTER 时返回拒绝原因。"}`), false, "text")
|
||||
if !res.Parsed || res.Stop {
|
||||
t.Fatalf("expected parsed non-stop result: %#v", res)
|
||||
}
|
||||
if len(res.Parts) != 1 || res.Parts[0].Text != "模型会在命中" {
|
||||
t.Fatalf("unexpected parts after filter: %#v", res.Parts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"}]}`
|
||||
}
|
||||
}
|
||||
|
||||
26
internal/util/tool_prompt_test.go
Normal file
26
internal/util/tool_prompt_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 := ""
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
88
internal/util/toolcalls_parse_item.go
Normal file
88
internal/util/toolcalls_parse_item.go
Normal 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
|
||||
}
|
||||
@@ -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"})
|
||||
|
||||
@@ -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',
|
||||
@@ -227,6 +245,24 @@ test('sieve flushes incomplete captured XML tool blocks without leaking raw tags
|
||||
assert.equal(leakedText.includes('<tool_call'), false);
|
||||
});
|
||||
|
||||
test('sieve captures XML wrapper tags with attributes without leaking wrapper text', () => {
|
||||
const events = runSieve(
|
||||
[
|
||||
'前置正文H。',
|
||||
'<tool_calls id="x"><tool_call><tool_name>read_file</tool_name><parameters>{"path":"README.MD"}</parameters></tool_call></tool_calls>',
|
||||
'后置正文I。',
|
||||
],
|
||||
['read_file'],
|
||||
);
|
||||
const leakedText = collectText(events);
|
||||
const hasToolCall = events.some((evt) => evt.type === 'tool_calls' && evt.calls?.length > 0);
|
||||
assert.equal(hasToolCall, true);
|
||||
assert.equal(leakedText.includes('前置正文H。'), true);
|
||||
assert.equal(leakedText.includes('后置正文I。'), true);
|
||||
assert.equal(leakedText.includes('<tool_calls id=\"x\">'), false);
|
||||
assert.equal(leakedText.includes('</tool_calls>'), false);
|
||||
});
|
||||
|
||||
test('sieve still intercepts large tool json payloads over previous capture limit', () => {
|
||||
const large = 'a'.repeat(9000);
|
||||
const payload = `{"tool_calls":[{"name":"read_file","input":{"path":"${large}"}}]}`;
|
||||
@@ -252,6 +288,46 @@ test('sieve keeps plain text intact in tool mode when no tool call appears', ()
|
||||
assert.equal(leakedText, '你好,这是普通文本回复。请继续。');
|
||||
});
|
||||
|
||||
test('sieve keeps plain "tool_calls" prose as text when no valid payload follows', () => {
|
||||
const events = runSieve(
|
||||
['前置。', '这里提到 tool_calls 只是解释,不是调用。', '后置。'],
|
||||
['read_file'],
|
||||
);
|
||||
const leakedText = collectText(events);
|
||||
const hasToolCall = events.some((evt) => evt.type === 'tool_calls' && evt.calls?.length > 0);
|
||||
assert.equal(hasToolCall, false);
|
||||
assert.equal(leakedText.includes('tool_calls'), true);
|
||||
assert.equal(leakedText, '前置。这里提到 tool_calls 只是解释,不是调用。后置。');
|
||||
});
|
||||
|
||||
test('sieve keeps numbered planning prose before a real tool payload (mobile-chat style)', () => {
|
||||
const events = runSieve(
|
||||
[
|
||||
'好的,我会依次测试每个工具,先把所有工具都调用一遍,然后汇总结果给你看。\n\n1. 获取当前时间\n',
|
||||
'{"tool_calls":[{"name":"get_current_time","input":{}}]}',
|
||||
],
|
||||
['get_current_time'],
|
||||
);
|
||||
const leakedText = collectText(events);
|
||||
const finalCalls = events.filter((evt) => evt.type === 'tool_calls').flatMap((evt) => evt.calls || []);
|
||||
assert.equal(finalCalls.length, 1);
|
||||
assert.equal(finalCalls[0].name, 'get_current_time');
|
||||
assert.equal(leakedText.includes('先把所有工具都调用一遍'), true);
|
||||
assert.equal(leakedText.includes('1. 获取当前时间'), true);
|
||||
assert.equal(leakedText.toLowerCase().includes('tool_calls'), false);
|
||||
});
|
||||
|
||||
test('sieve keeps numbered planning prose when no tool payload follows', () => {
|
||||
const events = runSieve(
|
||||
['好的,我会依次测试每个工具。\n\n1. 获取当前时间'],
|
||||
['get_current_time'],
|
||||
);
|
||||
const leakedText = collectText(events);
|
||||
const hasToolCall = events.some((evt) => evt.type === 'tool_calls' && evt.calls?.length > 0);
|
||||
assert.equal(hasToolCall, false);
|
||||
assert.equal(leakedText, '好的,我会依次测试每个工具。\n\n1. 获取当前时间');
|
||||
});
|
||||
|
||||
test('sieve emits unknown tool payload (no args) as executable tool call', () => {
|
||||
const events = runSieve(
|
||||
['{"tool_calls":[{"name":"not_in_schema"}]}', '后置正文G。'],
|
||||
|
||||
@@ -64,6 +64,27 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{Boolean(config?.env_source_present) && (
|
||||
<div className={`rounded-xl border px-4 py-3 text-sm ${
|
||||
config?.env_writeback_enabled
|
||||
? (config?.env_backed ? 'border-amber-500/30 bg-amber-500/10 text-amber-600' : 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600')
|
||||
: 'border-amber-500/30 bg-amber-500/10 text-amber-600'
|
||||
}`}>
|
||||
<p className="font-medium">
|
||||
{config?.env_writeback_enabled
|
||||
? (config?.env_backed
|
||||
? t('accountManager.envModeWritebackPendingTitle')
|
||||
: t('accountManager.envModeWritebackActiveTitle'))
|
||||
: t('accountManager.envModeRiskTitle')}
|
||||
</p>
|
||||
<p className="mt-1 text-xs opacity-90">
|
||||
{config?.env_writeback_enabled
|
||||
? t('accountManager.envModeWritebackDesc', { path: config?.config_path || 'config.json' })
|
||||
: t('accountManager.envModeRiskDesc')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<QueueCards queueStatus={queueStatus} t={t} />
|
||||
|
||||
<ApiKeysPanel
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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.",
|
||||
@@ -138,7 +139,12 @@
|
||||
"sessionCount": "Sessions: {count}",
|
||||
"deleteAllSessions": "Delete all sessions",
|
||||
"deleteAllSessionsConfirm": "Are you sure you want to delete all sessions for this account? This action cannot be undone.",
|
||||
"deleteAllSessionsSuccess": "Successfully deleted all sessions"
|
||||
"deleteAllSessionsSuccess": "Successfully deleted all sessions",
|
||||
"envModeRiskTitle": "Environment-variable config mode detected (persistence risk)",
|
||||
"envModeRiskDesc": "Detected DS2API_CONFIG_JSON/CONFIG_JSON. If DS2API_ENV_WRITEBACK is not enabled, Admin UI edits are in-memory only and may be lost after restart.",
|
||||
"envModeWritebackPendingTitle": "Env mode + auto-persistence enabled (pending file handoff)",
|
||||
"envModeWritebackActiveTitle": "Env mode + auto-persistence active",
|
||||
"envModeWritebackDesc": "The app will auto-create/write the config file and transition to file-backed mode. Current persistence path: {path}"
|
||||
},
|
||||
"apiTester": {
|
||||
"defaultMessage": "Hello, please introduce yourself in one sentence.",
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
"apiKeysDesc": "管理 API 访问密钥池",
|
||||
"addKey": "添加密钥",
|
||||
"copied": "已复制",
|
||||
"copyFailed": "复制失败",
|
||||
"copyKeyTitle": "复制密钥",
|
||||
"deleteKeyTitle": "删除密钥",
|
||||
"noApiKeys": "未找到 API 密钥",
|
||||
@@ -138,7 +139,12 @@
|
||||
"sessionCount": "会话: {count}",
|
||||
"deleteAllSessions": "删除所有会话",
|
||||
"deleteAllSessionsConfirm": "确定要删除该账号的所有会话吗?此操作不可恢复。",
|
||||
"deleteAllSessionsSuccess": "删除成功"
|
||||
"deleteAllSessionsSuccess": "删除成功",
|
||||
"envModeRiskTitle": "当前为环境变量配置模式(有持久化风险)",
|
||||
"envModeRiskDesc": "检测到 DS2API_CONFIG_JSON/CONFIG_JSON。若未开启 DS2API_ENV_WRITEBACK,管理台改动仅在内存生效,重启可能丢失。",
|
||||
"envModeWritebackPendingTitle": "环境变量模式 + 自动持久化已开启(等待落盘)",
|
||||
"envModeWritebackActiveTitle": "环境变量模式 + 自动持久化已生效",
|
||||
"envModeWritebackDesc": "程序会自动创建/写入配置文件并在后续切换为文件模式。当前持久化路径:{path}"
|
||||
},
|
||||
"apiTester": {
|
||||
"defaultMessage": "你好,请用一句话介绍你自己。",
|
||||
|
||||
Reference in New Issue
Block a user