Fix tool loop handling and count tokens endpoint

This commit is contained in:
lutc5
2026-05-06 15:58:37 +08:00
parent 1c349227a3
commit fe1d5b5348
6 changed files with 182 additions and 7 deletions

View File

@@ -231,8 +231,19 @@ func (a *App) forceQuit() {
a.mu.Unlock() a.mu.Unlock()
a.emitLog("info", "正在停止代理并退出应用") a.emitLog("info", "正在停止代理并退出应用")
if err := a.StopProxy(); err != nil {
runtime.LogWarningf(a.ctx, "stop proxy before exit failed: %v", err) done := make(chan struct{})
go func() {
if err := a.StopProxy(); err != nil {
runtime.LogWarningf(a.ctx, "stop proxy before exit failed: %v", err)
}
close(done)
}()
select {
case <-done:
case <-time.After(1200 * time.Millisecond):
runtime.LogWarning(a.ctx, "force quit continuing before proxy shutdown completed")
} }
os.Exit(0) os.Exit(0)
} }

View File

@@ -15,6 +15,7 @@ const status = ref({ running: false, addr: '', models: 0 })
const toast = ref('') const toast = ref('')
const themeMode = ref(localStorage.getItem('lingma-theme-mode') || 'system') const themeMode = ref(localStorage.getItem('lingma-theme-mode') || 'system')
const appliedTheme = ref('light') const appliedTheme = ref('light')
const forceQuitting = ref(false)
let systemThemeQuery = null let systemThemeQuery = null
let toastTimer = null let toastTimer = null
@@ -106,12 +107,13 @@ async function copyEndpoint() {
} }
async function forceQuitApp() { async function forceQuitApp() {
const confirmed = window.confirm('确定要停止代理并退出应用吗?') if (forceQuitting.value) return
if (!confirmed) return forceQuitting.value = true
showToast('正在停止代理并退出应用...') showToast('正在停止代理并退出应用...')
try { try {
await ForceQuitApp() await ForceQuitApp()
} catch (e) { } catch (e) {
forceQuitting.value = false
addLog('error', '退出应用失败:' + (e.message || String(e))) addLog('error', '退出应用失败:' + (e.message || String(e)))
} }
} }
@@ -271,7 +273,7 @@ onUnmounted(() => {
<button class="icon-button" type="button" :title="themeTitle()" @click="toggleTheme"> <button class="icon-button" type="button" :title="themeTitle()" @click="toggleTheme">
<i class="bi" :class="themeIcon()" aria-hidden="true"></i> <i class="bi" :class="themeIcon()" aria-hidden="true"></i>
</button> </button>
<button class="icon-button danger-icon-button" type="button" title="停止代理并退出应用" @click="forceQuitApp"> <button class="icon-button danger-icon-button" type="button" title="停止代理并退出应用" :disabled="forceQuitting" @click="forceQuitApp">
<i class="bi bi-power" aria-hidden="true"></i> <i class="bi bi-power" aria-hidden="true"></i>
</button> </button>
</div> </div>

View File

@@ -117,6 +117,7 @@ func NewServer(addr string, svc *service.Service) *Server {
mux.HandleFunc("/v1/props", s.handleModelProps) mux.HandleFunc("/v1/props", s.handleModelProps)
mux.HandleFunc("/props", s.handleModelProps) mux.HandleFunc("/props", s.handleModelProps)
mux.HandleFunc("/version", s.handleVersion) mux.HandleFunc("/version", s.handleVersion)
mux.HandleFunc("/v1/messages/count_tokens", s.handleAnthropicCountTokens)
mux.HandleFunc("/v1/messages", s.handleAnthropicMessages) mux.HandleFunc("/v1/messages", s.handleAnthropicMessages)
mux.HandleFunc("/v1/chat/completions", s.handleOpenAIChatCompletions) mux.HandleFunc("/v1/chat/completions", s.handleOpenAIChatCompletions)
mux.HandleFunc("/api/v1/chat/completions", s.handleOpenAIChatCompletions) mux.HandleFunc("/api/v1/chat/completions", s.handleOpenAIChatCompletions)
@@ -446,6 +447,27 @@ func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
}) })
} }
func (s *Server) handleAnthropicCountTokens(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
if r.Method != http.MethodPost {
writeAnthropicError(w, http.StatusMethodNotAllowed, "invalid_request_error", "method not allowed")
return
}
var req anthropicRequest
if err := decodeJSON(r, &req); err != nil {
writeAnthropicError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"input_tokens": estimateAnthropicInputTokens(req),
})
}
func (s *Server) handleAnthropicMessages(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAnthropicMessages(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodOptions { if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
@@ -1292,6 +1314,9 @@ func anthropicHostedWebSearchCall(req anthropicRequest) (toolemulation.ToolCall,
if !hasAnthropicHostedWebSearchTool(req.Tools) { if !hasAnthropicHostedWebSearchTool(req.Tools) {
return toolemulation.ToolCall{}, false return toolemulation.ToolCall{}, false
} }
if hasAnthropicToolResult(req.Messages) {
return toolemulation.ToolCall{}, false
}
if !anthropicHostedWebSearchRequested(req.Tools, req.ToolChoice) { if !anthropicHostedWebSearchRequested(req.Tools, req.ToolChoice) {
return toolemulation.ToolCall{}, false return toolemulation.ToolCall{}, false
} }
@@ -1307,6 +1332,46 @@ func anthropicHostedWebSearchCall(req anthropicRequest) (toolemulation.ToolCall,
}, true }, true
} }
func hasAnthropicToolResult(messages []rawMessage) bool {
for _, message := range messages {
items, ok := message.Content.([]any)
if !ok {
continue
}
for _, item := range items {
m, ok := item.(map[string]any)
if ok && stringFromAny(m["type"]) == "tool_result" {
return true
}
}
}
return false
}
func estimateAnthropicInputTokens(req anthropicRequest) int {
payload := map[string]any{
"model": req.Model,
"system": req.System,
"messages": req.Messages,
"tools": req.Tools,
"tool_choice": req.ToolChoice,
"thinking": req.Thinking,
}
raw, err := json.Marshal(payload)
if err != nil {
return 1
}
runes := len([]rune(string(raw)))
if runes == 0 {
return 1
}
tokens := (runes + 2) / 3
if tokens < 1 {
return 1
}
return tokens
}
func hasAnthropicHostedWebSearchTool(raw any) bool { func hasAnthropicHostedWebSearchTool(raw any) bool {
items, ok := raw.([]any) items, ok := raw.([]any)
if !ok { if !ok {

View File

@@ -218,6 +218,64 @@ func TestAnthropicHostedWebSearchCallIgnoresRegularClientWebSearch(t *testing.T)
} }
} }
func TestAnthropicHostedWebSearchCallIgnoresToolResultFollowup(t *testing.T) {
req := anthropicRequest{
Tools: []any{
map[string]any{
"name": "web_search",
"type": "web_search_20250305",
},
},
ToolChoice: map[string]any{
"type": "tool",
"name": "web_search",
},
Messages: []rawMessage{{
Role: "user",
Content: []any{
map[string]any{
"type": "tool_result",
"tool_use_id": "toolu_123",
"content": "result",
},
},
}},
}
if _, ok := anthropicHostedWebSearchCall(req); ok {
t.Fatal("hosted web_search should not short-circuit after a tool_result")
}
}
func TestAnthropicCountTokensEndpoint(t *testing.T) {
server := NewServer("", service.New(service.Config{
Model: "Qwen3-Coder",
Timeout: time.Second,
}))
req := httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", strings.NewReader(`{
"model":"kmodel",
"max_tokens":128,
"system":"You are concise.",
"messages":[{"role":"user","content":"hello"}],
"tools":[{"name":"read_file","input_schema":{"type":"object","properties":{"file_path":{"type":"string"}},"required":["file_path"]}}]
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
server.http.Handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", rec.Code, rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["input_tokens"].(float64) <= 0 {
t.Fatalf("input_tokens = %#v", body["input_tokens"])
}
}
func TestDiscoveryCompatibilityEndpoints(t *testing.T) { func TestDiscoveryCompatibilityEndpoints(t *testing.T) {
server := NewServer("", service.New(service.Config{ server := NewServer("", service.New(service.Config{
Model: "Qwen3-Coder", Model: "Qwen3-Coder",

View File

@@ -283,10 +283,11 @@ func ActionOutputPrompt(toolCallID string, output string) string {
if output == "" { if output == "" {
return "" return ""
} }
next := "Based on the tool result above, answer the user's request directly if you have enough information. Only use another structured action block if a specific missing fact still requires another tool call."
if id := strings.TrimSpace(toolCallID); id != "" { if id := strings.TrimSpace(toolCallID); id != "" {
return "Tool result for " + id + ":\n" + output + "\n\nBased on the tool result above, continue with the next appropriate action using the structured format." return "Tool result for " + id + ":\n" + output + "\n\n" + next
} }
return "Tool result:\n" + output + "\n\nBased on the tool result above, continue with the next appropriate action using the structured format." return "Tool result:\n" + output + "\n\n" + next
} }
func ActionBlockExample(tools []ToolDef) string { func ActionBlockExample(tools []ToolDef) string {
@@ -629,6 +630,9 @@ func ParseActionBlocks(text string, tools []ToolDef, cfg Config) ([]ToolCall, st
// Filter arguments against the tool's input schema to strip unknown params // Filter arguments against the tool's input schema to strip unknown params
if schema, ok := toolSchemaMap[call.Name]; ok && len(schema) > 0 { if schema, ok := toolSchemaMap[call.Name]; ok && len(schema) > 0 {
call.Arguments = filterArgsBySchema(call.Arguments, schema) call.Arguments = filterArgsBySchema(call.Arguments, schema)
if !hasRequiredArgs(call.Arguments, schema) {
continue
}
} }
calls = append(calls, call) calls = append(calls, call)
spans = append(spans, span{start: start, end: end + 3}) spans = append(spans, span{start: start, end: end + 3})
@@ -1011,6 +1015,19 @@ func filterArgsBySchema(args map[string]any, schema map[string]any) map[string]a
return out return out
} }
func hasRequiredArgs(args map[string]any, schema map[string]any) bool {
for _, key := range requiredKeys(schema) {
value, ok := args[key]
if !ok {
return false
}
if s, ok := value.(string); ok && strings.TrimSpace(s) == "" {
return false
}
}
return true
}
func cloneMap(src map[string]any) map[string]any { func cloneMap(src map[string]any) map[string]any {
if src == nil { if src == nil {
return nil return nil

View File

@@ -154,3 +154,25 @@ func TestParseActionBlocksMapsReadAlias(t *testing.T) {
t.Fatalf("calls = %+v", calls) t.Fatalf("calls = %+v", calls)
} }
} }
func TestParseActionBlocksDropsCallsMissingRequiredArgs(t *testing.T) {
text := "```json action\n{\"tool\":\"Read\",\"parameters\":{\"path\":\"/tmp/a.txt\"}}\n```"
calls, clean, err := ParseActionBlocks(text, []ToolDef{{
Name: "Read",
InputSchema: map[string]any{
"properties": map[string]any{
"file_path": map[string]any{"type": "string"},
},
"required": []any{"file_path"},
},
}}, Config{})
if err != nil {
t.Fatal(err)
}
if len(calls) != 0 {
t.Fatalf("expected no calls, got %+v", calls)
}
if !strings.Contains(clean, "\"path\"") {
t.Fatalf("clean should preserve unparseable action block, got %q", clean)
}
}