From 55f4ba11956a94dc83eb4d51b05df3e0497226bf Mon Sep 17 00:00:00 2001
From: matevip
Date: Sat, 2 May 2026 15:40:59 +0800
Subject: [PATCH] feat(llm): per-Model HTTP read-timeout override
---
.../vip/mate/agent/AgentGraphBuilder.java | 38 ++++++++++++---
.../AgentAnthropicChatModelBuilder.java | 39 ++++++++++++---
.../AgentClaudeCodeChatModelBuilder.java | 16 +++++--
...AgentOpenAiCompatibleChatModelBuilder.java | 5 +-
.../vip/mate/llm/chatmodel/HttpTimeouts.java | 48 +++++++++++++++++++
.../vip/mate/llm/model/ModelConfigEntity.java | 15 ++++++
.../h2/V75__model_config_request_timeout.sql | 7 +++
.../V75__model_config_request_timeout.sql | 15 ++++++
8 files changed, 166 insertions(+), 17 deletions(-)
create mode 100644 mateclaw-server/src/main/java/vip/mate/llm/chatmodel/HttpTimeouts.java
create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V75__model_config_request_timeout.sql
create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V75__model_config_request_timeout.sql
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
index eefb47b1..1e8e4a09 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
@@ -1095,6 +1095,16 @@ public class AgentGraphBuilder {
/** Transitional public visibility for {@code chatmodel} sub-package builders; will move into the builder in PR-0b. */
public OpenAiApi buildOpenAiApi(ModelProviderEntity provider) {
+ return buildOpenAiApi(provider, null);
+ }
+
+ /**
+ * RFC-03 Lane B1 overload — accepts a per-model read-timeout override
+ * (seconds). Threaded into both the sync RestClient and streaming
+ * WebClient so timeout behavior is consistent across blocking and
+ * streaming chat completions. Null falls back to the default 180s.
+ */
+ public OpenAiApi buildOpenAiApi(ModelProviderEntity provider, Integer readTimeoutOverride) {
if (provider == null || !modelProviderService.isProviderConfigured(provider.getProviderId())) {
throw new MateClawException("err.agent.provider_not_configured", "Provider 未完成配置,请在模型设置中填写有效的 API Key 和 Base URL");
}
@@ -1116,9 +1126,9 @@ public class AgentGraphBuilder {
MultiValueMap headers = buildOpenAiHeaders(kwargs);
String completionsPath = resolveOpenAiCompletionsPath(baseUrl, kwargs);
RestClient.Builder restClientBuilder = applyHttpTimeouts(
- restClientBuilderProvider.getIfAvailable(RestClient::builder));
+ restClientBuilderProvider.getIfAvailable(RestClient::builder), readTimeoutOverride);
WebClient.Builder webClientBuilder = applyHttpTimeoutsToWebClient(
- webClientBuilderProvider.getIfAvailable(WebClient::builder));
+ webClientBuilderProvider.getIfAvailable(WebClient::builder), readTimeoutOverride);
// Spring AI OpenAiApi 构造函数会先 set User-Agent 为 "spring-ai",再 addAll 我们的 headers,
// 导致自定义 User-Agent 被追加而非覆盖。因此对需要伪装客户端身份的 provider(如 kimi-code),
@@ -1475,11 +1485,19 @@ public class AgentGraphBuilder {
* readTimeout=180s(覆盖 nginx 60s 网关超时 + 留足真实长响应余量;超时后由上层 retry 接管)。
*/
private RestClient.Builder applyHttpTimeouts(RestClient.Builder builder) {
+ return applyHttpTimeouts(builder, null);
+ }
+
+ /**
+ * RFC-03 Lane B1 overload — accepts a per-model read-timeout override
+ * (seconds). Null falls back to the default 180s.
+ */
+ private RestClient.Builder applyHttpTimeouts(RestClient.Builder builder, Integer readTimeoutOverride) {
HttpClient httpClient = HttpClient.newBuilder()
- .connectTimeout(Duration.ofSeconds(10))
+ .connectTimeout(vip.mate.llm.chatmodel.HttpTimeouts.CONNECT_TIMEOUT)
.build();
JdkClientHttpRequestFactory rf = new JdkClientHttpRequestFactory(httpClient);
- rf.setReadTimeout(Duration.ofSeconds(180));
+ rf.setReadTimeout(vip.mate.llm.chatmodel.HttpTimeouts.resolveReadTimeout(readTimeoutOverride));
return builder.requestFactory(rf);
}
@@ -1499,12 +1517,20 @@ public class AgentGraphBuilder {
* starter is excluded by design).
*/
private WebClient.Builder applyHttpTimeoutsToWebClient(WebClient.Builder builder) {
+ return applyHttpTimeoutsToWebClient(builder, null);
+ }
+
+ /**
+ * RFC-03 Lane B1 overload — same per-model override semantics as
+ * {@link #applyHttpTimeouts(RestClient.Builder, Integer)}.
+ */
+ private WebClient.Builder applyHttpTimeoutsToWebClient(WebClient.Builder builder, Integer readTimeoutOverride) {
HttpClient httpClient = HttpClient.newBuilder()
- .connectTimeout(Duration.ofSeconds(10))
+ .connectTimeout(vip.mate.llm.chatmodel.HttpTimeouts.CONNECT_TIMEOUT)
.build();
org.springframework.http.client.reactive.JdkClientHttpConnector connector =
new org.springframework.http.client.reactive.JdkClientHttpConnector(httpClient);
- connector.setReadTimeout(Duration.ofSeconds(180));
+ connector.setReadTimeout(vip.mate.llm.chatmodel.HttpTimeouts.resolveReadTimeout(readTimeoutOverride));
return builder.clientConnector(connector);
}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentAnthropicChatModelBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentAnthropicChatModelBuilder.java
index 10c53de2..e91756f4 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentAnthropicChatModelBuilder.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentAnthropicChatModelBuilder.java
@@ -63,7 +63,7 @@ public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
@Override
public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
- AnthropicApi api = buildAnthropicApi(provider);
+ AnthropicApi api = buildAnthropicApi(provider, model.getRequestTimeoutSeconds());
AnthropicChatOptions options = buildAnthropicOptions(model);
return AnthropicChatModel.builder()
.anthropicApi(api)
@@ -74,6 +74,14 @@ public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
}
AnthropicApi buildAnthropicApi(ModelProviderEntity provider) {
+ return buildAnthropicApi(provider, null);
+ }
+
+ /**
+ * RFC-03 Lane B1 overload — accepts a per-model read-timeout override
+ * (seconds). Null falls back to the default 180s.
+ */
+ AnthropicApi buildAnthropicApi(ModelProviderEntity provider, Integer readTimeoutOverride) {
if (provider == null || !modelProviderService.isProviderConfigured(provider.getProviderId())) {
throw new MateClawException("err.agent.anthropic_not_configured",
"Anthropic Provider 未完成配置,请在模型设置中填写有效的 API Key 和 Base URL");
@@ -85,9 +93,9 @@ public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
}
String baseUrl = provider.getBaseUrl();
RestClient.Builder restClientBuilder = applyHttpTimeouts(
- restClientBuilderProvider.getIfAvailable(RestClient::builder));
+ restClientBuilderProvider.getIfAvailable(RestClient::builder), readTimeoutOverride);
WebClient.Builder webClientBuilder = applyHttpTimeoutsToWebClient(
- webClientBuilderProvider.getIfAvailable(WebClient::builder));
+ webClientBuilderProvider.getIfAvailable(WebClient::builder), readTimeoutOverride);
AnthropicApi.Builder builder = AnthropicApi.builder()
.apiKey(apiKey.trim())
@@ -194,11 +202,20 @@ public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
* duplicating the snippet.
*/
static RestClient.Builder applyHttpTimeouts(RestClient.Builder builder) {
+ return applyHttpTimeouts(builder, null);
+ }
+
+ /**
+ * RFC-03 Lane B1 overload — accepts a per-model read-timeout override
+ * (seconds). Null / zero / negative falls back to {@link vip.mate.llm.chatmodel.HttpTimeouts#DEFAULT_READ_TIMEOUT}
+ * so unset model configs keep the historical 180s.
+ */
+ static RestClient.Builder applyHttpTimeouts(RestClient.Builder builder, Integer readTimeoutOverride) {
HttpClient httpClient = HttpClient.newBuilder()
- .connectTimeout(Duration.ofSeconds(10))
+ .connectTimeout(vip.mate.llm.chatmodel.HttpTimeouts.CONNECT_TIMEOUT)
.build();
JdkClientHttpRequestFactory rf = new JdkClientHttpRequestFactory(httpClient);
- rf.setReadTimeout(Duration.ofSeconds(180));
+ rf.setReadTimeout(vip.mate.llm.chatmodel.HttpTimeouts.resolveReadTimeout(readTimeoutOverride));
return builder.requestFactory(rf);
}
@@ -214,12 +231,20 @@ public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
* doesn't pull in reactor-netty (excluded by this project's pom).
*/
static WebClient.Builder applyHttpTimeoutsToWebClient(WebClient.Builder builder) {
+ return applyHttpTimeoutsToWebClient(builder, null);
+ }
+
+ /**
+ * RFC-03 Lane B1 overload — same per-model override semantics as
+ * {@link #applyHttpTimeouts(RestClient.Builder, Integer)}.
+ */
+ static WebClient.Builder applyHttpTimeoutsToWebClient(WebClient.Builder builder, Integer readTimeoutOverride) {
HttpClient httpClient = HttpClient.newBuilder()
- .connectTimeout(Duration.ofSeconds(10))
+ .connectTimeout(vip.mate.llm.chatmodel.HttpTimeouts.CONNECT_TIMEOUT)
.build();
org.springframework.http.client.reactive.JdkClientHttpConnector connector =
new org.springframework.http.client.reactive.JdkClientHttpConnector(httpClient);
- connector.setReadTimeout(Duration.ofSeconds(180));
+ connector.setReadTimeout(vip.mate.llm.chatmodel.HttpTimeouts.resolveReadTimeout(readTimeoutOverride));
return builder.clientConnector(connector);
}
}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentClaudeCodeChatModelBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentClaudeCodeChatModelBuilder.java
index 9ba5a2b1..ae3f8f47 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentClaudeCodeChatModelBuilder.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentClaudeCodeChatModelBuilder.java
@@ -96,7 +96,7 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder {
String accessToken = oauthService.getValidToken();
// 2) Build the Anthropic API client wired with OAuth headers.
- AnthropicApi api = buildOauthAnthropicApi(accessToken);
+ AnthropicApi api = buildOauthAnthropicApi(accessToken, model.getRequestTimeoutSeconds());
// 3) Reuse the canonical Anthropic options builder — same Claude 4.7
// sampling-params handling, thinking-budget mapping, prompt cache.
@@ -122,6 +122,15 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder {
* can verify header composition without spinning up a chat model.
*/
AnthropicApi buildOauthAnthropicApi(String accessToken) {
+ return buildOauthAnthropicApi(accessToken, null);
+ }
+
+ /**
+ * RFC-03 Lane B1 overload — same OAuth-stamped Anthropic client, with a
+ * per-model read-timeout override threaded through to the underlying
+ * RestClient + WebClient timeouts.
+ */
+ AnthropicApi buildOauthAnthropicApi(String accessToken, Integer readTimeoutOverride) {
String authHeader = apiHeaders.bearerAuth(accessToken);
String userAgent = apiHeaders.userAgent();
String xApp = apiHeaders.xApp();
@@ -135,7 +144,7 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder {
// rate-limited harder than spec'd. Reference: openclaw
// anthropic-transport-stream.ts:567-574.
RestClient.Builder restClientBuilder = AgentAnthropicChatModelBuilder.applyHttpTimeouts(
- restClientBuilderProvider.getIfAvailable(RestClient::builder))
+ restClientBuilderProvider.getIfAvailable(RestClient::builder), readTimeoutOverride)
.defaultHeader(HttpHeaders.AUTHORIZATION, authHeader)
.defaultHeader(HttpHeaders.USER_AGENT, userAgent)
.defaultHeader(HttpHeaders.ACCEPT, "application/json")
@@ -152,7 +161,8 @@ public class AgentClaudeCodeChatModelBuilder implements ChatModelBuilder {
// staring at SDK internals.
.requestInterceptor(new RateLimitDiagnosticInterceptor());
- WebClient.Builder webClientBuilder = webClientBuilderProvider.getIfAvailable(WebClient::builder)
+ WebClient.Builder webClientBuilder = AgentAnthropicChatModelBuilder.applyHttpTimeoutsToWebClient(
+ webClientBuilderProvider.getIfAvailable(WebClient::builder), readTimeoutOverride)
.defaultHeader(HttpHeaders.AUTHORIZATION, authHeader)
.defaultHeader(HttpHeaders.USER_AGENT, userAgent)
.defaultHeader(HttpHeaders.ACCEPT, "application/json")
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentOpenAiCompatibleChatModelBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentOpenAiCompatibleChatModelBuilder.java
index 86b83b96..32a48031 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentOpenAiCompatibleChatModelBuilder.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/chatmodel/AgentOpenAiCompatibleChatModelBuilder.java
@@ -42,7 +42,10 @@ public class AgentOpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
@Override
public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
- OpenAiApi api = agentGraphBuilder.buildOpenAiApi(provider);
+ // RFC-03 Lane B1 — pass model.requestTimeoutSeconds so providers /
+ // models with extended-thinking p99s don't false-positive on the
+ // hardcoded 180s read timeout.
+ OpenAiApi api = agentGraphBuilder.buildOpenAiApi(provider, model.getRequestTimeoutSeconds());
OpenAiChatOptions options = agentGraphBuilder.buildOpenAiOptions(model, provider);
ChatModel raw = OpenAiChatModel.builder()
.openAiApi(api)
diff --git a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/HttpTimeouts.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/HttpTimeouts.java
new file mode 100644
index 00000000..4376f67a
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/HttpTimeouts.java
@@ -0,0 +1,48 @@
+package vip.mate.llm.chatmodel;
+
+import java.time.Duration;
+
+/**
+ * RFC-03 Lane B1 — central resolver for the per-LLM-request HTTP read
+ * timeout, so {@link vip.mate.llm.model.ModelConfigEntity#getRequestTimeoutSeconds()}
+ * can override the legacy 180s default without each chatmodel builder
+ * inventing its own fallback chain.
+ *
+ * Used by:
+ *
+ * - {@code AgentAnthropicChatModelBuilder.applyHttpTimeouts}
+ * - {@code AgentAnthropicChatModelBuilder.applyHttpTimeoutsToWebClient}
+ * - {@code AgentClaudeCodeChatModelBuilder} (via Anthropic helper)
+ * - {@code AgentGraphBuilder} legacy timeout helpers
+ *
+ *
+ * Connect timeout stays at the canonical 10s — long-tail thinking
+ * latency manifests on the read path, not on connect.
+ */
+public final class HttpTimeouts {
+
+ /** Connect timeout — never overridable; 10s is enough for any sane endpoint. */
+ public static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
+
+ /**
+ * Default read timeout when no per-model override is set. Matches the
+ * historical hardcoded value so unset rows behave identically to the
+ * pre-RFC-03 baseline.
+ */
+ public static final Duration DEFAULT_READ_TIMEOUT = Duration.ofSeconds(180);
+
+ private HttpTimeouts() {}
+
+ /**
+ * Resolve the effective read timeout: the override if positive, else the
+ * canonical 180s default. Null and non-positive values fall back, so
+ * callers can pass {@code modelConfig.getRequestTimeoutSeconds()} directly
+ * without null-checks.
+ */
+ public static Duration resolveReadTimeout(Integer override) {
+ if (override == null || override <= 0) {
+ return DEFAULT_READ_TIMEOUT;
+ }
+ return Duration.ofSeconds(override);
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelConfigEntity.java b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelConfigEntity.java
index c2eac724..73f20aca 100644
--- a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelConfigEntity.java
+++ b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelConfigEntity.java
@@ -35,6 +35,21 @@ public class ModelConfigEntity {
/** 模型最大输入 token 数(上下文窗口),0 或 null 表示使用全局默认 */
private Integer maxInputTokens;
+ /**
+ * RFC-03 Lane B1 — per-model HTTP read timeout (seconds).
+ *
+ *
Null / zero / negative → fall back to the global default of 180s
+ * (existing behavior, see {@code AgentAnthropicChatModelBuilder.applyHttpTimeouts}
+ * and the corresponding helper in {@code AgentGraphBuilder}). A positive
+ * value overrides for this specific model.
+ *
+ *
Use cases: {@code o1-pro} / Claude thinking-mode / large-prompt
+ * generation calls that legitimately exceed 3 min, where the default
+ * raises false-positive timeouts; conversely {@code haiku}-class models
+ * that p99 well under 30s, where a tighter timeout fails fast.
+ */
+ private Integer requestTimeoutSeconds;
+
private Double topP;
private Boolean enableSearch;
diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V75__model_config_request_timeout.sql b/mateclaw-server/src/main/resources/db/migration/h2/V75__model_config_request_timeout.sql
new file mode 100644
index 00000000..bdaea748
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/h2/V75__model_config_request_timeout.sql
@@ -0,0 +1,7 @@
+-- V75: Per-model HTTP read timeout (RFC-03 Lane B1).
+-- Lets thinking models (o1-pro, claude opus extended-thinking, qwen3-max
+-- with deep reasoning) override the default 180s read timeout when their
+-- p99 legitimately exceeds it. Null / zero keeps the existing global
+-- default — no behavior change for existing rows.
+
+ALTER TABLE mate_model_config ADD COLUMN IF NOT EXISTS request_timeout_seconds INTEGER;
diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V75__model_config_request_timeout.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V75__model_config_request_timeout.sql
new file mode 100644
index 00000000..be3b697d
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/mysql/V75__model_config_request_timeout.sql
@@ -0,0 +1,15 @@
+-- V75: Per-model HTTP read timeout (RFC-03 Lane B1).
+-- Lets thinking models (o1-pro, claude opus extended-thinking, qwen3-max
+-- with deep reasoning) override the default 180s read timeout when their
+-- p99 legitimately exceeds it. Null / zero keeps the existing global
+-- default — no behavior change for existing rows.
+--
+-- MySQL lacks `ADD COLUMN IF NOT EXISTS`; use INFORMATION_SCHEMA guard.
+SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE()
+ AND TABLE_NAME = 'mate_model_config'
+ AND COLUMN_NAME = 'request_timeout_seconds');
+SET @s := IF(@c = 0,
+ 'ALTER TABLE mate_model_config ADD COLUMN request_timeout_seconds INT DEFAULT NULL',
+ 'SELECT 1');
+PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;