@coclaw/openclaw-coclaw 0.24.0 → 0.25.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/index.js CHANGED
@@ -814,14 +814,15 @@ const plugin = {
814
814
  loadProviderCatalogRuntime: () => import('openclaw/plugin-sdk/provider-catalog-runtime'),
815
815
  });
816
816
 
817
- // 模型默认配置 RPC(coclaw.model.set / list)。三个 SDK 子入口的字面量
817
+ // 模型默认配置 RPC(coclaw.model.set / list / listUsable)。三个 SDK 子入口的字面量
818
818
  // dynamic import 必须留在本入口源码——OpenClaw plugin loader 只扫入口源码
819
819
  // 命中 `openclaw/plugin-sdk/*` 字面量并触发 jiti 重写;藏在子模块的字面量
820
820
  // loader 看不到 → 原生 Node 解析必败。
821
821
  registerModelDefaultHandlers(api, {
822
822
  loadConfigMutation: () => import('openclaw/plugin-sdk/config-mutation'),
823
- loadModelsProviderRuntime: () => import('openclaw/plugin-sdk/models-provider-runtime'),
824
823
  loadProviderAuth: () => import('openclaw/plugin-sdk/provider-auth'),
824
+ // agent-runtime barrel:resolveProviderIdForAuth(内联别名归一)+ loadModelCatalog(选模型器枚举 / set 存在性干净目录)
825
+ loadAgentRuntime: () => import('openclaw/plugin-sdk/agent-runtime'),
825
826
  });
826
827
 
827
828
  const scheduler = new AutoUpgradeScheduler({ pluginId: api.id, logger });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "OpenClaw plugin for remote chat over WebRTC. Run `openclaw coclaw enroll` after install.",
@@ -1,22 +1,25 @@
1
1
  /**
2
- * model-default/handlers.js —— coclaw.model.set / list 两个 RPC 的纯函数实现
2
+ * model-default/handlers.js —— coclaw.model.set / list / listUsable 三个 RPC 的纯函数实现
3
3
  *
4
4
  * 设计要点(详见 docs/model-config-api.md § 3):
5
- * - DI 注入 sdk(mutateConfigFile / buildModelsProviderData / isProviderAuthProfileConfigured
5
+ * - DI 注入 sdk(mutateConfigFile / loadModelCatalog / provider-auth 凭据探针 / resolveProviderIdForAuth
6
6
  * + loadConfig + resolveAgentDir,便于单测;产线注入在 ./index.js
7
- * - **出参不加 status wrap**(gateway-method-design skill 新约定):set → {};list → { default, agents }
7
+ * - **出参不加 status wrap**(gateway-method-design skill 约定):set → {};list → { default, agents }
8
+ * listUsable → { byProvider, configuredProviders }
8
9
  * - 错误码只用 INVALID_ARGS / IO_FAILED,参考 provider-auth/handlers.js
9
10
  * 既有 plugin 的 respondError 用 INTERNAL_ERROR 与本节契约不一致,所以本模块自带局部 helper
10
11
  * - set 校验 fail-fast 顺序:params shape → 拒未知字段 → agentId → primary 类型 → primary 形态
11
- * (纯字符串:含 '/'、'/' 不在端点;不依赖 cfg)→ loadConfig → provider 凭据 catalog
12
+ * (纯字符串:含 '/'、'/' 不在端点;不依赖 cfg)→ loadConfig → 凭据门存在性
12
13
  * 形态校验**前置在 loadConfig 之前**,cfg 不可读时非法形态仍是 INVALID_ARGS 而非 IO_FAILED
13
- * - catalog 校验用 `view: 'all'`:picker 可见性过滤会误判某些合法 provider 不存在(subagent 调研结论)
14
+ * - 凭据门 + 选模型器枚举 + list 信号全部走统一别名感知原语(resolve.js),杜绝跨界面口径分叉(§ 3.2.1)
15
+ * - set 存在性 + listUsable 枚举走同一干净目录 loadModelCatalog({readOnly:true}):选得到 ⇒ 设得上(红线天然成立)
14
16
  */
15
17
 
16
- import { listAllPrimariesWithCredentials } from './resolve.js';
18
+ import { listAllPrimariesWithCredentials, computeProviderUsable, enumerateUsableModels } from './resolve.js';
17
19
  import { writePrimary } from './persist.js';
18
20
 
19
- const ALLOWED_KEYS = new Set(['agentId', 'primary']);
21
+ const SET_ALLOWED_KEYS = new Set(['agentId', 'primary']);
22
+ const LISTUSABLE_ALLOWED_KEYS = new Set(['agentId']);
20
23
 
