@tiens.nguyen/gonext-local-worker 1.0.330 → 1.0.332

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1768,6 +1768,76 @@ function _warmupPing(quiet) {
1768
1768
  });
1769
1769
  }
1770
1770
 
1771
+ // ─── Coding-backend resolution (task #117, folded into #123 Part B) ──────────────────
1772
+ // ONE answer to "which coding backend is this job using?", for the two decisions THIS
1773
+ // process makes before python starts: keep an Ollama model warm, and fetch the
1774
+ // OpenAI-compatible API key. The answer is passed down as `codingBackend` so python
1775
+ // doesn't re-derive it and can't disagree.
1776
+ //
1777
+ // The reference implementation is _resolve_coding_backend() in gonext_agent_chat.py —
1778
+ // it is pure and carries the offline resolution table (tests/test_backend_resolution.py).
1779
+ // Keep this rule in step with it: explicit kind wins → Ollama probe → remote ⇒ openai →
1780
+ // local. The probe runs BEFORE the remote rule so a remote Ollama box stays "ollama".
1781
+
1782
+ // root → { value, until }. A YES is cached long (a box doesn't stop being Ollama), a NO
1783
+ // only briefly: "not Ollama" and "the probe failed" are indistinguishable here, so caching
1784
+ // a failure would strand a box that was merely booting/unreachable for one job.
1785
+ const _ollamaProbeCache = new Map();
1786
+ const _PROBE_TTL_YES_MS = 30 * 60_000;
1787
+ const _PROBE_TTL_NO_MS = 60_000;
1788
+
1789
+ async function _probeIsOllama(baseURL) {
1790
+ const root = String(baseURL || "").replace(/\/+$/, "").replace(/\/v1$/i, "");
1791
+ if (!root) return false;
1792
+ const hit = _ollamaProbeCache.get(root);
1793
+ if (hit && hit.until > Date.now()) return hit.value;
1794
+ let isOllama = false;
1795
+ try {
1796
+ // Ollama answers GET / with the plain-text banner "Ollama is running".
1797
+ const res = await fetch(root, {
1798
+ headers: { "User-Agent": "gonext-worker/1.0" },
1799
+ signal: AbortSignal.timeout(6000),
1800
+ });
1801
+ isOllama = res.ok && /ollama/i.test((await res.text()).slice(0, 200));
1802
+ } catch {
1803
+ isOllama = false; // unreachable/TLS/404 → not provably Ollama
1804
+ }
1805
+ _ollamaProbeCache.set(root, {
1806
+ value: isOllama,
1807
+ until: Date.now() + (isOllama ? _PROBE_TTL_YES_MS : _PROBE_TTL_NO_MS),
1808
+ });
1809
+ return isOllama;
1810
+ }
1811
+
1812
+ function _isLocalCodingUrl(baseURL) {
1813
+ let host = "";
1814
+ try {
1815
+ host = new URL(String(baseURL || "")).hostname.toLowerCase().replace(/\.$/, "");
1816
+ } catch {
1817
+ return true; // unparseable → we are certainly not talking to a paid remote
1818
+ }
1819
+ if (!host) return true;
1820
+ if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
1821
+ if (["localhost", "0.0.0.0", "::", "::1"].includes(host)) return true;
1822
+ if (/^127(\.\d{1,3}){3}$/.test(host)) return true;
1823
+ if (/^(10(\.\d{1,3}){3}|192\.168(\.\d{1,3}){2}|172\.(1[6-9]|2\d|3[01])(\.\d{1,3}){2}|169\.254(\.\d{1,3}){2})$/.test(host))
1824
+ return true;
1825
+ if (host.includes(":") && (host.startsWith("fe80:") || /^f[cd]/.test(host))) return true;
1826
+ if (/\.(local|localdomain|lan|home|internal)$/.test(host)) return true;
1827
+ // A dotless hostname ("mac-studio", "ollama1") is a LAN box: a public host always has a
1828
+ // dot, and erring this way only ever means "don't expect an API key here".
1829
+ return !host.includes(".") && !host.includes(":");
1830
+ }
1831
+
1832
+ /** Resolve to "ollama" | "openai" | "local". `kind` is the user's Settings choice
1833
+ * ("ollama" | "openai" | "" = Auto). Only Auto costs a probe. */
1834
+ async function resolveCodingBackend(kind, baseURL) {
1835
+ if (kind === "ollama") return "ollama";
1836
+ if (kind === "openai") return "openai";
1837
+ if (await _probeIsOllama(baseURL)) return "ollama";
1838
+ return _isLocalCodingUrl(baseURL) ? "local" : "openai";
1839
+ }
1840
+
1771
1841
  /**
1772
1842
  * Remember the agent's Ollama coding model (from a claimed agent_chat job) and keep it
1773
1843
  * resident via a background interval. Warms IMMEDIATELY only when the model is first
@@ -1801,19 +1871,33 @@ async function runAgentChatJob(job) {
1801
1871
  // "cancelled" status with completed/failed.
1802
1872
  const cancelAc = new AbortController();
1803
1873
  let cancelled = false;
1804
- // Remember this job's Ollama coding model and keep it resident via a background
1805
- // interval. Only warms now if the model is new/changed — repeat questions rely on
1806
- // the interval, so we don't re-warm (or re-log) on every question. The warm-up uses
1807
- // Ollama's native /api/generate, so SKIP it for an OpenAI-compatible coder (task #108):
1808
- // that endpoint doesn't exist there and we shouldn't hit a paid API with keep-warm pings.
1809
- if (payload?.codingKind !== "openai") {
1810
- trackCodingModelForWarmup(payload?.codingBaseURL ?? "", payload?.codingModelId ?? "");
1811
- }
1874
+ // Task #108: the user's Coding backend setting ("ollama" | "openai" | "" = Auto).
1875
+ const codingKind =
1876
+ payload?.codingKind === "ollama" || payload?.codingKind === "openai" ? payload.codingKind : "";
1812
1877
  const runRes = await workerFetch(`/api/worker/jobs/${jobId}`, {
1813
1878
  method: "PATCH",
1814
1879
  body: JSON.stringify({ jobStatus: "running" }),
1815
1880
  });
1816
1881
  await ensureWorkerOk(runRes, `mark running agent_chat jobId=${jobId}`);
1882
+ // Task #117/#123: `codingKind` above is the SETTING, not the answer — resolve the actual
1883
+ // backend ONCE here and let both decisions below, plus python, read that one value.
1884
+ // AFTER the "running" PATCH on purpose: an Auto config costs a probe (~6s worst case,
1885
+ // then cached), and the REPL shouldn't watch a claimed job sit in "queued" for it.
1886
+ const codingBackend = await resolveCodingBackend(codingKind, payload?.codingBaseURL ?? "");
1887
+ console.log(
1888
+ `[gonext-worker] agent job ${jobId}: coding backend ${codingBackend}` +
1889
+ ` (kind=${codingKind || "auto"}, ${payload?.codingBaseURL || "no coding url"})`
1890
+ );
1891
+ // Remember this job's Ollama coding model and keep it resident via a background
1892
+ // interval. Only warms now if the model is new/changed — repeat questions rely on
1893
+ // the interval, so we don't re-warm (or re-log) on every question. The warm-up uses
1894
+ // Ollama's native /api/generate, so it runs ONLY for an actual Ollama backend: on
1895
+ // anything else that endpoint doesn't exist, and on a cloud one we'd be pinging a paid
1896
+ // API every 4 minutes (which an Auto+cloud config did until #117 was fixed — the old
1897
+ // gate only skipped an EXPLICIT openai kind).
1898
+ if (codingBackend === "ollama") {
1899
+ trackCodingModelForWarmup(payload?.codingBaseURL ?? "", payload?.codingModelId ?? "");
1900
+ }
1817
1901
 
1818
1902
  let buf = "";
1819
1903
  let flushTimer = null;
@@ -1902,14 +1986,14 @@ async function runAgentChatJob(job) {
1902
1986
  jobActiveWorkspace = webWork;
1903
1987
  }
1904
1988
 
1905
- // Task #108: coding backend kind ("ollama" | "openai" | "" = Auto/sniff). For an
1906
- // OpenAI-compatible coder, fetch the API key from a dedicated worker-authed endpoint
1907
- // (NOT the job payload/Mongo doc secret hygiene) and inject it into python stdin only.
1908
- const codingKind = payload?.codingKind === "ollama" || payload?.codingKind === "openai"
1909
- ? payload.codingKind
1910
- : "";
1989
+ // Task #108: for an OpenAI-compatible coder, fetch the API key from a dedicated
1990
+ // worker-authed endpoint (NOT the job payload/Mongo doc secret hygiene) and inject
1991
+ // it into python stdin only. Keyed off the RESOLVED backend (#117), so an Auto config
1992
+ // pointing at a cloud endpoint now gets its key: it used to require the explicit
1993
+ // "openai" kind, and python's `coding_api_key or agent_api_key` fallback then
1994
+ // authenticated a cloud provider with the LOCAL agent key → 401.
1911
1995
  let codingApiKey = "";
1912
- if (codingKind === "openai") {
1996
+ if (codingBackend === "openai") {
1913
1997
  try {
1914
1998
  const kr = await workerFetch("/api/worker/coding-key");
1915
1999
  if (kr.ok) {
@@ -1929,6 +2013,11 @@ async function runAgentChatJob(job) {
1929
2013
  codingBaseURL: payload?.codingBaseURL ?? "",
1930
2014
  codingModelId: payload?.codingModelId ?? "",
1931
2015
  codingKind,
2016
+ // The resolved backend (#117): python trusts this instead of probing again, so the
2017
+ // key fetch above and python's streaming / repair / step-budget branches can't
2018
+ // disagree about what this endpoint is. Python re-resolves only if it ends up on a
2019
+ // DIFFERENT url than the one judged here (its no-model-id fallback to the chat model).
2020
+ codingBackend,
1932
2021
  codingApiKey,
1933
2022
  // Task #107: when true, python emits a {type:code_response} event per code-model
1934
2023
  // call so the worker persists the full raw text to Mongo.
@@ -879,6 +879,112 @@ def _is_ollama_server(base_url):
879
879
  return False
880
880
 
881
881
 
882
+ # Loopback and RFC1918/link-local ranges — a URL on one of these is a box the user owns,
883
+ # which is the whole point: only a REMOTE endpoint can plausibly need an API key.
884
+ _LOOPBACK_RE = re.compile(r"^127(?:\.\d{1,3}){3}$")
885
+ _PRIVATE_V4_RE = re.compile(
886
+ r"^(?:10(?:\.\d{1,3}){3}"
887
+ r"|192\.168(?:\.\d{1,3}){2}"
888
+ r"|172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}"
889
+ r"|169\.254(?:\.\d{1,3}){2})$"
890
+ )
891
+ # mDNS / router-assigned search domains. A bare dotless hostname counts too (see below).
892
+ _LOCAL_SUFFIXES = (".local", ".localdomain", ".lan", ".home", ".internal")
893
+
894
+
895
+ def _is_local_url(base_url) -> bool:
896
+ """True when the base URL points at this machine or the local network.
897
+
898
+ Kept pure (no I/O) so the backend resolution table can be replayed offline. A dotless
899
+ hostname ("mac-studio", "ollama1") is treated as LAN: a public host effectively always
900
+ has a dot, and being wrong in that direction is the safe way — a LAN box is simply
901
+ never told to expect an API key.
902
+ """
903
+ host = _host_of(base_url)
904
+ if "@" in host:
905
+ host = host.rsplit("@", 1)[1] # strip user:pass@
906
+ if host.startswith("["):
907
+ host = host[1:].split("]", 1)[0] # [::1]:8080 → ::1
908
+ elif host.count(":") == 1:
909
+ host = host.rsplit(":", 1)[0] # host:port → host
910
+ host = host.strip().lower().rstrip(".")
911
+ if not host:
912
+ return True # no host at all → we are not talking to anyone remote
913
+ if host in ("localhost", "0.0.0.0", "::", "::1"):
914
+ return True
915
+ if _LOOPBACK_RE.match(host) or _PRIVATE_V4_RE.match(host):
916
+ return True
917
+ if ":" in host and (host.startswith("fe80:") or host[:2] in ("fc", "fd")):
918
+ return True # IPv6 link-local / unique-local
919
+ if host.endswith(_LOCAL_SUFFIXES):
920
+ return True
921
+ return "." not in host and ":" not in host
922
+
923
+
924
+ def _resolve_coding_backend(kind, base_url, probe=None):
925
+ """Answer "which coding backend is this?" ONCE. Returns (backend, why) where backend
926
+ is one of "ollama" | "openai" | "local" and `why` is log copy explaining the choice.
927
+
928
+ Task #117 (folded into #123). This used to be re-derived in three places from TWO
929
+ different questions — `coding_is_ollama` ("is this an Ollama server?", explicit kind
930
+ else a URL sniff) and `coding_kind == "openai"` ("did the user explicitly pick
931
+ OpenAI-compatible?") — and Auto fell between them: a cloud endpoint left on the
932
+ default Auto sniffed as not-Ollama and was not explicitly openai, so it got NEITHER
933
+ treatment (no streaming, none of the #113 reasoning repairs, and the small local step
934
+ budget). Every branch now keys off this one value.
935
+
936
+ Order matters. The Ollama probe runs BEFORE the remote-host rule so a remote Ollama
937
+ (ollama1.gomarsic.cc) still resolves to "ollama" and keeps its own treatment.
938
+
939
+ explicit kind → that value
940
+ probe says Ollama → "ollama"
941
+ remote URL (not ours) → "openai" ← only a remote endpoint needs a key
942
+ otherwise → "local" (an MLX box on 127.0.0.1)
943
+
944
+ Deliberately NOT keyed off "is an API key set": the Settings UI only renders the key
945
+ field once the OpenAI-compatible kind is picked, so on Auto the user cannot supply one
946
+ — a stored key there is only ever a leftover from switching the dropdown back, and
947
+ using it as the test would make the answer depend on that history.
948
+
949
+ `probe` is injectable so the resolution table can be replayed offline without network.
950
+ """
951
+ k = (kind or "").strip().lower()
952
+ if k == "ollama":
953
+ return "ollama", "explicit setting"
954
+ if k == "openai":
955
+ return "openai", "explicit setting"
956
+ if (probe or _is_ollama_server)(base_url):
957
+ return "ollama", "auto: server answers as Ollama"
958
+ if not _is_local_url(base_url):
959
+ return "openai", "auto: remote URL, not Ollama"
960
+ return "local", "auto: local URL, not Ollama"
961
+
962
+
963
+ def _coding_backend_flags(backend):
964
+ """Everything the resolved backend decides, derived in ONE place (task #117 → #123).
965
+
966
+ Pure, so the whole resolution → behaviour table can be replayed offline. The callers
967
+ below must not re-derive any of this from `coding_kind` or a URL sniff — that drift is
968
+ the bug this fixes.
969
+
970
+ ollama_tweaks — reasoning_effort='none' (Ollama's OpenAI-compat endpoint ignores
971
+ Qwen3's /no_think but honors this) and the worker's keep-warm ping.
972
+ stream — Ollama and remote OpenAI-compatible endpoints stream, so a long
973
+ generation can't trip a proxy idle read-timeout and include_usage
974
+ keeps output-token counting alive. Local MLX stays non-streaming.
975
+ openai_repairs— the #113 reasoning-model repairs (prose-only nudge, parse-error
976
+ sanitizing, no-code streak breaker). Never applied to Ollama/MLX.
977
+ budget — (default, workspace) step budget: a cloud coder is stronger and
978
+ cheaper-per-step than a small local model, so it gets more room.
979
+ """
980
+ return {
981
+ "ollama_tweaks": backend == "ollama",
982
+ "stream": backend in ("ollama", "openai"),
983
+ "openai_repairs": backend == "openai",
984
+ "budget": (20, 40) if backend == "openai" else (10, 20) if backend == "ollama" else (5, 12),
985
+ }
986
+
987
+
882
988
  def _detect_model_id(base_url, api_key=""):
883
989
  """Ask an OpenAI-compatible server which model it serves (first reported id).
884
990
 
@@ -3509,9 +3615,10 @@ def run_agent_chat(cfg):
3509
3615
  raw_coding_base = _normalize_openai_base(cfg.get("codingBaseURL") or "")
3510
3616
  raw_coding_model = (cfg.get("codingModelId") or "").strip()
3511
3617
  # Task #108: explicit coding backend kind + API key for an OpenAI-compatible endpoint.
3512
- # coding_kind: "ollama" | "openai" | "" (Auto sniff by URL). coding_api_key: the
3513
- # Bearer key for an OpenAI-compatible coder (empty otherwise). The key is used for the
3514
- # code-model calls (and coding-model auto-detect), NOT the chat model's agent_api_key.
3618
+ # coding_kind: "ollama" | "openai" | "" the USER'S SETTING, not the answer. What the
3619
+ # branches read is `coding_backend` below, resolved once from this plus the URL.
3620
+ # coding_api_key: the Bearer key for an OpenAI-compatible coder (empty otherwise). Used
3621
+ # for the code-model calls (and coding-model auto-detect), NOT the chat agent_api_key.
3515
3622
  coding_kind = (cfg.get("codingKind") or "").strip().lower()
3516
3623
  if coding_kind not in ("ollama", "openai"):
3517
3624
  coding_kind = ""
@@ -3568,20 +3675,22 @@ def run_agent_chat(cfg):
3568
3675
  # local MLX keeps the original tight default). Overridable via cfg.maxSteps.
3569
3676
  # The provide_final_answer override below is only the exhaustion fallback.
3570
3677
  #
3571
- # Resolve the effective backend the SAME way as the model setup further down
3572
- # (explicit kind wins; Auto sniffs the URL) so the budgets match the coder that runs.
3573
- if coding_kind == "openai":
3574
- _budget_is_ollama = False
3575
- elif coding_kind == "ollama":
3576
- _budget_is_ollama = True
3577
- else:
3578
- _budget_is_ollama = _is_ollama_server(coding_base_url)
3579
- if coding_kind == "openai":
3580
- _default_budget, _ws_budget = 20, 40 # OpenAI-compatible coder
3581
- elif _budget_is_ollama:
3582
- _default_budget, _ws_budget = 10, 20 # Ollama coder
3678
+ # THE ONE RESOLUTION (task #117 #123 Part B). Everything that used to ask "is this
3679
+ # Ollama?" or "did the user pick openai?" separately reasoning_effort, streaming, the
3680
+ # #113 reasoning repairs, and this step budget — now reads `coding_backend`.
3681
+ #
3682
+ # The worker (gonext-local-worker.mjs) resolves this first, because it must decide
3683
+ # whether to fetch the coding API key BEFORE python starts, and passes the answer down
3684
+ # as codingBackend. Trust it only when we ended up on the URL it judged: the fallbacks
3685
+ # above can swap coding_base_url for the chat model's, which is a different server and
3686
+ # may well be a different KIND of server.
3687
+ _backend_hint = (cfg.get("codingBackend") or "").strip().lower()
3688
+ if _backend_hint in ("ollama", "openai", "local") and coding_base_url == raw_coding_base:
3689
+ coding_backend, _backend_why = _backend_hint, "resolved by the worker"
3583
3690
  else:
3584
- _default_budget, _ws_budget = 5, 12 # local MLX / Auto — original defaults
3691
+ coding_backend, _backend_why = _resolve_coding_backend(coding_kind, coding_base_url)
3692
+ coding_flags = _coding_backend_flags(coding_backend)
3693
+ _default_budget, _ws_budget = coding_flags["budget"]
3585
3694
  try:
3586
3695
  max_steps = int(cfg.get("maxSteps") or _default_budget)
3587
3696
  except (TypeError, ValueError):
@@ -6268,25 +6377,16 @@ def run_agent_chat(cfg):
6268
6377
  # the live test — minutes per step on a slow GPU — so disable it explicitly.
6269
6378
  # Sniffed per-server because MLX might reject the unknown param.
6270
6379
  extra_model_kwargs = {}
6271
- # Task #108: the explicit backend kind (from web Settings) OVERRIDES the URL-sniff.
6272
- # Auto ("") sniff by URL (today's behavior; correctly False for MLX). Only Ollama
6273
- # gets reasoning_effort='none' (MLX/OpenAI reject or don't need it).
6274
- if coding_kind == "ollama":
6275
- coding_is_ollama = True
6276
- elif coding_kind == "openai":
6277
- coding_is_ollama = False
6278
- else:
6279
- coding_is_ollama = _is_ollama_server(coding_base_url)
6280
- if coding_is_ollama:
6380
+ # `coding_backend` was resolved once, above, and `coding_flags` derives everything
6381
+ # from it do NOT re-derive any of this from the kind or a URL sniff here.
6382
+ if coding_flags["ollama_tweaks"]:
6281
6383
  _log("coding server is Ollama → reasoning_effort='none' (disable thinking) + streaming")
6282
6384
  extra_model_kwargs["reasoning_effort"] = "none"
6283
- # Stream for Ollama AND for OpenAI-compatible remotes (task #108/#104): a remote
6284
- # endpoint behind a proxy hits the same idle read-timeout streaming avoids, and
6285
- # stream_options.include_usage keeps output-token counting working. Only a LOCAL
6286
- # MLX coder (Auto, non-ollama) stays non-streaming.
6287
- _stream_coder = coding_is_ollama or coding_kind == "openai"
6288
- _log(f"coding backend: kind={coding_kind or 'auto'} ollama={coding_is_ollama} "
6289
- f"stream={_stream_coder} key={'yes' if coding_api_key else 'no'}")
6385
+ _stream_coder = coding_flags["stream"]
6386
+ _log(f"coding backend: {coding_backend} ({_backend_why}; "
6387
+ f"kind={coding_kind or 'auto'}, {_host_of(coding_base_url)}) "
6388
+ f"stream={_stream_coder} steps={max_steps} "
6389
+ f"key={'yes' if coding_api_key else 'no'}")
6290
6390
  model = _LoggingModel(
6291
6391
  model_id=coding_model_id,
6292
6392
  api_base=coding_base_url,
@@ -6299,9 +6399,12 @@ def run_agent_chat(cfg):
6299
6399
  # Task #107: persist each code-model response to Mongo when the user opted in.
6300
6400
  save_full_response=save_full_response,
6301
6401
  # Task #113: enables the reasoning-model repairs (prose-only nudge, parse-error
6302
- # sanitizing, no-code breaker). Explicit kind=openai ONLY — Ollama and Auto/MLX
6303
- # never take those branches.
6304
- openai_backend=(coding_kind == "openai"),
6402
+ # sanitizing, no-code breaker). Gated to the OpenAI-compatible backend — Ollama
6403
+ # and local MLX never take those branches. #113 gated this on the EXPLICIT
6404
+ # kind; the intent (don't touch Ollama) is unchanged, but an Auto+cloud config
6405
+ # now resolves to "openai" instead of "local", so it finally gets the repairs
6406
+ # it was always meant to have.
6407
+ openai_backend=coding_flags["openai_repairs"],
6305
6408
  # Cap each HTTP attempt at 600s so a genuinely slow single call (a
6306
6409
  # remote M6000/Vulkan box doing a cold ~4-5min prompt-eval) can finish
6307
6410
  # on the first try, while a truly hung request still fails into OUR
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.330",
3
+ "version": "1.0.332",
4
4
  "description": "Polls GoNext cloud API for async local LLM jobs and runs them against Ollama/OpenAI-compatible servers on this Mac",
5
5
  "type": "module",
6
6
  "license": "MIT",