feat(fantasy): add Fantasy SDK provider adapter

Bridge between picoclaw's agent loop and the charm.land/fantasy SDK:
- Factory function to create providers from config (OpenAI, Anthropic, etc.)
- Adapter layer converting Fantasy responses to picoclaw message format
- Claude CLI subprocess provider for local Claude Code usage
- Bidirectional message/tool-call converters with full test coverage
This commit is contained in:
ZanzyTHEbar 2026-02-15 21:47:49 +00:00
parent 156531e8f3
commit 2fb835e538
6 changed files with 1551 additions and 0 deletions

177
pkg/fantasy/adapter.go Normal file
View file

@ -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
}

343
pkg/fantasy/adapter_test.go Normal file
View file

@ -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")
}
}

184
pkg/fantasy/claude_cli.go Normal file
View file

@ -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)

140
pkg/fantasy/convert.go Normal file
View file

@ -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
}

475
pkg/fantasy/convert_test.go Normal file
View file

@ -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")
}
}

232
pkg/fantasy/factory.go Normal file
View file

@ -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
}