Z ZPI 文档 v0.3.8
介绍页

源码

packages/builtin-plugins/src/web/client.ts

typescript399 行查看原文
import { isIP } from "node:net";

export interface WebSearchInput {
  query: string;
  count?: number;
  ori_question?: string | null;
  localization?: string;
  need_content?: boolean;
}
export interface WebSearchResult {
  title: string;
  url: string;
  snippet: string;
  summary: string;
  content: string;
  site_name: string | null;
  site_icon: string | null;
  date: string | null;
  rerank_score: number;
  authority: number;
}
export interface WebFetchResult {
  title: string;
  content: string;
}

export const WEB_DEADLINE_MS = 30_000;
export const WEB_RESPONSE_BYTES = 2 * 1024 * 1024;
const MAX_ATTEMPTS = 3;
const MAX_RETRY_WAIT_MS = 5_000;
type Operation = "search" | "fetch";
const endpoints = {
  search: "https://api.deepseek.com/v1/tools/search",
  fetch: "https://api.deepseek.com/v1/tools/fetch",
} as const;

export class WebRequestError extends Error {
  truncated: boolean;
  constructor(message: string, truncated = false) {
    super(message);
    this.name = "WebRequestError";
    this.truncated = truncated;
  }
}

const object = (value: unknown): value is Record<string, unknown> =>
  value !== null && typeof value === "object" && !Array.isArray(value);
const nullableString = (value: unknown): value is string | null =>
  value === null || typeof value === "string";
const invalidResponse = () =>
  new WebRequestError("DeepSeek returned an invalid web response. Please try again.");

