@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 +26 -0
- package/dist/checkpoints.js +4 -0
- package/dist/fs-walk.js +3 -0
- package/dist/index.js +18 -1
- package/dist/providers/openai.js +10 -33
- package/dist/providers/reasoning.js +39 -0
- package/dist/providers/registry.js +54 -0
- package/dist/sandbox.js +3 -1
- package/package.json +1 -1
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
|
package/dist/checkpoints.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/providers/openai.js
CHANGED
|
@@ -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
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
// the
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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;
|