honeydo 0.1.0 → 0.1.2
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/README.md +3 -0
- package/package.json +1 -1
- package/packages/cli/dist/index.js +0 -0
- package/packages/doubao/dist/cli.js +0 -0
- package/packages/gcli/dist/cli.d.ts +262 -6
- package/packages/gcli/dist/cli.js +1504 -73
- package/packages/gcli/dist/cli.js.map +1 -1
- package/packages/lmedia/dist/index.js +532 -132
- package/packages/lmedia/python/turbo_merge.py +239 -0
- package/packages/lmedia/python/video_gen.py +31 -0
- package/packages/minimax/dist/cli.js +0 -0
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* backend's own timeout flag.
|
|
26
26
|
*/
|
|
27
27
|
import { spawn } from "node:child_process";
|
|
28
|
-
import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
28
|
+
import { copyFileSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
29
29
|
import { homedir } from "node:os";
|
|
30
30
|
import { dirname, resolve } from "node:path";
|
|
31
31
|
import { emitKeypressEvents } from "node:readline";
|
|
@@ -60,6 +60,38 @@ export const QUOTA_CACHE_PATH = `${homedir()}/.config/gcli/quota-cache.json`;
|
|
|
60
60
|
const QUOTA_TTL_OK_MS = 60_000;
|
|
61
61
|
const QUOTA_TTL_FAIL_MS = 15_000;
|
|
62
62
|
const QUOTA_FETCH_TIMEOUT_MS = 2_500;
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// hermes backend — paths & constants
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
const HERMES_BIN = "hermes";
|
|
67
|
+
/** hermes 主配置文件(model 段 + providers 段是本子命令的编辑目标)。 */
|
|
68
|
+
export const HERMES_CONFIG_PATH = `${homedir()}/.hermes/config.yaml`;
|
|
69
|
+
/** hermes env 文件(provider API key 的落点;全程保持 0o600)。 */
|
|
70
|
+
export const HERMES_ENV_PATH = `${homedir()}/.hermes/.env`;
|
|
71
|
+
/** hermes cron 任务定义(重 pin 只读它;改动经 `hermes cron edit` 下发)。 */
|
|
72
|
+
export const HERMES_CRON_JOBS_PATH = `${homedir()}/.hermes/cron/jobs.json`;
|
|
73
|
+
/** hermes 会话库(session_model_usage 表在此 —— 实测在顶层 state.db)。 */
|
|
74
|
+
export const HERMES_STATE_DB_PATH = `${homedir()}/.hermes/state.db`;
|
|
75
|
+
/** cc-switch name → hermes provider 字段映射(keyEnv/modelOverride)。 */
|
|
76
|
+
export const HERMES_PROVIDERS_REGISTRY_PATH = `${homedir()}/.config/gcli/hermes-providers.json`;
|
|
77
|
+
/** 上一次切换的一跳记录(0o600,含旧 token 的 prevValue)。 */
|
|
78
|
+
export const HERMES_STATE_PATH = `${homedir()}/.config/gcli/hermes-state.json`;
|
|
79
|
+
/** hermes providers 条目的 transport 常量(cc-switch 只提供 anthropic 兼容端点)。 */
|
|
80
|
+
export const HERMES_TRANSPORT = "anthropic_messages";
|
|
81
|
+
/** 切换后验证 ping 的硬超时。 */
|
|
82
|
+
export const HERMES_VERIFY_TIMEOUT_MS = 60_000;
|
|
83
|
+
/**
|
|
84
|
+
* 内置 seed(防推导造出与现有配置平行的条目):键是 cc-switch provider 名,
|
|
85
|
+
* 值是 hermes 侧已手工建立好的 id/keyEnv。
|
|
86
|
+
*/
|
|
87
|
+
export const HERMES_PROVIDER_SEEDS = {
|
|
88
|
+
// cc-switch 真实条目名(09-06 sqlite3 实证):kimi 系的 coding plan 条目叫 "kimi"
|
|
89
|
+
kimi: { id: "kimi-coding", keyEnv: "KIMI_CODING_API_KEY" },
|
|
90
|
+
"Kimi For Coding": { id: "kimi-coding", keyEnv: "KIMI_CODING_API_KEY" },
|
|
91
|
+
"glm flash lastest": { id: "glm-flash", keyEnv: "BIGMODEL_API_KEY" },
|
|
92
|
+
};
|
|
93
|
+
/** `status`/`rollback` 为 hermes 子命令的保留字位置参数。 */
|
|
94
|
+
const HERMES_RESERVED_WORDS = new Set(["status", "rollback"]);
|
|
63
95
|
/** Provider-name allowlist (C4b). Names outside this set are rejected. */
|
|
64
96
|
const PROVIDER_NAME_RE = /^[A-Za-z0-9 &._-]+$/;
|
|
65
97
|
// ---------------------------------------------------------------------------
|
|
@@ -87,6 +119,8 @@ export function parseSubcommand(argv) {
|
|
|
87
119
|
return { subcommand: "claude", rest: argv.slice(1) };
|
|
88
120
|
if (first === "api")
|
|
89
121
|
return { subcommand: "api", rest: argv.slice(1) };
|
|
122
|
+
if (first === "hermes")
|
|
123
|
+
return { subcommand: "hermes", rest: argv.slice(1) };
|
|
90
124
|
if (first.startsWith("-"))
|
|
91
125
|
return { subcommand: undefined, rest: argv };
|
|
92
126
|
return { error: `unknown subcommand: ${first}` };
|
|
@@ -466,15 +500,28 @@ export function buildClaudeArgs(opts) {
|
|
|
466
500
|
* high-quality output in ~45s vs claude-agent's 53min). We rely on a sufficient
|
|
467
501
|
* --max-tokens budget (default 80000) to cover both thinking and text, not on
|
|
468
502
|
* disabling thinking. Disabling it would discard the very capability we chose
|
|
469
|
-
* k3 for.
|
|
503
|
+
* k3 for. (`--thinking auto`, the CLI default, preserves this omitted-field
|
|
504
|
+
* behaviour. bigmodel GLM endpoints default thinking ON and share max_tokens
|
|
505
|
+
* between thinking and text — small explicit budgets can end up thinking-only;
|
|
506
|
+
* --thinking off/on send explicit disabled/enabled for that case.)
|
|
470
507
|
*/
|
|
471
508
|
export function buildApiBody(req) {
|
|
472
|
-
|
|
509
|
+
const body = {
|
|
473
510
|
model: req.model,
|
|
474
511
|
max_tokens: req.maxTokens,
|
|
475
512
|
stream: req.stream,
|
|
476
513
|
messages: [{ role: "user", content: req.prompt }],
|
|
477
514
|
};
|
|
515
|
+
if (req.thinking === "off") {
|
|
516
|
+
body.thinking = { type: "disabled" };
|
|
517
|
+
}
|
|
518
|
+
else if (req.thinking === "on") {
|
|
519
|
+
body.thinking = {
|
|
520
|
+
type: "enabled",
|
|
521
|
+
budget_tokens: Math.floor(req.maxTokens / 2),
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
return body;
|
|
478
525
|
}
|
|
479
526
|
/**
|
|
480
527
|
* Build the full URL for the messages endpoint. The cc-switch base_url is
|
|
@@ -497,24 +544,7 @@ export function buildApiEndpoint(baseUrl) {
|
|
|
497
544
|
* `data:{...}` (kimi, no space) and `data: {...}` (with space) parse the same.
|
|
498
545
|
*/
|
|
499
546
|
export function extractTextDelta(line) {
|
|
500
|
-
|
|
501
|
-
if (!trimmed.startsWith("data:"))
|
|
502
|
-
return null;
|
|
503
|
-
const payload = trimmed.slice("data:".length).trim();
|
|
504
|
-
if (!payload || payload === "[DONE]")
|
|
505
|
-
return null;
|
|
506
|
-
try {
|
|
507
|
-
const evt = JSON.parse(payload);
|
|
508
|
-
if (evt.type === "content_block_delta" &&
|
|
509
|
-
evt.delta?.type === "text_delta" &&
|
|
510
|
-
typeof evt.delta.text === "string") {
|
|
511
|
-
return evt.delta.text;
|
|
512
|
-
}
|
|
513
|
-
return null;
|
|
514
|
-
}
|
|
515
|
-
catch {
|
|
516
|
-
return null;
|
|
517
|
-
}
|
|
547
|
+
return extractSseLineMeta(line).text;
|
|
518
548
|
}
|
|
519
549
|
/**
|
|
520
550
|
* Aggregate text from a non-streaming messages response body.
|
|
@@ -539,6 +569,564 @@ export function extractNonStreamText(body) {
|
|
|
539
569
|
}
|
|
540
570
|
return out;
|
|
541
571
|
}
|
|
572
|
+
const SSE_LINE_EMPTY = {
|
|
573
|
+
text: null,
|
|
574
|
+
thinkingChars: 0,
|
|
575
|
+
parsed: false,
|
|
576
|
+
};
|
|
577
|
+
/**
|
|
578
|
+
* SSE line → structured meta (text delta + thinking volume + stop_reason).
|
|
579
|
+
*
|
|
580
|
+
* One JSON.parse per line feeding both the text aggregation and the "why did a
|
|
581
|
+
* stream produce no text" diagnosis (thinking-only streams that end in
|
|
582
|
+
* stop_reason=max_tokens are an endpoint budget issue, not a transport one).
|
|
583
|
+
* Non-`data:` lines / malformed JSON → empty meta.
|
|
584
|
+
*/
|
|
585
|
+
export function extractSseLineMeta(line) {
|
|
586
|
+
const trimmed = line.trim();
|
|
587
|
+
if (!trimmed.startsWith("data:"))
|
|
588
|
+
return SSE_LINE_EMPTY;
|
|
589
|
+
const payload = trimmed.slice("data:".length).trim();
|
|
590
|
+
if (!payload || payload === "[DONE]")
|
|
591
|
+
return SSE_LINE_EMPTY;
|
|
592
|
+
try {
|
|
593
|
+
const evt = JSON.parse(payload);
|
|
594
|
+
const meta = {
|
|
595
|
+
text: null,
|
|
596
|
+
thinkingChars: 0,
|
|
597
|
+
parsed: true,
|
|
598
|
+
};
|
|
599
|
+
if (evt.type === "content_block_delta") {
|
|
600
|
+
if (evt.delta?.type === "text_delta" &&
|
|
601
|
+
typeof evt.delta.text === "string") {
|
|
602
|
+
meta.text = evt.delta.text;
|
|
603
|
+
}
|
|
604
|
+
else if (evt.delta?.type === "thinking_delta" &&
|
|
605
|
+
typeof evt.delta.thinking === "string") {
|
|
606
|
+
meta.thinkingChars = evt.delta.thinking.length;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
else if (evt.type === "message_delta" &&
|
|
610
|
+
typeof evt.delta?.stop_reason === "string") {
|
|
611
|
+
meta.stopReason = evt.delta.stop_reason;
|
|
612
|
+
}
|
|
613
|
+
return meta;
|
|
614
|
+
}
|
|
615
|
+
catch {
|
|
616
|
+
return SSE_LINE_EMPTY;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Inspect a 200 non-streaming messages body that yielded no text, so the error
|
|
621
|
+
* can say WHY (thinking-only under a starved max_tokens budget vs an empty
|
|
622
|
+
* content array from a flaky gateway — the former is deterministic, the latter
|
|
623
|
+
* is worth retrying).
|
|
624
|
+
*/
|
|
625
|
+
export function describeNoTextBody(body) {
|
|
626
|
+
const info = { blocks: "", sawThinking: false };
|
|
627
|
+
if (typeof body !== "object" || body === null)
|
|
628
|
+
return info;
|
|
629
|
+
const obj = body;
|
|
630
|
+
if (typeof obj.stop_reason === "string")
|
|
631
|
+
info.stopReason = obj.stop_reason;
|
|
632
|
+
if (!Array.isArray(obj.content))
|
|
633
|
+
return info;
|
|
634
|
+
const names = [];
|
|
635
|
+
for (const block of obj.content) {
|
|
636
|
+
if (typeof block === "object" && block !== null) {
|
|
637
|
+
const type = block.type;
|
|
638
|
+
if (typeof type === "string") {
|
|
639
|
+
names.push(type);
|
|
640
|
+
if (type === "thinking")
|
|
641
|
+
info.sawThinking = true;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
info.blocks = names.join(",");
|
|
646
|
+
return info;
|
|
647
|
+
}
|
|
648
|
+
/** HTTP statuses worth an automatic retry (transient server/gateway trouble). */
|
|
649
|
+
export const API_RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
|
|
650
|
+
// ---------------------------------------------------------------------------
|
|
651
|
+
// hermes backend — pure helpers (unit-tested in cli.test.ts)
|
|
652
|
+
// ---------------------------------------------------------------------------
|
|
653
|
+
/**
|
|
654
|
+
* Strip a trailing context-window marker like `[1M]` (a claude-agent
|
|
655
|
+
* convention cc-switch stores in model names; raw APIs reject it).
|
|
656
|
+
* Only a suffix anchored at the very end is removed.
|
|
657
|
+
*/
|
|
658
|
+
export function stripContextSuffix(model) {
|
|
659
|
+
return model.replace(/\[[^\]]*\]$/, "");
|
|
660
|
+
}
|
|
661
|
+
/** cc-switch 值入行级文件(.env/config.yaml)前的消毒:拒绝裸换行(防行注入)。 */
|
|
662
|
+
function containsLineBreak(value) {
|
|
663
|
+
return value.includes("\n") || value.includes("\r");
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Parse argv for `gcli hermes ...`. Strict like parseApiArgs (unknown flags
|
|
667
|
+
* are exit-2 errors — there is no backend to forward to). `--no-verify` is
|
|
668
|
+
* accepted via allowNegative. At most one positional.
|
|
669
|
+
*/
|
|
670
|
+
export function parseHermesArgs(argv) {
|
|
671
|
+
try {
|
|
672
|
+
const { values, positionals } = parseArgs({
|
|
673
|
+
args: argv,
|
|
674
|
+
options: {
|
|
675
|
+
model: { type: "string" },
|
|
676
|
+
"dry-run": { type: "boolean" },
|
|
677
|
+
verify: { type: "boolean" },
|
|
678
|
+
"keep-on-fail": { type: "boolean" },
|
|
679
|
+
help: { type: "boolean" },
|
|
680
|
+
},
|
|
681
|
+
strict: true,
|
|
682
|
+
allowPositionals: true,
|
|
683
|
+
allowNegative: true,
|
|
684
|
+
});
|
|
685
|
+
if (positionals.length > 1) {
|
|
686
|
+
return {
|
|
687
|
+
error: `hermes: unexpected extra argument: "${positionals[1]}" (usage: gcli hermes <provider|status|rollback> [--model <m>] [--dry-run] [--no-verify] [--keep-on-fail])`,
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
return {
|
|
691
|
+
provider: positionals[0],
|
|
692
|
+
model: typeof values.model === "string" ? values.model : undefined,
|
|
693
|
+
dryRun: values["dry-run"] === true,
|
|
694
|
+
verify: values.verify !== false,
|
|
695
|
+
keepOnFail: values["keep-on-fail"] === true,
|
|
696
|
+
help: values.help === true,
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
catch (err) {
|
|
700
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Derive a hermes provider id from a cc-switch display name: lowercase,
|
|
705
|
+
* non-[a-z0-9] runs folded to `-`, leading/trailing dashes trimmed.
|
|
706
|
+
* May return "" for names with no alphanumerics (caller treats as error).
|
|
707
|
+
*/
|
|
708
|
+
export function deriveHermesId(ccName) {
|
|
709
|
+
return ccName
|
|
710
|
+
.toLowerCase()
|
|
711
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
712
|
+
.replace(/^-+|-+$/g, "");
|
|
713
|
+
}
|
|
714
|
+
/** Derive the .env key name from a hermes provider id: UPPER + `_API_KEY`. */
|
|
715
|
+
export function deriveKeyEnv(id) {
|
|
716
|
+
return `${id.toUpperCase().replace(/-/g, "_")}_API_KEY`;
|
|
717
|
+
}
|
|
718
|
+
/** Safe YAML scalars are written bare; anything else is single-quoted. */
|
|
719
|
+
const SAFE_YAML_SCALAR_RE = /^[A-Za-z0-9._/:@-]+$/;
|
|
720
|
+
/** Quote a scalar for our line-level YAML writer ('' escapes a single quote). */
|
|
721
|
+
export function quoteYamlScalar(value) {
|
|
722
|
+
if (value === "")
|
|
723
|
+
return "''";
|
|
724
|
+
if (SAFE_YAML_SCALAR_RE.test(value))
|
|
725
|
+
return value;
|
|
726
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
727
|
+
}
|
|
728
|
+
/** Inverse of quoteYamlScalar for reading (unescapes '' inside '...'). */
|
|
729
|
+
function unquoteYamlScalar(raw) {
|
|
730
|
+
const t = raw.trim();
|
|
731
|
+
if (t.length >= 2 && t.startsWith("'") && t.endsWith("'")) {
|
|
732
|
+
return t.slice(1, -1).replace(/''/g, "'");
|
|
733
|
+
}
|
|
734
|
+
return t;
|
|
735
|
+
}
|
|
736
|
+
/** col-0 bare section header, e.g. `model:` / `providers:` (nothing after `:`). */
|
|
737
|
+
const TOP_SECTION_RE = /^(\S[^:]*):\s*$/;
|
|
738
|
+
/**
|
|
739
|
+
* Where to insert new lines at a section's end: just before endLine, unless
|
|
740
|
+
* the preceding element is the trailing-newline artifact (`"a\n".split("\n")
|
|
741
|
+
* → ["a",""]`), in which case before that empty element — so an appended
|
|
742
|
+
* block keeps the file's trailing newline and gains no stray blank line.
|
|
743
|
+
*/
|
|
744
|
+
function sectionInsertAt(lines, span) {
|
|
745
|
+
let at = span.endLine;
|
|
746
|
+
while (at > span.headerLine + 1 && lines[at - 1] === "")
|
|
747
|
+
at--;
|
|
748
|
+
return at;
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Build the top-level section table: col-0 `key:` headers delimit sections;
|
|
752
|
+
* any other col-0 line (a scalar like `timezone: Asia/Shanghai`) is still a
|
|
753
|
+
* boundary. endLine is exclusive.
|
|
754
|
+
*/
|
|
755
|
+
function findTopLevelSections(lines) {
|
|
756
|
+
const col0 = [];
|
|
757
|
+
for (let i = 0; i < lines.length; i++) {
|
|
758
|
+
if (/^\S/.test(lines[i]))
|
|
759
|
+
col0.push(i);
|
|
760
|
+
}
|
|
761
|
+
const spans = [];
|
|
762
|
+
for (let j = 0; j < col0.length; j++) {
|
|
763
|
+
const start = col0[j];
|
|
764
|
+
const m = TOP_SECTION_RE.exec(lines[start]);
|
|
765
|
+
if (m === null)
|
|
766
|
+
continue;
|
|
767
|
+
const end = j + 1 < col0.length ? col0[j + 1] : lines.length;
|
|
768
|
+
spans.push({ key: m[1], headerLine: start, endLine: end });
|
|
769
|
+
}
|
|
770
|
+
return spans;
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Edit the top-level `model:` section (2-space `key: value` lines only):
|
|
774
|
+
* replace the wanted keys in place; insert missing ones at the section end;
|
|
775
|
+
* any unexpected structure (deeper nesting, a bare `key:` sub-section) is an
|
|
776
|
+
* error — 宁报错不猜.
|
|
777
|
+
*/
|
|
778
|
+
function editModelSection(lines, span, model) {
|
|
779
|
+
const wanted = [
|
|
780
|
+
["default", model.default],
|
|
781
|
+
["provider", model.provider],
|
|
782
|
+
["base_url", model.base_url],
|
|
783
|
+
];
|
|
784
|
+
const out = [...lines];
|
|
785
|
+
const found = new Set();
|
|
786
|
+
for (let i = span.headerLine + 1; i < span.endLine; i++) {
|
|
787
|
+
const line = out[i];
|
|
788
|
+
if (line.trim() === "" || line.trimStart().startsWith("#"))
|
|
789
|
+
continue;
|
|
790
|
+
// exactly 2-space indent, then `key: value`; a 4-space line (nested) or a
|
|
791
|
+
// bare `key:` (sub-section) fails this match → structural error.
|
|
792
|
+
const m = /^( {2})([^\s:#][^:]*?):(.+)$/.exec(line);
|
|
793
|
+
if (m === null) {
|
|
794
|
+
return {
|
|
795
|
+
error: `config.yaml model 段第 ${i + 1} 行结构无法识别(意外嵌套?): ${line.trim()}`,
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
const key = m[2].trim();
|
|
799
|
+
const w = wanted.find(([k]) => k === key);
|
|
800
|
+
if (w !== undefined) {
|
|
801
|
+
out[i] = `${m[1]}${key}: ${quoteYamlScalar(w[1])}`;
|
|
802
|
+
found.add(key);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
const missing = wanted.filter(([k]) => !found.has(k));
|
|
806
|
+
if (missing.length > 0) {
|
|
807
|
+
out.splice(sectionInsertAt(out, span), 0, ...missing.map(([k, v]) => ` ${k}: ${quoteYamlScalar(v)}`));
|
|
808
|
+
}
|
|
809
|
+
return { lines: out };
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Edit the top-level `providers:` section: entries are 2-space `id:` blocks
|
|
813
|
+
* with 4-space `key: value` fields. Upserts the wanted fields of the entry
|
|
814
|
+
* `prov.id` (missing fields appended at the block end); appends a new block
|
|
815
|
+
* at the section end when the id is absent. Any other shape → error.
|
|
816
|
+
*/
|
|
817
|
+
function editProvidersSection(lines, span, prov) {
|
|
818
|
+
const wanted = [
|
|
819
|
+
["name", prov.name],
|
|
820
|
+
["base_url", prov.base_url],
|
|
821
|
+
["transport", prov.transport],
|
|
822
|
+
["key_env", prov.key_env],
|
|
823
|
+
["default_model", prov.default_model],
|
|
824
|
+
];
|
|
825
|
+
const entries = [];
|
|
826
|
+
let cur;
|
|
827
|
+
for (let i = span.headerLine + 1; i < span.endLine; i++) {
|
|
828
|
+
const line = lines[i];
|
|
829
|
+
if (line.trim() === "" || line.trimStart().startsWith("#"))
|
|
830
|
+
continue;
|
|
831
|
+
const h = /^ {2}(\S[^:]*):\s*$/.exec(line);
|
|
832
|
+
if (h !== null) {
|
|
833
|
+
cur = { id: h[1], start: i, end: i + 1 };
|
|
834
|
+
entries.push(cur);
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
const f = /^ {4}\S[^:]*:/.exec(line);
|
|
838
|
+
if (f !== null && cur !== undefined) {
|
|
839
|
+
cur.end = i + 1;
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
return {
|
|
843
|
+
error: `config.yaml providers 段第 ${i + 1} 行结构无法识别(意外嵌套?): ${line.trim()}`,
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
const out = [...lines];
|
|
847
|
+
const target = entries.find((e) => e.id === prov.id);
|
|
848
|
+
if (target === undefined) {
|
|
849
|
+
const block = [
|
|
850
|
+
` ${prov.id}:`,
|
|
851
|
+
...wanted.map(([k, v]) => ` ${k}: ${quoteYamlScalar(v)}`),
|
|
852
|
+
];
|
|
853
|
+
out.splice(sectionInsertAt(out, span), 0, ...block);
|
|
854
|
+
return { lines: out };
|
|
855
|
+
}
|
|
856
|
+
const found = new Set();
|
|
857
|
+
for (let i = target.start + 1; i < target.end; i++) {
|
|
858
|
+
const line = out[i];
|
|
859
|
+
const f = /^( {4})([^\s:#][^:]*?):/.exec(line);
|
|
860
|
+
if (f === null)
|
|
861
|
+
continue; // blank/comment line inside the block
|
|
862
|
+
const key = f[2].trim();
|
|
863
|
+
const w = wanted.find(([k]) => k === key);
|
|
864
|
+
if (w !== undefined) {
|
|
865
|
+
out[i] = `${f[1]}${key}: ${quoteYamlScalar(w[1])}`;
|
|
866
|
+
found.add(key);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
const missing = wanted.filter(([k]) => !found.has(k));
|
|
870
|
+
if (missing.length > 0) {
|
|
871
|
+
out.splice(target.end, 0, ...missing.map(([k, v]) => ` ${k}: ${quoteYamlScalar(v)}`));
|
|
872
|
+
}
|
|
873
|
+
return { lines: out };
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Targeted line-level YAML editor for hermes' config.yaml (zero-dep: no yaml
|
|
877
|
+
* lib guaranteed). Only the top-level `model:` (3 keys) and `providers:`
|
|
878
|
+
* (one entry) sections are touched; everything else is byte-preserved.
|
|
879
|
+
* Missing sections / unexpected nesting → {error}, never a guess (callers
|
|
880
|
+
* must not write on error). Idempotent by construction.
|
|
881
|
+
*/
|
|
882
|
+
export function editHermesConfig(text, edit) {
|
|
883
|
+
const lines = text.split("\n");
|
|
884
|
+
const modelSpan = findTopLevelSections(lines).find((s) => s.key === "model");
|
|
885
|
+
if (modelSpan === undefined) {
|
|
886
|
+
return { error: "config.yaml 缺少顶层 model: 段" };
|
|
887
|
+
}
|
|
888
|
+
const r1 = editModelSection(lines, modelSpan, edit.model);
|
|
889
|
+
if ("error" in r1)
|
|
890
|
+
return r1;
|
|
891
|
+
const provSpan = findTopLevelSections(r1.lines).find((s) => s.key === "providers");
|
|
892
|
+
if (provSpan === undefined) {
|
|
893
|
+
return { error: "config.yaml 缺少顶层 providers: 段" };
|
|
894
|
+
}
|
|
895
|
+
const r2 = editProvidersSection(r1.lines, provSpan, edit.provider);
|
|
896
|
+
if ("error" in r2)
|
|
897
|
+
return r2;
|
|
898
|
+
return { text: r2.lines.join("\n") };
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Read (not edit) the parts of config.yaml the hermes backend needs: the
|
|
902
|
+
* current model section values and the list of providers entry ids.
|
|
903
|
+
* Missing model: section or unparseable model lines → {error}.
|
|
904
|
+
*/
|
|
905
|
+
export function parseHermesConfig(text) {
|
|
906
|
+
const lines = text.split("\n");
|
|
907
|
+
const sections = findTopLevelSections(lines);
|
|
908
|
+
const modelSpan = sections.find((s) => s.key === "model");
|
|
909
|
+
if (modelSpan === undefined) {
|
|
910
|
+
return { error: "config.yaml 缺少顶层 model: 段" };
|
|
911
|
+
}
|
|
912
|
+
const model = {};
|
|
913
|
+
for (let i = modelSpan.headerLine + 1; i < modelSpan.endLine; i++) {
|
|
914
|
+
const line = lines[i];
|
|
915
|
+
if (line.trim() === "" || line.trimStart().startsWith("#"))
|
|
916
|
+
continue;
|
|
917
|
+
const m = /^ {2}([^\s:#][^:]*?):(.*)$/.exec(line);
|
|
918
|
+
if (m === null) {
|
|
919
|
+
return {
|
|
920
|
+
error: `config.yaml model 段第 ${i + 1} 行结构无法识别: ${line.trim()}`,
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
const key = m[1].trim();
|
|
924
|
+
const val = unquoteYamlScalar(m[2]);
|
|
925
|
+
if (key === "default")
|
|
926
|
+
model.default = val;
|
|
927
|
+
else if (key === "provider")
|
|
928
|
+
model.provider = val;
|
|
929
|
+
else if (key === "base_url")
|
|
930
|
+
model.base_url = val;
|
|
931
|
+
}
|
|
932
|
+
const providerIds = [];
|
|
933
|
+
const provSpan = sections.find((s) => s.key === "providers");
|
|
934
|
+
if (provSpan !== undefined) {
|
|
935
|
+
for (let i = provSpan.headerLine + 1; i < provSpan.endLine; i++) {
|
|
936
|
+
const h = /^ {2}(\S[^:]*):\s*$/.exec(lines[i]);
|
|
937
|
+
if (h !== null)
|
|
938
|
+
providerIds.push(h[1]);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
return { info: { model, providerIds } };
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* Upsert one `KEY=value` line in a .env text (pure). Only active lines
|
|
945
|
+
* (`^KEY=`) match — commented-out occurrences are left alone; comments and
|
|
946
|
+
* blank lines are byte-preserved. value=null deletes the line(s); a missing
|
|
947
|
+
* key with a non-null value is appended at the end.
|
|
948
|
+
*/
|
|
949
|
+
export function upsertEnvLines(text, key, value) {
|
|
950
|
+
const lines = text.split("\n");
|
|
951
|
+
const prefix = `${key}=`;
|
|
952
|
+
let seen = false;
|
|
953
|
+
const out = [];
|
|
954
|
+
for (const line of lines) {
|
|
955
|
+
if (line.startsWith(prefix)) {
|
|
956
|
+
seen = true;
|
|
957
|
+
if (value !== null)
|
|
958
|
+
out.push(`${key}=${value}`);
|
|
959
|
+
continue; // null → drop the line
|
|
960
|
+
}
|
|
961
|
+
out.push(line);
|
|
962
|
+
}
|
|
963
|
+
if (!seen && value !== null) {
|
|
964
|
+
// keep a trailing newline trailing: insert before a final empty element
|
|
965
|
+
if (out.length > 0 && out[out.length - 1] === "") {
|
|
966
|
+
out.splice(out.length - 1, 0, `${key}=${value}`);
|
|
967
|
+
}
|
|
968
|
+
else {
|
|
969
|
+
out.push(`${key}=${value}`);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
return out.join("\n");
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Plan cron re-pins: jobs that are enabled AND pinned to `oldProvider`
|
|
976
|
+
* (jobs.json shape `{jobs: [...]}`, a bare array tolerated). Malformed input
|
|
977
|
+
* yields an empty plan, never a throw.
|
|
978
|
+
*/
|
|
979
|
+
export function buildCronRepinPlan(jobsJson, oldProvider) {
|
|
980
|
+
const jobs = Array.isArray(jobsJson)
|
|
981
|
+
? jobsJson
|
|
982
|
+
: typeof jobsJson === "object" && jobsJson !== null
|
|
983
|
+
? jobsJson.jobs
|
|
984
|
+
: undefined;
|
|
985
|
+
if (!Array.isArray(jobs))
|
|
986
|
+
return [];
|
|
987
|
+
const out = [];
|
|
988
|
+
for (const j of jobs) {
|
|
989
|
+
if (typeof j !== "object" || j === null)
|
|
990
|
+
continue;
|
|
991
|
+
const r = j;
|
|
992
|
+
if (r.enabled !== true)
|
|
993
|
+
continue;
|
|
994
|
+
if (r.provider !== oldProvider)
|
|
995
|
+
continue;
|
|
996
|
+
if (typeof r.id !== "string" || r.id === "")
|
|
997
|
+
continue;
|
|
998
|
+
out.push({
|
|
999
|
+
jobId: r.id,
|
|
1000
|
+
prevProvider: oldProvider,
|
|
1001
|
+
prevModel: typeof r.model === "string" && r.model !== "" ? r.model : null,
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
return out;
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* Parse the provider registry (`hermes-providers.json`). Corrupt/missing
|
|
1008
|
+
* content is best-effort → empty registry; entries lacking id/keyEnv are
|
|
1009
|
+
* dropped.
|
|
1010
|
+
*/
|
|
1011
|
+
export function parseHermesRegistry(text) {
|
|
1012
|
+
try {
|
|
1013
|
+
const raw = JSON.parse(text);
|
|
1014
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
1015
|
+
return {};
|
|
1016
|
+
}
|
|
1017
|
+
const out = {};
|
|
1018
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
1019
|
+
if (typeof v !== "object" || v === null)
|
|
1020
|
+
continue;
|
|
1021
|
+
const r = v;
|
|
1022
|
+
if (typeof r.id !== "string" || r.id === "")
|
|
1023
|
+
continue;
|
|
1024
|
+
if (typeof r.keyEnv !== "string" || r.keyEnv === "")
|
|
1025
|
+
continue;
|
|
1026
|
+
const entry = { id: r.id, keyEnv: r.keyEnv };
|
|
1027
|
+
if (typeof r.modelOverride === "string" && r.modelOverride !== "") {
|
|
1028
|
+
entry.modelOverride = r.modelOverride;
|
|
1029
|
+
}
|
|
1030
|
+
out[k] = entry;
|
|
1031
|
+
}
|
|
1032
|
+
return out;
|
|
1033
|
+
}
|
|
1034
|
+
catch {
|
|
1035
|
+
return {};
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
export function serializeHermesRegistry(reg) {
|
|
1039
|
+
return `${JSON.stringify(reg, null, 2)}\n`;
|
|
1040
|
+
}
|
|
1041
|
+
function parseHermesModelPoint(v) {
|
|
1042
|
+
if (typeof v !== "object" || v === null)
|
|
1043
|
+
return undefined;
|
|
1044
|
+
const r = v;
|
|
1045
|
+
if (typeof r.id !== "string" ||
|
|
1046
|
+
typeof r.model !== "string" ||
|
|
1047
|
+
typeof r.base_url !== "string") {
|
|
1048
|
+
return undefined;
|
|
1049
|
+
}
|
|
1050
|
+
return { id: r.id, model: r.model, base_url: r.base_url };
|
|
1051
|
+
}
|
|
1052
|
+
/**
|
|
1053
|
+
* Parse `hermes-state.json` STRICTLY (畸形 → {error},不猜): it carries the
|
|
1054
|
+
* previous token and the rollback plan, so a half-broken state must not be
|
|
1055
|
+
* acted on.
|
|
1056
|
+
*/
|
|
1057
|
+
export function parseHermesStateFile(text) {
|
|
1058
|
+
let raw;
|
|
1059
|
+
try {
|
|
1060
|
+
raw = JSON.parse(text);
|
|
1061
|
+
}
|
|
1062
|
+
catch {
|
|
1063
|
+
return { error: "state 文件不是合法 JSON" };
|
|
1064
|
+
}
|
|
1065
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
1066
|
+
return { error: "state 文件根不是对象" };
|
|
1067
|
+
}
|
|
1068
|
+
const ls = raw.lastSwitch;
|
|
1069
|
+
if (typeof ls !== "object" || ls === null || Array.isArray(ls)) {
|
|
1070
|
+
return { error: "state 缺少 lastSwitch 对象" };
|
|
1071
|
+
}
|
|
1072
|
+
const r = ls;
|
|
1073
|
+
const to = parseHermesModelPoint(r.to);
|
|
1074
|
+
const from = parseHermesModelPoint(r.from);
|
|
1075
|
+
if (to === undefined || from === undefined) {
|
|
1076
|
+
return { error: "state lastSwitch.to/from 畸形" };
|
|
1077
|
+
}
|
|
1078
|
+
if (typeof r.ts !== "number" ||
|
|
1079
|
+
typeof r.ccName !== "string" ||
|
|
1080
|
+
typeof r.configBackup !== "string") {
|
|
1081
|
+
return { error: "state lastSwitch 标量字段畸形" };
|
|
1082
|
+
}
|
|
1083
|
+
const env = r.env;
|
|
1084
|
+
if (typeof env !== "object" || env === null || Array.isArray(env)) {
|
|
1085
|
+
return { error: "state env 畸形" };
|
|
1086
|
+
}
|
|
1087
|
+
const e = env;
|
|
1088
|
+
if (typeof e.key !== "string" ||
|
|
1089
|
+
(e.prevValue !== null && typeof e.prevValue !== "string")) {
|
|
1090
|
+
return { error: "state env 畸形" };
|
|
1091
|
+
}
|
|
1092
|
+
const cr = r.cronRepinned;
|
|
1093
|
+
if (!Array.isArray(cr)) {
|
|
1094
|
+
return { error: "state cronRepinned 畸形" };
|
|
1095
|
+
}
|
|
1096
|
+
const cronRepinned = [];
|
|
1097
|
+
for (const c of cr) {
|
|
1098
|
+
if (typeof c !== "object" || c === null) {
|
|
1099
|
+
return { error: "state cronRepinned 条目畸形" };
|
|
1100
|
+
}
|
|
1101
|
+
const x = c;
|
|
1102
|
+
if (typeof x.jobId !== "string" ||
|
|
1103
|
+
typeof x.prevProvider !== "string" ||
|
|
1104
|
+
(x.prevModel !== null && typeof x.prevModel !== "string")) {
|
|
1105
|
+
return { error: "state cronRepinned 条目畸形" };
|
|
1106
|
+
}
|
|
1107
|
+
cronRepinned.push({
|
|
1108
|
+
jobId: x.jobId,
|
|
1109
|
+
prevProvider: x.prevProvider,
|
|
1110
|
+
prevModel: x.prevModel,
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
return {
|
|
1114
|
+
state: {
|
|
1115
|
+
lastSwitch: {
|
|
1116
|
+
ts: r.ts,
|
|
1117
|
+
ccName: r.ccName,
|
|
1118
|
+
to,
|
|
1119
|
+
from,
|
|
1120
|
+
configBackup: r.configBackup,
|
|
1121
|
+
env: { key: e.key, prevValue: e.prevValue },
|
|
1122
|
+
cronRepinned,
|
|
1123
|
+
},
|
|
1124
|
+
},
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
export function serializeHermesStateFile(s) {
|
|
1128
|
+
return `${JSON.stringify(s, null, 2)}\n`;
|
|
1129
|
+
}
|
|
542
1130
|
function isOk(r) {
|
|
543
1131
|
return !("error" in r);
|
|
544
1132
|
}
|
|
@@ -654,6 +1242,8 @@ export function parseApiArgs(argv) {
|
|
|
654
1242
|
provider: { type: "string" },
|
|
655
1243
|
"max-tokens": { type: "string" },
|
|
656
1244
|
timeout: { type: "string" },
|
|
1245
|
+
thinking: { type: "string" },
|
|
1246
|
+
retry: { type: "string" },
|
|
657
1247
|
stream: { type: "boolean" },
|
|
658
1248
|
version: { type: "boolean" },
|
|
659
1249
|
help: { type: "boolean" },
|
|
@@ -682,6 +1272,35 @@ export function parseApiArgs(argv) {
|
|
|
682
1272
|
}
|
|
683
1273
|
timeoutMs = n;
|
|
684
1274
|
}
|
|
1275
|
+
// thinking: auto (default, omit field — endpoint default, k3 keeps its
|
|
1276
|
+
// quality source) | off (disabled; avoids thinking-only responses on
|
|
1277
|
+
// budget-starved requests) | on (enabled, budget = max_tokens/2, hence
|
|
1278
|
+
// the >= 2048 floor).
|
|
1279
|
+
let thinking = "auto";
|
|
1280
|
+
if (values.thinking !== undefined) {
|
|
1281
|
+
const t = values.thinking;
|
|
1282
|
+
if (t !== "auto" && t !== "off" && t !== "on") {
|
|
1283
|
+
return {
|
|
1284
|
+
error: `--thinking must be auto|off|on, got "${t}"`,
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
thinking = t;
|
|
1288
|
+
}
|
|
1289
|
+
if (thinking === "on" && maxTokens < 2048) {
|
|
1290
|
+
return {
|
|
1291
|
+
error: `--thinking on needs --max-tokens >= 2048 (thinking budget = max_tokens/2 = ${Math.floor(maxTokens / 2)}), got ${maxTokens}`,
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
let retries = 1;
|
|
1295
|
+
if (values.retry !== undefined) {
|
|
1296
|
+
const n = Number(values.retry);
|
|
1297
|
+
if (!Number.isInteger(n) || n < 0 || n > 5) {
|
|
1298
|
+
return {
|
|
1299
|
+
error: `--retry must be an integer in [0, 5], got "${values.retry}"`,
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
retries = n;
|
|
1303
|
+
}
|
|
685
1304
|
return {
|
|
686
1305
|
prompt: typeof values.prompt === "string" ? values.prompt : undefined,
|
|
687
1306
|
model: typeof values.model === "string" ? values.model : undefined,
|
|
@@ -690,6 +1309,8 @@ export function parseApiArgs(argv) {
|
|
|
690
1309
|
timeoutMs,
|
|
691
1310
|
// default stream=true; --no-stream (allowNegative) → false
|
|
692
1311
|
stream: values.stream !== false,
|
|
1312
|
+
thinking,
|
|
1313
|
+
retries,
|
|
693
1314
|
version: values.version === true,
|
|
694
1315
|
help: values.help === true,
|
|
695
1316
|
cwd: typeof values.cwd === "string" ? values.cwd : undefined,
|
|
@@ -832,30 +1453,57 @@ export function runAgyInteractive(args, cwd) {
|
|
|
832
1453
|
export function runClaudeInteractive(args, cwd) {
|
|
833
1454
|
return spawnInteractive(CLAUDE_BIN, args, cwd);
|
|
834
1455
|
}
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
// ---------------------------------------------------------------------------
|
|
1456
|
+
const API_RETRY_BASE_DELAY_MS = 400;
|
|
1457
|
+
const API_RETRY_MAX_DELAY_MS = 4000;
|
|
838
1458
|
/**
|
|
839
1459
|
* Production deps.runApi: POST an anthropic-compatible /v1/messages request
|
|
840
1460
|
* and return a normalized RunOutcome.
|
|
841
1461
|
*
|
|
842
1462
|
* - stream=true: reads the SSE body chunk-by-chunk, decodes UTF-8, splits on
|
|
843
1463
|
* newlines, and aggregates `text_delta` payloads into stdout. Two clocks
|
|
844
|
-
* guard against hangs: an idle timer (reset on
|
|
845
|
-
*
|
|
846
|
-
* AbortController → exit 1
|
|
1464
|
+
* guard against hangs: an idle timer (reset on EVERY received chunk —
|
|
1465
|
+
* thinking models can emit long non-text stretches) and an absolute timer
|
|
1466
|
+
* (timeoutMs). Either firing aborts the fetch via AbortController → exit 1
|
|
1467
|
+
* with a timeout message.
|
|
847
1468
|
* - stream=false: awaits the full JSON body and extracts `content[].text`,
|
|
848
1469
|
* then applies the 50k-char truncation.
|
|
849
1470
|
*
|
|
1471
|
+
* TRANSIENT failures are retried automatically (default 1 retry, `--retry N`):
|
|
1472
|
+
* network-level fetch errors, HTTP 408/429/5xx, malformed JSON, empty-content
|
|
1473
|
+
* 200 bodies, and streams that die or deliver no SSE events at all. Each retry
|
|
1474
|
+
* note lands on stderr (stdout stays pipe-clean), so a recovered attempt still
|
|
1475
|
+
* leaves a trace. Deterministic failures are NOT retried — timeouts, 4xx, and
|
|
1476
|
+
* thinking-only responses (retrying cannot fix a max_tokens budget starved by
|
|
1477
|
+
* endpoint-default thinking); those errors carry a diagnosis instead.
|
|
1478
|
+
*
|
|
850
1479
|
* HTTP errors (non-2xx, network failure, abort) → exit 1 with diagnostics on
|
|
851
1480
|
* stderr; stdout stays empty so the caller's empty-output guard still works.
|
|
852
1481
|
*/
|
|
853
1482
|
export async function runApi(req) {
|
|
1483
|
+
const maxAttempts = Math.max(1, (req.retries ?? 0) + 1);
|
|
1484
|
+
const retryNotes = [];
|
|
1485
|
+
for (let attempt = 1;; attempt++) {
|
|
1486
|
+
const res = await runApiAttempt(req);
|
|
1487
|
+
if (res.retryable === false || attempt >= maxAttempts) {
|
|
1488
|
+
if (retryNotes.length === 0)
|
|
1489
|
+
return res.outcome;
|
|
1490
|
+
return {
|
|
1491
|
+
...res.outcome,
|
|
1492
|
+
stderr: [...retryNotes, res.outcome.stderr].join("\n"),
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1495
|
+
const delayMs = Math.min(API_RETRY_MAX_DELAY_MS, API_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1));
|
|
1496
|
+
retryNotes.push(`gcli: api attempt ${attempt}/${maxAttempts} failed (${res.retryable}); retrying in ${delayMs}ms`);
|
|
1497
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
/** A single fetch + body-consumption pass (fresh AbortController + timers). */
|
|
1501
|
+
async function runApiAttempt(req) {
|
|
854
1502
|
const controller = new AbortController();
|
|
855
1503
|
const { signal } = controller;
|
|
856
1504
|
// Two timers: absolute + idle. timeoutMs is both the hard ceiling and the
|
|
857
1505
|
// default idle budget — SSE streams that go quiet for that long are treated
|
|
858
|
-
// as hung. We reset the idle clock whenever we receive
|
|
1506
|
+
// as hung. We reset the idle clock whenever we receive any chunk.
|
|
859
1507
|
let idleTimer;
|
|
860
1508
|
let absoluteTimer;
|
|
861
1509
|
let timedOut = false;
|
|
@@ -878,6 +1526,11 @@ export async function runApi(req) {
|
|
|
878
1526
|
if (absoluteTimer)
|
|
879
1527
|
clearTimeout(absoluteTimer);
|
|
880
1528
|
};
|
|
1529
|
+
const timeoutOutcome = () => ({
|
|
1530
|
+
exitCode: 1,
|
|
1531
|
+
stdout: "",
|
|
1532
|
+
stderr: `gcli: api timed out after ${req.timeoutMs}ms`,
|
|
1533
|
+
});
|
|
881
1534
|
const headers = {
|
|
882
1535
|
Authorization: `Bearer ${req.token}`,
|
|
883
1536
|
"anthropic-version": "2023-06-01",
|
|
@@ -895,18 +1548,16 @@ export async function runApi(req) {
|
|
|
895
1548
|
}
|
|
896
1549
|
catch (err) {
|
|
897
1550
|
clearTimers();
|
|
898
|
-
if (timedOut)
|
|
899
|
-
return {
|
|
900
|
-
exitCode: 1,
|
|
901
|
-
stdout: "",
|
|
902
|
-
stderr: `gcli: api timed out after ${req.timeoutMs}ms`,
|
|
903
|
-
};
|
|
904
|
-
}
|
|
1551
|
+
if (timedOut)
|
|
1552
|
+
return { outcome: timeoutOutcome(), retryable: false };
|
|
905
1553
|
const msg = err instanceof Error ? err.message : String(err);
|
|
906
1554
|
return {
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1555
|
+
outcome: {
|
|
1556
|
+
exitCode: 1,
|
|
1557
|
+
stdout: "",
|
|
1558
|
+
stderr: `gcli: api request failed: ${msg}`,
|
|
1559
|
+
},
|
|
1560
|
+
retryable: `fetch failed: ${msg}`.slice(0, 120),
|
|
910
1561
|
};
|
|
911
1562
|
}
|
|
912
1563
|
if (!response.ok) {
|
|
@@ -920,9 +1571,14 @@ export async function runApi(req) {
|
|
|
920
1571
|
}
|
|
921
1572
|
const trimmed = detail.trim().slice(0, 1000);
|
|
922
1573
|
return {
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
1574
|
+
outcome: {
|
|
1575
|
+
exitCode: 1,
|
|
1576
|
+
stdout: "",
|
|
1577
|
+
stderr: `gcli: api returned HTTP ${response.status}${trimmed ? `: ${trimmed}` : ""}`,
|
|
1578
|
+
},
|
|
1579
|
+
retryable: API_RETRYABLE_STATUS.has(response.status)
|
|
1580
|
+
? `HTTP ${response.status}`
|
|
1581
|
+
: false,
|
|
926
1582
|
};
|
|
927
1583
|
}
|
|
928
1584
|
if (!req.stream) {
|
|
@@ -934,58 +1590,118 @@ export async function runApi(req) {
|
|
|
934
1590
|
catch (err) {
|
|
935
1591
|
const msg = err instanceof Error ? err.message : String(err);
|
|
936
1592
|
return {
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
1593
|
+
outcome: {
|
|
1594
|
+
exitCode: 1,
|
|
1595
|
+
stdout: "",
|
|
1596
|
+
stderr: `gcli: api returned malformed JSON: ${msg}`,
|
|
1597
|
+
},
|
|
1598
|
+
retryable: "malformed JSON",
|
|
1599
|
+
};
|
|
1600
|
+
}
|
|
1601
|
+
// Some gateways answer 200 with an error envelope instead of a message.
|
|
1602
|
+
const errEnvelope = body;
|
|
1603
|
+
if (errEnvelope.type === "error" || errEnvelope.error != null) {
|
|
1604
|
+
return {
|
|
1605
|
+
outcome: {
|
|
1606
|
+
exitCode: 1,
|
|
1607
|
+
stdout: "",
|
|
1608
|
+
stderr: `gcli: api returned an error body: ${JSON.stringify(body).slice(0, 500)}`,
|
|
1609
|
+
},
|
|
1610
|
+
retryable: false,
|
|
940
1611
|
};
|
|
941
1612
|
}
|
|
942
1613
|
const text = extractNonStreamText(body);
|
|
943
1614
|
if (!text) {
|
|
1615
|
+
const info = describeNoTextBody(body);
|
|
1616
|
+
if (!info.blocks) {
|
|
1617
|
+
// 200 with nothing usable in content — gateway hiccup, worth a retry.
|
|
1618
|
+
return {
|
|
1619
|
+
outcome: {
|
|
1620
|
+
exitCode: 1,
|
|
1621
|
+
stdout: "",
|
|
1622
|
+
stderr: "gcli: api returned no text content (empty content[])",
|
|
1623
|
+
},
|
|
1624
|
+
retryable: "empty content[]",
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
944
1627
|
return {
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
1628
|
+
outcome: {
|
|
1629
|
+
exitCode: 1,
|
|
1630
|
+
stdout: "",
|
|
1631
|
+
stderr: `gcli: api returned no text content (blocks=[${info.blocks}]` +
|
|
1632
|
+
`${info.stopReason ? `, stop_reason=${info.stopReason}` : ""})` +
|
|
1633
|
+
(info.sawThinking
|
|
1634
|
+
? " — thinking consumed the whole max_tokens budget; raise --max-tokens or pass --thinking off"
|
|
1635
|
+
: ""),
|
|
1636
|
+
},
|
|
1637
|
+
// thinking-only is a deterministic budget outcome, not a flake
|
|
1638
|
+
retryable: false,
|
|
948
1639
|
};
|
|
949
1640
|
}
|
|
950
|
-
return {
|
|
1641
|
+
return {
|
|
1642
|
+
outcome: { exitCode: 0, stdout: truncate(text), stderr: "" },
|
|
1643
|
+
retryable: false,
|
|
1644
|
+
};
|
|
951
1645
|
}
|
|
952
1646
|
// Streaming: aggregate text_delta chunks. response.body is a web stream;
|
|
953
1647
|
// TextDecoder handles multi-byte UTF-8 split across chunk boundaries, and a
|
|
954
1648
|
// leftover buffer carries the partial final line until the next newline.
|
|
955
1649
|
if (response.body === null) {
|
|
956
1650
|
clearTimers();
|
|
957
|
-
return {
|
|
1651
|
+
return {
|
|
1652
|
+
outcome: {
|
|
1653
|
+
exitCode: 1,
|
|
1654
|
+
stdout: "",
|
|
1655
|
+
stderr: "gcli: api stream had no body",
|
|
1656
|
+
},
|
|
1657
|
+
retryable: "stream had no body",
|
|
1658
|
+
};
|
|
958
1659
|
}
|
|
959
1660
|
const reader = response.body.getReader();
|
|
960
1661
|
const decoder = new TextDecoder("utf-8");
|
|
961
1662
|
let aggregated = "";
|
|
962
1663
|
let leftover = "";
|
|
1664
|
+
let sawEvent = false;
|
|
1665
|
+
let thinkingChars = 0;
|
|
1666
|
+
let stopReason;
|
|
963
1667
|
try {
|
|
964
1668
|
// eslint-disable-next-line no-constant-condition
|
|
965
1669
|
while (true) {
|
|
966
1670
|
const { done, value } = await reader.read();
|
|
967
1671
|
if (done)
|
|
968
1672
|
break;
|
|
1673
|
+
// Any traffic proves the stream is alive — reset the idle clock on every
|
|
1674
|
+
// chunk, not only text-bearing ones (thinking models can emit long
|
|
1675
|
+
// non-text stretches before the first text_delta).
|
|
1676
|
+
resetIdle();
|
|
969
1677
|
leftover += decoder.decode(value, { stream: true });
|
|
970
1678
|
// SSE events are separated by newlines; process every complete line and
|
|
971
1679
|
// keep the trailing partial in `leftover`.
|
|
972
1680
|
const lines = leftover.split(/\r?\n/);
|
|
973
1681
|
leftover = lines.pop() ?? "";
|
|
974
1682
|
for (const line of lines) {
|
|
975
|
-
const
|
|
976
|
-
if (
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1683
|
+
const meta = extractSseLineMeta(line);
|
|
1684
|
+
if (meta.parsed)
|
|
1685
|
+
sawEvent = true;
|
|
1686
|
+
if (meta.text !== null)
|
|
1687
|
+
aggregated += meta.text;
|
|
1688
|
+
thinkingChars += meta.thinkingChars;
|
|
1689
|
+
if (meta.stopReason !== undefined)
|
|
1690
|
+
stopReason = meta.stopReason;
|
|
980
1691
|
}
|
|
981
1692
|
}
|
|
982
1693
|
// Flush any trailing line (some servers omit the final newline).
|
|
983
1694
|
const tail = decoder.decode();
|
|
984
1695
|
leftover += tail;
|
|
985
1696
|
if (leftover.length > 0) {
|
|
986
|
-
const
|
|
987
|
-
if (
|
|
988
|
-
|
|
1697
|
+
const meta = extractSseLineMeta(leftover);
|
|
1698
|
+
if (meta.parsed)
|
|
1699
|
+
sawEvent = true;
|
|
1700
|
+
if (meta.text !== null)
|
|
1701
|
+
aggregated += meta.text;
|
|
1702
|
+
thinkingChars += meta.thinkingChars;
|
|
1703
|
+
if (meta.stopReason !== undefined)
|
|
1704
|
+
stopReason = meta.stopReason;
|
|
989
1705
|
}
|
|
990
1706
|
}
|
|
991
1707
|
catch (err) {
|
|
@@ -993,31 +1709,54 @@ export async function runApi(req) {
|
|
|
993
1709
|
if (timedOut) {
|
|
994
1710
|
// Timeout is a backend error (exit 1) per the contract, even when some
|
|
995
1711
|
// text was already received — callers must not treat a timed-out
|
|
996
|
-
// response as success.
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
stderr: `gcli: api timed out after ${req.timeoutMs}ms`,
|
|
1001
|
-
};
|
|
1712
|
+
// response as success. Nothing has been emitted yet (stream output is
|
|
1713
|
+
// aggregated before printing), so a retry would also be safe — but a
|
|
1714
|
+
// request that already burned its whole time budget won't get one.
|
|
1715
|
+
return { outcome: timeoutOutcome(), retryable: false };
|
|
1002
1716
|
}
|
|
1003
1717
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1004
1718
|
return {
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1719
|
+
outcome: {
|
|
1720
|
+
exitCode: 1,
|
|
1721
|
+
stdout: "",
|
|
1722
|
+
stderr: `gcli: api stream read failed: ${msg}`,
|
|
1723
|
+
},
|
|
1724
|
+
retryable: `stream read failed: ${msg}`.slice(0, 120),
|
|
1008
1725
|
};
|
|
1009
1726
|
}
|
|
1010
1727
|
clearTimers();
|
|
1011
1728
|
if (!aggregated) {
|
|
1729
|
+
if (!sawEvent) {
|
|
1730
|
+
// Connection delivered no SSE events at all — transport-level flake.
|
|
1731
|
+
return {
|
|
1732
|
+
outcome: {
|
|
1733
|
+
exitCode: 1,
|
|
1734
|
+
stdout: "",
|
|
1735
|
+
stderr: "gcli: api stream produced no text (stream closed with no SSE events)",
|
|
1736
|
+
},
|
|
1737
|
+
retryable: "no SSE events",
|
|
1738
|
+
};
|
|
1739
|
+
}
|
|
1740
|
+
const detail = thinkingChars > 0
|
|
1741
|
+
? ` (thinking ~${thinkingChars}ch${stopReason ? `, stop_reason=${stopReason}` : ""}) — thinking consumed the whole max_tokens budget; raise --max-tokens or pass --thinking off`
|
|
1742
|
+
: stopReason
|
|
1743
|
+
? ` (stop_reason=${stopReason})`
|
|
1744
|
+
: "";
|
|
1012
1745
|
return {
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1746
|
+
outcome: {
|
|
1747
|
+
exitCode: 1,
|
|
1748
|
+
stdout: "",
|
|
1749
|
+
stderr: `gcli: api stream produced no text${detail}`,
|
|
1750
|
+
},
|
|
1751
|
+
retryable: false,
|
|
1016
1752
|
};
|
|
1017
1753
|
}
|
|
1018
1754
|
// Streaming output is not truncated (real-time, contract §A); only the
|
|
1019
1755
|
// non-stream path truncates.
|
|
1020
|
-
return {
|
|
1756
|
+
return {
|
|
1757
|
+
outcome: { exitCode: 0, stdout: aggregated, stderr: "" },
|
|
1758
|
+
retryable: false,
|
|
1759
|
+
};
|
|
1021
1760
|
}
|
|
1022
1761
|
// ---------------------------------------------------------------------------
|
|
1023
1762
|
// cc-switch provider lookup
|
|
@@ -1387,12 +2126,161 @@ async function fetchProviderQuotasHttp(items) {
|
|
|
1387
2126
|
return out;
|
|
1388
2127
|
}
|
|
1389
2128
|
// ---------------------------------------------------------------------------
|
|
2129
|
+
// hermes backend — production deps implementations
|
|
2130
|
+
// ---------------------------------------------------------------------------
|
|
2131
|
+
/**
|
|
2132
|
+
* Production deps.runHermes: spawn `hermes <args>` with the same IO/timeout
|
|
2133
|
+
* contract as runClaude (pipe stdout/stderr, SIGTERM on timeout, no stdin
|
|
2134
|
+
* inheritance needed — cron edit / gateway / -z ping never read stdin).
|
|
2135
|
+
*/
|
|
2136
|
+
function runHermesProcess(args, timeoutMs) {
|
|
2137
|
+
return new Promise((resolveFn) => {
|
|
2138
|
+
const child = spawn(HERMES_BIN, args, {
|
|
2139
|
+
env: { ...process.env },
|
|
2140
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2141
|
+
});
|
|
2142
|
+
let stdout = "";
|
|
2143
|
+
let stderr = "";
|
|
2144
|
+
let timedOut = false;
|
|
2145
|
+
child.stdout.on("data", (chunk) => {
|
|
2146
|
+
stdout += chunk.toString();
|
|
2147
|
+
});
|
|
2148
|
+
child.stderr.on("data", (chunk) => {
|
|
2149
|
+
stderr += chunk.toString();
|
|
2150
|
+
});
|
|
2151
|
+
const timer = setTimeout(() => {
|
|
2152
|
+
timedOut = true;
|
|
2153
|
+
child.kill("SIGTERM");
|
|
2154
|
+
}, timeoutMs);
|
|
2155
|
+
child.on("error", () => {
|
|
2156
|
+
clearTimeout(timer);
|
|
2157
|
+
resolveFn({ stdout, stderr, exitCode: null, timedOut: false });
|
|
2158
|
+
});
|
|
2159
|
+
child.on("close", (code, signal) => {
|
|
2160
|
+
clearTimeout(timer);
|
|
2161
|
+
resolveFn({
|
|
2162
|
+
stdout,
|
|
2163
|
+
stderr,
|
|
2164
|
+
exitCode: code,
|
|
2165
|
+
signal: signal ?? null,
|
|
2166
|
+
timedOut,
|
|
2167
|
+
});
|
|
2168
|
+
});
|
|
2169
|
+
});
|
|
2170
|
+
}
|
|
2171
|
+
/** Production deps.readTextFile: UTF-8 read; missing/unreadable → undefined. */
|
|
2172
|
+
function readTextFileFromDisk(path) {
|
|
2173
|
+
return new Promise((resolvePromise) => {
|
|
2174
|
+
try {
|
|
2175
|
+
resolvePromise(readFileSync(path, "utf8"));
|
|
2176
|
+
}
|
|
2177
|
+
catch {
|
|
2178
|
+
resolvePromise(undefined);
|
|
2179
|
+
}
|
|
2180
|
+
});
|
|
2181
|
+
}
|
|
2182
|
+
/**
|
|
2183
|
+
* Production deps.writeTextFileAtomic: tmp + rename (atomic on POSIX).
|
|
2184
|
+
* mode 缺省时沿用目标文件现有权限,目标不存在则 0o600。Any failure →
|
|
2185
|
+
* {ok:false} (never throws); the tmp file is cleaned up best-effort.
|
|
2186
|
+
*/
|
|
2187
|
+
function writeTextFileAtomicToDisk(path, text, mode) {
|
|
2188
|
+
return new Promise((resolvePromise) => {
|
|
2189
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
2190
|
+
try {
|
|
2191
|
+
let m = mode;
|
|
2192
|
+
if (m === undefined) {
|
|
2193
|
+
try {
|
|
2194
|
+
m = statSync(path).mode & 0o777;
|
|
2195
|
+
}
|
|
2196
|
+
catch {
|
|
2197
|
+
m = 0o600;
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
2201
|
+
writeFileSync(tmp, text, { encoding: "utf8", mode: m });
|
|
2202
|
+
renameSync(tmp, path);
|
|
2203
|
+
resolvePromise({ ok: true });
|
|
2204
|
+
}
|
|
2205
|
+
catch (err) {
|
|
2206
|
+
try {
|
|
2207
|
+
rmSync(tmp, { force: true });
|
|
2208
|
+
}
|
|
2209
|
+
catch {
|
|
2210
|
+
// best-effort cleanup
|
|
2211
|
+
}
|
|
2212
|
+
resolvePromise({
|
|
2213
|
+
ok: false,
|
|
2214
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2215
|
+
});
|
|
2216
|
+
}
|
|
2217
|
+
});
|
|
2218
|
+
}
|
|
2219
|
+
/** Production deps.copyFile: full-file copy (backup / whole-file restore). */
|
|
2220
|
+
function copyFileOnDisk(src, dest) {
|
|
2221
|
+
return new Promise((resolvePromise) => {
|
|
2222
|
+
try {
|
|
2223
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
2224
|
+
copyFileSync(src, dest);
|
|
2225
|
+
resolvePromise({ ok: true });
|
|
2226
|
+
}
|
|
2227
|
+
catch (err) {
|
|
2228
|
+
resolvePromise({
|
|
2229
|
+
ok: false,
|
|
2230
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2231
|
+
});
|
|
2232
|
+
}
|
|
2233
|
+
});
|
|
2234
|
+
}
|
|
2235
|
+
/**
|
|
2236
|
+
* Production deps.queryLastSessionModel: sqlite3 -readonly -json against
|
|
2237
|
+
* HERMES_STATE_DB_PATH for the newest session_model_usage row (best-effort
|
|
2238
|
+
* verification; any failure → undefined). Same spawn skeleton as
|
|
2239
|
+
* readCcSwitchProvider.
|
|
2240
|
+
*/
|
|
2241
|
+
function queryLastSessionModelFromDb() {
|
|
2242
|
+
return new Promise((resolvePromise) => {
|
|
2243
|
+
const sql = "SELECT model, billing_provider AS provider FROM session_model_usage ORDER BY last_seen DESC LIMIT 1";
|
|
2244
|
+
const child = spawn("sqlite3", ["-readonly", "-json", HERMES_STATE_DB_PATH, sql], {
|
|
2245
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2246
|
+
});
|
|
2247
|
+
let stdout = "";
|
|
2248
|
+
child.stdout.on("data", (chunk) => {
|
|
2249
|
+
stdout += chunk.toString();
|
|
2250
|
+
});
|
|
2251
|
+
child.on("error", () => resolvePromise(undefined));
|
|
2252
|
+
child.on("close", (code) => {
|
|
2253
|
+
if (code !== 0) {
|
|
2254
|
+
resolvePromise(undefined);
|
|
2255
|
+
return;
|
|
2256
|
+
}
|
|
2257
|
+
try {
|
|
2258
|
+
const parsed = JSON.parse(stdout.trim() || "[]");
|
|
2259
|
+
if (!Array.isArray(parsed) || parsed.length === 0) {
|
|
2260
|
+
resolvePromise(undefined);
|
|
2261
|
+
return;
|
|
2262
|
+
}
|
|
2263
|
+
const row = parsed[0];
|
|
2264
|
+
if (typeof row.model !== "string" || typeof row.provider !== "string") {
|
|
2265
|
+
resolvePromise(undefined);
|
|
2266
|
+
return;
|
|
2267
|
+
}
|
|
2268
|
+
resolvePromise({ model: row.model, provider: row.provider });
|
|
2269
|
+
}
|
|
2270
|
+
catch {
|
|
2271
|
+
resolvePromise(undefined);
|
|
2272
|
+
}
|
|
2273
|
+
});
|
|
2274
|
+
});
|
|
2275
|
+
}
|
|
2276
|
+
// ---------------------------------------------------------------------------
|
|
1390
2277
|
// Main
|
|
1391
2278
|
// ---------------------------------------------------------------------------
|
|
1392
2279
|
const HELP = `Usage:
|
|
1393
2280
|
gcli [claude] [options] [-- <args>] wrap the claude CLI (default backend)
|
|
1394
2281
|
gcli agy [options] [-- <args>] wrap the agy CLI (explicit subcommand)
|
|
1395
2282
|
gcli api [options] call an anthropic-compatible messages API
|
|
2283
|
+
gcli hermes <provider|status|rollback> [options] hermes agent 主模型一键切换
|
|
1396
2284
|
|
|
1397
2285
|
gcli sits in front of three backends and gives skills a stable entry point:
|
|
1398
2286
|
50k-char output truncation (agy/claude; api only when non-stream), a hard
|
|
@@ -1461,6 +2349,13 @@ api backend (\`gcli api ...\`) — pure HTTP, no agent, no subprocess:
|
|
|
1461
2349
|
--max-tokens <n> Output token budget (default 80000)
|
|
1462
2350
|
--timeout <ms> Idle + absolute timeout in ms (default 300000, max 1800000)
|
|
1463
2351
|
--stream|--no-stream Stream SSE and aggregate live (default stream)
|
|
2352
|
+
--thinking <mode> auto|off|on (default auto = omit the thinking field,
|
|
2353
|
+
endpoint default applies; off sends disabled; on
|
|
2354
|
+
sends enabled with budget = max_tokens/2, needs
|
|
2355
|
+
--max-tokens >= 2048)
|
|
2356
|
+
--retry <n> Auto retries for transient failures: network errors,
|
|
2357
|
+
HTTP 408/429/5xx, malformed JSON, empty content
|
|
2358
|
+
(default 1, max 5; exponential backoff 400ms..4s)
|
|
1464
2359
|
--version Print the api backend identity
|
|
1465
2360
|
--help Show this help
|
|
1466
2361
|
|
|
@@ -1468,8 +2363,38 @@ api backend (\`gcli api ...\`) — pure HTTP, no agent, no subprocess:
|
|
|
1468
2363
|
- No --cwd (no file operations; supplied --cwd is warned + ignored).
|
|
1469
2364
|
- --yolo/--sandbox are rejected (they are agent flags; api has no agent).
|
|
1470
2365
|
- Unknown flags exit 2 (strict; nothing to forward to).
|
|
1471
|
-
- thinking
|
|
1472
|
-
|
|
2366
|
+
- thinking auto keeps the endpoint's own default. Beware bigmodel GLM
|
|
2367
|
+
endpoints: thinking defaults ON there and shares the max_tokens budget
|
|
2368
|
+
with text, so a small explicit budget can yield a thinking-only response
|
|
2369
|
+
("no text content ... blocks=[thinking], stop_reason=max_tokens").
|
|
2370
|
+
Fix: raise --max-tokens or pass --thinking off (k3 quality workflows:
|
|
2371
|
+
keep auto/on + a generous budget). Deterministic failures are never
|
|
2372
|
+
retried; transient ones (network/408/429/5xx/empty body) are, with each
|
|
2373
|
+
attempt noted on stderr.
|
|
2374
|
+
|
|
2375
|
+
hermes backend (\`gcli hermes ...\`) — hermes agent 主模型/provider 一键切换:
|
|
2376
|
+
<provider> cc-switch provider 名(exact/case/substring 三层匹配);
|
|
2377
|
+
TTY 下省略则弹出选择菜单;非 TTY 省略 → exit 2 零交互
|
|
2378
|
+
status 当前 model 段 + .env key 存在性 + 上次切换记录
|
|
2379
|
+
rollback 切回上一个 provider(from/to 互换写回,支持来回 toggle)
|
|
2380
|
+
--model <name> 覆盖模型名(自动剥 [1M] 后缀),并持久化进 registry
|
|
2381
|
+
--dry-run 只输出计划:零写入零 spawn(.env 行显示 KEY=<redacted>)
|
|
2382
|
+
--no-verify 跳过切换后的 hermes -z ping 验证
|
|
2383
|
+
--keep-on-fail 验证/网关失败时不自动回滚(默认自动回滚)
|
|
2384
|
+
|
|
2385
|
+
Notes:
|
|
2386
|
+
- 切换流程 = 备份 config.yaml → 改 model/providers 段(原子写)→ .env
|
|
2387
|
+
upsert key(0o600)→ cron 重 pin(仅 enabled 且 pin 在旧 provider 的
|
|
2388
|
+
job,单条失败 warn 继续)→ 重启网关 → hermes -z ping 验证(60s 硬门槛)
|
|
2389
|
+
+ state.db session_model_usage best-effort 比对。
|
|
2390
|
+
- 验证或网关失败默认自动回滚整链路(config/.env/cron pin/网关)。
|
|
2391
|
+
- 备份命名 config.yaml.bak-before-<id>-<epoch>,写入均 tmp+rename 原子写。
|
|
2392
|
+
- registry: ~/.config/gcli/hermes-providers.json(cc-switch 名 → id/keyEnv
|
|
2393
|
+
/modelOverride);state: ~/.config/gcli/hermes-state.json(0o600,含旧
|
|
2394
|
+
token 的 prevValue 供 rollback)。
|
|
2395
|
+
- 输出红线:任何 stdout/stderr/dry-run 计划只打印 key 名,永不打印 token 值。
|
|
2396
|
+
- 绝不修改 ~/.claude/settings.json 与 cc-switch.db(后者只读)。
|
|
2397
|
+
- config.yaml 结构不认识(缺 model:/providers: 段、意外嵌套)→ 报错零写盘。
|
|
1473
2398
|
|
|
1474
2399
|
Exit codes: 0 success | 1 backend error / timeout / empty output | 2 bad args
|
|
1475
2400
|
| N (interactive mode: child's exit code passed through unchanged)`;
|
|
@@ -1870,7 +2795,7 @@ async function resolveApiProviderEnv(providerName, model, deps) {
|
|
|
1870
2795
|
envResult.env.ANTHROPIC_MODEL ??
|
|
1871
2796
|
envResult.env.ANTHROPIC_DEFAULT_SONNET_MODEL ??
|
|
1872
2797
|
envResult.env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
|
|
1873
|
-
const resolvedModel = rawModel
|
|
2798
|
+
const resolvedModel = rawModel === undefined ? undefined : stripContextSuffix(rawModel);
|
|
1874
2799
|
if (!baseUrl) {
|
|
1875
2800
|
return err(1, "gcli: provider env is missing ANTHROPIC_BASE_URL");
|
|
1876
2801
|
}
|
|
@@ -1938,6 +2863,9 @@ async function runApiBackend(parsed, deps) {
|
|
|
1938
2863
|
prompt,
|
|
1939
2864
|
stream: parsed.stream,
|
|
1940
2865
|
timeoutMs: parsed.timeoutMs,
|
|
2866
|
+
// auto = omit the thinking field entirely (endpoint default)
|
|
2867
|
+
thinking: parsed.thinking === "auto" ? undefined : parsed.thinking,
|
|
2868
|
+
retries: parsed.retries,
|
|
1941
2869
|
};
|
|
1942
2870
|
const outcome = await deps.runApi(req);
|
|
1943
2871
|
// Empty output is a backend error (exit 1), consistent with the other
|
|
@@ -1951,6 +2879,494 @@ async function runApiBackend(parsed, deps) {
|
|
|
1951
2879
|
}
|
|
1952
2880
|
return wrap(outcome);
|
|
1953
2881
|
}
|
|
2882
|
+
// ---------------------------------------------------------------------------
|
|
2883
|
+
// hermes backend — orchestration (status / rollback / switch)
|
|
2884
|
+
// ---------------------------------------------------------------------------
|
|
2885
|
+
const HERMES_USAGE = "gcli hermes <provider|status|rollback> [--model <m>] [--dry-run] [--no-verify] [--keep-on-fail]";
|
|
2886
|
+
function hermesFail(exitCode, stderr) {
|
|
2887
|
+
return { exitCode, stdout: "", stderr };
|
|
2888
|
+
}
|
|
2889
|
+
/** Read the active (uncommented) value of KEY from a .env text, or null. */
|
|
2890
|
+
function envValueOf(envText, key) {
|
|
2891
|
+
const prefix = `${key}=`;
|
|
2892
|
+
for (const line of envText.split("\n")) {
|
|
2893
|
+
if (line.startsWith(prefix))
|
|
2894
|
+
return line.slice(prefix.length);
|
|
2895
|
+
}
|
|
2896
|
+
return null;
|
|
2897
|
+
}
|
|
2898
|
+
/**
|
|
2899
|
+
* `gcli hermes status`:当前 model 段 + .env key 存在性 + 上次切换记录。
|
|
2900
|
+
* 无切换历史时优雅显示(exit 0)。config 不可读/不可解析 → exit 1。
|
|
2901
|
+
*/
|
|
2902
|
+
async function hermesStatus(deps) {
|
|
2903
|
+
const configText = await deps.readTextFile(HERMES_CONFIG_PATH);
|
|
2904
|
+
if (configText === undefined) {
|
|
2905
|
+
return hermesFail(1, "gcli: hermes: 无法读取 ~/.hermes/config.yaml");
|
|
2906
|
+
}
|
|
2907
|
+
const info = parseHermesConfig(configText);
|
|
2908
|
+
if ("error" in info) {
|
|
2909
|
+
return hermesFail(1, `gcli: hermes: ${info.error}`);
|
|
2910
|
+
}
|
|
2911
|
+
const provider = info.info.model.provider ?? "(unset)";
|
|
2912
|
+
const model = info.info.model.default ?? "(unset)";
|
|
2913
|
+
const baseUrl = info.info.model.base_url ?? "(unset)";
|
|
2914
|
+
// .env key 存在性:按当前 provider id 反查 registry/seed 的 keyEnv
|
|
2915
|
+
const regText = await deps.readTextFile(HERMES_PROVIDERS_REGISTRY_PATH);
|
|
2916
|
+
const registry = regText === undefined ? {} : parseHermesRegistry(regText);
|
|
2917
|
+
const known = [
|
|
2918
|
+
...Object.values(registry),
|
|
2919
|
+
...Object.values(HERMES_PROVIDER_SEEDS),
|
|
2920
|
+
];
|
|
2921
|
+
const entry = known.find((e) => e.id === provider);
|
|
2922
|
+
let envLine = "env_key: unknown(provider 不在 registry/seed 中)";
|
|
2923
|
+
if (entry !== undefined) {
|
|
2924
|
+
const envText = await deps.readTextFile(HERMES_ENV_PATH);
|
|
2925
|
+
const present = envText !== undefined && envValueOf(envText, entry.keyEnv) !== null;
|
|
2926
|
+
envLine = `env_key: ${entry.keyEnv} ${present ? "present" : "missing"}`;
|
|
2927
|
+
}
|
|
2928
|
+
let lastLine = "last_switch: none";
|
|
2929
|
+
const stateText = await deps.readTextFile(HERMES_STATE_PATH);
|
|
2930
|
+
if (stateText !== undefined) {
|
|
2931
|
+
const st = parseHermesStateFile(stateText);
|
|
2932
|
+
if ("state" in st) {
|
|
2933
|
+
const s = st.state.lastSwitch;
|
|
2934
|
+
lastLine = `last_switch: ${s.from.id} -> ${s.to.id}(${s.from.model} -> ${s.to.model})@ ${new Date(s.ts).toISOString()}`;
|
|
2935
|
+
}
|
|
2936
|
+
else {
|
|
2937
|
+
lastLine = "last_switch: (state file malformed)";
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
2940
|
+
return {
|
|
2941
|
+
exitCode: 0,
|
|
2942
|
+
stdout: [
|
|
2943
|
+
`provider: ${provider}`,
|
|
2944
|
+
`model: ${model}`,
|
|
2945
|
+
`base_url: ${baseUrl}`,
|
|
2946
|
+
envLine,
|
|
2947
|
+
lastLine,
|
|
2948
|
+
].join("\n"),
|
|
2949
|
+
stderr: "",
|
|
2950
|
+
};
|
|
2951
|
+
}
|
|
2952
|
+
/**
|
|
2953
|
+
* 回滚执行体(rollback 命令与切换失败自动回滚共用):
|
|
2954
|
+
* 快照现状 config → copyFile 整文件恢复 → .env 回写/删行 → cron 逐条回放旧
|
|
2955
|
+
* pin(单条失败 warn 继续)→ 网关重启(stop 失败仅 warn,start 失败=失败)→
|
|
2956
|
+
* ping 验证(可选)。全程不 throw。
|
|
2957
|
+
*/
|
|
2958
|
+
async function performHermesRollback(s, deps, verify, logs) {
|
|
2959
|
+
let ok = true;
|
|
2960
|
+
let snapshotPath;
|
|
2961
|
+
const snap = `${HERMES_CONFIG_PATH}.bak-before-${s.from.id}-${Math.floor(Date.now() / 1000)}`;
|
|
2962
|
+
const snapR = await deps.copyFile(HERMES_CONFIG_PATH, snap);
|
|
2963
|
+
if (snapR.ok) {
|
|
2964
|
+
snapshotPath = snap;
|
|
2965
|
+
}
|
|
2966
|
+
else {
|
|
2967
|
+
logs.push(`gcli: hermes: rollback: warn: 现状快照失败(toggle 将不可用): ${snapR.error}`);
|
|
2968
|
+
}
|
|
2969
|
+
const envText = (await deps.readTextFile(HERMES_ENV_PATH)) ?? "";
|
|
2970
|
+
const currentEnvValue = envValueOf(envText, s.env.key);
|
|
2971
|
+
const rc = await deps.copyFile(s.configBackup, HERMES_CONFIG_PATH);
|
|
2972
|
+
if (!rc.ok) {
|
|
2973
|
+
logs.push(`gcli: hermes: rollback: 备份恢复失败(${s.configBackup}): ${rc.error}`);
|
|
2974
|
+
return { ok: false, snapshotPath, currentEnvValue };
|
|
2975
|
+
}
|
|
2976
|
+
logs.push("gcli: hermes: rollback: config.yaml 已整文件恢复");
|
|
2977
|
+
const we = await deps.writeTextFileAtomic(HERMES_ENV_PATH, upsertEnvLines(envText, s.env.key, s.env.prevValue), 0o600);
|
|
2978
|
+
if (!we.ok) {
|
|
2979
|
+
logs.push(`gcli: hermes: rollback: .env 回写失败: ${we.error}`);
|
|
2980
|
+
ok = false;
|
|
2981
|
+
}
|
|
2982
|
+
else {
|
|
2983
|
+
logs.push(`gcli: hermes: rollback: .env 已回写 ${s.env.key}`);
|
|
2984
|
+
}
|
|
2985
|
+
for (const c of s.cronRepinned) {
|
|
2986
|
+
const args = ["cron", "edit", c.jobId, "--provider", c.prevProvider];
|
|
2987
|
+
if (c.prevModel !== null)
|
|
2988
|
+
args.push("--model", c.prevModel);
|
|
2989
|
+
const r = await deps.runHermes(args, DEFAULT_TIMEOUT_MS);
|
|
2990
|
+
if (r.exitCode !== 0) {
|
|
2991
|
+
logs.push(`gcli: hermes: rollback: warn: cron edit ${c.jobId} 回放失败(继续): ${(r.stderr || r.stdout).trim().slice(0, 200)}`);
|
|
2992
|
+
}
|
|
2993
|
+
else {
|
|
2994
|
+
logs.push(`gcli: hermes: rollback: cron ${c.jobId} 已回 pin → ${c.prevProvider}`);
|
|
2995
|
+
}
|
|
2996
|
+
}
|
|
2997
|
+
const gst = await deps.runHermes(["gateway", "stop"], DEFAULT_TIMEOUT_MS);
|
|
2998
|
+
if (gst.exitCode !== 0) {
|
|
2999
|
+
logs.push("gcli: hermes: rollback: warn: gateway stop 非零退出(网关可能本就没跑),继续 start");
|
|
3000
|
+
}
|
|
3001
|
+
const gsa = await deps.runHermes(["gateway", "start"], DEFAULT_TIMEOUT_MS);
|
|
3002
|
+
if (gsa.exitCode !== 0) {
|
|
3003
|
+
logs.push(`gcli: hermes: rollback: gateway start 失败: ${(gsa.stderr || gsa.stdout).trim().slice(0, 300)}`);
|
|
3004
|
+
ok = false;
|
|
3005
|
+
}
|
|
3006
|
+
else {
|
|
3007
|
+
logs.push("gcli: hermes: rollback: 网关已重启");
|
|
3008
|
+
}
|
|
3009
|
+
if (verify) {
|
|
3010
|
+
const ping = await deps.runHermes(["-z", "ping"], HERMES_VERIFY_TIMEOUT_MS);
|
|
3011
|
+
if (ping.timedOut === true || ping.exitCode !== 0) {
|
|
3012
|
+
logs.push("gcli: hermes: rollback: 回滚后验证 ping 未通过");
|
|
3013
|
+
ok = false;
|
|
3014
|
+
}
|
|
3015
|
+
else {
|
|
3016
|
+
logs.push("gcli: hermes: rollback: 验证 ping 通过");
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
return { ok, snapshotPath, currentEnvValue };
|
|
3020
|
+
}
|
|
3021
|
+
/** `gcli hermes rollback`:无 state → exit 1;畸形 → exit 1(不猜)。 */
|
|
3022
|
+
async function hermesRollback(parsed, deps) {
|
|
3023
|
+
const stateText = await deps.readTextFile(HERMES_STATE_PATH);
|
|
3024
|
+
if (stateText === undefined) {
|
|
3025
|
+
return hermesFail(1, "gcli: hermes: 无可回滚的切换历史 / no switch history to roll back");
|
|
3026
|
+
}
|
|
3027
|
+
const st = parseHermesStateFile(stateText);
|
|
3028
|
+
if ("error" in st) {
|
|
3029
|
+
return hermesFail(1, `gcli: hermes: state 文件畸形,拒绝回滚(不猜): ${st.error}`);
|
|
3030
|
+
}
|
|
3031
|
+
const s = st.state.lastSwitch;
|
|
3032
|
+
const logs = [
|
|
3033
|
+
`gcli: hermes: 回滚 ${s.to.id}(${s.to.model})-> ${s.from.id}(${s.from.model})…`,
|
|
3034
|
+
];
|
|
3035
|
+
const r = await performHermesRollback(s, deps, parsed.verify, logs);
|
|
3036
|
+
if (!r.ok || r.snapshotPath === undefined) {
|
|
3037
|
+
logs.push("gcli: hermes: 回滚未完全成功(详见上方日志)");
|
|
3038
|
+
return { exitCode: 1, stdout: "", stderr: logs.join("\n") };
|
|
3039
|
+
}
|
|
3040
|
+
// toggle:from/to 互换写回;configBackup 指向本次快照(= 旧 to 的配置)。
|
|
3041
|
+
const swapped = {
|
|
3042
|
+
lastSwitch: {
|
|
3043
|
+
ts: Date.now(),
|
|
3044
|
+
ccName: s.ccName,
|
|
3045
|
+
to: s.from,
|
|
3046
|
+
from: s.to,
|
|
3047
|
+
configBackup: r.snapshotPath,
|
|
3048
|
+
env: { key: s.env.key, prevValue: r.currentEnvValue },
|
|
3049
|
+
cronRepinned: s.cronRepinned,
|
|
3050
|
+
},
|
|
3051
|
+
};
|
|
3052
|
+
const ws = await deps.writeTextFileAtomic(HERMES_STATE_PATH, serializeHermesStateFile(swapped), 0o600);
|
|
3053
|
+
if (!ws.ok) {
|
|
3054
|
+
logs.push(`gcli: hermes: warn: state 写回失败(toggle 将不可用): ${ws.error}`);
|
|
3055
|
+
}
|
|
3056
|
+
logs.push(`gcli: hermes: 回滚完成,当前 ${s.from.id} / ${s.from.model}`);
|
|
3057
|
+
return { exitCode: 0, stdout: "", stderr: logs.join("\n") };
|
|
3058
|
+
}
|
|
3059
|
+
/**
|
|
3060
|
+
* `gcli hermes <provider>` 一键切换(设计文档流程 0-9)。所有失败 resolve
|
|
3061
|
+
* RunOutcome,禁止 throw。进度/诊断全走 stderr;stdout 仅 dry-run 计划。
|
|
3062
|
+
* token 红线:任何输出只打印 key 名,token 值永不出现。
|
|
3063
|
+
*/
|
|
3064
|
+
async function hermesSwitch(parsed, deps) {
|
|
3065
|
+
const logs = [];
|
|
3066
|
+
// 0. 目标 provider 名:位置参数,或 TTY picker;非 TTY 缺参 → exit 2 零交互
|
|
3067
|
+
let ccQuery = parsed.provider;
|
|
3068
|
+
if (ccQuery === undefined) {
|
|
3069
|
+
if (!deps.isInteractive()) {
|
|
3070
|
+
return hermesFail(2, `gcli: hermes 需要 provider 位置参数(或保留字 status/rollback);非 TTY 下零交互。\nusage: ${HERMES_USAGE}`);
|
|
3071
|
+
}
|
|
3072
|
+
const lookup0 = await deps.readCcSwitchProvider();
|
|
3073
|
+
if (!lookup0.ok) {
|
|
3074
|
+
return hermesFail(1, `gcli: hermes: ${lookup0.message}`);
|
|
3075
|
+
}
|
|
3076
|
+
if (lookup0.providers.length === 0) {
|
|
3077
|
+
return hermesFail(1, "gcli: hermes: no cc-switch providers configured");
|
|
3078
|
+
}
|
|
3079
|
+
const picked = await deps.pickProvider(lookup0.providers.map((p) => ({ name: p.name })), 0);
|
|
3080
|
+
if (picked.kind === "skip") {
|
|
3081
|
+
return {
|
|
3082
|
+
exitCode: 0,
|
|
3083
|
+
stdout: "",
|
|
3084
|
+
stderr: "gcli: hermes: 已取消,未切换",
|
|
3085
|
+
};
|
|
3086
|
+
}
|
|
3087
|
+
ccQuery = picked.entry.name;
|
|
3088
|
+
}
|
|
3089
|
+
// 1. cc-switch 取 provider(复用三层匹配)
|
|
3090
|
+
const lookup = await deps.readCcSwitchProvider();
|
|
3091
|
+
if (!lookup.ok) {
|
|
3092
|
+
return hermesFail(1, `gcli: hermes: ${lookup.message}`);
|
|
3093
|
+
}
|
|
3094
|
+
const match = matchProviderName(ccQuery, lookup.providers.map((p) => p.name));
|
|
3095
|
+
if ("none" in match) {
|
|
3096
|
+
return hermesFail(2, `gcli: hermes: provider not found / 未知 provider: ${ccQuery}`);
|
|
3097
|
+
}
|
|
3098
|
+
if ("ambiguous" in match) {
|
|
3099
|
+
const candidates = match.ambiguous
|
|
3100
|
+
.map((n) => `"${n}"`)
|
|
3101
|
+
.sort()
|
|
3102
|
+
.join(", ");
|
|
3103
|
+
return hermesFail(2, `gcli: hermes: ambiguous provider: ${candidates}`);
|
|
3104
|
+
}
|
|
3105
|
+
const ccName = match.matched;
|
|
3106
|
+
const target = lookup.providers.find((p) => p.name === ccName);
|
|
3107
|
+
if (target === undefined) {
|
|
3108
|
+
return hermesFail(2, `gcli: hermes: provider not found: ${ccQuery}`);
|
|
3109
|
+
}
|
|
3110
|
+
const cfgJson = typeof target.settingsConfig === "string"
|
|
3111
|
+
? target.settingsConfig
|
|
3112
|
+
: JSON.stringify(target.settingsConfig);
|
|
3113
|
+
const envResult = extractProviderEnv(cfgJson);
|
|
3114
|
+
if ("error" in envResult) {
|
|
3115
|
+
return hermesFail(1, `gcli: hermes: ${envResult.error}`);
|
|
3116
|
+
}
|
|
3117
|
+
const penv = envResult.env;
|
|
3118
|
+
const baseUrl = penv.ANTHROPIC_BASE_URL;
|
|
3119
|
+
const token = penv.ANTHROPIC_AUTH_TOKEN ?? penv.ANTHROPIC_API_KEY;
|
|
3120
|
+
if (baseUrl === undefined || baseUrl === "") {
|
|
3121
|
+
return hermesFail(1, "gcli: hermes: provider env is missing ANTHROPIC_BASE_URL");
|
|
3122
|
+
}
|
|
3123
|
+
if (token === undefined || token === "") {
|
|
3124
|
+
return hermesFail(1, "gcli: hermes: provider env is missing ANTHROPIC_AUTH_TOKEN(无 token)");
|
|
3125
|
+
}
|
|
3126
|
+
// 换行消毒:cc-switch 值将写入行级文件(.env/config.yaml),裸换行会破坏行结构
|
|
3127
|
+
for (const [label, value] of [
|
|
3128
|
+
["name", ccName],
|
|
3129
|
+
["ANTHROPIC_BASE_URL", baseUrl],
|
|
3130
|
+
["token", token],
|
|
3131
|
+
]) {
|
|
3132
|
+
if (containsLineBreak(value)) {
|
|
3133
|
+
return hermesFail(1, `gcli: hermes: cc-switch provider ${label} 含裸换行,拒绝写入(宁报错不猜)`);
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
3136
|
+
// 2. 读现状(= rollback 的 from)并验证结构
|
|
3137
|
+
const configText = await deps.readTextFile(HERMES_CONFIG_PATH);
|
|
3138
|
+
if (configText === undefined) {
|
|
3139
|
+
return hermesFail(1, "gcli: hermes: 无法读取 ~/.hermes/config.yaml");
|
|
3140
|
+
}
|
|
3141
|
+
const info = parseHermesConfig(configText);
|
|
3142
|
+
if ("error" in info) {
|
|
3143
|
+
return hermesFail(1, `gcli: hermes: ${info.error} —— 无法安全解析 config.yaml,拒绝写入`);
|
|
3144
|
+
}
|
|
3145
|
+
const fromId = info.info.model.provider ?? "";
|
|
3146
|
+
const fromModel = info.info.model.default ?? "";
|
|
3147
|
+
if (fromId === "" || fromModel === "") {
|
|
3148
|
+
return hermesFail(1, "gcli: hermes: config.yaml model 段缺 provider/default,拒绝写入");
|
|
3149
|
+
}
|
|
3150
|
+
const from = {
|
|
3151
|
+
id: fromId,
|
|
3152
|
+
model: fromModel,
|
|
3153
|
+
base_url: info.info.model.base_url ?? "",
|
|
3154
|
+
};
|
|
3155
|
+
// 3. registry 解析 hermes 字段(文件 → seed → 推导+写回)
|
|
3156
|
+
const regText = await deps.readTextFile(HERMES_PROVIDERS_REGISTRY_PATH);
|
|
3157
|
+
const registry = regText === undefined ? {} : parseHermesRegistry(regText);
|
|
3158
|
+
let entry = registry[ccName] ?? HERMES_PROVIDER_SEEDS[ccName];
|
|
3159
|
+
let registryDirty = false;
|
|
3160
|
+
if (entry === undefined) {
|
|
3161
|
+
const id = deriveHermesId(ccName);
|
|
3162
|
+
if (id === "") {
|
|
3163
|
+
return hermesFail(1, `gcli: hermes: 无法从 "${ccName}" 推导 provider id`);
|
|
3164
|
+
}
|
|
3165
|
+
if (info.info.providerIds.includes(id)) {
|
|
3166
|
+
return hermesFail(1, `gcli: hermes: 推导 id "${id}" 与 config.yaml 已有 providers 条目冲突,拒绝覆盖;请在 ${HERMES_PROVIDERS_REGISTRY_PATH} 手工登记`);
|
|
3167
|
+
}
|
|
3168
|
+
entry = { id, keyEnv: deriveKeyEnv(id) };
|
|
3169
|
+
registry[ccName] = entry;
|
|
3170
|
+
registryDirty = true;
|
|
3171
|
+
}
|
|
3172
|
+
// 4. 模型名推导:--model > registry modelOverride > cc-switch(剥 [1M] 后缀)
|
|
3173
|
+
const ccModel = penv.ANTHROPIC_MODEL !== undefined
|
|
3174
|
+
? stripContextSuffix(penv.ANTHROPIC_MODEL)
|
|
3175
|
+
: undefined;
|
|
3176
|
+
if (parsed.model !== undefined) {
|
|
3177
|
+
// --model 持久化进 registry 的 modelOverride
|
|
3178
|
+
entry = { ...entry, modelOverride: parsed.model };
|
|
3179
|
+
registry[ccName] = entry;
|
|
3180
|
+
registryDirty = true;
|
|
3181
|
+
}
|
|
3182
|
+
const model = parsed.model ?? entry.modelOverride ?? ccModel;
|
|
3183
|
+
if (model === undefined || model === "") {
|
|
3184
|
+
return hermesFail(1, "gcli: hermes: no model resolved(cc-switch 无 ANTHROPIC_MODEL,可用 --model 指定)");
|
|
3185
|
+
}
|
|
3186
|
+
if (containsLineBreak(model)) {
|
|
3187
|
+
return hermesFail(1, "gcli: hermes: 模型名含裸换行,拒绝写入(宁报错不猜)");
|
|
3188
|
+
}
|
|
3189
|
+
const to = { id: entry.id, model, base_url: baseUrl };
|
|
3190
|
+
// 5. 构造编辑(纯函数;结构异常 → 报错零写盘)
|
|
3191
|
+
const edit = {
|
|
3192
|
+
model: { default: model, provider: to.id, base_url: baseUrl },
|
|
3193
|
+
provider: {
|
|
3194
|
+
id: to.id,
|
|
3195
|
+
name: ccName,
|
|
3196
|
+
base_url: baseUrl,
|
|
3197
|
+
transport: HERMES_TRANSPORT,
|
|
3198
|
+
key_env: entry.keyEnv,
|
|
3199
|
+
default_model: model,
|
|
3200
|
+
},
|
|
3201
|
+
};
|
|
3202
|
+
const edited = editHermesConfig(configText, edit);
|
|
3203
|
+
if ("error" in edited) {
|
|
3204
|
+
return hermesFail(1, `gcli: hermes: ${edited.error} —— 无法安全识别 config.yaml 结构,拒绝写入 / refusing to write`);
|
|
3205
|
+
}
|
|
3206
|
+
// cron 重 pin 计划(读 jobs.json;读不到/畸形 → warn 跳过,不阻塞)
|
|
3207
|
+
let cronPlan = [];
|
|
3208
|
+
let cronWarn;
|
|
3209
|
+
const jobsText = await deps.readTextFile(HERMES_CRON_JOBS_PATH);
|
|
3210
|
+
if (jobsText === undefined) {
|
|
3211
|
+
cronWarn = "无法读取 cron jobs.json,跳过重 pin";
|
|
3212
|
+
}
|
|
3213
|
+
else {
|
|
3214
|
+
try {
|
|
3215
|
+
cronPlan = buildCronRepinPlan(JSON.parse(jobsText), from.id);
|
|
3216
|
+
}
|
|
3217
|
+
catch {
|
|
3218
|
+
cronWarn = "cron jobs.json 不是合法 JSON,跳过重 pin";
|
|
3219
|
+
}
|
|
3220
|
+
}
|
|
3221
|
+
// 6. dry-run:输出计划,零写入零 spawn(备份路径仅为预告)
|
|
3222
|
+
const backupPath = `${HERMES_CONFIG_PATH}.bak-before-${to.id}-${Math.floor(Date.now() / 1000)}`;
|
|
3223
|
+
if (parsed.dryRun) {
|
|
3224
|
+
const planLines = [
|
|
3225
|
+
"gcli hermes 切换计划(dry-run,未做任何写入):",
|
|
3226
|
+
` provider: ${to.id}(cc-switch: ${ccName})`,
|
|
3227
|
+
` model: ${model}`,
|
|
3228
|
+
` base_url: ${baseUrl}`,
|
|
3229
|
+
` backup: ${backupPath}`,
|
|
3230
|
+
` config.yaml: model.default=${model} model.provider=${to.id} model.base_url=${baseUrl}; providers upsert ${to.id}`,
|
|
3231
|
+
` .env: upsert ${entry.keyEnv}=<redacted>`,
|
|
3232
|
+
` cron: repin ${cronPlan.length} job(s) ${from.id} -> ${to.id}${cronPlan.length > 0 ? `(${cronPlan.map((c) => c.jobId).join(", ")})` : ""}`,
|
|
3233
|
+
" gateway: hermes gateway stop && hermes gateway start",
|
|
3234
|
+
` verify: ${parsed.verify ? `hermes -z ping(${HERMES_VERIFY_TIMEOUT_MS}ms 超时)+ state.db best-effort 比对` : "已跳过(--no-verify)"}`,
|
|
3235
|
+
];
|
|
3236
|
+
if (cronWarn !== undefined)
|
|
3237
|
+
planLines.push(` warn: ${cronWarn}`);
|
|
3238
|
+
return { exitCode: 0, stdout: planLines.join("\n"), stderr: "" };
|
|
3239
|
+
}
|
|
3240
|
+
// 7. registry 写回(推导/modelOverride 持久化;best-effort)
|
|
3241
|
+
if (registryDirty) {
|
|
3242
|
+
const w = await deps.writeTextFileAtomic(HERMES_PROVIDERS_REGISTRY_PATH, serializeHermesRegistry(registry));
|
|
3243
|
+
if (!w.ok) {
|
|
3244
|
+
logs.push(`gcli: hermes: warn: registry 写回失败(不影响本次切换): ${w.error}`);
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3247
|
+
// 8. 备份先行 → 原子写 config
|
|
3248
|
+
const bak = await deps.copyFile(HERMES_CONFIG_PATH, backupPath);
|
|
3249
|
+
if (!bak.ok) {
|
|
3250
|
+
return hermesFail(1, `gcli: hermes: 备份失败,中止切换(未写入任何内容): ${bak.error}`);
|
|
3251
|
+
}
|
|
3252
|
+
logs.push(`gcli: hermes: 已备份 config.yaml → ${backupPath}`);
|
|
3253
|
+
const wc = await deps.writeTextFileAtomic(HERMES_CONFIG_PATH, edited.text);
|
|
3254
|
+
if (!wc.ok) {
|
|
3255
|
+
return hermesFail(1, `gcli: hermes: config.yaml 写入失败(备份在 ${backupPath}): ${wc.error}`);
|
|
3256
|
+
}
|
|
3257
|
+
logs.push(`gcli: hermes: config.yaml 已切换到 ${to.id} / ${model}`);
|
|
3258
|
+
// 9. .env upsert(保持 0o600;失败则从备份恢复 config 避免半切换态)
|
|
3259
|
+
const envText = (await deps.readTextFile(HERMES_ENV_PATH)) ?? "";
|
|
3260
|
+
const prevValue = envValueOf(envText, entry.keyEnv);
|
|
3261
|
+
const we = await deps.writeTextFileAtomic(HERMES_ENV_PATH, upsertEnvLines(envText, entry.keyEnv, token), 0o600);
|
|
3262
|
+
if (!we.ok) {
|
|
3263
|
+
await deps.copyFile(backupPath, HERMES_CONFIG_PATH);
|
|
3264
|
+
return hermesFail(1, `gcli: hermes: .env 写入失败,已从备份恢复 config.yaml: ${we.error}`);
|
|
3265
|
+
}
|
|
3266
|
+
logs.push(`gcli: hermes: .env 已 upsert ${entry.keyEnv}=<redacted>`);
|
|
3267
|
+
// 10. cron 重 pin(单条失败 warn 继续,不触发回滚)
|
|
3268
|
+
const cronRepinned = [];
|
|
3269
|
+
if (cronWarn !== undefined) {
|
|
3270
|
+
logs.push(`gcli: hermes: warn: ${cronWarn}`);
|
|
3271
|
+
}
|
|
3272
|
+
for (const c of cronPlan) {
|
|
3273
|
+
cronRepinned.push(c);
|
|
3274
|
+
const r = await deps.runHermes(["cron", "edit", c.jobId, "--provider", to.id, "--model", model], DEFAULT_TIMEOUT_MS);
|
|
3275
|
+
if (r.exitCode !== 0) {
|
|
3276
|
+
logs.push(`gcli: hermes: warn: cron edit ${c.jobId} 失败(继续,不触发回滚): ${(r.stderr || r.stdout).trim().slice(0, 200)}`);
|
|
3277
|
+
}
|
|
3278
|
+
else {
|
|
3279
|
+
logs.push(`gcli: hermes: cron ${c.jobId} 重 pin → ${to.id}`);
|
|
3280
|
+
}
|
|
3281
|
+
}
|
|
3282
|
+
// 11. 网关重启(stop 失败仅 warn;start 失败 = 网关失败)
|
|
3283
|
+
let gatewayFailed;
|
|
3284
|
+
const gst = await deps.runHermes(["gateway", "stop"], DEFAULT_TIMEOUT_MS);
|
|
3285
|
+
if (gst.exitCode !== 0) {
|
|
3286
|
+
logs.push("gcli: hermes: warn: gateway stop 非零退出(网关可能本就没跑),继续 start");
|
|
3287
|
+
}
|
|
3288
|
+
const gsa = await deps.runHermes(["gateway", "start"], DEFAULT_TIMEOUT_MS);
|
|
3289
|
+
if (gsa.exitCode !== 0) {
|
|
3290
|
+
gatewayFailed = `hermes gateway start 失败: ${(gsa.stderr || gsa.stdout).trim().slice(0, 300)}`;
|
|
3291
|
+
logs.push(`gcli: hermes: ${gatewayFailed}`);
|
|
3292
|
+
}
|
|
3293
|
+
else {
|
|
3294
|
+
logs.push("gcli: hermes: 网关已重启");
|
|
3295
|
+
}
|
|
3296
|
+
// 12. 验证:(a) ping 硬门槛;(b) state.db 比对 best-effort(不一致仅 warn)
|
|
3297
|
+
let verifyFailed;
|
|
3298
|
+
if (parsed.verify) {
|
|
3299
|
+
const ping = await deps.runHermes(["-z", "ping"], HERMES_VERIFY_TIMEOUT_MS);
|
|
3300
|
+
if (ping.timedOut === true) {
|
|
3301
|
+
verifyFailed = `hermes -z ping 超时(${HERMES_VERIFY_TIMEOUT_MS}ms)`;
|
|
3302
|
+
}
|
|
3303
|
+
else if (ping.exitCode !== 0) {
|
|
3304
|
+
verifyFailed = `hermes -z ping 退出码 ${ping.exitCode}: ${(ping.stderr || ping.stdout).trim().slice(0, 300)}`;
|
|
3305
|
+
}
|
|
3306
|
+
else {
|
|
3307
|
+
logs.push("gcli: hermes: 验证 ping 通过");
|
|
3308
|
+
}
|
|
3309
|
+
if (verifyFailed === undefined) {
|
|
3310
|
+
const last = await deps.queryLastSessionModel();
|
|
3311
|
+
if (last === undefined) {
|
|
3312
|
+
logs.push("gcli: hermes: warn: state.db 比对不可用(best-effort,跳过)");
|
|
3313
|
+
}
|
|
3314
|
+
else if (last.provider !== to.id) {
|
|
3315
|
+
logs.push(`gcli: hermes: warn: state.db 最新 session 的 billing_provider=${last.provider}(期望 ${to.id})——请人工复核`);
|
|
3316
|
+
}
|
|
3317
|
+
}
|
|
3318
|
+
}
|
|
3319
|
+
else {
|
|
3320
|
+
logs.push("gcli: hermes: 已跳过验证(--no-verify)");
|
|
3321
|
+
}
|
|
3322
|
+
const stateRecord = {
|
|
3323
|
+
lastSwitch: {
|
|
3324
|
+
ts: Date.now(),
|
|
3325
|
+
ccName,
|
|
3326
|
+
to,
|
|
3327
|
+
from,
|
|
3328
|
+
configBackup: backupPath,
|
|
3329
|
+
env: { key: entry.keyEnv, prevValue },
|
|
3330
|
+
cronRepinned,
|
|
3331
|
+
},
|
|
3332
|
+
};
|
|
3333
|
+
// 13. 失败 → 默认自动回滚(--keep-on-fail 抑制,但仍写 state 以便手动 rollback)
|
|
3334
|
+
const failedReason = gatewayFailed ?? verifyFailed;
|
|
3335
|
+
if (failedReason !== undefined) {
|
|
3336
|
+
logs.push(`gcli: hermes: 切换验证失败 / verification failed: ${failedReason}`);
|
|
3337
|
+
const ws = await deps.writeTextFileAtomic(HERMES_STATE_PATH, serializeHermesStateFile(stateRecord), 0o600);
|
|
3338
|
+
if (!ws.ok) {
|
|
3339
|
+
logs.push(`gcli: hermes: warn: state 文件写入失败: ${ws.error}`);
|
|
3340
|
+
}
|
|
3341
|
+
if (parsed.keepOnFail) {
|
|
3342
|
+
logs.push("gcli: hermes: --keep-on-fail 生效,保留现场(可 gcli hermes rollback 手动回滚)");
|
|
3343
|
+
return { exitCode: 1, stdout: "", stderr: logs.join("\n") };
|
|
3344
|
+
}
|
|
3345
|
+
logs.push("gcli: hermes: 开始自动回滚…");
|
|
3346
|
+
const rb = await performHermesRollback(stateRecord.lastSwitch, deps, parsed.verify, logs);
|
|
3347
|
+
logs.push(rb.ok
|
|
3348
|
+
? "gcli: hermes: 验证失败,已自动回滚 / rolled back"
|
|
3349
|
+
: "gcli: hermes: 自动回滚未完全成功,请检查现场(备份文件仍在)");
|
|
3350
|
+
return { exitCode: 1, stdout: "", stderr: logs.join("\n") };
|
|
3351
|
+
}
|
|
3352
|
+
// 14. 成功 → 写 state(best-effort;失败仅 warn,切换本身已成功)
|
|
3353
|
+
const ws = await deps.writeTextFileAtomic(HERMES_STATE_PATH, serializeHermesStateFile(stateRecord), 0o600);
|
|
3354
|
+
if (!ws.ok) {
|
|
3355
|
+
logs.push(`gcli: hermes: warn: state 文件写入失败(rollback 将不可用): ${ws.error}`);
|
|
3356
|
+
}
|
|
3357
|
+
logs.push(`gcli: hermes: 切换完成 ${from.id} -> ${to.id}(${model})`);
|
|
3358
|
+
return { exitCode: 0, stdout: "", stderr: logs.join("\n") };
|
|
3359
|
+
}
|
|
3360
|
+
/** hermes 子命令入口:status / rollback / switch 三分支。 */
|
|
3361
|
+
async function runHermesBackend(parsed, deps) {
|
|
3362
|
+
if (parsed.provider !== undefined &&
|
|
3363
|
+
HERMES_RESERVED_WORDS.has(parsed.provider)) {
|
|
3364
|
+
return parsed.provider === "status"
|
|
3365
|
+
? hermesStatus(deps)
|
|
3366
|
+
: hermesRollback(parsed, deps);
|
|
3367
|
+
}
|
|
3368
|
+
return hermesSwitch(parsed, deps);
|
|
3369
|
+
}
|
|
1954
3370
|
/**
|
|
1955
3371
|
* Route argv to the agy / claude / api backend via injectable deps (C1/C2).
|
|
1956
3372
|
* Returns a {exitCode, stdout, stderr} outcome; main() owns process.exit.
|
|
@@ -1970,6 +3386,16 @@ export async function run(argv, deps) {
|
|
|
1970
3386
|
}
|
|
1971
3387
|
return runApiBackend(parsed, deps);
|
|
1972
3388
|
}
|
|
3389
|
+
if (sub.subcommand === "hermes") {
|
|
3390
|
+
const parsed = parseHermesArgs(sub.rest);
|
|
3391
|
+
if ("error" in parsed) {
|
|
3392
|
+
return { exitCode: 2, stdout: "", stderr: `gcli: ${parsed.error}` };
|
|
3393
|
+
}
|
|
3394
|
+
if (parsed.help) {
|
|
3395
|
+
return { exitCode: 0, stdout: HELP, stderr: "" };
|
|
3396
|
+
}
|
|
3397
|
+
return runHermesBackend(parsed, deps);
|
|
3398
|
+
}
|
|
1973
3399
|
const parsed = parseCliArgs(sub.rest);
|
|
1974
3400
|
if (!isOk(parsed)) {
|
|
1975
3401
|
return { exitCode: 2, stdout: "", stderr: `gcli: ${parsed.error}` };
|
|
@@ -1996,6 +3422,11 @@ async function main() {
|
|
|
1996
3422
|
fetchProviderQuotas: (items) => fetchProviderQuotasHttp(items),
|
|
1997
3423
|
readLastProvider: () => readLastProviderFromDisk(),
|
|
1998
3424
|
writeLastProvider: (name) => writeLastProviderToDisk(name),
|
|
3425
|
+
runHermes: (args, timeoutMs) => runHermesProcess(args, timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
3426
|
+
readTextFile: (path) => readTextFileFromDisk(path),
|
|
3427
|
+
writeTextFileAtomic: (path, text, mode) => writeTextFileAtomicToDisk(path, text, mode),
|
|
3428
|
+
copyFile: (src, dest) => copyFileOnDisk(src, dest),
|
|
3429
|
+
queryLastSessionModel: () => queryLastSessionModelFromDb(),
|
|
1999
3430
|
};
|
|
2000
3431
|
const r = await run(process.argv.slice(2), deps);
|
|
2001
3432
|
if (r.stdout)
|