bereach-openclaw 1.5.7 → 1.5.8
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/node_modules/@bereach/tools/src/cache-types.ts +2 -0
- package/node_modules/@bereach/tools/src/definitions.ts +5 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/commands/connector.ts +200 -9
- package/src/hooks/cache.ts +1 -0
- package/src/hooks/context.ts +15 -0
- package/src/hooks/enforcement.ts +8 -2
- package/src/hooks/lifecycle.ts +79 -0
- package/src/index.ts +3 -3
|
@@ -102,4 +102,6 @@ export interface CacheStore {
|
|
|
102
102
|
sessionMeta: SessionMeta | null;
|
|
103
103
|
onboardingState: OnboardingState | null;
|
|
104
104
|
recentEvents: RecentEvent[];
|
|
105
|
+
/** LLM circuit breaker status: "auth" | "billing" | "error" when tripped, null when OK */
|
|
106
|
+
llmStatus?: string | null;
|
|
105
107
|
}
|
|
@@ -1307,6 +1307,11 @@ export const definitions: ToolDefinition[] = [
|
|
|
1307
1307
|
},
|
|
1308
1308
|
},
|
|
1309
1309
|
},
|
|
1310
|
+
disabledTaskTypes: {
|
|
1311
|
+
type: "array",
|
|
1312
|
+
items: { type: "string" },
|
|
1313
|
+
description: "Task types to stop dispatching for this campaign (e.g. ['lead-gen-discovery']). Use when strategically pausing a task type (low conversion, enough leads). Set to [] to re-enable all.",
|
|
1314
|
+
},
|
|
1310
1315
|
},
|
|
1311
1316
|
},
|
|
1312
1317
|
},
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -59,6 +59,15 @@ type TaskResult = {
|
|
|
59
59
|
invitationsAccepted?: number;
|
|
60
60
|
error?: string;
|
|
61
61
|
reason?: string;
|
|
62
|
+
// Token usage tracking
|
|
63
|
+
tokenUsage?: {
|
|
64
|
+
inputTokens: number;
|
|
65
|
+
outputTokens: number;
|
|
66
|
+
cacheReadTokens?: number;
|
|
67
|
+
cacheWriteTokens?: number;
|
|
68
|
+
};
|
|
69
|
+
estimatedCostUsd?: number;
|
|
70
|
+
model?: string;
|
|
62
71
|
};
|
|
63
72
|
|
|
64
73
|
const MIN_POLL_MS = 5000;
|
|
@@ -131,6 +140,98 @@ function extractTaskResultFromWrapper(output: Record<string, unknown>): TaskResu
|
|
|
131
140
|
return null;
|
|
132
141
|
}
|
|
133
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Extract token usage from the OpenClaw --json wrapper's meta field.
|
|
145
|
+
* Returns null if no usage data is available.
|
|
146
|
+
*/
|
|
147
|
+
function extractTokenUsageFromWrapper(
|
|
148
|
+
output: Record<string, unknown>,
|
|
149
|
+
): { usage: TaskResult["tokenUsage"]; model?: string } | null {
|
|
150
|
+
const result = output?.result as Record<string, unknown> | undefined;
|
|
151
|
+
const meta = (result?.meta ?? output?.meta ?? {}) as Record<string, unknown>;
|
|
152
|
+
// OpenClaw 2026.4+ puts token data in meta.agentMeta.lastCallUsage
|
|
153
|
+
const agentMeta = (meta.agentMeta ?? {}) as Record<string, unknown>;
|
|
154
|
+
const lastCall = (agentMeta.lastCallUsage ?? {}) as Record<string, number>;
|
|
155
|
+
const costData = (meta.cost ?? {}) as Record<string, number>;
|
|
156
|
+
const usageData = (meta.usage ?? {}) as Record<string, number>;
|
|
157
|
+
|
|
158
|
+
const inputTokens = lastCall.input ?? costData.inputTokens ?? costData.input ?? usageData.input ?? 0;
|
|
159
|
+
const outputTokens = lastCall.output ?? costData.outputTokens ?? costData.output ?? usageData.output ?? 0;
|
|
160
|
+
|
|
161
|
+
if (inputTokens === 0 && outputTokens === 0) return null;
|
|
162
|
+
|
|
163
|
+
const cacheRead = lastCall.cacheRead ?? usageData.cacheRead ?? costData.cacheReadTokens ?? 0;
|
|
164
|
+
const cacheWrite = lastCall.cacheWrite ?? usageData.cacheWrite ?? costData.cacheWriteTokens ?? 0;
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
usage: {
|
|
168
|
+
inputTokens,
|
|
169
|
+
outputTokens,
|
|
170
|
+
...(cacheRead > 0 ? { cacheReadTokens: cacheRead } : {}),
|
|
171
|
+
...(cacheWrite > 0 ? { cacheWriteTokens: cacheWrite } : {}),
|
|
172
|
+
},
|
|
173
|
+
model: agentMeta.model as string | undefined,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Model pricing per 1M tokens */
|
|
178
|
+
const MODEL_PRICING: Record<string, { input: number; output: number; cacheRead: number }> = {
|
|
179
|
+
"haiku": { input: 1.0, output: 5.0, cacheRead: 0.1 },
|
|
180
|
+
"sonnet": { input: 3.0, output: 15.0, cacheRead: 0.3 },
|
|
181
|
+
"flash": { input: 0.3, output: 2.5, cacheRead: 0.03 },
|
|
182
|
+
"pro": { input: 1.25, output: 10.0, cacheRead: 0.125 },
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
function estimateTaskCost(
|
|
186
|
+
usage: NonNullable<TaskResult["tokenUsage"]>,
|
|
187
|
+
modelSlug?: string | null,
|
|
188
|
+
): number {
|
|
189
|
+
const key = Object.keys(MODEL_PRICING).find((k) => modelSlug?.includes(k)) ?? "haiku";
|
|
190
|
+
const p = MODEL_PRICING[key];
|
|
191
|
+
const cached = usage.cacheReadTokens ?? 0;
|
|
192
|
+
const uncached = Math.max(0, usage.inputTokens - cached);
|
|
193
|
+
return (uncached * p.input + cached * p.cacheRead + usage.outputTokens * p.output) / 1_000_000;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Enrich a TaskResult with token usage from the OpenClaw wrapper output */
|
|
197
|
+
function enrichResultWithTokens(
|
|
198
|
+
result: TaskResult,
|
|
199
|
+
output: Record<string, unknown>,
|
|
200
|
+
modelSlug?: string | null,
|
|
201
|
+
): void {
|
|
202
|
+
if (result.tokenUsage) return; // already enriched (e.g. by lifecycle hook)
|
|
203
|
+
const extracted = extractTokenUsageFromWrapper(output);
|
|
204
|
+
if (!extracted?.usage) return;
|
|
205
|
+
const effectiveModel = extracted.model ?? modelSlug ?? undefined;
|
|
206
|
+
result.tokenUsage = extracted.usage;
|
|
207
|
+
result.model = effectiveModel;
|
|
208
|
+
result.estimatedCostUsd = estimateTaskCost(extracted.usage, effectiveModel);
|
|
209
|
+
console.log(
|
|
210
|
+
`[connector] Token usage: input=${extracted.usage.inputTokens} output=${extracted.usage.outputTokens}` +
|
|
211
|
+
` cacheRead=${extracted.usage.cacheReadTokens ?? 0} cost=$${result.estimatedCostUsd.toFixed(4)}` +
|
|
212
|
+
` model=${effectiveModel ?? "unknown"}`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// LLM provider error detection — mirrors apps/web/src/lib/errors/llm-errors.ts
|
|
217
|
+
// but self-contained since plugin can't import from apps/web.
|
|
218
|
+
const LLM_ERROR_PATTERNS = [
|
|
219
|
+
/authentication_error/i, /invalid.*api.?key/i, /permission_error/i,
|
|
220
|
+
/401.*unauthorized/i, /access_denied.*api/i, /unauthorized.*invalid/i,
|
|
221
|
+
/out of (extra )?usage/i, /insufficient.quota/i, /exceeded.*current quota/i,
|
|
222
|
+
/RESOURCE_EXHAUSTED/i, /credit balance.*too low/i, /exceeded.*spending.*limit/i,
|
|
223
|
+
/billing.*hard.*limit/i, /payment.*required/i,
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
/** Scan text for LLM provider errors (auth, billing). Returns the matched text or null. */
|
|
227
|
+
function detectLlmError(text: string): string | null {
|
|
228
|
+
for (const pattern of LLM_ERROR_PATTERNS) {
|
|
229
|
+
const match = text.match(pattern);
|
|
230
|
+
if (match) return match[0];
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
134
235
|
/** Stderr patterns that are noise (module warnings, not actual errors) */
|
|
135
236
|
const HARMLESS_STDERR_RE = [
|
|
136
237
|
/Cannot find module '@aws-sdk\/client-bedrock'/,
|
|
@@ -348,8 +449,11 @@ async function executeViaWebhook(
|
|
|
348
449
|
|
|
349
450
|
// Webhook completed - lifecycle hook has already posted the result.
|
|
350
451
|
// Return a success indicator so the connector doesn't double-report.
|
|
452
|
+
// Also extract token usage for the safety-net submission.
|
|
453
|
+
const webhookResult = (output.result as TaskResult) ?? { success: true };
|
|
454
|
+
enrichResultWithTokens(webhookResult, output, task.model);
|
|
351
455
|
return {
|
|
352
|
-
result:
|
|
456
|
+
result: webhookResult,
|
|
353
457
|
error: null,
|
|
354
458
|
};
|
|
355
459
|
} catch (err) {
|
|
@@ -430,20 +534,25 @@ async function executeViaExecFile(
|
|
|
430
534
|
// B17 fix: extract it instead of returning the raw wrapper.
|
|
431
535
|
const extracted = extractTaskResultFromWrapper(output);
|
|
432
536
|
if (extracted) {
|
|
537
|
+
enrichResultWithTokens(extracted, output, task.model);
|
|
433
538
|
return { result: extracted, error: null };
|
|
434
539
|
}
|
|
435
540
|
|
|
436
541
|
// Fallback: if output itself looks like a flat TaskResult
|
|
437
542
|
if (output.success !== undefined || output.contactsProcessed !== undefined) {
|
|
438
|
-
|
|
543
|
+
const flat = output as unknown as TaskResult;
|
|
544
|
+
enrichResultWithTokens(flat, output, task.model);
|
|
545
|
+
return { result: flat, error: null };
|
|
439
546
|
}
|
|
440
547
|
|
|
441
|
-
// Last resort: lifecycle hook may have already posted the real result
|
|
442
|
-
|
|
548
|
+
// Last resort: lifecycle hook may have already posted the real result.
|
|
549
|
+
// Don't blindly claim success — if we can't parse a result, something went wrong.
|
|
550
|
+
// The lifecycle hook will report the real result if it ran successfully.
|
|
551
|
+
return { result: { success: false, reason: "no_parseable_result" } as unknown as TaskResult, error: null };
|
|
443
552
|
} catch {
|
|
444
|
-
// Non-JSON output -
|
|
553
|
+
// Non-JSON output - can't determine success without structured data.
|
|
445
554
|
return {
|
|
446
|
-
result: { success:
|
|
555
|
+
result: { success: false, reason: "non_json_output" } as unknown as TaskResult,
|
|
447
556
|
error: null,
|
|
448
557
|
};
|
|
449
558
|
}
|
|
@@ -466,11 +575,14 @@ async function executeViaExecFile(
|
|
|
466
575
|
// B17 fix: extract TaskResult from OpenClaw wrapper (same as happy path)
|
|
467
576
|
const extracted = extractTaskResultFromWrapper(output);
|
|
468
577
|
if (extracted) {
|
|
578
|
+
enrichResultWithTokens(extracted, output, task.model);
|
|
469
579
|
return { result: extracted, error: null };
|
|
470
580
|
}
|
|
471
581
|
// Accept flat { success: true } format
|
|
472
582
|
if (output.success !== undefined || output.contactsProcessed !== undefined) {
|
|
473
|
-
|
|
583
|
+
const flat = output as unknown as TaskResult;
|
|
584
|
+
enrichResultWithTokens(flat, output, task.model);
|
|
585
|
+
return { result: flat, error: null };
|
|
474
586
|
}
|
|
475
587
|
} catch {
|
|
476
588
|
// Not JSON
|
|
@@ -479,8 +591,20 @@ async function executeViaExecFile(
|
|
|
479
591
|
|
|
480
592
|
// Filter known-harmless stderr noise (e.g. @aws-sdk/client-bedrock module warnings)
|
|
481
593
|
const filtered = filterStderr(stderr);
|
|
482
|
-
|
|
483
|
-
|
|
594
|
+
// When execFile fails, err.message is "Command failed: openclaw agent ..." which is useless.
|
|
595
|
+
// Prefer stderr content, then stdout snippet, then exit code - anything more useful than the command string.
|
|
596
|
+
let errorMsg: string;
|
|
597
|
+
if (filtered) {
|
|
598
|
+
errorMsg = filtered;
|
|
599
|
+
} else if (execErr.message && !execErr.message.startsWith("Command failed:")) {
|
|
600
|
+
errorMsg = execErr.message;
|
|
601
|
+
} else {
|
|
602
|
+
// No stderr, message is just the command - build a useful error from what we have
|
|
603
|
+
const exitCode = execErr.code ?? "unknown";
|
|
604
|
+
const stdoutSnippet = stdout ? ` stdout=${stdout.slice(0, 200)}` : "";
|
|
605
|
+
errorMsg = `Agent process exited with code ${exitCode}${stdoutSnippet}`;
|
|
606
|
+
}
|
|
607
|
+
return { result: null, error: errorMsg.slice(0, 500) };
|
|
484
608
|
}
|
|
485
609
|
}
|
|
486
610
|
|
|
@@ -569,6 +693,62 @@ export async function runConnectorLoop(
|
|
|
569
693
|
console.log(`[connector] OpenClaw: ${config.openclawUrl}`);
|
|
570
694
|
}
|
|
571
695
|
|
|
696
|
+
// Proactive LLM key validation on startup: detect bad API keys before wasting tasks.
|
|
697
|
+
// Makes a lightweight check against the LLM provider. Non-fatal — just warns.
|
|
698
|
+
const llmModel = readEnv("OPENCLAW_DEFAULT_MODEL") || readEnv("BEREACH_TASK_MODEL");
|
|
699
|
+
if (llmModel) {
|
|
700
|
+
const isAnthropic = llmModel.includes("anthropic") || llmModel.includes("claude");
|
|
701
|
+
const isGemini = llmModel.includes("gemini") || llmModel.includes("google");
|
|
702
|
+
const anthropicKey = readEnv("ANTHROPIC_API_KEY");
|
|
703
|
+
const geminiKey = readEnv("GEMINI_API_KEY") || readEnv("GOOGLE_API_KEY");
|
|
704
|
+
|
|
705
|
+
if (isAnthropic && anthropicKey) {
|
|
706
|
+
try {
|
|
707
|
+
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
708
|
+
method: "POST",
|
|
709
|
+
headers: {
|
|
710
|
+
"x-api-key": anthropicKey,
|
|
711
|
+
"anthropic-version": "2023-06-01",
|
|
712
|
+
"content-type": "application/json",
|
|
713
|
+
},
|
|
714
|
+
body: JSON.stringify({ model: "claude-haiku-4-5-20241022", max_tokens: 1, messages: [{ role: "user", content: "hi" }] }),
|
|
715
|
+
signal: AbortSignal.timeout(10000),
|
|
716
|
+
});
|
|
717
|
+
if (res.status === 401 || res.status === 403) {
|
|
718
|
+
const body = await res.text().catch(() => "");
|
|
719
|
+
console.error(`[connector] ANTHROPIC_API_KEY is INVALID (HTTP ${res.status}): ${body.slice(0, 200)}`);
|
|
720
|
+
console.error("[connector] Tasks will fail until the API key is updated. Reporting to BeReach API...");
|
|
721
|
+
// Report the error to BeReach API so the circuit breaker can trip
|
|
722
|
+
try {
|
|
723
|
+
await fetch(`${config.apiUrl}/connectors/heartbeat`, {
|
|
724
|
+
method: "POST",
|
|
725
|
+
headers: buildHeaders(config),
|
|
726
|
+
body: JSON.stringify({ llmKeyError: { provider: "anthropic", status: res.status, error: body.slice(0, 200) } }),
|
|
727
|
+
signal: AbortSignal.timeout(5000),
|
|
728
|
+
});
|
|
729
|
+
} catch { /* best effort */ }
|
|
730
|
+
} else {
|
|
731
|
+
console.log(`[connector] Anthropic API key validated (HTTP ${res.status})`);
|
|
732
|
+
}
|
|
733
|
+
} catch (err) {
|
|
734
|
+
console.warn(`[connector] Anthropic key validation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
735
|
+
}
|
|
736
|
+
} else if (isGemini && geminiKey) {
|
|
737
|
+
try {
|
|
738
|
+
const res = await fetch(`https://generativelanguage.googleapis.com/v1/models?key=${geminiKey}`, {
|
|
739
|
+
signal: AbortSignal.timeout(10000),
|
|
740
|
+
});
|
|
741
|
+
if (res.status === 401 || res.status === 403) {
|
|
742
|
+
console.error(`[connector] GEMINI_API_KEY is INVALID (HTTP ${res.status})`);
|
|
743
|
+
} else {
|
|
744
|
+
console.log(`[connector] Gemini API key validated (HTTP ${res.status})`);
|
|
745
|
+
}
|
|
746
|
+
} catch (err) {
|
|
747
|
+
console.warn(`[connector] Gemini key validation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
572
752
|
// Heartbeat runs independently - does NOT touch pollInterval or consecutiveErrors.
|
|
573
753
|
// Otherwise it would neutralize exponential backoff during error recovery.
|
|
574
754
|
// Uses internalAbort so auth failures kill both heartbeat AND poll loops.
|
|
@@ -700,6 +880,17 @@ export async function runConnectorLoop(
|
|
|
700
880
|
console.warn(`[connector] DIAGNOSTIC: Task ${task.id} failed in <10s with 0 tool calls — likely context injection failure or model error`);
|
|
701
881
|
}
|
|
702
882
|
|
|
883
|
+
// Scan output for LLM provider errors (auth/billing) that the gateway
|
|
884
|
+
// may not have flagged via meta.stopReason. If found, force-fail the task
|
|
885
|
+
// so the circuit breaker can detect it on the API side.
|
|
886
|
+
const allText = [error, result?.error, (result as any)?.reason].filter(Boolean).join(" ");
|
|
887
|
+
const llmError = detectLlmError(allText);
|
|
888
|
+
if (llmError && result && result.success !== false) {
|
|
889
|
+
console.warn(`[connector] LLM provider error detected in task ${task.id}: ${llmError}`);
|
|
890
|
+
result.success = false;
|
|
891
|
+
if (!result.error) result.error = `LLM provider error: ${llmError}`;
|
|
892
|
+
}
|
|
893
|
+
|
|
703
894
|
// Always submit from connector as safety net. In webhook mode the lifecycle
|
|
704
895
|
// hook usually reports first, but if it fails silently (network, crash) the
|
|
705
896
|
// result is lost and the task stays "running" forever. The result endpoint
|
package/src/hooks/cache.ts
CHANGED
|
@@ -176,6 +176,7 @@ export async function fetchSnapshot(apiKey: string): Promise<CacheStore> {
|
|
|
176
176
|
sessionMeta: snapshot.sessionMeta ?? null,
|
|
177
177
|
onboardingState: snapshot.onboardingState ?? null,
|
|
178
178
|
recentEvents: snapshot.recentEvents ?? [],
|
|
179
|
+
llmStatus: snapshot.llmStatus ?? null,
|
|
179
180
|
};
|
|
180
181
|
|
|
181
182
|
// Fallback: if snapshot didn't include limits, fetch from dedicated endpoint
|
package/src/hooks/context.ts
CHANGED
|
@@ -584,6 +584,21 @@ function formatLiveStatus(state: SessionState, data: CacheStore, apiKey?: string
|
|
|
584
584
|
|
|
585
585
|
// ── ACTIVE MODE: data-only context for onboarded users ──
|
|
586
586
|
|
|
587
|
+
// LLM provider error warning — show prominently so the agent can inform the user
|
|
588
|
+
if (data.llmStatus) {
|
|
589
|
+
lines.push("### AI Provider Error");
|
|
590
|
+
if (data.llmStatus === "auth") {
|
|
591
|
+
lines.push("Your AI provider credentials are INVALID. Automated campaigns are paused.");
|
|
592
|
+
lines.push("Tell the user to update their API key in their provider's dashboard.");
|
|
593
|
+
} else if (data.llmStatus === "billing") {
|
|
594
|
+
lines.push("Your AI provider credits are EXHAUSTED. Automated campaigns are paused.");
|
|
595
|
+
lines.push("Tell the user to add credits or upgrade their plan with their AI provider.");
|
|
596
|
+
} else {
|
|
597
|
+
lines.push("There is an issue with the AI provider. Automated campaigns may be paused.");
|
|
598
|
+
}
|
|
599
|
+
lines.push("");
|
|
600
|
+
}
|
|
601
|
+
|
|
587
602
|
// Account
|
|
588
603
|
if (data.activeAccount) {
|
|
589
604
|
const a = data.activeAccount;
|
package/src/hooks/enforcement.ts
CHANGED
|
@@ -318,9 +318,15 @@ function createDmGuard() {
|
|
|
318
318
|
async function guardScheduledDm(toolName: string, params: Record<string, unknown>, key: string, state: SessionState) {
|
|
319
319
|
if (toolName !== "bereach_scheduled_message_create") return null;
|
|
320
320
|
|
|
321
|
-
// DM history check
|
|
321
|
+
// DM history check — skip in task mode when prefetch already loaded conversation
|
|
322
|
+
// data server-side. The agent is instructed to skip tool calls when prefetch is
|
|
323
|
+
// present, so dmHistoryChecked will be empty. This is safe because:
|
|
324
|
+
// 1. Prefetch runs the same dedup check server-side before injecting data
|
|
325
|
+
// 2. Drafts (default status) are user-reviewed before sending
|
|
326
|
+
const isDraft = !params.status || params.status === "draft";
|
|
327
|
+
const isTaskMode = !!state.currentTaskMode;
|
|
322
328
|
const dmId = extractDmIdentifier(params);
|
|
323
|
-
if (dmId && !state.dmHistoryChecked.has(dmId)) {
|
|
329
|
+
if (dmId && !state.dmHistoryChecked.has(dmId) && !(isTaskMode && isDraft)) {
|
|
324
330
|
log(`scheduled DM dedup BLOCKED: history not checked for ${dmId}`);
|
|
325
331
|
return {
|
|
326
332
|
blocked: true,
|
package/src/hooks/lifecycle.ts
CHANGED
|
@@ -65,6 +65,82 @@ function parseStructuredResult(text: string): Record<string, unknown> | null {
|
|
|
65
65
|
/** Exported for tests. */
|
|
66
66
|
export { parseStructuredResult as _parseStructuredResult };
|
|
67
67
|
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Token usage extraction & cost estimation
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
/** Model pricing per 1M tokens (hardcoded — plugin can't import from apps/web) */
|
|
73
|
+
const MODEL_PRICING: Record<string, { input: number; output: number; cacheRead: number }> = {
|
|
74
|
+
"haiku": { input: 1.0, output: 5.0, cacheRead: 0.1 },
|
|
75
|
+
"sonnet": { input: 3.0, output: 15.0, cacheRead: 0.3 },
|
|
76
|
+
"flash": { input: 0.3, output: 2.5, cacheRead: 0.03 },
|
|
77
|
+
"pro": { input: 1.25, output: 10.0, cacheRead: 0.125 },
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function estimateTaskCost(
|
|
81
|
+
inputTokens: number,
|
|
82
|
+
outputTokens: number,
|
|
83
|
+
cacheReadTokens: number,
|
|
84
|
+
modelSlug?: string,
|
|
85
|
+
): number {
|
|
86
|
+
const key = Object.keys(MODEL_PRICING).find((k) => modelSlug?.includes(k)) ?? "haiku";
|
|
87
|
+
const p = MODEL_PRICING[key];
|
|
88
|
+
const uncached = Math.max(0, inputTokens - cacheReadTokens);
|
|
89
|
+
return (uncached * p.input + cacheReadTokens * p.cacheRead + outputTokens * p.output) / 1_000_000;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Enrich a task result with token usage and cost estimation from OpenClaw's
|
|
94
|
+
* endCtx.meta. The meta object may contain:
|
|
95
|
+
* - meta.cost: { inputTokens, outputTokens } (preferred)
|
|
96
|
+
* - meta.usage: { input, output, cacheRead, cacheWrite } (alternative)
|
|
97
|
+
*/
|
|
98
|
+
function enrichWithTokenUsage(
|
|
99
|
+
taskResult: Record<string, unknown>,
|
|
100
|
+
endCtx: unknown,
|
|
101
|
+
taskMode: TaskModeInfo,
|
|
102
|
+
): void {
|
|
103
|
+
try {
|
|
104
|
+
const meta = (endCtx as any)?.meta ?? (endCtx as any)?.result?.meta ?? {};
|
|
105
|
+
// OpenClaw provides token data in multiple possible locations:
|
|
106
|
+
// - meta.agentMeta.lastCallUsage (OpenClaw 2026.4+)
|
|
107
|
+
// - meta.cost (older versions / test runner)
|
|
108
|
+
// - meta.usage (alternative format)
|
|
109
|
+
const agentMeta = meta.agentMeta ?? {};
|
|
110
|
+
const lastCall = agentMeta.lastCallUsage ?? {};
|
|
111
|
+
const costData = meta.cost ?? {};
|
|
112
|
+
const usageData = meta.usage ?? {};
|
|
113
|
+
|
|
114
|
+
const inputTokens = lastCall.input ?? costData.inputTokens ?? costData.input ?? usageData.input ?? 0;
|
|
115
|
+
const outputTokens = lastCall.output ?? costData.outputTokens ?? costData.output ?? usageData.output ?? 0;
|
|
116
|
+
|
|
117
|
+
// No token data available — skip enrichment
|
|
118
|
+
if (inputTokens === 0 && outputTokens === 0) {
|
|
119
|
+
log(`token usage: no data in endCtx.meta for ${taskMode.taskId} (keys: ${Object.keys(meta).join(",")})`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const cacheRead = lastCall.cacheRead ?? usageData.cacheRead ?? costData.cacheReadTokens ?? 0;
|
|
124
|
+
const cacheWrite = lastCall.cacheWrite ?? usageData.cacheWrite ?? costData.cacheWriteTokens ?? 0;
|
|
125
|
+
|
|
126
|
+
taskResult.tokenUsage = {
|
|
127
|
+
inputTokens,
|
|
128
|
+
outputTokens,
|
|
129
|
+
...(cacheRead > 0 ? { cacheReadTokens: cacheRead } : {}),
|
|
130
|
+
...(cacheWrite > 0 ? { cacheWriteTokens: cacheWrite } : {}),
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// Model slug from agentMeta (most reliable), env, or task mode
|
|
134
|
+
const modelSlug = agentMeta.model ?? readEnv("BEREACH_TASK_MODEL") ?? readEnv("OPENCLAW_MODEL") ?? undefined;
|
|
135
|
+
taskResult.model = modelSlug;
|
|
136
|
+
taskResult.estimatedCostUsd = estimateTaskCost(inputTokens, outputTokens, cacheRead, modelSlug);
|
|
137
|
+
|
|
138
|
+
log(`token usage for ${taskMode.taskId}: input=${inputTokens} output=${outputTokens} cacheRead=${cacheRead} cacheWrite=${cacheWrite} cost=$${(taskResult.estimatedCostUsd as number).toFixed(4)} model=${modelSlug ?? "unknown"}`);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
log(`token usage extraction failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
68
144
|
// ---------------------------------------------------------------------------
|
|
69
145
|
// Hook registration
|
|
70
146
|
// ---------------------------------------------------------------------------
|
|
@@ -156,6 +232,9 @@ export function registerLifecycleHook(
|
|
|
156
232
|
const shouldSubmit = result || !isExecFileMode;
|
|
157
233
|
const taskResult = result ?? fallbackResult;
|
|
158
234
|
|
|
235
|
+
// Enrich with token usage from OpenClaw meta (if available)
|
|
236
|
+
enrichWithTokenUsage(taskResult, endCtx, taskMode);
|
|
237
|
+
|
|
159
238
|
// Retry with backoff — task results are critical, losing them means
|
|
160
239
|
// the task stays "running" forever and the workflow chain stalls.
|
|
161
240
|
let posted = false;
|
package/src/index.ts
CHANGED
|
@@ -87,10 +87,10 @@ async function autoDetectModels(apiKey: string): Promise<void> {
|
|
|
87
87
|
const currentCreative = data.aiModels?.creative;
|
|
88
88
|
|
|
89
89
|
// Only auto-switch if still on Anthropic defaults (respect manual choices)
|
|
90
|
-
|
|
90
|
+
const isAnthropicDefault =
|
|
91
91
|
currentFast === "anthropic/claude-haiku-4-5" &&
|
|
92
|
-
currentCreative === "anthropic/claude-sonnet-4-6"
|
|
93
|
-
) {
|
|
92
|
+
(currentCreative === "anthropic/claude-haiku-4-5" || currentCreative === "anthropic/claude-sonnet-4-6");
|
|
93
|
+
if (isAnthropicDefault) {
|
|
94
94
|
const patchRes = await fetch(`${API_BASE}/me/settings`, {
|
|
95
95
|
method: "PATCH",
|
|
96
96
|
headers: {
|