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.emitLog("info", "正在停止代理并退出应用")
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ const status = ref({ running: false, addr: '', models: 0 })
|
||||
const toast = ref('')
|
||||
const themeMode = ref(localStorage.getItem('lingma-theme-mode') || 'system')
|
||||
const appliedTheme = ref('light')
|
||||
const forceQuitting = ref(false)
|
||||
let systemThemeQuery = null
|
||||
let toastTimer = null
|
||||
|
||||
@@ -106,12 +107,13 @@ async function copyEndpoint() {
|
||||
}
|
||||
|
||||
async function forceQuitApp() {
|
||||
const confirmed = window.confirm('确定要停止代理并退出应用吗?')
|
||||
if (!confirmed) return
|
||||
if (forceQuitting.value) return
|
||||
forceQuitting.value = true
|
||||
showToast('正在停止代理并退出应用...')
|
||||
try {
|
||||
await ForceQuitApp()
|
||||
} catch (e) {
|
||||
forceQuitting.value = false
|
||||
addLog('error', '退出应用失败:' + (e.message || String(e)))
|
||||
}
|
||||
}
|
||||
@@ -271,7 +273,7 @@ onUnmounted(() => {
|
||||
<button class="icon-button" type="button" :title="themeTitle()" @click="toggleTheme">
|
||||
<i class="bi" :class="themeIcon()" aria-hidden="true"></i>
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -117,6 +117,7 @@ func NewServer(addr string, svc *service.Service) *Server {
|
||||
mux.HandleFunc("/v1/props", s.handleModelProps)
|
||||
mux.HandleFunc("/props", s.handleModelProps)
|
||||
mux.HandleFunc("/version", s.handleVersion)
|
||||
mux.HandleFunc("/v1/messages/count_tokens", s.handleAnthropicCountTokens)
|
||||
mux.HandleFunc("/v1/messages", s.handleAnthropicMessages)
|
||||
mux.HandleFunc("/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) {
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -1292,6 +1314,9 @@ func anthropicHostedWebSearchCall(req anthropicRequest) (toolemulation.ToolCall,
|
||||
if !hasAnthropicHostedWebSearchTool(req.Tools) {
|
||||
return toolemulation.ToolCall{}, false
|
||||
}
|
||||
if hasAnthropicToolResult(req.Messages) {
|
||||
return toolemulation.ToolCall{}, false
|
||||
}
|
||||
if !anthropicHostedWebSearchRequested(req.Tools, req.ToolChoice) {
|
||||
return toolemulation.ToolCall{}, false
|
||||
}
|
||||
@@ -1307,6 +1332,46 @@ func anthropicHostedWebSearchCall(req anthropicRequest) (toolemulation.ToolCall,
|
||||
}, 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 {
|
||||
items, ok := raw.([]any)
|
||||
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) {
|
||||
server := NewServer("", service.New(service.Config{
|
||||
Model: "Qwen3-Coder",
|
||||
|
||||
@@ -283,10 +283,11 @@ func ActionOutputPrompt(toolCallID string, output string) string {
|
||||
if output == "" {
|
||||
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 != "" {
|
||||
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 {
|
||||
@@ -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
|
||||
if schema, ok := toolSchemaMap[call.Name]; ok && len(schema) > 0 {
|
||||
call.Arguments = filterArgsBySchema(call.Arguments, schema)
|
||||
if !hasRequiredArgs(call.Arguments, schema) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
calls = append(calls, call)
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
if src == nil {
|
||||
return nil
|
||||
|
||||
@@ -154,3 +154,25 @@ func TestParseActionBlocksMapsReadAlias(t *testing.T) {
|
||||
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