21
24
  function respondInvalid(respond, message) {
22
25
  respond(false, undefined, { code: 'INVALID_ARGS', message });
@@ -47,42 +50,61 @@ function parseProviderModel(primary) {
47
50
  }
48
51
 
49
52
  /**
50
- * cfg 相关的 primary 校验:provider 凭据 + catalog 存在性。
53
+ * 构造 set / listUsable 用的统一别名感知凭据 deps。
54
+ * @param {object} sdk
55
+ * @param {string} agentDir
56
+ * @returns {object}
57
+ */
58
+ function buildCredDeps(sdk, agentDir) {
59
+ return {
60
+ agentDir,
61
+ isProviderApiKeyConfigured: sdk.isProviderApiKeyConfigured,
62
+ hasConfiguredSecretInput: sdk.hasConfiguredSecretInput,
63
+ ensureAuthProfileStore: sdk.ensureAuthProfileStore,
64
+ resolveProviderIdForAuth: sdk.resolveProviderIdForAuth,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * cfg 相关的 primary 校验:凭据门 + 干净目录存在性。
51
70
  * 形态拆分由调用方完成(fail-fast 前置在 loadConfig 之前)。
52
71
  *
72
+ * - 凭据门走统一原语 computeProviderUsable(取代旧 ledger-only isProviderAuthProfileConfigured):
73
+ * 覆盖 env + 账本 + 内联 + 别名套餐,修「内联/env/别名 provider 选得到设不上」,且继续拒幽灵
74
+ * (无任何源凭据的 openai/gpt-5.5 被门挡住)。cooldown 中凭据仍算已配置(沿用 isProviderApiKeyConfigured 立场)。
75
+ * - 存在性走干净目录 loadModelCatalog({readOnly:true})(与选模型器枚举同源 → 「选得到设不上」红线天然成立);
76
+ * 去掉旧 buildModelsProviderData view:'all'(~1076 模型/~10s、鉴权盲、过松)。
77
+ * loadModelCatalog readOnly 自带「持久化失败→静态 manifest」兜底;整体抛错由外层 catch 映射 IO_FAILED(set 是写操作,失败安全为先)。
78
+ *
53
79
  * @returns {Promise<string|null>} 错误 message;null 表通过
54
80
  */
55
- async function validateProviderCredAndCatalog({ provider, model, primary, cfg, sdk, agentDir }) {
56
- // 凭据校验:isProviderAuthProfileConfigured 内部就是 listUsableProviderAuthProfileIds().length > 0
57
- // cooldown profile 仍算"已配置"(cooldown 是临时态,上游 fallback 主循环会主动跳)
58
- const hasCred = sdk.isProviderAuthProfileConfigured({ provider, cfg, agentDir });
59
- if (!hasCred) {
60
- return `provider "${provider}" has no usable auth profile`;
81
+ async function validateProviderCredAndCatalog({ provider, model, primary, cfg, sdk, deps }) {
82
+ if (!computeProviderUsable(primary, cfg, deps)) {
83
+ return `provider "${provider}" has no usable credential`;
61
84
  }
62
-
63
- // catalog 校验:view: 'all' 绕开 picker 可见性过滤(picker 过滤掉的 provider 不影响合法性)
64
- const data = await sdk.buildModelsProviderData(cfg, undefined, { view: 'all' });
65
- const modelSet = data?.byProvider?.get(provider);
66
- if (!modelSet || !modelSet.has(model)) {
85
+ const entries = await sdk.loadModelCatalog({ readOnly: true });
86
+ const exists = Array.isArray(entries)
87
+ && entries.some((e) => e && e.provider === provider && e.id === model);
88
+ if (!exists) {
67
89
  return `model "${primary}" not found in catalog`;
68
90
  }
69
91
  return null;
70
92
  }
71
93
 
72
94
  /**
73
- * 构造 set + list 两个 handler。
95
+ * 构造 set / list / listUsable 三个 handler。
74
96
  *
75
97
  * @param {object} opts
76
98
  * @param {object} opts.sdk
77
- * @param {Function} opts.sdk.mutateConfigFile - openclaw/plugin-sdk/config-mutation
78
- * @param {Function} opts.sdk.buildModelsProviderData - openclaw/plugin-sdk/models-provider-runtime
79
- * @param {Function} opts.sdk.isProviderAuthProfileConfigured - openclaw/plugin-sdk/provider-auth(set 用)
80
- * @param {Function} opts.sdk.isProviderApiKeyConfigured - openclaw/plugin-sdk/provider-auth(list 凭据信号用)
81
- * @param {Function} opts.sdk.hasConfiguredSecretInput - openclaw/plugin-sdk/provider-auth(list 内联 key 判定)
82
- * @param {Function} opts.sdk.ensureAuthProfileStore - openclaw/plugin-sdk/provider-auth(list 账本非空判定)
99
+ * @param {Function} opts.sdk.mutateConfigFile - openclaw/plugin-sdk/config-mutation(set 写盘)
100
+ * @param {Function} opts.sdk.loadModelCatalog - openclaw/plugin-sdk/agent-runtime(set 存在性 + listUsable 枚举的干净目录)
101
+ * @param {Function} opts.sdk.isProviderApiKeyConfigured - openclaw/plugin-sdk/provider-auth(env+账本凭据信号,别名感知)
102
+ * @param {Function} opts.sdk.hasConfiguredSecretInput - openclaw/plugin-sdk/provider-auth(内联 key 判定)
103
+ * @param {Function} opts.sdk.ensureAuthProfileStore - openclaw/plugin-sdk/provider-auth(账本非空 / configuredProviders)
104
+ * @param {Function} opts.sdk.resolveProviderIdForAuth - openclaw/plugin-sdk/agent-runtime(别名归一)
83
105
  * @param {Function} opts.loadConfig - 返回当前 cfg snapshot;缺失时返回 null
84
- * @param {Function} opts.resolveAgentDir - 返回 main agent /agent 子目录全路径
85
- * @returns {{ set: Function, list: Function }}
106
+ * @param {Function} opts.resolveAgentDir - 返回 agent /agent 子目录全路径(默认 main agent;agentId 贯穿)
107
+ * @returns {{ set: Function, list: Function, listUsable: Function }}
86
108
  */
87
109
  export function buildModelDefaultHandlers({ sdk, loadConfig, resolveAgentDir }) {
88
110
  async function set({ params, respond }) {
@@ -92,7 +114,7 @@ export function buildModelDefaultHandlers({ sdk, loadConfig, resolveAgentDir })
92
114
  return;
93
115
  }
94
116
  for (const key of Object.keys(params)) {
95
- if (!ALLOWED_KEYS.has(key)) {
117
+ if (!SET_ALLOWED_KEYS.has(key)) {
96
118
  respondInvalid(respond, `unknown field: ${key}`);
97
119
  return;
98
120
  }
@@ -137,7 +159,7 @@ export function buildModelDefaultHandlers({ sdk, loadConfig, resolveAgentDir })
137
159
  primary,
138
160
  cfg,
139
161
  sdk,
140
- agentDir: resolveAgentDir(),
162
+ deps: buildCredDeps(sdk, resolveAgentDir()),
141
163
  });
142
164
  if (validationError) {
143
165
  respondInvalid(respond, validationError);
@@ -171,21 +193,63 @@ export function buildModelDefaultHandlers({ sdk, loadConfig, resolveAgentDir })
171
193
  respondIoFailed(respond, new Error('runtime config not available'));
172
194
  return;
173
195
  }
174
- // 凭据信号(providerUsable / hasAnyUsableCredential)借三源判定:
175
- // env+账本 走 isProviderApiKeyConfigured(别名归一化其内部完成),
176
- // 内联 key 走 hasConfiguredSecretInput,账本非空走 ensureAuthProfileStore。
177
- const deps = {
178
- agentDir: resolveAgentDir(),
179
- isProviderApiKeyConfigured: sdk.isProviderApiKeyConfigured,
180
- hasConfiguredSecretInput: sdk.hasConfiguredSecretInput,
181
- ensureAuthProfileStore: sdk.ensureAuthProfileStore,
182
- };
183
- respond(true, listAllPrimariesWithCredentials(cfg, deps));
196
+ // 凭据信号(providerUsable / hasAnyUsableCredential)借统一别名感知原语:
197
+ // env+账本 走 isProviderApiKeyConfigured(别名归一其内部完成),
198
+ // 内联 key 走 hasConfiguredSecretInput + resolveProviderIdForAuth 两侧归一,
199
+ // 账本非空走 ensureAuthProfileStore。
200
+ respond(true, listAllPrimariesWithCredentials(cfg, buildCredDeps(sdk, resolveAgentDir())));
201
+ }
202
+ catch (err) {
203
+ respondIoFailed(respond, err);
204
+ }
205
+ }
206
+
207
+ async function listUsable({ params, respond }) {
208
+ try {
209
+ if (!params || typeof params !== 'object' || Array.isArray(params)) {
210
+ respondInvalid(respond, 'params must be an object');
211
+ return;
212
+ }
213
+ for (const key of Object.keys(params)) {
214
+ if (!LISTUSABLE_ALLOWED_KEYS.has(key)) {
215
+ respondInvalid(respond, `unknown field: ${key}`);
216
+ return;
217
+ }
218
+ }
219
+ const { agentId } = params;
220
+ if (agentId !== undefined && !isNonEmptyString(agentId)) {
221
+ respondInvalid(respond, 'agentId must be a non-empty string when provided');
222
+ return;
223
+ }
224
+
225
+ const cfg = loadConfig();
226
+ if (!cfg) {
227
+ respondIoFailed(respond, new Error('runtime config not available'));
228
+ return;
229
+ }
230
+
231
+ // agentId 一路贯穿到凭据判定(与 set/providerUsable 同 agent 的 agentDir,不分叉)。
232
+ // 产线 resolveAgentDir=mainAgentDir 忽略入参恒 main(凭据按设计统一落 main、各 agent 层叠可见),
233
+ // 故四个消费点在产线天然同 dir;测试可注入按 agentId 分目录的 resolver 钉住贯穿。
234
+ const deps = buildCredDeps(sdk, resolveAgentDir(agentId));
235
+
236
+ // 干净目录 loadModelCatalog({readOnly:true}) 自带「持久化失败→静态 manifest」兜底(model-catalog.ts)。
237
+ // 仍整体抛错(罕见,如 runtime config 取不到)→ 兜空 entries:byProvider 退化为空,
238
+ // 但 configuredProviders 不依赖目录仍可算 → 「不空白」,UI 加 provider 排除照常工作(§ 3.2.1 降级)。
239
+ let entries;
240
+ try {
241
+ entries = await sdk.loadModelCatalog({ readOnly: true });
242
+ }
243
+ catch {
244
+ entries = [];
245
+ }
246
+
247
+ respond(true, enumerateUsableModels(entries, cfg, deps));
184
248
  }
185
249
  catch (err) {
186
250
  respondIoFailed(respond, err);
187
251
  }
188
252
  }
189
253
 
190
- return { set, list };
254
+ return { set, list, listUsable };
191
255
  }
@@ -1,8 +1,8 @@
1
1
  /**
2
- * model-default 注册入口 —— 把 coclaw.model.set / list 接到 gateway。
2
+ * model-default 注册入口 —— 把 coclaw.model.set / list / listUsable 接到 gateway。
3
3
  *
4
4
  * 设计(同 provider-auth/index.js):
5
- * - 三个 SDK 子入口(config-mutation / models-provider-runtime / provider-auth)懒加载,
5
+ * - 三个 SDK 子入口(config-mutation / provider-auth / agent-runtime)懒加载,
6
6
  * 首次 RPC 调用才解析;失败硬编码 IO_FAILED 透 message
7
7
  * - mainAgentDir 走 claw-paths.js;loadConfig 走 claw-config.js
8
8
  * - opts 主要给单测用:可注入 fake sdk 工厂 / fake agentDir resolver / fake loadConfig
@@ -21,33 +21,35 @@ import { getClawConfig } from '../claw-config.js';
21
21
  // SDK(结果一致、运行无伤但去重失效)。当前仅 RPC handler 走该路径——
22
22
  // 不要在 hook 回调里访问本模块的 export。详见 docs/module-boundaries.md。
23
23
  let _configMutationP;
24
- let _modelsP;
25
24
  let _providerAuthP;
25
+ let _agentRuntimeP;
26
26
 
27
27
  function defaultLoadConfigMutation() {
28
28
  _configMutationP ??= import('openclaw/plugin-sdk/config-mutation');
29
29
  return _configMutationP;
30
30
  }
31
- function defaultLoadModelsProviderRuntime() {
32
- _modelsP ??= import('openclaw/plugin-sdk/models-provider-runtime');
33
- return _modelsP;
34
- }
35
31
  function defaultLoadProviderAuth() {
36
32
  _providerAuthP ??= import('openclaw/plugin-sdk/provider-auth');
37
33
  return _providerAuthP;
38
34
  }
35
+ function defaultLoadAgentRuntime() {
36
+ // agent-runtime barrel 同时给:resolveProviderIdForAuth(别名归一)+ loadModelCatalog(干净目录,
37
+ // set 存在性 / listUsable 选模型器枚举同源)。barrel re-export provider-auth-aliases.js + model-catalog.js。
38
+ _agentRuntimeP ??= import('openclaw/plugin-sdk/agent-runtime');
39
+ return _agentRuntimeP;
40
+ }
39
41
 
40
42
  /**
41
43
  * 测试辅助:清掉懒加载 SDK 缓存。
42
44
  */
43
45
  export function __resetSdkCaches() {
44
46
  _configMutationP = undefined;
45
- _modelsP = undefined;
46
47
  _providerAuthP = undefined;
48
+ _agentRuntimeP = undefined;
47
49
  }
48
50
 
49
51
  /**
50
- * 在 gateway api 上注册 `coclaw.model.set` / `coclaw.model.list`。
52
+ * 在 gateway api 上注册 `coclaw.model.set` / `coclaw.model.list` / `coclaw.model.listUsable`。
51
53
  *
52
54
  * 仅 `register(api)` 的 `if (api.registrationMode === 'full')` 分支调;
53
55
  * 其它 mode 注册副作用违规(参 plugins/openclaw/CLAUDE.md "Service / register 副作用边界")。
@@ -57,33 +59,35 @@ export function __resetSdkCaches() {
57
59
  * @param {Function} [opts.resolveAgentDir] - 覆盖 agentDir 解析(默认 mainAgentDir)
58
60
  * @param {Function} [opts.loadConfig] - 覆盖 cfg 读取(默认 getClawConfig)
59
61
  * @param {Function} [opts.loadConfigMutation] - 必传(生产由入口注入字面量 dynamic import)
60
- * @param {Function} [opts.loadModelsProviderRuntime] - 必传(同上)
61
62
  * @param {Function} [opts.loadProviderAuth] - 必传(同上)
63
+ * @param {Function} [opts.loadAgentRuntime] - 必传(同上;resolveProviderIdForAuth + loadModelCatalog)
62
64
  */
63
65
  export function registerModelDefaultHandlers(api, opts = {}) {
64
66
  const resolveAgentDir = opts.resolveAgentDir ?? mainAgentDir;
65
67
  const loadConfig = opts.loadConfig ?? getClawConfig;
66
68
  const loadConfigMutation = opts.loadConfigMutation ?? defaultLoadConfigMutation;
67
- const loadModelsProviderRuntime = opts.loadModelsProviderRuntime ?? defaultLoadModelsProviderRuntime;
68
69
  const loadProviderAuth = opts.loadProviderAuth ?? defaultLoadProviderAuth;
70
+ const loadAgentRuntime = opts.loadAgentRuntime ?? defaultLoadAgentRuntime;
69
71
 
70
72
  let handlersPromise;
71
73
  async function getHandlers() {
72
74
  if (!handlersPromise) {
73
75
  handlersPromise = (async () => {
74
- const [configMutation, modelsRuntime, providerAuth] = await Promise.all([
76
+ const [configMutation, providerAuth, agentRuntime] = await Promise.all([
75
77
  loadConfigMutation(),
76
- loadModelsProviderRuntime(),
77
78
  loadProviderAuth(),
79
+ loadAgentRuntime(),
78
80
  ]);
79
81
  const sdk = {
80
82
  mutateConfigFile: configMutation.mutateConfigFile,
81
- buildModelsProviderData: modelsRuntime.buildModelsProviderData,
82
- isProviderAuthProfileConfigured: providerAuth.isProviderAuthProfileConfigured,
83
- // list 凭据信号用(provider-auth barrel 已加载,无需新增子入口字面量)
83
+ // 干净目录(set 存在性 + listUsable 枚举同源):agent-runtime barrel re-export model-catalog.js
84
+ loadModelCatalog: agentRuntime.loadModelCatalog,
85
+ // 凭据信号(providerUsable / hasAnyUsableCredential / 凭据门 / configuredProviders)
84
86
  isProviderApiKeyConfigured: providerAuth.isProviderApiKeyConfigured,
85
87
  hasConfiguredSecretInput: providerAuth.hasConfiguredSecretInput,
86
88
  ensureAuthProfileStore: providerAuth.ensureAuthProfileStore,
89
+ // 别名归一(内联凭据信号 + 选模型器枚举 + configuredProviders)
90
+ resolveProviderIdForAuth: agentRuntime.resolveProviderIdForAuth,
87
91
  };
88
92
  return buildModelDefaultHandlers({ sdk, loadConfig, resolveAgentDir });
89
93
  })();
@@ -110,4 +114,5 @@ export function registerModelDefaultHandlers(api, opts = {}) {
110
114
 
111
115
  api.registerGatewayMethod('coclaw.model.set', wrap('set'));
112
116
  api.registerGatewayMethod('coclaw.model.list', wrap('list'));
117
+ api.registerGatewayMethod('coclaw.model.listUsable', wrap('listUsable'));
113
118
  }
@@ -102,37 +102,95 @@ export function providerSegmentOf(primary) {
102
102
  }
103
103
 
104
104
  /**
105
- * 某 provider 是否配了内联 key(cfg.models.providers[provider].apiKey)。
105
+ * 某 provider 是否配了内联 key(cfg.models.providers.<id>.apiKey)。
106
+ * 别名感知:查询名与各内联节点 id 两侧都过 resolveProviderIdForAuth 归一后比较,
107
+ * 故持基座 volcengine 内联 key 的用户查 volcengine-plan(套餐变体)也命中。
106
108
  * 仅是"配置信号"(hasConfiguredSecretInput 不验证 env 引用能否真解析,
107
109
  * 见心智模型典型陷阱 #20),方向偏向少误报。
110
+ * @param {object} cfg
111
+ * @param {string} provider - 裸 provider 名
112
+ * @param {object} deps - { hasConfiguredSecretInput, resolveProviderIdForAuth }
113
+ * @returns {boolean}
108
114
  */
109
- function hasInlineKey(cfg, provider, hasConfiguredSecretInput) {
110
- const entry = cfg?.models?.providers?.[provider];
111
- return entry ? hasConfiguredSecretInput(entry.apiKey) : false;
115
+ function hasInlineKey(cfg, provider, deps) {
116
+ const providers = cfg?.models?.providers;
117
+ if (!providers || typeof providers !== 'object') return false;
118
+ const targetId = deps.resolveProviderIdForAuth(provider);
119
+ for (const [nodeId, entry] of Object.entries(providers)) {
120
+ if (!entry || !deps.hasConfiguredSecretInput(entry.apiKey)) continue;
121
+ if (deps.resolveProviderIdForAuth(nodeId) === targetId) return true;
122
+ }
123
+ return false;
112
124
  }
113
125
 
114
126
  /**
115
- * primary 那家 provider 有没有可用凭据。
116
- * 判定 = isProviderApiKeyConfigured(覆盖环境变量 + 自管账本,别名归一化其内部完成)
117
- * provider 配了内联 key
127
+ * provider(裸名,无斜杠)有没有可用凭据 —— 统一别名感知原语。
128
+ * 判定 = isProviderApiKeyConfigured(覆盖 env + 自管账本,别名归一其内部完成)
129
+ * hasInlineKey(内联 key,别名归一)。
130
+ * 覆盖 env + 内联 + 账本 + 别名套餐;统一漏 IAM/本地(hasAuthForModelProvider 未导出 plugin-sdk,接受)。
131
+ * 选模型器枚举 / model.set 门 / providerUsable / noKey 四个消费点同吃这一个原语,杜绝跨界面口径分叉。
132
+ * @param {string|null} provider - 裸 provider 名(如 'openai' / 'volcengine-plan')
133
+ * @param {object} cfg
134
+ * @param {object} deps - { agentDir, isProviderApiKeyConfigured, hasConfiguredSecretInput, resolveProviderIdForAuth }
135
+ * @returns {boolean}
136
+ */
137
+ export function computeProviderUsableByName(provider, cfg, deps) {
138
+ if (typeof provider !== 'string' || provider.length === 0) return false;
139
+ if (deps.isProviderApiKeyConfigured({ provider, agentDir: deps.agentDir })) return true;
140
+ return hasInlineKey(cfg, provider, deps);
141
+ }
142
+
143
+ /**
144
+ * 该 primary 那家 provider 有没有可用凭据:取 provider 段后委托 computeProviderUsableByName。
118
145
  * primary 解析不出 provider 段(含 null)时恒 false(UI 此时走 noPrimary,不看它)。
119
146
  * @param {string|null} primary
120
147
  * @param {object} cfg
121
- * @param {object} deps - { agentDir, isProviderApiKeyConfigured, hasConfiguredSecretInput }
148
+ * @param {object} deps - computeProviderUsableByName
122
149
  * @returns {boolean}
123
150
  */
124
151
  export function computeProviderUsable(primary, cfg, deps) {
125
- const provider = providerSegmentOf(primary);
126
- if (!provider) return false;
127
- if (deps.isProviderApiKeyConfigured({ provider, agentDir: deps.agentDir })) return true;
128
- return hasInlineKey(cfg, provider, deps.hasConfiguredSecretInput);
152
+ return computeProviderUsableByName(providerSegmentOf(primary), cfg, deps);
153
+ }
154
+
155
+ /**
156
+ * 收集"候选 provider 名"(未归一、原始拼写)供 env 探测:
157
+ * default + 各 agent primary 的 provider 段 ∪ 内联节点 id ∪ 账本各 profile 的 provider。
158
+ * env-only 凭据无法穷举所有环境变量名,只在这些候选上用 isProviderApiKeyConfigured 探测,
159
+ * 与 providerAuth.list 的 env 口径一致(纯 env、又非主模型/账本/内联的 provider 不计,接受残留)。
160
+ * @param {object} cfg
161
+ * @param {object|null} store - ensureAuthProfileStore 返回值(可能为 null)
162
+ * @returns {Set<string>}
163
+ */
164
+ function collectCandidateProviders(cfg, store) {
165
+ const out = new Set();
166
+ const base = listAllPrimaries(cfg);
167
+ const seg = providerSegmentOf(base.default.primary);
168
+ if (seg) out.add(seg);
169
+ for (const v of Object.values(base.agents)) {
170
+ const s = providerSegmentOf(v.primary);
171
+ if (s) out.add(s);
172
+ }
173
+ const providers = cfg?.models?.providers;
174
+ if (providers && typeof providers === 'object') {
175
+ for (const id of Object.keys(providers)) out.add(id);
176
+ }
177
+ if (store && store.profiles && typeof store.profiles === 'object') {
178
+ for (const cred of Object.values(store.profiles)) {
179
+ if (cred && typeof cred.provider === 'string' && cred.provider.length > 0) {
180
+ out.add(cred.provider);
181
+ }
182
+ }
183
+ }
184
+ return out;
129
185
  }
130
186
 
131
187
  /**
132
- * 这台 claw 有没有任何可用凭据:自管账本非空 任一 provider 节点有内联 key。
133
- * 驱动 UI 的 noKey 引导。
188
+ * 这台 claw 有没有任何可用凭据:自管账本非空 OR 任一内联 key OR 任一候选 provider env key。
189
+ * 驱动 UI 的 noKey 引导。必补 C:补 env(候选集口径见 collectCandidateProviders),
190
+ * 与 per-provider 的 providerUsable 口径对齐,根治"纯 env-only 用户被误弹『还没加 key』"。
191
+ * 补后仅漏纯 IAM-only/本地(pro,接受 spurious noKey)。
134
192
  * @param {object} cfg
135
- * @param {object} deps - { agentDir, ensureAuthProfileStore, hasConfiguredSecretInput }
193
+ * @param {object} deps - { agentDir, ensureAuthProfileStore, hasConfiguredSecretInput, isProviderApiKeyConfigured }
136
194
  * @returns {boolean}
137
195
  */
138
196
  export function computeHasAnyUsableCredential(cfg, deps) {
@@ -144,14 +202,91 @@ export function computeHasAnyUsableCredential(cfg, deps) {
144
202
  if (entry && deps.hasConfiguredSecretInput(entry.apiKey)) return true;
145
203
  }
146
204
  }
205
+ for (const provider of collectCandidateProviders(cfg, store)) {
206
+ if (deps.isProviderApiKeyConfigured({ provider, agentDir: deps.agentDir })) return true;
207
+ }
147
208
  return false;
148
209
  }
149
210
 
211
+ /**
212
+ * 别名归一的"已配 provider"集 = 用户已持任一来源凭据的基座 provider id 集,
213
+ * 供 UI 加 provider 时排除(套餐用户持 volcengine key 后不再被叫去加 volcengine/volcengine-plan)。
214
+ * 三源(每个都过 resolveProviderIdForAuth 归一后去重):
215
+ * ① 账本各 profile 的 cred.provider
216
+ * ② 内联各带 apiKey 的节点 id
217
+ * ③ env 候选(collectCandidateProviders 口径)中 isProviderApiKeyConfigured 命中的
218
+ * @param {object} cfg
219
+ * @param {object} deps - { agentDir, ensureAuthProfileStore, hasConfiguredSecretInput, isProviderApiKeyConfigured, resolveProviderIdForAuth }
220
+ * @returns {string[]} 升序去重
221
+ */
222
+ export function computeConfiguredProviders(cfg, deps) {
223
+ const store = deps.ensureAuthProfileStore(deps.agentDir, { allowKeychainPrompt: false });
224
+ const out = new Set();
225
+ const add = (raw) => {
226
+ const id = deps.resolveProviderIdForAuth(raw);
227
+ if (id) out.add(id);
228
+ };
229
+ if (store && store.profiles && typeof store.profiles === 'object') {
230
+ for (const cred of Object.values(store.profiles)) {
231
+ if (cred && typeof cred.provider === 'string' && cred.provider.length > 0) add(cred.provider);
232
+ }
233
+ }
234
+ const providers = cfg?.models?.providers;
235
+ if (providers && typeof providers === 'object') {
236
+ for (const [id, entry] of Object.entries(providers)) {
237
+ if (entry && deps.hasConfiguredSecretInput(entry.apiKey)) add(id);
238
+ }
239
+ }
240
+ for (const provider of collectCandidateProviders(cfg, store)) {
241
+ if (deps.isProviderApiKeyConfigured({ provider, agentDir: deps.agentDir })) add(provider);
242
+ }
243
+ return [...out].sort();
244
+ }
245
+
246
+ /**
247
+ * 选模型器枚举(纯同步):把干净目录按 entry.provider 分组,留 computeProviderUsableByName 为真的 provider。
248
+ * catalogEntries 由调用方传入(子任务 2 的 handler 调 loadModelCatalog({readOnly:true}) 后传进来),
249
+ * 本函数不自己 await loadModelCatalog;空 / 非数组 entries → 空 byProvider。
250
+ * 变体 provider(如 volcengine-plan)经 manifest 目录行进入 entries、再经基座 key 别名感知保留;
251
+ * 无凭据 provider 被丢(含幽灵——幽灵根本不在 loadModelCatalog 这个源里)。
252
+ *
253
+ * 文本模态过滤:核实结论 = 不过滤。loadModelCatalog 输出的 ModelCatalogEntry 无 output kind 字段
254
+ * (image_generation 等是网关响应的另一类型;imageModel 注入只在 buildModelsProviderData 尾部、不在此源),
255
+ * 故无"纯图像/视频生成"条目混入;entry.input 是"输入"模态而非输出 kind,按它滤会误删多模态文本模型。
256
+ *
257
+ * @param {object[]} catalogEntries - loadModelCatalog({readOnly:true}) 的结果(ModelCatalogEntry[])
258
+ * @param {object} cfg
259
+ * @param {object} deps - { agentDir, isProviderApiKeyConfigured, hasConfiguredSecretInput, resolveProviderIdForAuth, ensureAuthProfileStore }
260
+ * @returns {{ byProvider: Record<string, string[]>, configuredProviders: string[] }}
261
+ */
262
+ export function enumerateUsableModels(catalogEntries, cfg, deps) {
263
+ const grouped = new Map(); // provider -> Set<modelId>
264
+ if (Array.isArray(catalogEntries)) {
265
+ for (const entry of catalogEntries) {
266
+ if (!entry || typeof entry.provider !== 'string' || entry.provider.length === 0) continue;
267
+ if (typeof entry.id !== 'string' || entry.id.length === 0) continue;
268
+ let set = grouped.get(entry.provider);
269
+ if (!set) {
270
+ set = new Set();
271
+ grouped.set(entry.provider, set);
272
+ }
273
+ set.add(entry.id);
274
+ }
275
+ }
276
+ const byProvider = {};
277
+ for (const [provider, ids] of grouped) {
278
+ if (computeProviderUsableByName(provider, cfg, deps)) {
279
+ byProvider[provider] = [...ids].sort();
280
+ }
281
+ }
282
+ return { byProvider, configuredProviders: computeConfiguredProviders(cfg, deps) };
283
+ }
284
+
150
285
  /**
151
286
  * 装配带凭据信号的 list 出参(docs/model-config-api.md § 3.4「凭据信号」)。
152
287
  * 在 listAllPrimaries 基础上给每个 scope 加 providerUsable,并加顶层 hasAnyUsableCredential。
153
288
  * @param {object} cfg
154
- * @param {object} deps - { agentDir, isProviderApiKeyConfigured, hasConfiguredSecretInput, ensureAuthProfileStore }
289
+ * @param {object} deps - { agentDir, isProviderApiKeyConfigured, hasConfiguredSecretInput, ensureAuthProfileStore, resolveProviderIdForAuth }
155
290
  * @returns {{
156
291
  * default: { primary: string|null, providerUsable: boolean },
157
292
  * agents: Record<string, { primary: string|null, providerUsable: boolean }>,
@@ -38,6 +38,7 @@ import { PORTAL_PROVIDER_ID, CONFIG_DEFAULT_BASE_URL, VALID_REGIONS } from './mi
38
38
  import { getPortalModels } from './portal-model-catalog.js';
39
39
  import { remoteLog } from '../remote-log.js';
40
40
  import { getClawConfig } from '../claw-config.js';
41
+ import { listAllPrimaries, providerSegmentOf } from '../model-default/resolve.js';
41
42
  import { deepMergeInto } from '../utils/deep-merge.js';
42
43
  import {
43
44
  isVerificationNote,
@@ -149,14 +150,29 @@ export function buildProviderAuthHandlers({
149
150
  respondInvalid(respond, 'provider must be a non-empty string when provided');
150
151
  return;
151
152
  }
153
+ // 账本来源(source='profile')
152
154
  const store = sdk.ensureAuthProfileStore(resolveAgentDir());
153
- const profiles = [];
155
+ const ledger = [];
154
156
  const raw = store?.profiles ?? {};
155
157
  for (const [profileId, cred] of Object.entries(raw)) {
156
158
  if (!isWellFormedCredential(cred)) continue;
157
- if (filterProvider && cred.provider !== filterProvider) continue;
158
- profiles.push(toListEntry(profileId, cred, sdk.formatApiKeyPreview));
159
+ ledger.push(toListEntry(profileId, cred, sdk.formatApiKeyPreview));
159
160
  }
161
+ // 内联来源(source='inline'):读 cfg;cfg 读不到时退化为仅账本,不连累 ledger 路径
162
+ let cfg = null;
163
+ try { cfg = resolveConfig(); }
164
+ catch { cfg = null; }
165
+ const inline = listInlineEntries(cfg, {
166
+ hasConfiguredSecretInput: sdk.hasConfiguredSecretInput,
167
+ formatApiKeyPreview: sdk.formatApiKeyPreview,
168
+ });
169
+ // env 来源(source='env'):候选=账本∪内联∪主模型段;仅未被账本/内联覆盖的 sole-source 才列
170
+ const covered = new Set([...ledger, ...inline].map((e) => e.provider));
171
+ const candidates = new Set([...covered, ...collectPrimaryProviderSegments(cfg)]);
172
+ const env = listEnvEntries(candidates, covered, { resolveEnvApiKey: sdk.resolveEnvApiKey });
173
+
174
+ let profiles = [...ledger, ...inline, ...env];
175
+ if (filterProvider) profiles = profiles.filter((e) => e.provider === filterProvider);
160
176
  respond(true, { profiles });
161
177
  }
162
178
  catch (err) {
@@ -171,16 +187,40 @@ export function buildProviderAuthHandlers({
171
187
  respondInvalid(respond, 'provider must be a non-empty string');
172
188
  return;
173
189
  }
174
- const result = await sdk.removeProviderAuthProfilesWithLock({
175
- provider,
176
- agentDir: resolveAgentDir(),
177
- });
178
- // 同 setApiKey:锁/磁盘失败时上游返回 null
179
- if (result === null) {
180
- respondIoFailed(respond, new Error('failed to update auth-profiles store'));
190
+ const source = params?.source ?? 'profile';
191
+ if (source === 'profile') {
192
+ const result = await sdk.removeProviderAuthProfilesWithLock({
193
+ provider,
194
+ agentDir: resolveAgentDir(),
195
+ });
196
+ // setApiKey:锁/磁盘失败时上游返回 null
197
+ if (result === null) {
198
+ respondIoFailed(respond, new Error('failed to update auth-profiles store'));
199
+ return;
200
+ }
201
+ respond(true, {});
181
202
  return;
182
203
  }
183
- respond(true, {});
204
+ if (source === 'inline') {
205
+ // 内联撤销:只删 cfg.models.providers[provider].apiKey 字段,保留节点其余内容
206
+ //(baseUrl/api/models 是用户的自定义 provider 定义,删整节点会把"没 key"恶化成"模型不存在")。
207
+ // 删 key 后节点变空 {} → 顺手清掉空节点。afterWrite:auto → hot 路径零打断(docs § 2.5 / § 6.14)。
208
+ await sdk.mutateConfigFile({
209
+ afterWrite: { mode: 'auto' },
210
+ mutate(draft) {
211
+ const providers = draft?.models?.providers;
212
+ if (!providers || typeof providers !== 'object' || Array.isArray(providers)) return;
213
+ const node = providers[provider];
214
+ if (!node || typeof node !== 'object') return; // 幂等:无该节点视为已撤销
215
+ delete node.apiKey;
216
+ if (Object.keys(node).length === 0) delete providers[provider];
217
+ },
218
+ });
219
+ respond(true, {});
220
+ return;
221
+ }
222
+ // env 及未知 source:插件无法撤销(env 在进程环境里)→ 后端兜底拒绝
223
+ respondInvalid(respond, `cannot remove credential with source "${source}"`);
184
224
  }
185
225
  catch (err) {
186
226
  respondIoFailed(respond, err);
@@ -528,13 +568,15 @@ function isWellFormedCredential(cred) {
528
568
  }
529
569
 
530
570
  /**
531
- * 把单条 credential 转成 list RPC 出参元素。
571
+ * 把单条账本 credential 转成 list RPC 出参元素(source='profile',可撤销)。
532
572
  * 关键:原始 key / token / OAuth access/refresh 绝不出 handler。
533
573
  */
534
574
  function toListEntry(profileId, cred, formatApiKeyPreview) {
535
575
  const out = {
536
576
  profileId,
537
577
  provider: cred.provider,
578
+ source: 'profile',
579
+ removable: true,
538
580
  type: cred.type,
539
581
  };
540
582
  if (cred.type === 'api_key' && typeof cred.key === 'string' && cred.key.length > 0) {
@@ -547,3 +589,78 @@ function toListEntry(profileId, cred, formatApiKeyPreview) {
547
589
  }
548
590
  return out;
549
591
  }
592
+
593
+ /**
594
+ * 内联来源:cfg.models.providers 里 apiKey 配了的节点(用户手写 / OAuth 写的节点若带 key)。
595
+ * 只看 apiKey 字段——OAuth 登录写的节点无 apiKey,天然不会被误收(docs § 6.14)。
596
+ * @param {object} cfg
597
+ * @param {object} deps - { hasConfiguredSecretInput, formatApiKeyPreview }
598
+ * @returns {object[]} source='inline'、removable=true 的出参元素
599
+ */
600
+ function listInlineEntries(cfg, { hasConfiguredSecretInput, formatApiKeyPreview }) {
601
+ const out = [];
602
+ const providers = cfg?.models?.providers;
603
+ if (!providers || typeof providers !== 'object' || Array.isArray(providers)) return out;
604
+ for (const [provider, node] of Object.entries(providers)) {
605
+ if (!node || typeof node !== 'object') continue;
606
+ if (!hasConfiguredSecretInput(node.apiKey)) continue;
607
+ const entry = {
608
+ profileId: `${provider}#inline`,
609
+ provider,
610
+ source: 'inline',
611
+ removable: true,
612
+ type: 'api_key',
613
+ };
614
+ // 仅明文 string key 给预览;{env}/{file} 引用形态不预览(避免怪异展示)
615
+ if (typeof node.apiKey === 'string' && !node.apiKey.startsWith('{')) {
616
+ entry.keyPreview = formatApiKeyPreview(node.apiKey);
617
+ }
618
+ out.push(entry);
619
+ }
620
+ return out;
621
+ }
622
+
623
+ /**
624
+ * env 来源:对候选 provider 集合探测环境变量;仅当该 provider 未被账本/内联覆盖(sole source)才列,
625
+ * 避免与已可撤销的行重复添噪。env 来源不可撤销(removable=false),仅展示。
626
+ * @param {Iterable<string>} candidates - 候选 provider(账本∪内联∪主模型段)
627
+ * @param {Set<string>} covered - 已由账本/内联列出的 provider(原始拼写)
628
+ * @param {object} deps - { resolveEnvApiKey }
629
+ * @returns {object[]} source='env'、removable=false 的出参元素
630
+ */
631
+ function listEnvEntries(candidates, covered, { resolveEnvApiKey }) {
632
+ const out = [];
633
+ const seen = new Set();
634
+ for (const provider of candidates) {
635
+ if (!provider || covered.has(provider) || seen.has(provider)) continue;
636
+ const hit = resolveEnvApiKey(provider);
637
+ if (!hit?.apiKey) continue;
638
+ seen.add(provider);
639
+ out.push({
640
+ profileId: `${provider}#env`,
641
+ provider,
642
+ source: 'env',
643
+ removable: false,
644
+ type: 'api_key',
645
+ });
646
+ }
647
+ return out;
648
+ }
649
+
650
+ /**
651
+ * 收集 default + 各 agent 主模型的 provider 段(去重交给调用方的 Set)。
652
+ * 复用 model-default/resolve.js,不重复造主模型读取逻辑。
653
+ * @param {object} cfg
654
+ * @returns {string[]}
655
+ */
656
+ function collectPrimaryProviderSegments(cfg) {
657
+ const all = listAllPrimaries(cfg);
658
+ const segs = [];
659
+ const push = (primary) => {
660
+ const seg = providerSegmentOf(primary);
661
+ if (seg) segs.push(seg);
662
+ };
663
+ push(all.default.primary);
664
+ for (const v of Object.values(all.agents)) push(v.primary);
665
+ return segs;
666
+ }
@@ -714,8 +714,9 @@ export class RealtimeBridge {
714
714
  try {
715
715
  const authToken = this.__resolveGatewayAuthToken();
716
716
  const params = {
717
+ // 声明支持 v3–v4:OpenClaw 网关自 v4 起要求协议范围含 4,旧网关仍可协商回 v3
717
718
  minProtocol: 3,
718
- maxProtocol: 3,
719
+ maxProtocol: 4,
719
720
  client: {
720
721
  id: 'gateway-client',
721
722
  version: this.__pluginVersion ?? 'unknown',
@@ -300,8 +300,18 @@ export function createSessionManager(options = {}) {
300
300
  * 按 sessionId 获取消息,返回完整 JSONL 行级结构。
301
301
  * 只返回 type==="message" 且有合法 message.role 的行。
302
302
  * limit 语义:不传/null/非 number/NaN/Infinity/<1 → 返回全部;>=1 的有限 number → 取最后 Math.trunc(limit) 条。无默认/最大值。
303
+ *
304
+ * 取不到正文时用错误码区分成因,调用方据此精确处置(不再统一塌缩成空数组):
305
+ * - transcript 文件不存在(裸名 / .reset. / .deleted. 变体全无)→ 抛 code='NOT_FOUND'
306
+ * - 文件在但一行都解析不出(非空却零行成功 JSON.parse)→ 抛 code='PARSE_FAILED'
307
+ * - 真·读盘 IO 错误:readTranscriptText 原样上抛(由 RPC 层映射)
308
+ * 空文件 / 全空白行(含仅含空格、制表符的行)/ 文件在但无 message 行 → 正常返回 { messages: [] }(良性空,非失败)。
309
+ *
310
+ * 部分坏行(有成功解析的行 + 个别 JSON.parse 失败)走容错:返回解析出的消息,
311
+ * 并在 payload 平行附 badLines(仅 >0 时)记录坏行原文供排障,不丢整段。
312
+ * badLines[].index 是坏行在「非空白内容行」序列中的 0-based 位置(空白行已被跳过,故非原始文件行号)。
303
313
  * @param {{ sessionId: string, agentId?: string, limit?: number }} params
304
- * @returns {Promise<{ messages: object[] }>}
314
+ * @returns {Promise<{ messages: object[], badLines?: { index: number, raw: string, error: string }[] }>}
305
315
  */
306
316
  async function getById(params = {}) {
307
317
  const agentId = typeof params.agentId === 'string' && params.agentId.trim() ? params.agentId.trim() : 'main';
@@ -313,25 +323,42 @@ export function createSessionManager(options = {}) {
313
323
  const limitNum = useLimit ? Math.trunc(params.limit) : 0;
314
324
  const file = await resolveTranscriptFile(agentId, sessionId);
315
325
  if (!file) {
316
- return { messages: [] };
326
+ throw Object.assign(new Error(`session transcript not found: ${sessionId}`), { code: 'NOT_FOUND' });
317
327
  }
318
328
 
319
329
  const text = await readTranscriptText(file);
320
330
  const messages = [];
331
+ const badLines = [];
332
+ // parseOk 在 JSON.parse 成功后立即 +1,必须在 type 过滤之前——
333
+ // 否则"全合法 JSON 但无 message 行"会被错判 PARSE_FAILED
334
+ let parseOk = 0;
335
+ let index = -1;
321
336
  for await (const line of iterTextLines(text)) {
337
+ // 纯空白行(仅空格/制表符等,iterTextLines 只跳零长度段)视同空行:
338
+ // 既不计入 parseOk 也不进 badLines,保证"全空白文件 → 良性空"不变量、不误判 PARSE_FAILED
339
+ if (line.trim() === '') continue;
340
+ index++;
341
+ let row;
322
342
  try {
323
- const row = JSON.parse(line);
324
- if (row?.type !== 'message') continue;
325
- const msg = row?.message;
326
- if (!msg || typeof msg !== 'object' || !msg.role) continue;
327
- messages.push(row);
343
+ row = JSON.parse(line);
328
344
  }
329
345
  catch (err) {
346
+ badLines.push({ index, raw: line, error: String(err?.message ?? err) });
330
347
  logger.warn?.(`[session-manager] bad json line skipped: ${String(err?.message ?? err)}`);
348
+ continue;
331
349
  }
350
+ parseOk++;
351
+ if (row?.type !== 'message') continue;
352
+ const msg = row?.message;
353
+ if (!msg || typeof msg !== 'object' || !msg.role) continue;
354
+ messages.push(row);
355
+ }
356
+ // 非空文件却一行都没解析出 = 整文损坏;空 / 全空白文件(含纯空格行)跳过后零内容行 → parseOk=0 且 badLines=[],不算损坏
357
+ if (parseOk === 0 && badLines.length > 0) {
358
+ throw Object.assign(new Error(`session transcript unparseable: ${sessionId}`), { code: 'PARSE_FAILED' });
332
359
  }
333
360
  const sliced = (useLimit && messages.length > limitNum) ? messages.slice(-limitNum) : messages;
334
- return { messages: sliced };
361
+ return badLines.length > 0 ? { messages: sliced, badLines } : { messages: sliced };
335
362
  }
336
363
 
337
364
  return { listAll, listAllEntries, get, getById };