Release v1.4.7 with unlimited default timeout

This commit is contained in:
lutc5
2026-05-06 16:29:35 +08:00
parent fe1d5b5348
commit 22f793c188
13 changed files with 73 additions and 25 deletions

View File

@@ -2,10 +2,16 @@
## Unreleased ## Unreleased
## v1.4.7 - 2026-05-06
- Renamed user-facing product, desktop app, release assets, and documentation from Lingma IPC Proxy to Lingma Proxy. - Renamed user-facing product, desktop app, release assets, and documentation from Lingma IPC Proxy to Lingma Proxy.
- Clarified that Remote API mode is the recommended default and that only IPC plugin mode is based on the `coolxll/lingma-ipc-proxy` protocol discovery. - Clarified that Remote API mode is the recommended default and that only IPC plugin mode is based on the `coolxll/lingma-ipc-proxy` protocol discovery.
- Added `lingma-proxy.json` and `~/.config/lingma-proxy/config.json` config lookup/write paths while keeping legacy `lingma-ipc-proxy` config fallback. - Added `lingma-proxy.json` and `~/.config/lingma-proxy/config.json` config lookup/write paths while keeping legacy `lingma-ipc-proxy` config fallback.
- Added a desktop top-bar force quit button that stops the proxy and exits the app on macOS and Windows. - Added a desktop top-bar force quit button that stops the proxy and exits the app on macOS and Windows.
- Added Anthropic `/v1/messages/count_tokens` compatibility for Claude Code v2.1.129+.
- Reduced prompt-emulated tool loops by allowing final answers after tool results and dropping tool calls with missing required arguments.
- Prevented hosted Anthropic `web_search` from being short-circuited again after a `tool_result` follow-up.
- Changed the default proxy request timeout to `0`, meaning no proxy-level per-request deadline. Positive timeout values still enable timeout-triggered remote fallback.
## v1.4.6 - 2026-05-06 ## v1.4.6 - 2026-05-06

View File

