refactor: rename action→activity, statemachine→workflow, remove HTTP API layer
- action/ → activity/ (Temporal activities) - statemachine/ → workflow/ (Temporal workflows) - Removed internal/api/ and cmd/server/ (api-gw handles HTTP, Temporal is the API) - Created pkg/types/types.go as single source of truth for all shared types - Extracted CallRoleLLM helper (DRY: implementer/planner/judge shared pattern) - Fixed circular import: workflow_graph_query uses string activity names - Fixed logger.logf → logger.Info/Warn (method didn't exist) - Fixed routing types: added Branches, Activity, BackoffSeconds, TaskActivity - Fixed db.Canvas.Name, db.Client→DB, GetWorkflow→FetchWorkflow - Removed unused imports - All tests pass, build clean, vet clean
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/rockliang/poimen/workflows/pkg/types"
|
||||
)
|
||||
|
||||
var (
|
||||
// LocalLLMBaseURL is the base URL for the local LLM API (OpenAI-compatible)
|
||||
// Can be overridden via LOCAL_LLM_BASE_URL env var (for Kubernetes internal service)
|
||||
LocalLLMBaseURL string
|
||||
)
|
||||
|
||||
func init() {
|
||||
LocalLLMBaseURL = os.Getenv("LOCAL_LLM_BASE_URL")
|
||||
if LocalLLMBaseURL == "" {
|
||||
// Default: external hostname (for local dev)
|
||||
LocalLLMBaseURL = "https://api.riotpiao.com"
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
// SupportedModels maps local model names to verify they exist
|
||||
SupportedModels = map[string]bool{
|
||||
"reasoning": true, // Reasoning model for planner/judge
|
||||
"ornith:35b": true, // Ornith 35B for implementer
|
||||
"ornith:13b": true, // Alternative Ornith size
|
||||
"qwen2.5:3b": true, // Qwen alternative
|
||||
}
|
||||
)
|
||||
|
||||
// OpenAIClient is a wrapper around the local OpenAI-compatible API.
|
||||
type OpenAIClient struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new OpenAIClient pointing to the local LLM API.
|
||||
func NewClient() (*OpenAIClient, error) {
|
||||
return &OpenAIClient{
|
||||
baseURL: LocalLLMBaseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 0, // No timeout for streaming
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MessageInput is the input to CreateMessage.
|
||||
type MessageInput struct {
|
||||
Model types.ModelSpec
|
||||
SystemPrompt string
|
||||
Messages []MessageParam
|
||||
AuthToken string // Optional JWT token for authenticated endpoints
|
||||
}
|
||||
|
||||
// MessageParam represents a message parameter.
|
||||
type MessageParam struct {
|
||||
Role string
|
||||
Content string
|
||||
}
|
||||
|
||||
// openaiRequest is the request body for the OpenAI-compatible API.
|
||||
type openaiRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []openaiMessage `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
Temp float64 `json:"temperature,omitempty"`
|
||||
MaxToken int `json:"max_tokens,omitempty"`
|
||||
}
|
||||
|
||||
type openaiMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// openaiResponse is the response from the OpenAI-compatible API.
|
||||
type openaiResponse struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
// CreateMessage calls the local OpenAI-compatible API and returns the response text.
|
||||
func (c *OpenAIClient) CreateMessage(ctx context.Context, in MessageInput) (string, error) {
|
||||
// Validate model
|
||||
if !SupportedModels[in.Model.ModelID] {
|
||||
return "", fmt.Errorf("unsupported model: %s (supported: reasoning, ornith:35b)", in.Model.ModelID)
|
||||
}
|
||||
|
||||
// Build request
|
||||
messages := []openaiMessage{
|
||||
{
|
||||
Role: "system",
|
||||
Content: in.SystemPrompt,
|
||||
},
|
||||
}
|
||||
for _, msg := range in.Messages {
|
||||
messages = append(messages, openaiMessage{
|
||||
Role: msg.Role,
|
||||
Content: msg.Content,
|
||||
})
|
||||
}
|
||||
|
||||
req := openaiRequest{
|
||||
Model: in.Model.ModelID,
|
||||
Messages: messages,
|
||||
Stream: false,
|
||||
}
|
||||
|
||||
// Marshal request
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
// Create HTTP request
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST",
|
||||
fmt.Sprintf("%s/v1/chat/completions", c.baseURL),
|
||||
bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create HTTP request: %w", err)
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Add authentication header if token provided
|
||||
if in.AuthToken != "" {
|
||||
httpReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", in.AuthToken))
|
||||
}
|
||||
|
||||
// Send request
|
||||
resp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to connect to local LLM API at %s: %w (ensure homelab-frontend is running)", c.baseURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
// Check status
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("local LLM API returned status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
// Unmarshal response
|
||||
var respObj openaiResponse
|
||||
if err := json.Unmarshal(respBody, &respObj); err != nil {
|
||||
return "", fmt.Errorf("failed to unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
// Extract content
|
||||
if len(respObj.Choices) == 0 {
|
||||
return "", fmt.Errorf("no choices in response from local LLM API")
|
||||
}
|
||||
|
||||
return respObj.Choices[0].Message.Content, nil
|
||||
}
|
||||
|
||||
// HealthCheck verifies the local LLM API is reachable and has the required models.
|
||||
func (c *OpenAIClient) HealthCheck(ctx context.Context) error {
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "GET",
|
||||
fmt.Sprintf("%s/readyz", c.baseURL), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("local LLM API at %s is unreachable: %w", c.baseURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("local LLM API health check failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/rockliang/poimen/workflows/pkg/types"
|
||||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
client, err := NewClient()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create client: %v", err)
|
||||
}
|
||||
if client == nil {
|
||||
t.Fatal("client is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthCheck(t *testing.T) {
|
||||
client, err := NewClient()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create client: %v", err)
|
||||
}
|
||||
|
||||
// Skip if local LLM API not available
|
||||
err = client.HealthCheck(context.Background())
|
||||
if err != nil {
|
||||
t.Logf("local LLM API not available (expected in test env): %v", err)
|
||||
t.Skip("local LLM API health check failed - skipping integration test")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportedModels(t *testing.T) {
|
||||
tests := []struct {
|
||||
model string
|
||||
expected bool
|
||||
}{
|
||||
{"reasoning", true},
|
||||
{"ornith:35b", true},
|
||||
{"ornith:13b", true},
|
||||
{"qwen2.5:3b", true},
|
||||
{"unsupported-model", false},
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.model, func(t *testing.T) {
|
||||
if SupportedModels[tt.model] != tt.expected {
|
||||
t.Errorf("model %q: expected %v, got %v", tt.model, tt.expected, SupportedModels[tt.model])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateMessageValidation(t *testing.T) {
|
||||
client, _ := NewClient()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
modelID string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid reasoning", "reasoning", true}, // Will fail to connect, but validates model
|
||||
{"valid ornith", "ornith:35b", true}, // Will fail to connect, but validates model
|
||||
{"invalid model", "invalid-model", false}, // Should fail validation
|
||||
{"empty model", "", false}, // Should fail validation
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
in := MessageInput{
|
||||
Model: types.ModelSpec{
|
||||
ModelID: tt.modelID,
|
||||
},
|
||||
SystemPrompt: "test",
|
||||
Messages: []MessageParam{
|
||||
{Role: "user", Content: "test"},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := client.CreateMessage(context.Background(), in)
|
||||
|
||||
hasErr := err != nil
|
||||
if hasErr != tt.wantErr {
|
||||
if tt.wantErr {
|
||||
t.Logf("expected error for model %q (likely API not reachable): %v", tt.modelID, err)
|
||||
} else if !hasErr {
|
||||
t.Errorf("expected error for invalid model %q, but got none", tt.modelID)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalLLMBaseURL(t *testing.T) {
|
||||
if LocalLLMBaseURL != "https://api.riotpiao.com" {
|
||||
t.Errorf("expected base URL https://api.riotpiao.com, got %s", LocalLLMBaseURL)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user