diff --git a/desktop/frontend/src/components/SettingsPanel.tsx b/desktop/frontend/src/components/SettingsPanel.tsx index d0d04cc72e..b47c221c27 100644 --- a/desktop/frontend/src/components/SettingsPanel.tsx +++ b/desktop/frontend/src/components/SettingsPanel.tsx @@ -6,6 +6,7 @@ import { app, openExternal } from "../lib/bridge"; import { normalizeLangPref, useI18n, useT, type DictKey, type LangPref } from "../lib/i18n"; import { apiKeyEnvFromProviderName, createLatestRequestGate, inferredVisionModels, mergedFetchedProviderModels, mergeProviderModelContextWindows, providerApiKeyEnvForSave, providerDefaultModel, providerIsConfigured, providerModelCandidates, providerModelContextWindowDrafts, providerModelContextWindowIsSmall, providerRequiresKey } from "../lib/providerModels"; import { cachedFetchProviderModels, invalidateProviderCacheByAPIKeyEnv, shouldSkipAutoRefresh } from "../lib/providerModelCache"; +import { opencodeGoPresetDescriptionKeys } from "../lib/providerPresetDescriptions"; import { useUpdater } from "../lib/useUpdater"; import { applyTheme, @@ -5289,10 +5290,9 @@ function providerPresetDescription(preset: ProviderPresetView, t: ReturnType; diff --git a/desktop/frontend/src/locales/en.ts b/desktop/frontend/src/locales/en.ts index 8e030b3219..d576dfd869 100644 --- a/desktop/frontend/src/locales/en.ts +++ b/desktop/frontend/src/locales/en.ts @@ -2569,7 +2569,9 @@ export const en = { "settings.addProvider.preset.zaiCodingPlanGlobalDesc": "Z.AI international coding-plan endpoint with GLM-5.2 default.", "settings.addProvider.preset.zaiCodingPlanGlobalAnthropicDesc": "Z.AI international Anthropic-compatible coding-plan endpoint with GLM-5.2 default.", "settings.addProvider.preset.opencodeGoDesc": "OpenCode Go relay with native Kimi K3 and mixed-model capability overrides.", - "settings.addProvider.preset.opencodeGoAnthropicDesc": "OpenCode Go Anthropic route for Qwen and MiniMax models.", + "settings.addProvider.preset.opencodeGoAnthropicDesc": "OpenCode Go Anthropic route for Qwen and MiniMax.", + "settings.addProvider.preset.opencodeGoDeepSeekAnthropicDesc": "DeepSeek Flash over Anthropic Messages with server-side web search.", + "settings.addProvider.preset.opencodeGoDeepSeekResponsesDesc": "DeepSeek Flash over stateless Responses API with server-side web search.", "settings.addProvider.preset.opencodeZenAnthropicDesc": "OpenCode Zen Anthropic route for Claude and Qwen models.", "settings.addProvider.preset.qwenCnDesc": "Qwen/DashScope China standard API endpoint.", "settings.addProvider.preset.qwenGlobalDesc": "Qwen/DashScope international standard API endpoint.", diff --git a/desktop/frontend/src/locales/zh-TW.ts b/desktop/frontend/src/locales/zh-TW.ts index 91d6240f27..24aafcc20c 100644 --- a/desktop/frontend/src/locales/zh-TW.ts +++ b/desktop/frontend/src/locales/zh-TW.ts @@ -1744,7 +1744,9 @@ export const zhTW: Record = { "settings.addProvider.preset.zaiCodingPlanGlobalDesc": "Z.AI 國際 coding plan 端點,預設 GLM-5.2。", "settings.addProvider.preset.zaiCodingPlanGlobalAnthropicDesc": "Z.AI 國際 Anthropic-compatible coding plan 端點,預設 GLM-5.2。", "settings.addProvider.preset.opencodeGoDesc": "OpenCode Go 混合模型閘道,原生預置 Kimi K3 與每模型能力覆蓋。", - "settings.addProvider.preset.opencodeGoAnthropicDesc": "OpenCode Go Anthropic 線路,預置 Qwen 與 MiniMax 模型。", + "settings.addProvider.preset.opencodeGoAnthropicDesc": "OpenCode Go Anthropic 線路,預置 Qwen 與 MiniMax。", + "settings.addProvider.preset.opencodeGoDeepSeekAnthropicDesc": "DeepSeek Flash Anthropic Messages 線路,預設啟用伺服器端網頁搜尋。", + "settings.addProvider.preset.opencodeGoDeepSeekResponsesDesc": "DeepSeek Flash 無狀態 Responses API 線路,預設啟用伺服器端網頁搜尋。", "settings.addProvider.preset.opencodeZenAnthropicDesc": "OpenCode Zen Anthropic 線路,預置 Claude 與 Qwen 模型。", "settings.addProvider.preset.qwenCnDesc": "Qwen/DashScope 中國標準 API 端點。", "settings.addProvider.preset.qwenGlobalDesc": "Qwen/DashScope 國際標準 API 端點。", diff --git a/desktop/frontend/src/locales/zh.ts b/desktop/frontend/src/locales/zh.ts index 58b7b51892..2b63a570d7 100644 --- a/desktop/frontend/src/locales/zh.ts +++ b/desktop/frontend/src/locales/zh.ts @@ -2572,7 +2572,9 @@ export const zh: Record = { "settings.addProvider.preset.zaiCodingPlanGlobalDesc": "Z.AI 国际 coding plan 端点,默认 GLM-5.2。", "settings.addProvider.preset.zaiCodingPlanGlobalAnthropicDesc": "Z.AI 国际 Anthropic-compatible coding plan 端点,默认 GLM-5.2。", "settings.addProvider.preset.opencodeGoDesc": "OpenCode Go 混合模型网关,原生预置 Kimi K3 与每模型能力覆盖。", - "settings.addProvider.preset.opencodeGoAnthropicDesc": "OpenCode Go Anthropic 线路,预置 Qwen 与 MiniMax 模型。", + "settings.addProvider.preset.opencodeGoAnthropicDesc": "OpenCode Go Anthropic 线路,预置 Qwen 与 MiniMax。", + "settings.addProvider.preset.opencodeGoDeepSeekAnthropicDesc": "DeepSeek Flash Anthropic Messages 线路,默认启用服务端网页搜索。", + "settings.addProvider.preset.opencodeGoDeepSeekResponsesDesc": "DeepSeek Flash 无状态 Responses API 线路,默认启用服务端网页搜索。", "settings.addProvider.preset.opencodeZenAnthropicDesc": "OpenCode Zen Anthropic 线路,预置 Claude 与 Qwen 模型。", "settings.addProvider.preset.qwenCnDesc": "Qwen/DashScope 国内标准 API 端点。", "settings.addProvider.preset.qwenGlobalDesc": "Qwen/DashScope 国际标准 API 端点。", diff --git a/desktop/settings_app.go b/desktop/settings_app.go index b11d3bd185..9d54be6d3d 100644 --- a/desktop/settings_app.go +++ b/desktop/settings_app.go @@ -652,7 +652,7 @@ func providerViewFromEntryForRootWithResolverAndCredentials(p config.ProviderEnt ReasoningProtocol: p.ReasoningProtocol, Thinking: providerThinkingForSettings(p.Thinking), WebSearch: config.EffectiveWebSearch(&p), - ServerWebSearchCapability: config.IsOfficialDeepSeekWebSearchEndpoint(&p), + ServerWebSearchCapability: config.HasServerWebSearchCapability(&p), SupportedEfforts: nonNil(p.SupportedEfforts), DefaultEffort: p.DefaultEffort, ModelOverrides: providerModelOverridesForView(p.ModelOverrides, models), diff --git a/desktop/settings_app_opencode_go_test.go b/desktop/settings_app_opencode_go_test.go new file mode 100644 index 0000000000..25c18242ac --- /dev/null +++ b/desktop/settings_app_opencode_go_test.go @@ -0,0 +1,21 @@ +package main + +import ( + "testing" + + "reasonix/internal/config" +) + +func TestProviderViewFromEntryUsesOpenCodeGoDeepSeekWebSearch(t *testing.T) { + enabled := true + view := providerViewFromEntry(config.ProviderEntry{ + Name: "opencode-go-deepseek-responses", + Kind: "responses", + BaseURL: "https://opencode.ai/zen/go/v1", + Models: []string{"deepseek-v4-flash"}, + WebSearch: &enabled, + }, false, true) + if !view.ServerWebSearchCapability || !view.WebSearch { + t.Fatalf("OpenCode Go DeepSeek web search view = %+v, want enabled verified capability", view) + } +} diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 0c464e1e52..10601d028f 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -390,7 +390,8 @@ Kimi Global, Kimi Coding Plan, MiMo API, MiMo Anthropic, MiMo Token Plan CN/SGP/AMS and their Anthropic-compatible variants, MiniMax CN/Global API, MiniMax CN/Global Anthropic, GLM CN, Z.AI Global, GLM/Z.AI Coding Plan OpenAI-compatible and -Anthropic-compatible endpoints, OpenCode Go, OpenCode Go Anthropic, OpenCode Zen +Anthropic-compatible endpoints, OpenCode Go, OpenCode Go Anthropic, OpenCode Go +DeepSeek Anthropic, OpenCode Go DeepSeek Responses, OpenCode Zen Anthropic, Qwen/DashScope CN/Global, Qwen Coding Plan CN/Global OpenAI-compatible and Anthropic-compatible endpoints, StepFun OpenAI-compatible and Anthropic-compatible endpoints, NovitaAI, GMI Cloud, Vercel AI Gateway, @@ -403,8 +404,15 @@ usually needs only the provider API key: the key value is stored in Reasonix hom environment-variable name, context window, vision model metadata, proxy bypass for China-only endpoints, MiniMax `reasoning_split`, GLM/MiniMax thinking heuristics, Anthropic-compatible Bearer auth where needed, Ollama Cloud -max-effort support, and OpenCode Go per-model reasoning overrides. The OpenCode -Go preset includes its native `kimi-k3` subscription route with image input, +max-effort support, and OpenCode Go per-model reasoning overrides. The dedicated +OpenCode Go DeepSeek Anthropic and DeepSeek Responses presets expose the verified +Flash routes and enable provider-side `web_search` by default; the Responses +variant uses stateless context replay. The existing mixed OpenCode Go Anthropic +preset remains scoped to Qwen and MiniMax so server tools are not sent to +unverified models. DeepSeek Pro remains on the Chat Completions preset because +live Anthropic and Responses requests currently fail in the OpenCode Go upstream +conversion. The OpenCode Go preset includes its native `kimi-k3` subscription +route with image input, `high`/`max` reasoning effort, and a 1,048,576-token context window. Existing untouched OpenCode Go preset installs are upgraded automatically; edited model catalogs are preserved. The Kimi CN and Kimi Global direct-API presets also include diff --git a/docs/GUIDE.zh-CN.md b/docs/GUIDE.zh-CN.md index 710470fc6e..9ffe573974 100644 --- a/docs/GUIDE.zh-CN.md +++ b/docs/GUIDE.zh-CN.md @@ -334,7 +334,9 @@ Kimi CN、Kimi Global、Kimi Coding Plan、MiMo API、MiMo Anthropic、MiMo Toke CN/SGP/AMS 及其 Anthropic-compatible 变体、MiniMax CN/Global API、MiniMax CN/Global Anthropic、GLM CN、Z.AI Global、GLM/Z.AI Coding Plan 的 OpenAI-compatible 与 Anthropic-compatible 端点、OpenCode Go、OpenCode Go -Anthropic、OpenCode Zen Anthropic、Qwen/DashScope CN/Global、Qwen Coding Plan +Anthropic、OpenCode Go DeepSeek Anthropic、OpenCode Go DeepSeek Responses、 +OpenCode Zen Anthropic、Qwen/DashScope CN/Global、 +Qwen Coding Plan CN/Global 的 OpenAI-compatible 与 Anthropic-compatible 端点、StepFun OpenAI-compatible 与 Anthropic-compatible 端点、NovitaAI、GMI Cloud、Vercel AI Gateway、HuggingFace Router、NVIDIA NIM、KiloCode 和 Ollama Cloud。Plan 表示 @@ -344,8 +346,13 @@ Gateway、HuggingFace Router、NVIDIA NIM、KiloCode 和 Ollama Cloud。Plan 表 `config.toml` 只保存端点、模型列表、key 环境变量名、上下文窗口、视觉模型元数据、 中国区端点直连、MiniMax `reasoning_split`、GLM/MiniMax thinking heuristic、 Anthropic-compatible 网关需要的 Bearer 认证、Ollama Cloud max-effort 支持, -以及 OpenCode Go 的每模型 reasoning 覆盖。OpenCode Go 预设原生包含订阅线路的 -`kimi-k3`,并配置图像输入、`high`/`max` 推理强度和 1,048,576 token 上下文窗口。未修改过 +以及 OpenCode Go 的每模型 reasoning 覆盖。专用的 OpenCode Go DeepSeek Anthropic 与 +DeepSeek Responses 预设接入已验证的 Flash 线路,并默认启用 provider 侧 `web_search`; +Responses 变体使用无状态上下文回放。原有混合 OpenCode Go Anthropic 预设仍只包含 Qwen +与 MiniMax,避免把服务端搜索工具发送给未验证模型。DeepSeek Pro 暂时仍只放在 Chat +Completions 预设中,因为真实 Anthropic +和 Responses 请求目前会在 OpenCode Go 的上游转换阶段失败。OpenCode Go 预设原生包含 +订阅线路的 `kimi-k3`,并配置图像输入、`high`/`max` 推理强度和 1,048,576 token 上下文窗口。未修改过 模型目录的既有 OpenCode Go 预设会自动升级;用户编辑过的模型目录保持不变。 Kimi CN 和 Kimi Global 直连 API 预设也包含 `kimi-k3`,支持图像输入、1,048,576 token 上下文窗口以及官方 `low`/`high`/`max` 推理强度(默认 `max`)。对官方 K3 端点,Reasonix diff --git a/internal/config/provider_presets.go b/internal/config/provider_presets.go index 663d85aad3..1aca46b44a 100644 --- a/internal/config/provider_presets.go +++ b/internal/config/provider_presets.go @@ -114,7 +114,6 @@ var ( legacyOpenCodeGoModels = []string{"glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6", "deepseek-v4-pro", "deepseek-v4-flash", "mimo-v2.5-pro", "mimo-v2.5"} opencodeGoModels = []string{"glm-5.2", "glm-5.1", "kimi-k3", "kimi-k2.7-code", "kimi-k2.6", "deepseek-v4-pro", "deepseek-v4-flash", "mimo-v2.5-pro", "mimo-v2.5"} opencodeGoVisionModels = []string{"kimi-k3"} - opencodeGoAnthropicModels = []string{"qwen3.7-plus", "qwen3.7-max", "qwen3.6-plus", "minimax-m3", "minimax-m2.7", "minimax-m2.5"} opencodeZenAnthropicModels = []string{"claude-sonnet-4-6", "claude-opus-4-8", "claude-haiku-4-5", "qwen3.6-plus", "qwen3.5-plus", "qwen3.6-plus-free"} opencodeZenAnthropicVisionModels = []string{"claude-sonnet-4-6", "claude-opus-4-8", "claude-haiku-4-5"} @@ -706,23 +705,9 @@ var curatedProviderPresets = []ProviderPreset{ }, }}, }, - { - ID: "opencode-go-anthropic", - Label: "OpenCode Go Anthropic", - Description: "OpenCode Go subscription Anthropic-compatible route for Qwen and MiniMax models.", - KeyEnv: "OPENCODE_GO_API_KEY", - Entries: []ProviderEntry{{ - Name: "opencode-go-anthropic", - Kind: "anthropic", - BaseURL: "https://opencode.ai/zen/go", - Models: opencodeGoAnthropicModels, - VisionModels: []string{"qwen3.7-plus", "qwen3.6-plus"}, - Default: "qwen3.7-plus", - APIKeyEnv: "OPENCODE_GO_API_KEY", - Thinking: "adaptive", - ContextWindow: 262144, - }}, - }, + opencodeGoAnthropicPreset, + opencodeGoDeepSeekAnthropicPreset, + opencodeGoDeepSeekResponsesPreset, { ID: "opencode-zen-anthropic", Label: "OpenCode Zen Anthropic", diff --git a/internal/config/provider_presets_opencode_go.go b/internal/config/provider_presets_opencode_go.go new file mode 100644 index 0000000000..2bbea60051 --- /dev/null +++ b/internal/config/provider_presets_opencode_go.go @@ -0,0 +1,59 @@ +package config + +var opencodeGoAnthropicPreset = ProviderPreset{ + ID: "opencode-go-anthropic", + Label: "OpenCode Go Anthropic", + Description: "OpenCode Go subscription Anthropic-compatible route for Qwen and MiniMax models.", + KeyEnv: "OPENCODE_GO_API_KEY", + Entries: []ProviderEntry{{ + Name: "opencode-go-anthropic", + Kind: "anthropic", + BaseURL: "https://opencode.ai/zen/go", + Models: []string{"qwen3.7-plus", "qwen3.7-max", "qwen3.6-plus", "minimax-m3", "minimax-m2.7", "minimax-m2.5"}, + VisionModels: []string{"qwen3.7-plus", "qwen3.6-plus"}, + Default: "qwen3.7-plus", + APIKeyEnv: "OPENCODE_GO_API_KEY", + Thinking: "adaptive", + ContextWindow: 262144, + }}, +} + +var opencodeGoDeepSeekAnthropicPreset = ProviderPreset{ + ID: "opencode-go-deepseek-anthropic", + Label: "OpenCode Go DeepSeek Anthropic", + Description: "OpenCode Go Anthropic Messages route for DeepSeek Flash with server-side web search.", + KeyEnv: "OPENCODE_GO_API_KEY", + Entries: []ProviderEntry{{ + Name: "opencode-go-deepseek-anthropic", + Kind: "anthropic", + BaseURL: "https://opencode.ai/zen/go", + Models: []string{"deepseek-v4-flash"}, + Default: "deepseek-v4-flash", + APIKeyEnv: "OPENCODE_GO_API_KEY", + Thinking: "adaptive", + WebSearch: boolPointer(true), + ContextWindow: 262144, + SupportedEfforts: []string{"disabled", "high", "max"}, + DefaultEffort: "high", + }}, +} + +var opencodeGoDeepSeekResponsesPreset = ProviderPreset{ + ID: "opencode-go-deepseek-responses", + Label: "OpenCode Go DeepSeek Responses", + Description: "OpenCode Go stateless Responses API route for DeepSeek Flash with server-side web search.", + KeyEnv: "OPENCODE_GO_API_KEY", + Entries: []ProviderEntry{{ + Name: "opencode-go-deepseek-responses", + Kind: "responses", + BaseURL: "https://opencode.ai/zen/go/v1", + Models: []string{"deepseek-v4-flash"}, + Default: "deepseek-v4-flash", + APIKeyEnv: "OPENCODE_GO_API_KEY", + ResponsesMode: "stateless", + WebSearch: boolPointer(true), + ContextWindow: 128000, + SupportedEfforts: []string{"disabled", "high", "max"}, + DefaultEffort: "high", + }}, +} diff --git a/internal/config/provider_presets_test.go b/internal/config/provider_presets_test.go index 2f5f6502e3..5e427054de 100644 --- a/internal/config/provider_presets_test.go +++ b/internal/config/provider_presets_test.go @@ -51,6 +51,8 @@ func TestCuratedProviderPresetsCoverRequestedProviders(t *testing.T) { "zai-coding-plan-global-anthropic", "opencode-go", "opencode-go-anthropic", + "opencode-go-deepseek-anthropic", + "opencode-go-deepseek-responses", "opencode-zen-anthropic", "qwen-cn", "qwen-global", @@ -97,6 +99,45 @@ func TestCuratedProviderPresetsCoverRequestedProviders(t *testing.T) { } } +func TestOpenCodeGoDeepSeekAlternativeProtocolPresets(t *testing.T) { + responsesPreset, ok := CuratedProviderPreset("opencode-go-deepseek-responses") + if !ok || len(responsesPreset.Entries) != 1 { + t.Fatalf("opencode-go-deepseek-responses preset = %+v, found=%v", responsesPreset, ok) + } + responses := responsesPreset.Entries[0] + if responses.Kind != "responses" || responses.BaseURL != "https://opencode.ai/zen/go/v1" || responses.ResponsesMode != "stateless" || responses.Default != "deepseek-v4-flash" { + t.Fatalf("opencode-go-deepseek-responses entry = %+v", responses) + } + if !responses.HasModel("deepseek-v4-flash") || responses.HasModel("deepseek-v4-pro") { + t.Fatalf("opencode-go-deepseek-responses models = %v, want currently verified Flash only", responses.ModelList()) + } + if !EffectiveWebSearch(&responses) || !HasServerWebSearchCapability(&responses) { + t.Fatalf("opencode-go-deepseek-responses web search = effective:%t capability:%t", EffectiveWebSearch(&responses), HasServerWebSearchCapability(&responses)) + } + if cap := EffortCapabilityForEntry(&responses); !cap.Supported || cap.Default != "high" || !containsString(cap.Levels, "disabled") || !containsString(cap.Levels, "max") { + t.Fatalf("opencode-go-deepseek-responses effort capability = %+v", cap) + } + + anthropicPreset, ok := CuratedProviderPreset("opencode-go-deepseek-anthropic") + if !ok || len(anthropicPreset.Entries) != 1 { + t.Fatalf("opencode-go-deepseek-anthropic preset = %+v, found=%v", anthropicPreset, ok) + } + var cfg Config + if err := cfg.UpsertProvider(anthropicPreset.Entries[0]); err != nil { + t.Fatalf("UpsertProvider(opencode-go-deepseek-anthropic): %v", err) + } + flash, ok := cfg.ResolveModel("opencode-go-deepseek-anthropic/deepseek-v4-flash") + if !ok { + t.Fatal("opencode-go-deepseek-anthropic/deepseek-v4-flash did not resolve") + } + if cfgEntry, _ := cfg.Provider("opencode-go-deepseek-anthropic"); cfgEntry.HasModel("deepseek-v4-pro") || !EffectiveWebSearch(cfgEntry) || !HasServerWebSearchCapability(cfgEntry) { + t.Fatalf("opencode-go-deepseek-anthropic entry = %+v, want Flash-only web search capability", cfgEntry) + } + if cap := EffortCapabilityForEntry(flash); !cap.Supported || cap.Default != "high" || !containsString(cap.Levels, "max") { + t.Fatalf("opencode-go-deepseek-anthropic Flash effort capability = %+v", cap) + } +} + func TestDeepSeekAnthropicPresetIsOptionalAndModelScoped(t *testing.T) { preset, ok := CuratedProviderPreset("deepseek-anthropic") if !ok || len(preset.Entries) != 1 { @@ -563,7 +604,7 @@ func TestCuratedProviderPresetCapabilities(t *testing.T) { if !ok { t.Fatal("opencode-go-anthropic provider missing") } - if goAnthropic.Kind != "anthropic" || goAnthropic.BaseURL != "https://opencode.ai/zen/go" || goAnthropic.DefaultModel() != "qwen3.7-plus" || !goAnthropic.HasModel("minimax-m3") { + if goAnthropic.Kind != "anthropic" || goAnthropic.BaseURL != "https://opencode.ai/zen/go" || goAnthropic.DefaultModel() != "qwen3.7-plus" || !goAnthropic.HasModel("minimax-m3") || goAnthropic.HasModel("deepseek-v4-flash") { t.Fatalf("opencode-go-anthropic capability mismatch: %+v", goAnthropic) } diff --git a/internal/config/web_search.go b/internal/config/web_search.go index c033c9b673..2b99fd1b1c 100644 --- a/internal/config/web_search.go +++ b/internal/config/web_search.go @@ -43,6 +43,41 @@ func IsOfficialDeepSeekWebSearchEndpoint(e *ProviderEntry) bool { } } +// IsOpenCodeGoDeepSeekWebSearchEndpoint matches the exact OpenCode Go routes +// and model set verified to execute provider-side search. The capability is +// model-scoped because the existing Anthropic preset also contains Qwen and +// MiniMax models whose server-tool behavior is independent and not implied by +// the shared endpoint. +func IsOpenCodeGoDeepSeekWebSearchEndpoint(e *ProviderEntry) bool { + if !SupportsServerWebSearch(e) { + return false + } + u, err := url.Parse(strings.TrimSpace(e.BaseURL)) + if err != nil || !strings.EqualFold(u.Scheme, "https") || !strings.EqualFold(u.Hostname(), "opencode.ai") || + u.Port() != "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return false + } + models := e.ModelList() + if len(models) != 1 || !strings.EqualFold(strings.TrimSpace(models[0]), "deepseek-v4-flash") { + return false + } + path := strings.TrimRight(u.EscapedPath(), "/") + switch strings.ToLower(strings.TrimSpace(e.Kind)) { + case "responses": + return path == "/zen/go/v1" + case "anthropic": + return path == "/zen/go" + default: + return false + } +} + +// HasServerWebSearchCapability reports backend-verified endpoint/model pairs +// that the Desktop can safely expose as a provider-side web-search toggle. +func HasServerWebSearchCapability(e *ProviderEntry) bool { + return IsOfficialDeepSeekWebSearchEndpoint(e) || IsOpenCodeGoDeepSeekWebSearchEndpoint(e) +} + // EffectiveWebSearch resolves the persisted tri-state. Official DeepSeek // Anthropic and Responses endpoints default on when old configuration omitted // web_search, while compatible third-party endpoints remain opt-in. An explicit diff --git a/internal/config/web_search_test.go b/internal/config/web_search_test.go index 24505de7bb..16ff0c697d 100644 --- a/internal/config/web_search_test.go +++ b/internal/config/web_search_test.go @@ -82,6 +82,53 @@ func TestEffectiveWebSearchDefaultsOnlySupportedOfficialDeepSeekAPIs(t *testing. } } +func TestOpenCodeGoDeepSeekWebSearchCapabilityIsModelScoped(t *testing.T) { + explicitTrue := true + tests := []struct { + name string + entry ProviderEntry + want bool + }{ + { + name: "responses flash", + entry: ProviderEntry{Kind: "responses", BaseURL: "https://opencode.ai/zen/go/v1", Models: []string{"deepseek-v4-flash"}, WebSearch: &explicitTrue}, + want: true, + }, + { + name: "anthropic flash trailing slash", + entry: ProviderEntry{Kind: "anthropic", BaseURL: "https://opencode.ai/zen/go/", Model: "deepseek-v4-flash", WebSearch: &explicitTrue}, + want: true, + }, + { + name: "mixed anthropic catalog remains unverified", + entry: ProviderEntry{Kind: "anthropic", BaseURL: "https://opencode.ai/zen/go", Models: []string{"qwen3.7-plus", "deepseek-v4-flash"}, WebSearch: &explicitTrue}, + want: false, + }, + { + name: "pro remains unsupported", + entry: ProviderEntry{Kind: "responses", BaseURL: "https://opencode.ai/zen/go/v1", Model: "deepseek-v4-pro", WebSearch: &explicitTrue}, + want: false, + }, + { + name: "wrong responses path", + entry: ProviderEntry{Kind: "responses", BaseURL: "https://opencode.ai/zen/go", Model: "deepseek-v4-flash", WebSearch: &explicitTrue}, + want: false, + }, + { + name: "lookalike host", + entry: ProviderEntry{Kind: "anthropic", BaseURL: "https://opencode.ai.attacker.example/zen/go", Model: "deepseek-v4-flash", WebSearch: &explicitTrue}, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := HasServerWebSearchCapability(&tt.entry); got != tt.want { + t.Fatalf("HasServerWebSearchCapability(%+v) = %t, want %t", tt.entry, got, tt.want) + } + }) + } +} + func TestWebSearchTOMLRoundTripPreservesExplicitOff(t *testing.T) { explicitFalse := false cfg := &Config{Providers: []ProviderEntry{{ diff --git a/internal/provider/anthropic/realdeepseek_test.go b/internal/provider/anthropic/realdeepseek_test.go index 0538b6ef45..2f46f43c78 100644 --- a/internal/provider/anthropic/realdeepseek_test.go +++ b/internal/provider/anthropic/realdeepseek_test.go @@ -13,6 +13,77 @@ import ( "reasonix/internal/provider" ) +// TestRealOpenCodeGoDeepSeekAnthropicWebSearch exercises Reasonix's complete +// Messages serialization and server-side web search parser against the OpenCode +// Go DeepSeek Flash route. The key stays process-local; ordinary CI never runs it. +func TestRealOpenCodeGoDeepSeekAnthropicWebSearch(t *testing.T) { + key := os.Getenv("OPENCODE_GO_API_KEY") + if key == "" { + t.Skip("OPENCODE_GO_API_KEY not set — skipping live probe") + } + + p, err := New(provider.Config{ + Name: "opencode-go-deepseek-anthropic", + BaseURL: "https://opencode.ai/zen/go", + Model: "deepseek-v4-flash", + APIKey: key, + Extra: map[string]any{ + "api_key_env": "OPENCODE_GO_API_KEY", + "thinking": "adaptive", + "effort": "high", + "web_search": true, + }, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + + turn := collectLiveDeepSeekTurn(t, p, provider.Request{Messages: []provider.Message{{ + Role: provider.RoleUser, Content: "Search the web for the OpenCode Go documentation and reply with one source URL.", + }}, MaxTokens: 768}) + if strings.TrimSpace(turn.text) == "" { + t.Fatalf("OpenCode Go Anthropic web_search returned no assistant text; reasoning_len=%d", len(turn.reasoning)) + } + t.Logf("opencode-go-deepseek-anthropic web_search: text=%d reasoning=%d prompt=%d", len(turn.text), len(turn.reasoning), turn.promptTokens) +} + +// TestRealOpenCodeGoDeepSeekAnthropicToolLoop verifies that the gateway accepts +// an assistant tool call and the corresponding tool result on the next request. +func TestRealOpenCodeGoDeepSeekAnthropicToolLoop(t *testing.T) { + key := os.Getenv("OPENCODE_GO_API_KEY") + if key == "" { + t.Skip("OPENCODE_GO_API_KEY not set — skipping live probe") + } + p, err := New(provider.Config{ + Name: "opencode-go-deepseek-anthropic", BaseURL: "https://opencode.ai/zen/go", Model: "deepseek-v4-flash", APIKey: key, + Extra: map[string]any{"api_key_env": "OPENCODE_GO_API_KEY", "thinking": "adaptive", "effort": "high"}, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + tools := []provider.ToolSchema{{ + Name: "get_marker", Description: "Return a fixed integration-test marker. Always call this tool when the user asks for the marker.", + Parameters: json.RawMessage(`{"type":"object","properties":{},"additionalProperties":false}`), + }} + messages := []provider.Message{ + {Role: provider.RoleSystem, Content: "You are a concise tool-using assistant. Call the requested tool before answering."}, + {Role: provider.RoleUser, Content: "Call get_marker, then report its result."}, + } + first := collectLiveDeepSeekTurn(t, p, provider.Request{Messages: messages, Tools: tools, MaxTokens: 512}) + if len(first.calls) == 0 { + t.Fatalf("OpenCode Go Anthropic returned no tool call; text_len=%d reasoning_len=%d", len(first.text), len(first.reasoning)) + } + messages = append(messages, + provider.Message{Role: provider.RoleAssistant, Content: first.text, ReasoningContent: first.reasoning, ReasoningSignature: first.signature, ToolCalls: first.calls}, + provider.Message{Role: provider.RoleTool, ToolCallID: first.calls[0].ID, Name: first.calls[0].Name, Content: "protocol-round-trip-ok"}, + ) + second := collectLiveDeepSeekTurn(t, p, provider.Request{Messages: messages, Tools: tools, MaxTokens: 512}) + if strings.TrimSpace(second.text) == "" { + t.Fatalf("OpenCode Go Anthropic tool follow-up returned no text; reasoning_len=%d calls=%d", len(second.reasoning), len(second.calls)) + } + t.Logf("opencode-go-deepseek-anthropic tool loop: calls=%d reasoning=%d signature=%d second_text=%d", len(first.calls), len(first.reasoning), len(first.signature), len(second.text)) +} + // TestRealDeepSeekAnthropicToolLoop exercises the official Messages endpoint's // unsigned thinking replay contract. It is build-tagged and credential-gated so // ordinary CI remains deterministic and free of live API cost. @@ -57,10 +128,11 @@ func TestRealDeepSeekAnthropicToolLoop(t *testing.T) { messages = append(messages, provider.Message{ - Role: provider.RoleAssistant, - Content: first.text, - ReasoningContent: first.reasoning, - ToolCalls: first.calls, + Role: provider.RoleAssistant, + Content: first.text, + ReasoningContent: first.reasoning, + ReasoningSignature: first.signature, + ToolCalls: first.calls, }, provider.Message{ Role: provider.RoleTool, @@ -152,7 +224,7 @@ func TestRealDeepSeekAnthropicIgnoresImages(t *testing.T) { } type liveDeepSeekTurn struct { - text, reasoning string + text, reasoning, signature string calls []provider.ToolCall promptTokens, cacheHitTokens int } @@ -173,6 +245,9 @@ func collectLiveDeepSeekTurn(t *testing.T, p provider.Provider, req provider.Req text.WriteString(chunk.Text) case provider.ChunkReasoning: reasoning.WriteString(chunk.Text) + if chunk.Signature != "" { + out.signature = chunk.Signature + } case provider.ChunkToolCall: if chunk.ToolCall != nil { out.calls = append(out.calls, *chunk.ToolCall) diff --git a/internal/provider/responses/realdeepseek_test.go b/internal/provider/responses/realdeepseek_test.go index b514087c38..669bf6cfc4 100644 --- a/internal/provider/responses/realdeepseek_test.go +++ b/internal/provider/responses/realdeepseek_test.go @@ -3,13 +3,86 @@ package responses import ( + "context" + "encoding/json" "os" "strings" "testing" + "time" "reasonix/internal/provider" ) +// TestRealOpenCodeGoDeepSeekResponsesWebSearch exercises Reasonix's stateless +// Responses request, server-side web search, and replay-item capture against the +// OpenCode Go DeepSeek Flash route. +func TestRealOpenCodeGoDeepSeekResponsesWebSearch(t *testing.T) { + key := os.Getenv("OPENCODE_GO_API_KEY") + if key == "" { + t.Skip("OPENCODE_GO_API_KEY not set — skipping live probe") + } + + p := New(Config{ + Name: "opencode-go-deepseek-responses", + BaseURL: "https://opencode.ai/zen/go/v1", + Model: "deepseek-v4-flash", + APIKey: key, + KeyEnv: "OPENCODE_GO_API_KEY", + Effort: "high", + Mode: "stateless", + WebSearch: true, + MaxOutputTokens: 768, + }) + messages := []provider.Message{{ + Role: provider.RoleUser, Content: "Search the web for the OpenCode Go documentation and reply with one source URL.", + }} + first := collectLiveOpenCodeGoResponsesTurn(t, p, provider.Request{Messages: messages, MaxTokens: 768}) + if strings.TrimSpace(first.text) == "" { + t.Fatal("OpenCode Go Responses web_search returned no assistant text") + } + if len(first.items) == 0 { + t.Fatal("OpenCode Go Responses returned no completed web_search_call replay item") + } + messages = append(messages, + provider.Message{Role: provider.RoleAssistant, Content: first.text, ResponsesItems: first.items}, + provider.Message{Role: provider.RoleUser, Content: "Using the previous search result, reply with only the source domain."}, + ) + second := collectLiveOpenCodeGoResponsesTurn(t, p, provider.Request{Messages: messages, MaxTokens: 256}) + if strings.TrimSpace(second.text) == "" { + t.Fatal("OpenCode Go Responses stateless replay returned no follow-up text") + } + t.Logf("opencode-go-deepseek-responses: first_text=%d replay_items=%d second_text=%d", len(first.text), len(first.items), len(second.text)) +} + +type liveOpenCodeGoResponsesTurn struct { + text string + items []json.RawMessage +} + +func collectLiveOpenCodeGoResponsesTurn(t *testing.T, p provider.Provider, req provider.Request) liveOpenCodeGoResponsesTurn { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + stream, err := p.Stream(ctx, req) + if err != nil { + t.Fatalf("Stream: %v", err) + } + var out liveOpenCodeGoResponsesTurn + var text strings.Builder + for chunk := range stream { + switch chunk.Type { + case provider.ChunkText: + text.WriteString(chunk.Text) + case provider.ChunkResponsesItem: + out.items = append(out.items, append(json.RawMessage(nil), chunk.ResponsesItem...)) + case provider.ChunkError: + t.Fatalf("stream error: %v", chunk.Err) + } + } + out.text = text.String() + return out +} + // TestRealDeepSeekResponsesWebSearch exercises the official stateless // Responses endpoint with its provider-executed web_search tool. It is // credential-gated and build-tagged so ordinary CI remains deterministic. diff --git a/internal/provider/responses/responses.go b/internal/provider/responses/responses.go index 745cdfc76b..cb3903e5fc 100644 --- a/internal/provider/responses/responses.go +++ b/internal/provider/responses/responses.go @@ -348,7 +348,7 @@ func (c *client) buildRequestBody(req provider.Request) (map[string]any, bool, [ return body, true, messages } - body["input"] = messagesToInput(rest, c.vision, c.vendor == "deepseek", c.caps.summaryRequired) + body["input"] = messagesToInput(rest, c.vision, c.webSearch, c.caps.summaryRequired) return body, false, messages } @@ -359,7 +359,7 @@ func splitInstructions(messages []provider.Message) (string, []provider.Message) return messages[0].Content, messages[1:] } -func messagesToInput(messages []provider.Message, vision, replayDeepSeekItems, summary bool) []map[string]any { +func messagesToInput(messages []provider.Message, vision, replayWebSearchItems, summary bool) []map[string]any { input := make([]map[string]any, 0, len(messages)*2) for _, message := range messages { switch message.Role { @@ -409,7 +409,7 @@ func messagesToInput(messages []provider.Message, vision, replayDeepSeekItems, s } input = append(input, item) } - if replayDeepSeekItems { + if replayWebSearchItems { for _, raw := range message.ResponsesItems { if item, ok := decodeReplayableWebSearchItem(raw); ok { input = append(input, item) @@ -459,7 +459,7 @@ func (c *client) conversationDigest(messages []provider.Message) string { payload, _ := json.Marshal(struct { Instructions string `json:"instructions,omitempty"` Input []map[string]any `json:"input"` - }{Instructions: instructions, Input: messagesToInput(rest, c.vision, c.vendor == "deepseek", c.caps.summaryRequired)}) + }{Instructions: instructions, Input: messagesToInput(rest, c.vision, c.webSearch, c.caps.summaryRequired)}) sum := sha256.Sum256(payload) return hex.EncodeToString(sum[:]) } @@ -618,7 +618,7 @@ func (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- } } case "response.output_item.done": - if event.Item != nil && event.Item.Type == "web_search_call" && c.vendor == "deepseek" { + if event.Item != nil && event.Item.Type == "web_search_call" && c.webSearch { if _, ok := decodeReplayableWebSearchItem(event.Item.Raw); ok { key := event.Item.ID if key == "" { diff --git a/internal/provider/responses/responses_test.go b/internal/provider/responses/responses_test.go index bf22a39233..d45bee7da6 100644 --- a/internal/provider/responses/responses_test.go +++ b/internal/provider/responses/responses_test.go @@ -302,7 +302,7 @@ func TestStreamToleratesWebSearchLifecycleEvents(t *testing.T) { } } -func TestDeepSeekStatelessReplayPreservesCompletedWebSearchCall(t *testing.T) { +func TestEnabledStatelessWebSearchPreservesCompletedCallForCompatibleGateway(t *testing.T) { var bodies []map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body map[string]any @@ -323,10 +323,7 @@ func TestDeepSeekStatelessReplayPreservesCompletedWebSearchCall(t *testing.T) { })) defer server.Close() - client := New(Config{Name: "deepseek", APIKey: "key", BaseURL: server.URL, Model: "deepseek-v4-flash", Mode: "stateless", WebSearch: true}).(*client) - // The test server is local, so pin the vendor classification to the official - // DeepSeek behavior under test without weakening production URL detection. - client.vendor = "deepseek" + client := New(Config{Name: "compatible", APIKey: "key", BaseURL: server.URL, Model: "deepseek-v4-flash", Mode: "stateless", WebSearch: true}).(*client) first := collect(t, client, provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "search"}}}) var replayItems []json.RawMessage for _, chunk := range first { @@ -360,7 +357,7 @@ func TestDeepSeekStatelessReplayPreservesCompletedWebSearchCall(t *testing.T) { } } -func TestResponsesItemsAreIgnoredOutsideOfficialDeepSeekWire(t *testing.T) { +func TestResponsesItemsAreIgnoredWhenServerWebSearchIsDisabled(t *testing.T) { raw := json.RawMessage(`{"id":"ws_1","type":"web_search_call","status":"completed"}`) client := New(Config{Name: "compatible", BaseURL: "https://gateway.example", Model: "m", Mode: "stateless"}).(*client) body, _, _ := client.buildRequestBody(provider.Request{Messages: []provider.Message{ @@ -383,7 +380,7 @@ func TestDeepSeekReplayDropsMalformedOrIncompleteSearchItems(t *testing.T) { json.RawMessage(`{"id":"fc_1","type":"function_call","status":"completed"}`), json.RawMessage(`{"id":`), } - client := New(Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", Mode: "stateless"}).(*client) + client := New(Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", Mode: "stateless", WebSearch: true}).(*client) body, _, _ := client.buildRequestBody(provider.Request{Messages: []provider.Message{ {Role: provider.RoleUser, Content: "search"}, {Role: provider.RoleAssistant, Content: "answer", ResponsesItems: items}, diff --git a/internal/provider/responses/web_search_replay_test.go b/internal/provider/responses/web_search_replay_test.go new file mode 100644 index 0000000000..e185babe51 --- /dev/null +++ b/internal/provider/responses/web_search_replay_test.go @@ -0,0 +1,22 @@ +package responses + +import ( + "encoding/json" + "testing" + + "reasonix/internal/provider" +) + +func TestConversationDigestIncludesEnabledWebSearchReplayItems(t *testing.T) { + messages := []provider.Message{ + {Role: provider.RoleUser, Content: "search"}, + {Role: provider.RoleAssistant, Content: "found", ResponsesItems: []json.RawMessage{ + json.RawMessage(`{"id":"ws_1","type":"web_search_call","status":"completed"}`), + }}, + } + searchOff := New(Config{Name: "compatible", BaseURL: "https://gateway.example", Model: "m", Mode: "stateful"}).(*client) + searchOn := New(Config{Name: "compatible", BaseURL: "https://gateway.example", Model: "m", Mode: "stateful", WebSearch: true}).(*client) + if searchOff.conversationDigest(messages) == searchOn.conversationDigest(messages) { + t.Fatal("web search replay items must change the digest (wire sends completed search calls)") + } +}