From 09aa5265703d83fb0d432a681fe506ad58be3d7c Mon Sep 17 00:00:00 2001 From: Syngnat Date: Fri, 27 Mar 2026 17:02:51 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(ai/provider/chat-ui):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8D=83=E9=97=AE=20Coding=20Plan=20?= =?UTF-8?q?=E9=A2=84=E8=AE=BE=E4=B8=8E=20Claude=20CLI=20=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 统一千问 Coding Plan 到 claude-cli 链路 - 修正旧配置识别与模型列表逻辑 - 透传 Claude CLI 鉴权失败和错误事件 - 移除误杀正常回复的启动定时器 --- frontend/src/components/AISettingsModal.tsx | 57 +++- frontend/src/types.ts | 3 +- frontend/src/utils/aiProviderPresets.test.ts | 43 +++ frontend/src/utils/aiProviderPresets.ts | 52 +++- internal/ai/provider/claude_cli.go | 161 +++++++++- internal/ai/provider/claude_cli_test.go | 285 ++++++++++++++++++ internal/ai/service/service.go | 76 ++++- internal/ai/service/service_qwen_test.go | 122 ++++++-- .../ai/service/service_volcengine_test.go | 22 ++ internal/ai/types.go | 6 +- 10 files changed, 765 insertions(+), 62 deletions(-) diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx index 829db1b..e148379 100644 --- a/frontend/src/components/AISettingsModal.tsx +++ b/frontend/src/components/AISettingsModal.tsx @@ -11,6 +11,7 @@ import { QWEN_CODING_PLAN_MODELS, resolvePresetBaseURL, resolvePresetModelSelection, + resolvePresetTransport, } from '../utils/aiProviderPresets'; import { PROVIDER_PRESET_CARD_BASE_STYLE, @@ -37,6 +38,7 @@ interface ProviderPreset { desc: string; color: string; backendType: AIProviderType; + fixedApiFormat?: string; defaultBaseUrl: string; defaultModel: string; models: string[]; @@ -46,7 +48,7 @@ const PROVIDER_PRESETS: ProviderPreset[] = [ { key: 'openai', label: 'OpenAI', icon: , desc: 'GPT-5.4 / 5.3 系列', color: '#10b981', backendType: 'openai', defaultBaseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-4o', models: [] }, { key: 'deepseek', label: 'DeepSeek', icon: , desc: 'DeepSeek-V4 / R1', color: '#3b82f6', backendType: 'openai', defaultBaseUrl: 'https://api.deepseek.com/v1', defaultModel: 'deepseek-chat', models: [] }, { key: 'qwen-bailian', label: '通义千问(百炼通用)', icon: , desc: '百炼 Anthropic 兼容 / 模型从远端拉取', color: '#6366f1', backendType: 'anthropic', defaultBaseUrl: QWEN_BAILIAN_ANTHROPIC_BASE_URL, defaultModel: '', models: [] }, - { key: 'qwen-coding-plan', label: '通义千问(Coding Plan)', icon: , desc: 'Coding Plan 专属入口 / 使用官方支持模型清单', color: '#4f46e5', backendType: 'anthropic', defaultBaseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, defaultModel: '', models: QWEN_CODING_PLAN_MODELS }, + { key: 'qwen-coding-plan', label: '通义千问(Coding Plan)', icon: , desc: 'Claude Code CLI 代理链路 / 使用官方支持模型清单', color: '#4f46e5', backendType: 'custom', fixedApiFormat: 'claude-cli', defaultBaseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, defaultModel: '', models: QWEN_CODING_PLAN_MODELS }, { key: 'zhipu', label: '智谱 GLM', icon: , desc: 'GLM-5 / GLM-5-Turbo', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://open.bigmodel.cn/api/paas/v4', defaultModel: 'glm-4', models: [] }, { key: 'moonshot', label: 'Kimi', icon: , desc: 'Kimi K2.5 (Anthropic 兼容)', color: '#0d9488', backendType: 'anthropic', defaultBaseUrl: 'https://api.moonshot.cn/anthropic', defaultModel: 'moonshot-v1-8k', models: [] }, { key: 'anthropic', label: 'Claude', icon: , desc: 'Claude Opus/Sonnet', color: '#d97706', backendType: 'anthropic', defaultBaseUrl: 'https://api.anthropic.com', defaultModel: 'claude-3-5-sonnet-20241022', models: [] }, @@ -167,11 +169,22 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo const handleEditProvider = (p: AIProviderConfig) => { // 尝试根据 baseUrl 和 type 推断 preset const matchedPreset = matchProviderPreset(p); + const resolvedTransport = resolvePresetTransport({ + presetBackendType: matchedPreset.backendType, + presetFixedApiFormat: matchedPreset.fixedApiFormat, + valuesApiFormat: p.apiFormat, + }); setEditingProvider(p); setIsEditing(true); setTestStatus('idle'); form.resetFields(); - form.setFieldsValue({ ...p, type: matchedPreset.backendType, models: p.models || [], presetKey: matchedPreset.key, apiFormat: p.apiFormat || 'openai' }); + form.setFieldsValue({ + ...p, + type: resolvedTransport.type, + models: p.models || [], + presetKey: matchedPreset.key, + apiFormat: resolvedTransport.apiFormat || p.apiFormat || 'openai', + }); }; const handleDeleteProvider = async (id: string) => { @@ -220,15 +233,21 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo presetDefaultBaseUrl: preset.defaultBaseUrl, valuesBaseUrl: values.baseUrl, }); + const resolvedTransport = resolvePresetTransport({ + presetBackendType: preset.backendType, + presetFixedApiFormat: preset.fixedApiFormat, + valuesApiFormat: values.apiFormat, + }); const payload = { ...editingProvider, ...values, + ...resolvedTransport, name: finalName, model: finalModel, models: resolvedModels, baseUrl: finalBaseUrl, - apiFormat: values.apiFormat || 'openai', + apiFormat: resolvedTransport.apiFormat, }; // 后端 AISaveProvider 统一处理新增和更新,返回 void,失败抛异常 await Service?.AISaveProvider?.(payload); @@ -277,7 +296,29 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo presetDefaultBaseUrl: preset.defaultBaseUrl, valuesBaseUrl: values.baseUrl, }); - const res = await Service?.AITestProvider?.({ ...values, baseUrl: finalBaseUrl, maxTokens: Number(values.maxTokens) || 4096, temperature: Number(values.temperature) ?? 0.7 }); + const { model: finalModel, models: resolvedModels } = resolvePresetModelSelection({ + presetKey: values.presetKey || 'openai', + presetDefaultModel: preset.defaultModel, + presetModels: preset.models, + valuesModel: values.model, + customModels: values.models, + }); + const resolvedTransport = resolvePresetTransport({ + presetBackendType: preset.backendType, + presetFixedApiFormat: preset.fixedApiFormat, + valuesApiFormat: values.apiFormat, + }); + const res = await Service?.AITestProvider?.({ + ...editingProvider, + ...values, + ...resolvedTransport, + baseUrl: finalBaseUrl, + model: finalModel, + models: resolvedModels, + maxTokens: Number(values.maxTokens) || 4096, + temperature: Number(values.temperature) ?? 0.7, + apiFormat: resolvedTransport.apiFormat, + }); if (res?.success) { setTestStatus('success'); void messageApi.success('连接成功'); } else { setTestStatus('error'); void messageApi.error(`测试失败: ${res?.message || '未知错误'}`); } } catch (e: any) { setTestStatus('error'); void messageApi.error(e?.message || '测试失败'); } @@ -286,9 +327,15 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo const handlePresetChange = (presetKey: string) => { const preset = findPreset(presetKey); + const resolvedTransport = resolvePresetTransport({ + presetBackendType: preset.backendType, + presetFixedApiFormat: preset.fixedApiFormat, + valuesApiFormat: form.getFieldValue('apiFormat'), + }); form.setFieldsValue({ presetKey, - type: preset.backendType, + type: resolvedTransport.type, + apiFormat: resolvedTransport.apiFormat || 'openai', baseUrl: preset.defaultBaseUrl, model: preset.defaultModel, }); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 8633c10..7879668 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -204,7 +204,7 @@ export interface AIProviderConfig { baseUrl: string; model: string; models?: string[]; - apiFormat?: string; // custom 专用: openai | anthropic | gemini + apiFormat?: string; // custom 专用: openai | anthropic | gemini | claude-cli headers?: Record; maxTokens: number; temperature: number; @@ -243,4 +243,3 @@ export interface AISafetyResult { requiresConfirm: boolean; warningMessage?: string; } - diff --git a/frontend/src/utils/aiProviderPresets.test.ts b/frontend/src/utils/aiProviderPresets.test.ts index addc6d6..98bfc67 100644 --- a/frontend/src/utils/aiProviderPresets.test.ts +++ b/frontend/src/utils/aiProviderPresets.test.ts @@ -7,6 +7,7 @@ import { QWEN_CODING_PLAN_MODELS, resolvePresetBaseURL, resolvePresetModelSelection, + resolvePresetTransport, } from './aiProviderPresets'; describe('ai provider preset helpers', () => { @@ -24,6 +25,27 @@ describe('ai provider preset helpers', () => { })).toBe('qwen-coding-plan'); }); + it('maps Coding Plan Claude CLI config back to the dedicated Coding Plan preset', () => { + expect(matchQwenPresetKey({ + type: 'custom', + apiFormat: 'claude-cli', + baseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, + })).toBe('qwen-coding-plan'); + }); + + it('does not keep a baked-in model list for the Coding Plan preset', () => { + expect(QWEN_CODING_PLAN_MODELS).toEqual([ + 'qwen3.5-plus', + 'kimi-k2.5', + 'glm-5', + 'MiniMax-M2.5', + 'qwen3-max-2026-01-23', + 'qwen3-coder-next', + 'qwen3-coder-plus', + 'glm-4.7', + ]); + }); + it('keeps built-in preset model empty when the preset intentionally requires an explicit selection', () => { expect(resolvePresetModelSelection({ presetKey: 'qwen-coding-plan', @@ -65,4 +87,25 @@ describe('ai provider preset helpers', () => { valuesBaseUrl: 'https://example-proxy.internal/v1', })).toBe('https://example-proxy.internal/v1'); }); + + it('forces qwen coding plan to save as custom plus claude-cli', () => { + expect(resolvePresetTransport({ + presetBackendType: 'custom', + presetFixedApiFormat: 'claude-cli', + valuesApiFormat: 'anthropic', + })).toEqual({ + type: 'custom', + apiFormat: 'claude-cli', + }); + }); + + it('keeps custom preset transport editable', () => { + expect(resolvePresetTransport({ + presetBackendType: 'custom', + valuesApiFormat: 'gemini', + })).toEqual({ + type: 'custom', + apiFormat: 'gemini', + }); + }); }); diff --git a/frontend/src/utils/aiProviderPresets.ts b/frontend/src/utils/aiProviderPresets.ts index c4b61a0..7d39a5c 100644 --- a/frontend/src/utils/aiProviderPresets.ts +++ b/frontend/src/utils/aiProviderPresets.ts @@ -1,4 +1,4 @@ -import type { AIProviderConfig } from '../types'; +import type { AIProviderConfig, AIProviderType } from '../types'; export const LEGACY_QWEN_BAILIAN_OPENAI_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'; export const LEGACY_QWEN_CODING_PLAN_OPENAI_BASE_URL = 'https://coding.dashscope.aliyuncs.com/v1'; @@ -7,12 +7,14 @@ export const QWEN_CODING_PLAN_ANTHROPIC_BASE_URL = 'https://coding.dashscope.ali export const QWEN_BAILIAN_MODELS_BASE_URL = LEGACY_QWEN_BAILIAN_OPENAI_BASE_URL; export const QWEN_CODING_PLAN_MODELS = [ + 'qwen3.5-plus', + 'kimi-k2.5', + 'glm-5', + 'MiniMax-M2.5', + 'qwen3-max-2026-01-23', + 'qwen3-coder-next', 'qwen3-coder-plus', - 'qwen3-coder-480b-a35b-instruct', - 'qwen3-coder-30b-a3b-instruct', - 'qwen3-coder-flash', - 'qwen-plus', - 'qwen-turbo', + 'glm-4.7', ]; const CUSTOM_LIKE_PRESET_KEYS = new Set(['custom', 'ollama']); @@ -36,6 +38,17 @@ export interface ResolvePresetBaseURLInput { valuesBaseUrl?: string; } +export interface ResolvePresetTransportInput { + presetBackendType: AIProviderType; + presetFixedApiFormat?: string; + valuesApiFormat?: string; +} + +export interface ResolvePresetTransportResult { + type: AIProviderType; + apiFormat?: string; +} + export const getProviderHostname = (raw?: string): string => { if (!raw) return ''; try { @@ -56,7 +69,7 @@ export const getProviderFingerprint = (raw?: string): string => { } }; -export const matchQwenPresetKey = (provider: Pick): string | null => { +export const matchQwenPresetKey = (provider: Pick): string | null => { const fingerprint = getProviderFingerprint(provider.baseUrl); const bailianFingerprints = new Set([ getProviderFingerprint(LEGACY_QWEN_BAILIAN_OPENAI_BASE_URL), @@ -103,3 +116,28 @@ export const resolvePresetBaseURL = ({ } return presetDefaultBaseUrl; }; + +export const resolvePresetTransport = ({ + presetBackendType, + presetFixedApiFormat, + valuesApiFormat, +}: ResolvePresetTransportInput): ResolvePresetTransportResult => { + if (presetFixedApiFormat) { + return { + type: presetBackendType, + apiFormat: presetFixedApiFormat, + }; + } + + if (presetBackendType === 'custom') { + return { + type: presetBackendType, + apiFormat: valuesApiFormat || 'openai', + }; + } + + return { + type: presetBackendType, + apiFormat: undefined, + }; +}; diff --git a/internal/ai/provider/claude_cli.go b/internal/ai/provider/claude_cli.go index 4f5b64a..f7fc00b 100644 --- a/internal/ai/provider/claude_cli.go +++ b/internal/ai/provider/claude_cli.go @@ -5,16 +5,20 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "os" "os/exec" "runtime" "strings" + "time" ai "GoNavi-Wails/internal/ai" ) var claudeLookPath = exec.LookPath +var claudeCommandContext = exec.CommandContext +var claudeCLIRequestTimeout = 90 * time.Second // ClaudeCLIProvider 通过 Claude Code CLI 发送聊天请求 // 适用于 anyrouter/newapi 等只支持 Claude Code 协议的代理服务 @@ -48,19 +52,25 @@ func (p *ClaudeCLIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.C return nil, err } + ctx, cancel := ensureClaudeCLITimeout(ctx, claudeCLIRequestTimeout) + defer cancel() + prompt := buildPrompt(req.Messages) args := []string{"-p", prompt, "--output-format", "json", "--no-session-persistence"} if p.config.Model != "" { args = append(args, "--model", p.config.Model) } - cmd := exec.CommandContext(ctx, "claude", args...) + cmd := claudeCommandContext(ctx, "claude", args...) if err := p.setEnv(cmd); err != nil { return nil, err } output, err := cmd.Output() if err != nil { + if isClaudeCLITimeout(ctx, err) { + return nil, fmt.Errorf("claude CLI 执行超时(%s),当前 Base URL 或 API Key 可能没有返回有效响应", claudeCLIRequestTimeout) + } if exitErr, ok := err.(*exec.ExitError); ok { return nil, fmt.Errorf("claude CLI 执行失败: %s", string(exitErr.Stderr)) } @@ -68,13 +78,14 @@ func (p *ClaudeCLIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.C } // 解析 JSON 输出 - var result struct { - Result string `json:"result"` - } + var result cliStreamEvent if err := json.Unmarshal(output, &result); err != nil { // 如果 JSON 解析失败,直接返回原始文本 return &ai.ChatResponse{Content: strings.TrimSpace(string(output))}, nil } + if errMsg, hasError := extractClaudeCLIEventError(result); hasError { + return nil, fmt.Errorf("claude CLI 返回错误: %s", errMsg) + } return &ai.ChatResponse{Content: result.Result}, nil } @@ -85,6 +96,9 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, return err } + ctx, cancel := ensureClaudeCLITimeout(ctx, claudeCLIRequestTimeout) + defer cancel() + prompt := buildPrompt(req.Messages) args := []string{"-p", prompt, "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--no-session-persistence"} if p.config.Model != "" { @@ -93,7 +107,7 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, fmt.Printf("[ClaudeCLI DEBUG] Running: claude %v\n", args) - cmd := exec.CommandContext(ctx, "claude", args...) + cmd := claudeCommandContext(ctx, "claude", args...) if err := p.setEnv(cmd); err != nil { return err } @@ -137,7 +151,23 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, } switch event.Type { + case "system": + if isClaudeCLISystemRetryEvent(event) { + if errMsg, hasError := extractClaudeCLISystemRetryError(event); hasError { + callback(ai.StreamChunk{Error: errMsg, Done: true}) + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + return nil + } + } case "assistant": + if errMsg, hasError := extractClaudeCLIEventError(event); hasError { + callback(ai.StreamChunk{Error: errMsg, Done: true}) + _ = cmd.Wait() + return nil + } // 助手消息开始或文本内容 if event.Message.Content != nil { for _, block := range event.Message.Content { @@ -156,12 +186,18 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback(ai.StreamChunk{Content: event.Delta.Text}) } case "result": + if errMsg, hasError := extractClaudeCLIEventError(event); hasError { + callback(ai.StreamChunk{Error: errMsg, Done: true}) + _ = cmd.Wait() + return nil + } // 最终结果事件 — 不发送 content(assistant 事件已包含),只标记完成 callback(ai.StreamChunk{Done: true}) _ = cmd.Wait() return nil case "error": - callback(ai.StreamChunk{Error: event.Error.Message, Done: true}) + errMsg, _ := extractClaudeCLIEventError(event) + callback(ai.StreamChunk{Error: errMsg, Done: true}) _ = cmd.Wait() return nil } @@ -171,6 +207,14 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, stderrStr := strings.TrimSpace(stderrBuf.String()) fmt.Printf("[ClaudeCLI DEBUG] Process exited. stderr: %s\n", stderrStr) + if isClaudeCLITimeout(ctx, waitErr) { + callback(ai.StreamChunk{ + Error: fmt.Sprintf("claude CLI 执行超时(%s),当前 Base URL 或 API Key 可能没有返回有效响应", claudeCLIRequestTimeout), + Done: true, + }) + return nil + } + if waitErr != nil { errMsg := fmt.Sprintf("claude CLI 异常退出: %v", waitErr) if stderrStr != "" { @@ -184,6 +228,20 @@ func (p *ClaudeCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, return nil } +func ensureClaudeCLITimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { + if _, hasDeadline := ctx.Deadline(); hasDeadline || timeout <= 0 { + return ctx, func() {} + } + return context.WithTimeout(ctx, timeout) +} + +func isClaudeCLITimeout(ctx context.Context, err error) bool { + if err == nil { + return false + } + return errors.Is(ctx.Err(), context.DeadlineExceeded) || errors.Is(err, context.DeadlineExceeded) +} + // setEnv 设置 Claude CLI 的环境变量 func (p *ClaudeCLIProvider) setEnv(cmd *exec.Cmd) error { env, err := buildClaudeCLIEnv(p.config, cmd.Environ(), runtime.GOOS, claudeLookPath, fileExists) @@ -200,6 +258,7 @@ func buildClaudeCLIEnv(config ai.ProviderConfig, baseEnv []string, goos string, env = upsertEnv(env, "ANTHROPIC_BASE_URL", strings.TrimRight(config.BaseURL, "/")) } if config.APIKey != "" { + env = upsertEnv(env, "ANTHROPIC_AUTH_TOKEN", config.APIKey) env = upsertEnv(env, "ANTHROPIC_API_KEY", config.APIKey) } @@ -354,8 +413,15 @@ func buildPrompt(messages []ai.Message) string { // cliStreamEvent Claude CLI stream-json 输出的事件结构 type cliStreamEvent struct { - Type string `json:"type"` - Message struct { + Type string `json:"type"` + Subtype string `json:"subtype,omitempty"` + IsError bool `json:"is_error,omitempty"` + Attempt int `json:"attempt,omitempty"` + MaxRetries int `json:"max_retries,omitempty"` + RetryDelayMS float64 `json:"retry_delay_ms,omitempty"` + ErrorStatus int `json:"error_status,omitempty"` + SessionID string `json:"session_id,omitempty"` + Message struct { Content []struct { Type string `json:"type"` Text string `json:"text"` @@ -367,8 +433,79 @@ type cliStreamEvent struct { Text string `json:"text"` Thinking string `json:"thinking"` } `json:"delta,omitempty"` - Result string `json:"result,omitempty"` - Error struct { - Message string `json:"message"` - } `json:"error,omitempty"` + Result string `json:"result,omitempty"` + Error cliStreamEventError `json:"error,omitempty"` +} + +type cliStreamEventError struct { + Message string +} + +func (e *cliStreamEventError) UnmarshalJSON(data []byte) error { + trimmed := strings.TrimSpace(string(data)) + if trimmed == "" || trimmed == "null" { + e.Message = "" + return nil + } + + var text string + if err := json.Unmarshal(data, &text); err == nil { + e.Message = strings.TrimSpace(text) + return nil + } + + var payload struct { + Message string `json:"message"` + } + if err := json.Unmarshal(data, &payload); err != nil { + return err + } + e.Message = strings.TrimSpace(payload.Message) + return nil +} + +func extractClaudeCLIEventError(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 "claude CLI 返回未知错误", true +} + +func isClaudeCLISystemRetryEvent(event cliStreamEvent) bool { + return event.Type == "system" && event.Subtype == "api_retry" +} + +func extractClaudeCLISystemRetryError(event cliStreamEvent) (string, bool) { + if !isClaudeCLISystemRetryEvent(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("claude CLI 鉴权失败 (HTTP %d): %s", event.ErrorStatus, errText), true + } + return fmt.Sprintf("claude CLI 鉴权失败: %s", errText), true } diff --git a/internal/ai/provider/claude_cli_test.go b/internal/ai/provider/claude_cli_test.go index 4bd3ccb..773c8a2 100644 --- a/internal/ai/provider/claude_cli_test.go +++ b/internal/ai/provider/claude_cli_test.go @@ -1,9 +1,13 @@ package provider import ( + "context" "errors" + "os" + "path/filepath" "strings" "testing" + "time" "GoNavi-Wails/internal/ai" ) @@ -26,6 +30,9 @@ func TestBuildClaudeCLIEnv_IncludesAnthropicProxyEnv(t *testing.T) { if got := envValue(env, "ANTHROPIC_API_KEY"); got != "sk-test" { t.Fatalf("expected api key in env, got %q", got) } + if got := envValue(env, "ANTHROPIC_AUTH_TOKEN"); got != "sk-test" { + t.Fatalf("expected auth token in env, got %q", got) + } } func TestBuildClaudeCLIEnv_UsesDetectedGitBashOnWindows(t *testing.T) { @@ -67,3 +74,281 @@ func TestBuildClaudeCLIEnv_ReturnsActionableErrorWhenGitBashMissingOnWindows(t * t.Fatalf("expected env var hint, got %v", err) } } + +func TestClaudeCLIProvider_ChatTimesOutWhenCommandDoesNotFinish(t *testing.T) { + fakeClaude := writeFakeClaudeScript(t, "#!/bin/sh\nsleep 5\n") + restore := overrideClaudeCLIForTest(t, fakeClaude) + defer restore() + + originalRequestTimeout := claudeCLIRequestTimeout + claudeCLIRequestTimeout = 200 * time.Millisecond + defer func() { + claudeCLIRequestTimeout = originalRequestTimeout + }() + + provider, err := NewClaudeCLIProvider(ai.ProviderConfig{ + BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic", + APIKey: "sk-test", + Model: "qwen3.5-plus", + }) + if err != nil { + t.Fatalf("unexpected provider error: %v", err) + } + + start := time.Now() + _, err = provider.Chat(context.Background(), ai.ChatRequest{ + Messages: []ai.Message{{Role: "user", Content: "ping"}}, + }) + if err == nil { + t.Fatal("expected chat timeout error") + } + if !strings.Contains(err.Error(), "执行超时") { + t.Fatalf("expected timeout error, got %v", err) + } + if time.Since(start) < 200*time.Millisecond { + t.Fatalf("expected timeout path to wait for configured deadline, took %s", time.Since(start)) + } +} + +func TestClaudeCLIProvider_ChatStreamUsesRequestTimeoutWhenNoMeaningfulResponseArrives(t *testing.T) { + fakeClaude := writeFakeClaudeScript(t, "#!/bin/sh\necho '{\"type\":\"system\",\"subtype\":\"init\"}'\nexec sleep 5\n") + restore := overrideClaudeCLIForTest(t, fakeClaude) + defer restore() + + originalRequestTimeout := claudeCLIRequestTimeout + claudeCLIRequestTimeout = 200 * time.Millisecond + defer func() { + claudeCLIRequestTimeout = originalRequestTimeout + }() + + provider, err := NewClaudeCLIProvider(ai.ProviderConfig{ + BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic", + APIKey: "sk-test", + Model: "qwen3.5-plus", + }) + if err != nil { + t.Fatalf("unexpected provider error: %v", err) + } + + var chunks []ai.StreamChunk + err = provider.ChatStream(context.Background(), ai.ChatRequest{ + Messages: []ai.Message{{Role: "user", Content: "ping"}}, + }, func(chunk ai.StreamChunk) { + chunks = append(chunks, chunk) + }) + if err != nil { + t.Fatalf("expected stream provider to report timeout via callback, got %v", err) + } + if len(chunks) == 0 { + t.Fatal("expected timeout chunk") + } + lastChunk := chunks[len(chunks)-1] + if !lastChunk.Done { + t.Fatalf("expected timeout chunk to terminate stream, got %#v", lastChunk) + } + if !strings.Contains(lastChunk.Error, "执行超时") { + t.Fatalf("expected request timeout message, got %#v", lastChunk) + } +} + +func TestClaudeCLIProvider_ChatStreamAllowsDelayedMeaningfulResponse(t *testing.T) { + fakeClaude := writeFakeClaudeScript(t, "#!/bin/sh\necho '{\"type\":\"system\",\"subtype\":\"init\"}'\nsleep 0.2\necho '{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"OK\"}]}}'\necho '{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"OK\"}'\n") + restore := overrideClaudeCLIForTest(t, fakeClaude) + defer restore() + + originalRequestTimeout := claudeCLIRequestTimeout + claudeCLIRequestTimeout = 1 * time.Second + defer func() { + claudeCLIRequestTimeout = originalRequestTimeout + }() + + provider, err := NewClaudeCLIProvider(ai.ProviderConfig{ + BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic", + APIKey: "sk-test", + Model: "qwen3.5-plus", + }) + if err != nil { + t.Fatalf("unexpected provider error: %v", err) + } + + var chunks []ai.StreamChunk + err = provider.ChatStream(context.Background(), ai.ChatRequest{ + Messages: []ai.Message{{Role: "user", Content: "ping"}}, + }, func(chunk ai.StreamChunk) { + chunks = append(chunks, chunk) + }) + if err != nil { + t.Fatalf("expected delayed response to complete via callback, got %v", err) + } + if len(chunks) == 0 { + t.Fatal("expected delayed response chunks") + } + if chunks[0].Content != "OK" { + t.Fatalf("expected delayed content chunk, got %#v", chunks) + } + if !chunks[len(chunks)-1].Done { + t.Fatalf("expected terminal done chunk, got %#v", chunks[len(chunks)-1]) + } +} + +func TestClaudeCLIProvider_ChatReturnsErrorWhenJSONResponseIsError(t *testing.T) { + fakeClaude := writeFakeClaudeScript(t, "#!/bin/sh\necho '{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":true,\"result\":\"API Error: Unable to connect to API (ECONNRESET)\",\"error\":\"unknown\"}'\n") + restore := overrideClaudeCLIForTest(t, fakeClaude) + defer restore() + + provider, err := NewClaudeCLIProvider(ai.ProviderConfig{ + BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic", + APIKey: "sk-test", + Model: "qwen3.5-plus", + }) + if err != nil { + t.Fatalf("unexpected provider error: %v", err) + } + + _, err = provider.Chat(context.Background(), ai.ChatRequest{ + Messages: []ai.Message{{Role: "user", Content: "ping"}}, + }) + if err == nil { + t.Fatal("expected chat error when CLI JSON marks request as failed") + } + if !strings.Contains(err.Error(), "Unable to connect to API") { + t.Fatalf("expected upstream API error, got %v", err) + } +} + +func TestClaudeCLIProvider_ChatStreamReportsAssistantErrorEvent(t *testing.T) { + fakeClaude := writeFakeClaudeScript(t, "#!/bin/sh\necho '{\"type\":\"assistant\",\"is_error\":true,\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"API Error: Unable to connect to API (ECONNRESET)\"}]},\"error\":\"unknown\"}'\n") + restore := overrideClaudeCLIForTest(t, fakeClaude) + defer restore() + + provider, err := NewClaudeCLIProvider(ai.ProviderConfig{ + BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic", + APIKey: "sk-test", + Model: "qwen3.5-plus", + }) + if err != nil { + t.Fatalf("unexpected provider error: %v", err) + } + + var chunks []ai.StreamChunk + err = provider.ChatStream(context.Background(), ai.ChatRequest{ + Messages: []ai.Message{{Role: "user", Content: "ping"}}, + }, func(chunk ai.StreamChunk) { + chunks = append(chunks, chunk) + }) + if err != nil { + t.Fatalf("expected stream provider to report error via callback, got %v", err) + } + if len(chunks) != 1 { + t.Fatalf("expected a single terminal error chunk, got %#v", chunks) + } + if chunks[0].Content != "" { + t.Fatalf("expected assistant error event to avoid content output, got %#v", chunks[0]) + } + if !chunks[0].Done || !strings.Contains(chunks[0].Error, "Unable to connect to API") { + t.Fatalf("expected upstream API error chunk, got %#v", chunks[0]) + } +} + +func TestClaudeCLIProvider_ChatStreamReportsResultErrorEvent(t *testing.T) { + fakeClaude := writeFakeClaudeScript(t, "#!/bin/sh\necho '{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":true,\"result\":\"API Error: Unable to connect to API (ECONNRESET)\",\"error\":\"unknown\"}'\n") + restore := overrideClaudeCLIForTest(t, fakeClaude) + defer restore() + + provider, err := NewClaudeCLIProvider(ai.ProviderConfig{ + BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic", + APIKey: "sk-test", + Model: "qwen3.5-plus", + }) + if err != nil { + t.Fatalf("unexpected provider error: %v", err) + } + + var chunks []ai.StreamChunk + err = provider.ChatStream(context.Background(), ai.ChatRequest{ + Messages: []ai.Message{{Role: "user", Content: "ping"}}, + }, func(chunk ai.StreamChunk) { + chunks = append(chunks, chunk) + }) + if err != nil { + t.Fatalf("expected stream provider to report error via callback, got %v", err) + } + if len(chunks) != 1 { + t.Fatalf("expected a single terminal error chunk, got %#v", chunks) + } + if chunks[0].Content != "" { + t.Fatalf("expected result error event to avoid content output, got %#v", chunks[0]) + } + if !chunks[0].Done || !strings.Contains(chunks[0].Error, "Unable to connect to API") { + t.Fatalf("expected upstream API error chunk, got %#v", chunks[0]) + } +} + +func TestClaudeCLIProvider_ChatStreamReportsApiRetryAuthenticationFailure(t *testing.T) { + fakeClaude := writeFakeClaudeScript(t, "#!/bin/sh\necho '{\"type\":\"system\",\"subtype\":\"api_retry\",\"attempt\":1,\"max_retries\":10,\"retry_delay_ms\":536.11,\"error_status\":401,\"error\":\"authentication_failed\",\"session_id\":\"retry-1\"}'\nexec sleep 5\n") + restore := overrideClaudeCLIForTest(t, fakeClaude) + defer restore() + + provider, err := NewClaudeCLIProvider(ai.ProviderConfig{ + BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic", + APIKey: "sk-test", + Model: "qwen3.5-plus", + }) + if err != nil { + t.Fatalf("unexpected provider error: %v", err) + } + + var chunks []ai.StreamChunk + err = provider.ChatStream(context.Background(), ai.ChatRequest{ + Messages: []ai.Message{{Role: "user", Content: "ping"}}, + }, func(chunk ai.StreamChunk) { + chunks = append(chunks, chunk) + }) + if err != nil { + t.Fatalf("expected stream provider to report authentication error via callback, got %v", err) + } + if len(chunks) != 1 { + t.Fatalf("expected a single terminal error chunk, got %#v", chunks) + } + if !chunks[0].Done { + t.Fatalf("expected terminal error chunk, got %#v", chunks[0]) + } + if strings.Contains(chunks[0].Error, "未收到模型响应") { + t.Fatalf("expected auth failure instead of startup timeout, got %#v", chunks[0]) + } + if !strings.Contains(chunks[0].Error, "401") || !strings.Contains(chunks[0].Error, "authentication_failed") { + t.Fatalf("expected auth retry error details, got %#v", chunks[0]) + } +} + +func writeFakeClaudeScript(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "claude") + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatalf("failed to write fake claude script: %v", err) + } + return path +} + +func overrideClaudeCLIForTest(t *testing.T, fakeClaudePath string) func() { + t.Helper() + + originalLookPath := claudeLookPath + claudeLookPath = func(name string) (string, error) { + if name == "claude" { + return fakeClaudePath, nil + } + return originalLookPath(name) + } + + originalPath := os.Getenv("PATH") + if err := os.Setenv("PATH", filepath.Dir(fakeClaudePath)+string(os.PathListSeparator)+originalPath); err != nil { + t.Fatalf("failed to override PATH: %v", err) + } + + return func() { + claudeLookPath = originalLookPath + _ = os.Setenv("PATH", originalPath) + } +} diff --git a/internal/ai/service/service.go b/internal/ai/service/service.go index 285d61e..6897820 100644 --- a/internal/ai/service/service.go +++ b/internal/ai/service/service.go @@ -47,16 +47,28 @@ var miniMaxAnthropicModels = []string{ } var dashScopeCodingPlanModels = []string{ + "qwen3.5-plus", + "kimi-k2.5", + "glm-5", + "MiniMax-M2.5", + "qwen3-max-2026-01-23", + "qwen3-coder-next", "qwen3-coder-plus", - "qwen3-coder-480b-a35b-instruct", - "qwen3-coder-30b-a3b-instruct", - "qwen3-coder-flash", - "qwen-plus", - "qwen-turbo", + "glm-4.7", +} + +const dashScopeCodingPlanAnthropicBaseURL = "https://coding.dashscope.aliyuncs.com/apps/anthropic" + +var volcengineCodingPlanAllowedExactModels = []string{ + "auto", } var volcengineCodingPlanAllowedModelFamilies = []string{ + "doubao-seed-2.0-code", + "doubao-seed-2.0-pro", + "doubao-seed-2.0-lite", "doubao-seed-code", + "minimax-m2.5", "glm-4.7", "deepseek-v3.2", "kimi-k2", @@ -64,6 +76,25 @@ var volcengineCodingPlanAllowedModelFamilies = []string{ const volcengineCodingPlanEmptyModelsError = `当前接口未返回可用的火山 Coding Plan 模型,请检查账号权限或切换到"火山方舟"供应商` +var claudeCLIHealthCheckFunc = 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 &Service{ @@ -197,6 +228,12 @@ func (s *Service) AITestProvider(config ai.ProviderConfig) map[string]interface{ err = fmt.Errorf("上游服务器内部错误 (HTTP %d)", resp.StatusCode) } } + case "claude-cli": + testConfig := config + if strings.TrimSpace(testConfig.Model) == "" && isDashScopeCodingPlanProvider(testConfig) && len(dashScopeCodingPlanModels) > 0 { + testConfig.Model = dashScopeCodingPlanModels[0] + } + err = claudeCLIHealthCheckFunc(testConfig) default: if baseURL != "" { req, _ := http.NewRequest("GET", baseURL, nil) @@ -263,8 +300,12 @@ func isDashScopeCodingPlanAnthropicProvider(config ai.ProviderConfig) bool { if normalizedProviderType(config) != "anthropic" { return false } + return isDashScopeCodingPlanProvider(config) +} + +func isDashScopeCodingPlanProvider(config ai.ProviderConfig) bool { host, path := parseProviderBaseURL(config.BaseURL) - return host == "coding.dashscope.aliyuncs.com" && strings.HasPrefix(path, "/apps/anthropic") + return host == "coding.dashscope.aliyuncs.com" && (strings.HasPrefix(path, "/apps/anthropic") || strings.HasPrefix(path, "/v1")) } func isVolcengineCodingPlanProvider(config ai.ProviderConfig) bool { @@ -279,6 +320,17 @@ func filterVolcengineCodingPlanModels(models []string) []string { filtered := make([]string, 0, len(models)) for _, model := range models { lowerModel := strings.ToLower(strings.TrimSpace(model)) + matched := false + for _, exactModel := range volcengineCodingPlanAllowedExactModels { + if lowerModel == exactModel { + filtered = append(filtered, model) + matched = true + break + } + } + if matched { + continue + } for _, family := range volcengineCodingPlanAllowedModelFamilies { if strings.Contains(lowerModel, family) { filtered = append(filtered, model) @@ -304,7 +356,7 @@ func defaultStaticModelsForProvider(config ai.ProviderConfig) []string { if isMiniMaxAnthropicProvider(config) { return append([]string(nil), miniMaxAnthropicModels...) } - if isDashScopeCodingPlanAnthropicProvider(config) { + if isDashScopeCodingPlanProvider(config) { return append([]string(nil), dashScopeCodingPlanModels...) } return nil @@ -314,7 +366,10 @@ func normalizeProviderConfig(config ai.ProviderConfig) ai.ProviderConfig { switch { case isDashScopeBailianAnthropicProvider(config): config.Models = nil - case isDashScopeCodingPlanAnthropicProvider(config): + case isDashScopeCodingPlanProvider(config): + config.Type = "custom" + config.APIFormat = "claude-cli" + config.BaseURL = dashScopeCodingPlanAnthropicBaseURL config.Models = append([]string(nil), dashScopeCodingPlanModels...) default: staticModels := defaultStaticModelsForProvider(config) @@ -474,6 +529,11 @@ func (s *Service) AIListModels() map[string]interface{} { return map[string]interface{}{"success": false, "models": []string{}, "error": "未找到活跃 Provider"} } + config = normalizeProviderConfig(config) + if staticModels := defaultStaticModelsForProvider(config); len(staticModels) > 0 { + return map[string]interface{}{"success": true, "models": staticModels, "source": "static"} + } + models, err := fetchModelsFunc(config) if err != nil { // 回退到配置中的静态模型列表 diff --git a/internal/ai/service/service_qwen_test.go b/internal/ai/service/service_qwen_test.go index 8a364d5..ce4a19e 100644 --- a/internal/ai/service/service_qwen_test.go +++ b/internal/ai/service/service_qwen_test.go @@ -17,21 +17,34 @@ func TestDefaultStaticModelsForProvider_DoesNotReturnBailianStaticModels(t *test } } -func TestDefaultStaticModelsForProvider_ReturnsDashScopeCodingPlanModels(t *testing.T) { - models := defaultStaticModelsForProvider(ai.ProviderConfig{ - Type: "anthropic", - BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic", - }) +func TestDefaultStaticModelsForProvider_ReturnsDashScopeCodingPlanSupportedModels(t *testing.T) { expected := []string{ + "qwen3.5-plus", + "kimi-k2.5", + "glm-5", + "MiniMax-M2.5", + "qwen3-max-2026-01-23", + "qwen3-coder-next", "qwen3-coder-plus", - "qwen3-coder-480b-a35b-instruct", - "qwen3-coder-30b-a3b-instruct", - "qwen3-coder-flash", - "qwen-plus", - "qwen-turbo", + "glm-4.7", } - if !reflect.DeepEqual(models, expected) { - t.Fatalf("expected Coding Plan static models %v, got %v", expected, models) + testCases := []ai.ProviderConfig{ + { + Type: "anthropic", + BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic", + }, + { + Type: "custom", + APIFormat: "claude-cli", + BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic", + }, + } + + for _, testCase := range testCases { + models := defaultStaticModelsForProvider(testCase) + if !reflect.DeepEqual(models, expected) { + t.Fatalf("expected Coding Plan supported models %v, got %v for config %#v", expected, models, testCase) + } } } @@ -48,12 +61,21 @@ func TestNormalizeProviderConfig_DoesNotForceModelForDashScopeProviders(t *testi Type: "anthropic", BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic", }) + if codingPlan.Type != "custom" { + t.Fatalf("expected Coding Plan provider type to normalize to custom, got %q", codingPlan.Type) + } + if codingPlan.APIFormat != "claude-cli" { + t.Fatalf("expected Coding Plan provider api format to normalize to claude-cli, got %q", codingPlan.APIFormat) + } if codingPlan.Model != "" { t.Fatalf("expected Coding Plan model to remain empty until explicit selection, got %q", codingPlan.Model) } if len(codingPlan.Models) == 0 { t.Fatal("expected Coding Plan provider to expose official supported models") } + if codingPlan.Models[0] != "qwen3.5-plus" { + t.Fatalf("expected Coding Plan provider to expose latest supported models, got %v", codingPlan.Models) + } } func TestResolveModelsURL_UsesDashScopeCompatibleModelsEndpointForBailianAnthropic(t *testing.T) { @@ -66,20 +88,70 @@ func TestResolveModelsURL_UsesDashScopeCompatibleModelsEndpointForBailianAnthrop } } -func TestNewProviderHealthCheckRequest_UsesMessagesEndpointForDashScopeCodingPlanAnthropic(t *testing.T) { - req, err := newProviderHealthCheckRequest(ai.ProviderConfig{ - Type: "anthropic", - BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic", - Model: "qwen3-coder-plus", - APIKey: "sk-test", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) +func TestAIListModels_ReturnsStaticModelsForDashScopeCodingPlanWithoutRemoteFetch(t *testing.T) { + originalFetchModelsFunc := fetchModelsFunc + fetchModelsFunc = func(config ai.ProviderConfig) ([]string, error) { + t.Fatalf("expected Coding Plan model list to stay static and skip remote fetch, got config %#v", config) + return nil, nil } - if req.Method != "POST" { - t.Fatalf("expected POST request, got %s", req.Method) + defer func() { + fetchModelsFunc = originalFetchModelsFunc + }() + + service := NewService() + service.providers = []ai.ProviderConfig{ + { + ID: "provider-coding-plan", + Type: "anthropic", + BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic", + }, } - if req.URL.String() != "https://coding.dashscope.aliyuncs.com/apps/anthropic/v1/messages" { - t.Fatalf("expected Coding Plan messages endpoint, got %q", req.URL.String()) + service.activeProvider = "provider-coding-plan" + + 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) == 0 || models[0] != "qwen3.5-plus" { + t.Fatalf("expected official static Coding Plan models, got %#v", models) + } + if source, _ := result["source"].(string); source != "static" { + t.Fatalf("expected static source, got %#v", result["source"]) + } +} + +func TestAITestProvider_UsesClaudeCLIHealthCheckForDashScopeCodingPlan(t *testing.T) { + originalClaudeCLIHealthCheckFunc := claudeCLIHealthCheckFunc + defer func() { + claudeCLIHealthCheckFunc = originalClaudeCLIHealthCheckFunc + }() + + var received ai.ProviderConfig + claudeCLIHealthCheckFunc = func(config ai.ProviderConfig) error { + received = config + return nil + } + + service := NewService() + result := service.AITestProvider(ai.ProviderConfig{ + Type: "anthropic", + BaseURL: "https://coding.dashscope.aliyuncs.com/apps/anthropic", + APIKey: "sk-test", + }) + if result["success"] != true { + t.Fatalf("expected AITestProvider to succeed, got %#v", result) + } + if received.Type != "custom" { + t.Fatalf("expected Coding Plan test to use custom provider type, got %q", received.Type) + } + if received.APIFormat != "claude-cli" { + t.Fatalf("expected Coding Plan test to use claude-cli api format, got %q", received.APIFormat) + } + if received.Model != "qwen3.5-plus" { + t.Fatalf("expected Coding Plan test to default probe model to qwen3.5-plus, got %q", received.Model) } } diff --git a/internal/ai/service/service_volcengine_test.go b/internal/ai/service/service_volcengine_test.go index 6210861..bd80c97 100644 --- a/internal/ai/service/service_volcengine_test.go +++ b/internal/ai/service/service_volcengine_test.go @@ -19,16 +19,26 @@ func TestIsVolcengineCodingPlanProvider_MatchesCodingPlanBaseURL(t *testing.T) { func TestFilterVolcengineCodingPlanModels_KeepsOnlySupportedFamilies(t *testing.T) { filtered := filterVolcengineCodingPlanModels([]string{ + "Auto", "qwen3-14b-20250429", "wan2-1-14b-t2v-250225", + "Doubao-Seed-2.0-Code", + "Doubao-Seed-2.0-pro", + "Doubao-Seed-2.0-lite", "doubao-seed-code-32k-250615", + "MiniMax-M2.5", "GLM-4.7", "DeepSeek-V3.2", "kimi-k2-turbo-preview", }) expected := []string{ + "Auto", + "Doubao-Seed-2.0-Code", + "Doubao-Seed-2.0-pro", + "Doubao-Seed-2.0-lite", "doubao-seed-code-32k-250615", + "MiniMax-M2.5", "GLM-4.7", "DeepSeek-V3.2", "kimi-k2-turbo-preview", @@ -38,6 +48,18 @@ func TestFilterVolcengineCodingPlanModels_KeepsOnlySupportedFamilies(t *testing. } } +func TestFilterVolcengineCodingPlanModels_DoesNotBroadlyMatchAutoKeyword(t *testing.T) { + filtered := filterVolcengineCodingPlanModels([]string{ + "Auto", + "automatic-router-preview", + }) + + expected := []string{"Auto"} + if !reflect.DeepEqual(filtered, expected) { + t.Fatalf("expected only exact Auto model to remain, got %v", filtered) + } +} + func TestFilterFetchedModelsForProvider_DoesNotFilterVolcengineArk(t *testing.T) { rawModels := []string{ "qwen3-14b-20250429", diff --git a/internal/ai/types.go b/internal/ai/types.go index 0c83c6d..5f4ddae 100644 --- a/internal/ai/types.go +++ b/internal/ai/types.go @@ -25,7 +25,7 @@ type Tool struct { // Message 表示一条对话消息 type Message struct { - Role string `json:"role"` // "system" | "user" | "assistant" | "tool" + Role string `json:"role"` // "system" | "user" | "assistant" | "tool" Content string `json:"content"` Images []string `json:"images,omitempty"` // base64 encoded images with data:image/png;base64,... prefix ToolCallID string `json:"tool_call_id,omitempty"` // 当 role 为 "tool" 时必须传递 @@ -66,13 +66,13 @@ type StreamChunk struct { // ProviderConfig AI Provider 配置 type ProviderConfig struct { ID string `json:"id"` - Type string `json:"type"` // openai | anthropic | gemini | custom + Type string `json:"type"` // openai | anthropic | gemini | custom Name string `json:"name"` APIKey string `json:"apiKey"` BaseURL string `json:"baseUrl"` Model string `json:"model"` Models []string `json:"models,omitempty"` - APIFormat string `json:"apiFormat,omitempty"` // custom 专用: openai | anthropic | gemini + APIFormat string `json:"apiFormat,omitempty"` // custom 专用: openai | anthropic | gemini | claude-cli Headers map[string]string `json:"headers,omitempty"` MaxTokens int `json:"maxTokens"` Temperature float64 `json:"temperature"`