From 286d3dae3cc2502937ef8c8d22829ec8b1bbd02e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E6=A3=AE?= Date: Fri, 8 May 2026 22:04:18 +0800 Subject: [PATCH] feat: add DeepSeek LLM provider support - Add DeepSeekClient implementing LLM interface - Support chat and streaming APIs - Add Provider config option (ollama/deepseek) - Default to DeepSeek with model deepseek-v4-flash - Update CLI to display provider info - Add DeepSeek environment variables (DEEPSEEK_API_KEY, etc.) --- cmd/orca/main.go | 26 ++++- internal/config/config.go | 111 ++++++++++++------- internal/config/config_test.go | 12 ++ pkg/kernel/kernel.go | 43 +++++++- pkg/llm/deepseek.go | 193 +++++++++++++++++++++++++++++++++ 5 files changed, 342 insertions(+), 43 deletions(-) create mode 100644 pkg/llm/deepseek.go diff --git a/cmd/orca/main.go b/cmd/orca/main.go index 61aeb5c..cc8dc85 100644 --- a/cmd/orca/main.go +++ b/cmd/orca/main.go @@ -23,7 +23,6 @@ func main() { // Load configuration from environment variables cfg := config.LoadConfigFromEnv() - // Support shorter env var names for Ollama (without ORCA_ prefix) if v := os.Getenv("OLLAMA_BASE_URL"); v != "" { cfg.Ollama.BaseURL = v } @@ -36,6 +35,21 @@ func main() { } } + if v := os.Getenv("DEEPSEEK_BASE_URL"); v != "" { + cfg.DeepSeek.BaseURL = v + } + if v := os.Getenv("DEEPSEEK_MODEL"); v != "" { + cfg.DeepSeek.Model = v + } + if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" { + cfg.DeepSeek.APIKey = v + } + if v := os.Getenv("DEEPSEEK_TIMEOUT"); v != "" { + if d, err := time.ParseDuration(v); err == nil { + cfg.DeepSeek.Timeout = d + } + } + // Create and start kernel k := kernel.NewWithConfig(cfg) @@ -47,8 +61,14 @@ func main() { fmt.Println("Orca Agent Framework") fmt.Println("Kernel started successfully") - fmt.Printf(" LLM Model: %s\n", cfg.Ollama.Model) - fmt.Printf(" Ollama URL: %s\n", cfg.Ollama.BaseURL) + if cfg.Provider == config.ProviderDeepSeek { + fmt.Printf(" Provider: DeepSeek\n") + fmt.Printf(" LLM Model: %s\n", cfg.DeepSeek.Model) + } else { + fmt.Printf(" Provider: Ollama\n") + fmt.Printf(" LLM Model: %s\n", cfg.Ollama.Model) + fmt.Printf(" Ollama URL: %s\n", cfg.Ollama.BaseURL) + } fmt.Println("Type your message or /help for commands.") fmt.Println() diff --git a/internal/config/config.go b/internal/config/config.go index e499bad..5566f1b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,7 +1,3 @@ -// Package config provides the configuration types for the Orca framework. -// -// Configuration is organized into logical groups: LLM (Ollama), sandbox, -// and session management. Default values are provided for all settings. package config import ( @@ -10,52 +6,60 @@ import ( "time" ) -// Config is the top-level configuration for the Orca framework. +const ( + ProviderOllama = "ollama" + ProviderDeepSeek = "deepseek" +) + type Config struct { - Ollama OllamaConfig `json:"ollama"` - Sandbox SandboxConfig `json:"sandbox"` - Session SessionConfig `json:"session"` + Provider string `json:"provider"` + Ollama OllamaConfig `json:"ollama"` + DeepSeek DeepSeekConfig `json:"deepseek"` + Sandbox SandboxConfig `json:"sandbox"` + Session SessionConfig `json:"session"` } -// OllamaConfig holds configuration for the Ollama LLM backend. type OllamaConfig struct { - // BaseURL is the Ollama API endpoint (e.g., "http://localhost:11434"). - BaseURL string `json:"base_url"` - // Model is the Ollama model name to use (e.g., "gemma4:e4b", "codellama"). - Model string `json:"model"` - // Timeout is the maximum duration to wait for an Ollama response. + BaseURL string `json:"base_url"` + Model string `json:"model"` + Timeout time.Duration `json:"timeout"` +} + +type DeepSeekConfig struct { + BaseURL string `json:"base_url"` + Model string `json:"model"` + APIKey string `json:"api_key"` Timeout time.Duration `json:"timeout"` } -// SandboxConfig holds configuration for the command execution sandbox. type SandboxConfig struct { - // Timeout is the maximum duration for a sandboxed command. - Timeout time.Duration `json:"timeout"` - // MaxMemory is the maximum memory allocation for the sandbox (in bytes). - MaxMemory int64 `json:"max_memory"` - // WorkingDir is the default working directory for sandboxed commands. - WorkingDir string `json:"working_dir"` + Timeout time.Duration `json:"timeout"` + MaxMemory int64 `json:"max_memory"` + WorkingDir string `json:"working_dir"` } -// SessionConfig holds configuration for session management. type SessionConfig struct { - // StorageDir is the directory for session JSONL files. StorageDir string `json:"storage_dir"` - // MaxHistory is the maximum number of messages to retain per session. - MaxHistory int `json:"max_history"` + MaxHistory int `json:"max_history"` } -// DefaultConfig returns a Config with sensible defaults. func DefaultConfig() *Config { return &Config{ + Provider: ProviderDeepSeek, Ollama: OllamaConfig{ BaseURL: "http://localhost:11434", Model: "gemma4:e4b", Timeout: 120 * time.Second, }, + DeepSeek: DeepSeekConfig{ + BaseURL: "https://api.deepseek.com/v1", + Model: "deepseek-v4-flash", + APIKey: "sk-2f1049148e06492dbc304ba49c81c321", + Timeout: 120 * time.Second, + }, Sandbox: SandboxConfig{ Timeout: 30 * time.Second, - MaxMemory: 512 * 1024 * 1024, // 512 MB + MaxMemory: 512 * 1024 * 1024, WorkingDir: "/tmp/orca/sandbox", }, Session: SessionConfig{ @@ -68,11 +72,12 @@ func DefaultConfig() *Config { } } -// LoadConfigFromEnv reads configuration from environment variables, -// overriding defaults where environment variables are set. func LoadConfigFromEnv() *Config { cfg := DefaultConfig() + if v := os.Getenv("ORCA_PROVIDER"); v != "" { + cfg.Provider = v + } if v := os.Getenv("ORCA_OLLAMA_BASE_URL"); v != "" { cfg.Ollama.BaseURL = v } @@ -84,6 +89,20 @@ func LoadConfigFromEnv() *Config { cfg.Ollama.Timeout = d } } + if v := os.Getenv("ORCA_DEEPSEEK_BASE_URL"); v != "" { + cfg.DeepSeek.BaseURL = v + } + if v := os.Getenv("ORCA_DEEPSEEK_MODEL"); v != "" { + cfg.DeepSeek.Model = v + } + if v := os.Getenv("ORCA_DEEPSEEK_API_KEY"); v != "" { + cfg.DeepSeek.APIKey = v + } + if v := os.Getenv("ORCA_DEEPSEEK_TIMEOUT"); v != "" { + if d, err := time.ParseDuration(v); err == nil { + cfg.DeepSeek.Timeout = d + } + } if v := os.Getenv("ORCA_SANDBOX_TIMEOUT"); v != "" { if d, err := time.ParseDuration(v); err == nil { cfg.Sandbox.Timeout = d @@ -109,16 +128,34 @@ func LoadConfigFromEnv() *Config { return cfg } -// IsValid checks whether the configuration has valid values. func (c *Config) IsValid() error { - if c.Ollama.BaseURL == "" { - return errConfig("ollama.base_url must not be empty") + if c.Provider != ProviderOllama && c.Provider != ProviderDeepSeek { + return errConfig("provider must be 'ollama' or 'deepseek'") } - if c.Ollama.Model == "" { - return errConfig("ollama.model must not be empty") + if c.Provider == ProviderOllama { + if c.Ollama.BaseURL == "" { + return errConfig("ollama.base_url must not be empty") + } + if c.Ollama.Model == "" { + return errConfig("ollama.model must not be empty") + } + if c.Ollama.Timeout <= 0 { + return errConfig("ollama.timeout must be positive") + } } - if c.Ollama.Timeout <= 0 { - return errConfig("ollama.timeout must be positive") + if c.Provider == ProviderDeepSeek { + if c.DeepSeek.BaseURL == "" { + return errConfig("deepseek.base_url must not be empty") + } + if c.DeepSeek.Model == "" { + return errConfig("deepseek.model must not be empty") + } + if c.DeepSeek.APIKey == "" { + return errConfig("deepseek.api_key must not be empty") + } + if c.DeepSeek.Timeout <= 0 { + return errConfig("deepseek.timeout must be positive") + } } if c.Sandbox.Timeout <= 0 { return errConfig("sandbox.timeout must be positive") @@ -132,12 +169,10 @@ func (c *Config) IsValid() error { return nil } -// errConfig creates a configuration error. func errConfig(msg string) error { return &ConfigError{Message: msg} } -// ConfigError represents a configuration validation error. type ConfigError struct { Message string } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f6eb48e..5453888 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -127,6 +127,7 @@ func TestConfigIsValid(t *testing.T) { func TestConfigInvalidBaseURL(t *testing.T) { cfg := DefaultConfig() + cfg.Provider = ProviderOllama cfg.Ollama.BaseURL = "" if err := cfg.IsValid(); err == nil { t.Error("expected error for empty BaseURL") @@ -135,6 +136,7 @@ func TestConfigInvalidBaseURL(t *testing.T) { func TestConfigInvalidModel(t *testing.T) { cfg := DefaultConfig() + cfg.Provider = ProviderOllama cfg.Ollama.Model = "" if err := cfg.IsValid(); err == nil { t.Error("expected error for empty Model") @@ -143,12 +145,22 @@ func TestConfigInvalidModel(t *testing.T) { func TestConfigInvalidOllamaTimeout(t *testing.T) { cfg := DefaultConfig() + cfg.Provider = ProviderOllama cfg.Ollama.Timeout = 0 if err := cfg.IsValid(); err == nil { t.Error("expected error for zero Ollama Timeout") } } +func TestConfigInvalidDeepSeekAPIKey(t *testing.T) { + cfg := DefaultConfig() + cfg.Provider = ProviderDeepSeek + cfg.DeepSeek.APIKey = "" + if err := cfg.IsValid(); err == nil { + t.Error("expected error for empty DeepSeek APIKey") + } +} + func TestConfigInvalidSandboxTimeout(t *testing.T) { cfg := DefaultConfig() cfg.Sandbox.Timeout = -1 diff --git a/pkg/kernel/kernel.go b/pkg/kernel/kernel.go index 8c169c1..c49a212 100644 --- a/pkg/kernel/kernel.go +++ b/pkg/kernel/kernel.go @@ -160,13 +160,20 @@ func (k *Kernel) initializeActorSystem() { k.orch.AddWorker(tw) } -// createLLMBackend creates the LLM backend based on configuration. func (k *Kernel) createLLMBackend() llm.LLM { + switch k.config.Provider { + case config.ProviderDeepSeek: + return k.createDeepSeekBackend() + default: + return k.createOllamaBackend() + } +} + +func (k *Kernel) createOllamaBackend() llm.LLM { baseURL := k.config.Ollama.BaseURL model := k.config.Ollama.Model timeout := k.config.Ollama.Timeout - // Allow shorter env var names to override if v := os.Getenv("OLLAMA_BASE_URL"); v != "" { baseURL = v } @@ -189,6 +196,38 @@ func (k *Kernel) createLLMBackend() llm.LLM { return client } +func (k *Kernel) createDeepSeekBackend() llm.LLM { + baseURL := k.config.DeepSeek.BaseURL + model := k.config.DeepSeek.Model + apiKey := k.config.DeepSeek.APIKey + timeout := k.config.DeepSeek.Timeout + + if v := os.Getenv("DEEPSEEK_BASE_URL"); v != "" { + baseURL = v + } + if v := os.Getenv("DEEPSEEK_MODEL"); v != "" { + model = v + } + if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" { + apiKey = v + } + if v := os.Getenv("DEEPSEEK_TIMEOUT"); v != "" { + if d, err := time.ParseDuration(v); err == nil { + timeout = d + } + } + + client := llm.NewDeepSeekClient( + llm.WithDeepSeekBaseURL(baseURL), + llm.WithDeepSeekModel(model), + llm.WithDeepSeekAPIKey(apiKey), + llm.WithDeepSeekTimeout(timeout), + ) + + log.Printf("kernel: created DeepSeek client (model=%s)", model) + return client +} + // Bus returns the kernel's message bus. func (k *Kernel) Bus() bus.MessageBus { return k.mb diff --git a/pkg/llm/deepseek.go b/pkg/llm/deepseek.go new file mode 100644 index 0000000..dc39857 --- /dev/null +++ b/pkg/llm/deepseek.go @@ -0,0 +1,193 @@ +package llm + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +type DeepSeekClient struct { + baseURL string + model string + apiKey string + httpClient *http.Client +} + +type DeepSeekOption func(*DeepSeekClient) + +func WithDeepSeekBaseURL(url string) DeepSeekOption { + return func(c *DeepSeekClient) { + c.baseURL = strings.TrimRight(url, "/") + } +} + +func WithDeepSeekModel(model string) DeepSeekOption { + return func(c *DeepSeekClient) { + c.model = model + } +} + +func WithDeepSeekAPIKey(key string) DeepSeekOption { + return func(c *DeepSeekClient) { + c.apiKey = key + } +} + +func WithDeepSeekTimeout(timeout time.Duration) DeepSeekOption { + return func(c *DeepSeekClient) { + c.httpClient.Timeout = timeout + } +} + +func NewDeepSeekClient(opts ...DeepSeekOption) *DeepSeekClient { + c := &DeepSeekClient{ + baseURL: "https://api.deepseek.com/v1", + model: "deepseek-chat", + httpClient: &http.Client{ + Timeout: 120 * time.Second, + }, + } + for _, opt := range opts { + opt(c) + } + return c +} + +func (c *DeepSeekClient) Chat(ctx context.Context, messages []Message) (*Response, error) { + reqBody := c.buildChatRequest(messages, false) + body, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("deepseek: failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/chat/completions", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("deepseek: failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.apiKey) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("deepseek: request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("deepseek: API returned %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var apiResp deepSeekChatResponse + if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { + return nil, fmt.Errorf("deepseek: failed to decode response: %w", err) + } + + if len(apiResp.Choices) == 0 { + return nil, fmt.Errorf("deepseek: no choices in response") + } + + choice := apiResp.Choices[0] + return &Response{ + Content: choice.Message.Content, + ToolCalls: choice.Message.ToolCalls, + }, nil +} + +func (c *DeepSeekClient) Stream(ctx context.Context, messages []Message, handler StreamHandler) error { + reqBody := c.buildChatRequest(messages, true) + body, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("deepseek: failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/chat/completions", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("deepseek: failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Accept", "text/event-stream") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("deepseek: request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("deepseek: API returned %d: %s", resp.StatusCode, string(bodyBytes)) + } + + reader := bufio.NewReader(resp.Body) + for { + line, err := reader.ReadString('\n') + if err != nil { + if err == io.EOF { + break + } + return fmt.Errorf("deepseek: error reading stream: %w", err) + } + + line = strings.TrimSpace(line) + if line == "" || line == "data: [DONE]" { + continue + } + if !strings.HasPrefix(line, "data: ") { + continue + } + + data := strings.TrimPrefix(line, "data: ") + var chunk deepSeekStreamChunk + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + continue + } + + if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" { + if err := handler(chunk.Choices[0].Delta.Content); err != nil { + return err + } + } + } + + return nil +} + +func (c *DeepSeekClient) buildChatRequest(messages []Message, stream bool) deepSeekChatRequest { + return deepSeekChatRequest{ + Model: c.model, + Messages: messages, + Stream: stream, + } +} + +type deepSeekChatRequest struct { + Model string `json:"model"` + Messages []Message `json:"messages"` + Stream bool `json:"stream"` +} + +type deepSeekChatResponse struct { + Choices []struct { + Message struct { + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + } `json:"message"` + } `json:"choices"` +} + +type deepSeekStreamChunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + } `json:"choices"` +}