@@ -13,7 +13,7 @@ The proxy now supports two backend modes:
## Current Version ## Current Version
The current desktop line is `v1.4.6`. The current desktop line is `v1.4.7`.
See [CHANGELOG.md](./CHANGELOG.md) for release history. See [CHANGELOG.md](./CHANGELOG.md) for release history.
@@ -327,7 +327,7 @@ The proxy only reports models actually exposed by your Lingma plugin. The table
Default model when the client omits `model`: `kmodel` (`Kimi-K2.6` in the remote model list). Default model when the client omits `model`: `kmodel` (`Kimi-K2.6` in the remote model list).
Remote mode enables timeout fallback by default. On timeout, upstream 5xx/429, or network interruption, the proxy only switches models if no streaming bytes have been sent to the client yet. Fallback candidates are filtered against the actual `/v1/models` response, so unavailable models are skipped. Default order: Remote mode enables fallback by default. The default proxy request timeout is `0`, which means Lingma Proxy does not set its own per-request deadline and is suitable for long agent workflows. If you set `"timeout"` to a positive number of seconds, timeout errors can also trigger fallback. Upstream 5xx/429 or network interruption can trigger fallback regardless of the timeout setting, but the proxy only switches models if no streaming bytes have been sent to the client yet. Fallback candidates are filtered against the actual `/v1/models` response, so unavailable models are skipped. Default order:
`Kimi-K2.6 -> MiniMax-M2.7 -> Qwen3-Coder -> Qwen3.6-Plus -> Qwen3-Max -> Qwen3-Thinking` `Kimi-K2.6 -> MiniMax-M2.7 -> Qwen3-Coder -> Qwen3.6-Plus -> Qwen3-Max -> Qwen3-Thinking`
@@ -354,7 +354,7 @@ Example:
"mode": "agent", "mode": "agent",
"shell_type": "zsh", "shell_type": "zsh",
"session_mode": "auto", "session_mode": "auto",
"timeout": 300, "timeout": 0,
"remote_fallback_enabled": true, "remote_fallback_enabled": true,
"remote_fallback_models": [ "remote_fallback_models": [
"kmodel", "kmodel",

View File

@@ -16,7 +16,7 @@
## 当前版本 ## 当前版本
当前桌面端版本线:`v1.4.6` 当前桌面端版本线:`v1.4.7`
版本更新记录见 [CHANGELOG.md](./CHANGELOG.md)。 版本更新记录见 [CHANGELOG.md](./CHANGELOG.md)。
@@ -409,7 +409,7 @@ export ANTHROPIC_API_KEY="any"
当客户端请求没有携带 `model` 字段时,代理默认使用:`kmodel`(远端模型列表里的 Kimi-K2.6)。 当客户端请求没有携带 `model` 字段时,代理默认使用:`kmodel`(远端模型列表里的 Kimi-K2.6)。
远端模式默认开启超时兜底。遇到请求超时、上游 5xx/429 或网络中断时,代理只会在尚未向客户端输出任何流式内容的情况下切换模型。兜底候选会先和实际 `/v1/models` 返回结果求交集,不存在或当前账号不可用的模型会自动跳过。默认顺序: 远端模式默认开启兜底。代理默认请求超时为 `0`,表示 Lingma Proxy 不设置自己的单次请求 deadline适合长流程 Agent 任务。如果你把 `"timeout"` 设置为正数秒,超时错误也会触发兜底。上游 5xx/429 或网络中断不受超时设置影响,仍可触发兜底;但代理只会在尚未向客户端输出任何流式内容的情况下切换模型。兜底候选会先和实际 `/v1/models` 返回结果求交集,不存在或当前账号不可用的模型会自动跳过。默认顺序:
`Kimi-K2.6 -> MiniMax-M2.7 -> Qwen3-Coder -> Qwen3.6-Plus -> Qwen3-Max -> Qwen3-Thinking` `Kimi-K2.6 -> MiniMax-M2.7 -> Qwen3-Coder -> Qwen3.6-Plus -> Qwen3-Max -> Qwen3-Thinking`
@@ -436,7 +436,7 @@ export ANTHROPIC_API_KEY="any"
"mode": "agent", "mode": "agent",
"shell_type": "zsh", "shell_type": "zsh",
"session_mode": "auto", "session_mode": "auto",
"timeout": 300, "timeout": 0,
"remote_fallback_enabled": true, "remote_fallback_enabled": true,
"remote_fallback_models": [ "remote_fallback_models": [
"kmodel", "kmodel",

View File

@@ -100,7 +100,7 @@ func loadConfig() (service.Config, string) {
Model: "kmodel", Model: "kmodel",
ShellType: defaultShellType(), ShellType: defaultShellType(),
SessionMode: service.SessionModeAuto, SessionMode: service.SessionModeAuto,
Timeout: 300 * time.Second, Timeout: 0,
RemoteFallbackEnabled: true, RemoteFallbackEnabled: true,
RemoteFallbackModels: service.DefaultRemoteFallbackModels(), RemoteFallbackModels: service.DefaultRemoteFallbackModels(),
} }
@@ -130,7 +130,7 @@ func loadConfig() (service.Config, string) {
mode := flag.String("mode", cfg.Mode, "Lingma ACP mode value") mode := flag.String("mode", cfg.Mode, "Lingma ACP mode value")
model := flag.String("model", cfg.Model, "Default Lingma model when API request omits model") model := flag.String("model", cfg.Model, "Default Lingma model when API request omits model")
shellType := flag.String("shell-type", cfg.ShellType, "Shell type sent through ACP meta") shellType := flag.String("shell-type", cfg.ShellType, "Shell type sent through ACP meta")
timeoutSeconds := flag.Int("timeout", int(cfg.Timeout/time.Second), "Per-request timeout in seconds") timeoutSeconds := flag.Int("timeout", int(cfg.Timeout/time.Second), "Per-request timeout in seconds; 0 disables the proxy deadline")
remoteFallbackEnabled := flag.Bool("remote-fallback", cfg.RemoteFallbackEnabled, "Enable remote timeout/5xx fallback to the next available model") remoteFallbackEnabled := flag.Bool("remote-fallback", cfg.RemoteFallbackEnabled, "Enable remote timeout/5xx fallback to the next available model")
remoteFallbackModels := flag.String("remote-fallback-models", strings.Join(cfg.RemoteFallbackModels, ","), "Comma-separated remote fallback model IDs") remoteFallbackModels := flag.String("remote-fallback-models", strings.Join(cfg.RemoteFallbackModels, ","), "Comma-separated remote fallback model IDs")
sessionMode := flag.String("session-mode", string(cfg.SessionMode), "Session mode: auto, fresh, reuse") sessionMode := flag.String("session-mode", string(cfg.SessionMode), "Session mode: auto, fresh, reuse")
@@ -243,7 +243,7 @@ func overlayFileConfig(dst *service.Config, src fileConfig) {
if strings.TrimSpace(src.SessionMode) != "" { if strings.TrimSpace(src.SessionMode) != "" {
dst.SessionMode = parseSessionMode(src.SessionMode) dst.SessionMode = parseSessionMode(src.SessionMode)
} }
if src.TimeoutSeconds > 0 { if src.TimeoutSeconds >= 0 {
dst.Timeout = time.Duration(src.TimeoutSeconds) * time.Second dst.Timeout = time.Duration(src.TimeoutSeconds) * time.Second
} }
if src.RemoteFallbackEnabled != nil { if src.RemoteFallbackEnabled != nil {
@@ -300,7 +300,7 @@ func overlayEnvConfig(dst *service.Config) {
if value := strings.TrimSpace(os.Getenv("LINGMA_PROXY_SESSION_MODE")); value != "" { if value := strings.TrimSpace(os.Getenv("LINGMA_PROXY_SESSION_MODE")); value != "" {
dst.SessionMode = parseSessionMode(value) dst.SessionMode = parseSessionMode(value)
} }
if value := envInt("LINGMA_PROXY_TIMEOUT_SECONDS", 0); value > 0 { if value := envInt("LINGMA_PROXY_TIMEOUT_SECONDS", -1); value >= 0 {
dst.Timeout = time.Duration(value) * time.Second dst.Timeout = time.Duration(value) * time.Second
} }
if value, ok := envBool("LINGMA_REMOTE_FALLBACK_ENABLED"); ok { if value, ok := envBool("LINGMA_REMOTE_FALLBACK_ENABLED"); ok {

View File

@@ -922,7 +922,7 @@ func defaultConfig() service.Config {
Model: "kmodel", Model: "kmodel",
ShellType: defaultShellType(), ShellType: defaultShellType(),
SessionMode: service.SessionModeAuto, SessionMode: service.SessionModeAuto,
Timeout: 300 * time.Second, Timeout: 0,
RemoteFallbackEnabled: true, RemoteFallbackEnabled: true,
RemoteFallbackModels: service.DefaultRemoteFallbackModels(), RemoteFallbackModels: service.DefaultRemoteFallbackModels(),
} }
@@ -1000,7 +1000,7 @@ func defaultConfig() service.Config {
if fileCfg.SessionMode != "" { if fileCfg.SessionMode != "" {
cfg.SessionMode = service.SessionMode(fileCfg.SessionMode) cfg.SessionMode = service.SessionMode(fileCfg.SessionMode)
} }
if fileCfg.TimeoutSeconds > 0 { if fileCfg.TimeoutSeconds >= 0 {
cfg.Timeout = time.Duration(fileCfg.TimeoutSeconds) * time.Second cfg.Timeout = time.Duration(fileCfg.TimeoutSeconds) * time.Second
} }
if fileCfg.RemoteFallbackEnabled != nil { if fileCfg.RemoteFallbackEnabled != nil {

View File

@@ -252,7 +252,7 @@ onUnmounted(() => {
<span class="status-dot" :class="{ running: status.running }"></span> <span class="status-dot" :class="{ running: status.running }"></span>
<div> <div>
<strong>{{ status.running ? 'Proxy Running' : 'Proxy Stopped' }}</strong> <strong>{{ status.running ? 'Proxy Running' : 'Proxy Stopped' }}</strong>
<small>v1.4.6</small> <small>v1.4.7</small>
</div> </div>
</div> </div>
</aside> </aside>

View File

@@ -318,7 +318,7 @@ onUnmounted(() => {
</div> </div>
<div class="config-summary-item"> <div class="config-summary-item">
<label>超时</label> <label>超时</label>
<strong>{{ config.Timeout || 120 }} </strong> <strong>{{ config.Timeout > 0 ? `${config.Timeout}` : '不限制' }}</strong>
</div> </div>
<div class="config-summary-item span-2"> <div class="config-summary-item span-2">
<label>工作目录</label> <label>工作目录</label>

View File

@@ -163,12 +163,13 @@ async function save() {
</div> </div>
<div class="field"> <div class="field">
<label>超时秒数</label> <label>超时秒数</label>
<input v-model.number="config.Timeout" type="number" min="1" /> <input v-model.number="config.Timeout" type="number" min="0" />
<small>0 表示不设置代理层单次请求超时适合长流程任务</small>
</div> </div>
<div class="field span-2 switch-field"> <div class="field span-2 switch-field">
<div> <div>
<label>远端超时兜底</label> <label>远端超时兜底</label>
<p>远端 API 超时限流或 5xx 且尚未流式输出时自动切换到下一个可用模型</p> <p>设置正数超时后远端 API 超时限流或 5xx 且尚未流式输出时自动切换到下一个可用模型</p>
</div> </div>
<label class="switch"> <label class="switch">
<input v-model="config.RemoteFallbackEnabled" type="checkbox" /> <input v-model="config.RemoteFallbackEnabled" type="checkbox" />

View File

@@ -11,6 +11,6 @@
"email": "lutc5@asiainfo.com" "email": "lutc5@asiainfo.com"
}, },
"info": { "info": {
"productVersion": "1.4.6" "productVersion": "1.4.7"
} }
} }

View File

@@ -75,9 +75,6 @@ func New(cfg Config) *Client {
if cfg.CosyVersion == "" { if cfg.CosyVersion == "" {
cfg.CosyVersion = "2.11.2" cfg.CosyVersion = "2.11.2"
} }
if cfg.Timeout <= 0 {
cfg.Timeout = 300 * time.Second
}
cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/") cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/")
return &Client{cfg: cfg, client: &http.Client{Timeout: cfg.Timeout}} return &Client{cfg: cfg, client: &http.Client{Timeout: cfg.Timeout}}
} }

View File

@@ -4,8 +4,23 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
) )
func TestNewKeepsZeroTimeoutUnlimited(t *testing.T) {
client := New(Config{Timeout: 0})
if client.client.Timeout != 0 {
t.Fatalf("timeout = %v, want 0", client.client.Timeout)
}
}
func TestNewKeepsPositiveTimeout(t *testing.T) {
client := New(Config{Timeout: 7 * time.Second})
if client.client.Timeout != 7*time.Second {
t.Fatalf("timeout = %v, want 7s", client.client.Timeout)
}
}
func TestExtractBaseURLFromEndpointLog(t *testing.T) { func TestExtractBaseURLFromEndpointLog(t *testing.T) {
got := extractBaseURLFromText(`2026-04-10 INFO Update endpoint success. endpoint config: https://ai-lingma-cmb01-cn-beijing.rdc.aliyuncs.com`) got := extractBaseURLFromText(`2026-04-10 INFO Update endpoint success. endpoint config: https://ai-lingma-cmb01-cn-beijing.rdc.aliyuncs.com`)
want := "https://ai-lingma-cmb01-cn-beijing.rdc.aliyuncs.com" want := "https://ai-lingma-cmb01-cn-beijing.rdc.aliyuncs.com"

View File

@@ -167,9 +167,6 @@ func New(cfg Config) *Service {
if strings.TrimSpace(cfg.ShellType) == "" { if strings.TrimSpace(cfg.ShellType) == "" {
cfg.ShellType = lingmaipc.DefaultShellType() cfg.ShellType = lingmaipc.DefaultShellType()
} }
if cfg.Timeout <= 0 {
cfg.Timeout = 300 * time.Second
}
if cfg.Transport == "" { if cfg.Transport == "" {
cfg.Transport = lingmaipc.TransportAuto cfg.Transport = lingmaipc.TransportAuto
} }
@@ -225,6 +222,13 @@ func (s *Service) Close() error {
return s.closeClientLocked() return s.closeClientLocked()
} }
func contextWithOptionalTimeout(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
if timeout <= 0 {
return context.WithCancel(parent)
}
return context.WithTimeout(parent, timeout)
}
func (s *Service) State() State { func (s *Service) State() State {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -365,7 +369,7 @@ func (s *Service) generateRemote(
client := s.remoteClientLocked() client := s.remoteClientLocked()
var lastErr error var lastErr error
for i, model := range models { for i, model := range models {
attemptCtx, cancel := context.WithTimeout(ctx, s.cfg.Timeout) attemptCtx, cancel := contextWithOptionalTimeout(ctx, s.cfg.Timeout)
result, emitted, err := s.generateRemoteWithModel(attemptCtx, client, req, prompt, model, onDelta) result, emitted, err := s.generateRemoteWithModel(attemptCtx, client, req, prompt, model, onDelta)
cancel() cancel()
if err == nil { if err == nil {
@@ -513,7 +517,7 @@ func (s *Service) generateLocked(
req ChatRequest, req ChatRequest,
onDelta func(string), onDelta func(string),
) (result *ChatResult, err error) { ) (result *ChatResult, err error) {
requestCtx, cancel := context.WithTimeout(ctx, s.cfg.Timeout) requestCtx, cancel := contextWithOptionalTimeout(ctx, s.cfg.Timeout)
defer cancel() defer cancel()
ipcClient, err := s.ensureConnected(requestCtx) ipcClient, err := s.ensureConnected(requestCtx)

View File

@@ -1,8 +1,10 @@
package service package service
import ( import (
"context"
"errors" "errors"
"testing" "testing"
"time"
) )
func TestIsRecoverableIPCError(t *testing.T) { func TestIsRecoverableIPCError(t *testing.T) {
@@ -23,3 +25,26 @@ func TestIsRecoverableIPCErrorIgnoresModelErrors(t *testing.T) {
t.Fatal("timeout should not be treated as an immediate reconnect retry") t.Fatal("timeout should not be treated as an immediate reconnect retry")
} }
} }
func TestNewKeepsZeroTimeoutUnlimited(t *testing.T) {
svc := New(Config{Timeout: 0})
if svc.cfg.Timeout != 0 {
t.Fatalf("timeout = %v, want 0", svc.cfg.Timeout)
}
}
func TestContextWithOptionalTimeoutZeroDoesNotSetDeadline(t *testing.T) {
ctx, cancel := contextWithOptionalTimeout(context.Background(), 0)
defer cancel()
if _, ok := ctx.Deadline(); ok {
t.Fatal("zero timeout should not set a deadline")
}
}
func TestContextWithOptionalTimeoutPositiveSetsDeadline(t *testing.T) {
ctx, cancel := contextWithOptionalTimeout(context.Background(), time.Second)
defer cancel()
if _, ok := ctx.Deadline(); !ok {
t.Fatal("positive timeout should set a deadline")
}
}