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:
parent
156531e8f3
commit
2fb835e538
6 changed files with 1551 additions and 0 deletions
177
pkg/fantasy/adapter.go
Normal file
177
pkg/fantasy/adapter.go
Normal 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
343
pkg/fantasy/adapter_test.go
Normal 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
184
pkg/fantasy/claude_cli.go
Normal 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
140
pkg/fantasy/convert.go
Normal 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
475
pkg/fantasy/convert_test.go
Normal 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
232
pkg/fantasy/factory.go
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue