diff --git a/pkg/fantasy/adapter.go b/pkg/fantasy/adapter.go new file mode 100644 index 000000000..a270ef349 --- /dev/null +++ b/pkg/fantasy/adapter.go @@ -0,0 +1,177 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package fantasy + +import ( + "context" + "encoding/json" + "fmt" + + "charm.land/fantasy" + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/logger" + memstore "github.com/sipeed/picoclaw/pkg/memory/store" + "github.com/sipeed/picoclaw/pkg/tools" +) + +// PicoToolAdapter wraps a PicoClaw tool as a Fantasy AgentTool. +// It bridges PicoClaw's dual-channel ToolResult semantics with Fantasy's +// simple ToolResponse by publishing ForUser content to the bus as a side effect +// and returning only ForLLM content to Fantasy. +type PicoToolAdapter struct { + inner tools.Tool + bus *bus.MessageBus + channel string + chatID string + memStore *memstore.MemoryStore // may be nil if memory system disabled + agentID string + sessionKey string +} + +// Compile-time check that PicoToolAdapter implements fantasy.AgentTool. +var _ fantasy.AgentTool = (*PicoToolAdapter)(nil) + +// Info returns Fantasy-compatible tool metadata from the PicoClaw tool. +func (a *PicoToolAdapter) Info() fantasy.ToolInfo { + return fantasy.ToolInfo{ + Name: a.inner.Name(), + Description: a.inner.Description(), + Parameters: a.inner.Parameters(), + } +} + +// Run executes the PicoClaw tool and bridges the result to Fantasy. +// +// Side effects: +// - If the tool result has ForUser content and is not Silent, publishes to the bus. +// - If the tool is a ContextualTool, sets channel/chatID context before execution. +// - If the tool is an AsyncTool, wires a callback that publishes results to the bus. +func (a *PicoToolAdapter) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) { + // 1. Deserialize Fantasy's JSON string input into PicoClaw's map format. + args, err := parseToolArgs(call.Input) + if err != nil { + return fantasy.NewTextErrorResponse(fmt.Sprintf("invalid arguments: %v", err)), nil + } + + // 2. Set context for ContextualTool implementations. + if ct, ok := a.inner.(tools.ContextualTool); ok { + ct.SetContext(a.channel, a.chatID) + } + + // 3. Wire async callback for AsyncTool implementations. + if at, ok := a.inner.(tools.AsyncTool); ok { + at.SetCallback(func(_ context.Context, result *tools.ToolResult) { + if result != nil && result.ForUser != "" && !result.Silent && a.bus != nil { + a.bus.PublishOutbound(bus.OutboundMessage{ + Channel: a.channel, + ChatID: a.chatID, + Content: result.ForUser, + }) + } + }) + } + + // 4. Execute the PicoClaw tool. + result := a.inner.Execute(ctx, args) + if result == nil { + return fantasy.NewTextErrorResponse("tool returned nil result"), nil + } + + // 5. Dual-channel: publish ForUser content to bus (side effect). + // Fantasy never sees this — only the LLM-facing content is returned. + if result.ForUser != "" && !result.Silent && a.bus != nil { + a.bus.PublishOutbound(bus.OutboundMessage{ + Channel: a.channel, + ChatID: a.chatID, + Content: result.ForUser, + }) + } + + // 6. Offload large tool results to archival memory if configured. + if a.memStore != nil && !result.IsError && a.memStore.ShouldOffload(result.ForLLM) { + refID, summary, err := a.memStore.OffloadToolResult(ctx, a.inner.Name(), result.ForLLM, a.agentID, a.sessionKey) + if err == nil { + logger.DebugCF("adapter", "Offloaded large tool result to archival", + map[string]interface{}{ + "tool": a.inner.Name(), + "ref_id": refID, + "bytes": len(result.ForLLM), + }) + result.ForLLM = summary + } + // On error, fall through with original result + } + + // 7. Return only the LLM-facing content to Fantasy. + if result.IsError { + return fantasy.NewTextErrorResponse(result.ForLLM), nil + } + return fantasy.NewTextResponse(result.ForLLM), nil +} + +// ProviderOptions returns nil — PicoClaw tools have no provider-specific options. +func (a *PicoToolAdapter) ProviderOptions() fantasy.ProviderOptions { + return fantasy.ProviderOptions{} +} + +// SetProviderOptions is a no-op for PicoClaw tools. +func (a *PicoToolAdapter) SetProviderOptions(_ fantasy.ProviderOptions) {} + +// AdaptedToolsConfig configures how tools are adapted for the Fantasy agent. +type AdaptedToolsConfig struct { + MemStore *memstore.MemoryStore // optional: enables tool result offloading + AgentID string + SessionKey string +} + +// BuildAdaptedTools wraps visible tools in a ToolRegistry as Fantasy AgentTools. +// In progressive disclosure mode, only gateway tools (tool_search, tool_call, memory, etc.) +// are exposed to Fantasy. The agent discovers and invokes other tools via tool_search + tool_call. +func BuildAdaptedTools(registry *tools.ToolRegistry, msgBus *bus.MessageBus, channel, chatID string, opts ...AdaptedToolsConfig) []fantasy.AgentTool { + if registry == nil { + return nil + } + + var cfg AdaptedToolsConfig + if len(opts) > 0 { + cfg = opts[0] + } + + names := registry.ListVisible() + adapted := make([]fantasy.AgentTool, 0, len(names)) + + for _, name := range names { + tool, ok := registry.Get(name) + if !ok { + continue + } + adapted = append(adapted, &PicoToolAdapter{ + inner: tool, + bus: msgBus, + channel: channel, + chatID: chatID, + memStore: cfg.MemStore, + agentID: cfg.AgentID, + sessionKey: cfg.SessionKey, + }) + } + + return adapted +} + +// parseToolArgs deserializes a JSON string into a map. +// Handles both JSON objects and empty inputs gracefully. +func parseToolArgs(input string) (map[string]interface{}, error) { + if input == "" || input == "{}" { + return map[string]interface{}{}, nil + } + + var args map[string]interface{} + if err := json.Unmarshal([]byte(input), &args); err != nil { + return nil, fmt.Errorf("failed to parse tool arguments: %w", err) + } + return args, nil +} diff --git a/pkg/fantasy/adapter_test.go b/pkg/fantasy/adapter_test.go new file mode 100644 index 000000000..2e93c3373 --- /dev/null +++ b/pkg/fantasy/adapter_test.go @@ -0,0 +1,343 @@ +package fantasy + +import ( + "context" + "testing" + + "charm.land/fantasy" + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/tools" +) + +// --- Mock tool implementations --- + +type mockSilentTool struct{} + +func (t *mockSilentTool) Name() string { return "silent_tool" } +func (t *mockSilentTool) Description() string { return "A silent tool" } +func (t *mockSilentTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + } +} +func (t *mockSilentTool) Execute(_ context.Context, _ map[string]interface{}) *tools.ToolResult { + return &tools.ToolResult{ + ForLLM: "internal data", + ForUser: "", + Silent: true, + IsError: false, + } +} + +type mockDualChannelTool struct{} + +func (t *mockDualChannelTool) Name() string { return "dual_tool" } +func (t *mockDualChannelTool) Description() string { return "A dual channel tool" } +func (t *mockDualChannelTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "input": map[string]interface{}{"type": "string"}, + }, + } +} +func (t *mockDualChannelTool) Execute(_ context.Context, args map[string]interface{}) *tools.ToolResult { + input, _ := args["input"].(string) + return &tools.ToolResult{ + ForLLM: "LLM sees: " + input, + ForUser: "User sees: " + input, + Silent: false, + IsError: false, + } +} + +type mockErrorTool struct{} + +func (t *mockErrorTool) Name() string { return "error_tool" } +func (t *mockErrorTool) Description() string { return "A tool that errors" } +func (t *mockErrorTool) Parameters() map[string]interface{} { + return map[string]interface{}{"type": "object", "properties": map[string]interface{}{}} +} +func (t *mockErrorTool) Execute(_ context.Context, _ map[string]interface{}) *tools.ToolResult { + return &tools.ToolResult{ + ForLLM: "Something went wrong", + ForUser: "", + Silent: false, + IsError: true, + } +} + +type mockContextualTool struct { + channel string + chatID string +} + +func (t *mockContextualTool) Name() string { return "ctx_tool" } +func (t *mockContextualTool) Description() string { return "Contextual tool" } +func (t *mockContextualTool) Parameters() map[string]interface{} { + return map[string]interface{}{"type": "object", "properties": map[string]interface{}{}} +} +func (t *mockContextualTool) Execute(_ context.Context, _ map[string]interface{}) *tools.ToolResult { + return &tools.ToolResult{ + ForLLM: "channel=" + t.channel + " chat=" + t.chatID, + Silent: true, + } +} +func (t *mockContextualTool) SetContext(channel, chatID string) { + t.channel = channel + t.chatID = chatID +} + +type mockNilResultTool struct{} + +func (t *mockNilResultTool) Name() string { return "nil_tool" } +func (t *mockNilResultTool) Description() string { return "Returns nil" } +func (t *mockNilResultTool) Parameters() map[string]interface{} { + return map[string]interface{}{"type": "object", "properties": map[string]interface{}{}} +} +func (t *mockNilResultTool) Execute(_ context.Context, _ map[string]interface{}) *tools.ToolResult { + return nil +} + +// --- PicoToolAdapter.Info() Tests --- + +func TestAdapter_Info(t *testing.T) { + adapter := &PicoToolAdapter{inner: &mockDualChannelTool{}} + info := adapter.Info() + + if info.Name != "dual_tool" { + t.Errorf("Expected name 'dual_tool', got '%s'", info.Name) + } + if info.Description != "A dual channel tool" { + t.Errorf("Expected description mismatch") + } + if info.Parameters == nil { + t.Error("Expected non-nil parameters") + } +} + +// --- PicoToolAdapter.Run() Tests --- + +func TestAdapter_Run_SilentTool_NoPublish(t *testing.T) { + msgBus := bus.NewMessageBus() + adapter := &PicoToolAdapter{ + inner: &mockSilentTool{}, + bus: msgBus, + channel: "test", + chatID: "chat-1", + } + + call := fantasy.ToolCall{ + ID: "tc-1", + Name: "silent_tool", + Input: "{}", + } + + resp, err := adapter.Run(context.Background(), call) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if resp.Content != "internal data" { + t.Errorf("Expected 'internal data', got '%s'", resp.Content) + } + if resp.IsError { + t.Error("Expected non-error response") + } +} + +func TestAdapter_Run_DualChannel_PublishesForUser(t *testing.T) { + msgBus := bus.NewMessageBus() + adapter := &PicoToolAdapter{ + inner: &mockDualChannelTool{}, + bus: msgBus, + channel: "telegram", + chatID: "chat-42", + } + + call := fantasy.ToolCall{ + ID: "tc-dual", + Name: "dual_tool", + Input: `{"input": "hello"}`, + } + + resp, err := adapter.Run(context.Background(), call) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Fantasy should see ForLLM content + if resp.Content != "LLM sees: hello" { + t.Errorf("Expected 'LLM sees: hello', got '%s'", resp.Content) + } + + // Bus should have received ForUser content (we can't easily consume it + // in a non-blocking test without goroutines, but we verify it doesn't crash) +} + +func TestAdapter_Run_ErrorTool_ReturnsErrorResponse(t *testing.T) { + adapter := &PicoToolAdapter{ + inner: &mockErrorTool{}, + } + + call := fantasy.ToolCall{ + ID: "tc-err", + Name: "error_tool", + Input: "{}", + } + + resp, err := adapter.Run(context.Background(), call) + if err != nil { + t.Fatalf("Unexpected error (adapter should not return Go errors): %v", err) + } + + if !resp.IsError { + t.Error("Expected error response") + } + if resp.Content != "Something went wrong" { + t.Errorf("Expected error content, got '%s'", resp.Content) + } +} + +func TestAdapter_Run_NilResult_ReturnsError(t *testing.T) { + adapter := &PicoToolAdapter{ + inner: &mockNilResultTool{}, + } + + call := fantasy.ToolCall{ + ID: "tc-nil", + Name: "nil_tool", + Input: "{}", + } + + resp, err := adapter.Run(context.Background(), call) + if err != nil { + t.Fatalf("Unexpected Go error: %v", err) + } + + if !resp.IsError { + t.Error("Expected error response for nil result") + } + if resp.Content != "tool returned nil result" { + t.Errorf("Expected nil result error, got '%s'", resp.Content) + } +} + +func TestAdapter_Run_ContextualTool_SetsContext(t *testing.T) { + ctxTool := &mockContextualTool{} + adapter := &PicoToolAdapter{ + inner: ctxTool, + channel: "discord", + chatID: "guild-1", + } + + call := fantasy.ToolCall{ + ID: "tc-ctx", + Name: "ctx_tool", + Input: "{}", + } + + resp, err := adapter.Run(context.Background(), call) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // The tool should have received the context + if resp.Content != "channel=discord chat=guild-1" { + t.Errorf("Expected context in response, got '%s'", resp.Content) + } +} + +func TestAdapter_Run_InvalidJSON_ReturnsError(t *testing.T) { + adapter := &PicoToolAdapter{ + inner: &mockSilentTool{}, + } + + call := fantasy.ToolCall{ + ID: "tc-bad", + Name: "silent_tool", + Input: "not valid json{{{", + } + + resp, err := adapter.Run(context.Background(), call) + if err != nil { + t.Fatalf("Unexpected Go error: %v", err) + } + + if !resp.IsError { + t.Error("Expected error response for invalid JSON") + } +} + +// --- BuildAdaptedTools Tests --- + +func TestBuildAdaptedTools_NilRegistry(t *testing.T) { + result := BuildAdaptedTools(nil, nil, "", "") + if result != nil { + t.Error("Expected nil for nil registry") + } +} + +func TestBuildAdaptedTools_WrapsAllTools(t *testing.T) { + registry := tools.NewToolRegistry() + registry.Register(&mockSilentTool{}) + registry.Register(&mockDualChannelTool{}) + registry.Register(&mockErrorTool{}) + + adapted := BuildAdaptedTools(registry, nil, "ch", "id") + + if len(adapted) != 3 { + t.Fatalf("Expected 3 adapted tools, got %d", len(adapted)) + } + + // Verify all names are present + names := make(map[string]bool) + for _, tool := range adapted { + names[tool.Info().Name] = true + } + for _, expected := range []string{"silent_tool", "dual_tool", "error_tool"} { + if !names[expected] { + t.Errorf("Missing adapted tool: %s", expected) + } + } +} + +// --- parseToolArgs Tests --- + +func TestParseToolArgs_EmptyInput(t *testing.T) { + args, err := parseToolArgs("") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if len(args) != 0 { + t.Errorf("Expected empty map, got %d entries", len(args)) + } +} + +func TestParseToolArgs_EmptyObject(t *testing.T) { + args, err := parseToolArgs("{}") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if len(args) != 0 { + t.Errorf("Expected empty map, got %d entries", len(args)) + } +} + +func TestParseToolArgs_ValidJSON(t *testing.T) { + args, err := parseToolArgs(`{"key": "value", "num": 42}`) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if args["key"] != "value" { + t.Errorf("Expected key='value', got '%v'", args["key"]) + } +} + +func TestParseToolArgs_InvalidJSON(t *testing.T) { + _, err := parseToolArgs("not json") + if err == nil { + t.Error("Expected error for invalid JSON") + } +} diff --git a/pkg/fantasy/claude_cli.go b/pkg/fantasy/claude_cli.go new file mode 100644 index 000000000..c23b6fec7 --- /dev/null +++ b/pkg/fantasy/claude_cli.go @@ -0,0 +1,184 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package fantasy + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "iter" + "os/exec" + "strings" + + "charm.land/fantasy" +) + +// claudeCliProvider implements fantasy.Provider using the claude CLI subprocess. +type claudeCliProvider struct { + workspace string +} + +// claudeCliModel implements fantasy.LanguageModel. +type claudeCliModel struct { + workspace string + modelID string +} + +func newClaudeCliProvider(workspace string) fantasy.Provider { + return &claudeCliProvider{workspace: workspace} +} + +func (p *claudeCliProvider) Name() string { + return "claude-cli" +} + +func (p *claudeCliProvider) LanguageModel(_ context.Context, modelID string) (fantasy.LanguageModel, error) { + return &claudeCliModel{ + workspace: p.workspace, + modelID: modelID, + }, nil +} + +func (m *claudeCliModel) Provider() string { return "claude-cli" } +func (m *claudeCliModel) Model() string { return m.modelID } + +func (m *claudeCliModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) { + systemPrompt, userPrompt := extractPromptsFromCall(call) + + args := []string{"-p", "--output-format", "json", "--dangerously-skip-permissions", "--no-chrome"} + if systemPrompt != "" { + args = append(args, "--system-prompt", systemPrompt) + } + if m.modelID != "" && m.modelID != "claude-code" { + args = append(args, "--model", m.modelID) + } + args = append(args, "-") // read from stdin + + cmd := exec.CommandContext(ctx, "claude", args...) + cmd.Dir = m.workspace + cmd.Stdin = bytes.NewReader([]byte(userPrompt)) + + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("claude CLI failed: %w", err) + } + + text := extractTextFromCLIOutput(output) + + return &fantasy.Response{ + Content: fantasy.ResponseContent{fantasy.TextContent{Text: text}}, + FinishReason: fantasy.FinishReasonStop, + }, nil +} + +func (m *claudeCliModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) { + // Fall back to Generate for CLI provider — streaming not supported. + resp, err := m.Generate(ctx, call) + if err != nil { + return nil, err + } + + text := resp.Content.Text() + return func(yield func(fantasy.StreamPart) bool) { + // Emit the text as a delta followed by a finish signal. + if text != "" { + if !yield(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeTextDelta, + Delta: text, + }) { + return + } + } + yield(fantasy.StreamPart{ + Type: fantasy.StreamPartTypeFinish, + Usage: resp.Usage, + FinishReason: resp.FinishReason, + }) + }, nil +} + +func (m *claudeCliModel) GenerateObject(ctx context.Context, call fantasy.ObjectCall) (*fantasy.ObjectResponse, error) { + return nil, fmt.Errorf("claude-cli does not support object generation") +} + +func (m *claudeCliModel) StreamObject(ctx context.Context, call fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) { + return nil, fmt.Errorf("claude-cli does not support object streaming") +} + +// extractPromptsFromCall extracts system and user prompts from a Fantasy Call. +func extractPromptsFromCall(call fantasy.Call) (system, user string) { + var systemParts, userParts []string + + for _, msg := range call.Prompt { + text := extractText(msg.Content) + switch msg.Role { + case "system": + systemParts = append(systemParts, text) + case "user": + userParts = append(userParts, text) + case "assistant": + // Include assistant responses for context + userParts = append(userParts, "Assistant: "+text) + } + } + + return strings.Join(systemParts, "\n\n"), strings.Join(userParts, "\n\n") +} + +func extractText(parts []fantasy.MessagePart) string { + var texts []string + for _, p := range parts { + if tp, ok := p.(fantasy.TextPart); ok { + texts = append(texts, tp.Text) + } + } + return strings.Join(texts, "\n") +} + +// extractTextFromCLIOutput parses the claude CLI JSON output. +func extractTextFromCLIOutput(output []byte) string { + // Try JSON array format first (claude outputs array of objects) + var results []struct { + Type string `json:"type"` + Content string `json:"content"` + Text string `json:"text"` + } + if err := json.Unmarshal(output, &results); err == nil { + var texts []string + for _, r := range results { + switch { + case r.Content != "": + texts = append(texts, r.Content) + case r.Text != "": + texts = append(texts, r.Text) + } + } + if len(texts) > 0 { + return strings.Join(texts, "\n") + } + } + + // Try single JSON object + var single struct { + Result string `json:"result"` + Text string `json:"text"` + } + if err := json.Unmarshal(output, &single); err == nil { + if single.Result != "" { + return single.Result + } + if single.Text != "" { + return single.Text + } + } + + // Fallback: return raw output + return strings.TrimSpace(string(output)) +} + +// Ensure iter.Seq is properly typed for the compiler. +var _ iter.Seq[fantasy.StreamPart] = fantasy.StreamResponse(nil) diff --git a/pkg/fantasy/convert.go b/pkg/fantasy/convert.go new file mode 100644 index 000000000..2b3433d61 --- /dev/null +++ b/pkg/fantasy/convert.go @@ -0,0 +1,140 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package fantasy + +import ( + "encoding/json" + + "charm.land/fantasy" + "github.com/sipeed/picoclaw/pkg/messages" +) + +// MessagesToFantasy converts PicoClaw session messages to Fantasy's multipart format. +func MessagesToFantasy(msgs []messages.Message) []fantasy.Message { + out := make([]fantasy.Message, 0, len(msgs)) + + for _, msg := range msgs { + out = append(out, MessageToFantasy(msg)) + } + + return out +} + +// MessageToFantasy converts a single PicoClaw message to Fantasy format. +func MessageToFantasy(msg messages.Message) fantasy.Message { + var parts []fantasy.MessagePart + + // Tool result messages have a ToolCallID — they map to a ToolResultPart. + if msg.ToolCallID != "" { + parts = append(parts, fantasy.ToolResultPart{ + ToolCallID: msg.ToolCallID, + Output: fantasy.ToolResultOutputContentText{Text: msg.Content}, + }) + return fantasy.Message{ + Role: fantasy.MessageRole(msg.Role), + Content: parts, + } + } + + // Add text content if present. + if msg.Content != "" { + parts = append(parts, fantasy.TextPart{Text: msg.Content}) + } + + // Convert tool calls to ToolCallParts. + for _, tc := range msg.ToolCalls { + name := tc.Name + input := "" + + // Prefer Function.Name and Function.Arguments (OpenAI format). + if tc.Function != nil { + name = tc.Function.Name + input = tc.Function.Arguments + } else if tc.Arguments != nil { + // Fallback: serialize the map to JSON. + data, _ := json.Marshal(tc.Arguments) + input = string(data) + } + + parts = append(parts, fantasy.ToolCallPart{ + ToolCallID: tc.ID, + ToolName: name, + Input: input, + }) + } + + return fantasy.Message{ + Role: fantasy.MessageRole(msg.Role), + Content: parts, + } +} + +// StepToMessages converts a Fantasy StepResult back to PicoClaw message format +// for session storage. Each step may produce an assistant message with tool calls +// and zero or more tool result messages. +func StepToMessages(step fantasy.StepResult) []messages.Message { + var out []messages.Message + + // Build the assistant message from step content. + assistantMsg := messages.Message{ + Role: "assistant", + } + + var toolResults []messages.Message + + for _, c := range step.Response.Content { + switch ct := c.(type) { + case fantasy.TextContent: + assistantMsg.Content += ct.Text + + case fantasy.ToolCallContent: + assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, messages.ToolCall{ + ID: ct.ToolCallID, + Type: "function", + Function: &messages.FunctionCall{ + Name: ct.ToolName, + Arguments: ct.Input, + }, + }) + + case fantasy.ToolResultContent: + resultText := "" + if ct.Result != nil { + switch r := ct.Result.(type) { + case fantasy.ToolResultOutputContentText: + resultText = r.Text + case fantasy.ToolResultOutputContentError: + if r.Error != nil { + resultText = r.Error.Error() + } + } + } + toolResults = append(toolResults, messages.Message{ + Role: "tool", + Content: resultText, + ToolCallID: ct.ToolCallID, + }) + } + } + + // Always emit the assistant message (even if empty content with tool calls). + out = append(out, assistantMsg) + + // Append tool result messages after the assistant message. + out = append(out, toolResults...) + + return out +} + +// AgentResultToMessages converts a complete AgentResult to PicoClaw messages. +// This flattens all steps into a single message sequence. +func AgentResultToMessages(result *fantasy.AgentResult) []messages.Message { + var out []messages.Message + for _, step := range result.Steps { + out = append(out, StepToMessages(step)...) + } + return out +} diff --git a/pkg/fantasy/convert_test.go b/pkg/fantasy/convert_test.go new file mode 100644 index 000000000..a4762830e --- /dev/null +++ b/pkg/fantasy/convert_test.go @@ -0,0 +1,475 @@ +package fantasy + +import ( + "errors" + "testing" + + "charm.land/fantasy" + "github.com/sipeed/picoclaw/pkg/messages" +) + +// --- MessagesToFantasy Tests --- + +func TestMessagesToFantasy_EmptySlice(t *testing.T) { + result := MessagesToFantasy(nil) + if len(result) != 0 { + t.Errorf("Expected empty slice for nil input, got %d", len(result)) + } + + result = MessagesToFantasy([]messages.Message{}) + if len(result) != 0 { + t.Errorf("Expected empty slice for empty input, got %d", len(result)) + } +} + +func TestMessageToFantasy_SimpleTextMessage(t *testing.T) { + msg := messages.Message{ + Role: "user", + Content: "Hello, world", + } + + result := MessageToFantasy(msg) + + if result.Role != fantasy.MessageRoleUser { + t.Errorf("Expected role 'user', got '%s'", result.Role) + } + if len(result.Content) != 1 { + t.Fatalf("Expected 1 content part, got %d", len(result.Content)) + } + + tp, ok := fantasy.AsMessagePart[fantasy.TextPart](result.Content[0]) + if !ok { + t.Fatal("Expected TextPart") + } + if tp.Text != "Hello, world" { + t.Errorf("Expected 'Hello, world', got '%s'", tp.Text) + } +} + +func TestMessageToFantasy_AssistantWithMultipleToolCalls(t *testing.T) { + msg := messages.Message{ + Role: "assistant", + Content: "Let me run both tools.", + ToolCalls: []messages.ToolCall{ + { + ID: "call-1", + Type: "function", + Function: &messages.FunctionCall{ + Name: "read_file", + Arguments: `{"path": "/tmp/test.txt"}`, + }, + }, + { + ID: "call-2", + Type: "function", + Function: &messages.FunctionCall{ + Name: "write_file", + Arguments: `{"path": "/tmp/out.txt", "content": "data"}`, + }, + }, + }, + } + + result := MessageToFantasy(msg) + + if result.Role != fantasy.MessageRoleAssistant { + t.Errorf("Expected role 'assistant', got '%s'", result.Role) + } + + // Should have text + 2 tool calls = 3 parts + if len(result.Content) != 3 { + t.Fatalf("Expected 3 content parts (text + 2 tool calls), got %d", len(result.Content)) + } + + // Part 0: text + tp, ok := fantasy.AsMessagePart[fantasy.TextPart](result.Content[0]) + if !ok { + t.Fatal("Expected TextPart at index 0") + } + if tp.Text != "Let me run both tools." { + t.Errorf("Expected text content, got '%s'", tp.Text) + } + + // Part 1: first tool call + tc1, ok := fantasy.AsMessagePart[fantasy.ToolCallPart](result.Content[1]) + if !ok { + t.Fatal("Expected ToolCallPart at index 1") + } + if tc1.ToolCallID != "call-1" || tc1.ToolName != "read_file" { + t.Errorf("Tool call 1 mismatch: id=%s name=%s", tc1.ToolCallID, tc1.ToolName) + } + if tc1.Input != `{"path": "/tmp/test.txt"}` { + t.Errorf("Tool call 1 input mismatch: %s", tc1.Input) + } + + // Part 2: second tool call + tc2, ok := fantasy.AsMessagePart[fantasy.ToolCallPart](result.Content[2]) + if !ok { + t.Fatal("Expected ToolCallPart at index 2") + } + if tc2.ToolCallID != "call-2" || tc2.ToolName != "write_file" { + t.Errorf("Tool call 2 mismatch: id=%s name=%s", tc2.ToolCallID, tc2.ToolName) + } +} + +func TestMessageToFantasy_ToolCallWithMapArgsFallback(t *testing.T) { + msg := messages.Message{ + Role: "assistant", + ToolCalls: []messages.ToolCall{ + { + ID: "call-fallback", + Name: "exec", + Arguments: map[string]interface{}{ + "command": "ls -la", + }, + }, + }, + } + + result := MessageToFantasy(msg) + + if len(result.Content) != 1 { + t.Fatalf("Expected 1 part, got %d", len(result.Content)) + } + + tc, ok := fantasy.AsMessagePart[fantasy.ToolCallPart](result.Content[0]) + if !ok { + t.Fatal("Expected ToolCallPart") + } + if tc.ToolName != "exec" { + t.Errorf("Expected tool name 'exec', got '%s'", tc.ToolName) + } + if tc.Input != `{"command":"ls -la"}` { + t.Errorf("Expected serialized JSON, got '%s'", tc.Input) + } +} + +func TestMessageToFantasy_ToolResultMessage(t *testing.T) { + msg := messages.Message{ + Role: "tool", + Content: "file contents here", + ToolCallID: "call-1", + } + + result := MessageToFantasy(msg) + + if result.Role != fantasy.MessageRoleTool { + t.Errorf("Expected role 'tool', got '%s'", result.Role) + } + if len(result.Content) != 1 { + t.Fatalf("Expected 1 part, got %d", len(result.Content)) + } + + trp, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](result.Content[0]) + if !ok { + t.Fatal("Expected ToolResultPart") + } + if trp.ToolCallID != "call-1" { + t.Errorf("Expected ToolCallID 'call-1', got '%s'", trp.ToolCallID) + } + textOutput, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](trp.Output) + if !ok { + t.Fatal("Expected ToolResultOutputContentText") + } + if textOutput.Text != "file contents here" { + t.Errorf("Expected 'file contents here', got '%s'", textOutput.Text) + } +} + +func TestMessageToFantasy_EmptyContent(t *testing.T) { + msg := messages.Message{ + Role: "assistant", + Content: "", + } + + result := MessageToFantasy(msg) + + // Empty content + no tool calls = no parts + if len(result.Content) != 0 { + t.Errorf("Expected 0 parts for empty content, got %d", len(result.Content)) + } +} + +// --- StepToMessages Tests --- + +func TestStepToMessages_TextOnly(t *testing.T) { + step := fantasy.StepResult{ + Response: fantasy.Response{ + Content: fantasy.ResponseContent{ + fantasy.TextContent{Text: "Simple response"}, + }, + FinishReason: fantasy.FinishReasonStop, + }, + } + + msgs := StepToMessages(step) + + if len(msgs) != 1 { + t.Fatalf("Expected 1 message, got %d", len(msgs)) + } + if msgs[0].Role != "assistant" { + t.Errorf("Expected role 'assistant', got '%s'", msgs[0].Role) + } + if msgs[0].Content != "Simple response" { + t.Errorf("Expected 'Simple response', got '%s'", msgs[0].Content) + } +} + +func TestStepToMessages_MultipleToolCalls(t *testing.T) { + step := fantasy.StepResult{ + Response: fantasy.Response{ + Content: fantasy.ResponseContent{ + fantasy.TextContent{Text: "Running tools..."}, + fantasy.ToolCallContent{ + ToolCallID: "tc-1", + ToolName: "read_file", + Input: `{"path": "foo.txt"}`, + }, + fantasy.ToolCallContent{ + ToolCallID: "tc-2", + ToolName: "exec", + Input: `{"command": "ls"}`, + }, + fantasy.ToolResultContent{ + ToolCallID: "tc-1", + ToolName: "read_file", + Result: fantasy.ToolResultOutputContentText{Text: "file contents"}, + }, + fantasy.ToolResultContent{ + ToolCallID: "tc-2", + ToolName: "exec", + Result: fantasy.ToolResultOutputContentText{Text: "dir listing"}, + }, + }, + FinishReason: fantasy.FinishReasonToolCalls, + }, + } + + msgs := StepToMessages(step) + + // Should be: 1 assistant (with text + 2 tool calls) + 2 tool result messages + if len(msgs) != 3 { + t.Fatalf("Expected 3 messages (1 assistant + 2 tool results), got %d", len(msgs)) + } + + // Assistant message + assistantMsg := msgs[0] + if assistantMsg.Role != "assistant" { + t.Errorf("Expected 'assistant' role, got '%s'", assistantMsg.Role) + } + if assistantMsg.Content != "Running tools..." { + t.Errorf("Expected text content, got '%s'", assistantMsg.Content) + } + if len(assistantMsg.ToolCalls) != 2 { + t.Fatalf("Expected 2 tool calls, got %d", len(assistantMsg.ToolCalls)) + } + if assistantMsg.ToolCalls[0].ID != "tc-1" || assistantMsg.ToolCalls[0].Function.Name != "read_file" { + t.Errorf("Tool call 1 mismatch") + } + if assistantMsg.ToolCalls[1].ID != "tc-2" || assistantMsg.ToolCalls[1].Function.Name != "exec" { + t.Errorf("Tool call 2 mismatch") + } + + // Tool result messages + if msgs[1].Role != "tool" || msgs[1].ToolCallID != "tc-1" || msgs[1].Content != "file contents" { + t.Errorf("Tool result 1 mismatch: role=%s id=%s content=%s", msgs[1].Role, msgs[1].ToolCallID, msgs[1].Content) + } + if msgs[2].Role != "tool" || msgs[2].ToolCallID != "tc-2" || msgs[2].Content != "dir listing" { + t.Errorf("Tool result 2 mismatch: role=%s id=%s content=%s", msgs[2].Role, msgs[2].ToolCallID, msgs[2].Content) + } +} + +func TestStepToMessages_ErrorToolResult(t *testing.T) { + testErr := errors.New("permission denied") + step := fantasy.StepResult{ + Response: fantasy.Response{ + Content: fantasy.ResponseContent{ + fantasy.ToolCallContent{ + ToolCallID: "tc-err", + ToolName: "write_file", + Input: `{"path": "/etc/passwd"}`, + }, + fantasy.ToolResultContent{ + ToolCallID: "tc-err", + ToolName: "write_file", + Result: fantasy.ToolResultOutputContentError{Error: testErr}, + }, + }, + }, + } + + msgs := StepToMessages(step) + + if len(msgs) != 2 { + t.Fatalf("Expected 2 messages, got %d", len(msgs)) + } + + // Tool result should contain error text + toolMsg := msgs[1] + if toolMsg.Role != "tool" { + t.Errorf("Expected 'tool' role, got '%s'", toolMsg.Role) + } + if toolMsg.Content != "permission denied" { + t.Errorf("Expected error message, got '%s'", toolMsg.Content) + } +} + +func TestStepToMessages_ErrorToolResult_NilError(t *testing.T) { + step := fantasy.StepResult{ + Response: fantasy.Response{ + Content: fantasy.ResponseContent{ + fantasy.ToolResultContent{ + ToolCallID: "tc-nil", + Result: fantasy.ToolResultOutputContentError{Error: nil}, + }, + }, + }, + } + + msgs := StepToMessages(step) + + // Assistant message + tool result + if len(msgs) != 2 { + t.Fatalf("Expected 2 messages, got %d", len(msgs)) + } + // Nil error should produce empty content + if msgs[1].Content != "" { + t.Errorf("Expected empty content for nil error, got '%s'", msgs[1].Content) + } +} + +func TestStepToMessages_ToolCallWithoutText(t *testing.T) { + step := fantasy.StepResult{ + Response: fantasy.Response{ + Content: fantasy.ResponseContent{ + fantasy.ToolCallContent{ + ToolCallID: "tc-only", + ToolName: "exec", + Input: `{"command": "pwd"}`, + }, + }, + }, + } + + msgs := StepToMessages(step) + + if len(msgs) != 1 { + t.Fatalf("Expected 1 message, got %d", len(msgs)) + } + + // Assistant message with tool calls but no text + if msgs[0].Role != "assistant" { + t.Errorf("Expected 'assistant', got '%s'", msgs[0].Role) + } + if msgs[0].Content != "" { + t.Errorf("Expected empty content, got '%s'", msgs[0].Content) + } + if len(msgs[0].ToolCalls) != 1 { + t.Errorf("Expected 1 tool call, got %d", len(msgs[0].ToolCalls)) + } +} + +// --- AgentResultToMessages Tests --- + +func TestAgentResultToMessages_MultipleSteps(t *testing.T) { + result := &fantasy.AgentResult{ + Steps: []fantasy.StepResult{ + { + Response: fantasy.Response{ + Content: fantasy.ResponseContent{ + fantasy.ToolCallContent{ToolCallID: "s1-tc", ToolName: "exec", Input: `{}`}, + fantasy.ToolResultContent{ToolCallID: "s1-tc", Result: fantasy.ToolResultOutputContentText{Text: "ok"}}, + }, + }, + }, + { + Response: fantasy.Response{ + Content: fantasy.ResponseContent{ + fantasy.TextContent{Text: "Done!"}, + }, + }, + }, + }, + } + + msgs := AgentResultToMessages(result) + + // Step 1: assistant (with tool call) + tool result = 2 messages + // Step 2: assistant text = 1 message + if len(msgs) != 3 { + t.Fatalf("Expected 3 messages across 2 steps, got %d", len(msgs)) + } + + // Step 1 assistant + if msgs[0].Role != "assistant" || len(msgs[0].ToolCalls) != 1 { + t.Errorf("Step 1 assistant unexpected: role=%s toolcalls=%d", msgs[0].Role, len(msgs[0].ToolCalls)) + } + // Step 1 tool result + if msgs[1].Role != "tool" || msgs[1].Content != "ok" { + t.Errorf("Step 1 tool result unexpected: role=%s content=%s", msgs[1].Role, msgs[1].Content) + } + // Step 2 assistant + if msgs[2].Role != "assistant" || msgs[2].Content != "Done!" { + t.Errorf("Step 2 assistant unexpected: role=%s content=%s", msgs[2].Role, msgs[2].Content) + } +} + +// --- Round-trip fidelity test --- + +func TestRoundTrip_PicoClawToFantasyAndBack(t *testing.T) { + // Start with PicoClaw messages representing a typical conversation + original := []messages.Message{ + {Role: "user", Content: "Read the file"}, + { + Role: "assistant", + Content: "Reading file...", + ToolCalls: []messages.ToolCall{ + { + ID: "tc-read", + Type: "function", + Function: &messages.FunctionCall{ + Name: "read_file", + Arguments: `{"path": "test.txt"}`, + }, + }, + }, + }, + { + Role: "tool", + Content: "file contents here", + ToolCallID: "tc-read", + }, + {Role: "assistant", Content: "The file contains: file contents here"}, + } + + // Convert to Fantasy + fantasyMsgs := MessagesToFantasy(original) + + if len(fantasyMsgs) != 4 { + t.Fatalf("Expected 4 fantasy messages, got %d", len(fantasyMsgs)) + } + + // Verify key properties survived the conversion + // Message 0: user text + if fantasyMsgs[0].Role != fantasy.MessageRoleUser { + t.Errorf("Msg 0: expected user role") + } + + // Message 1: assistant with text + tool call + if fantasyMsgs[1].Role != fantasy.MessageRoleAssistant { + t.Errorf("Msg 1: expected assistant role") + } + if len(fantasyMsgs[1].Content) != 2 { + t.Errorf("Msg 1: expected 2 parts (text + tool call), got %d", len(fantasyMsgs[1].Content)) + } + + // Message 2: tool result + if fantasyMsgs[2].Role != fantasy.MessageRoleTool { + t.Errorf("Msg 2: expected tool role") + } + + // Message 3: final assistant text + if fantasyMsgs[3].Role != fantasy.MessageRoleAssistant { + t.Errorf("Msg 3: expected assistant role") + } +} diff --git a/pkg/fantasy/factory.go b/pkg/fantasy/factory.go new file mode 100644 index 000000000..de86a576c --- /dev/null +++ b/pkg/fantasy/factory.go @@ -0,0 +1,232 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package fantasy + +import ( + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "charm.land/fantasy" + "charm.land/fantasy/providers/openaicompat" + "github.com/openai/openai-go/v2/option" + "github.com/sipeed/picoclaw/pkg/config" +) + +// CreateProvider builds a Fantasy provider from PicoClaw config. +// It mirrors the provider selection logic from the legacy providers.CreateProvider. +func CreateProvider(cfg *config.Config) (fantasy.Provider, error) { + model := cfg.Agents.Defaults.Model + providerName := strings.ToLower(cfg.Agents.Defaults.Provider) + + lowerModel := strings.ToLower(model) + + // Resolve provider from explicit config + if providerName != "" { + switch providerName { + case "claude-cli", "claudecode", "claude-code": + workspace := cfg.Agents.Defaults.Workspace + if workspace == "" { + workspace = "." + } + return newClaudeCliProvider(workspace), nil + } + } + + // Build openaicompat options from config + apiKey, apiBase, proxy := resolveProvider(cfg, providerName, model, lowerModel) + + if apiKey == "" && apiBase == "" { + return nil, fmt.Errorf("no API key or base configured for provider (model: %s)", model) + } + if apiBase == "" { + return nil, fmt.Errorf("no API base configured for provider (model: %s)", model) + } + + timeout := resolveProviderTimeout(cfg, providerName) + + opts := []openaicompat.Option{ + openaicompat.WithBaseURL(apiBase), + openaicompat.WithAPIKey(apiKey), + openaicompat.WithName(providerNameOrDefault(providerName)), + } + + // Build HTTP client with proxy and timeout support + httpClient := buildHTTPClient(proxy, timeout) + if httpClient != nil { + opts = append(opts, openaicompat.WithHTTPClient(httpClient)) + } + + return openaicompat.New(opts...) +} + +// ModelID returns the effective model ID to pass to Fantasy's LanguageModel. +// It strips provider prefixes that the old system used for routing. +func ModelID(cfg *config.Config) string { + model := cfg.Agents.Defaults.Model + + // Strip provider prefix from model name (e.g., moonshot/kimi-k2.5 -> kimi-k2.5) + if idx := strings.Index(model, "/"); idx != -1 { + prefix := model[:idx] + if prefix == "moonshot" || prefix == "nvidia" { + return model[idx+1:] + } + } + + return model +} + +// resolveProvider determines the API key, base URL, and proxy for a given config. +func resolveProvider(cfg *config.Config, providerName, model, lowerModel string) (apiKey, apiBase, proxy string) { + // First, try explicitly configured provider + if providerName != "" { + switch providerName { + case "groq": + if cfg.Providers.Groq.APIKey != "" { + return cfg.Providers.Groq.APIKey, defaultIfEmpty(cfg.Providers.Groq.APIBase, "https://api.groq.com/openai/v1"), "" + } + case "openai", "gpt": + if cfg.Providers.OpenAI.APIKey != "" { + return cfg.Providers.OpenAI.APIKey, defaultIfEmpty(cfg.Providers.OpenAI.APIBase, "https://api.openai.com/v1"), "" + } + case "anthropic", "claude": + if cfg.Providers.Anthropic.APIKey != "" { + return cfg.Providers.Anthropic.APIKey, defaultIfEmpty(cfg.Providers.Anthropic.APIBase, "https://api.anthropic.com/v1"), "" + } + case "openrouter": + if cfg.Providers.OpenRouter.APIKey != "" { + return cfg.Providers.OpenRouter.APIKey, defaultIfEmpty(cfg.Providers.OpenRouter.APIBase, "https://openrouter.ai/api/v1"), "" + } + case "zhipu", "glm": + if cfg.Providers.Zhipu.APIKey != "" { + return cfg.Providers.Zhipu.APIKey, defaultIfEmpty(cfg.Providers.Zhipu.APIBase, "https://open.bigmodel.cn/api/paas/v4"), "" + } + case "gemini", "google": + if cfg.Providers.Gemini.APIKey != "" { + return cfg.Providers.Gemini.APIKey, defaultIfEmpty(cfg.Providers.Gemini.APIBase, "https://generativelanguage.googleapis.com/v1beta"), "" + } + case "vllm": + if cfg.Providers.VLLM.APIBase != "" { + return cfg.Providers.VLLM.APIKey, cfg.Providers.VLLM.APIBase, "" + } + case "shengsuanyun": + if cfg.Providers.ShengSuanYun.APIKey != "" { + return cfg.Providers.ShengSuanYun.APIKey, defaultIfEmpty(cfg.Providers.ShengSuanYun.APIBase, "https://router.shengsuanyun.com/api/v1"), "" + } + case "deepseek": + if cfg.Providers.DeepSeek.APIKey != "" { + return cfg.Providers.DeepSeek.APIKey, defaultIfEmpty(cfg.Providers.DeepSeek.APIBase, "https://api.deepseek.com/v1"), "" + } + } + } + + // Fallback: detect provider from model name + switch { + case (strings.Contains(lowerModel, "kimi") || strings.Contains(lowerModel, "moonshot") || strings.HasPrefix(model, "moonshot/")) && cfg.Providers.Moonshot.APIKey != "": + return cfg.Providers.Moonshot.APIKey, defaultIfEmpty(cfg.Providers.Moonshot.APIBase, "https://api.moonshot.cn/v1"), cfg.Providers.Moonshot.Proxy + + case strings.HasPrefix(model, "openrouter/") || strings.HasPrefix(model, "anthropic/") || strings.HasPrefix(model, "openai/") || strings.HasPrefix(model, "meta-llama/") || strings.HasPrefix(model, "deepseek/") || strings.HasPrefix(model, "google/"): + base := defaultIfEmpty(cfg.Providers.OpenRouter.APIBase, "https://openrouter.ai/api/v1") + return cfg.Providers.OpenRouter.APIKey, base, cfg.Providers.OpenRouter.Proxy + + case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) && cfg.Providers.Anthropic.APIKey != "": + return cfg.Providers.Anthropic.APIKey, defaultIfEmpty(cfg.Providers.Anthropic.APIBase, "https://api.anthropic.com/v1"), cfg.Providers.Anthropic.Proxy + + case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) && cfg.Providers.OpenAI.APIKey != "": + return cfg.Providers.OpenAI.APIKey, defaultIfEmpty(cfg.Providers.OpenAI.APIBase, "https://api.openai.com/v1"), cfg.Providers.OpenAI.Proxy + + case (strings.Contains(lowerModel, "gemini") || strings.HasPrefix(model, "google/")) && cfg.Providers.Gemini.APIKey != "": + return cfg.Providers.Gemini.APIKey, defaultIfEmpty(cfg.Providers.Gemini.APIBase, "https://generativelanguage.googleapis.com/v1beta"), cfg.Providers.Gemini.Proxy + + case (strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "zhipu") || strings.Contains(lowerModel, "zai")) && cfg.Providers.Zhipu.APIKey != "": + return cfg.Providers.Zhipu.APIKey, defaultIfEmpty(cfg.Providers.Zhipu.APIBase, "https://open.bigmodel.cn/api/paas/v4"), cfg.Providers.Zhipu.Proxy + + case (strings.Contains(lowerModel, "groq") || strings.HasPrefix(model, "groq/")) && cfg.Providers.Groq.APIKey != "": + return cfg.Providers.Groq.APIKey, defaultIfEmpty(cfg.Providers.Groq.APIBase, "https://api.groq.com/openai/v1"), cfg.Providers.Groq.Proxy + + case (strings.Contains(lowerModel, "nvidia") || strings.HasPrefix(model, "nvidia/")) && cfg.Providers.Nvidia.APIKey != "": + return cfg.Providers.Nvidia.APIKey, defaultIfEmpty(cfg.Providers.Nvidia.APIBase, "https://integrate.api.nvidia.com/v1"), cfg.Providers.Nvidia.Proxy + + case cfg.Providers.VLLM.APIBase != "": + return cfg.Providers.VLLM.APIKey, cfg.Providers.VLLM.APIBase, cfg.Providers.VLLM.Proxy + + default: + if cfg.Providers.OpenRouter.APIKey != "" { + base := defaultIfEmpty(cfg.Providers.OpenRouter.APIBase, "https://openrouter.ai/api/v1") + return cfg.Providers.OpenRouter.APIKey, base, cfg.Providers.OpenRouter.Proxy + } + } + + return "", "", "" +} + +// resolveProviderTimeout extracts the timeout from the matched provider config. +func resolveProviderTimeout(cfg *config.Config, providerName string) time.Duration { + var timeoutSec int + + switch providerName { + case "groq": + timeoutSec = cfg.Providers.Groq.Timeout + case "openai", "gpt": + timeoutSec = cfg.Providers.OpenAI.Timeout + case "anthropic", "claude": + timeoutSec = cfg.Providers.Anthropic.Timeout + case "openrouter": + timeoutSec = cfg.Providers.OpenRouter.Timeout + case "zhipu", "glm": + timeoutSec = cfg.Providers.Zhipu.Timeout + case "gemini", "google": + timeoutSec = cfg.Providers.Gemini.Timeout + case "vllm": + timeoutSec = cfg.Providers.VLLM.Timeout + case "shengsuanyun": + timeoutSec = cfg.Providers.ShengSuanYun.Timeout + case "deepseek": + timeoutSec = cfg.Providers.DeepSeek.Timeout + case "nvidia": + timeoutSec = cfg.Providers.Nvidia.Timeout + case "moonshot": + timeoutSec = cfg.Providers.Moonshot.Timeout + } + + if timeoutSec > 0 { + return time.Duration(timeoutSec) * time.Second + } + return 120 * time.Second +} + +// buildHTTPClient creates an HTTP client with optional proxy and timeout. +func buildHTTPClient(proxy string, timeout time.Duration) option.HTTPClient { + transport := http.DefaultTransport.(*http.Transport).Clone() + + if proxy != "" { + proxyURL, err := url.Parse(proxy) + if err == nil { + transport.Proxy = http.ProxyURL(proxyURL) + } + } + + return &http.Client{ + Timeout: timeout, + Transport: transport, + } +} + +func defaultIfEmpty(val, fallback string) string { + if val == "" { + return fallback + } + return val +} + +func providerNameOrDefault(name string) string { + if name == "" { + return "picoclaw" + } + return name +}