Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions admin/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,17 +190,18 @@ func handleDeploy(w http.ResponseWriter, r *http.Request) {
}

var steps []step
restartSteps := []step{
{"restart service", "sudo", []string{"systemctl", "restart", "mu"}},
}

switch req.Action {
case "restart":
steps = []step{
{"restart service", "sudo", []string{"-n", "systemctl", "restart", "mu"}},
}
steps = restartSteps
default: // "update"
steps = []step{
steps = append([]step{
{"git pull", "git", []string{"pull", "origin", "main"}},
{"go install", "go", []string{"install", "."}},
{"restart service", "sudo", []string{"-n", "systemctl", "restart", "mu"}},
}
}, restartSteps...)
}

for _, s := range steps {
Expand Down
10 changes: 5 additions & 5 deletions chat/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -1048,7 +1048,7 @@ func Load() {
System: "You are a helpful assistant that creates concise summaries. Provide only the summary content itself without any introductory phrases like 'Here is a summary' or 'This article is about'. Just write 2-3 clear sentences that capture the key points. Report what the article says without editorialising, fact-checking, or adding commentary about whether claims are true or false. Never add disclaimers, caveats, or corrections.",
Question: fmt.Sprintf("Summarize this article:\n\n%s", content),
Priority: ai.PriorityLow,
Model: "claude-haiku-4-5-20251001",
Model: ai.BackgroundModel(),
Caller: "article-summary",
}

Expand Down Expand Up @@ -1106,7 +1106,7 @@ func Load() {
System: fmt.Sprintf("You are a content categorization assistant. Your task is to categorize posts into ONE of these categories ONLY: %s. If the post does not clearly fit into any of these categories, respond with 'None'. Respond with ONLY the category name or 'None', nothing else.", strings.Join(topics, ", ")),
Question: fmt.Sprintf("Categorize this post:\n\nTitle: %s\n\nContent: %s\n\nWhich single category best fits this post?", title, content),
Priority: ai.PriorityLow,
Model: "claude-haiku-4-5-20251001",
Model: ai.BackgroundModel(),
Caller: "auto-tag-post",
}

Expand Down Expand Up @@ -1161,7 +1161,7 @@ func Load() {
System: "You are a note organization assistant. Given a note, suggest ONE short tag (1-2 words, lowercase) that best categorizes it. Examples: 'work', 'ideas', 'shopping', 'todo', 'recipe', 'travel', 'health', 'finance'. Respond with ONLY the tag, nothing else. If the note is too short or unclear, respond with 'personal'.",
Question: content,
Priority: ai.PriorityLow,
Model: "claude-haiku-4-5-20251001",
Model: ai.BackgroundModel(),
Caller: "auto-tag-note",
}

Expand Down Expand Up @@ -1219,7 +1219,7 @@ func generateSummaries() {
Rag: ragContext,
Question: prompt,
Priority: ai.PriorityMedium,
Model: "claude-haiku-4-5-20251001",
Model: ai.BackgroundModel(),
Caller: "topic-summary",
})

Expand Down Expand Up @@ -1596,7 +1596,7 @@ func (a *llmAnalyzer) Analyze(promptText, question string) (string, error) {
prompt := &ai.Prompt{
System: promptText,
Question: question,
Model: "claude-haiku-4-5-20251001",
Model: ai.BackgroundModel(),
}
return askLLM(prompt)
}
Expand Down
126 changes: 122 additions & 4 deletions internal/ai/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"io"
"net/http"
"os"
"strings"
"sync"
"time"

Expand All @@ -26,8 +27,52 @@ var (
cacheMisses int
cacheReadTokens int
cacheCreationTokens int

// Atlas Cloud config
atlasAPIKey = os.Getenv("ATLAS_API_KEY")
atlasBaseURL = "https://api.atlascloud.ai/v1"
)

// Atlas Cloud model aliases — used to route requests to Atlas Cloud
// instead of Anthropic. Any model string starting with "deepseek" or
// "qwen" is routed to Atlas Cloud automatically.
const (
ModelDeepSeekPro = "deepseek-v4-pro"
ModelDeepSeekFlash = "deepseek-v4-flash"
ModelQwenPlus = "qwen3.6-plus"
)

// DefaultModel is the model used for interactive queries (chat, agent).
// Falls back to Anthropic Sonnet if Atlas Cloud is not configured.
func DefaultModel() string {
if atlasAPIKey != "" {
return ModelDeepSeekPro
}
m := os.Getenv("ANTHROPIC_MODEL")
if m != "" {
return m
}
return "claude-sonnet-4-20250514"
}

