Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions crates/agent-gui/src/lib/chat/runner/agentRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,12 @@ import {
createStreamingTextReconciler,
describeProviderCacheShape,
finalizeProviderStreamOptions,
llm,
normalizeErrorMessage,
type ProviderRuntimeConfig,
prepareProviderRequest,
resolveProviderCacheRetention,
type StreamOptionsEx,
streamSimpleByApi,
type ToolChoice,
toSimpleStreamReasoning,
} from "../../providers/llm";
Expand All @@ -52,6 +52,7 @@ import {
type ProviderFailoverCandidate,
withProviderFailover,
} from "../../providers/runtime/providerFailover";
import { resolveStreamRetryConfig } from "../../providers/runtime/retryPolicy";
import type { RetryAttemptRecord } from "../../providers/runtime/streamRetry";
import type { RuntimePlatform } from "../../runtimePlatform";
import type { ProviderId, ReasoningLevel, SelectedModel } from "../../settings";
Expand Down Expand Up @@ -1324,6 +1325,7 @@ export async function runAssistantWithTools(params: {
reasoning: normalizeStreamReasoning(options?.reasoning) ?? fallbackReasoning,
workdir: params.workdir,
streamRetry: {
...resolveStreamRetryConfig(target.runtime.retryPolicy),
onRetry: (attempt, maxAttempts, errorMessage) => {
params.onToolStatus?.(
`第 ${round} 轮:连接已断开,正在重试 (${attempt}/${maxAttempts})...`,
Expand Down Expand Up @@ -1405,10 +1407,14 @@ export async function runAssistantWithTools(params: {
}),
);

return streamSimpleByApi(targetModel, effectiveContext, streamOptions);
return llm.stream({
model: targetModel,
context: effectiveContext,
options: streamOptions,
});
};

const wrapWithGuard = (stream: ReturnType<typeof streamSimpleByApi>) =>
const wrapWithGuard = (stream: ReturnType<typeof llm.stream>) =>
wrapStreamWithToolCallArgumentGuard(stream, (toolCall, reason) => {
incompleteToolCallArguments.set(toolCall.id, reason);
});
Expand Down
2 changes: 2 additions & 0 deletions crates/agent-gui/src/lib/providers/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,5 @@ export type {
StreamOptionsEx,
ToolChoice,
} from "./runtime/types";
export { llm, llmStream } from "./service/llmService";
export type { LlmAdapter, LlmStreamRequest } from "./service/types";
180 changes: 113 additions & 67 deletions crates/agent-gui/src/lib/providers/runtime/payloadPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import {
attachOpenAICompletionsNativeAttachments,
attachOpenAIResponsesNativeAttachments,
} from "../nativeResponsesAttachments";
import {
composePayloadInterceptorChain,
installDefaultPayloadInterceptors,
type PayloadInterceptor,
} from "../service/interceptors";
import { attachAnthropicAutomaticCaching } from "./anthropicCache";
import { attachAnthropicLongContextBeta } from "./anthropicLongContext";
import { attachCodexPromptCacheHint } from "./codexPromptCache";
Expand Down Expand Up @@ -83,76 +88,117 @@ export function attachPayloadDebugLogging(
};
}

const finalizePayloadMiddlewares = composePayloadMiddlewares([
(options, params) => attachAnthropicAutomaticCaching(params.providerId, params.baseUrl, options),
(options, params) =>
attachAnthropicLongContextBeta(options, {
providerId: params.providerId,
baseUrl: params.baseUrl,
model: params.model,
context: params.context,
}),
(options, params) => attachCodexResponsesStorage(params.providerId, options),
(options, params) =>
attachCodexPromptCacheHint(
params.providerId,
params.baseUrl,
params.promptCacheHintMode,
params.model,
options,
),
(options, params) =>
attachProviderNativeWebSearch(params.providerId, options, params.nativeWebSearch, {
baseUrl: params.baseUrl,
}),
(options, params) =>
attachXaiResponsesPayloadCompat(options, {
providerId: params.providerId,
baseUrl: params.baseUrl,
}),
(options, params) =>
attachDeepSeekResponsesPayloadCompat(options, {
providerId: params.providerId,
model: params.model,
context: params.context,
}),
(options, params) => {
if (!params.context || !params.model) return options;
let nextOptions = attachOpenAIResponsesNativeAttachments(options, {
context: params.context,
model: params.model,
providerId: params.providerId,
workdir: params.workdir,
});
nextOptions = attachOpenAICompletionsNativeAttachments(nextOptions, {
context: params.context,
model: params.model,
providerId: params.providerId,
workdir: params.workdir,
});
nextOptions = attachAnthropicMessagesNativeAttachments(nextOptions, {
context: params.context,
model: params.model,
providerId: params.providerId,
workdir: params.workdir,
});
return attachGeminiGenerativeAINativeAttachments(nextOptions, {
context: params.context,
model: params.model,
providerId: params.providerId,
workdir: params.workdir,
});
/**
* 现有 10 个中间件的具名默认拦截器(PR-3 注册化)。顺序与注册化前的
* finalizePayloadMiddlewares 数组逐项一致——顺序即协议正确性的一部分
* (如 native attachments 必须先于 gemini thought guard),由顺序快照
* 测试锁定。payload-debug-logging 是钉住的链尾:自定义拦截器插入在默认
* 拦截器之后、它之前,保证自定义改动仍被调试日志观测到。
*/
const DEFAULT_PAYLOAD_INTERCEPTORS: readonly PayloadInterceptor[] = [
{
name: "anthropic-automatic-caching",
intercept: (options, params) =>
attachAnthropicAutomaticCaching(params.providerId, params.baseUrl, options),
},
{
name: "anthropic-long-context-beta",
intercept: (options, params) =>
attachAnthropicLongContextBeta(options, {
providerId: params.providerId,
baseUrl: params.baseUrl,
model: params.model,
context: params.context,
}),
},
{
name: "codex-responses-storage",
intercept: (options, params) => attachCodexResponsesStorage(params.providerId, options),
},
{
name: "codex-prompt-cache-hint",
intercept: (options, params) =>
attachCodexPromptCacheHint(
params.providerId,
params.baseUrl,
params.promptCacheHintMode,
params.model,
options,
),
},
{
name: "provider-native-web-search",
intercept: (options, params) =>
attachProviderNativeWebSearch(params.providerId, options, params.nativeWebSearch, {
baseUrl: params.baseUrl,
}),
},
{
name: "xai-responses-payload-compat",
intercept: (options, params) =>
attachXaiResponsesPayloadCompat(options, {
providerId: params.providerId,
baseUrl: params.baseUrl,
}),
},
{
name: "deepseek-responses-payload-compat",
intercept: (options, params) =>
attachDeepSeekResponsesPayloadCompat(options, {
providerId: params.providerId,
model: params.model,
context: params.context,
}),
},
{
name: "native-attachments",
intercept: (options, params) => {
if (!params.context || !params.model) return options;
let nextOptions = attachOpenAIResponsesNativeAttachments(options, {
context: params.context,
model: params.model,
providerId: params.providerId,
workdir: params.workdir,
});
nextOptions = attachOpenAICompletionsNativeAttachments(nextOptions, {
context: params.context,
model: params.model,
providerId: params.providerId,
workdir: params.workdir,
});
nextOptions = attachAnthropicMessagesNativeAttachments(nextOptions, {
context: params.context,
model: params.model,
providerId: params.providerId,
workdir: params.workdir,
});
return attachGeminiGenerativeAINativeAttachments(nextOptions, {
context: params.context,
model: params.model,
providerId: params.providerId,
workdir: params.workdir,
});
},
},
{
name: "gemini-thought-signature-guard",
intercept: (options, params) =>
attachGeminiThoughtSignatureGuard(options, {
providerId: params.providerId,
baseUrl: params.baseUrl,
}),
},
(options, params) =>
attachGeminiThoughtSignatureGuard(options, {
providerId: params.providerId,
baseUrl: params.baseUrl,
}),
(options, params) => attachPayloadDebugLogging(options, params.debugLogger, params.extra),
]);
{
name: "payload-debug-logging",
intercept: (options, params) =>
attachPayloadDebugLogging(options, params.debugLogger, params.extra),
},
];

installDefaultPayloadInterceptors(DEFAULT_PAYLOAD_INTERCEPTORS);

export function finalizeProviderStreamOptions(
params: FinalizeProviderStreamOptionsParams,
): StreamOptionsEx {
return finalizePayloadMiddlewares(params.options, params);
return composePayloadInterceptorChain()(params.options, params);
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export function createProviderRuntimeConfig(
promptCacheRetention: provider.promptCacheRetention,
nativeWebSearchEnabled: controls.nativeWebSearchEnabled,
useSystemProxy: provider.useSystemProxy,
...(provider.retryPolicy ? { retryPolicy: provider.retryPolicy } : {}),
modelConfig: findProviderModelConfig(provider, model),
} as ProviderRuntimeConfig;
}
24 changes: 24 additions & 0 deletions crates/agent-gui/src/lib/providers/runtime/retryPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { ProviderRetryPolicy } from "../../settings";
import type { StreamRetryConfig } from "./streamRetry";

/**
* 供应商级重试策略 → withStreamRetry 选项的解析(PR-2 策略归属权反转)。
*
* - 缺省(undefined)→ 空对象:不带 maxAttempts/disabled,withStreamRetry
* 落到全局 DEFAULT_STREAM_RETRY_MAX_ATTEMPTS——与反转前行为逐字节一致;
* - off → { disabled: true }:禁用流内重试(不影响跨供应商 failover);
* - custom → { maxAttempts: maxRetries + 1 }:设置里存的是"首次失败后的
* 重试次数"(用户口径,不含首次请求),withStreamRetry 的 maxAttempts
* 是总尝试数,两者相差恰好 1。
*
* 返回值供消费方与自己的 onRetry/onRetryRecovered 回调展开合并;回调语义
* 与 buffer-until-commit 缓冲不受策略影响。failover 场景下每个候选用各自
* runtime 的策略调用本函数——策略跟着目标供应商走,与传输配置同口径。
*/
export function resolveStreamRetryConfig(
retryPolicy: ProviderRetryPolicy | undefined,
): Pick<StreamRetryConfig, "maxAttempts" | "disabled"> {
if (!retryPolicy) return {};
if (retryPolicy.mode === "off") return { disabled: true };
return { maxAttempts: retryPolicy.maxRetries + 1 };
}
Loading
Loading