@nanhara/hara 0.109.5 → 0.110.0

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,32 @@ All notable changes to `@nanhara/hara`.
5
5
  > Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
6
6
  > **patch** (last) number bumps for **optimizations/fixes of existing features**.
7
7
 
8
+ ## 0.110.0 — a provider registry: one key, many platforms, the right wire + thinking control for each
9
+
10
+ - **hara now speaks each platform its own way, chosen from a data-driven registry (a dictionary), not
11
+ scattered if/else.** One row per platform declares its **wire protocol** (chat / Anthropic / Responses)
12
+ + how it expresses the **thinking dial** + how it **caches**. Point hara at a custom baseURL and it Just
13
+ Works:
14
+ - **Any vendor's `.../anthropic` endpoint** — DeepSeek, Kimi/Moonshot, Zhipu GLM, MiniMax, and Alibaba's
15
+ `…/apps/anthropic` — now routes through the **Anthropic wire** (so you get prompt caching + a native
16
+ thinking budget). Verified end-to-end against Alibaba's endpoint.
17
+ - **Local Ollama / LM Studio** (`localhost:11434` / `1234`) — `reasoning off` sends `think:false`, which
18
+ actually stops a local reasoning model's thinking phase (**measured: deepseek-r1:14b 17s → 0.6s**).
19
+ - **Alibaba DashScope** chat (coding plan / pay-as-you-go) — `reasoning off` → `enable_thinking:false`
20
+ (**qwen3.7-plus ~14s → ~1.6s**); works for a custom `qwen3.7-plus`/`glm-5` profile, keyed on the
21
+ endpoint, not the model name.
22
+ - OpenAI reasoning models keep `reasoning_effort`; Anthropic keeps its thinking budget. The dial **UNSET
23
+ leaves the request untouched** everywhere (model default, zero impact — the safe default).
24
+ - The Responses API (Alibaba Token Plan's newest models) is a distinct wire hara doesn't speak yet; a
25
+ Responses endpoint now returns a clear pointer to use the chat or `/apps/anthropic` endpoint instead
26
+ (rather than sending a body it would reject). Coming once there's a Token-Plan key to verify against.
27
+ - **Windows: hara no longer hangs at startup / on the first command.** Three SYNCHRONOUS probes ran with
28
+ no timeout, so on Windows a slow/hung one froze the whole process (main-thread block — nothing could
29
+ interrupt it): the `where bash` shell probe (added in 0.109.0), `git ls-files` (the "which files exist"
30
+ scan), and the per-turn shadow `git add -A`. All three are now bounded (3s / 5s / 10s) and fall back
31
+ gracefully (cmd.exe / filesystem walk / skip the snapshot). This is the "stuck at shell/directory
32
+ probing, never got to the actual work" hang.
33
+
8
34
  ## 0.109.5 — Enter enters the conversation instantly + reasoning off truly disables DashScope thinking
9
35
 
10
36
  - **Pressing Enter now enters the conversation flow instantly — the message no longer sits stuck in the
@@ -22,6 +22,10 @@ function git(root, gitDir, args) {
22
22
  encoding: "utf8",
23
23
  stdio: ["ignore", "pipe", "ignore"],
24
24
  maxBuffer: 64 * 1024 * 1024,
25
+ // Bound it: the per-turn shadow `git add -A` over a large tree (or a hung git) must never freeze a
26
+ // turn. On timeout execFileSync throws → checkpoint()/ensureRepo() catch it → the snapshot is just
27
+ // skipped (best-effort), the turn proceeds.
28
+ timeout: 10000,
25
29
  }).toString();
26
30
  }
27
31
  function ensureRepo(root, gitDir) {
package/dist/fs-walk.js CHANGED
@@ -53,6 +53,9 @@ export function listProjectFiles(root, cap = 8000) {
53
53
  encoding: "utf8",
54
54
  maxBuffer: 32 * 1024 * 1024,
55
55
  stdio: ["ignore", "pipe", "ignore"],
56
+ // Bound it: a hung git (credential-helper GUI, a slow/network filesystem, a giant repo) must not
57
+ // freeze the "which files exist" probe — on timeout it throws → we fall through to the fs walk.
58
+ timeout: 5000,
56
59
  })
