feat(ai): S7d 生成 provider 切 Dify 的 fail-closed 硬化 + 超时对齐(代码层)

分支①第四步代码/配置层:RoutingMuseAiRuntimeClient 补 Dify 装配为占位 Unavailable 时的 fail-closed
——dify 选中但未装配/凭据缺 → 返回 Dify 专属 AI_DIFY_UNAVAILABLE 并拒绝,绝不回退 New-API(此前会
误报 AI_NEW_API_UNAVAILABLE);dify 分支三出口(requiresSourceRefs→REF_INVALID / !difyAvailable→
UNAVAILABLE / 否则 difyClient.execute)均不触碰 newApiClient(亲验)。Dify non-stream-read 超时默认
90→180s 对齐总预算(180≤180<SSE 死线 240);单人 solo-compose.env.example 启用 Dify 块(凭据取自
既有 S1 DIFY 块,未新造);New-API adapter 与配置保留(S8 才摘)。

在用 Agent 的 runtimeProvider 存于 muse_ai_agent_version.config(运行时 DB JSON 列)非静态种子,故真正
把在用 Agent 切 Dify 属 live 数据操作,归 M3 由主会话执行(本提交不含 DB 改动、不切换 live provider,
超时改动在 Dify 未启用前休眠)。

验证:ai 模块 530 用例全绿(新增 fail-closed 不回退 + Dify 完整正文形态 2 例);P1rDifyChatLiveAcceptanceIT
真连 100.64.0.8:18080 验证 wiring/契约/失败映射通过。**M3 live happy-path 阻断**:Dify「muse-写作透传」
app(75f105e8)/chat-messages 返 500(/info 200)= 该 app 模型后端未配好(Dify 侧配置,非 Muse 代码),
代码正确映射 AI_DIFY_PROVIDER_5XX 可重试+fail-closed;待所有者在 Dify 侧修复 app 模型配置后跑 M3。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-08 01:25:56 -07:00
parent 2ac42a0628
commit b583bd6dc6
6 changed files with 74 additions and 7 deletions

View File

@ -19,10 +19,15 @@ public class RoutingMuseAiRuntimeClient implements MuseAiRuntimeClient {
private final MuseAiRuntimeClient newApiClient; private final MuseAiRuntimeClient newApiClient;
private final MuseAiRuntimeClient difyClient; private final MuseAiRuntimeClient difyClient;
private final boolean difyAvailable;
public RoutingMuseAiRuntimeClient(MuseAiRuntimeClient newApiClient, MuseAiRuntimeClient difyClient) { public RoutingMuseAiRuntimeClient(MuseAiRuntimeClient newApiClient, MuseAiRuntimeClient difyClient) {
this.newApiClient = newApiClient == null ? new UnavailableMuseAiRuntimeClient() : newApiClient; this.newApiClient = newApiClient == null ? new UnavailableMuseAiRuntimeClient() : newApiClient;
this.difyClient = difyClient == null ? null : difyClient; this.difyClient = difyClient;
// Dify 槽位未装配(null)或仅装配为占位 Unavailable adapter ,一律视为 Dify 不可用
// WHY 单列 Unavailable:占位 Unavailable 会返回 New-API 语义错误码(AI_NEW_API_UNAVAILABLE),
// Dify 路径具误导性;S7d 要求选中 dify 必须失败关闭并抛 Dify 专属错误,绝不回退 New-API 伪成功
this.difyAvailable = difyClient != null && !(difyClient instanceof UnavailableMuseAiRuntimeClient);
} }
@Override @Override
@ -38,7 +43,9 @@ public class RoutingMuseAiRuntimeClient implements MuseAiRuntimeClient {
return failure(command, "provider_bad_request", "AI_AGENT_DIFY_REF_INVALID", return failure(command, "provider_bad_request", "AI_AGENT_DIFY_REF_INVALID",
"Dify runtime cannot satisfy Muse source attribution contract", false); "Dify runtime cannot satisfy Muse source attribution contract", false);
} }
if (difyClient == null) { // fail-closed:Dify 未装配/装配为 Unavailable/凭据缺失 拒绝,绝不回退 New-API
// (凭据/appId 缺失等细分错误由 difyClient 内部 validateConfig 失败关闭并返回 Dify 专属错误码)
if (!difyAvailable) {
return failure(command, "runtime_unavailable", "AI_DIFY_UNAVAILABLE", return failure(command, "runtime_unavailable", "AI_DIFY_UNAVAILABLE",
"AI Dify runtime unavailable", false); "AI Dify runtime unavailable", false);
} }

View File

