Fix tool loop handling and count tokens endpoint
This commit is contained in:
@@ -231,9 +231,20 @@ func (a *App) forceQuit() {
|
|||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
a.emitLog("info", "正在停止代理并退出应用")
|
a.emitLog("info", "正在停止代理并退出应用")
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
if err := a.StopProxy(); err != nil {
|
if err := a.StopProxy(); err != nil {
|
||||||
runtime.LogWarningf(a.ctx, "stop proxy before exit failed: %v", err)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user