57
60
  .split("\n")
58
61
  .map((s) => s.trim())
package/dist/index.js CHANGED
@@ -42,6 +42,7 @@ import { getTools } from "./tools/registry.js";
42
42
  import { EXPLORE_SYSTEM } from "./tools/agent.js";
43
43
  import { createAnthropicProvider } from "./providers/anthropic.js";
44
44
  import { createOpenAIProvider } from "./providers/openai.js";
45
+ import { resolvePlatform } from "./providers/registry.js";
45
46
  import { qwenDeviceLogin, getValidQwenAuth } from "./providers/qwen-oauth.js";
46
47
  import { loadAgentsMd, hasAgentsMd, INIT_PROMPT, findProjectRoot } from "./context/agents-md.js";
47
48
  import { getEmbedder } from "./search/embed.js";
@@ -127,9 +128,25 @@ async function buildProvider(cfg) {
127
128
  }
128
129
  if (!apiKey)
129
130
  return null;
130
- if (provider === "anthropic") {
131
+ // Transport is chosen from the provider REGISTRY (the dictionary) by wire protocol — so a custom
132
+ // baseURL Just Works: a vendor's `.../anthropic` endpoint (DeepSeek/Kimi/GLM/MiniMax/Aliyun) speaks the
133
+ // Anthropic wire (gets cache_control + thinking budget), DashScope/Ollama/OpenAI speak chat, etc.
134
+ const wire = resolvePlatform(provider, baseURL).wireApi;
135
+ if (wire === "anthropic") {
131
136
  return createAnthropicProvider({ apiKey, model, baseURL, reasoningEffort: cfg.reasoningEffort });
132
137
  }
138
+ if (wire === "responses") {
139
+ // The OpenAI Responses API (e.g. Aliyun Token Plan's newest models) — a distinct wire hara doesn't
140
+ // speak yet. Fail with guidance instead of sending a chat body it will reject. (Tracked for when a
141
+ // Token-Plan key is available to build + verify against; the chat + anthropic endpoints work today.)
142
+ return {
143
+ id: provider,
144
+ model,
145
+ async turn() {
146
+ return { text: "", toolUses: [], stop: "error", errorMsg: `This endpoint uses the OpenAI Responses API, which hara doesn't speak yet. Point hara at the plan's OpenAI-compatible chat endpoint (…/compatible-mode/v1 or …/v1) or its Anthropic-compatible endpoint (…/apps/anthropic) instead.` };
147
+ },
148
+ };
149
+ }
133
150
  return createOpenAIProvider({ apiKey, model, baseURL, label: provider, reasoningEffort: cfg.reasoningEffort });
134
151
  }
135
152
  /** Wrap the main provider with per-turn model routing when `routeModel` is configured: trivial/non-coding
@@ -1,5 +1,9 @@
1
1
  import OpenAI from "openai";
2
2
  import { imageToBase64 } from "../images.js";
3
+ import { reasoningParams } from "./reasoning.js";
4
+ import { resolvePlatform } from "./registry.js";
5
+ // Re-exported for callers that still import it from here (the reasoning-family check now lives in reasoning.ts).
6
+ export { isReasoningModel } from "./reasoning.js";
3
7
  /** Build OpenAI chat-completions messages from neutral history. */
4
8
  export function toOpenAI(system, history) {
5
9
  const msgs = [{ role: "system", content: system }];
@@ -45,27 +49,6 @@ export function toOpenAI(system, history) {
45
49
  }
46
50
  return msgs;
47
51
  }
