openclaw-exhausted-models 1.0.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/README.md ADDED
@@ -0,0 +1,92 @@
1
+ ## openclaw-exhausted-models 插件
2
+
3
+ **openclaw-exhausted-models** 是一个用于 OpenClaw 的模型熔断 / 备用模型插件,用来在对话过程中自动记住「因为额度耗尽、错误频发等原因不可用」的模型列表,并在之后的请求中优先改用备用模型;同时可以按每天固定时间点自动清空这份「超限模型列表」,如果你使用魔搭等免费但有限额的模型时可以使用这个插件。
4
+
5
+ ### 工作原理
6
+
7
+ - **模型顺序来源**
8
+ - 主模型:`openclaw.json` 中 `agents.defaults.model.primary`
9
+ - 备用模型列表:`openclaw.json` 中 `agents.defaults.model.fallbacks`(按顺序依次尝试)
10
+ - 插件内部会把它们组成一个有序列表:
11
+ 1. 主模型(primary)
12
+ 2. 所有备用模型(fallbacks)
13
+
14
+ - **选择模型**
15
+ - 每次触发 `before_model_resolve` 时:
16
+ - 从上述有序列表中,找到第一个 **不在超限集合** 里的模型;
17
+ - 若找到,则将其拆分成 `provider/model`,返回给 OpenClaw 作为本次会话使用的模型;
18
+ - 若所有模型都在超限集合中,则抛出错误,提示所有配置模型均不可用。
19
+
20
+ - **标记超限模型**
21
+ - 每次模型被选中时,会按 `sessionId` 记住本次使用的 `provider/model`;
22
+ - 当触发 `agent_end` 事件且 `success === false`(本次对话失败)时:
23
+ - 将本会话所用的模型加入「超限集合」,之后的请求将自动跳过该模型,优先尝试下一个备用模型。
24
+
25
+ - **按日重置超限集合**
26
+ - 插件会根据配置的时区和「每日重置时间」,判断是否需要清空超限集合;
27
+ - 在到达指定时间之后的任意一次请求中:
28
+ - 将清空所有已标记为超限的模型;
29
+ - 标记当天已经完成过清空,避免同一天重复清空。
30
+
31
+ ### 安装与启用
32
+
33
+ 1. 安装 npm 包:
34
+ - `npm install openclaw-exhausted-models`
35
+ 2. 在你的 OpenClaw 配置中(例如 `~/.openclaw/openclaw.json`),添加扩展:
36
+ ```json
37
+ {
38
+ "openclaw": {
39
+ "extensions": ["openclaw-exhausted-models"]
40
+ }
41
+ }
42
+ ```
43
+
44
+ ### 在 openclaw.json 中的配置
45
+
46
+ 在你的 `~/.openclaw/openclaw.json`(或工作区 `.openclaw/openclaw.json`)中,至少需要配置默认模型和插件条目,大致结构如下:
47
+
48
+ ```json
49
+ {
50
+ "agents": {
51
+ "defaults": {
52
+ "model": {
53
+ "primary": "anthropic/claude-sonnet-4-20250514",
54
+ "fallbacks": [
55
+ "openai/gpt-4.1-mini",
56
+ "openai/gpt-4.1",
57
+ "deepseek/deepseek-chat"
58
+ ]
59
+ }
60
+ }
61
+ },
62
+ "plugins": {
63
+ "entries": {
64
+ "openclaw-exhausted-models": {
65
+ "config": {
66
+ "resetHour": 8,
67
+ "resetMinute": 0,
68
+ "timezone": "Asia/Shanghai"
69
+ }
70
+ }
71
+ }
72
+ }
73
+ }
74
+ ```
75
+
76
+ #### 配置项说明(`plugins.entries["openclaw-exhausted-models"].config`)
77
+
78
+ - **`resetHour`**(可选,默认 `8`)
79
+ 每天清空「超限模型列表」的小时数(24 小时制)。
80
+
81
+ - **`resetMinute`**(可选,默认 `0`)
82
+ 每天清空「超限模型列表」的分钟数。
83
+
84
+ - **`timezone`**(可选,默认自动探测本机时区,失败时为 `UTC`)
85
+ 用于计算每日重置时刻的时区字符串,例如:`"Asia/Shanghai"`、`"America/Los_Angeles"`。
86
+
87
+ ### 使用建议
88
+
89
+ - **合理设置模型顺序**:把最常用、额度相对充足的模型放在 `primary`,把更廉价或更保守的模型放在 `fallbacks` 中的后面。
90
+ - **结合额度监控/告警**:当插件因为所有模型都被标记为超限而报错时,通常意味着所有模型的配额都已用尽,需要手动扩容或调整配置。
91
+ - **多工作区支持**:插件会尝试根据当前 `workspaceDir` 寻找对应的 `.openclaw` 目录,这样不同项目可以有各自独立的模型和插件配置。
92
+
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Exhausted Models Plugin
3
+ * 超限模型列表:当模型因额度/错误不可用时加入内存列表,次日指定时间清空。
4
+ *
5
+ * 模型顺序取自 openclaw.json 的:
6
+ * agents.defaults.model.primary → 主模型
7
+ * agents.defaults.model.fallbacks → 备用模型列表(按顺序)
8
+ *
9
+ * 标识格式:"provider/model"(如 "anthropic/claude-sonnet-4-20250514")
10
+ */
11
+ export default function register(api: {
12
+ on: (name: string, handler: (...args: unknown[]) => unknown, opts?: {
13
+ priority?: number;
14
+ }) => void;
15
+ }): void;
package/dist/index.js ADDED
@@ -0,0 +1,142 @@
1
+ "use strict";
2
+ /**
3
+ * Exhausted Models Plugin
4
+ * 超限模型列表:当模型因额度/错误不可用时加入内存列表,次日指定时间清空。
5
+ *
6
+ * 模型顺序取自 openclaw.json 的:
7
+ * agents.defaults.model.primary → 主模型
8
+ * agents.defaults.model.fallbacks → 备用模型列表(按顺序)
9
+ *
10
+ * 标识格式:"provider/model"(如 "anthropic/claude-sonnet-4-20250514")
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.default = register;
14
+ const DEFAULT_CONFIG = {
15
+ resetHour: 8,
16
+ resetMinute: 0,
17
+ // 若未配置 timezone,将自动使用当前设备时区
18
+ timezone: "",
19
+ };
20
+ let config = { ...DEFAULT_CONFIG };
21
+ let orderedModels = [];
22
+ // 内存中的超限集合
23
+ const exhaustedSet = new Set();
24
+ // 已执行清空的日期("YYYY-MM-DD"),防止同一天重复清空
25
+ let lastResetDate = "";
26
+ // sessionId -> 本次 run 选中的模型(agent_end 时加入超限列表)
27
+ const sessionModelMap = new Map();
28
+ function loadConfig(openclawDir) {
29
+ // @ts-ignore
30
+ const { existsSync, readFileSync } = require("fs");
31
+ // @ts-ignore
32
+ const { join } = require("path");
33
+ const configPath = join(openclawDir, "openclaw.json");
34
+ if (!existsSync(configPath))
35
+ return;
36
+ try {
37
+ const cfg = JSON.parse(readFileSync(configPath, "utf-8"));
38
+ const pluginEntry = cfg?.plugins?.entries?.["openclaw-exhausted-models"];
39
+ if (pluginEntry?.config) {
40
+ config = { ...DEFAULT_CONFIG, ...pluginEntry.config };
41
+ }
42
+ const modelCfg = cfg?.agents?.defaults?.model;
43
+ if (modelCfg?.primary) {
44
+ const list = [modelCfg.primary];
45
+ if (Array.isArray(modelCfg.fallbacks)) {
46
+ for (const f of modelCfg.fallbacks) {
47
+ if (typeof f === "string" && f)
48
+ list.push(f);
49
+ }
50
+ }
51
+ orderedModels = list;
52
+ }
53
+ }
54
+ catch {
55
+ /* ignore */
56
+ }
57
+ }
58
+ function getOpenClawDir(workspaceDir) {
59
+ // @ts-ignore
60
+ const { join } = require("path");
61
+ if (workspaceDir?.includes(".openclaw")) {
62
+ const idx = workspaceDir.indexOf(".openclaw");
63
+ return join(workspaceDir.slice(0, idx), ".openclaw");
64
+ }
65
+ // @ts-ignore
66
+ const home = (typeof process !== "undefined" ? process.env?.HOME : undefined) || "/home/node";
67
+ return join(home, ".openclaw");
68
+ }
69
+ function todayString(tz) {
70
+ const now = new Date(new Date().toLocaleString("en-US", { timeZone: tz }));
71
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
72
+ }
73
+ function getTz() {
74
+ if (config.timezone && typeof config.timezone === "string")
75
+ return config.timezone;
76
+ try {
77
+ const detected = Intl.DateTimeFormat().resolvedOptions().timeZone;
78
+ if (detected)
79
+ return detected;
80
+ }
81
+ catch {
82
+ // ignore
83
+ }
84
+ return "UTC";
85
+ }
86
+ function checkAndReset() {
87
+ const tz = getTz();
88
+ const today = todayString(tz);
89
+ if (lastResetDate === today)
90
+ return;
91
+ const now = new Date(new Date().toLocaleString("en-US", { timeZone: tz }));
92
+ const hour = now.getHours();
93
+ const minute = now.getMinutes();
94
+ const rh = config.resetHour ?? 8;
95
+ const rm = config.resetMinute ?? 0;
96
+ if (hour > rh || (hour === rh && minute >= rm)) {
97
+ exhaustedSet.clear();
98
+ lastResetDate = today;
99
+ }
100
+ }
101
+ function getFirstAvailable() {
102
+ if (!orderedModels.length)
103
+ return null;
104
+ const available = orderedModels.find((m) => !exhaustedSet.has(m));
105
+ return available ?? null;
106
+ }
107
+ function parseModelRef(ref) {
108
+ const slash = ref.indexOf("/");
109
+ if (slash < 1)
110
+ return null;
111
+ return { provider: ref.slice(0, slash), model: ref.slice(slash + 1) };
112
+ }
113
+ function register(api) {
114
+ api.on("before_model_resolve", (event, ctx) => {
115
+ const typedCtx = ctx;
116
+ loadConfig(getOpenClawDir(typedCtx.workspaceDir));
117
+ if (!orderedModels.length)
118
+ return;
119
+ checkAndReset();
120
+ const ref = getFirstAvailable();
121
+ if (!ref) {
122
+ throw new Error("Exhausted Models plugin: all configured models are marked exhausted; please update quotas or wait until the daily reset time.");
123
+ }
124
+ if (typedCtx.sessionId)
125
+ sessionModelMap.set(typedCtx.sessionId, ref);
126
+ const parsed = parseModelRef(ref);
127
+ if (!parsed)
128
+ return;
129
+ return { providerOverride: parsed.provider, modelOverride: parsed.model };
130
+ }, { priority: 100 });
131
+ api.on("agent_end", (event, ctx) => {
132
+ const typedEvent = event;
133
+ const typedCtx = ctx;
134
+ if (typedEvent.success !== false)
135
+ return;
136
+ const ref = typedCtx.sessionId ? sessionModelMap.get(typedCtx.sessionId) : undefined;
137
+ if (ref) {
138
+ exhaustedSet.add(ref);
139
+ sessionModelMap.delete(typedCtx.sessionId);
140
+ }
141
+ }, { priority: 0 });
142
+ }
package/index.ts ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Exhausted Models Plugin
3
+ * 超限模型列表:当模型因额度/错误不可用时加入内存列表,次日指定时间清空。
4
+ *
5
+ * 模型顺序取自 openclaw.json 的:
6
+ * agents.defaults.model.primary → 主模型
7
+ * agents.defaults.model.fallbacks → 备用模型列表(按顺序)
8
+ *
9
+ * 标识格式:"provider/model"(如 "anthropic/claude-sonnet-4-20250514")
10
+ */
11
+
12
+ const DEFAULT_CONFIG = {
13
+ resetHour: 8,
14
+ resetMinute: 0,
15
+ // 若未配置 timezone,将自动使用当前设备时区
16
+ timezone: "",
17
+ };
18
+
19
+ type ResolvedConfig = {
20
+ resetHour: number;
21
+ resetMinute: number;
22
+ timezone: string;
23
+ };
24
+
25
+ // 模型优先级列表,格式 "provider/model"
26
+ type ModelRef = string;
27
+
28
+ let config: ResolvedConfig = { ...DEFAULT_CONFIG };
29
+ let orderedModels: ModelRef[] = [];
30
+
31
+ // 内存中的超限集合
32
+ const exhaustedSet = new Set<ModelRef>();
33
+
34
+ // 已执行清空的日期("YYYY-MM-DD"),防止同一天重复清空
35
+ let lastResetDate = "";
36
+
37
+ // sessionId -> 本次 run 选中的模型(agent_end 时加入超限列表)
38
+ const sessionModelMap = new Map<string, ModelRef>();
39
+
40
+ function loadConfig(openclawDir: string): void {
41
+ // @ts-ignore
42
+ const { existsSync, readFileSync } = require("fs");
43
+ // @ts-ignore
44
+ const { join } = require("path");
45
+ const configPath = join(openclawDir, "openclaw.json");
46
+ if (!existsSync(configPath)) return;
47
+ try {
48
+ const cfg = JSON.parse(readFileSync(configPath, "utf-8"));
49
+
50
+ const pluginEntry = cfg?.plugins?.entries?.["openclaw-exhausted-models"];
51
+ if (pluginEntry?.config) {
52
+ config = { ...DEFAULT_CONFIG, ...(pluginEntry.config as object) };
53
+ }
54
+
55
+ const modelCfg = cfg?.agents?.defaults?.model;
56
+ if (modelCfg?.primary) {
57
+ const list: ModelRef[] = [modelCfg.primary];
58
+ if (Array.isArray(modelCfg.fallbacks)) {
59
+ for (const f of modelCfg.fallbacks) {
60
+ if (typeof f === "string" && f) list.push(f);
61
+ }
62
+ }
63
+ orderedModels = list;
64
+ }
65
+ } catch {
66
+ /* ignore */
67
+ }
68
+ }
69
+
70
+ function getOpenClawDir(workspaceDir?: string): string {
71
+ // @ts-ignore
72
+ const { join } = require("path");
73
+ if (workspaceDir?.includes(".openclaw")) {
74
+ const idx = workspaceDir.indexOf(".openclaw");
75
+ return join(workspaceDir.slice(0, idx), ".openclaw");
76
+ }
77
+ // @ts-ignore
78
+ const home: string = (typeof process !== "undefined" ? process.env?.HOME : undefined) || "/home/node";
79
+ return join(home, ".openclaw");
80
+ }
81
+
82
+ function todayString(tz: string): string {
83
+ const now = new Date(new Date().toLocaleString("en-US", { timeZone: tz }));
84
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
85
+ }
86
+
87
+ function getTz(): string {
88
+ if (config.timezone && typeof config.timezone === "string") return config.timezone;
89
+ try {
90
+ const detected = Intl.DateTimeFormat().resolvedOptions().timeZone;
91
+ if (detected) return detected;
92
+ } catch {
93
+ // ignore
94
+ }
95
+ return "UTC";
96
+ }
97
+
98
+ function checkAndReset(): void {
99
+ const tz = getTz();
100
+ const today = todayString(tz);
101
+ if (lastResetDate === today) return;
102
+ const now = new Date(new Date().toLocaleString("en-US", { timeZone: tz }));
103
+ const hour = now.getHours();
104
+ const minute = now.getMinutes();
105
+ const rh = config.resetHour ?? 8;
106
+ const rm = config.resetMinute ?? 0;
107
+ if (hour > rh || (hour === rh && minute >= rm)) {
108
+ exhaustedSet.clear();
109
+ lastResetDate = today;
110
+ }
111
+ }
112
+
113
+ function getFirstAvailable(): ModelRef | null {
114
+ if (!orderedModels.length) return null;
115
+ const available = orderedModels.find((m) => !exhaustedSet.has(m));
116
+ return available ?? null;
117
+ }
118
+
119
+ function parseModelRef(ref: ModelRef): { provider: string; model: string } | null {
120
+ const slash = ref.indexOf("/");
121
+ if (slash < 1) return null;
122
+ return { provider: ref.slice(0, slash), model: ref.slice(slash + 1) };
123
+ }
124
+
125
+ export default function register(api: {
126
+ on: (name: string, handler: (...args: unknown[]) => unknown, opts?: { priority?: number }) => void;
127
+ }) {
128
+ api.on(
129
+ "before_model_resolve",
130
+ (event: unknown, ctx: unknown) => {
131
+ const typedCtx = ctx as { sessionId?: string; workspaceDir?: string };
132
+ loadConfig(getOpenClawDir(typedCtx.workspaceDir));
133
+ if (!orderedModels.length) return;
134
+
135
+ checkAndReset();
136
+
137
+ const ref = getFirstAvailable();
138
+ if (!ref) {
139
+ throw new Error(
140
+ "Exhausted Models plugin: all configured models are marked exhausted; please update quotas or wait until the daily reset time."
141
+ );
142
+ }
143
+ if (typedCtx.sessionId) sessionModelMap.set(typedCtx.sessionId, ref);
144
+
145
+ const parsed = parseModelRef(ref);
146
+ if (!parsed) return;
147
+ return { providerOverride: parsed.provider, modelOverride: parsed.model };
148
+ },
149
+ { priority: 100 }
150
+ );
151
+
152
+ api.on(
153
+ "agent_end",
154
+ (event: unknown, ctx: unknown) => {
155
+ const typedEvent = event as { success?: boolean; error?: string };
156
+ const typedCtx = ctx as { sessionId?: string };
157
+ if (typedEvent.success !== false) return;
158
+ const ref = typedCtx.sessionId ? sessionModelMap.get(typedCtx.sessionId) : undefined;
159
+ if (ref) {
160
+ exhaustedSet.add(ref);
161
+ sessionModelMap.delete(typedCtx.sessionId!);
162
+ }
163
+ },
164
+ { priority: 0 }
165
+ );
166
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "id": "openclaw-exhausted-models",
3
+ "name": "Exhausted Models",
4
+ "description": "超限模型列表:自动跳过因额度/错误不可用的模型,按 agents.defaults.model 中的主模型与备用模型顺序切换,次日指定时间清空",
5
+ "version": "1.0.0",
6
+ "configSchema": {
7
+ "type": "object",
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "resetHour": {
11
+ "type": "integer",
12
+ "minimum": 0,
13
+ "maximum": 23,
14
+ "description": "每日清空的小时(0-23),默认 8"
15
+ },
16
+ "resetMinute": {
17
+ "type": "integer",
18
+ "minimum": 0,
19
+ "maximum": 59,
20
+ "description": "每日清空的分钟,默认 0"
21
+ },
22
+ "timezone": {
23
+ "type": "string",
24
+ "description": "时区(IANA 格式),默认 Asia/Shanghai"
25
+ }
26
+ }
27
+ }
28
+ }
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "openclaw-exhausted-models",
3
+ "version": "1.0.0",
4
+ "devDependencies": {
5
+ "@types/node": "*",
6
+ "typescript": "^5.9.3"
7
+ },
8
+ "main": "dist/index.js",
9
+ "types": "dist/index.d.ts",
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "prepublishOnly": "npm run build"
13
+ },
14
+ "openclaw": {
15
+ "extensions": ["openclaw-exhausted-models"]
16
+ }
17
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "CommonJS",
5
+ "declaration": true,
6
+ "esModuleInterop": true,
7
+ "strict": true,
8
+ "outDir": "dist",
9
+ "types": ["node"]
10
+ },
11
+ "include": ["index.ts"]
12
+ }