// BackgroundModel is the model used for cheap background tasks
// (summaries, tags, moderation, topics).
func BackgroundModel() string {
if atlasAPIKey != "" {
return ModelDeepSeekFlash
}
return "claude-haiku-4-5-20251001"
}

// isAtlasModel returns true if the model should be routed to Atlas Cloud.
func isAtlasModel(model string) bool {
return strings.HasPrefix(model, "deepseek") ||
strings.HasPrefix(model, "qwen") ||
strings.HasPrefix(model, "glm") ||
strings.HasPrefix(model, "kimi") ||
strings.HasPrefix(model, "minimax")
}

// generate sends a prompt to the configured LLM provider
func generate(prompt *Prompt) (string, error) {
// Acquire semaphore to limit concurrent requests
Expand Down Expand Up @@ -64,16 +109,19 @@ func generate(prompt *Prompt) (string, error) {

model := prompt.Model
if model == "" {
model = os.Getenv("ANTHROPIC_MODEL")
}
if model == "" {
model = "claude-sonnet-4-20250514"
model = DefaultModel()
}

caller := prompt.Caller
if caller == "" {
caller = "unknown"
}

// Route to Atlas Cloud for supported models.
if isAtlasModel(model) && atlasAPIKey != "" {
return generateAtlas(atlasAPIKey, model, systemPromptText, messages, caller)
}

return generateAnthropic(key, model, systemPromptText, messages, caller)
}

Expand Down Expand Up @@ -187,6 +235,76 @@ func GetCacheStats() (hits, misses, readTokens, creationTokens int) {
return cacheHits, cacheMisses, cacheReadTokens, cacheCreationTokens
}

// generateAtlas sends a request to Atlas Cloud's OpenAI-compatible API.
func generateAtlas(apiKey, model, systemPrompt string, messages []map[string]string, caller string) (string, error) {
app.Log("ai", "[LLM] Using Atlas Cloud with model %s", model)

var apiMessages []map[string]string
if systemPrompt != "" {
apiMessages = append(apiMessages, map[string]string{
"role": "system",
"content": systemPrompt,
})
}
for _, msg := range messages {
if msg["role"] != "system" {
apiMessages = append(apiMessages, msg)
}
}

req := map[string]interface{}{
"model": model,
"messages": apiMessages,
"max_tokens": 4096,
}

body, _ := json.Marshal(req)
httpReq, _ := http.NewRequest("POST", atlasBaseURL+"/chat/completions", bytes.NewReader(body))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+apiKey)

client := &http.Client{Timeout: llmTimeout}
resp, err := client.Do(httpReq)
if err != nil {
return "", fmt.Errorf("atlas cloud connection failed: %v", err)
}
defer resp.Body.Close()

respBody, _ := io.ReadAll(resp.Body)

var result 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"`
} `json:"usage"`
Error struct {
Message string `json:"message"`
} `json:"error"`
}
json.Unmarshal(respBody, &result)

if result.Error.Message != "" {
return "", fmt.Errorf("atlas cloud error: %s", result.Error.Message)
}

// Record usage.
recordUsage(caller, model,
result.Usage.PromptTokens, result.Usage.CompletionTokens, 0, 0)

app.Log("ai", "[LLM] Usage [%s]: input=%d output=%d (atlas cloud)",
caller, result.Usage.PromptTokens, result.Usage.CompletionTokens)

if len(result.Choices) == 0 {
return "", fmt.Errorf("atlas cloud returned no choices")
}
return result.Choices[0].Message.Content, nil
}

func truncateLog(s string, maxLen int) string {
if len(s) <= maxLen {
return s
Expand Down
4 changes: 3 additions & 1 deletion news/digest/digest.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,9 @@ func generate() {
if existing == nil {
createDigest()
} else {
updateDigest(existing)
// Digest already created for today — don't regenerate.
app.Log("digest", "Digest already exists for today (%s), skipping", existing.ID)
setSuccess()
}
}

Expand Down
2 changes: 1 addition & 1 deletion search/topics.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ func regenerateTopics() {
Return exactly 10 topics, one per line. Nothing else.`,
Question: "Extract 10 trending search topics from these recent headlines:\n\n" + strings.Join(headlines, "\n"),
Priority: ai.PriorityLow,
Model: "claude-haiku-4-5-20251001",
Model: ai.BackgroundModel(),
Caller: "topic-generation",
}

Expand Down
Loading