48
- /** Reasoning models on OpenAI (o-series + gpt-5) accept `reasoning_effort` on chat-completions.
49
- * Non-reasoning models reject it. We only attach the param when the model id matches a known
50
- * reasoning family — keeps DeepSeek/GLM/Qwen requests clean. Exported for tests. */
51
- export function isReasoningModel(model) {
52
- return /^(o1|o3|o4|gpt-5)/i.test(model);
53
- }
54
- /** DashScope (Alibaba — serves Qwen, GLM, …) runs a "thinking" phase that streams reasoning BEFORE the
55
- * answer — the main latency there (measured: qwen3.7-plus ~14s thinking vs ~1.6s without). It can be
56
- * silenced SERVER-SIDE with `enable_thinking:false` (actually stops generating the reasoning, not just
57
- * hides it). So map hara's reasoning dial onto it: an explicit **off** → thinking off (fast);
58
- * low/medium/high → on. Returns the flag to send, or `undefined` = leave the request UNTOUCHED — when
59
- * the dial is UNSET (keep the model's own default; zero impact — the safe default the user asked for)
60
- * or the endpoint isn't DashScope. Detected by the DashScope ENDPOINT (built-in qwen/qwen-oauth
61
- * providers, OR a custom baseURL on dashscope — which is how the reporter's `custom:qwen3.7-plus`
62
- * profile is set up), NOT the model name, so it covers custom Qwen/GLM profiles too. Exported for tests. */
63
- export function dashscopeThinking(effort, baseURL, label) {
64
- const isDashscope = /dashscope\.aliyuncs\.com/i.test(baseURL ?? "") || label === "qwen" || label === "qwen-oauth";
65
- if (!isDashscope || effort === undefined)
66
- return undefined;
67
- return effort !== "off";
68
- }
69
52
  /** OpenAI-compatible provider (works with OpenAI, Qwen/DashScope, GLM, Kimi, …). */