function privateIPv4(host: string) {
  const [a, b, c] = host.split(".").map(Number);
  return (
    a === 0 ||
    a === 10 ||
    a === 127 ||
    a >= 224 ||
    (a === 100 && b >= 64 && b <= 127) ||
    (a === 169 && b === 254) ||
    (a === 172 && b >= 16 && b <= 31) ||
    (a === 192 && (b === 168 || (b === 0 && (c === 0 || c === 2)) || (b === 88 && c === 99))) ||
    (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
    (a === 203 && b === 0 && c === 113)
  );
}

/** Literal/hostname screening only: DeepSeek owns DNS resolution and upstream redirect policy. */
export function publicWebUrl(value: unknown): string | null {
  if (
    typeof value !== "string" ||
    !value ||
    Array.from(value).length > 8192 ||
    /[\s\\]/u.test(value) ||
    Array.from(value).some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127) ||
    !/^https?:\/\/[^/?#@]+(?:[/?#]|$)/i.test(value)
  )
    return null;
  try {
    const url = new URL(value);
    if (!["http:", "https:"].includes(url.protocol) || !url.hostname || url.username || url.password)
      return null;
    const host = url.hostname.toLowerCase().replace(/\.+$/, "");
    const literal = host.replace(/^\[|\]$/g, "");
    const version = isIP(literal);
    if (version === 4) return privateIPv4(literal) ? null : value;
    if (version === 6) {
      // Only ordinary global unicast literals; reject mapped, link-local, transition and reserved ranges.
      const [first, second = "0"] = literal.split(":");
      const prefix = Number.parseInt(first, 16);
      const subnet = Number.parseInt(second || "0", 16);
      if (
        !Number.isFinite(prefix) ||
        prefix < 0x2000 ||
        prefix > 0x3fff ||
        prefix === 0x2002 ||
        (prefix === 0x2001 && (subnet < 0x200 || subnet === 0xdb8)) ||
        (prefix === 0x3fff && subnet <= 0xfff)
      )
        return null;
      return value;
    }
    if (
      !host.includes(".") ||
      /(?:^|\.)(?:localhost|local|localdomain|internal|intranet|lan|home|test|invalid|onion)$/.test(host) ||
      host === "home.arpa" ||
      host.endsWith(".home.arpa") ||
      host.split(".").some((label) => !/^[a-z\d](?:[a-z\d-]*[a-z\d])?$/.test(label))
    )
      return null;
    return value;
  } catch {
    return null;
  }
}

function unicodeLength(value: string) {
  return Array.from(value).length;
}

export function searchInput(value: unknown): WebSearchInput {
  const invalid = () =>
    new WebRequestError(
      "Invalid web_search arguments. Use query (1–128 Unicode characters), count (1–50), and the documented optional fields.",
    );
  if (
    !object(value) ||
    Object.keys(value).some(
      (key) => !["query", "count", "ori_question", "localization", "need_content"].includes(key),
    ) ||
    typeof value.query !== "string" ||
    !value.query.trim() ||
    unicodeLength(value.query) > 128 ||
    (value.count !== undefined &&
      (!Number.isInteger(value.count) || (value.count as number) < 1 || (value.count as number) > 50)) ||
    (value.ori_question !== undefined &&
      value.ori_question !== null &&
      (typeof value.ori_question !== "string" ||
        unicodeLength(value.ori_question) < 1 ||
        unicodeLength(value.ori_question) > 128)) ||
    (value.localization !== undefined && typeof value.localization !== "string") ||
    (value.need_content !== undefined && typeof value.need_content !== "boolean")
  )
    throw invalid();
  // Leave omitted optional fields omitted; DeepSeek applies its documented defaults.
  return {
    query: value.query,
    ...(value.count !== undefined ? { count: value.count as number } : {}),
    ...(value.ori_question !== undefined ? { ori_question: value.ori_question as string | null } : {}),
    ...(value.localization !== undefined ? { localization: value.localization as string } : {}),
    ...(value.need_content !== undefined ? { need_content: value.need_content as boolean } : {}),
  };
}

export function fetchInput(value: unknown): { url: string } {
  const url =
    object(value) && Object.keys(value).every((key) => key === "url") ? publicWebUrl(value.url) : null;
  if (!url)
    throw new WebRequestError(
      "Invalid web_fetch URL. Use a public HTTP or HTTPS URL without credentials (maximum 8192 characters).",
    );
  return { url };
}

export function searchResponse(value: unknown): WebSearchResult[] {
  if (!object(value) || !Array.isArray(value.results) || value.results.length > 50) throw invalidResponse();
  return value.results.map((item) => {
    if (
      !object(item) ||
      ![item.title, item.url, item.snippet, item.summary, item.content].every(
        (field) => typeof field === "string",
      ) ||
      !nullableString(item.site_name) ||
      !nullableString(item.site_icon) ||
      !nullableString(item.date) ||
      (item.date !== null &&
        (!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/.test(item.date) ||
          !Number.isFinite(Date.parse(item.date)))) ||
      typeof item.rerank_score !== "number" ||
      !Number.isFinite(item.rerank_score) ||
      item.rerank_score < 0 ||
      item.rerank_score > 3 ||
      typeof item.authority !== "number" ||
      !Number.isInteger(item.authority) ||
      item.authority < -1 ||
      item.authority > 4
    )
      throw invalidResponse();
    const url = publicWebUrl(item.url);
    if (!url) throw invalidResponse();
    return {
      title: item.title as string,
      url,
      snippet: item.snippet as string,
      summary: item.summary as string,
      content: item.content as string,
      site_name: item.site_name,
      site_icon: publicWebUrl(item.site_icon),
      date: item.date,
      rerank_score: item.rerank_score,
      authority: item.authority,
    };
  });
}

export function fetchResponse(value: unknown): WebFetchResult {
  if (!object(value) || typeof value.title !== "string" || typeof value.content !== "string")
    throw invalidResponse();
  if (!value.content.trim())
    throw new WebRequestError("DeepSeek returned no usable page content. Try another source.");
  return { title: value.title, content: value.content };
}

function aborted() {
  return new WebRequestError("Web request cancelled.");
}

function abortable<T>(promise: Promise<T>, signal: AbortSignal, late?: (value: T) => void): Promise<T> {
  return new Promise<T>((resolve, reject) => {
    let cancelled = signal.aborted;
    const abort = () => {
      cancelled = true;
      reject(aborted());
    };
    if (cancelled) reject(aborted());
    else signal.addEventListener("abort", abort, { once: true });
    promise.then(
      (value) => {
        signal.removeEventListener("abort", abort);
        if (cancelled) late?.(value);
        else resolve(value);
      },
      (error: unknown) => {
        signal.removeEventListener("abort", abort);
        reject(error);
      },
    );
  });
}

function wait(ms: number, signal: AbortSignal): Promise<void> {
  if (signal.aborted) return Promise.reject(aborted());
  if (ms <= 0) return Promise.resolve();
  return new Promise((resolve, reject) => {
    const abort = () => {
      clearTimeout(timer);
      reject(aborted());
    };
    const timer = setTimeout(() => {
      signal.removeEventListener("abort", abort);
      resolve();
    }, ms);
    signal.addEventListener("abort", abort, { once: true });
  });
}

// Share pacing across this instance's sessions and keys; other clients may still trigger account-level 429.
const limits = {
  search: { nextAt: 0, tail: Promise.resolve() },
  fetch: { nextAt: 0, tail: Promise.resolve() },
};
function throttle(operation: Operation, signal: AbortSignal) {
  const state = limits[operation];
  const turn = state.tail.then(async () => {
    if (signal.aborted) throw aborted();
    await wait(Math.max(0, state.nextAt - Date.now()), signal);
    if (signal.aborted) throw aborted();
    state.nextAt = Date.now() + 200;
  });
  state.tail = turn.catch(() => {});
  return abortable(turn, signal);
}

function discard(response: Response) {
  void response.body?.cancel().catch(() => {});
}

async function readJson(response: Response, signal: AbortSignal): Promise<unknown> {
  const oversized = () =>
    new WebRequestError(
      "DeepSeek's response exceeded the 2 MiB limit. Try a narrower query or another page.",
      true,
    );
  if (Number(response.headers.get("content-length")) > WEB_RESPONSE_BYTES) {
    discard(response);
    throw oversized();
  }
  if (!response.body) throw invalidResponse();
  const reader = response.body.getReader();
  const cancel = () => {
    void reader.cancel().catch(() => {});
  };
  signal.addEventListener("abort", cancel, { once: true });
  const decoder = new TextDecoder();
  let text = "";
  let bytes = 0;
  try {
    while (true) {
      if (signal.aborted) throw aborted();
      const chunk = await abortable(reader.read(), signal);
      if (signal.aborted) throw aborted();
      if (chunk.done) break;
      bytes += chunk.value.byteLength;
      if (bytes > WEB_RESPONSE_BYTES) throw oversized();
      text += decoder.decode(chunk.value, { stream: true });
    }
    text += decoder.decode();
    try {
      return JSON.parse(text);
    } catch {
      throw invalidResponse();
    }
  } finally {
    signal.removeEventListener("abort", cancel);
    cancel();
  }
}

function retryWait(response: Response, attempt: number) {
  const header = response.headers.get("retry-after")?.trim();
  if (header && /^\d+(?:\.\d+)?$/.test(header)) {
    const milliseconds = Number(header) * 1_000;
    // A syntactically valid, enormous wait must not wrap a timer or fall back to an earlier retry.
    return Number.isFinite(milliseconds) ? milliseconds : Number.POSITIVE_INFINITY;
  }
  const date = header ? Date.parse(header) - Date.now() : NaN;
  return Number.isFinite(date) ? Math.max(0, date) : Math.min(MAX_RETRY_WAIT_MS, 500 * 2 ** attempt);
}

function statusError(status: number) {
  const messages: Record<number, string> = {
    400: "DeepSeek rejected the web request. Check the tool arguments.",
    401: "DeepSeek authentication failed. Check the Web Tools API key in Settings.",
    402: "DeepSeek account balance is insufficient. Add credit before retrying.",
    403: "DeepSeek's security policy refused this page.",
    404: "DeepSeek could not retrieve usable page content. Try another source.",
    408: "DeepSeek timed out after limited retries. Please try again later.",
    413: "DeepSeek rejected the request because it was too large.",
    429: "DeepSeek's request limit was reached after limited retries. Please try again later.",
  };
  return new WebRequestError(
    messages[status] ??
      (status >= 500
        ? "DeepSeek is temporarily unavailable after limited retries. Please try again later."
        : "DeepSeek returned an unexpected web response."),
  );
}

export async function requestWeb(
  operation: Operation,
  input: WebSearchInput | { url: string },
  key: string,
  requestFetch: typeof fetch,
  signal: AbortSignal,
  beforeRequest?: () => void,
): Promise<unknown> {
  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
    await throttle(operation, signal);
    if (signal.aborted) throw aborted();
    beforeRequest?.();
    const response = await abortable(
      requestFetch(endpoints[operation], {
        method: "POST",
        redirect: "error",
        credentials: "omit",
        headers: {
          Authorization: `Bearer ${key}`,
          "Content-Type": "application/json",
          Accept: "application/json",
        },
        body: JSON.stringify(input),
        signal,
      }),
      signal,
      discard,
    );
    if (signal.aborted) {
      discard(response);
      throw aborted();
    }
    if (response.redirected || (response.status >= 300 && response.status < 400)) {
      discard(response);
      throw new WebRequestError("DeepSeek returned an unexpected redirect. The request was stopped.");
    }
    if (response.status === 200) return readJson(response, signal);
    discard(response);
    const retry = response.status === 408 || response.status === 429 || response.status >= 500;
    if (!retry || attempt + 1 === MAX_ATTEMPTS) throw statusError(response.status);
    const delay = retryWait(response, attempt);
    if (delay >= WEB_DEADLINE_MS)
      throw new WebRequestError(
        "DeepSeek requested a retry wait beyond this tool’s deadline. Please try again later.",
      );
    await wait(delay, signal);
  }
  throw new WebRequestError("DeepSeek web request failed.");
}