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: + *

+ * + *

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;