llm-client.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. import type { LlmConfig } from "@/stores/wiki-store"
  2. import { isAzureOpenAiEndpoint } from "@/lib/azure-openai"
  3. import { getProviderConfig, type RequestOverrides } from "./llm-providers"
  4. import { getHttpFetch, isFetchNetworkError } from "./tauri-fetch"
  5. import { countReasoningCharsInLine, extractReasoningTextFromLine } from "./reasoning-detector"
  6. import { resolveRuntimeLocalCliConfig } from "./local-cli-config"
  7. import { trimChatMessagesToBudget } from "./chat-request-budget"
  8. export type { ChatMessage, RequestOverrides } from "./llm-providers"
  9. export { isFetchNetworkError } from "./tauri-fetch"
  10. export interface StreamCallbacks {
  11. onToken: (token: string) => void
  12. onReasoningToken?: (token: string) => void
  13. onDone: () => void
  14. onError: (error: Error) => void
  15. }
  16. // Lazy import keeps the Tauri event/invoke bindings out of bundles that
  17. // never touch the subprocess provider (e.g. vitest with a fetch mock).
  18. async function streamViaClaudeCodeCli(
  19. config: LlmConfig,
  20. messages: import("./llm-providers").ChatMessage[],
  21. callbacks: StreamCallbacks,
  22. signal?: AbortSignal,
  23. requestOverrides?: RequestOverrides,
  24. ) {
  25. const mod = await import("./claude-cli-transport")
  26. return mod.streamClaudeCodeCli(config, messages, callbacks, signal, requestOverrides)
  27. }
  28. async function streamViaCodexCli(
  29. config: LlmConfig,
  30. messages: import("./llm-providers").ChatMessage[],
  31. callbacks: StreamCallbacks,
  32. signal?: AbortSignal,
  33. requestOverrides?: RequestOverrides,
  34. ) {
  35. const mod = await import("./codex-cli-transport")
  36. return mod.streamCodexCli(config, messages, callbacks, signal, requestOverrides)
  37. }
  38. const NETWORK_RETRY_DELAYS_MS = [30_000, 60_000, 90_000, 120_000]
  39. export const DEFAULT_LLM_REQUEST_TIMEOUT_MS = 30 * 60 * 1000
  40. export function shouldRetryWithBrowserFetch(errorDetail: string): boolean {
  41. return /client not allowed/i.test(errorDetail) && /tauri-plugin-http/i.test(errorDetail)
  42. }
  43. function parseLines(chunk: Uint8Array, buffer: string, decoder: TextDecoder): [string[], string] {
  44. const text = buffer + decoder.decode(chunk, { stream: true })
  45. const lines = text.split("\n")
  46. const remaining = lines.pop() ?? ""
  47. return [lines, remaining]
  48. }
  49. function waitForRetry(ms: number, signal?: AbortSignal): Promise<boolean> {
  50. if (signal?.aborted) return Promise.resolve(false)
  51. return new Promise((resolve) => {
  52. const onAbort = () => {
  53. clearTimeout(timeoutId)
  54. resolve(false)
  55. }
  56. const timeoutId = setTimeout(() => {
  57. signal?.removeEventListener("abort", onAbort)
  58. resolve(true)
  59. }, ms)
  60. signal?.addEventListener("abort", onAbort, { once: true })
  61. })
  62. }
  63. function parseInputLengthLimit(errorDetail: string): { inputLength: number; maxLength: number } | null {
  64. const match = /input length\s*([\d,]+)\s*exceeds(?:\s+the)?\s+maximum length\s*([\d,]+)/i.exec(errorDetail)
  65. ?? /input length\s*([\d,]+)\s*exceeds(?:\s+the)?\s+max(?:imum)?\s*([\d,]+)/i.exec(errorDetail)
  66. if (!match) return null
  67. const inputLength = Number(match[1]?.replace(/,/g, ""))
  68. const maxLength = Number(match[2]?.replace(/,/g, ""))
  69. if (!Number.isFinite(inputLength) || !Number.isFinite(maxLength) || maxLength <= 0) return null
  70. return { inputLength, maxLength }
  71. }
  72. function inputLengthLimitMessage(limit: { inputLength: number; maxLength: number }): string {
  73. return `输入内容过长:本次请求约 ${limit.inputLength} 字符,接口最大允许 ${limit.maxLength} 字符。请减少历史上下文、缩短章节正文,或确认当前接口是否真的支持所选模型的上下文长度。`
  74. }
  75. export async function streamChat(
  76. config: LlmConfig,
  77. messages: import("./llm-providers").ChatMessage[],
  78. callbacks: StreamCallbacks,
  79. signal?: AbortSignal,
  80. /**
  81. * Wire-agnostic sampling knobs. The provider's buildBody() translates
  82. * these into its native schema — OpenAI-style wires accept them at
  83. * the top level ({temperature: 0.1}), Gemini nests them under
  84. * generationConfig with renamed keys ({generationConfig: {temperature: 0.1}}).
  85. * Previously we spread them onto the body here, which broke Gemini
  86. * with "Unknown name 'temperature': Cannot find field." HTTP 400.
  87. */
  88. requestOverrides?: RequestOverrides,
  89. ): Promise<void> {
  90. const runtimeConfig = await resolveRuntimeLocalCliConfig(config)
  91. const { onToken, onDone, onError } = callbacks
  92. const decoder = new TextDecoder()
  93. // Claude Code CLI uses a subprocess transport (stdin/stdout), not
  94. // HTTP. Dispatch before getProviderConfig — that function throws for
  95. // this provider because it has no URL/headers.
  96. if (runtimeConfig.provider === "claude-code") {
  97. return streamViaClaudeCodeCli(runtimeConfig, messages, callbacks, signal, requestOverrides)
  98. }
  99. if (runtimeConfig.provider === "codex-cli") {
  100. return streamViaCodexCli(runtimeConfig, messages, callbacks, signal, requestOverrides)
  101. }
  102. const providerConfig = getProviderConfig(runtimeConfig)
  103. // Combined abort: (a) user cancel, (b) our long-horizon timeout.
  104. // The long timeout is a backstop for truly stuck requests; it's NOT
  105. // what fires when a user sees "Timeout" after 2 seconds — that is
  106. // almost always a fast network failure (DNS, TLS, 404, refused) that
  107. // WebKit surfaces as a generic "Load failed". We track whether the
  108. // backstop actually fired so we can tell the two apart in the error.
  109. const timeoutMs = DEFAULT_LLM_REQUEST_TIMEOUT_MS // 30 min — generous backstop for huge-context reasoning models
  110. let combinedSignal = signal
  111. let timeoutController: AbortController | undefined
  112. let timeoutFired = false
  113. let onSignalAbort: (() => void) | undefined
  114. if (typeof AbortSignal.timeout === "function") {
  115. timeoutController = new AbortController()
  116. const timeoutId = setTimeout(() => {
  117. timeoutFired = true
  118. timeoutController?.abort()
  119. }, timeoutMs)
  120. if (signal) {
  121. onSignalAbort = () => {
  122. clearTimeout(timeoutId)
  123. timeoutController?.abort()
  124. }
  125. signal.addEventListener("abort", onSignalAbort)
  126. }
  127. combinedSignal = timeoutController.signal
  128. }
  129. try {
  130. const buildRequestInit = (nextMessages: import("./llm-providers").ChatMessage[]): RequestInit => ({
  131. method: "POST",
  132. headers: providerConfig.headers,
  133. body: JSON.stringify(providerConfig.buildBody(nextMessages, requestOverrides)),
  134. signal: combinedSignal,
  135. })
  136. const sendRequest = async (requestInit: RequestInit): Promise<Response> => {
  137. const httpFetch = await getHttpFetch()
  138. let attempt = 0
  139. while (true) {
  140. try {
  141. return await httpFetch(providerConfig.url, requestInit)
  142. } catch (err) {
  143. if (signal?.aborted || combinedSignal?.aborted) throw err
  144. if (!isFetchNetworkError(err)) throw err
  145. if (timeoutFired) throw err
  146. const retryDelay = NETWORK_RETRY_DELAYS_MS[attempt]
  147. if (retryDelay === undefined) {
  148. throw new Error(
  149. `无法连接到模型接口:软件已自动等待并重试约 5 分钟,但仍然连接失败。` +
  150. `常见原因是网络不稳定、代理不可用、接口地址无法访问、服务商网关暂时中断,或本机网络环境阻断了访问。` +
  151. `请检查网络、代理和接口地址后再重试。接口地址:${providerConfig.url}`,
  152. )
  153. }
  154. attempt += 1
  155. const shouldContinue = await waitForRetry(retryDelay, combinedSignal)
  156. if (!shouldContinue) throw err
  157. }
  158. }
  159. }
  160. let requestInit = buildRequestInit(messages)
  161. let response: Response
  162. try {
  163. response = await sendRequest(requestInit)
  164. } catch (err) {
  165. if (signal?.aborted || (combinedSignal?.aborted && !timeoutFired)) {
  166. onDone()
  167. return
  168. }
  169. if (err instanceof Error && err.name === "AbortError") {
  170. // Backstop timeout aborted the request (we tracked this via
  171. // timeoutFired); treat it as a real timeout rather than a cancel.
  172. if (timeoutFired) {
  173. onError(new Error(`Request timed out after ${Math.round(timeoutMs / 60000)} min. Try a faster model or a smaller context.`))
  174. return
  175. }
  176. onDone()
  177. return
  178. }
  179. if (isFetchNetworkError(err)) {
  180. if (timeoutFired) {
  181. onError(new Error(`Request timed out after ${Math.round(timeoutMs / 60000)} min. Try a faster model or a smaller context.`))
  182. return
  183. }
  184. // Fast fetch failure: DNS, TLS handshake, connection refused,
  185. // wrong endpoint, CORS preflight rejection, etc. All webviews
  186. // collapse this class of failure into an opaque error — point
  187. // users at the likely cause (endpoint / key / connectivity).
  188. onError(new Error(`网络连接中断,请检查网络、代理或接口地址后重试。接口地址:${providerConfig.url}`))
  189. return
  190. }
  191. onError(err instanceof Error ? err : new Error(String(err)))
  192. return
  193. }
  194. if (!response.ok) {
  195. let errorDetail = `HTTP ${response.status}: ${response.statusText}`
  196. try {
  197. const body = await response.text()
  198. if (body) errorDetail += ` — ${body}`
  199. } catch {
  200. // ignore body read failure
  201. }
  202. let inputLimitRetrySucceeded = false
  203. const inputLimit = parseInputLengthLimit(errorDetail)
  204. if (inputLimit) {
  205. const retryRequestInit = buildRequestInit(
  206. trimChatMessagesToBudget(messages, Math.floor(inputLimit.maxLength * 0.85)),
  207. )
  208. if (retryRequestInit.body === requestInit.body) {
  209. onError(new Error(inputLengthLimitMessage(inputLimit)))
  210. return
  211. }
  212. requestInit = retryRequestInit
  213. try {
  214. response = await sendRequest(requestInit)
  215. } catch (err) {
  216. onError(err instanceof Error ? err : new Error(String(err)))
  217. return
  218. }
  219. if (response.ok) {
  220. inputLimitRetrySucceeded = true
  221. } else {
  222. let retryErrorDetail = `HTTP ${response.status}: ${response.statusText}`
  223. try {
  224. const retryBody = await response.text()
  225. if (retryBody) retryErrorDetail += ` — ${retryBody}`
  226. } catch {
  227. // ignore body read failure
  228. }
  229. onError(new Error(inputLengthLimitMessage(parseInputLengthLimit(retryErrorDetail) ?? inputLimit)))
  230. return
  231. }
  232. }
  233. if (
  234. !inputLimitRetrySucceeded &&
  235. response.status === 404 &&
  236. (runtimeConfig.provider === "azure" ||
  237. (runtimeConfig.provider === "custom" && isAzureOpenAiEndpoint(runtimeConfig.customEndpoint)))
  238. ) {
  239. onError(
  240. new Error(
  241. `${errorDetail}。Azure OpenAI 返回 404 通常表示部署名称不正确。请确认模型栏填写的是 Azure deployment name,而不是模型 SKU;接口地址填写 https://<resource>.openai.azure.com 或包含 /openai/deployments/<deployment-name> 的地址。`,
  242. ),
  243. )
  244. return
  245. }
  246. if (!inputLimitRetrySucceeded && shouldRetryWithBrowserFetch(errorDetail) && typeof globalThis.fetch === "function") {
  247. try {
  248. response = await globalThis.fetch(providerConfig.url, requestInit)
  249. } catch (err) {
  250. onError(err instanceof Error ? err : new Error(String(err)))
  251. return
  252. }
  253. if (!response.ok) {
  254. let retryErrorDetail = `HTTP ${response.status}: ${response.statusText}`
  255. try {
  256. const retryBody = await response.text()
  257. if (retryBody) retryErrorDetail += ` — ${retryBody}`
  258. } catch {
  259. // ignore body read failure
  260. }
  261. onError(new Error(retryErrorDetail))
  262. return
  263. }
  264. } else if (!inputLimitRetrySucceeded) {
  265. onError(new Error(errorDetail))
  266. return
  267. }
  268. }
  269. if (!response.body) {
  270. onError(new Error("Response body is null"))
  271. return
  272. }
  273. const reader = response.body.getReader()
  274. let lineBuffer = ""
  275. // Diagnostic counters. Some OpenAI-compatible endpoints stream
  276. // chain-of-thought through a `reasoning_content` (DeepSeek-R1,
  277. // Kimi K2.x) or `reasoning` (Qwen-flavored deployments) field
  278. // and only put the actual answer in `delta.content` after
  279. // thinking ends. Misbehaving endpoints sometimes emit kilobytes
  280. // of reasoning and end the stream with no content at all,
  281. // leaving the user with a silent empty analysis. We track the
  282. // two channels separately so the stream-end path can tell the
  283. // difference between "model said nothing" and "model thought
  284. // out loud but never produced an answer". See reasoning-
  285. // detector.ts.
  286. let contentCharsEmitted = 0
  287. let reasoningCharsObserved = 0
  288. const recordToken = (text: string) => {
  289. contentCharsEmitted += text.length
  290. onToken(text)
  291. }
  292. const recordReasoning = (line: string) => {
  293. const reasoningParts = extractReasoningTextFromLine(line)
  294. for (const part of reasoningParts) {
  295. callbacks.onReasoningToken?.(part)
  296. }
  297. }
  298. try {
  299. while (true) {
  300. const { done, value } = await reader.read()
  301. if (done) {
  302. if (lineBuffer.trim()) {
  303. const trimmed = lineBuffer.trim()
  304. reasoningCharsObserved += countReasoningCharsInLine(trimmed)
  305. recordReasoning(trimmed)
  306. const token = providerConfig.parseStream(trimmed)
  307. if (token !== null) recordToken(token)
  308. }
  309. break
  310. }
  311. const [lines, remaining] = parseLines(value, lineBuffer, decoder)
  312. lineBuffer = remaining
  313. for (const line of lines) {
  314. const trimmed = line.trim()
  315. if (!trimmed) continue
  316. reasoningCharsObserved += countReasoningCharsInLine(trimmed)
  317. recordReasoning(trimmed)
  318. const token = providerConfig.parseStream(trimmed)
  319. if (token !== null) recordToken(token)
  320. }
  321. }
  322. // Stream ended cleanly. If the model produced thinking tokens
  323. // but no actual answer, surface that as a clear diagnostic
  324. // instead of letting the caller silently see "" (which usually
  325. // surfaces several layers up as "analysis not available" with
  326. // no clue why). Threshold guards against single-stray-byte
  327. // false positives from spurious empty `reasoning:""` deltas.
  328. const REASONING_DIAGNOSTIC_THRESHOLD = 200
  329. if (
  330. contentCharsEmitted === 0 &&
  331. reasoningCharsObserved >= REASONING_DIAGNOSTIC_THRESHOLD
  332. ) {
  333. onError(
  334. new Error(
  335. `模型只输出了 ${reasoningCharsObserved.toLocaleString()} 字符的思考内容,但没有输出正文。` +
  336. `这通常表示接口触发了思考 token 上限、模型没有从思考阶段切换到正式回答,或当前兼容接口的流式输出不完整。` +
  337. `请缩短输入、提高 max_tokens,或在设置里切换其他模型后重试。`,
  338. ),
  339. )
  340. return
  341. }
  342. onDone()
  343. } catch (err) {
  344. if (err instanceof Error && (err.name === "AbortError" || (signal?.aborted))) {
  345. onDone()
  346. return
  347. }
  348. if (isFetchNetworkError(err)) {
  349. // Stream reader threw a network error mid-response (connection
  350. // dropped, server closed early, network blip). Same message
  351. // regardless of whether the webview is WebKit or Chromium.
  352. onError(new Error("Connection lost during streaming. Try again."))
  353. return
  354. }
  355. onError(err instanceof Error ? err : new Error(String(err)))
  356. } finally {
  357. reader.releaseLock()
  358. }
  359. } finally {
  360. if (onSignalAbort && signal) {
  361. signal.removeEventListener("abort", onSignalAbort)
  362. }
  363. }
  364. }