feat: add openrouter

This commit is contained in:
voocel
2026-03-10 23:55:45 +08:00
parent 0a48b66ed1
commit 74a8c8eaef
5 changed files with 59 additions and 14 deletions

View File

@@ -39,9 +39,9 @@ func (c *Config) ValidateBase() error {
return fmt.Errorf("api key is required (set OPENAI_API_KEY or ANTHROPIC_API_KEY)") return fmt.Errorf("api key is required (set OPENAI_API_KEY or ANTHROPIC_API_KEY)")
} }
switch c.Provider { switch c.Provider {
case "openai", "anthropic", "gemini": case "openai", "anthropic", "gemini", "openrouter":
default: default:
return fmt.Errorf("unsupported provider %q (use openai/anthropic/gemini)", c.Provider) return fmt.Errorf("unsupported provider %q (use openai/anthropic/gemini/openrouter)", c.Provider)
} }
return nil return nil
} }

View File

@@ -5,8 +5,10 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log" "log"
"os" "os"
"path/filepath"
"slices" "slices"
"strings" "strings"
"time" "time"
@@ -43,6 +45,11 @@ func Run(cfg Config, refs tools.References, prompts Prompts, styles map[string]s
return fmt.Errorf("init store: %w", err) return fmt.Errorf("init store: %w", err)
} }
// 1.5 日志写入文件CLI 模式同时输出到 stderr 和日志文件)
if cleanup := setupFileLogger(store.Dir()); cleanup != nil {
defer cleanup()
}
// 2. 创建模型 // 2. 创建模型
model, err := createModel(cfg) model, err := createModel(cfg)
if err != nil { if err != nil {
@@ -505,6 +512,23 @@ func finalizeSteerIfIdle(store *state.Store) {
clearHandledSteer(store) clearHandledSteer(store)
} }
// setupFileLogger 设置 CLI 模式日志,同时输出到 stderr 和日志文件。
func setupFileLogger(outputDir string) func() {
logPath := filepath.Join(outputDir, "meta", "cli.log")
if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil {
return nil
}
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return nil
}
log.SetOutput(io.MultiWriter(os.Stderr, f))
return func() {
log.SetOutput(os.Stderr)
_ = f.Close()
}
}
// createModel 根据 provider 创建对应的 LLM 模型。 // createModel 根据 provider 创建对应的 LLM 模型。
func createModel(cfg Config) (agentcore.ChatModel, error) { func createModel(cfg Config) (agentcore.ChatModel, error) {
var baseURL []string var baseURL []string
@@ -516,7 +540,7 @@ func createModel(cfg Config) (agentcore.ChatModel, error) {
return llm.NewAnthropicModel(cfg.ModelName, cfg.APIKey, baseURL...) return llm.NewAnthropicModel(cfg.ModelName, cfg.APIKey, baseURL...)
case "gemini": case "gemini":
return llm.NewGeminiModel(cfg.ModelName, cfg.APIKey, baseURL...) return llm.NewGeminiModel(cfg.ModelName, cfg.APIKey, baseURL...)
default: default: // openai, openrouter 及其他 OpenAI 兼容服务
return llm.NewOpenAIModel(cfg.ModelName, cfg.APIKey, baseURL...) return llm.NewOpenAIModel(cfg.ModelName, cfg.APIKey, baseURL...)
} }
} }

12
main.go
View File