70
53
  export function createOpenAIProvider(opts) {
71
54
  const client = new OpenAI({ apiKey: opts.apiKey, maxRetries: 4, ...(opts.baseURL ? { baseURL: opts.baseURL } : {}) });
@@ -86,18 +69,12 @@ export function createOpenAIProvider(opts) {
86
69
  };
87
70
  if (oaiTools.length)
88
71
  params.tools = oaiTools;
89
- // reasoning_effort: only attach for OpenAI reasoning models, and only when the user picked a
90
- // non-default level. "off" means "don't ask the model to reason" — for reasoning models we
91
- // pass "minimal" (gpt-5 / o-series accept it); on chat-style models that put reasoning in
92
- // the stream (DeepSeek/GLM) we can't silence it server-side, so the UI just won't render.
93
- if (opts.reasoningEffort && opts.reasoningEffort !== undefined && isReasoningModel(opts.model)) {
94
- params.reasoning_effort = opts.reasoningEffort === "off" ? "minimal" : opts.reasoningEffort;
95
- }
96
- // DashScope: reasoning "off" REALLY disables the thinking phase (big speedup), not just hides it.
97
- // Only fires on the DashScope endpoint + when the dial is set — UNSET stays untouched (zero impact).
98
- const dsThink = dashscopeThinking(opts.reasoningEffort, opts.baseURL, opts.label);
99
- if (dsThink !== undefined)
100
- params.enable_thinking = dsThink;
72
+ // Reasoning: the registry says HOW this platform expresses the thinking dial (DashScope
73
+ // enable_thinking, OpenAI reasoning models reasoning_effort, …); the applier turns hara's dial
74
+ // into the params to merge. UNSET {} (model default, zero impact). One data-driven line replaces
75
+ // the old per-platform if/else a new platform is a registry row, not code here.
76
+ const caps = resolvePlatform(opts.label, opts.baseURL);
77
+ Object.assign(params, reasoningParams(caps.reasoning, opts.reasoningEffort, opts.model));
101
78
  // Stream: emit text deltas live; accumulate tool-call args by index; grab usage from the tail chunk.
102
79
  let text = "";
103
80
  const acc = new Map();
@@ -0,0 +1,39 @@
1
+ // Reasoning control — data-driven. Every platform in the provider registry declares a reasoning STYLE
2
+ // (how it wants the thinking dial expressed on the wire); this module owns the small set of styles and
3
+ // maps hara's dial (off/low/medium/high, or UNSET) onto the request params each style needs. Adding a
4
+ // platform that reuses a style is pure data in the registry — no new code here. New code only when a
5
+ // genuinely new style appears (a new provider param shape).
6
+ /** OpenAI reasoning families that accept `reasoning_effort` / `reasoning.effort`. Others reject it, so the
7
+ * `reasoning_effort` / `reasoning_object` styles no-op on non-reasoning models. */
8
+ export function isReasoningModel(model) {
9
+ return /^(o1|o3|o4|gpt-5)/i.test(model);
10
+ }
11
+ /** Translate the dial into request params to MERGE into the wire body (chat/responses styles). Returns an
12
+ * empty object — leave the request untouched — when the dial is UNSET (keep the model's own default; zero
13
+ * impact, the safe default) or the style/model has nothing to add. Anthropic's `thinking_budget` is built
14
+ * in anthropic.ts (buildThinkingParam) and not covered here. Pure — exported for tests. */
15
+ export function reasoningParams(style, effort, model = "") {
16
+ if (effort === undefined)
17
+ return {};
18
+ switch (style) {
19
+ case "enable_thinking":
20
+ // off → false (stop the thinking phase, fast); any explicit level → true (keep it on).
21
+ return { enable_thinking: effort !== "off" };
22
+ case "ollama_think":
23
+ // Ollama's `think` boolean — off stops a local reasoning model's thinking (safe: non-thinking
24
+ // models ignore it). Same shape as enable_thinking, different param name.
25
+ return { think: effort !== "off" };
26
+ case "reasoning_effort":
27
+ if (!isReasoningModel(model))
28
+ return {};
29
+ return { reasoning_effort: effort === "off" ? "minimal" : effort };
30
+ case "reasoning_object":
31
+ if (!isReasoningModel(model))
32
+ return {};
33
+ return { reasoning: { effort: effort === "off" ? "minimal" : effort } };
34
+ case "thinking_budget": // Anthropic — applied by anthropic.ts, not on a chat/responses merge body
35
+ case "none":
36
+ default:
37
+ return {};
38
+ }
39
+ }
@@ -0,0 +1,54 @@
1
+ /** Base capabilities per wire protocol (the sensible default before per-platform overrides). */
2
+ const BY_WIRE = {
3
+ chat: { wireApi: "chat", reasoning: "reasoning_effort", cache: "auto" },
4
+ responses: { wireApi: "responses", reasoning: "reasoning_object", cache: "auto" },
5
+ anthropic: { wireApi: "anthropic", reasoning: "thinking_budget", cache: "cache_control" },
6
+ };
7
+ /** Endpoint-shape rules, matched against the baseURL. FIRST match wins — most specific first. This is what
8
+ * makes a *custom* profile (e.g. `custom:qwen3.7-plus` pointing at coding.dashscope) resolve correctly
9
+ * without the user declaring a provider id: the DashScope host implies chat + enable_thinking. */
10
+ const BY_BASEURL = [
11
+ // Alibaba Token Plan — OpenAI-compatible; new models use the Responses API.
12
+ { test: /token-plan.*maas\.aliyuncs\.com\/compatible-mode/i, caps: { wireApi: "responses", reasoning: "reasoning_object", cache: "auto" } },
13
+ // Alibaba DashScope — OpenAI-compatible chat (coding plan /v1, pay-as-you-go /compatible-mode): the key
14
+ // difference is `enable_thinking`, which actually turns Qwen/GLM thinking off (the DashScope speedup).
15
+ { test: /dashscope\.aliyuncs\.com\/(v1|compatible-mode)|maas\.aliyuncs\.com\/compatible-mode/i, caps: { wireApi: "chat", reasoning: "enable_thinking", cache: "auto" } },
16
+ // Local Ollama (OpenAI-compat, default port 11434): `think` toggles a local reasoning model's thinking
17
+ // (measured deepseek-r1:14b 17s → 0.6s); no cache. LM Studio (1234) is the same shape.
18
+ { test: /(localhost|127\.0\.0\.1|0\.0\.0\.0):(11434|1234)/i, caps: { wireApi: "chat", reasoning: "ollama_think", cache: "none" } },
19
+ // ANY vendor's Anthropic-compatible endpoint (path ends in /anthropic): DeepSeek, Kimi/Moonshot, Zhipu
20
+ // GLM, MiniMax, Aliyun apps/anthropic … all expose `.../anthropic` with thinking + explicit cache. One
21
+ // row covers the whole ecosystem — talk to it with the anthropic transport.
22
+ { test: /\/anthropic\/?($|\?)/i, caps: { wireApi: "anthropic", reasoning: "thinking_budget", cache: "cache_control" } },
23
+ // DeepSeek OpenAI-compatible (chat): the model id picks reasoning (deepseek-reasoner reasons, -chat
24
+ // doesn't); there's no per-request toggle on this path → leave it alone.
25
+ { test: /api\.deepseek\.com/i, caps: { wireApi: "chat", reasoning: "none", cache: "auto" } },
26
+ ];
27
+ /** Per-provider-id overrides (built-in providers whose id alone fixes the shape). */
28
+ const BY_PROVIDER = {
29
+ anthropic: { wireApi: "anthropic", reasoning: "thinking_budget", cache: "cache_control" },
30
+ qwen: { wireApi: "chat", reasoning: "enable_thinking", cache: "auto" }, // DashScope
31
+ "qwen-oauth": { wireApi: "chat", reasoning: "enable_thinking", cache: "auto" },
32
+ glm: { wireApi: "chat", reasoning: "none", cache: "auto" }, // Zhipu native /paas/v4 — different thinking param; leave alone (its /anthropic endpoint resolves via baseURL)
33
+ deepseek: { wireApi: "chat", reasoning: "none", cache: "auto" },
34
+ ollama: { wireApi: "chat", reasoning: "ollama_think", cache: "none" }, // local; `think` toggles reasoning
35
+ openai: { wireApi: "chat", reasoning: "reasoning_effort", cache: "auto" },
36
+ openrouter: { wireApi: "chat", reasoning: "none", cache: "auto" },
37
+ "hara-gateway": { wireApi: "chat", reasoning: "none", cache: "auto" },
38
+ };
39
+ /** Resolve a platform's capabilities from (provider id, baseURL, explicit wireApi override). Precedence:
40
+ * an explicit `wireApi` from config wins the transport; then the baseURL shape (so custom DashScope/
41
+ * token-plan/anthropic endpoints Just Work); then the provider-id override; else the wire default. */
42
+ export function resolvePlatform(providerId, baseURL, wireApiOverride) {
43
+ // baseURL shape is the strongest signal for a custom profile; else the provider-id override; else chat.
44
+ const byUrl = baseURL ? BY_BASEURL.find((r) => r.test.test(baseURL))?.caps : undefined;
45
+ const byProv = providerId ? BY_PROVIDER[providerId] : undefined;
46
+ const resolved = byUrl ?? { ...BY_WIRE.chat, ...(byProv ?? {}) };
47
+ // An explicit wireApi from config wins the transport. When it changes the wire, the reasoning style
48
+ // follows that wire's default (reasoning is wire-dependent — reasoning_effort on chat vs reasoning_object
49
+ // on responses); cache is kept from the resolved row (it tracks the endpoint, not the wire).
50
+ if (wireApiOverride && wireApiOverride !== resolved.wireApi) {
51
+ return { ...resolved, wireApi: wireApiOverride, reasoning: BY_WIRE[wireApiOverride].reasoning };
52
+ }
53
+ return resolved;
54
+ }
package/dist/sandbox.js CHANGED
@@ -17,7 +17,9 @@ function findWindowsBash() {
17
17
  if (_winBash !== undefined)
18
18
  return _winBash;
19
19
  // `where bash` finds Git Bash / WSL bash on PATH; also probe the default Git-for-Windows location.
20
- const onPath = spawnSync("where", ["bash"], { encoding: "utf8" });
20
+ // `timeout` is CRITICAL: this is a SYNCHRONOUS probe on the main thread — without it a slow `where`
21
+ // (a huge PATH, a dead network drive on PATH) hangs hara at startup with nothing able to interrupt it.
22
+ const onPath = spawnSync("where", ["bash"], { encoding: "utf8", timeout: 3000 });
21
23
  const hit = onPath.status === 0 ? String(onPath.stdout).split(/\r?\n/).find((l) => l.trim()) : "";
22
24
  _winBash = (hit && hit.trim()) || null;
23
25
  return _winBash;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.109.5",
3
+ "version": "0.110.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"