feat(ai): 接入 CodeBuddy CLI 并兼容官方登录态

- 新增 CodeBuddy CLI provider,支持 codebuddy/cbc 命令调用与输出解析
- 将 Base URL、API Key/Auth Token、自定义请求头映射到 CodeBuddy CLI 环境变量
- 扩展 custom provider 路由与测试链路,兼容空 Base URL 和 CLI 默认模型选择
- AI 设置新增 CodeBuddy 预设,并补齐 preset 回显识别与匹配逻辑
- 修正就绪态、模型列表与表单校验,允许留空凭证直接复用本机已登录账号
- 补充前后端定向测试并覆盖 CodeBuddy 配置展示文案

Close #574
This commit is contained in:
Syngnat
2026-06-18 12:06:58 +08:00
parent c8fe90cbee
commit f457f6aaca
16 changed files with 842 additions and 30 deletions

View File

@@ -0,0 +1,446 @@
package provider
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"os/exec"
"runtime"
"strings"
"time"
ai "GoNavi-Wails/internal/ai"
"GoNavi-Wails/internal/logger"
)
var codebuddyLookPath = exec.LookPath
var codebuddyCommandContext = exec.CommandContext
var codebuddyCLIRequestTimeout = 90 * time.Second
// CodeBuddyCLIProvider 通过 CodeBuddy CLI 发送聊天请求。
type CodeBuddyCLIProvider struct {
config ai.ProviderConfig
}
// NewCodeBuddyCLIProvider 创建 CodeBuddyCLIProvider 实例。
func NewCodeBuddyCLIProvider(config ai.ProviderConfig) (Provider, error) {
return &CodeBuddyCLIProvider{config: config}, nil
}
func (p *CodeBuddyCLIProvider) Name() string {
return "CodeBuddyCLI"
}
func (p *CodeBuddyCLIProvider) Validate() error {
_, err := resolveCodeBuddyCLICommand(codebuddyLookPath)
if err != nil {
return err
}
return nil
}
func (p *CodeBuddyCLIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) {
if err := p.Validate(); err != nil {
return nil, err
}
ctx, cancel := ensureClaudeCLITimeout(ctx, codebuddyCLIRequestTimeout)
defer cancel()
commandName, err := resolveCodeBuddyCLICommand(codebuddyLookPath)
if err != nil {
return nil, err
}
prompt := buildPrompt(req.Messages)
args := []string{"-p", prompt, "--output-format", "json", "--no-session-persistence"}
if strings.TrimSpace(p.config.Model) != "" {
args = append(args, "--model", strings.TrimSpace(p.config.Model))
}
cmd := codebuddyCommandContext(ctx, commandName, args...)
if err := p.setEnv(cmd); err != nil {
return nil, err
}
requestLog := logAIUpstreamRequestStart(
p.Name(),
"CLI",
codebuddyCLIEndpointForLog(p.config),
buildCodeBuddyCLIRequestLogBody("json", commandName, args, prompt, p.config, req),
)
var requestErr error
defer func() {
logAIUpstreamRequestFinish(requestLog, 0, requestErr)
}()
output, err := cmd.Output()
if err != nil {
if isClaudeCLITimeout(ctx, err) {
requestErr = fmt.Errorf("CodeBuddy CLI 执行超时(%s当前登录态、Base URL 或 API Key 可能没有返回有效响应", codebuddyCLIRequestTimeout)
return nil, requestErr
}
if exitErr, ok := err.(*exec.ExitError); ok {
requestErr = fmt.Errorf("CodeBuddy CLI 执行失败: %s", string(exitErr.Stderr))
return nil, requestErr
}
requestErr = fmt.Errorf("CodeBuddy CLI 执行失败: %w", err)
return nil, requestErr
}
resp, parseErr := parseCodeBuddyCLIChatOutput(output)
if parseErr != nil {
requestErr = parseErr
return nil, requestErr
}
return resp, nil
}
func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error {
if err := p.Validate(); err != nil {
return err
}
ctx, cancel := ensureClaudeCLITimeout(ctx, codebuddyCLIRequestTimeout)
defer cancel()
commandName, err := resolveCodeBuddyCLICommand(codebuddyLookPath)
if err != nil {
return err
}
prompt := buildPrompt(req.Messages)
args := []string{"-p", prompt, "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--no-session-persistence"}
if strings.TrimSpace(p.config.Model) != "" {
args = append(args, "--model", strings.TrimSpace(p.config.Model))
}
cmd := codebuddyCommandContext(ctx, commandName, args...)
if err := p.setEnv(cmd); err != nil {
return err
}
requestLog := logAIUpstreamRequestStart(
p.Name(),
"CLI",
codebuddyCLIEndpointForLog(p.config),
buildCodeBuddyCLIRequestLogBody("stream-json", commandName, args, prompt, p.config, req),
)
var requestErr error
defer func() {
logAIUpstreamRequestFinish(requestLog, 0, requestErr)
}()
cmd.Stdin = nil
stdout, err := cmd.StdoutPipe()
if err != nil {
requestErr = fmt.Errorf("创建 stdout 管道失败: %w", err)
return requestErr
}
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
if err := cmd.Start(); err != nil {
requestErr = fmt.Errorf("启动 CodeBuddy CLI 失败: %w", err)
return requestErr
}
if cmd.Process != nil {
logger.Infof("CodeBuddyCLI 请求进程已启动requestId=%s pid=%d", requestLog.id, cmd.Process.Pid)
}
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) == "" {
continue
}
var event cliStreamEvent
if err := json.Unmarshal([]byte(line), &event); err != nil {
logger.Warnf("CodeBuddyCLI 忽略非 JSON 输出requestId=%s line=%s", requestLog.id, RedactAIUpstreamLogText(line))
continue
}
switch event.Type {
case "system":
if isCodeBuddyCLISystemRetryEvent(event) {
if errMsg, hasError := extractCodeBuddyCLISystemRetryError(event); hasError {
callback(ai.StreamChunk{Error: errMsg, Done: true})
requestErr = fmt.Errorf("CodeBuddy CLI 鉴权失败: %s", errMsg)
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = cmd.Wait()
return nil
}
}
case "assistant":
if errMsg, hasError := extractCodeBuddyCLIEventError(event); hasError {
callback(ai.StreamChunk{Error: errMsg, Done: true})
requestErr = fmt.Errorf("CodeBuddy CLI 返回错误: %s", errMsg)
_ = cmd.Wait()
return nil
}
if event.Message.Content != nil {
for _, block := range event.Message.Content {
if block.Type == "thinking" && block.Thinking != "" {
callback(ai.StreamChunk{Thinking: block.Thinking})
} else if block.Type == "text" && block.Text != "" {
callback(ai.StreamChunk{Content: block.Text})
}
}
}
case "content_block_delta":
if event.Delta.Type == "thinking_delta" && event.Delta.Thinking != "" {
callback(ai.StreamChunk{Thinking: event.Delta.Thinking})
} else if event.Delta.Text != "" {
callback(ai.StreamChunk{Content: event.Delta.Text})
}
case "result":
if errMsg, hasError := extractCodeBuddyCLIEventError(event); hasError {
callback(ai.StreamChunk{Error: errMsg, Done: true})
requestErr = fmt.Errorf("CodeBuddy CLI 返回错误: %s", errMsg)
_ = cmd.Wait()
return nil
}
callback(ai.StreamChunk{Done: true})
_ = cmd.Wait()
return nil
case "error":
errMsg, _ := extractCodeBuddyCLIEventError(event)
callback(ai.StreamChunk{Error: errMsg, Done: true})
requestErr = fmt.Errorf("CodeBuddy CLI 返回错误: %s", errMsg)
_ = cmd.Wait()
return nil
}
}
waitErr := cmd.Wait()
stderrStr := strings.TrimSpace(stderrBuf.String())
if isClaudeCLITimeout(ctx, waitErr) {
requestErr = fmt.Errorf("CodeBuddy CLI 执行超时(%s当前登录态、Base URL 或 API Key 可能没有返回有效响应", codebuddyCLIRequestTimeout)
callback(ai.StreamChunk{
Error: requestErr.Error(),
Done: true,
})
return nil
}
if waitErr != nil {
errMsg := fmt.Sprintf("CodeBuddy CLI 异常退出: %v", waitErr)
if stderrStr != "" {
errMsg = fmt.Sprintf("CodeBuddy CLI 异常退出: %s", stderrStr)
}
requestErr = fmt.Errorf("%s", errMsg)
callback(ai.StreamChunk{Error: errMsg, Done: true})
return nil
}
callback(ai.StreamChunk{Done: true})
return nil
}
func resolveCodeBuddyCLICommand(lookPath func(string) (string, error)) (string, error) {
for _, command := range []string{"codebuddy", "cbc"} {
if _, err := lookPath(command); err == nil {
return command, nil
}
}
return "", fmt.Errorf("未找到 codebuddy 命令,请先安装 CodeBuddy CLI: npm install -g @tencent/codebuddy")
}
func codebuddyCLIEndpointForLog(config ai.ProviderConfig) string {
baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/")
if baseURL != "" {
return sanitizeAIUpstreamURL(baseURL)
}
return "codebuddy://cli"
}
func buildCodeBuddyCLIRequestLogBody(outputFormat string, commandName string, args []string, prompt string, config ai.ProviderConfig, req ai.ChatRequest) map[string]any {
return map[string]any{
"command": commandName,
"args": claudeCLIArgsForLog(args),
"prompt": prompt,
"output_format": outputFormat,
"model": strings.TrimSpace(config.Model),
"base_url": codebuddyCLIEndpointForLog(config),
"has_api_key": strings.TrimSpace(config.APIKey) != "",
"message_count": len(req.Messages),
"tool_count": len(req.Tools),
"tool_names": claudeCLIToolNamesForLog(req.Tools),
}
}
func parseCodeBuddyCLIChatOutput(output []byte) (*ai.ChatResponse, error) {
trimmed := bytes.TrimSpace(output)
if len(trimmed) == 0 {
return &ai.ChatResponse{}, nil
}
var events []cliStreamEvent
if err := json.Unmarshal(trimmed, &events); err == nil && len(events) > 0 {
return buildCodeBuddyCLIResponseFromEvents(events)
}
var event cliStreamEvent
if err := json.Unmarshal(trimmed, &event); err == nil {
return buildCodeBuddyCLIResponseFromEvents([]cliStreamEvent{event})
}
return &ai.ChatResponse{Content: strings.TrimSpace(string(output))}, nil
}
func buildCodeBuddyCLIResponseFromEvents(events []cliStreamEvent) (*ai.ChatResponse, error) {
parts := make([]string, 0, len(events))
resultText := ""
for _, event := range events {
if errMsg, hasError := extractCodeBuddyCLIEventError(event); hasError {
return nil, fmt.Errorf("CodeBuddy CLI 返回错误: %s", errMsg)
}
if strings.TrimSpace(event.Result) != "" {
resultText = strings.TrimSpace(event.Result)
}
for _, block := range event.Message.Content {
if block.Type == "text" && strings.TrimSpace(block.Text) != "" {
parts = append(parts, block.Text)
}
}
}
if resultText != "" {
return &ai.ChatResponse{Content: resultText}, nil
}
if len(parts) > 0 {
return &ai.ChatResponse{Content: strings.Join(parts, "")}, nil
}
return &ai.ChatResponse{}, nil
}
func (p *CodeBuddyCLIProvider) setEnv(cmd *exec.Cmd) error {
env, err := buildCodeBuddyCLIEnv(p.config, cmd.Environ(), runtime.GOOS, codebuddyLookPath, fileExists)
if err != nil {
return err
}
cmd.Env = env
return nil
}
func buildCodeBuddyCLIEnv(config ai.ProviderConfig, baseEnv []string, goos string, lookPath func(string) (string, error), exists func(string) bool) ([]string, error) {
env := append([]string(nil), baseEnv...)
if strings.TrimSpace(config.BaseURL) != "" {
env = upsertEnv(env, "CODEBUDDY_BASE_URL", strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"))
}
if strings.TrimSpace(config.APIKey) != "" {
env = upsertEnv(env, "CODEBUDDY_API_KEY", strings.TrimSpace(config.APIKey))
env = upsertEnv(env, "CODEBUDDY_AUTH_TOKEN", strings.TrimSpace(config.APIKey))
}
if len(config.Headers) > 0 {
if payload, err := json.Marshal(config.Headers); err == nil {
env = upsertEnv(env, "CODEBUDDY_CUSTOM_HEADERS", string(payload))
}
}
gitBashPath, err := resolveCodeBuddyGitBashPath(env, goos, lookPath, exists)
if err != nil {
return nil, err
}
if gitBashPath != "" {
env = upsertEnv(env, "CODEBUDDY_CODE_GIT_BASH_PATH", gitBashPath)
}
return env, nil
}
func resolveCodeBuddyGitBashPath(env []string, goos string, lookPath func(string) (string, error), exists func(string) bool) (string, error) {
if goos != "windows" {
return "", nil
}
if configured := strings.TrimSpace(envValue(env, "CODEBUDDY_CODE_GIT_BASH_PATH")); configured != "" {
if exists(configured) {
return configured, nil
}
return "", fmt.Errorf("CodeBuddy CLI 在 Windows 下配置的 CODEBUDDY_CODE_GIT_BASH_PATH 不存在: %s", configured)
}
for _, command := range []string{"bash.exe", "bash"} {
if bashPath, err := lookPath(command); err == nil && exists(bashPath) {
return bashPath, nil
}
}
if gitPath, err := lookPath("git.exe"); err == nil {
gitDir := parentWindowsPath(gitPath)
for _, candidate := range []string{
joinWindowsPath(parentWindowsPath(gitDir), "bin", "bash.exe"),
joinWindowsPath(gitDir, "bash.exe"),
} {
if candidate != "" && exists(candidate) {
return candidate, nil
}
}
}
for _, candidate := range windowsGitBashCandidates(env) {
if exists(candidate) {
return candidate, nil
}
}
return "", nil
}
func extractCodeBuddyCLIEventError(event cliStreamEvent) (string, bool) {
if event.Type != "error" && !event.IsError {
return "", false
}
if msg := strings.TrimSpace(event.Result); msg != "" {
return msg, true
}
for _, block := range event.Message.Content {
if block.Type == "text" && strings.TrimSpace(block.Text) != "" {
return strings.TrimSpace(block.Text), true
}
}
if msg := strings.TrimSpace(event.Error.Message); msg != "" {
return msg, true
}
return "CodeBuddy CLI 返回未知错误", true
}
func isCodeBuddyCLISystemRetryEvent(event cliStreamEvent) bool {
return event.Type == "system" && event.Subtype == "api_retry"
}
func extractCodeBuddyCLISystemRetryError(event cliStreamEvent) (string, bool) {
if !isCodeBuddyCLISystemRetryEvent(event) {
return "", false
}
errText := strings.TrimSpace(event.Error.Message)
if event.ErrorStatus != 401 && event.ErrorStatus != 403 && !strings.EqualFold(errText, "authentication_failed") {
return "", false
}
if errText == "" {
errText = "authentication_failed"
}
if event.ErrorStatus > 0 {
return fmt.Sprintf("CodeBuddy CLI 鉴权失败 (HTTP %d): %s", event.ErrorStatus, errText), true
}
return fmt.Sprintf("CodeBuddy CLI 鉴权失败: %s", errText), true
}

View File

@@ -0,0 +1,140 @@
package provider
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"GoNavi-Wails/internal/ai"
)
func TestBuildCodeBuddyCLIEnv_IncludesOfficialEnvNames(t *testing.T) {
env, err := buildCodeBuddyCLIEnv(ai.ProviderConfig{
BaseURL: "https://gateway.codebuddy.example/",
APIKey: "cb-test",
Headers: map[string]string{
"X-Workspace": "gonavi",
},
}, []string{"PATH=/usr/bin"}, "darwin", func(name string) (string, error) {
return "", errors.New("unexpected lookup")
}, func(path string) bool {
return false
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got := envValue(env, "CODEBUDDY_BASE_URL"); got != "https://gateway.codebuddy.example" {
t.Fatalf("expected trimmed base url, got %q", got)
}
if got := envValue(env, "CODEBUDDY_API_KEY"); got != "cb-test" {
t.Fatalf("expected api key in env, got %q", got)
}
if got := envValue(env, "CODEBUDDY_AUTH_TOKEN"); got != "cb-test" {
t.Fatalf("expected auth token in env, got %q", got)
}
if got := envValue(env, "CODEBUDDY_CUSTOM_HEADERS"); !strings.Contains(got, `"X-Workspace":"gonavi"`) {
t.Fatalf("expected custom headers JSON in env, got %q", got)
}
}
func TestBuildCodeBuddyCLIEnv_AllowsMissingGitBashOnWindows(t *testing.T) {
env, err := buildCodeBuddyCLIEnv(ai.ProviderConfig{}, []string{"ProgramFiles=C:\\Program Files"}, "windows", func(name string) (string, error) {
return "", errors.New("not found")
}, func(path string) bool {
return false
})
if err != nil {
t.Fatalf("expected no error when git bash is missing on windows, got %v", err)
}
if got := envValue(env, "CODEBUDDY_CODE_GIT_BASH_PATH"); got != "" {
t.Fatalf("expected no git bash env when missing, got %q", got)
}
}
func TestCodeBuddyCLIProvider_ChatParsesJSONEventArray(t *testing.T) {
fakeCodeBuddy := writeFakeCodeBuddyScript(t, "#!/bin/sh\necho '[{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"hello \"}]}},{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"hello world\"}]'\n")
restore := overrideCodeBuddyCLIForTest(t, fakeCodeBuddy)
defer restore()
provider, err := NewCodeBuddyCLIProvider(ai.ProviderConfig{
APIKey: "cb-test",
})
if err != nil {
t.Fatalf("unexpected provider error: %v", err)
}
resp, err := provider.Chat(context.Background(), ai.ChatRequest{
Messages: []ai.Message{{Role: "user", Content: "ping"}},
})
if err != nil {
t.Fatalf("expected chat to succeed, got %v", err)
}
if resp.Content != "hello world" {
t.Fatalf("expected result content, got %#v", resp)
}
}
func writeFakeCodeBuddyScript(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
if runtime.GOOS == "windows" {
bashPath, err := resolveClaudeCodeGitBashPath(os.Environ(), runtime.GOOS, exec.LookPath, fileExists)
if err != nil {
t.Fatalf("failed to resolve git bash for fake codebuddy command: %v", err)
}
scriptPath := filepath.Join(dir, "codebuddy.sh")
if err := os.WriteFile(scriptPath, []byte(content), 0o755); err != nil {
t.Fatalf("failed to write fake codebuddy shell script: %v", err)
}
wrapperPath := filepath.Join(dir, "codebuddy.cmd")
wrapper := "@echo off\r\n\"" + bashPath + "\" \"" + scriptPath + "\" %*\r\n"
if err := os.WriteFile(wrapperPath, []byte(wrapper), 0o755); err != nil {
t.Fatalf("failed to write fake codebuddy wrapper: %v", err)
}
return wrapperPath
}
path := filepath.Join(dir, "codebuddy")
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
t.Fatalf("failed to write fake codebuddy script: %v", err)
}
return path
}
func overrideCodeBuddyCLIForTest(t *testing.T, fakeCodeBuddyPath string) func() {
t.Helper()
originalLookPath := codebuddyLookPath
originalCommandContext := codebuddyCommandContext
codebuddyLookPath = func(name string) (string, error) {
if name == "codebuddy" || name == "cbc" {
return fakeCodeBuddyPath, nil
}
return originalLookPath(name)
}
codebuddyCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd {
if name == "codebuddy" || name == "cbc" {
return exec.CommandContext(ctx, fakeCodeBuddyPath, args...)
}
return originalCommandContext(ctx, name, args...)
}
originalPath := os.Getenv("PATH")
if err := os.Setenv("PATH", filepath.Dir(fakeCodeBuddyPath)+string(os.PathListSeparator)+originalPath); err != nil {
t.Fatalf("failed to override PATH: %v", err)
}
return func() {
codebuddyLookPath = originalLookPath
codebuddyCommandContext = originalCommandContext
_ = os.Setenv("PATH", originalPath)
}
}

View File

@@ -17,15 +17,14 @@ type CustomProvider struct {
// NewCustomProvider 创建自定义 Provider 实例
func NewCustomProvider(config ai.ProviderConfig) (Provider, error) {
if strings.TrimSpace(config.BaseURL) == "" {
return nil, fmt.Errorf("自定义 Provider 必须指定 Base URL")
}
// 根据 apiFormat 决定使用哪个底层协议,默认 openai
apiFormat := strings.ToLower(strings.TrimSpace(config.APIFormat))
if apiFormat == "" {
apiFormat = "openai"
}
if strings.TrimSpace(config.BaseURL) == "" && apiFormat != "claude-cli" && apiFormat != "codebuddy-cli" {
return nil, fmt.Errorf("自定义 Provider 必须指定 Base URL")
}
var innerProvider Provider
var err error
@@ -36,6 +35,8 @@ func NewCustomProvider(config ai.ProviderConfig) (Provider, error) {
innerProvider, err = NewGeminiProvider(config)
case "claude-cli":
innerProvider, err = NewClaudeCLIProvider(config)
case "codebuddy-cli":
innerProvider, err = NewCodeBuddyCLIProvider(config)
default: // "openai" 及其他
innerProvider, err = NewOpenAIProvider(config)
}

View File

@@ -102,6 +102,25 @@ var claudeCLIHealthCheckFunc = func(config ai.ProviderConfig) error {
return err
}
var codebuddyCLIHealthCheckFunc = func(config ai.ProviderConfig) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cliProvider, err := provider.NewProvider(config)
if err != nil {
return err
}
_, err = cliProvider.Chat(ctx, ai.ChatRequest{
Messages: []ai.Message{
{Role: "user", Content: "ping"},
},
MaxTokens: 1,
Temperature: 0,
})
return err
}
// NewService 创建 AI Service 实例
func NewService() *Service {
return NewServiceWithSecretStore(secretstore.NewKeyringStore())
@@ -533,6 +552,8 @@ func (s *Service) AITestProvider(config ai.ProviderConfig) map[string]interface{
testConfig.Model = dashScopeCodingPlanModels[0]
}
err = claudeCLIHealthCheckFunc(testConfig)
case "codebuddy-cli":
err = codebuddyCLIHealthCheckFunc(config)
default:
if baseURL != "" {
req, _ := http.NewRequest("GET", baseURL, nil)
@@ -660,6 +681,9 @@ func filterFetchedModelsForProvider(config ai.ProviderConfig, models []string) (
}
func defaultStaticModelsForProvider(config ai.ProviderConfig) []string {
if normalizedProviderType(config) == "codebuddy-cli" {
return append([]string(nil), config.Models...)
}
if isMiniMaxAnthropicProvider(config) {
return append([]string(nil), miniMaxAnthropicModels...)
}
@@ -726,6 +750,8 @@ func resolveModelsURL(config ai.ProviderConfig) string {
baseURL = "https://generativelanguage.googleapis.com"
}
return baseURL + "/v1beta/models?key=" + config.APIKey
case "codebuddy-cli":
return ""
case "openai":
fallthrough
default:
@@ -736,6 +762,9 @@ func resolveModelsURL(config ai.ProviderConfig) string {
func newModelsRequest(config ai.ProviderConfig) (*http.Request, error) {
config = normalizeProviderConfig(config)
url := resolveModelsURL(config)
if strings.TrimSpace(url) == "" {
return nil, fmt.Errorf("当前供应商不支持远端模型列表")
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
@@ -862,6 +891,13 @@ func (s *Service) AIListModels() map[string]interface{} {
}
config = normalizeProviderConfig(config)
if normalizedProviderType(config) == "codebuddy-cli" {
return map[string]interface{}{
"success": true,
"models": append([]string(nil), config.Models...),
"source": "static",
}
}
if staticModels := defaultStaticModelsForProvider(config); len(staticModels) > 0 {
return map[string]interface{}{"success": true, "models": staticModels, "source": "static"}
}
@@ -899,6 +935,8 @@ func fetchModels(config ai.ProviderConfig) ([]string, error) {
return fetchAnthropicModels(config)
case "gemini":
return fetchGeminiModels(config)
case "codebuddy-cli":
return append([]string(nil), config.Models...), nil
default:
return fetchOpenAIModels(config)
}

View File

@@ -0,0 +1,61 @@
package aiservice
import (
"testing"
"GoNavi-Wails/internal/ai"
)
func TestAIListModels_ReturnsStaticModelsForCodeBuddyCLIWithoutRemoteFetch(t *testing.T) {
service := NewService()
service.providers = []ai.ProviderConfig{
{
ID: "provider-codebuddy",
Type: "custom",
APIFormat: "codebuddy-cli",
Models: []string{"claude-sonnet-4", "gpt-4.1"},
},
}
service.activeProvider = "provider-codebuddy"
result := service.AIListModels()
if result["success"] != true {
t.Fatalf("expected AIListModels to succeed, got %#v", result)
}
models, ok := result["models"].([]string)
if !ok {
t.Fatalf("expected []string models, got %#v", result["models"])
}
if len(models) != 2 || models[0] != "claude-sonnet-4" {
t.Fatalf("expected static CodeBuddy models, got %#v", models)
}
if source, _ := result["source"].(string); source != "static" {
t.Fatalf("expected static source, got %#v", result["source"])
}
}
func TestAITestProvider_UsesCodeBuddyCLIHealthCheck(t *testing.T) {
originalHealthCheckFunc := codebuddyCLIHealthCheckFunc
defer func() {
codebuddyCLIHealthCheckFunc = originalHealthCheckFunc
}()
var received ai.ProviderConfig
codebuddyCLIHealthCheckFunc = func(config ai.ProviderConfig) error {
received = config
return nil
}
service := NewService()
result := service.AITestProvider(ai.ProviderConfig{
Type: "custom",
APIFormat: "codebuddy-cli",
APIKey: "cb-test",
})
if result["success"] != true {
t.Fatalf("expected AITestProvider to succeed, got %#v", result)
}
if received.APIFormat != "codebuddy-cli" {
t.Fatalf("expected CodeBuddy test to use codebuddy-cli api format, got %q", received.APIFormat)
}
}

View File

@@ -80,7 +80,7 @@ type ProviderConfig struct {
BaseURL string `json:"baseUrl"`
Model string `json:"model"`
Models []string `json:"models,omitempty"`
APIFormat string `json:"apiFormat,omitempty"` // custom 专用: openai | anthropic | gemini | claude-cli
APIFormat string `json:"apiFormat,omitempty"` // custom 专用: openai | anthropic | gemini | claude-cli | codebuddy-cli
Headers map[string]string `json:"headers,omitempty"`
MaxTokens int `json:"maxTokens"`
Temperature float64 `json:"temperature"`