@nanhara/hara 0.109.5 → 0.111.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 +40 -0
- package/dist/checkpoints.js +4 -0
- package/dist/fs-walk.js +3 -0
- package/dist/index.js +40 -18
- package/dist/providers/models.js +21 -0
- 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/dist/tui/App.js +15 -2
- package/dist/tui/model-picker.js +62 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,46 @@ 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.111.0 — an interactive /model picker: ↑↓ a model, ←→ its thinking
|
|
9
|
+
|
|
10
|
+
- **`/model` (no argument) now opens an interactive picker** built on the provider registry — the
|
|
11
|
+
"one key, many models" flow. It pulls the endpoint's **live model list** (`GET /models`; a coding-plan
|
|
12
|
+
key exposes ~10: Qwen / GLM / Kimi / MiniMax / …), and you drive it with the arrow keys:
|
|
13
|
+
- **↑↓** move through the models,
|
|
14
|
+
- **←→** set the **thinking level** for this endpoint — the levels come from the registry's reasoning
|
|
15
|
+
style, so a DashScope/Ollama endpoint shows `off / on` (the real speedup toggle) while an
|
|
16
|
+
OpenAI/Anthropic one shows `off / low / medium / high`,
|
|
17
|
+
- **⏎** applies (switches the model, sets the dial, rebuilds the provider, persists to the session),
|
|
18
|
+
**esc** cancels.
|
|
19
|
+
Endpoints that don't enumerate models still let you set the thinking level (and `/model <id>` still
|
|
20
|
+
switches directly, as before). TUI only; the readline REPL keeps the text form.
|
|
21
|
+
|
|
22
|
+
## 0.110.0 — a provider registry: one key, many platforms, the right wire + thinking control for each
|
|
23
|
+
|
|
24
|
+
- **hara now speaks each platform its own way, chosen from a data-driven registry (a dictionary), not
|
|
25
|
+
scattered if/else.** One row per platform declares its **wire protocol** (chat / Anthropic / Responses)
|
|
26
|
+
+ how it expresses the **thinking dial** + how it **caches**. Point hara at a custom baseURL and it Just
|
|
27
|
+
Works:
|
|
28
|
+
- **Any vendor's `.../anthropic` endpoint** — DeepSeek, Kimi/Moonshot, Zhipu GLM, MiniMax, and Alibaba's
|
|
29
|
+
`…/apps/anthropic` — now routes through the **Anthropic wire** (so you get prompt caching + a native
|
|
30
|
+
thinking budget). Verified end-to-end against Alibaba's endpoint.
|
|
31
|
+
- **Local Ollama / LM Studio** (`localhost:11434` / `1234`) — `reasoning off` sends `think:false`, which
|
|
32
|
+
actually stops a local reasoning model's thinking phase (**measured: deepseek-r1:14b 17s → 0.6s**).
|
|
33
|
+
- **Alibaba DashScope** chat (coding plan / pay-as-you-go) — `reasoning off` → `enable_thinking:false`
|
|
34
|
+
(**qwen3.7-plus ~14s → ~1.6s**); works for a custom `qwen3.7-plus`/`glm-5` profile, keyed on the
|
|
35
|
+
endpoint, not the model name.
|
|
36
|
+
- OpenAI reasoning models keep `reasoning_effort`; Anthropic keeps its thinking budget. The dial **UNSET
|
|
37
|
+
leaves the request untouched** everywhere (model default, zero impact — the safe default).
|
|
38
|
+
- The Responses API (Alibaba Token Plan's newest models) is a distinct wire hara doesn't speak yet; a
|
|
39
|
+
Responses endpoint now returns a clear pointer to use the chat or `/apps/anthropic` endpoint instead
|
|
40
|
+
(rather than sending a body it would reject). Coming once there's a Token-Plan key to verify against.
|
|
41
|
+
- **Windows: hara no longer hangs at startup / on the first command.** Three SYNCHRONOUS probes ran with
|
|
42
|
+
no timeout, so on Windows a slow/hung one froze the whole process (main-thread block — nothing could
|
|
43
|
+
interrupt it): the `where bash` shell probe (added in 0.109.0), `git ls-files` (the "which files exist"
|
|
44
|
+
scan), and the per-turn shadow `git add -A`. All three are now bounded (3s / 5s / 10s) and fall back
|
|
45
|
+
gracefully (cmd.exe / filesystem walk / skip the snapshot). This is the "stuck at shell/directory
|
|
46
|
+
probing, never got to the actual work" hang.
|
|
47
|
+
|
|
8
48
|
## 0.109.5 — Enter enters the conversation instantly + reasoning off truly disables DashScope thinking
|
|
9
49
|
|
|
10
50
|
- **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,8 @@ 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";
|
|
46
|
+
import { listModels } from "./providers/models.js";
|
|
45
47
|
import { qwenDeviceLogin, getValidQwenAuth } from "./providers/qwen-oauth.js";
|
|
46
48
|
import { loadAgentsMd, hasAgentsMd, INIT_PROMPT, findProjectRoot } from "./context/agents-md.js";
|
|
47
49
|
import { getEmbedder } from "./search/embed.js";
|
|
@@ -127,9 +129,25 @@ async function buildProvider(cfg) {
|
|
|
127
129
|
}
|
|
128
130
|
if (!apiKey)
|
|
129
131
|
return null;
|
|
130
|
-
|
|
132
|
+
// Transport is chosen from the provider REGISTRY (the dictionary) by wire protocol — so a custom
|
|
133
|
+
// baseURL Just Works: a vendor's `.../anthropic` endpoint (DeepSeek/Kimi/GLM/MiniMax/Aliyun) speaks the
|
|
134
|
+
// Anthropic wire (gets cache_control + thinking budget), DashScope/Ollama/OpenAI speak chat, etc.
|
|
135
|
+
const wire = resolvePlatform(provider, baseURL).wireApi;
|
|
136
|
+
if (wire === "anthropic") {
|
|
131
137
|
return createAnthropicProvider({ apiKey, model, baseURL, reasoningEffort: cfg.reasoningEffort });
|
|
132
138
|
}
|
|
139
|
+
if (wire === "responses") {
|
|
140
|
+
// The OpenAI Responses API (e.g. Aliyun Token Plan's newest models) — a distinct wire hara doesn't
|
|
141
|
+
// speak yet. Fail with guidance instead of sending a chat body it will reject. (Tracked for when a
|
|
142
|
+
// Token-Plan key is available to build + verify against; the chat + anthropic endpoints work today.)
|
|
143
|
+
return {
|
|
144
|
+
id: provider,
|
|
145
|
+
model,
|
|
146
|
+
async turn() {
|
|
147
|
+
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.` };
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
133
151
|
return createOpenAIProvider({ apiKey, model, baseURL, label: provider, reasoningEffort: cfg.reasoningEffort });
|
|
134
152
|
}
|
|
135
153
|
/** Wrap the main provider with per-turn model routing when `routeModel` is configured: trivial/non-coding
|
|
@@ -2860,24 +2878,28 @@ program.action(async (opts) => {
|
|
|
2860
2878
|
const force = parts.some((p) => p === "--force" || p === "all" || p === "-f");
|
|
2861
2879
|
const id = parts.find((p) => p !== "--force" && p !== "all" && p !== "-f");
|
|
2862
2880
|
if (!id) {
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2881
|
+
// Bare /model → the interactive picker: the endpoint's live model list (↑↓) + its thinking
|
|
2882
|
+
// level (←→, per the registry's reasoning style). Falls back to typing an id if the endpoint
|
|
2883
|
+
// doesn't enumerate models.
|
|
2884
|
+
const bURL = cfg.baseURL ?? providerDefaultBaseURL(cfg.provider);
|
|
2885
|
+
const models = await listModels(bURL, cfg.apiKey ?? "");
|
|
2886
|
+
const style = resolvePlatform(cfg.provider, bURL).reasoning;
|
|
2887
|
+
const chosen = await h.pickModel({ models, style, current: cfg.model, effort: cfg.reasoningEffort });
|
|
2888
|
+
if (!chosen)
|
|
2889
|
+
return; // esc — no change
|
|
2890
|
+
if (chosen.model) {
|
|
2891
|
+
cfg.model = chosen.model;
|
|
2892
|
+
meta.model = chosen.model;
|
|
2867
2893
|
}
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
const
|
|
2872
|
-
if (
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
__lines.push(` ${r.id}: ${eff}${tag}`);
|
|
2878
|
-
}
|
|
2879
|
-
}
|
|
2880
|
-
return void h.sink.notice(__lines.join("\n"));
|
|
2894
|
+
cfg.reasoningEffort = chosen.effort;
|
|
2895
|
+
visionProvider = undefined;
|
|
2896
|
+
remindedVision = false;
|
|
2897
|
+
const p2 = await buildProvider(cfg);
|
|
2898
|
+
if (!p2)
|
|
2899
|
+
return void h.sink.notice("(could not rebuild provider)");
|
|
2900
|
+
provider = p2;
|
|
2901
|
+
saveSession(meta, history);
|
|
2902
|
+
return void h.sink.notice(`(model → ${cfg.provider}:${cfg.model} · thinking ${chosen.effort ?? "default"})`);
|
|
2881
2903
|
}
|
|
2882
2904
|
cfg.model = id;
|
|
2883
2905
|
meta.model = id;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Model discovery — "what can this key run?" A coding-plan / OpenAI-compatible key usually exposes many
|
|
2
|
+
// models (Qwen, GLM, Kimi, …) via `GET {baseURL}/models`; the /model picker lists them so you switch by
|
|
3
|
+
// arrow keys, not by memorizing ids. Best-effort: many endpoints don't implement it → [] and the picker
|
|
4
|
+
// falls back to typing an id. `fetchImpl` is injected so this stays pure/testable.
|
|
5
|
+
export async function listModels(baseURL, apiKey, fetchImpl = fetch) {
|
|
6
|
+
if (!baseURL)
|
|
7
|
+
return []; // SDK-default hosts (anthropic/openai) — no custom endpoint to enumerate
|
|
8
|
+
try {
|
|
9
|
+
const url = baseURL.replace(/\/+$/, "") + "/models";
|
|
10
|
+
const r = await fetchImpl(url, { headers: { Authorization: `Bearer ${apiKey}` } });
|
|
11
|
+
if (!r.ok)
|
|
12
|
+
return [];
|
|
13
|
+
const j = (await r.json());
|
|
14
|
+
const ids = (j?.data ?? []).map((m) => m?.id).filter((x) => typeof x === "string" && x.length > 0);
|
|
15
|
+
// Stable order + de-dup so the picker list doesn't jump around between opens.
|
|
16
|
+
return [...new Set(ids)].sort((a, b) => a.localeCompare(b));
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
}
|
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;
|
package/dist/tui/App.js
CHANGED
|
@@ -18,6 +18,7 @@ import { accent } from "./theme.js";
|
|
|
18
18
|
import { renderMarkdown } from "../md.js";
|
|
19
19
|
import { clearTodos, currentTodos, onTodosChange } from "../tools/todo.js";
|
|
20
20
|
import { onTurnPhase, turnPhase } from "../agent/phase.js";
|
|
21
|
+
import { ModelPicker } from "./model-picker.js";
|
|
21
22
|
let _id = 0;
|
|
22
23
|
const nid = () => ++_id;
|
|
23
24
|
const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
@@ -344,6 +345,7 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
344
345
|
const [askText, setAskText] = useState(null);
|
|
345
346
|
const [reasoningOpen, setReasoningOpen] = useState(false);
|
|
346
347
|
const [showTranscript, setShowTranscript] = useState(false); // Ctrl+T full-transcript overlay
|
|
348
|
+
const [picker, setPicker] = useState(null); // /model picker overlay
|
|
347
349
|
const [modeSelector, setModeSelector] = useState(false); // transient approval selector: shift+tab pops it, auto-hides
|
|
348
350
|
const modeSelectorTimerRef = useRef(null);
|
|
349
351
|
// Live checklist mirror: TodoPanel reads this, and `Working` derives its spinner verb from the
|
|
@@ -515,13 +517,14 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
515
517
|
return askTextFn(question);
|
|
516
518
|
};
|
|
517
519
|
const setApprovalFn = (m) => setStatus((s) => ({ ...s, approval: m }));
|
|
520
|
+
const pickModelFn = (o) => new Promise((resolve) => setPicker({ ...o, resolve }));
|
|
518
521
|
// Enter the conversation flow INSTANTLY: yield one macrotask so ink paints the committed message +
|
|
519
522
|
// cleared input + spinner BEFORE the turn's synchronous prep runs (reading @-files, base64-encoding
|
|
520
523
|
// images) and before the model's slow first token. Without this, that sync prep blocks ink's flush,
|
|
521
524
|
// so pressing Enter leaves the message stuck in the input box for seconds ("回车一直不动"). One tick.
|
|
522
525
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
523
526
|
try {
|
|
524
|
-
await onSubmit(t, { sink, confirm: confirmFn, select: selectFn, ask: askFn, setApproval: setApprovalFn, signal: ctrl.signal, exit, approval: statusRef.current.approval, drainQueue }, images);
|
|
527
|
+
await onSubmit(t, { sink, confirm: confirmFn, select: selectFn, ask: askFn, pickModel: pickModelFn, setApproval: setApprovalFn, signal: ctrl.signal, exit, approval: statusRef.current.approval, drainQueue }, images);
|
|
525
528
|
}
|
|
526
529
|
catch (e) {
|
|
527
530
|
pushCurrent("notice", `error: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -563,6 +566,8 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
563
566
|
return setShowTranscript((x) => !x); // open/close the full-transcript overlay
|
|
564
567
|
if (showTranscript)
|
|
565
568
|
return; // while open, the overlay's own useInput owns every key (scroll / esc)
|
|
569
|
+
if (picker)
|
|
570
|
+
return; // the /model picker overlay owns input (↑↓ model, ←→ thinking, ⏎, esc) while open
|
|
566
571
|
// Free-text question awaiting an answer: Esc cancels (empty answer); all other keys belong to the InputBox.
|
|
567
572
|
if (askText) {
|
|
568
573
|
if (key.escape) {
|
|
@@ -624,5 +629,13 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
624
629
|
});
|
|
625
630
|
if (showTranscript)
|
|
626
631
|
return _jsx(Transcript, { items: [...history, ...current], onClose: () => setShowTranscript(false) });
|
|
627
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: header ? [{ id: -1, kind: "notice", text: "" }, ...history] : history, children: (item) => (item.id === -1 ? _jsx(HeaderCard, { ...header }, "hdr") : _jsx(Block, { item: item }, item.id)) }), current.map((item) => (_jsx(Block, { item: item, open: reasoningOpen, liveRows: liveRows }, item.id))), _jsx(TodoPanel, { todos: todos }),
|
|
632
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: header ? [{ id: -1, kind: "notice", text: "" }, ...history] : history, children: (item) => (item.id === -1 ? _jsx(HeaderCard, { ...header }, "hdr") : _jsx(Block, { item: item }, item.id)) }), current.map((item) => (_jsx(Block, { item: item, open: reasoningOpen, liveRows: liveRows }, item.id))), _jsx(TodoPanel, { todos: todos }), picker && (_jsx(ModelPicker, { models: picker.models, style: picker.style, current: picker.current, effort: picker.effort, onSelect: (model, effort) => {
|
|
633
|
+
const r = picker.resolve;
|
|
634
|
+
setPicker(null);
|
|
635
|
+
r({ model, effort });
|
|
636
|
+
}, onCancel: () => {
|
|
637
|
+
const r = picker.resolve;
|
|
638
|
+
setPicker(null);
|
|
639
|
+
r(null);
|
|
640
|
+
} })), prompt && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` ${stripAnsi(prompt.title)}` }), prompt.options.map((o, i) => (_jsx(Text, { color: i === promptSel ? "cyan" : undefined, bold: i === promptSel, children: (i === promptSel ? " ❯ " : " ") + `${i + 1}. ` + o.label }, i))), _jsx(Text, { dimColor: true, children: ` ↑↓ or 1–${prompt.options.length} to choose · Enter · Esc cancels` })] })), askText && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` ? ${stripAnsi(askText.title)}` }), _jsx(Text, { dimColor: true, children: " type your answer below · Enter to send · Esc cancels" })] })), pool.length > 0 && !prompt && !askText && (_jsx(Box, { flexDirection: "column", children: pool.map((l, i) => (_jsx(Text, { color: accent(), children: ` › ${l.length > 72 ? l.slice(0, 72) + "…" : l}` }, i))) })), modeSelector ? _jsx(ModeLine, { approval: status.approval }) : _jsx(StatusRow, { working: working, todos: todos, queued: pool.length }), _jsx(InputBox, { status: status, cwd: cwd, model: model, route: header?.routeHost, isActive: !prompt, vim: vim, onSubmit: handleSubmit, onClipboardImage: onClipboardImage })] }));
|
|
628
641
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// The /model picker: ↑↓ choose a model (from the key's live /models list), ←→ set the thinking level for
|
|
3
|
+
// this endpoint, ⏎ apply, esc cancel. Built on the provider registry — the reasoning STYLE (from the
|
|
4
|
+
// endpoint) decides which levels ←→ offers. The nav math is pure + exported for tests; the component is a
|
|
5
|
+
// thin ink shell over it (like the Transcript overlay).
|
|
6
|
+
import { Box, Text, useInput } from "ink";
|
|
7
|
+
import { useState } from "react";
|
|
8
|
+
/** The levels ←→ cycles for a style. Binary thinking toggles (DashScope enable_thinking, Ollama think)
|
|
9
|
+
* show off/on; graded styles (OpenAI/Anthropic effort/budget) show the full dial; `none` → no control. */
|
|
10
|
+
export function levelsFor(style) {
|
|
11
|
+
if (style === "none")
|
|
12
|
+
return [];
|
|
13
|
+
if (style === "enable_thinking" || style === "ollama_think")
|
|
14
|
+
return ["off", "high"]; // "high" renders as "on"
|
|
15
|
+
return ["off", "low", "medium", "high"];
|
|
16
|
+
}
|
|
17
|
+
/** Label a level for display — binary styles read as on/off, graded ones as the level name. */
|
|
18
|
+
export function levelLabel(style, e) {
|
|
19
|
+
if (style === "enable_thinking" || style === "ollama_think")
|
|
20
|
+
return e === "off" ? "off" : "on";
|
|
21
|
+
return String(e);
|
|
22
|
+
}
|
|
23
|
+
/** Pure navigation: ↑↓ moves through models (wrapping); ←→ cycles the thinking level for the endpoint's
|
|
24
|
+
* style. No-ops when there are no models / the style has no levels. Exported for tests. */
|
|
25
|
+
export function movePicker(s, key, modelCount, style) {
|
|
26
|
+
if (key === "up" || key === "down") {
|
|
27
|
+
if (modelCount <= 0)
|
|
28
|
+
return s;
|
|
29
|
+
const d = key === "down" ? 1 : -1;
|
|
30
|
+
return { ...s, modelIdx: (s.modelIdx + d + modelCount) % modelCount };
|
|
31
|
+
}
|
|
32
|
+
const levels = levelsFor(style);
|
|
33
|
+
if (!levels.length)
|
|
34
|
+
return s;
|
|
35
|
+
const cur = Math.max(0, levels.indexOf(s.effort));
|
|
36
|
+
const d = key === "right" ? 1 : -1;
|
|
37
|
+
return { ...s, effort: levels[(cur + d + levels.length) % levels.length] };
|
|
38
|
+
}
|
|
39
|
+
export function ModelPicker({ models, style, current, effort, onSelect, onCancel, }) {
|
|
40
|
+
const start = Math.max(0, models.indexOf(current ?? ""));
|
|
41
|
+
const [s, setS] = useState({ modelIdx: start, effort });
|
|
42
|
+
useInput((_input, key) => {
|
|
43
|
+
if (key.escape)
|
|
44
|
+
return onCancel();
|
|
45
|
+
if (key.return)
|
|
46
|
+
return onSelect(models[s.modelIdx] ?? current ?? "", s.effort);
|
|
47
|
+
if (key.upArrow)
|
|
48
|
+
setS((p) => movePicker(p, "up", models.length, style));
|
|
49
|
+
else if (key.downArrow)
|
|
50
|
+
setS((p) => movePicker(p, "down", models.length, style));
|
|
51
|
+
else if (key.leftArrow)
|
|
52
|
+
setS((p) => movePicker(p, "left", models.length, style));
|
|
53
|
+
else if (key.rightArrow)
|
|
54
|
+
setS((p) => movePicker(p, "right", models.length, style));
|
|
55
|
+
});
|
|
56
|
+
const hasLevels = levelsFor(style).length > 0;
|
|
57
|
+
const dial = hasLevels ? `thinking ◀ ${levelLabel(style, s.effort)} ▶` : "";
|
|
58
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` pick a model · ↑↓ model · ${hasLevels ? "←→ thinking · " : ""}⏎ apply · esc` }), models.length === 0 ? (_jsx(Text, { dimColor: true, children: " (this endpoint doesn't list models — use /model <id> to set one directly)" })) : (models.map((m, i) => {
|
|
59
|
+
const on = i === s.modelIdx;
|
|
60
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: on ? "cyan" : undefined, bold: on, children: (on ? " ❯ " : " ") + m }), on && hasLevels ? _jsx(Text, { dimColor: true, children: " " + dial }) : null] }, m));
|
|
61
|
+
}))] }));
|
|
62
|
+
}
|