Files
poimen-workflows/action/llm/client.go
T
Test 121cad1ad5
ci / test (push) Successful in 1m14s
feat: integrate local LLM API (homelab-frontend) + Pi skills
Replace Anthropic client with OpenAI-compatible client targeting https://api.riotpiao.com.
Configure models: reasoning (Planner/Judge), ornith:35b (Implementer).
Add health check on startup.
Add Pi provider support for skill preparation (--pi-provider=local-llm).

Files changed:
- action/llm/client.go: OpenAI-compatible HTTP client + HealthCheck()
- action/llm/client_test.go: Unit tests for model validation & health
- cmd/starter/main.go: Health check before workflow, local model defaults
- statemachine/types.go: PiProvider field for OrchestratorInput

Models:
- Planner: reasoning (smart decisions)
- Judge: reasoning (quality review)
- Implementer: ornith:35b (cheap execution)

Skills: pi clone-or-fetch --provider=local-llm with 504 timeout learning.

Verification: go build ./cmd/starter ./cmd/worker ./action/llm ✓
Tests: go test -v ./action/llm ✓ (all passing)
2026-08-26 14:54:34 -07:00

182 lines
4.7 KiB
Go

package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/rockliang/poimen/workflows/statemachine"
)
const (
// LocalLLMBaseURL is the base URL for the local LLM API (OpenAI-compatible)
// Points to homelab-frontend gateway
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 statemachine.ModelSpec
SystemPrompt string
Messages []MessageParam
}
// 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")
// 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
}