@@ -46,15 +46,19 @@ func main() {
} }
func buildConfig(style string) app.Config { func buildConfig(style string) app.Config {
provider := envOr("LLM_PROVIDER", "openai") provider := envOr("LLM_PROVIDER", "openrouter")
apiKey := os.Getenv("Z_OPENAI_API_KEY") apiKey := os.Getenv("Z_OPENAI_API_KEY")
baseURL := os.Getenv("Z_OPENAI_BASE_URL") baseURL := os.Getenv("Z_OPENAI_BASE_URL")
if provider == "anthropic" { switch provider {
case "anthropic":
apiKey = envOr("ANTHROPIC_API_KEY", apiKey) apiKey = envOr("ANTHROPIC_API_KEY", apiKey)
baseURL = envOr("ANTHROPIC_BASE_URL", baseURL) baseURL = envOr("ANTHROPIC_BASE_URL", baseURL)
} else if provider == "gemini" { case "gemini":
apiKey = envOr("GEMINI_API_KEY", apiKey) apiKey = envOr("GEMINI_API_KEY", apiKey)
baseURL = envOr("GEMINI_BASE_URL", baseURL) baseURL = envOr("GEMINI_BASE_URL", baseURL)
case "openrouter":
apiKey = envOr("OPENROUTER_API_KEY", apiKey)
baseURL = envOr("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
} }
cfg := app.Config{ cfg := app.Config{
@@ -62,7 +66,7 @@ func buildConfig(style string) app.Config {
Provider: provider, Provider: provider,
APIKey: apiKey, APIKey: apiKey,
BaseURL: baseURL, BaseURL: baseURL,
ModelName: envOr("MODEL_NAME", ""), ModelName: "stepfun/step-3.5-flash:free",
Style: style, Style: style,
} }
return cfg return cfg

View File

@@ -279,7 +279,7 @@ func (m *Model) updateViewportSize() {
bodyH := m.bodyHeight() bodyH := m.bodyHeight()
eventH, streamH := m.splitHeights(bodyH) eventH, streamH := m.splitHeights(bodyH)
m.viewport.Width = centerW - 2 m.viewport.Width = centerW - 2
m.viewport.Height = eventH m.viewport.Height = eventH - 1 // -1 为 event panel header 行
m.streamVP.Width = centerW - 2 m.streamVP.Width = centerW - 2
m.streamVP.Height = streamH - 1 // -1 为 stream panel header 行 m.streamVP.Height = streamH - 1 // -1 为 stream panel header 行
} }
@@ -366,16 +366,16 @@ func (m Model) View() string {
centerW := m.width - leftW - rightW centerW := m.width - leftW - rightW
eventH, streamH := m.splitHeights(bodyH) eventH, streamH := m.splitHeights(bodyH)
if m.viewport.Width != centerW-2 || m.viewport.Height != eventH { if m.viewport.Width != centerW-2 || m.viewport.Height != eventH-1 {
m.viewport.Width = centerW - 2 m.viewport.Width = centerW - 2
m.viewport.Height = eventH m.viewport.Height = eventH - 1 // -1 为 event panel header 行
} }
if m.streamVP.Width != centerW-2 || m.streamVP.Height != streamH-1 { if m.streamVP.Width != centerW-2 || m.streamVP.Height != streamH-1 {
m.streamVP.Width = centerW - 2 m.streamVP.Width = centerW - 2
m.streamVP.Height = streamH - 1 // -1 为 stream panel header 行 m.streamVP.Height = streamH - 1 // -1 为 stream panel header 行
} }
eventFlow := renderEventFlowViewport(m.viewport, centerW, eventH) eventFlow := renderEventFlowViewport(m.viewport, centerW, eventH, !m.focusStream)
streamPanel := renderStreamPanel(m.streamVP, centerW, streamH, m.focusStream) streamPanel := renderStreamPanel(m.streamVP, centerW, streamH, m.focusStream)
center := lipgloss.JoinVertical(lipgloss.Left, eventFlow, streamPanel) center := lipgloss.JoinVertical(lipgloss.Left, eventFlow, streamPanel)

View File

@@ -170,13 +170,30 @@ func renderEventContent(events []app.UIEvent, width int) string {
} }
// renderEventFlowViewport 用 viewport 包装渲染事件流面板。 // renderEventFlowViewport 用 viewport 包装渲染事件流面板。
func renderEventFlowViewport(vp viewport.Model, width, height int) string { func renderEventFlowViewport(vp viewport.Model, width, height int, focused bool) string {
// 标题栏
titleColor := colorDim
if focused {
titleColor = colorAccent
}
title := lipgloss.NewStyle().Foreground(titleColor).Render("✦ 事件流")
lineW := width - lipgloss.Width(title) - 4
if lineW < 0 {
lineW = 0
}
separator := lipgloss.NewStyle().Foreground(colorDim).Render(strings.Repeat("─", lineW))
header := " " + title + " " + separator
vpH := height - 1
if vpH < 1 {
vpH = 1
}
style := lipgloss.NewStyle(). style := lipgloss.NewStyle().
Width(width). Width(width).
Height(height). Height(vpH).
Padding(0, 1) Padding(0, 1)
return style.Render(vp.View()) return header + "\n" + style.Render(vp.View())
} }
// renderStreamPanel 渲染流式输出面板(中间列下半部分)。 // renderStreamPanel 渲染流式输出面板(中间列下半部分)。