feat: añadir claude-code como proveedor LLM via claude -p
Implementa un nuevo proveedor LLM que ejecuta 'claude -p' como subproceso, permitiendo usar Claude Code como backend de cualquier agente Matrix. Cambios: - pkg/llm/types.go: nueva constante ProviderClaudeCode - pkg/llm/router.go: routing de 'claude-code' antes de 'claude*' (Anthropic API) - internal/config/schema.go: nuevo tipo ClaudeCodeCfg con campos para binary, timeout, disable_tools, allowed/disallowed tools, permission_mode, model, fallback_model, session_id y add_dirs - shell/llm/claudecode.go: provider completo — buildClaudeArgs(), flattenMessages(), parseClaudeOutput() y filterEnv() para limpiar ANTHROPIC_API_KEY del entorno y que claude use su propia auth OAuth - shell/llm/factory.go: case 'claude-code' en FromConfig(), WithFallback() ahora recibe fallbackCfg para sobreescribir model/max_tokens al hacer fallback - agents/runtime.go: actualizado para pasar fallbackCfg a WithFallback() No se tocó: los proveedores existentes (anthropic.go, openai.go), el core puro de decision ni el listener de Matrix.
This commit is contained in:
+1
-1
@@ -129,7 +129,7 @@ func New(cfg *config.AgentConfig, rules []decision.Rule, logger *slog.Logger) (*
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("fallback LLM config error", "err", err)
|
logger.Warn("fallback LLM config error", "err", err)
|
||||||
} else {
|
} else {
|
||||||
llmFunc = shelllm.WithFallback(primaryLLM, fallbackLLM, llmLog)
|
llmFunc = shelllm.WithFallback(primaryLLM, fallbackLLM, cfg.LLM.Fallback, llmLog)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,24 @@ type LLMProviderCfg struct {
|
|||||||
BaseURL string `yaml:"base_url"`
|
BaseURL string `yaml:"base_url"`
|
||||||
MaxTokens int `yaml:"max_tokens"`
|
MaxTokens int `yaml:"max_tokens"`
|
||||||
Temperature float64 `yaml:"temperature"`
|
Temperature float64 `yaml:"temperature"`
|
||||||
|
|
||||||
|
// ClaudeCode holds configuration for the claude-code provider (claude -p).
|
||||||
|
ClaudeCode ClaudeCodeCfg `yaml:"claude_code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaudeCodeCfg configures the claude -p subprocess provider.
|
||||||
|
type ClaudeCodeCfg struct {
|
||||||
|
Binary string `yaml:"binary"` // path to claude binary (default: "claude")
|
||||||
|
Timeout time.Duration `yaml:"timeout"` // subprocess timeout (default: 5m)
|
||||||
|
DisableTools bool `yaml:"disable_tools"` // pass --tools "" to disable all internal tools
|
||||||
|
AllowedTools []string `yaml:"allowed_tools"` // tools claude -p can use internally (e.g. Bash, Read, Edit)
|
||||||
|
DisallowedTools []string `yaml:"disallowed_tools"` // tools to block
|
||||||
|
WorkingDir string `yaml:"working_dir"` // working directory for claude -p
|
||||||
|
PermissionMode string `yaml:"permission_mode"` // default, acceptEdits, bypassPermissions, plan
|
||||||
|
Model string `yaml:"model"` // inner model: sonnet, opus, haiku, or full name
|
||||||
|
FallbackModel string `yaml:"fallback_model"` // fallback model if primary is overloaded
|
||||||
|
SessionID string `yaml:"session_id"` // fixed session ID for continuity
|
||||||
|
AddDirs []string `yaml:"add_dirs"` // additional directories accessible
|
||||||
}
|
}
|
||||||
|
|
||||||
type LLMReasoningCfg struct {
|
type LLMReasoningCfg struct {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import "strings"
|
|||||||
// Route maps a model string to its provider. Pure function.
|
// Route maps a model string to its provider. Pure function.
|
||||||
func Route(model string) ProviderID {
|
func Route(model string) ProviderID {
|
||||||
switch {
|
switch {
|
||||||
|
case model == "claude-code" || strings.HasPrefix(model, "claude-code/"):
|
||||||
|
return ProviderClaudeCode
|
||||||
case strings.HasPrefix(model, "claude"):
|
case strings.HasPrefix(model, "claude"):
|
||||||
return ProviderAnthropic
|
return ProviderAnthropic
|
||||||
case strings.HasPrefix(model, "gpt"), strings.HasPrefix(model, "o1"), strings.HasPrefix(model, "o3"):
|
case strings.HasPrefix(model, "gpt"), strings.HasPrefix(model, "o1"), strings.HasPrefix(model, "o3"):
|
||||||
|
|||||||
+4
-3
@@ -16,9 +16,10 @@ const (
|
|||||||
type ProviderID string
|
type ProviderID string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ProviderAnthropic ProviderID = "anthropic"
|
ProviderAnthropic ProviderID = "anthropic"
|
||||||
ProviderOpenAI ProviderID = "openai"
|
ProviderOpenAI ProviderID = "openai"
|
||||||
ProviderOllama ProviderID = "ollama"
|
ProviderOllama ProviderID = "ollama"
|
||||||
|
ProviderClaudeCode ProviderID = "claude-code"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Message struct {
|
type Message struct {
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/enmanuel/agents/internal/config"
|
||||||
|
coretypes "github.com/enmanuel/agents/pkg/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultClaudeBinary = "claude"
|
||||||
|
defaultClaudeTimeout = 5 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// claudeJSONOutput represents the JSON output from `claude -p --output-format json`.
|
||||||
|
type claudeJSONOutput struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Subtype string `json:"subtype"`
|
||||||
|
CostUSD float64 `json:"cost_usd"`
|
||||||
|
IsError bool `json:"is_error"`
|
||||||
|
Duration float64 `json:"duration_api_ms"`
|
||||||
|
NumTurns int `json:"num_turns"`
|
||||||
|
Result string `json:"result"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
TotalCost float64 `json:"total_cost_usd"`
|
||||||
|
Usage claudeUsage `json:"usage"`
|
||||||
|
ContentBlock []claudeContent `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type claudeUsage struct {
|
||||||
|
InputTokens int `json:"input_tokens"`
|
||||||
|
OutputTokens int `json:"output_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type claudeContent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClaudeCodeComplete creates a CompleteFunc that executes `claude -p` as a subprocess.
|
||||||
|
func NewClaudeCodeComplete(cfg config.ClaudeCodeCfg, log *slog.Logger) coretypes.CompleteFunc {
|
||||||
|
binary := cfg.Binary
|
||||||
|
if binary == "" {
|
||||||
|
binary = defaultClaudeBinary
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := cfg.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = defaultClaudeTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(ctx context.Context, req coretypes.CompletionRequest) (coretypes.CompletionResponse, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
args := buildClaudeArgs(cfg, req)
|
||||||
|
|
||||||
|
prompt := flattenMessages(req.Messages)
|
||||||
|
|
||||||
|
log.Debug("claude_code_exec",
|
||||||
|
"binary", binary,
|
||||||
|
"args", strings.Join(args, " "),
|
||||||
|
"prompt_len", len(prompt),
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, binary, args...)
|
||||||
|
if cfg.WorkingDir != "" {
|
||||||
|
cmd.Dir = cfg.WorkingDir
|
||||||
|
}
|
||||||
|
// Build clean env: inherit parent but remove ANTHROPIC_API_KEY
|
||||||
|
// so claude uses its own OAuth auth instead of a potentially invalid key.
|
||||||
|
cmd.Env = filterEnv(os.Environ(), "ANTHROPIC_API_KEY")
|
||||||
|
cmd.Stdin = strings.NewReader(prompt)
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
err := cmd.Run()
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
log.Debug("claude_code_done",
|
||||||
|
"elapsed_ms", elapsed.Milliseconds(),
|
||||||
|
"stdout_len", stdout.Len(),
|
||||||
|
"stderr_len", stderr.Len(),
|
||||||
|
"exit_err", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return parseClaudeOutput(stdout.Bytes(), stderr.Bytes(), err, elapsed, log)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildClaudeArgs constructs the CLI arguments for claude -p.
|
||||||
|
func buildClaudeArgs(cfg config.ClaudeCodeCfg, req coretypes.CompletionRequest) []string {
|
||||||
|
args := []string{"--print", "--output-format", "json"}
|
||||||
|
|
||||||
|
if req.SystemPrompt != "" {
|
||||||
|
args = append(args, "--system-prompt", req.SystemPrompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.DisableTools {
|
||||||
|
args = append(args, "--tools", "")
|
||||||
|
} else {
|
||||||
|
if len(cfg.AllowedTools) > 0 {
|
||||||
|
args = append(args, "--allowedTools")
|
||||||
|
args = append(args, cfg.AllowedTools...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.DisallowedTools) > 0 {
|
||||||
|
args = append(args, "--disallowedTools")
|
||||||
|
args = append(args, cfg.DisallowedTools...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.PermissionMode != "" {
|
||||||
|
args = append(args, "--permission-mode", cfg.PermissionMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Model != "" {
|
||||||
|
args = append(args, "--model", cfg.Model)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.FallbackModel != "" {
|
||||||
|
args = append(args, "--fallback-model", cfg.FallbackModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.SessionID != "" {
|
||||||
|
args = append(args, "--session-id", cfg.SessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dir := range cfg.AddDirs {
|
||||||
|
args = append(args, "--add-dir", dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
// flattenMessages converts a conversation history into a single text prompt for stdin.
|
||||||
|
func flattenMessages(msgs []coretypes.Message) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, m := range msgs {
|
||||||
|
switch m.Role {
|
||||||
|
case coretypes.RoleUser:
|
||||||
|
fmt.Fprintf(&b, "User: %s\n\n", m.Content)
|
||||||
|
case coretypes.RoleAssistant:
|
||||||
|
fmt.Fprintf(&b, "Assistant: %s\n\n", m.Content)
|
||||||
|
case coretypes.RoleTool:
|
||||||
|
fmt.Fprintf(&b, "Tool result: %s\n\n", m.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseClaudeOutput parses the JSON output from `claude -p --output-format json`.
|
||||||
|
func parseClaudeOutput(
|
||||||
|
stdout, stderr []byte,
|
||||||
|
execErr error,
|
||||||
|
elapsed time.Duration,
|
||||||
|
log *slog.Logger,
|
||||||
|
) (coretypes.CompletionResponse, error) {
|
||||||
|
// If the process failed and there's no stdout, report the error
|
||||||
|
if execErr != nil && len(stdout) == 0 {
|
||||||
|
errMsg := string(stderr)
|
||||||
|
if errMsg == "" {
|
||||||
|
errMsg = execErr.Error()
|
||||||
|
}
|
||||||
|
return coretypes.CompletionResponse{}, fmt.Errorf("claude-code process failed: %s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON output
|
||||||
|
var output claudeJSONOutput
|
||||||
|
if err := json.Unmarshal(stdout, &output); err != nil {
|
||||||
|
// Fall back to treating stdout as plain text
|
||||||
|
log.Warn("claude_code_json_parse_failed", "err", err, "stdout_len", len(stdout))
|
||||||
|
return coretypes.CompletionResponse{
|
||||||
|
Content: strings.TrimSpace(string(stdout)),
|
||||||
|
FinishReason: "stop",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if output.IsError {
|
||||||
|
return coretypes.CompletionResponse{}, fmt.Errorf("claude-code error: %s", output.Result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract text from result field or content blocks
|
||||||
|
content := output.Result
|
||||||
|
if content == "" && len(output.ContentBlock) > 0 {
|
||||||
|
var parts []string
|
||||||
|
for _, block := range output.ContentBlock {
|
||||||
|
if block.Type == "text" && block.Text != "" {
|
||||||
|
parts = append(parts, block.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content = strings.Join(parts, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
finishReason := "stop"
|
||||||
|
if execErr != nil {
|
||||||
|
finishReason = "error"
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("claude_code_response",
|
||||||
|
"content_len", len(content),
|
||||||
|
"input_tokens", output.Usage.InputTokens,
|
||||||
|
"output_tokens", output.Usage.OutputTokens,
|
||||||
|
"num_turns", output.NumTurns,
|
||||||
|
"cost_usd", output.TotalCost,
|
||||||
|
"elapsed_ms", elapsed.Milliseconds(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return coretypes.CompletionResponse{
|
||||||
|
Content: content,
|
||||||
|
Usage: coretypes.TokenUsage{
|
||||||
|
InputTokens: output.Usage.InputTokens,
|
||||||
|
OutputTokens: output.Usage.OutputTokens,
|
||||||
|
TotalTokens: output.Usage.InputTokens + output.Usage.OutputTokens,
|
||||||
|
},
|
||||||
|
FinishReason: finishReason,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterEnv returns a copy of environ with the named keys removed.
|
||||||
|
func filterEnv(environ []string, keys ...string) []string {
|
||||||
|
out := make([]string, 0, len(environ))
|
||||||
|
for _, e := range environ {
|
||||||
|
skip := false
|
||||||
|
for _, k := range keys {
|
||||||
|
if strings.HasPrefix(e, k+"=") {
|
||||||
|
skip = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !skip {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+11
-2
@@ -23,18 +23,27 @@ func FromConfig(cfg config.LLMProviderCfg, log *slog.Logger) (coretypes.Complete
|
|||||||
base = "http://localhost:11434/v1"
|
base = "http://localhost:11434/v1"
|
||||||
}
|
}
|
||||||
return NewOpenAIComplete("OLLAMA_API_KEY", base, log), nil
|
return NewOpenAIComplete("OLLAMA_API_KEY", base, log), nil
|
||||||
|
case "claude-code":
|
||||||
|
return NewClaudeCodeComplete(cfg.ClaudeCode, log), nil
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown LLM provider: %s", cfg.Provider)
|
return nil, fmt.Errorf("unknown LLM provider: %s", cfg.Provider)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithFallback wraps primary with a fallback CompleteFunc.
|
// WithFallback wraps primary with a fallback CompleteFunc.
|
||||||
// If primary returns an error, fallback is tried.
|
// If primary returns an error, fallback is tried with the fallback config's model.
|
||||||
func WithFallback(primary, fallback coretypes.CompleteFunc, log *slog.Logger) coretypes.CompleteFunc {
|
func WithFallback(primary, fallback coretypes.CompleteFunc, fallbackCfg config.LLMProviderCfg, log *slog.Logger) coretypes.CompleteFunc {
|
||||||
return func(ctx context.Context, req coretypes.CompletionRequest) (coretypes.CompletionResponse, error) {
|
return func(ctx context.Context, req coretypes.CompletionRequest) (coretypes.CompletionResponse, error) {
|
||||||
resp, err := primary(ctx, req)
|
resp, err := primary(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("llm_fallback_triggered", "primary_err", err)
|
log.Warn("llm_fallback_triggered", "primary_err", err)
|
||||||
|
// Override request fields with fallback config values
|
||||||
|
if fallbackCfg.Model != "" {
|
||||||
|
req.Model = fallbackCfg.Model
|
||||||
|
}
|
||||||
|
if fallbackCfg.MaxTokens > 0 {
|
||||||
|
req.MaxTokens = fallbackCfg.MaxTokens
|
||||||
|
}
|
||||||
return fallback(ctx, req)
|
return fallback(ctx, req)
|
||||||
}
|
}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
|
|||||||
Reference in New Issue
Block a user