Models & Providers

jina-web-search-dsh-plugin

minatoai/jina-web-search-dsh-plugin · ★ 1

minatoai

Jina AI tools for DeepSeek Harness: 12 model tools (web / arXiv / SSRN search, r

License
MIT
Added
2026-08-23
Maintenance
active
Install kind
github

No manifest check is available; listing does not imply compatibility or security review.

安装

dsh plugin --profile web add github:minatoai/jina-web-search-dsh-plugin

GitHub 源码安装:首次需按提示配置 allowBuilds 构建授权后重试

For install and environment setup, plus plugin development tutorials, see DSH community docs ↗

README

Source README · body: en · Read Chinese README

Table of contents
  1. dsh-jina
  2. Changelog
  3. 0.14.0 (2026-09-28)
  4. 0.13.0 (2026-09-26)
  5. Features
  6. Reader options (jina_read)
  7. Field tests (cross-checked against the built-in web_search)
  8. Installation
  9. Updating
  10. API key resolution, rotation and automatic discard
  11. Rotation and discard rules
  12. Endpoint domains (no proxy needed)
  13. Uninstall
  14. Repository structure
  15. Development notes
  16. Tests

English | 简体中文

dsh-jina

A Jina AI plugin (bundle) for DeepSeek Harness: it exposes Jina Reader / Search API capabilities to the model as tool calls, and adds a configuration form on the Web Plugins page (on the dsh-jina bundle card) for several API keys (with automatic failover when one runs out of credits) and the endpoint domains (mainland mirrors / global / auto); on older harnesses the same card falls back to Settings → Plugins → Configure.

No proxy needed. Jina's global domains (r.jina.ai / s.jina.ai) are DNS-poisoned in mainland China and their origin is unreachable, so the plugin defaults to the official mainland mirrors Jina provides, r.jinaai.cn / s.jinaai.cn (a mainland CDN, the same API and authentication), which are reachable directly. The plugin itself configures no proxy at all.

Changelog

Only the latest release is listed here; the full version history lives in change-log.en.md.

0.14.0 (2026-09-28)

  • change The low-balance reminder moves from a 15-minute page-owned poll to a call-driven, shared ledger. Credits only go down when a tool is called, and the call happens host-side; the old version had the client guessing a 15-minute alarm to ask about it, so it could learn of money the host had just spent up to 15 minutes late — and closing the page stopped all checking. Meanwhile the card was already subscribed to credentials/reference-updated and refreshed on the spot (discardKey calls exactly credentials.unset), while the notice sat beside it with an alarm of its own: one fact, two roads.
  • feat A balance ledger, host-side: a perKey cache keyed by key value (in memory only, never crossing the wire), a total, an updatedAt, and a read-only route GET /api/dsh-jina/balance that is a pure read of memory with no outbound request at all. The card's "refresh" updates the ledger too, and both routes report one number and one timestamp.
  • feat The probe window: anchored at the first call in it, landing 60 seconds later. Jina's reported balance does not settle immediately after a call, so probing the instant a call ends still reads the pre-call number. Later calls inside an open window do not push it back (ten calls in a burst yield one probe; continuous work still yields one per window, so it cannot starve and no deferred-maximum is needed). A window probes only the keys it actually used (usually one) and serves the rest from cache. No call, no probe — without calls nothing is spent, so the feature needs no periodic fallback poll.
  • feat A 401/402 stops the key counting at once, one rule on all four paths (the call path, the window probe, the health check, and the discard): the moment a key answers 401/402 its credits leave the total, instead of the total reading high for up to a minute on exactly the call that proved it unusable. The window probe subtracts from the ledger but does not delete the credential (discarding belongs to the call path and the health check). A slot the seam refuses to delete (a read-only launching environment, jina-api-key.txt) stays in the pool (the walk skips it) but no longer counts; a top-up lets it serve again and its next probe puts it back in the total.
  • feat The balance carries a "N 分钟前更新" line — in the bottom-right notice and under the card's 总余额 line, both places: 刚刚更新 / N 分钟前更新 / N 小时前更新, and no line at all when nothing was ever confirmed rather than an invented "0 分钟前". The ledger is confirmed on a host schedule, not live, and saying so is what makes the number something you can reason about.
  • fix That line cannot lie (three things the review added): (1) a probe round that confirmed nothing — every probe failing at the transport, or answering without a balance number — leaves the timestamp alone, because otherwise a stale figure would be stamped "刚刚更新", which is the one thing an age line exists to prevent; (2) the card dates a figure only when it has one, so "总余额:未知" is never paired with "刚刚更新"; (3) the age line moves on its own (the settings card re-renders locally every 30 seconds, no network), since a snapshot would otherwise claim "刚刚更新" for as long as the page stays open.
  • fix A failed read is no longer "no balance": mid-rework a fetch failure folded into "no balance", which hid a notice that was legitimately showing on one network hiccup. A failure now leaves the state alone — an offline moment is not evidence that the credits came back.
  • fix The timer-driven probe cannot take the host down: nothing is above it to catch a throw, so the whole round is wrapped — a background nicety must not surface as a process-level unhandled rejection. The window is closed before the first await, so a failure cannot leave it stuck open and stop every later probe either.
  • fix The second review caught three more accounting defects: (1) one 402 used to have four accountings (a deletable key subtracted, a key the seam refused to delete not, a window probe keeping its old figure, the health check excluding it anyway) — now one rule on all four paths: 401/402 always stops the key counting; (2) the window probe could write a just-discarded key back (it snapshotted the pool before its probes and re-based on it afterwards; the probes take up to 30s, so a concurrent 402's removal was undone and the old balance re-cached — exactly the "total goes up right after a 402" this ledger exists to prevent), and the timer probe no longer writes a snapshot back; (3) dropping a key that never counted re-dated the total, dressing the other keys' older figure up as "刚刚更新" — it now advances the timestamp only when a cached figure was actually subtracted.
  • test Full suite: 188 tests (187 pass, 1 opt-in skip, 0 fail), with 18 new cases: 9 host-side ones driving the 60-second window on a fake clock (no call, no probe; a call opening the window and the probe landing on time; ten calls in one window producing one probe; a call inside an open window not pushing it back; only the window's keys probed; a 402 subtracting immediately with no probe; a 402 on a slot the seam refuses to delete still stopping it counting; a window probe meeting a 402 stopping it counting without deleting it; the health check writing back), 4 client-side ones (reading the ledger and never the primer, the age line walking its buckets, no age line when nothing was ever confirmed, a failed read leaving state alone), 2 contract assertions, and 4 this review added (no confirmation means no timestamp move — a transport failure, a shapeless reply and a wholly failed health check; a failed round still lets the next call open a fresh window; the card never dates an unknown balance and does schedule its own local re-render; dropping a key that never contributed does not re-date the total). All were mutation-tested: restoring the defect must turn them red. One vacuous test was replaced too — the old "a slot the seam refuses to delete still counts" half returned 402 only to non-probe requests while the health check sends nothing but probes, so it never reached the discard logic and merely re-read the number it had just written.
  • The threshold constant, the copy, the glow styling, the live demo, dismissal memory and top-up re-arming, and the key rotation semantics are all unchanged.

0.13.0 (2026-09-26)

  • feat Low-balance reminder: when the total credits behind the pool cross 1,000,000, a small card appears in the bottom-right corner of the Web UI with three lines and one action — title Jina Tools, "该插件可用点数少于 ,请注意补充。", "当前还有 。", and a single "知道了". It re-reads the card's own /api/dsh-jina/primer every 15 minutes (that probe is free — measured: eight consecutive probes moved no balance); it stays up for as long as the pool is below the threshold, a dismissal hides it and is remembered (a reload never repeats an unchanged drop), and a top-up above the threshold re-arms it. The threshold is the LOW_BALANCE_THRESHOLD constant in ui/client.js.
  • change The "打开设置" jump was removed: it had to be DOM-driven (DSH exposes no open-settings API to a plugin) and did not behave well in practice, so the whole thing is gone on request, with a regression assertion keeping it out.
  • feat A glowing border that flashes twice: a red border with a box-shadow glow, pulsing twice on appearance (@keyframes + animation: … 1.1s ease-in-out 2; the keyframes live in an injected <style id="dsh-jina-low-balance-style">, idempotently injected once). Skipped under prefers-reduced-motion; a profile with no document keeps the steady glow without the pulse.
  • feat It can be demonstrated on the spot: run localStorage.setItem('dsh-jina:low-balance-threshold','20000000') in the page console and reload — the notice appears immediately (the current total is about 18.17M); localStorage.removeItem('dsh-jina:low-balance-threshold') and a reload restore the shipped threshold. A malformed value is ignored, so a console typo never becomes "reminder permanently off".
  • change It is only visible while a page is open, and it needs no DeepSeek Harness change: it rides the shell's shell.overlay slot (a root-scope list slot that renders every registered id, with no allowlist), while the plugin's client half already used that same slots service for its card. DSH has no host-side notification channel, so it never wakes an idle session and does not reach headless/CLI use.
  • fix The connection hint's retired proxy advice is gone: it no longer tells you to check a 本地代理 address/port that no longer exists. It now states what is true — confirm the machine can reach Jina (mainland r.jinaai.cn / global r.jina.ai); if the environment that launched dsh carries HTTP_PROXY / HTTPS_PROXY, global requests ride it while mainland requests bypass it.
  • test Full suite: 170 tests (169 pass, 1 opt-in skip, 0 fail), with 15 new cases including 6 instrumented ones (a tiny React runtime whose setters re-render, plus controllable setInterval / localStorage / fetch / document, driving the real bundle through the whole poll path: appearing, surviving the next poll while un-dismissed, hiding on dismissal and staying hidden across a reload, re-arming after a top-up, a failed poll not changing state, the override applying while a malformed override is ignored, and the stylesheet injected exactly once), plus the glow/two-flash contract, a render assertion for the three lines and the single action, and regression assertions keeping both the settings jump and the retired proxy wording out. Also mutation-checked: restoring the first version's "latch on show" logic makes 2 of those 3 cases fail.

The full 0.12.1 notes (jina_read selector-timeout fix and friends) live in change-log.en.md.

Features

Once installed, every session (all agent presets) gets 8 jina_* tools:

Tool Corresponding jina-cli command Description
jina_web_search jina search General web search (default web domain; images / blog domains; time filter and region/language hints supported)
jina_search_arxiv jina search --arxiv arXiv preprint search (CS / ML / math / physics, etc.; returns canonical arxiv.org paper links; scoped to the source with site: arxiv.org)
jina_search_ssrn jina search --ssrn SSRN paper search (economics / finance / law / management and other social sciences; returns papers.ssrn.com links; scoped to the source with site: ssrn.com)
jina_read jina read Read a web page as clean markdown, with target selectors and noise filtering (see below). PDFs always use the plain extractor — verbatim and ~40x cheaper than OCR
jina_read_pdf jina read + X-Respond-With: jina-ocr-v1 Read a PDF through jina-ocr-v1, one page per request — the only path that works on scanned / image-only PDFs. pages picks pages ("3" / "1-5" / "2,4,7"), defaulting to the first 5 (maxPages, cap 50). One page at a time (the API silently serves page 1 for an out-of-range X-Page, which is how the tool detects the end of the document); results carry a provenance marker
jina_screenshot jina screenshot Web page screenshot, returns a hosted image URL (full-page capture supported)
jina_datetime jina datetime Guess a page's publish/update time
jina_primer jina primer Current context: host clock (ISO time/unix/timezone/UTC offset), network facts (public IP + location, best-effort) and Jina account status (identity/balance)

Reader options (jina_read)

The card's "reader options" block — or the jina-tools section of settings.yaml directly — sets jina_read's defaults. Every option can be overridden per call by the matching tool parameter.

Option Field Default Effect and cost
Image policy imagePolicy all all = the API default; alt = alt text only (cheaper); none = drop images
Generate image alt text autoAltText off X-With-Generated-Alt: captions images that lack one. Needs an API key (401 anonymously) and is mutually exclusive with OCR (it does not work when X-Respond-With is set). Because supplying a key is what makes a read billable, this is off by default
Selector group useSelectors on Sends a conservative X-Target-Selector (article containers only) and X-Remove-Selector (header/footer/nav/cookie banners/ads/sidebars/comments). If nothing matches, the read is retried against the full page rather than returning empty
Target / remove selectors targetSelector / removeSelector empty = built-in lists Override the built-in lists (for unusual site structures or a false positive)

Every read also sends three zero-side-effect parameters: X-Preset: agent (the vendor's preset for AI agents — the docs state a preset only fills options the caller did not set explicitly, so it never overrides an explicit parameter), X-Base: final (resolve relative links against the post-redirect URL) and X-Timeout: 120 (a slow-page safety net matched to the client's own 120 s ceiling). Exception: the attempt that carries a target selector runs with a 30 s server patience and a 45 s client ceiling — X-Target-Selector implies X-Wait-For-Selector, and "wait forever when it does not match" used to collide with the client ceiling and surface as a network timeout (0.12.1).

Per-call parameters (jina_read): targetSelector, waitForSelector, removeSelector, noCache, plus the existing links / images / json / apiKey. Per-call parameters (jina_read_pdf): url, pages, maxPages, allowNonPdf, apiKey. A URL that is not a .pdf is refused (allowNonPdf: true forces it through) — because jina-ocr-v1 fabricates content on ordinary web pages.

Why these defaults: the three fixed parameters are the Reader options that cost nothing in the worst case. Alt-text generation needs a key and changes billing, so it is off unless asked for. The two hidden, undocumented options (X-Remove-Overlay, X-Detach-Invisibles) are deliberately not enabled by default; the latter officially requires the browser engine and disables caching.

So the model doesn't have to memorize parameters to pick the right search domain, academic search was split into two dedicated tools, jina_search_arxiv / jina_search_ssrn (backed by jina search --arxiv / --ssrn) — the tool name says it all, and the model calls them directly when the user asks for papers. The table below is a sampled comparison from 2026-08-13 on the same machine with a real network environment (VPN system proxy): the same query was run through this plugin and dsh's built-in web_search, then the results were manually verified.

Scenario This plugin (dsh-jina) Built-in web_search Verdict
Academic search (arXiv) jina_search_arxiv "retrieval augmented generation survey" → 9/9 all canonical arxiv.org links: 2312.10997 (classic RAG survey), 2506.00054, 2410.12837, 2501.09136 (Agentic RAG), 2405.07437, 2504.08748, etc. — every result on-topic with accurate abstracts Same query returned arXiv mirror sites (ezproxy.obspm.fr, ar5iv, sinoxiv.napstic.cn) and BibTeX links; no canonical links ✅ jina wins: canonical links + precise recall
Academic search (SSRN) jina_search_ssrn "large language models financial markets" → 9/9 all papers.ssrn.com originals: market sentiment prediction, LLM-simulated trading, AI herding, investor disagreement, etc. — highly relevant No SSRN-specific search capability ✅ jina wins: exclusive SSRN domain
Chinese news / community / official sources jina_web_search puts official sources (government / company sites) first, plus time filtering Relevant results, but official sources not ranked first ✅ jina better: authoritative sources first + time filter
General academic search (no domain specified) Default web domain covers Springer / IEEE / ACL moderately (use the dedicated tools above for academic search) Broad coverage of Springer / IEEE / ACL ✅ web_search better: use it for general academic search

Conclusion / division of labor: academic papers → jina_search_arxiv / jina_search_ssrn; time-sensitive Chinese news → jina_web_search (+ time); general academic / engineering docs → built-in web_search. They complement each other and cover all search scenarios.

Note: the table is a one-round sampled comparison (not a strict benchmark); results depend on that day's network and query choices. Both toolchains work in practice; treat the conclusions as selection guidance.

Installation

Repository: https://github.com/minatoAI/jina-web-search-dsh-plugin

The plugin is distributed as a bundle and installed into a profile with dsh plugin (use pnpm dsh instead of dsh when running from a source checkout):

install from GitHub (no build script, so no allowBuilds grant needed)

dsh plugin --profile web add github:minatoAI/jina-web-search-dsh-plugin

more robust: pin to a commit so later pushes don't change the installed code

dsh plugin --profile web add github:minatoAI/jina-web-search-dsh-plugin#<commit-sha>

or install from a local folder (development)

dsh plugin --profile web add ./jina-dsh-plugin

Restart dsh after installing (new bundles take effect on next startup):

dsh --profile web

Then open the Web UI → Settings → Plugins → Configuration tab → expand the Jina Tools card → paste your key into the API key section → click 添加. You can keep adding: every click stores one more key, and the card never shows or asks you to manage an individual key. Get a free key at https://jina.ai/. Add at least two: when any key is revoked or runs out of credits the plugin discards it and switches to the next one, so the task is not interrupted (see below).

Updating

The plugin is a profile dependency, so an upgrade is make the profile fetch the new code again, then restart dsh. For the web profile:

  1. Pull the new version into the profile (any one of these):

    # A. Re-resolve the GitHub dependency (recommended)
    cd "$DSH_HOME/profiles/web"        # Windows: C:\Users\<you>\.dsh\profiles\web
    pnpm update dsh-jina
    
    # B. Remove and re-add (equivalent — both fetch the branch head)
    dsh plugin --profile web remove dsh-jina
    dsh plugin --profile web add github:minatoAI/jina-web-search-dsh-plugin
    
    # C. Web UI: sidebar → Plugins → remove dsh-jina → install the GitHub URL above
    
  2. Restart dsh so the new code is loaded:

    dsh --profile web
    
  3. Check that it worked:

    # The installed copy's version (this repo bumps package.json on every release)
    Get-Content "$DSH_HOME/profiles/web/node_modules/dsh-jina/package.json" | Select-String '"version"'
    

    Then open the card and click Refresh once to confirm it works (the "Key 总数 / 总余额" lines).

Why a plain pnpm install usually does not upgrade: a GitHub dependency is pinned in pnpm-lock.yaml to the commit resolved at install time, and pnpm install honours the lockfile; only pnpm update dsh-jina (or remove + add) re-resolves the branch head. Pinning at install time (...#<commit-sha>) behaves the same way — upgrading means explicitly switching to the new SHA.

A local-folder install (add ./jina-dsh-plugin) never touches the remote: git pull in that directory and restart dsh.

The same card also carries the endpoint domains: a three-way select deciding which pair of domains each call uses — mainland (r.jinaai.cn / s.jinaai.cn, Jina's official mainland mirrors, a domestic CDN reached directly with no proxy/VPN), global (r.jina.ai / s.jina.ai) and auto (the default: use whichever side answered last, and switch to the other when it fails). Selecting saves immediately, and the next tool call uses it — no dsh restart.

The card's API key / connection check section reports only two things: how many keys it holds and the total balance (the credits behind the surviving keys), plus the connection state and the endpoint domains the check actually used; click Refresh to re-check (adding a key or changing the endpoint also triggers an automatic re-check). Nothing per key is shown — no plaintext, no fingerprint, no "which one is in use", and no manual removal. This data is served by the host-side plugin through the /api/dsh-jina/primer route (the same endpoint the jina_primer tool uses); the plaintext key never leaves the host.

While the pool is working it also raises a low-balance reminder on its own: once the total drops below the threshold (default 1,000,000 credits), a small card appears in the bottom-right corner of the Web UI with three lines and one action — title Jina Tools, "该插件可用点数少于 ,请注意补充。", "当前还有 。", and a single "知道了" — with a red border that glows and flashes twice, and it stays up for as long as the pool is below it; a dismissal hides it and is remembered (a reload never repeats an unchanged drop), and a top-up above the threshold re-arms it. The threshold is the LOW_BALANCE_THRESHOLD constant in ui/client.js (set it to 0 to disable).

How the balance is refreshed

Credits only go down when a tool is called, and the call happens host-side — so the host owns the refresh schedule rather than the page guessing one.

timeline ─────────────────────────────────────────────────────►

        A          A+1         A+2         A+3
        │           │           │           │
calls   █   █   █   █           █           █
        │           │           │           │
        ▼           ▼           ▼           ▼
      probe       probe       probe       probe
      (only the keys that window actually used)

        └── window 1 ──┘└─ window 2 ─┘└ window 3 ┘
  • The window is anchored at the first call in it and lands 60 seconds later. Jina's reported balance does not settle immediately after a call, so probing the instant a call ends still reads the pre-call number.
  • Later calls inside an open window do not push it back: continuous work still yields exactly one probe per window, so the check can never be starved by being busy.
  • A window probes only the keys it used (usually one) and serves the rest from cache — one call asks about one key, not the whole pool.
  • No call, no probe. Without calls nothing is spent and the balance cannot change, so this feature needs no periodic fallback poll.
  • A 401/402 stops the key counting at once: one rule on all four paths — the call path, the window probe, the health check and the discard. A slot the seam refuses to delete stays in the pool but no longer counts either.
  • While a key is unaccounted for the total is a lower bound: a key that joins has to serve a call before a window probes it, and "never probe a key nothing touched" means the host will not spend a request to complete it. So the reminder may fire early rather than late — deliberately: better an early warning than silence while the books are incomplete.
  • A round that confirmed nothing does not move the timestamp: if every probe fails at the transport, or answers without a balance number, the ledger keeps both its figure and its age — it must never be stamped "刚刚更新". The window still closes normally and the next call opens a fresh one, so a failure cannot wedge the probe.

The balance also carries a "N 分钟前更新" line — in the bottom-right notice and under the card's 总余额 line — saying when the figure was confirmed: 刚刚更新 / N 分钟前更新 / N 小时前更新. It is not live, and saying so is what makes the number something you can reason about. The page's notice re-reads the ledger every 10 seconds (a local read of host memory — no credits, no outbound traffic), so it appears within about ten seconds of the balance being confirmed; the settings card has no poll of its own, so it keeps that line moving with a local re-render every 30 seconds (also no network). While the balance is unknown (the ledger has no figure yet) the line is absent, rather than an unknown figure wearing a "刚刚更新" stamp.

To see it on the spot (no code change, no restart): open the Web page, press F12 for the console and run

localStorage.setItem('dsh-jina:low-balance-threshold', '20000000') // temporarily above the current total (~18.17M)

then reload — the notice appears; run localStorage.removeItem('dsh-jina:low-balance-threshold') and reload to restore the shipped threshold. The override belongs to that browser profile only, and a malformed value (empty, abc, 0, negative) is ignored.

The notice rides the shell's shell.overlay slot and is a client-side surface: it is only visible while a page is open and never wakes an idle session (DSH has no host-side notification channel).

⚠️ Note that the Settings page covers it: the shell.overlay layer is z-index: 20 while Settings is a full-viewport modal (z-index: 1000), so the bottom-right notice is not visible inside Settings → Plugins → Jina Tools (a child cannot escape its parent's stacking context). The card therefore re-evaluates the same threshold in its "API key / connection check" block and adds a red line "⚠️ 总余额已低于提醒阈值 N credits" when crossed; on the main view (with Settings closed) the floating notice is what shows.

API key resolution, rotation and automatic discard

Each tool call resolves keys in the following order; the first source that yields a key is that call's rotation pool (every key inside a source joins the rotation, and later sources are not consulted):

  1. The apiKey tool-call parameter (a single key, never rotated — the "use exactly this one" escape hatch)
  2. The keys added on the card, in the order they were added (persisted by dsh's credential store, e.g. ~/.dsh/.credentials.yaml; the same names work as environment variables, so headless profiles are covered too)
  3. jina-api-key.txt in the session workspace (one key per line; blank lines and # comments ignored)
  4. jina-api-key.txt in the dsh home directory ($DSH_HOME, default ~/.dsh) (likewise one key per line)

Rotation and discard rules

Upstream status Meaning Behaviour
401 key revoked / expired switch to the next key, and discard that key from the credential store
402 quota exhausted switch to the next key, and discard that key
balance ≤ 0 the health check finds the credits gone discard that key
429 rate limited switch to the next key but never discard (temporary); it returns to the rotation after a one-minute cooldown
0 / 422 / 5xx network, arguments, upstream fault neither rotate nor discard (another key cannot fix it and would only waste the rest of the pool)
  • Self-managing: a usable key stays, an unusable one is discarded automatically — which is why the card has no remove control and shows nothing per key. The user only ever adds.
  • Round-robin: every call starts at the key after the one used last, so N keys each carry roughly 1/N of the traffic — Jina's rate limits (RPM/TPM) are counted per key, and spreading the load is what keeps a busy session out of 429. A planned rotation never prints the "switched" line (only a switch caused by a real failure does).
  • The switch is visible: a mid-call switch appends one line such as [已自动切换 API key: #1 (credential JINA_API_KEY) quota exhausted (HTTP 402), 已自动移除; switched to #2 (credential JINA_API_KEY_2). 可在 Plugins → dsh-jina 卡片里添加新的 key。]; a json: true payload is never touched.
  • A fully exhausted pool reports every key it tried, its source and its status (e.g. #1 (credential JINA_API_KEY) quota exhausted (HTTP 402), 已自动移除; #2 (workspace jina-api-key.txt line 2) rate limited (HTTP 429)) and points at adding a key or topping up.
  • Adding a key takes effect immediately (no restart; resolved on every call, and credential values are only ever sent up through credentials.set — no read endpoint returns the plaintext).
  • The same key appearing in several slots or in the file is requested only once.
  • Key files and read-only sources are never deleted: a key from jina-api-key.txt (the plugin will not rewrite a user's file) and a reference supplied read-only by the launching environment (the seam refuses the write) are only skipped, never removed from disk.

Endpoint domains (no proxy needed)

Jina's global domains r.jina.ai / s.jina.ai are DNS-poisoned in mainland China and their origin addresses are blackholed, so a direct connection always fails. Jina therefore provides mainland mirror domains (see jina-ai/reader#1237): r.jina.ai → r.jinaai.cn and s.jina.ai → s.jinaai.cn — the API, parameters and authentication are identical, only the domain changes.

Mode Domains used When
cn (mainland) r.jinaai.cn / s.jinaai.cn Mainland China networks. A domestic CDN reached directly, needs no proxy at all; pinning this side means it never falls back to the global domains
global r.jina.ai / s.jina.ai Overseas networks, or when you bring your own proxy
auto (default) both Use whichever side answered last, and switch to the other when it fails. Each host is tried once, at most two attempts per call; the winner is remembered in-process, so only the first call may pay for one extra probe

Rules and caveats:

  • The plugin no longer configures any proxy: there is no proxy input on the card and it does not read JINA_PROXY_URL. You can still run your own VPN/system proxy, but the plugin never discovers or sets one.
  • Mainland-domain requests bypass an inherited proxy: if the environment that started dsh carries HTTP_PROXY / HTTPS_PROXY, a mainland-domain attempt appends jinaai.cn to NO_PROXY (keeping the existing list), so a domestic CDN address is not pushed into the VPN.
  • Measured data: r.jinaai.cn answers 200 on a direct, proxy-less connection and returns content byte-for-byte identical to r.jina.ai through a proxy (7521 characters, the same title), settling at 1.1–1.2 s (24.8 s on the first cold start while the CDN warms its origin); the s.jinaai.cn search endpoint returns 200 with a key. api.jina.ai has no mainland domain (api.jinaai.cn → HTTP 525), and its SNI is blocked (the same Cloudflare IP answers normally with SNI cloudflare.com but gets an immediate ECONNRESET with SNI api.jina.ai) — which is also why jina_embed / jina_rerank / jina_classify were removed.
  • Error messages name the domains actually tried, e.g. "endpoints tried: https://r.jina.ai/, https://r.jinaai.cn/", so it is easy to tell whether the global side is blocked or the whole local network is down.
  • When a domain's resolved IP changes, no configuration change is needed (system DNS resolution is used); if the vendor ever retires the .cn domains, switch the mode to "auto" or "global" and bring your own proxy.
  • Upgrading: 0.11.x and earlier may have stored a proxyUrl in the jina-tools section. The new version never reads that field, so leaving it is harmless; delete the line if you want the document clean.

Uninstall

dsh plugin --profile web remove dsh-jina

Repository structure

jina-dsh-plugin/
├── package.json       # manifest: "dsh": { "bundle": {"patch": ...}, "client": {"platform": "web"} }; the browser half is exported via exports["./client"] → ui/client.js
├── cordis.patch.yml   # composition layer: one dual-face row dsh-jina (host tools + browser card; an exact-package-name row name is a hard requirement of the client-modules scan)
├── index.js           # host plugin: 8 tools (incl. dedicated jina_search_arxiv / jina_search_ssrn academic search and the jina_read_pdf OCR tool) + network transport (dual-endpoint routing) + the multi-key round-robin pool (JINA_API_KEY / _2 … _10 + key files)
├── keys.js            # pure module: key-pool policy (credential refs / key-file parsing / rotation order and cooldowns / status labels; zero deps, unit-testable)
├── settings.js        # pure module: endpoint policy (mainland / global / auto routing order and the NO_PROXY override) + reader-policy defaults and the settings schema (zero deps, unit-testable)
├── primer.js          # pure module: jina_primer parsing/formatting logic (zero deps, unit-testable)
├── test/
│   ├── primer.test.js        # jina_primer unit tests (auto-discovered by node --test)
│   ├── settings.test.js      # endpoint-policy unit tests (mode validation / routing order / NO_PROXY merging)
│   ├── keys.test.js          # key-pool policy unit tests (rotation / cooldowns / parsing / discard decision)
│   ├── multi-key.test.js     # mock-host multi-key integration tests (asserts Authorization per request and the failover)
│   ├── plugin-endpoints.test.js # mock-host endpoint-routing integration tests (incl. an opt-in live mainland-domain case)
│   ├── reader-headers.test.js# jina_read header contract tests (decodes the helper's stdin, asserts the headers really sent)
│   ├── client-bundle.test.js # browser-bundle contract tests (syntax + registration id + settings transport + single-input key form wiring)
│   ├── client-render.test.js # renders both views for real in a VM (catches scoping/binding defects)
│   └── tools.test.js         # jina_web_search model-facing contract tests (TDD)
├── ui/
│   ├── package.json   # subpackage manifest (exports["./client"]; the dsh.client declaration lives in the root manifest, this only keeps the subpackage complete)
│   ├── index.js       # empty host half (kept for the historical subpackage shape; the composition no longer references it)
│   └── client.js      # prebuilt browser bundle: the "Jina Tools" card on the Plugins page (single key input + endpoint domains + reader options)
├── change-log.md      # full changelog (Simplified Chinese)
├── change-log.en.md   # full changelog (English)
├── README.md          # Simplified Chinese README
└── README.en.md       # this file

Development notes

  • The host plugin only depends on Node built-ins and dsh host services (fs, subprocess, tools, credentials, llm, webServer) — no third-party npm dependencies; credentials go through dsh's native credential seam (the key pool references JINA_API_KEY / JINA_API_KEY_2 … JINA_API_KEY_10; the seam stores one value per reference and no read endpoint returns a value, so "several keys" means "several references", and any POSIX identifier is a legal reference name — no harness change is needed) and the configuration through the plugin's own jina-tools settings namespace (the endpoint domain field plus the imagePolicy / autoAltText / useSelectors / three selector overrides reader policies), so it works with any profile composition out of the box.
  • The settings-seam contract (dsh 0.1.4 onward): settings.register() is gone — a settings namespace is the Loader/profile composition entry id, and the row this plugin inserts in cordis.patch.yml is exactly jina-tools (which is why the card's NS matches). The plugin declares its editable fields through export const Config = createSettingsSchema() in index.js (settings.js, a zero-dependency hand-written node): every field node carries meta.volatile: true, and '~standard': { version: 1, vendor: 'schemastery', validate } is the harness's only entry point for resolving config (resolveConfig() requires a synchronous plain object; vendor must be 'schemastery' or every save degrades into a full plugin remount). validate() builds one cross-copy-safe volatile reference per field (Symbol.for('cosmokit.volatile.write')); on save the harness only writes the new value into those references, so the object apply(ctx, config) captured keeps its identity, the plugin is never remounted, and it re-reads settingsSnapshot(config) on every operation (toolSettingsOf() normalizes "unset" into the documented defaults). Saving therefore takes effect immediately, with no restart — the same contract as the API key. The primer payload's settingsLive field is the health check for exactly this.
  • The client bundle is committed directly (ui/client.js), no build step — git installs work as-is. To change the UI, edit that file and restart. The registration id in the bundle's top-level window.__ModuleLoader__.load MUST equal the graph row id (the exact package name dsh-jina) — the module system matches registrations only by row id (a trailing /client excepted); registering under any other key (e.g. the old row name dsh-jina/ui) fails the whole page with loaded without registering "dsh-jina" + Failed to load plugins. The card registers into the settings.plugin.item slot declared by the Web settings package (Settings → Plugins → Configuration), the standard place for third-party plugin configuration.
  • The remote.<ns> injection rule: the gateway's $mount registers every Remote namespace as its own cordis service, so a client plugin must declare remote.<ns> (e.g. remote.credentials, remote.settings) in its own inject before reading that property — declaring only 'remote' is not enough, the property access itself throws cannot get property "remote.settings" without inject, and the error reaching the settings.plugin.item slot boundary makes the whole card disappear (the 0.6.0 regression, now pinned by test/client-bundle.test.js). This plugin declares inject = ['slots','remote','remote.credentials','remote.settings'], and the read sites keep a try/catch so a missing service degrades to a notice instead of crashing the slot.
  • The keys are managed through the credentials Remote namespace (credentials.describe/set/unset, with credentials/reference-updated forwarded by remote): the card describes all ten slots in one batch to learn "configured / source / writable" for each (a value is never returned — it crosses the wire only on save), and the form is therefore one input — 添加 writes into the first free slot, while the host half uses credentials.unset to discard a slot that answers 401/402 or reports no credits left (the card itself never calls unset, and shows nothing per key). The KEY_REFS table in ui/client.js must match keys.js exactly (the credentials namespace has no enumeration, so the card can only describe reference names it writes down), pinned by test/client-bundle.test.js. The rotation/cooldown policy lives in keys.js (pure functions) and the host half re-resolves the whole pool on every operation (the same "resolve per call" contract the single key always had); /api/dsh-jina/primer probes every key in parallel, cleans up dead keys, and returns just four numbers — usable count, key count, total balance, discarded count. The endpoint field rides the settings Remote namespace (remote.settings.describe/mutate, each write fenced by the revision the page read, with external edits arriving as the forwarded settings/document-updated).
  • The composition layer follows dsh conventions: one dual-face row dsh-jina carries both the host half and the browser half. The browser half is declared by the ROOT manifest's dsh.client (platform: web, graph edge @deepseek-ai/dsh-api-remotes) plus exports["./client"]; the host's client-modules service locates the root manifest by the row name (an exact package name) and wires it into the Web boot graph. Note the client-modules scan accepts only exact-package-name rows: subpath rows (e.g. dsh-jina/ui) are never scanned as client rows — the browser half must be declared at the package root.

Tests

Pure logic (endpoint policy, primer parsing/formatting, etc.) is covered by the Node built-in test runner with zero dependencies:

npm test   # same as node --test (auto-discovers test/*.test.js)
  • test/settings.test.js, test/primer.test.js, test/keys.test.js, test/tools.test.js: pure functions and the model-facing contract (keys.test.js covers the reference grammar, key-file parsing, status mapping, cooldown folding, rotation order, the pool signature and source labels; settings.test.js covers endpoint-mode validation, routing order and NO_PROXY merging).

  • test/multi-key.test.js: drives the host half through a fake Cordis context, decodes the network helper's stdin for every request and asserts the Authorization header — covering a 402 handing over to a backup key and deleting it from the credential store, 401 doing the same, 429 only parking, read-only and file-sourced keys never deleted, round-robin rotation and cooldown skipping, a three-key walk, no rotation on 422/5xx/network failure (a transport failure only switches the endpoint domain, never the key), an explicit apiKey never rotating, per-key reporting for an exhausted pool, the key-file fallback with several lines, duplicate-key collapsing, an added key reaching the next call, and a primer route that returns only counts and the total while discarding a zero-balance key.

  • test/reader-headers.test.js: drives the host half through a fake Cordis context and decodes the network helper's stdin, asserting the Reader headers jina_read really sends — the fixed parameters (X-Preset: agent / X-Base: final), X-Timeout and the client ceiling split into the 30 s/45 s selector pair and the 120 s/120 s whole-page pair, the image policy, the selector group's three fallback shapes (short body / 422 / timeout) and "an explicit empty targetSelector reads the whole page", the OCR switch and X-Page, zero requests when no key can be resolved, the three alt-text constraints (opt-in, key-gated, mutually exclusive with OCR), JSON-envelope unwrapping with usage, verbatim fallback for an unparsable body, the schema's field declarations (all seven fields carry meta.volatile), the validator's tolerant surface, and "a save reaches the next call".

  • test/plugin-endpoints.test.js: drives the host half through a fake Cordis context and asserts the Config export's volatile contract, the URL each attempt really requests, the environment the network helper actually receives (a mainland domain gets NO_PROXY appended, every other case inherits it whole), the domains named in the error text, and the /api/dsh-jina/primer payload (including settingsLive and the endpoint side this check used). Its JINA_LIVE_CN=1 case really spawns the helper and completes one mainland-domain request:

    $env:JINA_LIVE_CN='1'; npm test
    

    Where child processes cannot be spawned (a sandboxed runner) that case skips itself with the reason.

  • test/client-bundle.test.js: parses the prebuilt ui/client.js and pins the registration id, the jina-tools key, the settings transport, revision fencing, the single-input key form (one keyDraft string + firstFreeRef + add, and the card itself never calls unset) and its agreement with keys.js — the bundle has no build step, so a syntax error would otherwise surface only at runtime.

Fixtures use real captured r.jina.ai / ipinfo.io response shapes; tests cover parse tolerance, time-fact derivation, text/JSON rendering and the "never prints undefined" contract.