diff --git a/README.md b/README.md index c2806b5..49f58ff 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ Current scope: - one request at a time - supports Windows named-pipe transport and local websocket transport - 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 @@ -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 a Windows executable: diff --git a/cmd/lingma-ipc-proxy/main.go b/cmd/lingma-ipc-proxy/main.go index 2ea380f..5ccd488 100644 --- a/cmd/lingma-ipc-proxy/main.go +++ b/cmd/lingma-ipc-proxy/main.go @@ -10,6 +10,7 @@ import ( "os" "os/signal" "path/filepath" + "runtime" "strconv" "strings" "syscall" @@ -88,7 +89,7 @@ func loadConfig() (service.Config, string) { Transport: lingmaipc.TransportAuto, Cwd: currentDir(), Mode: "agent", - ShellType: "powershell", + ShellType: defaultShellType(), SessionMode: service.SessionModeAuto, Timeout: 120 * time.Second, } @@ -299,3 +300,10 @@ func valueOr(value string, fallback string) string { } return fallback } + +func defaultShellType() string { + if runtime.GOOS == "windows" { + return "powershell" + } + return "zsh" +} diff --git a/go.mod b/go.mod index cdaee18..36e1177 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module lingma-ipc-proxy -go 1.25.0 +go 1.21 require ( github.com/Microsoft/go-winio v0.6.2 diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 7c7a0b2..9f73e4c 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -9,6 +9,7 @@ import ( "time" "lingma-ipc-proxy/internal/service" + "lingma-ipc-proxy/internal/toolemulation" ) type Server struct { @@ -18,11 +19,13 @@ type Server struct { } type anthropicRequest struct { - Model string `json:"model"` - MaxTokens int `json:"max_tokens,omitempty"` - System any `json:"system,omitempty"` - Messages []rawMessage `json:"messages"` - Stream bool `json:"stream,omitempty"` + Model string `json:"model"` + MaxTokens int `json:"max_tokens,omitempty"` + System any `json:"system,omitempty"` + Messages []rawMessage `json:"messages"` + Stream bool `json:"stream,omitempty"` + Tools any `json:"tools,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` } type openAIChatRequest struct { @@ -31,11 +34,15 @@ type openAIChatRequest struct { Stream bool `json:"stream,omitempty"` MaxTokens int `json:"max_tokens,omitempty"` MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` + Tools any `json:"tools,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` } type rawMessage struct { - Role string `json:"role"` - Content any `json:"content"` + Role string `json:"role"` + Content any `json:"content"` + ToolCalls []any `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` } type modelResponse struct { @@ -170,13 +177,26 @@ func (s *Server) handleAnthropicMessages(w http.ResponseWriter, r *http.Request) 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{ "id": fmt.Sprintf("msg_%d", time.Now().UnixNano()), "type": "message", "role": "assistant", - "content": []map[string]any{{"type": "text", "text": result.Text}}, + "content": content, "model": result.Model, - "stop_reason": "end_turn", + "stop_reason": stopReason, "stop_sequence": nil, "usage": map[string]any{ "input_tokens": result.InputTokens, @@ -224,6 +244,27 @@ func (s *Server) handleOpenAIChatCompletions(w http.ResponseWriter, r *http.Requ } 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{ "id": fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano()), "object": "chat.completion", @@ -231,12 +272,9 @@ func (s *Server) handleOpenAIChatCompletions(w http.ResponseWriter, r *http.Requ "model": result.Model, "choices": []map[string]any{ { - "index": 0, - "message": map[string]any{ - "role": "assistant", - "content": result.Text, - }, - "finish_reason": "stop", + "index": 0, + "message": message, + "finish_reason": finishReason, }, }, "usage": map[string]any{ @@ -254,17 +292,73 @@ func (s *Server) handleAnthropicStream(w http.ResponseWriter, r *http.Request, r 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) if err != nil { writeAnthropicError(w, http.StatusInternalServerError, "api_error", err.Error()) return } - model := strings.TrimSpace(req.Model) - if model == "" { - model = "lingma" - } - msgID := fmt.Sprintf("msg_%d", time.Now().UnixNano()) streamingHeaders(w) if err := writeSSEEvent(w, flusher, "message_start", map[string]any{ "type": "message_start", @@ -383,18 +477,65 @@ func (s *Server) handleOpenAIStream(w http.ResponseWriter, r *http.Request, req 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) if model == "" { model = "lingma" } chatID := fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano()) 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) if err := writeOpenAIChunk(w, flusher, map[string]any{ "id": chatID, @@ -493,22 +634,41 @@ func normalizeAnthropicRequest(req anthropicRequest) (service.ChatRequest, error messages := make([]service.ChatMessage, 0, len(req.Messages)) for _, message := range req.Messages { role := strings.ToLower(strings.TrimSpace(message.Role)) - text := strings.TrimSpace(extractText(message.Content)) - if role != "user" && role != "assistant" { - continue + switch role { + case "user": + 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 { 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{ - Model: strings.TrimSpace(req.Model), - System: strings.TrimSpace(extractText(req.System)), - Messages: messages, + Model: strings.TrimSpace(req.Model), + System: strings.TrimSpace(extractText(req.System)), + Messages: messages, + Tools: toolemulation.ExtractAnthropicTools(req.Tools), + ToolChoice: toolChoice, }, nil } @@ -517,24 +677,41 @@ func normalizeOpenAIRequest(req openAIChatRequest) (service.ChatRequest, error) systemParts := make([]string, 0, 2) for _, message := range req.Messages { role := strings.ToLower(strings.TrimSpace(message.Role)) - text := strings.TrimSpace(extractText(message.Content)) - if text == "" { - continue - } switch role { case "system": - systemParts = append(systemParts, text) - case "user", "assistant": - messages = append(messages, service.ChatMessage{Role: role, Text: text}) + text := strings.TrimSpace(extractText(message.Content)) + if 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 { return service.ChatRequest{}, fmt.Errorf("no user or assistant messages found") } return service.ChatRequest{ - Model: strings.TrimSpace(req.Model), - System: strings.Join(systemParts, "\n\n"), - Messages: messages, + Model: strings.TrimSpace(req.Model), + System: strings.Join(systemParts, "\n\n"), + Messages: messages, + Tools: toolemulation.ExtractTools(req.Tools), + ToolChoice: toolemulation.ExtractToolChoice(req.ToolChoice), }, nil } @@ -681,3 +858,117 @@ func (s *Server) release() { 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 +} diff --git a/internal/lingmaipc/client.go b/internal/lingmaipc/client.go index 1304dda..e830d8f 100644 --- a/internal/lingmaipc/client.go +++ b/internal/lingmaipc/client.go @@ -17,9 +17,6 @@ import ( ) const ( - PipeDir = `\\.\pipe\` - PipePrefix = "lingma-" - MetaRequestID = "ai-coding/request-id" MetaMode = "ai-coding/mode" MetaModel = "ai-coding/model" diff --git a/internal/lingmaipc/pipe_const_other.go b/internal/lingmaipc/pipe_const_other.go new file mode 100644 index 0000000..1dc30b6 --- /dev/null +++ b/internal/lingmaipc/pipe_const_other.go @@ -0,0 +1,8 @@ +//go:build !windows + +package lingmaipc + +const ( + PipeDir = "" + PipePrefix = "" +) diff --git a/internal/lingmaipc/pipe_const_windows.go b/internal/lingmaipc/pipe_const_windows.go new file mode 100644 index 0000000..54645ff --- /dev/null +++ b/internal/lingmaipc/pipe_const_windows.go @@ -0,0 +1,8 @@ +//go:build windows + +package lingmaipc + +const ( + PipeDir = `\\.\pipe\` + PipePrefix = "lingma-" +) diff --git a/internal/lingmaipc/pipe_other.go b/internal/lingmaipc/pipe_other.go new file mode 100644 index 0000000..64cbc0e --- /dev/null +++ b/internal/lingmaipc/pipe_other.go @@ -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") +} diff --git a/internal/lingmaipc/pipe_windows.go b/internal/lingmaipc/pipe_windows.go new file mode 100644 index 0000000..54dd833 --- /dev/null +++ b/internal/lingmaipc/pipe_windows.go @@ -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 +} diff --git a/internal/lingmaipc/transport.go b/internal/lingmaipc/transport.go index b69ef8f..3d6b767 100644 --- a/internal/lingmaipc/transport.go +++ b/internal/lingmaipc/transport.go @@ -8,7 +8,6 @@ import ( "errors" "fmt" "io" - "net" "net/url" "os" "path/filepath" @@ -19,7 +18,6 @@ import ( "sync" "time" - winio "github.com/Microsoft/go-winio" "github.com/gorilla/websocket" ) @@ -74,16 +72,23 @@ func ResolveDialOptions(transport Transport, explicitPipe string, explicitWebSoc return DialOptions{Transport: TransportWebSocket, WebSocketURL: wsURL}, nil } - pipePath, pipeErr := ResolvePipePath(explicitPipe) - if pipeErr == nil { - return DialOptions{Transport: TransportPipe, PipePath: pipePath}, nil + if runtime.GOOS == "windows" { + pipePath, pipeErr := ResolvePipePath(explicitPipe) + 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) if wsErr == 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: pipePath, err := ResolvePipePath(explicitPipe) 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 { url string conn *websocket.Conn diff --git a/internal/service/service.go b/internal/service/service.go index 320a915..e8f9b49 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -12,6 +12,7 @@ import ( "time" "lingma-ipc-proxy/internal/lingmaipc" + "lingma-ipc-proxy/internal/toolemulation" ) type SessionMode string @@ -42,9 +43,11 @@ type ChatMessage struct { } type ChatRequest struct { - Model string - System string - Messages []ChatMessage + Model string + System string + Messages []ChatMessage + Tools []toolemulation.ToolDef + ToolChoice toolemulation.ToolChoice } type ChatResult struct { @@ -62,6 +65,7 @@ type ChatResult struct { Endpoint string Transport string EffectiveSession SessionMode + ToolCalls []toolemulation.ToolCall } type StreamEvent struct { @@ -74,9 +78,10 @@ type StreamResult struct { } type Model struct { - ID string `json:"id"` - Name string `json:"name"` - Scene string `json:"scene,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Scene string `json:"scene,omitempty"` + InternalID string `json:"-"` } type State struct { @@ -97,6 +102,7 @@ type Service struct { transport lingmaipc.Transport stickySessionID string stickyModelID string + modelMap map[string]string // official name -> internal id } type promptRunResult struct { @@ -170,6 +176,16 @@ func (s *Service) ListModels(ctx context.Context) ([]Model, error) { if len(models) == 0 { 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 } @@ -235,17 +251,19 @@ func (s *Service) generateLocked( _ = s.deleteSessionLocked(cleanupCtx, ipcClient, sessionID) }() + internalModelID := s.resolveInternalModelID(req.Model) + requestID := lingmaipc.CreateRequestID("serve") meta := lingmaipc.CreateMeta(lingmaipc.MetaOptions{ RequestID: requestID, Mode: s.cfg.Mode, - Model: req.Model, + Model: internalModelID, ShellType: s.cfg.ShellType, CurrentFilePath: s.cfg.CurrentFilePath, EnabledMCP: []any{}, }) - modelID := strings.TrimSpace(req.Model) + modelID := strings.TrimSpace(internalModelID) if modelID != "" && s.shouldSetModel(sessionID, effectiveMode, modelID) { if err := ipcClient.Request(requestCtx, "session/set_model", map[string]any{ "sessionId": sessionID, @@ -284,6 +302,28 @@ func (s *Service) generateLocked( } 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 } @@ -546,7 +586,7 @@ func resolveSessionMode(req ChatRequest, configured SessionMode) SessionMode { if configured != SessionModeAuto { 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 SessionModeReuse @@ -567,13 +607,35 @@ func buildLingmaPrompt(req ChatRequest, mode SessionMode) (string, error) { if mode == SessionModeReuse { 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 } + 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) - if strings.TrimSpace(req.System) != "" { - parts = append(parts, "System instructions:", strings.TrimSpace(req.System)) + if system != "" { + parts = append(parts, "System instructions:", system) } parts = append(parts, "Conversation transcript:") for _, message := range messages { @@ -627,7 +689,7 @@ func extractModels(raw any) []Model { if name == "" { 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 { 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") } +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 { switch strings.ToLower(strings.TrimSpace(key)) { case "assistant", "chat", "developer", "inline", "quest": diff --git a/lingma-ipc-proxy.macos.json b/lingma-ipc-proxy.macos.json new file mode 100644 index 0000000..15f769e --- /dev/null +++ b/lingma-ipc-proxy.macos.json @@ -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" +} diff --git a/scripts/test-macos.sh b/scripts/test-macos.sh new file mode 100755 index 0000000..c38c5dd --- /dev/null +++ b/scripts/test-macos.sh @@ -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