feat(llm): per-Model HTTP read-timeout override

This commit is contained in:
matevip 2026-05-02 15:40:59 +08:00
parent 0c7554212b
commit 55f4ba1195
8 changed files with 166 additions and 17 deletions

View File

@ -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<String, String> 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);
}

View File

@ -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.</p>
*/
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);
}
}

View File

@ -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")

View File

@ -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)

View File

@ -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.
*
* <p>Used by:
* <ul>
* <li>{@code AgentAnthropicChatModelBuilder.applyHttpTimeouts}</li>
* <li>{@code AgentAnthropicChatModelBuilder.applyHttpTimeoutsToWebClient}</li>
* <li>{@code AgentClaudeCodeChatModelBuilder} (via Anthropic helper)</li>
* <li>{@code AgentGraphBuilder} legacy timeout helpers</li>
* </ul>
*
* <p>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);
}
}

View File

@ -35,6 +35,21 @@ public class ModelConfigEntity {
/** 模型最大输入 token 数上下文窗口0 或 null 表示使用全局默认 */
private Integer maxInputTokens;
/**
* RFC-03 Lane B1 per-model HTTP read timeout (seconds).
*
* <p>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.
*
* <p>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;

View File

@ -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;

View File

@ -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;