feat: add OpenAI/Anthropic tools support with tool emulation
- Parse tools/tool_choice from OpenAI and Anthropic requests - Inject tool definitions into system prompt via toolemulation - Parse action blocks (```json action) from model responses - Retry logic for forced tool_choice (any/required) - Return proper tool_calls / tool_use in responses - Support streaming tools via collect-and-replay pattern - Add tool history projection (assistant tool_calls + tool results) - Model ID normalization: use official names (Qwen3.6-Plus, etc.) - Fix resolveSessionMode to use Fresh mode when tools present
This commit is contained in:
37
README.md
37
README.md
@@ -14,6 +14,8 @@ Current scope:
|
|||||||
- one request at a time
|
- one request at a time
|
||||||
- supports Windows named-pipe transport and local websocket transport
|
- supports Windows named-pipe transport and local websocket transport
|
||||||
- directly uses Lingma IPC, not DOM/CDP
|
- directly uses Lingma IPC, not DOM/CDP
|
||||||
|
- OpenAI-compatible `tools` / `tool_choice` support (tool emulation via prompt engineering)
|
||||||
|
- Anthropic-compatible `tools` / `tool_choice` support
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
@@ -69,6 +71,41 @@ Recommended layout:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## macOS / Linux
|
||||||
|
|
||||||
|
This project also works on macOS and Linux via **WebSocket transport**. The Windows named-pipe transport is automatically skipped on non-Windows platforms.
|
||||||
|
|
||||||
|
### Run on macOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/OpenSources/lingma-ipc-proxy
|
||||||
|
go run ./cmd/lingma-ipc-proxy --transport websocket --port 8095
|
||||||
|
|
||||||
|
# Or use auto-detect (will discover websocket port from Lingma's shared client cache)
|
||||||
|
go run ./cmd/lingma-ipc-proxy --port 8095
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build on macOS / Linux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/OpenSources/lingma-ipc-proxy
|
||||||
|
go build -o ./dist/lingma-ipc-proxy ./cmd/lingma-ipc-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
### macOS Config Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 8095,
|
||||||
|
"transport": "websocket",
|
||||||
|
"mode": "agent",
|
||||||
|
"shell_type": "zsh",
|
||||||
|
"session_mode": "auto",
|
||||||
|
"timeout": 120
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
Build a Windows executable:
|
Build a Windows executable:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -88,7 +89,7 @@ func loadConfig() (service.Config, string) {
|
|||||||
Transport: lingmaipc.TransportAuto,
|
Transport: lingmaipc.TransportAuto,
|
||||||
Cwd: currentDir(),
|
Cwd: currentDir(),
|
||||||
Mode: "agent",
|
Mode: "agent",
|
||||||
ShellType: "powershell",
|
ShellType: defaultShellType(),
|
||||||
SessionMode: service.SessionModeAuto,
|
SessionMode: service.SessionModeAuto,
|
||||||
Timeout: 120 * time.Second,
|
Timeout: 120 * time.Second,
|
||||||
}
|
}
|
||||||
@@ -299,3 +300,10 @@ func valueOr(value string, fallback string) string {
|
|||||||
}
|
}
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func defaultShellType() string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return "powershell"
|
||||||
|
}
|
||||||
|
return "zsh"
|
||||||
|
}
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -1,6 +1,6 @@
|
|||||||
module lingma-ipc-proxy
|
module lingma-ipc-proxy
|
||||||
|
|
||||||
go 1.25.0
|
go 1.21
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Microsoft/go-winio v0.6.2
|
github.com/Microsoft/go-winio v0.6.2
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"lingma-ipc-proxy/internal/service"
|
"lingma-ipc-proxy/internal/service"
|
||||||
|
"lingma-ipc-proxy/internal/toolemulation"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
@@ -18,11 +19,13 @@ type Server struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type anthropicRequest struct {
|
type anthropicRequest struct {
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
MaxTokens int `json:"max_tokens,omitempty"`
|
MaxTokens int `json:"max_tokens,omitempty"`
|
||||||
System any `json:"system,omitempty"`
|
System any `json:"system,omitempty"`
|
||||||
Messages []rawMessage `json:"messages"`
|
Messages []rawMessage `json:"messages"`
|
||||||
Stream bool `json:"stream,omitempty"`
|
Stream bool `json:"stream,omitempty"`
|
||||||
|
Tools any `json:"tools,omitempty"`
|
||||||
|
ToolChoice any `json:"tool_choice,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type openAIChatRequest struct {
|
type openAIChatRequest struct {
|
||||||
@@ -31,11 +34,15 @@ type openAIChatRequest struct {
|
|||||||
Stream bool `json:"stream,omitempty"`
|
Stream bool `json:"stream,omitempty"`
|
||||||
MaxTokens int `json:"max_tokens,omitempty"`
|
MaxTokens int `json:"max_tokens,omitempty"`
|
||||||
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
|
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
|
||||||
|
Tools any `json:"tools,omitempty"`
|
||||||
|
ToolChoice any `json:"tool_choice,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type rawMessage struct {
|
type rawMessage struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content any `json:"content"`
|
Content any `json:"content"`
|
||||||
|
ToolCalls []any `json:"tool_calls,omitempty"`
|
||||||
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type modelResponse struct {
|
type modelResponse struct {
|
||||||
@@ -170,13 +177,26 @@ func (s *Server) handleAnthropicMessages(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
content := []map[string]any{{"type": "text", "text": result.Text}}
|
||||||
|
stopReason := "end_turn"
|
||||||
|
if len(result.ToolCalls) > 0 {
|
||||||
|
for _, tc := range result.ToolCalls {
|
||||||
|
content = append(content, map[string]any{
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": tc.ID,
|
||||||
|
"name": tc.Name,
|
||||||
|
"input": tc.Arguments,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
stopReason = "tool_use"
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"id": fmt.Sprintf("msg_%d", time.Now().UnixNano()),
|
"id": fmt.Sprintf("msg_%d", time.Now().UnixNano()),
|
||||||
"type": "message",
|
"type": "message",
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": []map[string]any{{"type": "text", "text": result.Text}},
|
"content": content,
|
||||||
"model": result.Model,
|
"model": result.Model,
|
||||||
"stop_reason": "end_turn",
|
"stop_reason": stopReason,
|
||||||
"stop_sequence": nil,
|
"stop_sequence": nil,
|
||||||
"usage": map[string]any{
|
"usage": map[string]any{
|
||||||
"input_tokens": result.InputTokens,
|
"input_tokens": result.InputTokens,
|
||||||
@@ -224,6 +244,27 @@ func (s *Server) handleOpenAIChatCompletions(w http.ResponseWriter, r *http.Requ
|
|||||||
}
|
}
|
||||||
|
|
||||||
created := time.Now().Unix()
|
created := time.Now().Unix()
|
||||||
|
message := map[string]any{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": result.Text,
|
||||||
|
}
|
||||||
|
finishReason := "stop"
|
||||||
|
if len(result.ToolCalls) > 0 {
|
||||||
|
toolCalls := make([]map[string]any, 0, len(result.ToolCalls))
|
||||||
|
for _, tc := range result.ToolCalls {
|
||||||
|
argsJSON, _ := json.Marshal(tc.Arguments)
|
||||||
|
toolCalls = append(toolCalls, map[string]any{
|
||||||
|
"id": tc.ID,
|
||||||
|
"type": "function",
|
||||||
|
"function": map[string]any{
|
||||||
|
"name": tc.Name,
|
||||||
|
"arguments": string(argsJSON),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
message["tool_calls"] = toolCalls
|
||||||
|
finishReason = "tool_calls"
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"id": fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano()),
|
"id": fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano()),
|
||||||
"object": "chat.completion",
|
"object": "chat.completion",
|
||||||
@@ -231,12 +272,9 @@ func (s *Server) handleOpenAIChatCompletions(w http.ResponseWriter, r *http.Requ
|
|||||||
"model": result.Model,
|
"model": result.Model,
|
||||||
"choices": []map[string]any{
|
"choices": []map[string]any{
|
||||||
{
|
{
|
||||||
"index": 0,
|
"index": 0,
|
||||||
"message": map[string]any{
|
"message": message,
|
||||||
"role": "assistant",
|
"finish_reason": finishReason,
|
||||||
"content": result.Text,
|
|
||||||
},
|
|
||||||
"finish_reason": "stop",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"usage": map[string]any{
|
"usage": map[string]any{
|
||||||
@@ -254,17 +292,73 @@ func (s *Server) handleAnthropicStream(w http.ResponseWriter, r *http.Request, r
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model := strings.TrimSpace(req.Model)
|
||||||
|
if model == "" {
|
||||||
|
model = "lingma"
|
||||||
|
}
|
||||||
|
msgID := fmt.Sprintf("msg_%d", time.Now().UnixNano())
|
||||||
|
|
||||||
|
if len(req.Tools) > 0 {
|
||||||
|
result, err := s.svc.Generate(r.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
writeAnthropicError(w, http.StatusInternalServerError, "api_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
streamingHeaders(w)
|
||||||
|
_ = writeSSEEvent(w, flusher, "message_start", map[string]any{
|
||||||
|
"type": "message_start",
|
||||||
|
"message": map[string]any{
|
||||||
|
"id": msgID, "type": "message", "role": "assistant", "content": []any{},
|
||||||
|
"model": model, "stop_reason": nil, "stop_sequence": nil,
|
||||||
|
"usage": map[string]any{"input_tokens": 0, "output_tokens": 0},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
_ = writeSSEEvent(w, flusher, "content_block_start", map[string]any{
|
||||||
|
"type": "content_block_start", "index": 0,
|
||||||
|
"content_block": map[string]any{"type": "text", "text": ""},
|
||||||
|
})
|
||||||
|
if result.Text != "" {
|
||||||
|
_ = writeSSEEvent(w, flusher, "content_block_delta", map[string]any{
|
||||||
|
"type": "content_block_delta", "index": 0,
|
||||||
|
"delta": map[string]any{"type": "text_delta", "text": result.Text},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ = writeSSEEvent(w, flusher, "content_block_stop", map[string]any{
|
||||||
|
"type": "content_block_stop", "index": 0,
|
||||||
|
})
|
||||||
|
for i, tc := range result.ToolCalls {
|
||||||
|
_ = writeSSEEvent(w, flusher, "content_block_start", map[string]any{
|
||||||
|
"type": "content_block_start", "index": i + 1,
|
||||||
|
"content_block": map[string]any{"type": "tool_use", "id": tc.ID, "name": tc.Name, "input": map[string]any{}},
|
||||||
|
})
|
||||||
|
argsJSON, _ := json.Marshal(tc.Arguments)
|
||||||
|
_ = writeSSEEvent(w, flusher, "content_block_delta", map[string]any{
|
||||||
|
"type": "content_block_delta", "index": i + 1,
|
||||||
|
"delta": map[string]any{"type": "input_json_delta", "partial_json": string(argsJSON)},
|
||||||
|
})
|
||||||
|
_ = writeSSEEvent(w, flusher, "content_block_stop", map[string]any{
|
||||||
|
"type": "content_block_stop", "index": i + 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
stopReason := "end_turn"
|
||||||
|
if len(result.ToolCalls) > 0 {
|
||||||
|
stopReason = "tool_use"
|
||||||
|
}
|
||||||
|
_ = writeSSEEvent(w, flusher, "message_delta", map[string]any{
|
||||||
|
"type": "message_delta",
|
||||||
|
"delta": map[string]any{"stop_reason": stopReason, "stop_sequence": nil},
|
||||||
|
"usage": map[string]any{"output_tokens": result.OutputTokens},
|
||||||
|
})
|
||||||
|
_ = writeSSEEvent(w, flusher, "message_stop", map[string]any{"type": "message_stop"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
events, done, err := s.svc.GenerateStream(r.Context(), req)
|
events, done, err := s.svc.GenerateStream(r.Context(), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeAnthropicError(w, http.StatusInternalServerError, "api_error", err.Error())
|
writeAnthropicError(w, http.StatusInternalServerError, "api_error", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
model := strings.TrimSpace(req.Model)
|
|
||||||
if model == "" {
|
|
||||||
model = "lingma"
|
|
||||||
}
|
|
||||||
msgID := fmt.Sprintf("msg_%d", time.Now().UnixNano())
|
|
||||||
streamingHeaders(w)
|
streamingHeaders(w)
|
||||||
if err := writeSSEEvent(w, flusher, "message_start", map[string]any{
|
if err := writeSSEEvent(w, flusher, "message_start", map[string]any{
|
||||||
"type": "message_start",
|
"type": "message_start",
|
||||||
@@ -383,18 +477,65 @@ func (s *Server) handleOpenAIStream(w http.ResponseWriter, r *http.Request, req
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
events, done, err := s.svc.GenerateStream(r.Context(), req)
|
|
||||||
if err != nil {
|
|
||||||
writeOpenAIError(w, http.StatusInternalServerError, "api_error", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
model := strings.TrimSpace(req.Model)
|
model := strings.TrimSpace(req.Model)
|
||||||
if model == "" {
|
if model == "" {
|
||||||
model = "lingma"
|
model = "lingma"
|
||||||
}
|
}
|
||||||
chatID := fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano())
|
chatID := fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano())
|
||||||
created := time.Now().Unix()
|
created := time.Now().Unix()
|
||||||
|
|
||||||
|
if len(req.Tools) > 0 {
|
||||||
|
result, err := s.svc.Generate(r.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
writeOpenAIError(w, http.StatusInternalServerError, "api_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
streamingHeaders(w)
|
||||||
|
_ = writeOpenAIChunk(w, flusher, map[string]any{
|
||||||
|
"id": chatID, "object": "chat.completion.chunk", "created": created, "model": model,
|
||||||
|
"choices": []map[string]any{{"index": 0, "delta": map[string]any{"role": "assistant"}, "finish_reason": nil}},
|
||||||
|
})
|
||||||
|
if result.Text != "" {
|
||||||
|
_ = writeOpenAIChunk(w, flusher, map[string]any{
|
||||||
|
"id": chatID, "object": "chat.completion.chunk", "created": created, "model": model,
|
||||||
|
"choices": []map[string]any{{"index": 0, "delta": map[string]any{"content": result.Text}, "finish_reason": nil}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for i, tc := range result.ToolCalls {
|
||||||
|
argsJSON, _ := json.Marshal(tc.Arguments)
|
||||||
|
_ = writeOpenAIChunk(w, flusher, map[string]any{
|
||||||
|
"id": chatID, "object": "chat.completion.chunk", "created": created, "model": model,
|
||||||
|
"choices": []map[string]any{{
|
||||||
|
"index": 0,
|
||||||
|
"delta": map[string]any{
|
||||||
|
"tool_calls": []map[string]any{{
|
||||||
|
"index": i, "id": tc.ID, "type": "function",
|
||||||
|
"function": map[string]any{"name": tc.Name, "arguments": string(argsJSON)},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
"finish_reason": nil,
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
finishReason := "stop"
|
||||||
|
if len(result.ToolCalls) > 0 {
|
||||||
|
finishReason = "tool_calls"
|
||||||
|
}
|
||||||
|
_ = writeOpenAIChunk(w, flusher, map[string]any{
|
||||||
|
"id": chatID, "object": "chat.completion.chunk", "created": created, "model": model,
|
||||||
|
"choices": []map[string]any{{"index": 0, "delta": map[string]any{}, "finish_reason": finishReason}},
|
||||||
|
})
|
||||||
|
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
|
||||||
|
flusher.Flush()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
events, done, err := s.svc.GenerateStream(r.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
writeOpenAIError(w, http.StatusInternalServerError, "api_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
streamingHeaders(w)
|
streamingHeaders(w)
|
||||||
if err := writeOpenAIChunk(w, flusher, map[string]any{
|
if err := writeOpenAIChunk(w, flusher, map[string]any{
|
||||||
"id": chatID,
|
"id": chatID,
|
||||||
@@ -493,22 +634,41 @@ func normalizeAnthropicRequest(req anthropicRequest) (service.ChatRequest, error
|
|||||||
messages := make([]service.ChatMessage, 0, len(req.Messages))
|
messages := make([]service.ChatMessage, 0, len(req.Messages))
|
||||||
for _, message := range req.Messages {
|
for _, message := range req.Messages {
|
||||||
role := strings.ToLower(strings.TrimSpace(message.Role))
|
role := strings.ToLower(strings.TrimSpace(message.Role))
|
||||||
text := strings.TrimSpace(extractText(message.Content))
|
switch role {
|
||||||
if role != "user" && role != "assistant" {
|
case "user":
|
||||||
continue
|
text, toolResults := extractAnthropicUserContent(message.Content)
|
||||||
|
for _, tr := range toolResults {
|
||||||
|
prompt := toolemulation.ActionOutputPrompt(tr.ToolUseID, tr.Content)
|
||||||
|
if prompt != "" {
|
||||||
|
messages = append(messages, service.ChatMessage{Role: "user", Text: prompt})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if text != "" {
|
||||||
|
messages = append(messages, service.ChatMessage{Role: role, Text: text})
|
||||||
|
}
|
||||||
|
case "assistant":
|
||||||
|
text, calls := extractAnthropicAssistantContent(message.Content)
|
||||||
|
projected := toolemulation.AssistantToolCallsToText(text, calls)
|
||||||
|
if projected != "" {
|
||||||
|
messages = append(messages, service.ChatMessage{Role: role, Text: projected})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if text == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
messages = append(messages, service.ChatMessage{Role: role, Text: text})
|
|
||||||
}
|
}
|
||||||
if len(messages) == 0 {
|
if len(messages) == 0 {
|
||||||
return service.ChatRequest{}, fmt.Errorf("no user or assistant messages found")
|
return service.ChatRequest{}, fmt.Errorf("no user or assistant messages found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toolChoice := toolemulation.ToolChoice{Mode: "auto"}
|
||||||
|
if req.ToolChoice != nil {
|
||||||
|
toolChoice = toolemulation.ExtractToolChoice(req.ToolChoice)
|
||||||
|
}
|
||||||
|
|
||||||
return service.ChatRequest{
|
return service.ChatRequest{
|
||||||
Model: strings.TrimSpace(req.Model),
|
Model: strings.TrimSpace(req.Model),
|
||||||
System: strings.TrimSpace(extractText(req.System)),
|
System: strings.TrimSpace(extractText(req.System)),
|
||||||
Messages: messages,
|
Messages: messages,
|
||||||
|
Tools: toolemulation.ExtractAnthropicTools(req.Tools),
|
||||||
|
ToolChoice: toolChoice,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,24 +677,41 @@ func normalizeOpenAIRequest(req openAIChatRequest) (service.ChatRequest, error)
|
|||||||
systemParts := make([]string, 0, 2)
|
systemParts := make([]string, 0, 2)
|
||||||
for _, message := range req.Messages {
|
for _, message := range req.Messages {
|
||||||
role := strings.ToLower(strings.TrimSpace(message.Role))
|
role := strings.ToLower(strings.TrimSpace(message.Role))
|
||||||
text := strings.TrimSpace(extractText(message.Content))
|
|
||||||
if text == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch role {
|
switch role {
|
||||||
case "system":
|
case "system":
|
||||||
systemParts = append(systemParts, text)
|
text := strings.TrimSpace(extractText(message.Content))
|
||||||
case "user", "assistant":
|
if text != "" {
|
||||||
messages = append(messages, service.ChatMessage{Role: role, Text: text})
|
systemParts = append(systemParts, text)
|
||||||
|
}
|
||||||
|
case "user":
|
||||||
|
text := strings.TrimSpace(extractText(message.Content))
|
||||||
|
if text != "" {
|
||||||
|
messages = append(messages, service.ChatMessage{Role: role, Text: text})
|
||||||
|
}
|
||||||
|
case "assistant":
|
||||||
|
text := strings.TrimSpace(extractText(message.Content))
|
||||||
|
calls := extractOpenAIToolCalls(message.ToolCalls)
|
||||||
|
projected := toolemulation.AssistantToolCallsToText(text, calls)
|
||||||
|
if projected != "" {
|
||||||
|
messages = append(messages, service.ChatMessage{Role: role, Text: projected})
|
||||||
|
}
|
||||||
|
case "tool":
|
||||||
|
output := strings.TrimSpace(extractText(message.Content))
|
||||||
|
prompt := toolemulation.ActionOutputPrompt(message.ToolCallID, output)
|
||||||
|
if prompt != "" {
|
||||||
|
messages = append(messages, service.ChatMessage{Role: "user", Text: prompt})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(messages) == 0 {
|
if len(messages) == 0 {
|
||||||
return service.ChatRequest{}, fmt.Errorf("no user or assistant messages found")
|
return service.ChatRequest{}, fmt.Errorf("no user or assistant messages found")
|
||||||
}
|
}
|
||||||
return service.ChatRequest{
|
return service.ChatRequest{
|
||||||
Model: strings.TrimSpace(req.Model),
|
Model: strings.TrimSpace(req.Model),
|
||||||
System: strings.Join(systemParts, "\n\n"),
|
System: strings.Join(systemParts, "\n\n"),
|
||||||
Messages: messages,
|
Messages: messages,
|
||||||
|
Tools: toolemulation.ExtractTools(req.Tools),
|
||||||
|
ToolChoice: toolemulation.ExtractToolChoice(req.ToolChoice),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,3 +858,117 @@ func (s *Server) release() {
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extractOpenAIToolCalls(raw []any) []toolemulation.ToolCall {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]toolemulation.ToolCall, 0, len(raw))
|
||||||
|
for _, item := range raw {
|
||||||
|
m, ok := item.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id := stringFromAny(m["id"])
|
||||||
|
fn, ok := m["function"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := stringFromAny(fn["name"])
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
argsRaw := stringFromAny(fn["arguments"])
|
||||||
|
var args map[string]any
|
||||||
|
if argsRaw != "" {
|
||||||
|
_ = json.Unmarshal([]byte(argsRaw), &args)
|
||||||
|
}
|
||||||
|
out = append(out, toolemulation.ToolCall{
|
||||||
|
ID: id,
|
||||||
|
Name: name,
|
||||||
|
Arguments: args,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type anthropicToolResult struct {
|
||||||
|
ToolUseID string
|
||||||
|
Content string
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractAnthropicUserContent(content any) (string, []anthropicToolResult) {
|
||||||
|
text := extractText(content)
|
||||||
|
items, ok := content.([]any)
|
||||||
|
if !ok {
|
||||||
|
return text, nil
|
||||||
|
}
|
||||||
|
var results []anthropicToolResult
|
||||||
|
var textParts []string
|
||||||
|
for _, item := range items {
|
||||||
|
m, ok := item.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch stringFromAny(m["type"]) {
|
||||||
|
case "text":
|
||||||
|
if t := stringFromAny(m["text"]); t != "" {
|
||||||
|
textParts = append(textParts, t)
|
||||||
|
}
|
||||||
|
case "tool_result":
|
||||||
|
toolUseID := stringFromAny(m["tool_use_id"])
|
||||||
|
resultText := extractText(m["content"])
|
||||||
|
if resultText != "" {
|
||||||
|
results = append(results, anthropicToolResult{
|
||||||
|
ToolUseID: toolUseID,
|
||||||
|
Content: resultText,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(textParts) > 0 {
|
||||||
|
text = strings.Join(textParts, "\n")
|
||||||
|
}
|
||||||
|
return text, results
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractAnthropicAssistantContent(content any) (string, []toolemulation.ToolCall) {
|
||||||
|
text := extractText(content)
|
||||||
|
items, ok := content.([]any)
|
||||||
|
if !ok {
|
||||||
|
return text, nil
|
||||||
|
}
|
||||||
|
calls := make([]toolemulation.ToolCall, 0, len(items))
|
||||||
|
var textParts []string
|
||||||
|
for _, item := range items {
|
||||||
|
m, ok := item.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch stringFromAny(m["type"]) {
|
||||||
|
case "text":
|
||||||
|
if t := stringFromAny(m["text"]); t != "" {
|
||||||
|
textParts = append(textParts, t)
|
||||||
|
}
|
||||||
|
case "tool_use":
|
||||||
|
id := stringFromAny(m["id"])
|
||||||
|
name := stringFromAny(m["name"])
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var args map[string]any
|
||||||
|
if rawInput, ok := m["input"].(map[string]any); ok {
|
||||||
|
args = rawInput
|
||||||
|
}
|
||||||
|
calls = append(calls, toolemulation.ToolCall{
|
||||||
|
ID: id,
|
||||||
|
Name: name,
|
||||||
|
Arguments: args,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(textParts) > 0 {
|
||||||
|
text = strings.Join(textParts, "\n")
|
||||||
|
}
|
||||||
|
return text, calls
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,9 +17,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
PipeDir = `\\.\pipe\`
|
|
||||||
PipePrefix = "lingma-"
|
|
||||||
|
|
||||||
MetaRequestID = "ai-coding/request-id"
|
MetaRequestID = "ai-coding/request-id"
|
||||||
MetaMode = "ai-coding/mode"
|
MetaMode = "ai-coding/mode"
|
||||||
MetaModel = "ai-coding/model"
|
MetaModel = "ai-coding/model"
|
||||||
|
|||||||
8
internal/lingmaipc/pipe_const_other.go
Normal file
8
internal/lingmaipc/pipe_const_other.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package lingmaipc
|
||||||
|
|
||||||
|
const (
|
||||||
|
PipeDir = ""
|
||||||
|
PipePrefix = ""
|
||||||
|
)
|
||||||
8
internal/lingmaipc/pipe_const_windows.go
Normal file
8
internal/lingmaipc/pipe_const_windows.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package lingmaipc
|
||||||
|
|
||||||
|
const (
|
||||||
|
PipeDir = `\\.\pipe\`
|
||||||
|
PipePrefix = "lingma-"
|
||||||
|
)
|
||||||
12
internal/lingmaipc/pipe_other.go
Normal file
12
internal/lingmaipc/pipe_other.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package lingmaipc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func connectPipeTransport(ctx context.Context, pipePath string) (framedTransport, error) {
|
||||||
|
return nil, errors.New("pipe transport is only supported on Windows")
|
||||||
|
}
|
||||||
57
internal/lingmaipc/pipe_windows.go
Normal file
57
internal/lingmaipc/pipe_windows.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package lingmaipc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
winio "github.com/Microsoft/go-winio"
|
||||||
|
)
|
||||||
|
|
||||||
|
type pipeTransport struct {
|
||||||
|
path string
|
||||||
|
conn net.Conn
|
||||||
|
reader *framedReader
|
||||||
|
write sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectPipeTransport(ctx context.Context, pipePath string) (framedTransport, error) {
|
||||||
|
conn, err := winio.DialPipeContext(ctx, pipePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect Lingma IPC pipe %s: %w", pipePath, err)
|
||||||
|
}
|
||||||
|
return &pipeTransport{
|
||||||
|
path: pipePath,
|
||||||
|
conn: conn,
|
||||||
|
reader: newFramedReader(conn),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *pipeTransport) ReadFrame() ([]byte, error) {
|
||||||
|
return t.reader.ReadFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *pipeTransport) WriteFrame(body []byte) error {
|
||||||
|
t.write.Lock()
|
||||||
|
defer t.write.Unlock()
|
||||||
|
|
||||||
|
frame := []byte(fmt.Sprintf("Content-Length: %d\r\n\r\n", len(body)))
|
||||||
|
if _, err := t.conn.Write(frame); err != nil {
|
||||||
|
return fmt.Errorf("write frame header: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := t.conn.Write(body); err != nil {
|
||||||
|
return fmt.Errorf("write frame body: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *pipeTransport) Close() error {
|
||||||
|
return t.conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *pipeTransport) Address() string {
|
||||||
|
return t.path
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -19,7 +18,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
winio "github.com/Microsoft/go-winio"
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -74,16 +72,23 @@ func ResolveDialOptions(transport Transport, explicitPipe string, explicitWebSoc
|
|||||||
return DialOptions{Transport: TransportWebSocket, WebSocketURL: wsURL}, nil
|
return DialOptions{Transport: TransportWebSocket, WebSocketURL: wsURL}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
pipePath, pipeErr := ResolvePipePath(explicitPipe)
|
if runtime.GOOS == "windows" {
|
||||||
if pipeErr == nil {
|
pipePath, pipeErr := ResolvePipePath(explicitPipe)
|
||||||
return DialOptions{Transport: TransportPipe, PipePath: pipePath}, nil
|
if pipeErr == nil {
|
||||||
|
return DialOptions{Transport: TransportPipe, PipePath: pipePath}, nil
|
||||||
|
}
|
||||||
|
wsURL, wsErr := ResolveWebSocketURL(explicitWebSocketURL)
|
||||||
|
if wsErr == nil {
|
||||||
|
return DialOptions{Transport: TransportWebSocket, WebSocketURL: wsURL}, nil
|
||||||
|
}
|
||||||
|
return DialOptions{}, fmt.Errorf("resolve Lingma transport automatically: pipe: %w; websocket: %v", pipeErr, wsErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
wsURL, wsErr := ResolveWebSocketURL(explicitWebSocketURL)
|
wsURL, wsErr := ResolveWebSocketURL(explicitWebSocketURL)
|
||||||
if wsErr == nil {
|
if wsErr == nil {
|
||||||
return DialOptions{Transport: TransportWebSocket, WebSocketURL: wsURL}, nil
|
return DialOptions{Transport: TransportWebSocket, WebSocketURL: wsURL}, nil
|
||||||
}
|
}
|
||||||
return DialOptions{}, fmt.Errorf("resolve Lingma transport automatically: pipe: %w; websocket: %v", pipeErr, wsErr)
|
return DialOptions{}, fmt.Errorf("resolve Lingma transport automatically on %s: websocket: %w", runtime.GOOS, wsErr)
|
||||||
case TransportPipe:
|
case TransportPipe:
|
||||||
pipePath, err := ResolvePipePath(explicitPipe)
|
pipePath, err := ResolvePipePath(explicitPipe)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -307,51 +312,6 @@ func connectTransport(ctx context.Context, opts DialOptions) (framedTransport, e
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type pipeTransport struct {
|
|
||||||
path string
|
|
||||||
conn net.Conn
|
|
||||||
reader *framedReader
|
|
||||||
write sync.Mutex
|
|
||||||
}
|
|
||||||
|
|
||||||
func connectPipeTransport(ctx context.Context, pipePath string) (*pipeTransport, error) {
|
|
||||||
conn, err := winio.DialPipeContext(ctx, pipePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("connect Lingma IPC pipe %s: %w", pipePath, err)
|
|
||||||
}
|
|
||||||
return &pipeTransport{
|
|
||||||
path: pipePath,
|
|
||||||
conn: conn,
|
|
||||||
reader: newFramedReader(conn),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *pipeTransport) ReadFrame() ([]byte, error) {
|
|
||||||
return t.reader.ReadFrame()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *pipeTransport) WriteFrame(body []byte) error {
|
|
||||||
t.write.Lock()
|
|
||||||
defer t.write.Unlock()
|
|
||||||
|
|
||||||
frame := []byte(fmt.Sprintf("Content-Length: %d\r\n\r\n", len(body)))
|
|
||||||
if _, err := t.conn.Write(frame); err != nil {
|
|
||||||
return fmt.Errorf("write frame header: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := t.conn.Write(body); err != nil {
|
|
||||||
return fmt.Errorf("write frame body: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *pipeTransport) Close() error {
|
|
||||||
return t.conn.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *pipeTransport) Address() string {
|
|
||||||
return t.path
|
|
||||||
}
|
|
||||||
|
|
||||||
type websocketTransport struct {
|
type websocketTransport struct {
|
||||||
url string
|
url string
|
||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"lingma-ipc-proxy/internal/lingmaipc"
|
"lingma-ipc-proxy/internal/lingmaipc"
|
||||||
|
"lingma-ipc-proxy/internal/toolemulation"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SessionMode string
|
type SessionMode string
|
||||||
@@ -42,9 +43,11 @@ type ChatMessage struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ChatRequest struct {
|
type ChatRequest struct {
|
||||||
Model string
|
Model string
|
||||||
System string
|
System string
|
||||||
Messages []ChatMessage
|
Messages []ChatMessage
|
||||||
|
Tools []toolemulation.ToolDef
|
||||||
|
ToolChoice toolemulation.ToolChoice
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChatResult struct {
|
type ChatResult struct {
|
||||||
@@ -62,6 +65,7 @@ type ChatResult struct {
|
|||||||
Endpoint string
|
Endpoint string
|
||||||
Transport string
|
Transport string
|
||||||
EffectiveSession SessionMode
|
EffectiveSession SessionMode
|
||||||
|
ToolCalls []toolemulation.ToolCall
|
||||||
}
|
}
|
||||||
|
|
||||||
type StreamEvent struct {
|
type StreamEvent struct {
|
||||||
@@ -74,9 +78,10 @@ type StreamResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Scene string `json:"scene,omitempty"`
|
Scene string `json:"scene,omitempty"`
|
||||||
|
InternalID string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type State struct {
|
type State struct {
|
||||||
@@ -97,6 +102,7 @@ type Service struct {
|
|||||||
transport lingmaipc.Transport
|
transport lingmaipc.Transport
|
||||||
stickySessionID string
|
stickySessionID string
|
||||||
stickyModelID string
|
stickyModelID string
|
||||||
|
modelMap map[string]string // official name -> internal id
|
||||||
}
|
}
|
||||||
|
|
||||||
type promptRunResult struct {
|
type promptRunResult struct {
|
||||||
@@ -170,6 +176,16 @@ func (s *Service) ListModels(ctx context.Context) ([]Model, error) {
|
|||||||
if len(models) == 0 {
|
if len(models) == 0 {
|
||||||
models = []Model{{ID: "lingma", Name: "Lingma", Scene: "default"}}
|
models = []Model{{ID: "lingma", Name: "Lingma", Scene: "default"}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.modelMap = make(map[string]string, len(models))
|
||||||
|
for _, m := range models {
|
||||||
|
if m.InternalID != "" {
|
||||||
|
s.modelMap[m.ID] = m.InternalID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
return models, nil
|
return models, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,17 +251,19 @@ func (s *Service) generateLocked(
|
|||||||
_ = s.deleteSessionLocked(cleanupCtx, ipcClient, sessionID)
|
_ = s.deleteSessionLocked(cleanupCtx, ipcClient, sessionID)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
internalModelID := s.resolveInternalModelID(req.Model)
|
||||||
|
|
||||||
requestID := lingmaipc.CreateRequestID("serve")
|
requestID := lingmaipc.CreateRequestID("serve")
|
||||||
meta := lingmaipc.CreateMeta(lingmaipc.MetaOptions{
|
meta := lingmaipc.CreateMeta(lingmaipc.MetaOptions{
|
||||||
RequestID: requestID,
|
RequestID: requestID,
|
||||||
Mode: s.cfg.Mode,
|
Mode: s.cfg.Mode,
|
||||||
Model: req.Model,
|
Model: internalModelID,
|
||||||
ShellType: s.cfg.ShellType,
|
ShellType: s.cfg.ShellType,
|
||||||
CurrentFilePath: s.cfg.CurrentFilePath,
|
CurrentFilePath: s.cfg.CurrentFilePath,
|
||||||
EnabledMCP: []any{},
|
EnabledMCP: []any{},
|
||||||
})
|
})
|
||||||
|
|
||||||
modelID := strings.TrimSpace(req.Model)
|
modelID := strings.TrimSpace(internalModelID)
|
||||||
if modelID != "" && s.shouldSetModel(sessionID, effectiveMode, modelID) {
|
if modelID != "" && s.shouldSetModel(sessionID, effectiveMode, modelID) {
|
||||||
if err := ipcClient.Request(requestCtx, "session/set_model", map[string]any{
|
if err := ipcClient.Request(requestCtx, "session/set_model", map[string]any{
|
||||||
"sessionId": sessionID,
|
"sessionId": sessionID,
|
||||||
@@ -284,6 +302,28 @@ func (s *Service) generateLocked(
|
|||||||
}
|
}
|
||||||
|
|
||||||
result = s.buildChatResult(req, sessionID, requestID, prompt, runResult, effectiveMode)
|
result = s.buildChatResult(req, sessionID, requestID, prompt, runResult, effectiveMode)
|
||||||
|
|
||||||
|
if len(req.Tools) > 0 {
|
||||||
|
calls, remaining, parseErr := toolemulation.ParseActionBlocks(result.Text, toolemulation.Config{})
|
||||||
|
if parseErr == nil && len(calls) > 0 {
|
||||||
|
result.Text = remaining
|
||||||
|
result.ToolCalls = calls
|
||||||
|
} else if (req.ToolChoice.Mode == "any" || req.ToolChoice.Mode == "tool") && len(calls) == 0 {
|
||||||
|
if !toolemulation.LooksLikeRefusal(result.Text) {
|
||||||
|
hintPrompt := prompt + "\n\nImportant: You must use one of the available tools to answer this request. Output a \"```json action\" block."
|
||||||
|
retryResult, retryErr := s.runPromptLocked(requestCtx, ipcClient, sessionID, hintPrompt, requestID, meta, onDelta)
|
||||||
|
if retryErr == nil && retryResult != nil {
|
||||||
|
retryCalls, retryRemaining, retryParseErr := toolemulation.ParseActionBlocks(retryResult.AssistantText, toolemulation.Config{})
|
||||||
|
if retryParseErr == nil && len(retryCalls) > 0 {
|
||||||
|
result.Text = retryRemaining
|
||||||
|
result.ToolCalls = retryCalls
|
||||||
|
result.OutputTokens = estimateTokens(retryResult.AssistantText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -546,7 +586,7 @@ func resolveSessionMode(req ChatRequest, configured SessionMode) SessionMode {
|
|||||||
if configured != SessionModeAuto {
|
if configured != SessionModeAuto {
|
||||||
return configured
|
return configured
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(req.System) != "" || len(filteredMessages(req.Messages)) > 1 {
|
if len(req.Tools) > 0 || strings.TrimSpace(req.System) != "" || len(filteredMessages(req.Messages)) > 1 {
|
||||||
return SessionModeFresh
|
return SessionModeFresh
|
||||||
}
|
}
|
||||||
return SessionModeReuse
|
return SessionModeReuse
|
||||||
@@ -567,13 +607,35 @@ func buildLingmaPrompt(req ChatRequest, mode SessionMode) (string, error) {
|
|||||||
if mode == SessionModeReuse {
|
if mode == SessionModeReuse {
|
||||||
return lastUser, nil
|
return lastUser, nil
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(req.System) == "" && len(messages) == 1 {
|
|
||||||
|
system := strings.TrimSpace(req.System)
|
||||||
|
if len(req.Tools) > 0 {
|
||||||
|
system = toolemulation.InjectTooling(system, req.Tools, req.ToolChoice)
|
||||||
|
}
|
||||||
|
|
||||||
|
if system == "" && len(messages) == 1 {
|
||||||
return lastUser, nil
|
return lastUser, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(req.Tools) > 0 {
|
||||||
|
parts := make([]string, 0, len(messages)+2)
|
||||||
|
if system != "" {
|
||||||
|
parts = append(parts, system)
|
||||||
|
}
|
||||||
|
for _, message := range messages {
|
||||||
|
role := "User"
|
||||||
|
if message.Role == "assistant" {
|
||||||
|
role = "Assistant"
|
||||||
|
}
|
||||||
|
parts = append(parts, fmt.Sprintf("%s: %s", role, message.Text))
|
||||||
|
}
|
||||||
|
parts = append(parts, "Assistant:")
|
||||||
|
return strings.Join(parts, "\n\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
parts := make([]string, 0, len(messages)+4)
|
parts := make([]string, 0, len(messages)+4)
|
||||||
if strings.TrimSpace(req.System) != "" {
|
if system != "" {
|
||||||
parts = append(parts, "System instructions:", strings.TrimSpace(req.System))
|
parts = append(parts, "System instructions:", system)
|
||||||
}
|
}
|
||||||
parts = append(parts, "Conversation transcript:")
|
parts = append(parts, "Conversation transcript:")
|
||||||
for _, message := range messages {
|
for _, message := range messages {
|
||||||
@@ -627,7 +689,7 @@ func extractModels(raw any) []Model {
|
|||||||
if name == "" {
|
if name == "" {
|
||||||
name = id
|
name = id
|
||||||
}
|
}
|
||||||
seen[id] = Model{ID: id, Name: name, Scene: currentScene}
|
seen[name] = Model{ID: name, Name: name, Scene: currentScene, InternalID: id}
|
||||||
}
|
}
|
||||||
for key, child := range typed {
|
for key, child := range typed {
|
||||||
nextScene := currentScene
|
nextScene := currentScene
|
||||||
@@ -657,6 +719,15 @@ func likelyModelID(id string) bool {
|
|||||||
return strings.Contains(lowered, "qwen") || strings.Contains(lowered, "model") || strings.Contains(lowered, "auto") || strings.Contains(lowered, "coder")
|
return strings.Contains(lowered, "qwen") || strings.Contains(lowered, "model") || strings.Contains(lowered, "auto") || strings.Contains(lowered, "coder")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) resolveInternalModelID(officialName string) string {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if internalID, ok := s.modelMap[officialName]; ok && internalID != "" {
|
||||||
|
return internalID
|
||||||
|
}
|
||||||
|
return officialName
|
||||||
|
}
|
||||||
|
|
||||||
func isSceneKey(key string) bool {
|
func isSceneKey(key string) bool {
|
||||||
switch strings.ToLower(strings.TrimSpace(key)) {
|
switch strings.ToLower(strings.TrimSpace(key)) {
|
||||||
case "assistant", "chat", "developer", "inline", "quest":
|
case "assistant", "chat", "developer", "inline", "quest":
|
||||||
|
|||||||
10
lingma-ipc-proxy.macos.json
Normal file
10
lingma-ipc-proxy.macos.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 8095,
|
||||||
|
"transport": "websocket",
|
||||||
|
"mode": "agent",
|
||||||
|
"shell_type": "zsh",
|
||||||
|
"session_mode": "auto",
|
||||||
|
"timeout": 120,
|
||||||
|
"cwd": "/Users/tiancheng"
|
||||||
|
}
|
||||||
74
scripts/test-macos.sh
Executable file
74
scripts/test-macos.sh
Executable file
@@ -0,0 +1,74 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# lingma-ipc-proxy macOS 功能测试脚本
|
||||||
|
# 用法: ./scripts/test-macos.sh [host:port]
|
||||||
|
|
||||||
|
ENDPOINT="${1:-127.0.0.1:8095}"
|
||||||
|
MODEL="dashscope_qwen3_coder"
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
assert_contains() {
|
||||||
|
local response="$1"
|
||||||
|
local expected="$2"
|
||||||
|
local test_name="$3"
|
||||||
|
if echo "$response" | grep -q "$expected"; then
|
||||||
|
echo " ✅ $test_name"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo " ❌ $test_name"
|
||||||
|
echo " 期望包含: $expected"
|
||||||
|
echo " 实际响应: $(echo "$response" | head -c 200)"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "========================================"
|
||||||
|
echo "lingma-ipc-proxy macOS 功能测试"
|
||||||
|
echo "端点: http://$ENDPOINT"
|
||||||
|
echo "========================================"
|
||||||
|
|
||||||
|
# 1. 测试 /v1/models
|
||||||
|
echo ""
|
||||||
|
echo "[1/4] 测试 /v1/models"
|
||||||
|
RESPONSE=$(curl -s "http://$ENDPOINT/v1/models" 2>/dev/null || echo "ERROR")
|
||||||
|
assert_contains "$RESPONSE" "dashscope_qwen3_coder" "模型列表包含 Qwen3-Coder"
|
||||||
|
assert_contains "$RESPONSE" "kmodel" "模型列表包含 Kimi"
|
||||||
|
assert_contains "$RESPONSE" '"object":"list"' "响应格式正确"
|
||||||
|
|
||||||
|
# 2. 测试 /v1/chat/completions 非流式
|
||||||
|
echo ""
|
||||||
|
echo "[2/4] 测试 /v1/chat/completions (非流式)"
|
||||||
|
RESPONSE=$(curl -s -X POST "http://$ENDPOINT/v1/chat/completions" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"model\":\"$MODEL\",\"messages\":[{\"role\":\"user\",\"content\":\"1+1=?\"}],\"stream\":false}" 2>/dev/null || echo "ERROR")
|
||||||
|
assert_contains "$RESPONSE" "2" "回答包含正确答案"
|
||||||
|
assert_contains "$RESPONSE" "chat.completion" "响应类型正确"
|
||||||
|
assert_contains "$RESPONSE" "stop" "finish_reason 为 stop"
|
||||||
|
|
||||||
|
# 3. 测试 /v1/chat/completions 流式
|
||||||
|
echo ""
|
||||||
|
echo "[3/4] 测试 /v1/chat/completions (流式 SSE)"
|
||||||
|
RESPONSE=$(curl -s -N -X POST "http://$ENDPOINT/v1/chat/completions" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"model\":\"$MODEL\",\"messages\":[{\"role\":\"user\",\"content\":\"1+1=?\"}],\"stream\":true}" 2>/dev/null || echo "ERROR")
|
||||||
|
assert_contains "$RESPONSE" "data:" "包含 SSE data: 前缀"
|
||||||
|
assert_contains "$RESPONSE" "chat.completion.chunk" "chunk 类型正确"
|
||||||
|
|
||||||
|
# 4. 测试 /v1/messages (Anthropic 格式)
|
||||||
|
echo ""
|
||||||
|
echo "[4/4] 测试 /v1/messages (Anthropic 格式)"
|
||||||
|
RESPONSE=$(curl -s -X POST "http://$ENDPOINT/v1/messages" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"model\":\"$MODEL\",\"messages\":[{\"role\":\"user\",\"content\":\"2+2=?\"}],\"stream\":false}" 2>/dev/null || echo "ERROR")
|
||||||
|
assert_contains "$RESPONSE" "4" "回答包含正确答案"
|
||||||
|
assert_contains "$RESPONSE" "end_turn" "stop_reason 为 end_turn"
|
||||||
|
|
||||||
|
# 汇总
|
||||||
|
echo ""
|
||||||
|
echo "========================================"
|
||||||
|
echo "测试结果: $PASS 通过, $FAIL 失败"
|
||||||
|
echo "========================================"
|
||||||
|
|
||||||
|
if [ "$FAIL" -gt 0 ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user