tool.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832
  1. import os from 'node:os';
  2. import path from 'node:path';
  3. import {
  4. type PluginInput,
  5. type ToolDefinition,
  6. tool,
  7. } from '@opencode-ai/plugin';
  8. import { buildBinaryResultMessage, saveBinary } from './binary';
  9. import {
  10. buildCacheKey,
  11. CACHE,
  12. cacheFetchResult,
  13. isInvalidLlmsResult,
  14. } from './cache';
  15. import {
  16. DEFAULT_TIMEOUT_SECONDS,
  17. MAX_BINARY_DOWNLOAD_BYTES,
  18. MAX_LLMS_PROBE_TIMEOUT_MS,
  19. MAX_RESPONSE_BYTES,
  20. MAX_TIMEOUT_SECONDS,
  21. WEBFETCH_DESCRIPTION,
  22. } from './constants';
  23. import {
  24. buildAllowedOrigins,
  25. buildConditionalHeaders,
  26. buildPermissionPatterns,
  27. decodeBody,
  28. extractHeaderMetadata,
  29. fetchWithUpgradeFallback,
  30. getBinaryKind,
  31. isBinaryContentType,
  32. isDocsLikeUrl,
  33. isGenericBinaryMime,
  34. isHtmlLikeContentType,
  35. looksLikeHtmlText,
  36. looksLikeTextBody,
  37. normalizeUrl,
  38. probeLlmsText,
  39. readBodyLimited,
  40. runWithScopedTimeout,
  41. } from './network';
  42. import {
  43. decideSecondaryModelUse,
  44. readSecondaryModelFromConfig,
  45. runSecondaryModelWithFallback,
  46. } from './secondary-model';
  47. import type { RedirectStep, SmartfetchOptions } from './types';
  48. import {
  49. buildLlmsRequiredMessage,
  50. buildRedirectResultMessage,
  51. cleanFetchedText,
  52. detectQualitySignals,
  53. escapeHtml,
  54. extractFromHtml,
  55. extractHeadingsFromMarkdown,
  56. frontmatter,
  57. inferCanonicalUrlFromText,
  58. joinRenderedContent,
  59. pickContent,
  60. renderMessageForFormat,
  61. trimBlankRuns,
  62. withTruncationMarker,
  63. wordCount,
  64. } from './utils';
  65. const z = tool.schema;
  66. export function createWebfetchTool(
  67. pluginCtx: PluginInput,
  68. options: SmartfetchOptions = {},
  69. ): ToolDefinition {
  70. const binaryDir =
  71. options.binaryDir || path.join(os.tmpdir(), 'opencode-smartfetch');
  72. return tool({
  73. description: WEBFETCH_DESCRIPTION,
  74. args: {
  75. url: z.httpUrl(),
  76. format: z.enum(['text', 'markdown', 'html']).default('markdown'),
  77. timeout: z
  78. .number()
  79. .positive()
  80. .max(MAX_TIMEOUT_SECONDS)
  81. .optional()
  82. .describe('Timeout in seconds, max 120.'),
  83. prompt: z
  84. .string()
  85. .optional()
  86. .describe(
  87. 'Optional extraction task to run on the fetched content using a cheap secondary model.',
  88. ),
  89. extract_main: z.boolean().default(true),
  90. prefer_llms_txt: z.enum(['auto', 'always', 'never']).default('auto'),
  91. include_metadata: z.boolean().default(true),
  92. save_binary: z
  93. .boolean()
  94. .default(false)
  95. .describe(
  96. 'Save binary payload to disk when it fits within the active download limit.',
  97. ),
  98. },
  99. async execute(args, ctx) {
  100. const secondaryModels = await readSecondaryModelFromConfig(
  101. ctx.directory || pluginCtx.directory,
  102. );
  103. const normalized = normalizeUrl(args.url);
  104. const url = new URL(normalized.url);
  105. const cacheKey = buildCacheKey(
  106. args.url,
  107. args.extract_main,
  108. args.prefer_llms_txt,
  109. args.save_binary,
  110. );
  111. const shouldProbeLlmsTxt =
  112. args.prefer_llms_txt === 'always' ||
  113. (args.prefer_llms_txt === 'auto' && isDocsLikeUrl(url));
  114. const permissionPatterns = buildPermissionPatterns(
  115. normalized,
  116. shouldProbeLlmsTxt,
  117. );
  118. const allowedOrigins = buildAllowedOrigins(permissionPatterns);
  119. await ctx.ask({
  120. permission: 'webfetch',
  121. patterns: permissionPatterns,
  122. always: permissionPatterns,
  123. metadata: {
  124. url: normalized.url,
  125. requested_url: args.url,
  126. fallback_url: normalized.fallbackUrl,
  127. llms_probe_enabled: shouldProbeLlmsTxt,
  128. format: args.format,
  129. prompt: args.prompt,
  130. },
  131. });
  132. const timeoutMs = Math.min(
  133. (args.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1000,
  134. MAX_TIMEOUT_SECONDS * 1000,
  135. );
  136. const controller = new AbortController();
  137. const timeout = setTimeout(
  138. () => controller.abort(new Error(`timeout after ${timeoutMs}ms`)),
  139. timeoutMs,
  140. );
  141. const abortHandler = () => controller.abort(ctx.abort.reason);
  142. ctx.abort.addEventListener('abort', abortHandler, { once: true });
  143. try {
  144. let fetchResult = CACHE.get(cacheKey);
  145. if (isInvalidLlmsResult(fetchResult)) {
  146. CACHE.delete(cacheKey);
  147. fetchResult = undefined;
  148. }
  149. const cacheHit = !!fetchResult;
  150. if (fetchResult) {
  151. fetchResult = {
  152. ...fetchResult,
  153. requestedUrl: args.url,
  154. cacheHit: true,
  155. };
  156. }
  157. if (!fetchResult) {
  158. let staleFetchResult = CACHE.get(cacheKey, {
  159. allowStale: true,
  160. noDeleteOnStaleGet: true,
  161. });
  162. if (isInvalidLlmsResult(staleFetchResult)) {
  163. CACHE.delete(cacheKey);
  164. staleFetchResult = undefined;
  165. }
  166. let llmsProbeError: string | undefined;
  167. if (shouldProbeLlmsTxt) {
  168. const fallbackOrigin = normalized.fallbackUrl
  169. ? new URL(normalized.fallbackUrl).origin
  170. : undefined;
  171. const probeTimeoutMs = Math.max(
  172. 1,
  173. Math.min(MAX_LLMS_PROBE_TIMEOUT_MS, timeoutMs),
  174. );
  175. const llms = await runWithScopedTimeout(
  176. controller.signal,
  177. probeTimeoutMs,
  178. (probeSignal) =>
  179. probeLlmsText(url, timeoutMs, probeSignal, fallbackOrigin),
  180. );
  181. if (llms && 'text' in llms) {
  182. const llmsHeaders = llms.headers || {};
  183. const text = trimBlankRuns(llms.text);
  184. fetchResult = {
  185. requestedUrl: args.url,
  186. finalUrl: llms.url,
  187. statusCode: llms.statusCode,
  188. contentType: llmsHeaders.contentType || 'text/plain',
  189. charset: llmsHeaders.charset,
  190. etag: llmsHeaders.etag,
  191. lastModified: llmsHeaders.lastModified,
  192. contentLength: llmsHeaders.contentLength,
  193. filename: llmsHeaders.filename,
  194. canonicalUrl: inferCanonicalUrlFromText(text, llms.url),
  195. headings: extractHeadingsFromMarkdown(text),
  196. title: undefined,
  197. rawContent: text,
  198. markdown: text,
  199. text,
  200. html: text,
  201. extractedMain: false,
  202. usedLlmsTxt: true,
  203. sourceKind: 'llms_txt',
  204. upgradedToHttps: !!llms.upgradedToHttps,
  205. redirectChain: llms.redirectChain || [],
  206. truncated: !!llms.truncated,
  207. wordCount: wordCount(text),
  208. qualitySignals: detectQualitySignals({
  209. text,
  210. markdown: text,
  211. rawContent: text,
  212. wordCount: wordCount(text),
  213. sourceKind: 'llms_txt',
  214. extractedMain: false,
  215. }),
  216. llmsProbeTruncated: !!llms.truncated,
  217. decodedCharset: llms.decodedCharset,
  218. decodeFallback: llms.decodeFallback,
  219. decodeWarning: llms.decodeWarning,
  220. cacheHit: false,
  221. };
  222. cacheFetchResult(
  223. fetchResult,
  224. args.extract_main,
  225. args.prefer_llms_txt,
  226. args.save_binary,
  227. );
  228. } else if (llms?.error) {
  229. llmsProbeError = llms.error;
  230. }
  231. if (!fetchResult && args.prefer_llms_txt === 'always') {
  232. const metadata = args.include_metadata
  233. ? frontmatter({
  234. requested_url: args.url,
  235. used_llms_txt: false,
  236. llms_probe_error: llmsProbeError,
  237. prefer_llms_txt: args.prefer_llms_txt,
  238. })
  239. : '';
  240. return joinRenderedContent(
  241. metadata,
  242. renderMessageForFormat(
  243. buildLlmsRequiredMessage(args.url, llmsProbeError),
  244. args.format,
  245. ),
  246. args.format,
  247. );
  248. }
  249. }
  250. if (!fetchResult) {
  251. const { result, upgradedToHttps } = await fetchWithUpgradeFallback(
  252. normalized,
  253. timeoutMs,
  254. args.format,
  255. controller.signal,
  256. buildConditionalHeaders(staleFetchResult),
  257. 'GET',
  258. allowedOrigins,
  259. );
  260. if ('blockedRedirect' in result) {
  261. const metadata = args.include_metadata
  262. ? frontmatter({
  263. requested_url: args.url,
  264. redirect_url: result.redirectUrl,
  265. status_code: result.statusCode,
  266. redirect_chain: result.redirectChain.map(
  267. (step: RedirectStep) =>
  268. `${step.status} ${step.from} -> ${step.to}`,
  269. ),
  270. upgraded_to_https: upgradedToHttps,
  271. })
  272. : '';
  273. return joinRenderedContent(
  274. metadata,
  275. renderMessageForFormat(
  276. buildRedirectResultMessage(
  277. args.url,
  278. result.redirectUrl,
  279. result.statusCode,
  280. ),
  281. args.format,
  282. ),
  283. args.format,
  284. );
  285. }
  286. const { response, finalUrl, redirectChain } = result;
  287. if (response.status === 304 && staleFetchResult) {
  288. fetchResult = {
  289. ...staleFetchResult,
  290. requestedUrl: args.url,
  291. finalUrl,
  292. statusCode: staleFetchResult.statusCode,
  293. redirectChain,
  294. llmsProbeError,
  295. cacheRevalidated: true,
  296. upstreamStatusCode: 304,
  297. cacheHit: false,
  298. };
  299. cacheFetchResult(
  300. fetchResult,
  301. args.extract_main,
  302. args.prefer_llms_txt,
  303. args.save_binary,
  304. );
  305. } else {
  306. if (!response.ok) {
  307. throw new Error(
  308. `Request failed with status code: ${response.status}`,
  309. );
  310. }
  311. const headerMetadata = extractHeaderMetadata(
  312. response.headers,
  313. finalUrl,
  314. );
  315. const explicitBinary = isBinaryContentType(
  316. headerMetadata.contentType || '',
  317. );
  318. const genericBinaryMime = isGenericBinaryMime(
  319. headerMetadata.contentType || '',
  320. );
  321. const binaryDownloadLimit = args.save_binary
  322. ? MAX_RESPONSE_BYTES
  323. : MAX_BINARY_DOWNLOAD_BYTES;
  324. if (
  325. explicitBinary &&
  326. !genericBinaryMime &&
  327. typeof headerMetadata.contentLength === 'number' &&
  328. headerMetadata.contentLength > binaryDownloadLimit
  329. ) {
  330. try {
  331. await response.body?.cancel();
  332. } catch {
  333. // ignore cancel failures
  334. }
  335. fetchResult = {
  336. requestedUrl: args.url,
  337. finalUrl,
  338. statusCode: response.status,
  339. contentType:
  340. headerMetadata.contentType || 'application/octet-stream',
  341. charset: headerMetadata.charset,
  342. etag: headerMetadata.etag,
  343. lastModified: headerMetadata.lastModified,
  344. contentLength: headerMetadata.contentLength,
  345. filename: headerMetadata.filename,
  346. canonicalUrl: finalUrl,
  347. redirectChain,
  348. upgradedToHttps,
  349. truncated: false,
  350. binary: true,
  351. binaryKind: getBinaryKind(
  352. headerMetadata.contentType || 'application/octet-stream',
  353. ),
  354. downloadLimitBytes: binaryDownloadLimit,
  355. metadataOnly: true,
  356. data: undefined,
  357. llmsProbeError,
  358. llmsProbeTruncated: false,
  359. cacheHit: false,
  360. };
  361. cacheFetchResult(
  362. fetchResult,
  363. args.extract_main,
  364. args.prefer_llms_txt,
  365. args.save_binary,
  366. );
  367. } else {
  368. const readLimit =
  369. explicitBinary && !genericBinaryMime
  370. ? binaryDownloadLimit
  371. : MAX_RESPONSE_BYTES;
  372. const body = await readBodyLimited(response, readLimit);
  373. const provisionalDecoded =
  374. !headerMetadata.contentType ||
  375. genericBinaryMime ||
  376. /^text\//i.test(headerMetadata.contentType)
  377. ? decodeBody(
  378. body.data,
  379. headerMetadata.charset,
  380. headerMetadata.contentType,
  381. )
  382. : undefined;
  383. const looksHtmlPayload = provisionalDecoded
  384. ? looksLikeHtmlText(provisionalDecoded.text)
  385. : false;
  386. const contentType = headerMetadata.contentType
  387. ? genericBinaryMime && looksLikeTextBody(body.data)
  388. ? looksHtmlPayload
  389. ? 'text/html'
  390. : 'text/plain'
  391. : /^text\/plain(?:;|$)/i.test(headerMetadata.contentType) &&
  392. looksHtmlPayload
  393. ? 'text/html'
  394. : headerMetadata.contentType
  395. : looksLikeTextBody(body.data)
  396. ? looksHtmlPayload
  397. ? 'text/html'
  398. : 'text/plain'
  399. : 'application/octet-stream';
  400. if (isBinaryContentType(contentType)) {
  401. const binaryTooLarge =
  402. body.truncated ||
  403. (typeof headerMetadata.contentLength === 'number' &&
  404. headerMetadata.contentLength > binaryDownloadLimit);
  405. fetchResult = {
  406. requestedUrl: args.url,
  407. finalUrl,
  408. statusCode: response.status,
  409. contentType,
  410. charset: headerMetadata.charset,
  411. etag: headerMetadata.etag,
  412. lastModified: headerMetadata.lastModified,
  413. contentLength: headerMetadata.contentLength,
  414. filename: headerMetadata.filename,
  415. canonicalUrl: finalUrl,
  416. redirectChain,
  417. upgradedToHttps,
  418. truncated: body.truncated,
  419. binary: true,
  420. binaryKind: getBinaryKind(contentType),
  421. downloadLimitBytes: binaryDownloadLimit,
  422. metadataOnly: binaryTooLarge,
  423. data: binaryTooLarge ? undefined : body.data,
  424. llmsProbeError,
  425. llmsProbeTruncated: false,
  426. cacheHit: false,
  427. };
  428. cacheFetchResult(
  429. fetchResult,
  430. args.extract_main,
  431. args.prefer_llms_txt,
  432. args.save_binary,
  433. );
  434. } else {
  435. const decoded =
  436. provisionalDecoded ||
  437. decodeBody(body.data, headerMetadata.charset, contentType);
  438. const rawText = decoded.text;
  439. const extracted = isHtmlLikeContentType(contentType)
  440. ? await extractFromHtml(
  441. rawText,
  442. finalUrl,
  443. args.extract_main,
  444. )
  445. : {
  446. title: undefined,
  447. rawContent: cleanFetchedText(rawText),
  448. html: cleanFetchedText(rawText),
  449. text: cleanFetchedText(rawText),
  450. markdown: cleanFetchedText(rawText),
  451. extractedMain: false,
  452. canonicalUrl: undefined,
  453. headings: [],
  454. };
  455. fetchResult = {
  456. requestedUrl: args.url,
  457. finalUrl,
  458. statusCode: response.status,
  459. contentType,
  460. charset: headerMetadata.charset,
  461. etag: headerMetadata.etag,
  462. lastModified: headerMetadata.lastModified,
  463. contentLength: headerMetadata.contentLength,
  464. filename: headerMetadata.filename,
  465. canonicalUrl:
  466. extracted.canonicalUrl ||
  467. inferCanonicalUrlFromText(extracted.markdown, finalUrl) ||
  468. finalUrl,
  469. headings: extracted.headings?.length
  470. ? extracted.headings
  471. : extractHeadingsFromMarkdown(extracted.markdown),
  472. title: extracted.title,
  473. rawContent: extracted.rawContent,
  474. markdown: extracted.markdown,
  475. text: extracted.text,
  476. html: extracted.html,
  477. extractedMain: extracted.extractedMain,
  478. usedLlmsTxt: false,
  479. sourceKind: isHtmlLikeContentType(contentType)
  480. ? 'html'
  481. : 'text',
  482. upgradedToHttps,
  483. redirectChain,
  484. truncated: body.truncated,
  485. wordCount: wordCount(extracted.text),
  486. qualitySignals: detectQualitySignals({
  487. text: extracted.text,
  488. markdown: extracted.markdown,
  489. rawContent: extracted.rawContent,
  490. wordCount: wordCount(extracted.text),
  491. sourceKind: isHtmlLikeContentType(contentType)
  492. ? 'html'
  493. : 'text',
  494. extractedMain: extracted.extractedMain,
  495. }),
  496. llmsProbeError,
  497. llmsProbeTruncated: false,
  498. decodedCharset: decoded.decodedCharset,
  499. decodeFallback: decoded.decodeFallback,
  500. decodeWarning: decoded.decodeWarning,
  501. cacheHit: false,
  502. };
  503. cacheFetchResult(
  504. fetchResult,
  505. args.extract_main,
  506. args.prefer_llms_txt,
  507. args.save_binary,
  508. );
  509. }
  510. }
  511. }
  512. }
  513. }
  514. ctx.metadata({
  515. title:
  516. ('binary' in fetchResult
  517. ? fetchResult.filename
  518. : fetchResult.title) || fetchResult.finalUrl,
  519. metadata: {
  520. url: fetchResult.finalUrl,
  521. contentType: fetchResult.contentType,
  522. truncated: fetchResult.truncated,
  523. },
  524. });
  525. if ('binary' in fetchResult) {
  526. if (fetchResult.metadataOnly || !fetchResult.data) {
  527. const metadata = args.include_metadata
  528. ? frontmatter({
  529. requested_url: fetchResult.requestedUrl,
  530. final_url: fetchResult.finalUrl,
  531. canonical_url: fetchResult.canonicalUrl,
  532. status_code: fetchResult.statusCode,
  533. source_content_type: fetchResult.contentType,
  534. charset: fetchResult.charset,
  535. etag: fetchResult.etag,
  536. last_modified: fetchResult.lastModified,
  537. content_length: fetchResult.contentLength,
  538. filename: fetchResult.filename,
  539. binary_kind: fetchResult.binaryKind,
  540. redirect_chain: fetchResult.redirectChain.map(
  541. (step: RedirectStep) =>
  542. `${step.status} ${step.from} -> ${step.to}`,
  543. ),
  544. upgraded_to_https: fetchResult.upgradedToHttps,
  545. llms_probe_error: fetchResult.llmsProbeError,
  546. cache_revalidated: fetchResult.cacheRevalidated,
  547. cache_hit: fetchResult.cacheHit ?? cacheHit,
  548. upstream_status_code: fetchResult.upstreamStatusCode,
  549. truncated: fetchResult.truncated,
  550. download_limit_bytes:
  551. fetchResult.downloadLimitBytes ?? MAX_BINARY_DOWNLOAD_BYTES,
  552. binary_metadata_only: true,
  553. })
  554. : '';
  555. return joinRenderedContent(
  556. metadata,
  557. renderMessageForFormat(
  558. buildBinaryResultMessage(fetchResult),
  559. args.format,
  560. ),
  561. args.format,
  562. );
  563. }
  564. if (!args.save_binary) {
  565. const metadata = args.include_metadata
  566. ? frontmatter({
  567. requested_url: fetchResult.requestedUrl,
  568. final_url: fetchResult.finalUrl,
  569. canonical_url: fetchResult.canonicalUrl,
  570. status_code: fetchResult.statusCode,
  571. source_content_type: fetchResult.contentType,
  572. charset: fetchResult.charset,
  573. etag: fetchResult.etag,
  574. last_modified: fetchResult.lastModified,
  575. content_length: fetchResult.contentLength,
  576. filename: fetchResult.filename,
  577. binary_kind: fetchResult.binaryKind,
  578. redirect_chain: fetchResult.redirectChain.map(
  579. (step: RedirectStep) =>
  580. `${step.status} ${step.from} -> ${step.to}`,
  581. ),
  582. upgraded_to_https: fetchResult.upgradedToHttps,
  583. truncated: fetchResult.truncated,
  584. save_binary: false,
  585. cache_hit: fetchResult.cacheHit ?? cacheHit,
  586. })
  587. : '';
  588. return joinRenderedContent(
  589. metadata,
  590. renderMessageForFormat(
  591. `${fetchResult.binaryKind.toUpperCase()} content fetched but not saved. Re-run with save_binary=true to persist it.`,
  592. args.format,
  593. ),
  594. args.format,
  595. );
  596. }
  597. const savedPath = await saveBinary(
  598. binaryDir,
  599. fetchResult.data,
  600. fetchResult.contentType,
  601. fetchResult.filename,
  602. );
  603. const metadata = args.include_metadata
  604. ? frontmatter({
  605. requested_url: fetchResult.requestedUrl,
  606. final_url: fetchResult.finalUrl,
  607. canonical_url: fetchResult.canonicalUrl,
  608. status_code: fetchResult.statusCode,
  609. source_content_type: fetchResult.contentType,
  610. charset: fetchResult.charset,
  611. etag: fetchResult.etag,
  612. last_modified: fetchResult.lastModified,
  613. content_length: fetchResult.contentLength,
  614. filename: fetchResult.filename,
  615. binary_kind: fetchResult.binaryKind,
  616. redirect_chain: fetchResult.redirectChain.map(
  617. (step: RedirectStep) =>
  618. `${step.status} ${step.from} -> ${step.to}`,
  619. ),
  620. upgraded_to_https: fetchResult.upgradedToHttps,
  621. llms_probe_error: fetchResult.llmsProbeError,
  622. cache_revalidated: fetchResult.cacheRevalidated,
  623. cache_hit: fetchResult.cacheHit ?? cacheHit,
  624. upstream_status_code: fetchResult.upstreamStatusCode,
  625. truncated: fetchResult.truncated,
  626. download_limit_bytes:
  627. fetchResult.downloadLimitBytes ?? MAX_BINARY_DOWNLOAD_BYTES,
  628. saved_path: savedPath,
  629. })
  630. : '';
  631. return joinRenderedContent(
  632. metadata,
  633. renderMessageForFormat(
  634. buildBinaryResultMessage(fetchResult, savedPath),
  635. args.format,
  636. ),
  637. args.format,
  638. );
  639. }
  640. const baseContent = pickContent(fetchResult, args.format);
  641. const secondaryModelDecision = decideSecondaryModelUse(
  642. fetchResult,
  643. args.prompt,
  644. secondaryModels,
  645. );
  646. const metadata = args.include_metadata
  647. ? frontmatter({
  648. requested_url: fetchResult.requestedUrl,
  649. final_url: fetchResult.finalUrl,
  650. canonical_url: fetchResult.canonicalUrl,
  651. status_code: fetchResult.statusCode,
  652. source_content_type: fetchResult.contentType,
  653. charset: fetchResult.charset,
  654. etag: fetchResult.etag,
  655. last_modified: fetchResult.lastModified,
  656. content_length: fetchResult.contentLength,
  657. filename: fetchResult.filename,
  658. headings: fetchResult.headings,
  659. title: fetchResult.title,
  660. source_kind: fetchResult.sourceKind,
  661. used_llms_txt: fetchResult.usedLlmsTxt,
  662. extracted_main: fetchResult.extractedMain,
  663. redirect_chain: fetchResult.redirectChain.map(
  664. (step: RedirectStep) =>
  665. `${step.status} ${step.from} -> ${step.to}`,
  666. ),
  667. upgraded_to_https: fetchResult.upgradedToHttps,
  668. llms_probe_error: fetchResult.llmsProbeError,
  669. llms_probe_truncated: fetchResult.llmsProbeTruncated,
  670. cache_revalidated: fetchResult.cacheRevalidated,
  671. cache_hit: fetchResult.cacheHit ?? cacheHit,
  672. upstream_status_code: fetchResult.upstreamStatusCode,
  673. truncated: fetchResult.truncated,
  674. word_count: fetchResult.wordCount,
  675. quality_signals: fetchResult.qualitySignals,
  676. decoded_charset: fetchResult.decodedCharset,
  677. decode_fallback: fetchResult.decodeFallback,
  678. decode_warning: fetchResult.decodeWarning,
  679. secondary_model: undefined,
  680. secondary_model_skipped_reason:
  681. !secondaryModelDecision.use && args.prompt
  682. ? secondaryModelDecision.reason
  683. : undefined,
  684. })
  685. : '';
  686. if (!secondaryModelDecision.use) {
  687. return joinRenderedContent(metadata, baseContent, args.format);
  688. }
  689. if (!secondaryModels.length) {
  690. return joinRenderedContent(metadata, baseContent, args.format);
  691. }
  692. let secondaryRun:
  693. | Awaited<ReturnType<typeof runSecondaryModelWithFallback>>
  694. | undefined;
  695. let secondaryModelError: string | undefined;
  696. try {
  697. secondaryRun = await runSecondaryModelWithFallback(
  698. pluginCtx.client,
  699. ctx.directory || process.cwd(),
  700. secondaryModels,
  701. args.prompt || '',
  702. fetchResult.markdown,
  703. );
  704. } catch (error: unknown) {
  705. secondaryModelError =
  706. error instanceof Error ? error.message : String(error);
  707. }
  708. if (!secondaryRun) {
  709. const degradedMetadata = args.include_metadata
  710. ? frontmatter({
  711. requested_url: fetchResult.requestedUrl,
  712. final_url: fetchResult.finalUrl,
  713. canonical_url: fetchResult.canonicalUrl,
  714. status_code: fetchResult.statusCode,
  715. source_content_type: fetchResult.contentType,
  716. charset: fetchResult.charset,
  717. etag: fetchResult.etag,
  718. last_modified: fetchResult.lastModified,
  719. content_length: fetchResult.contentLength,
  720. filename: fetchResult.filename,
  721. headings: fetchResult.headings,
  722. title: fetchResult.title,
  723. source_kind: fetchResult.sourceKind,
  724. used_llms_txt: fetchResult.usedLlmsTxt,
  725. extracted_main: fetchResult.extractedMain,
  726. redirect_chain: fetchResult.redirectChain.map(
  727. (step: RedirectStep) =>
  728. `${step.status} ${step.from} -> ${step.to}`,
  729. ),
  730. upgraded_to_https: fetchResult.upgradedToHttps,
  731. llms_probe_error: fetchResult.llmsProbeError,
  732. llms_probe_truncated: fetchResult.llmsProbeTruncated,
  733. cache_revalidated: fetchResult.cacheRevalidated,
  734. cache_hit: fetchResult.cacheHit ?? cacheHit,
  735. upstream_status_code: fetchResult.upstreamStatusCode,
  736. truncated: fetchResult.truncated,
  737. word_count: fetchResult.wordCount,
  738. quality_signals: fetchResult.qualitySignals,
  739. decoded_charset: fetchResult.decodedCharset,
  740. decode_fallback: fetchResult.decodeFallback,
  741. decode_warning: fetchResult.decodeWarning,
  742. secondary_model: undefined,
  743. secondary_model_skipped_reason: 'secondary_model_failed',
  744. secondary_model_error: secondaryModelError,
  745. })
  746. : '';
  747. return joinRenderedContent(
  748. degradedMetadata,
  749. baseContent,
  750. args.format,
  751. );
  752. }
  753. const metadataWithSecondary = args.include_metadata
  754. ? frontmatter({
  755. requested_url: fetchResult.requestedUrl,
  756. final_url: fetchResult.finalUrl,
  757. canonical_url: fetchResult.canonicalUrl,
  758. status_code: fetchResult.statusCode,
  759. source_content_type: fetchResult.contentType,
  760. charset: fetchResult.charset,
  761. etag: fetchResult.etag,
  762. last_modified: fetchResult.lastModified,
  763. content_length: fetchResult.contentLength,
  764. filename: fetchResult.filename,
  765. headings: fetchResult.headings,
  766. title: fetchResult.title,
  767. source_kind: fetchResult.sourceKind,
  768. used_llms_txt: fetchResult.usedLlmsTxt,
  769. extracted_main: fetchResult.extractedMain,
  770. redirect_chain: fetchResult.redirectChain.map(
  771. (step: RedirectStep) =>
  772. `${step.status} ${step.from} -> ${step.to}`,
  773. ),
  774. upgraded_to_https: fetchResult.upgradedToHttps,
  775. llms_probe_error: fetchResult.llmsProbeError,
  776. llms_probe_truncated: fetchResult.llmsProbeTruncated,
  777. cache_revalidated: fetchResult.cacheRevalidated,
  778. cache_hit: fetchResult.cacheHit ?? cacheHit,
  779. upstream_status_code: fetchResult.upstreamStatusCode,
  780. truncated: fetchResult.truncated,
  781. word_count: fetchResult.wordCount,
  782. quality_signals: fetchResult.qualitySignals,
  783. decoded_charset: fetchResult.decodedCharset,
  784. decode_fallback: fetchResult.decodeFallback,
  785. decode_warning: fetchResult.decodeWarning,
  786. secondary_model_input_truncated: secondaryRun.inputTruncated,
  787. secondary_model_input_chars: secondaryRun.inputChars,
  788. secondary_model_source_chars: secondaryRun.sourceChars,
  789. secondary_model: `${secondaryRun.model.providerID}/${secondaryRun.model.modelID}`,
  790. })
  791. : '';
  792. const secondaryRaw =
  793. secondaryRun.text || 'No response from secondary model.';
  794. const secondaryContent =
  795. args.format === 'html'
  796. ? withTruncationMarker(
  797. `<pre>${escapeHtml(secondaryRaw)}</pre>`,
  798. 'html',
  799. false,
  800. )
  801. : withTruncationMarker(secondaryRaw, args.format, false);
  802. return joinRenderedContent(
  803. metadataWithSecondary,
  804. secondaryContent,
  805. args.format,
  806. );
  807. } finally {
  808. clearTimeout(timeout);
  809. ctx.abort.removeEventListener('abort', abortHandler);
  810. }
  811. },
  812. });
  813. }