@ -267,7 +267,9 @@ public class MuseAiProperties {
private List<Credential> credentials = List.of(); private List<Credential> credentials = List.of();
private Integer connectTimeoutSeconds = 5; private Integer connectTimeoutSeconds = 5;
private Integer nonStreamReadTimeoutSeconds = 90; // 生成主链切 Dify(S7d)默认对齐 180s 总预算:实际请求超时取 min(该值, 总预算),
// 若低于总预算会把长篇生成截短,故默认 180( < SSE 死线 240s)
private Integer nonStreamReadTimeoutSeconds = 180;
private Integer totalTimeoutSeconds = 180; private Integer totalTimeoutSeconds = 180;
@Data @Data

View File

@ -248,7 +248,9 @@ muse:
- ref: ${MUSE_AI_DIFY_PARSER_CREDENTIAL_REF:dify-parser-s1} - ref: ${MUSE_AI_DIFY_PARSER_CREDENTIAL_REF:dify-parser-s1}
api-key: ${MUSE_AI_DIFY_PARSER_API_KEY:} api-key: ${MUSE_AI_DIFY_PARSER_API_KEY:}
connect-timeout-seconds: ${MUSE_AI_DIFY_CONNECT_TIMEOUT_SECONDS:5} connect-timeout-seconds: ${MUSE_AI_DIFY_CONNECT_TIMEOUT_SECONDS:5}
non-stream-read-timeout-seconds: ${MUSE_AI_DIFY_NON_STREAM_READ_TIMEOUT_SECONDS:90} # 生成主链切 Dify(S7d):短读超时对齐 180s 总预算。WHY 不是 90:实际请求超时取 min(非流式短读, 总预算),
# 90 会把长篇生成截到 90s;须 ≥ 总预算(180s)且 < SSE 死线(240s)才能让上游先出话、后端不提前关门。
non-stream-read-timeout-seconds: ${MUSE_AI_DIFY_NON_STREAM_READ_TIMEOUT_SECONDS:180}
total-timeout-seconds: ${MUSE_AI_DIFY_TOTAL_TIMEOUT_SECONDS:180} total-timeout-seconds: ${MUSE_AI_DIFY_TOTAL_TIMEOUT_SECONDS:180}
--- #################### 芋道相关配置 #################### --- #################### 芋道相关配置 ####################

View File

@ -525,6 +525,54 @@ class MuseAiRuntimeClientTest {
assertTrue(response.failure().retryable()); assertTrue(response.failure().retryable());
} }
@Test
void should_failClosedForDifyWhenAssembledUnavailableWithoutFallbackToNewApi() {
// S7d fail-closed 反向:Dify 槽位装配为占位 Unavailable( null),选中 dify 仍必须失败关闭,
// 返回 Dify 专属不可用错误码,且绝不回退 New-API newApi lambda 若被命中会返回 wrong 结果使断言失败
RoutingMuseAiRuntimeClient client = new RoutingMuseAiRuntimeClient(
command -> new MuseAiRuntimeClient.RuntimeResponse(
new MuseAiRuntimeClient.RuntimeResult("corr-real-1", "success", Map.of("summary", "wrong"),
Map.of(), null, "new-api-should-not-run", "stop", Instant.now()), null),
new UnavailableMuseAiRuntimeClient());
MuseAiRuntimeClient.RuntimeResponse response = client.execute(command(difyInputSummary("agent")));
assertNull(response.result());
assertEquals("runtime_unavailable", response.failure().failureType());
assertEquals("AI_DIFY_UNAVAILABLE", response.failure().errorCode());
assertFalse(response.failure().retryable());
}
// S7d Dify 形态:完整正文(>80 句末标点)经护栏通过后,fullOutput 承载完整正文(仅内存供 S7b),
// 落库摘要 content 仍只 80 字截断,证明审的是完整正文主权摘要不含全文
private static final String COMPLETE_DIFY_OUTPUT_SAMPLE =
"夜色像一层薄薄的墨慢慢浸透了整座城市,街灯在雨里晕开成一团团柔和的光,"
+ "她合上笔记本望向窗外那条空荡荡的长街,心里第一次生出一种前所未有的笃定,"
+ "仿佛所有漂泊多年的疑问都在这一刻悄然有了答案。";
@Test
void should_routeDifyCompleteOutputWithFullOutputWhileSummaryTruncated() throws Exception {
startServer(exchange -> writeJson(exchange, 200,
"{\"message_id\":\"dify-msg-3\",\"answer\":\"" + COMPLETE_DIFY_OUTPUT_SAMPLE + "\"}", null));
RoutingMuseAiRuntimeClient client = new RoutingMuseAiRuntimeClient(
new RealNewApiMuseAiRuntimeClient(newApiProperties(true)),
new RealDifyMuseAiRuntimeClient(difyProperties(true)));
MuseAiRuntimeClient.RuntimeResponse response = client.execute(command(difyInputSummary("agent")));
assertNull(response.failure());
assertEquals("success", response.result().status());
assertEquals("dify", response.result().outputSummary().get("provider"));
assertEquals("untraceable_provider_context", response.result().outputSummary().get("traceability"));
// fullOutput = 完整正文(仅内存, S7b 瞬态通路);落库摘要 content 80 不等于完整正文
assertEquals(COMPLETE_DIFY_OUTPUT_SAMPLE, response.result().fullOutput());
String summary = String.valueOf(response.result().outputSummary().get("content"));
assertTrue(summary.length() <= 80, "落库摘要不得超过 80 字快照");
assertTrue(response.result().fullOutput().length() > summary.length(),
"完整正文长度须大于落库摘要,证明摘要为截断快照而非全文");
assertFalse(summary.equals(response.result().fullOutput()), "落库摘要不得等于完整正文");
}
private MuseAiRuntimeClient.RuntimeCommand command(Map<String, Object> inputSummary) { private MuseAiRuntimeClient.RuntimeCommand command(Map<String, Object> inputSummary) {
return new MuseAiRuntimeClient.RuntimeCommand( return new MuseAiRuntimeClient.RuntimeCommand(
"cmd-real-1", "3001", "4001", "corr-real-1", 1, "model-a", "prompt-v9", "cmd-real-1", "3001", "4001", "corr-real-1", 1, "model-a", "prompt-v9",

View File

@ -42,7 +42,8 @@ MUSE_AI_DIFY_PARSER_API_KEY=app-It4V7WukHohP8rhWV0gOn2H5
MUSE_AI_DIFY_PARSER_CREDENTIAL_REF=dify-parser-s1 MUSE_AI_DIFY_PARSER_CREDENTIAL_REF=dify-parser-s1
MUSE_AI_DIFY_CONNECT_TIMEOUT_SECONDS=5 MUSE_AI_DIFY_CONNECT_TIMEOUT_SECONDS=5
MUSE_AI_DIFY_FIRST_BYTE_TIMEOUT_SECONDS=15 MUSE_AI_DIFY_FIRST_BYTE_TIMEOUT_SECONDS=15
MUSE_AI_DIFY_NON_STREAM_READ_TIMEOUT_SECONDS=90 # S7d 生成主链切 Dify:短读超时对齐 180s 总预算(实际请求超时取 min(短读,总预算),90 会截短长篇生成)。
MUSE_AI_DIFY_NON_STREAM_READ_TIMEOUT_SECONDS=180
MUSE_AI_DIFY_STREAM_IDLE_TIMEOUT_SECONDS=30 MUSE_AI_DIFY_STREAM_IDLE_TIMEOUT_SECONDS=30
MUSE_AI_DIFY_TOTAL_TIMEOUT_SECONDS=180 MUSE_AI_DIFY_TOTAL_TIMEOUT_SECONDS=180
MUSE_KNOWLEDGE_DIFY_BASE_URL=http://100.64.0.8:18080/v1 MUSE_KNOWLEDGE_DIFY_BASE_URL=http://100.64.0.8:18080/v1

View File

@ -10,6 +10,13 @@ MUSE_ACCOUNT_EVENTS_PUBLISH_WORKER_ENABLED=false
MUSE_ACCOUNT_EVENTS_ATTRIBUTION_WORKER_ENABLED=false MUSE_ACCOUNT_EVENTS_ATTRIBUTION_WORKER_ENABLED=false
MUSE_ACCOUNT_EVENTS_QUOTA_REQUEST_WORKER_ENABLED=false MUSE_ACCOUNT_EVENTS_QUOTA_REQUEST_WORKER_ENABLED=false
# AI 运行时后续由 S7/S8 切到 DifyS4 保持 New-API/Dify adapter 未配置即 fail-closed # AI 运行时:S7d 生成主链切 Dify启用 Dify adapterNew-API adapter 代码保留但配置默认关闭S8 验收后才摘)
MUSE_AI_NEW_API_ENABLED=false MUSE_AI_NEW_API_ENABLED=false
MUSE_AI_DIFY_ENABLED=false MUSE_AI_DIFY_ENABLED=true
# Dify 生成 app 的真实凭据不在样例提交见文件开头base-url / writing app-id / api-key 从
# scripts/dev/p1r-external-acceptance.env 的 DIFY_S1 块Muse 专属 workspace注入勿在此新造。
# credentialRef 仅为引用名(非密钥),须与注入的 Dify 凭据 ref 一致。
MUSE_AI_DIFY_WRITING_CREDENTIAL_REF=dify-writing-s1
# 生成正文时延上限对齐 180s 总预算(< SSE 死线 240s短读超时须 ≥ 总预算,否则被 min() 截到 90s。
MUSE_AI_DIFY_NON_STREAM_READ_TIMEOUT_SECONDS=180
MUSE_AI_DIFY_TOTAL_TIMEOUT_SECONDS=180