@desplega.ai/agent-swarm 1.79.4 → 1.80.1
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/openapi.json +496 -32
- package/package.json +14 -6
- package/src/artifact-sdk/server.ts +2 -1
- package/src/be/db.ts +102 -31
- package/src/be/migrations/063_cost_context_schema_relax.sql +133 -0
- package/src/be/migrations/064_scripts.sql +39 -0
- package/src/be/migrations/065_script_embeddings.sql +7 -0
- package/src/be/pricing-normalize.ts +81 -0
- package/src/be/scripts/db.ts +391 -0
- package/src/be/scripts/embeddings.ts +231 -0
- package/src/be/scripts/maintenance.ts +9 -0
- package/src/be/scripts/typecheck.ts +193 -0
- package/src/be/seed-pricing.ts +293 -0
- package/src/cli.tsx +22 -5
- package/src/commands/artifact.ts +3 -2
- package/src/commands/claude-managed-setup.ts +21 -4
- package/src/commands/codex-login.ts +5 -3
- package/src/commands/onboard.tsx +2 -1
- package/src/commands/runner.ts +663 -246
- package/src/commands/setup.tsx +5 -3
- package/src/hooks/hook.ts +4 -3
- package/src/http/context.ts +6 -2
- package/src/http/index.ts +126 -68
- package/src/http/memory.ts +28 -0
- package/src/http/openapi.ts +1 -0
- package/src/http/page-proxy.ts +2 -1
- package/src/http/route-def.ts +1 -0
- package/src/http/schedules.ts +37 -0
- package/src/http/scripts.ts +381 -0
- package/src/http/session-data.ts +74 -23
- package/src/linear/outbound.ts +9 -2
- package/src/otel-impl.ts +200 -0
- package/src/otel.ts +132 -0
- package/src/providers/claude-adapter.ts +52 -6
- package/src/providers/claude-managed-adapter.ts +43 -17
- package/src/providers/claude-managed-pricing.ts +34 -0
- package/src/providers/codex-adapter.ts +38 -27
- package/src/providers/codex-models.ts +22 -3
- package/src/providers/devin-adapter.ts +11 -0
- package/src/providers/opencode-adapter.ts +31 -7
- package/src/providers/pi-mono-adapter.ts +39 -7
- package/src/providers/pricing-sources.md +52 -0
- package/src/providers/swarm-events-shared.ts +8 -4
- package/src/providers/types.ts +33 -10
- package/src/scripts-runtime/ctx.ts +23 -0
- package/src/scripts-runtime/eval-harness.ts +39 -0
- package/src/scripts-runtime/executors/native.ts +229 -0
- package/src/scripts-runtime/executors/registry.ts +16 -0
- package/src/scripts-runtime/executors/types.ts +63 -0
- package/src/scripts-runtime/extract-signature.ts +81 -0
- package/src/scripts-runtime/import-allowlist.ts +109 -0
- package/src/scripts-runtime/loader.ts +96 -0
- package/src/scripts-runtime/redacted.ts +48 -0
- package/src/scripts-runtime/sdk-allowlist.ts +29 -0
- package/src/scripts-runtime/stdlib/fetch.ts +46 -0
- package/src/scripts-runtime/stdlib/glob.ts +8 -0
- package/src/scripts-runtime/stdlib/grep.ts +34 -0
- package/src/scripts-runtime/stdlib/index.ts +16 -0
- package/src/scripts-runtime/stdlib/table.ts +17 -0
- package/src/scripts-runtime/swarm-config.ts +35 -0
- package/src/scripts-runtime/swarm-sdk.ts +197 -0
- package/src/scripts-runtime/types/stdlib.d.ts +104 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +86 -0
- package/src/server.ts +18 -0
- package/src/tests/api-key.test.ts +33 -0
- package/src/tests/claude-managed-adapter.test.ts +17 -3
- package/src/tests/claude-managed-setup.test.ts +10 -1
- package/src/tests/codex-adapter.test.ts +20 -19
- package/src/tests/codex-login.test.ts +1 -1
- package/src/tests/context-snapshot.test.ts +2 -2
- package/src/tests/context-window.test.ts +65 -1
- package/src/tests/devin-adapter.test.ts +2 -0
- package/src/tests/http/context-routes.test.ts +161 -0
- package/src/tests/linear-outbound-sync.test.ts +109 -0
- package/src/tests/mcp-tools.test.ts +69 -0
- package/src/tests/migration-063-schema-relax.test.ts +109 -0
- package/src/tests/opencode-adapter.test.ts +146 -1
- package/src/tests/otel-impl-secret-scrubbing.test.ts +33 -0
- package/src/tests/pages-view-count.test.ts +30 -5
- package/src/tests/providers/codex-cost.test.ts +18 -0
- package/src/tests/providers/opencode-cost.test.ts +74 -0
- package/src/tests/providers/pi-cost.test.ts +128 -0
- package/src/tests/redacted.test.ts +29 -0
- package/src/tests/runner-tool-spans.test.ts +268 -0
- package/src/tests/script-executor-conformance.test.ts +142 -0
- package/src/tests/script-executor-registry.test.ts +17 -0
- package/src/tests/scripts-db.test.ts +329 -0
- package/src/tests/scripts-embeddings.test.ts +291 -0
- package/src/tests/scripts-extract-signature.test.ts +47 -0
- package/src/tests/scripts-http.test.ts +350 -0
- package/src/tests/scripts-import-allowlist.test.ts +55 -0
- package/src/tests/scripts-mcp-e2e.test.ts +269 -0
- package/src/tests/scripts-runtime-secret-egress.test.ts +44 -0
- package/src/tests/scripts-runtime.test.ts +289 -0
- package/src/tests/sdk-allowlist.test.ts +59 -0
- package/src/tests/secret-scrubber.test.ts +54 -1
- package/src/tests/session-costs-codex-recompute.test.ts +35 -22
- package/src/tests/session-costs-model-key-normalize.test.ts +271 -0
- package/src/tests/session-costs-recompute-all-providers.test.ts +170 -0
- package/src/tests/store-progress-cost.test.ts +6 -1
- package/src/tests/swarm-config.test.ts +38 -0
- package/src/tests/tool-annotations.test.ts +2 -2
- package/src/tests/tool-call-progress.test.ts +30 -0
- package/src/tests/workflow-e2e.test.ts +218 -0
- package/src/tests/workflow-executors.test.ts +32 -2
- package/src/tests/workflow-input-redaction.test.ts +232 -0
- package/src/tests/workflow-swarm-script.test.ts +273 -0
- package/src/tools/memory-rate.ts +2 -1
- package/src/tools/script-common.ts +88 -0
- package/src/tools/script-delete.ts +35 -0
- package/src/tools/script-query-types.ts +37 -0
- package/src/tools/script-run.ts +43 -0
- package/src/tools/script-search.ts +32 -0
- package/src/tools/script-upsert.ts +43 -0
- package/src/tools/store-progress.ts +16 -60
- package/src/tools/tool-config.ts +7 -0
- package/src/tools/utils.ts +65 -12
- package/src/types.ts +122 -10
- package/src/utils/api-key.ts +28 -0
- package/src/utils/context-window.ts +104 -4
- package/src/utils/page-session.ts +8 -6
- package/src/utils/secret-scrubber.ts +29 -1
- package/src/workflows/engine.ts +12 -4
- package/src/workflows/executors/index.ts +1 -0
- package/src/workflows/executors/registry.ts +2 -0
- package/src/workflows/executors/script.ts +12 -1
- package/src/workflows/executors/swarm-script.ts +170 -0
- package/src/workflows/input.ts +65 -0
- package/src/workflows/recovery.ts +31 -3
- package/src/workflows/resume.ts +43 -5
|
@@ -66,6 +66,11 @@ import {
|
|
|
66
66
|
type WebSearchItem,
|
|
67
67
|
} from "@openai/codex-sdk";
|
|
68
68
|
import { buildRatingsFromLlm, fetchRetrievalsForTask, postRatings } from "../be/memory/raters/llm";
|
|
69
|
+
import {
|
|
70
|
+
CONTEXT_FORMULA,
|
|
71
|
+
clampContextPercent,
|
|
72
|
+
computeContextUsedUnified,
|
|
73
|
+
} from "../utils/context-window";
|
|
69
74
|
import { summarizeSession as runSummarize } from "../utils/internal-ai";
|
|
70
75
|
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
71
76
|
import { type CodexAgentsMdHandle, writeCodexAgentsMd } from "./codex-agents-md";
|
|
@@ -523,6 +528,11 @@ class CodexSession implements ProviderSession {
|
|
|
523
528
|
const inputTokens = usage?.input_tokens ?? 0;
|
|
524
529
|
const cachedInputTokens = usage?.cached_input_tokens ?? 0;
|
|
525
530
|
const outputTokens = usage?.output_tokens ?? 0;
|
|
531
|
+
// Phase 6: Codex SDK surfaces `reasoning_output_tokens` separately from
|
|
532
|
+
// `output_tokens` for reasoning models (gpt-5.3-codex, gpt-5.4 thinking).
|
|
533
|
+
// Pre-fix this number was read into `lastUsage` but never reached
|
|
534
|
+
// `CostData`, so reasoning-heavy sessions silently under-billed.
|
|
535
|
+
const reasoningOutputTokens = usage?.reasoning_output_tokens ?? 0;
|
|
526
536
|
return {
|
|
527
537
|
// Runner overrides with its own session id.
|
|
528
538
|
sessionId: "",
|
|
@@ -540,9 +550,12 @@ class CodexSession implements ProviderSession {
|
|
|
540
550
|
),
|
|
541
551
|
inputTokens,
|
|
542
552
|
outputTokens,
|
|
553
|
+
reasoningOutputTokens,
|
|
543
554
|
cacheReadTokens: cachedInputTokens,
|
|
544
|
-
//
|
|
545
|
-
|
|
555
|
+
// Phase 6: undefined (NOT 0). Codex SDK can't honestly report cache
|
|
556
|
+
// writes; leaving it undefined preserves that distinction in the DB
|
|
557
|
+
// instead of mixing genuine zeros with "unknown".
|
|
558
|
+
cacheWriteTokens: undefined,
|
|
546
559
|
durationMs: Date.now() - this.startedAt,
|
|
547
560
|
numTurns: this.numTurns,
|
|
548
561
|
model: this.resolvedModel,
|
|
@@ -760,36 +773,34 @@ class CodexSession implements ProviderSession {
|
|
|
760
773
|
case "turn.completed": {
|
|
761
774
|
this.lastUsage = event.usage;
|
|
762
775
|
if (event.usage) {
|
|
763
|
-
//
|
|
764
|
-
//
|
|
765
|
-
//
|
|
766
|
-
//
|
|
767
|
-
// the model's context window, even though no single model call did.
|
|
776
|
+
// Phase 9: switch from the codex-specific "peak proxy" formula
|
|
777
|
+
// (`uncached_input + output`) to the unified
|
|
778
|
+
// `input + cache_read + cache_create + output` so cross-provider
|
|
779
|
+
// percent comparisons are meaningful.
|
|
768
780
|
//
|
|
769
|
-
//
|
|
770
|
-
//
|
|
771
|
-
//
|
|
772
|
-
//
|
|
773
|
-
//
|
|
774
|
-
//
|
|
775
|
-
//
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
// expecting an integer percent, so a 0-1 fraction would render as
|
|
786
|
-
// "0%" instead of e.g. "40%".
|
|
781
|
+
// Note: Codex's `input_tokens` already includes cached_input_tokens
|
|
782
|
+
// (it's the TOTAL across the turn — see the longer comment that
|
|
783
|
+
// used to live here, preserved in git history). We therefore pass
|
|
784
|
+
// `cacheReadTokens: 0` to avoid double-counting the cached portion.
|
|
785
|
+
// The trade-off the old comment flagged is still real — a chatty
|
|
786
|
+
// turn can over-report because `input_tokens` is the SUM across
|
|
787
|
+
// every model call in the turn — but having the SAME formula
|
|
788
|
+
// everywhere wins over the local optimum. Clamp catches the
|
|
789
|
+
// chatty-turn overshoot at 100%. Old rows tagged 'peak-proxy'
|
|
790
|
+
// remain in `task_context_snapshots`; the UI surfaces both.
|
|
791
|
+
const contextUsed = computeContextUsedUnified({
|
|
792
|
+
inputTokens: event.usage.input_tokens,
|
|
793
|
+
cacheReadTokens: 0,
|
|
794
|
+
cacheCreateTokens: 0,
|
|
795
|
+
outputTokens: event.usage.output_tokens,
|
|
796
|
+
});
|
|
787
797
|
this.emit({
|
|
788
798
|
type: "context_usage",
|
|
789
|
-
contextUsedTokens:
|
|
799
|
+
contextUsedTokens: contextUsed,
|
|
790
800
|
contextTotalTokens: this.contextWindow,
|
|
791
|
-
contextPercent:
|
|
801
|
+
contextPercent: clampContextPercent(contextUsed, this.contextWindow) ?? 0,
|
|
792
802
|
outputTokens: event.usage.output_tokens,
|
|
803
|
+
contextFormula: CONTEXT_FORMULA,
|
|
793
804
|
});
|
|
794
805
|
}
|
|
795
806
|
break;
|
|
@@ -126,12 +126,22 @@ export const CODEX_MODEL_PRICING: Record<CodexModel, CodexModelPricing> = {
|
|
|
126
126
|
},
|
|
127
127
|
};
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Phase 6 — one-warning-per-process tracking so unknown models log once
|
|
131
|
+
* instead of spamming the worker log on every turn.
|
|
132
|
+
*/
|
|
133
|
+
const _warnedUnknownCodexModels = new Set<string>();
|
|
134
|
+
|
|
129
135
|
/**
|
|
130
136
|
* Compute USD cost from a Codex `Usage` payload. The Codex SDK reports
|
|
131
137
|
* `input_tokens` as the TOTAL input fed to the model across the turn (cached
|
|
132
138
|
* + uncached), so we subtract `cached_input_tokens` before billing the
|
|
133
|
-
* uncached portion at the full rate.
|
|
134
|
-
*
|
|
139
|
+
* uncached portion at the full rate.
|
|
140
|
+
*
|
|
141
|
+
* Phase 6: returns 0 for unknown models AND logs a one-time warning, so an
|
|
142
|
+
* operator running `MODEL_OVERRIDE=gpt-future-2027` notices that the worker
|
|
143
|
+
* is silently dropping cost. The server-side recompute path (Phase 2) tags
|
|
144
|
+
* such rows `costSource='unpriced'`, which surfaces as a yellow UI badge.
|
|
135
145
|
*/
|
|
136
146
|
export function computeCodexCostUsd(
|
|
137
147
|
model: string,
|
|
@@ -140,7 +150,16 @@ export function computeCodexCostUsd(
|
|
|
140
150
|
outputTokens: number,
|
|
141
151
|
): number {
|
|
142
152
|
const pricing = CODEX_MODEL_PRICING[model as CodexModel];
|
|
143
|
-
if (!pricing)
|
|
153
|
+
if (!pricing) {
|
|
154
|
+
if (!_warnedUnknownCodexModels.has(model)) {
|
|
155
|
+
_warnedUnknownCodexModels.add(model);
|
|
156
|
+
console.warn(
|
|
157
|
+
`[codex] unpriced model ${JSON.stringify(model)} — adapter cost will report $0; ` +
|
|
158
|
+
"server-side recompute will tag costSource='unpriced' if the pricing table has no rows.",
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
144
163
|
const uncachedInput = Math.max(0, inputTokens - cachedInputTokens);
|
|
145
164
|
const inputCost = (uncachedInput / 1_000_000) * pricing.inputPerMillion;
|
|
146
165
|
const cachedCost = (cachedInputTokens / 1_000_000) * pricing.cachedInputPerMillion;
|
|
@@ -279,6 +279,12 @@ class DevinSession implements ProviderSession {
|
|
|
279
279
|
if (this.settled || this.aborted) return;
|
|
280
280
|
this.pollCount += 1;
|
|
281
281
|
|
|
282
|
+
// Phase 8: Devin's session API does NOT report per-poll context-window
|
|
283
|
+
// info (the model is fully managed by Devin). We deliberately don't emit
|
|
284
|
+
// a synthetic `context_usage` event here — faking one with `contextUsedTokens=0`
|
|
285
|
+
// would be misleading. `peakContextTokens` stays null for devin tasks,
|
|
286
|
+
// which the UI surfaces as "not available" rather than "0".
|
|
287
|
+
|
|
282
288
|
let response: DevinSessionResponse;
|
|
283
289
|
try {
|
|
284
290
|
response = await getSession(this.orgId, this.devinApiKey, this._sessionId!);
|
|
@@ -788,6 +794,11 @@ class DevinSession implements ProviderSession {
|
|
|
788
794
|
numTurns: this.pollCount,
|
|
789
795
|
model: "devin",
|
|
790
796
|
isError,
|
|
797
|
+
// Phase 3 — tag CostData so the API recompute path engages. Devin's
|
|
798
|
+
// pricing is ACU-based (one row under `provider='devin', model='*',
|
|
799
|
+
// token_class='acu'`); the harness USD value above is already correct,
|
|
800
|
+
// but tagging the row exposes its source to the UI badge.
|
|
801
|
+
provider: "devin",
|
|
791
802
|
};
|
|
792
803
|
}
|
|
793
804
|
}
|
|
@@ -12,7 +12,11 @@ import { existsSync, mkdirSync } from "node:fs";
|
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import type { AssistantMessage, Config, Event as OpencodeEvent } from "@opencode-ai/sdk";
|
|
14
14
|
import { createOpencode } from "@opencode-ai/sdk";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
CONTEXT_FORMULA,
|
|
17
|
+
clampContextPercent,
|
|
18
|
+
getContextWindowSize,
|
|
19
|
+
} from "../utils/context-window";
|
|
16
20
|
import { validateOpencodeCredentials } from "../utils/credentials";
|
|
17
21
|
import { fetchInstalledMcpServers } from "../utils/mcp-server-fetcher";
|
|
18
22
|
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
@@ -104,7 +108,7 @@ function resolvePluginPath(): string {
|
|
|
104
108
|
return join(import.meta.dir, "../../plugin/opencode-plugins/agent-swarm.ts");
|
|
105
109
|
}
|
|
106
110
|
|
|
107
|
-
class OpencodeSession implements ProviderSession {
|
|
111
|
+
export class OpencodeSession implements ProviderSession {
|
|
108
112
|
private _sessionId: string;
|
|
109
113
|
private listeners: Array<(event: ProviderEvent) => void> = [];
|
|
110
114
|
// Buffer for events emitted before any listener is attached.
|
|
@@ -115,6 +119,7 @@ class OpencodeSession implements ProviderSession {
|
|
|
115
119
|
// leaving agent_tasks.provider/.model NULL. Buffer + flush on first attach.
|
|
116
120
|
private pendingEvents: ProviderEvent[] = [];
|
|
117
121
|
private completionResolve!: (result: ProviderResult) => void;
|
|
122
|
+
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: reserved for future error-propagation paths; symmetric with completionResolve.
|
|
118
123
|
private completionReject!: (err: Error) => void;
|
|
119
124
|
private completionPromise: Promise<ProviderResult>;
|
|
120
125
|
private server: { url: string; close(): void };
|
|
@@ -237,6 +242,15 @@ class OpencodeSession implements ProviderSession {
|
|
|
237
242
|
case "message.updated": {
|
|
238
243
|
const msg = ev.properties.info;
|
|
239
244
|
if (!isAssistantMessage(msg) || msg.sessionID !== this._sessionId) break;
|
|
245
|
+
// Phase 9 fix: opencode fires `message.updated` repeatedly during a single
|
|
246
|
+
// assistant turn (streaming text deltas, tool transitions, etc.) and only
|
|
247
|
+
// populates `tokens`/`cost` on the FINAL update once `time.completed` is
|
|
248
|
+
// set. Accumulating on every event would either no-op (zero tokens) or —
|
|
249
|
+
// if opencode ever back-fills intermediate snapshots — multi-count. Gate
|
|
250
|
+
// the accumulator AND the context emit on the finalized signal so both
|
|
251
|
+
// paths see the same canonical "this turn is done" moment.
|
|
252
|
+
const messageFinalized = msg.time?.completed != null;
|
|
253
|
+
if (!messageFinalized) break;
|
|
240
254
|
// Accumulate cost from each completed assistant message ("step")
|
|
241
255
|
this.totalCostUsd += msg.cost;
|
|
242
256
|
this.inputTokens += msg.tokens?.input ?? 0;
|
|
@@ -247,21 +261,31 @@ class OpencodeSession implements ProviderSession {
|
|
|
247
261
|
if (!this.model && msg.modelID) this.model = msg.modelID;
|
|
248
262
|
|
|
249
263
|
// Emit context_usage so the runner can POST /api/tasks/:id/context
|
|
250
|
-
// (drives the dashboard's context-usage progress bar)
|
|
251
|
-
//
|
|
264
|
+
// (drives the dashboard's context-usage progress bar). The runner-side
|
|
265
|
+
// throttle (CONTEXT_THROTTLE_MS = 30s) means the FIRST emit wins for any
|
|
266
|
+
// short task — so this MUST carry real numbers, not the zero-tokens
|
|
267
|
+
// placeholder opencode sends on intermediate streaming updates. The
|
|
268
|
+
// `time.completed` gate above (in the accumulator block) guarantees we
|
|
269
|
+
// only land here for finalized messages.
|
|
252
270
|
const turnInput = msg.tokens?.input ?? 0;
|
|
253
271
|
const turnOutput = msg.tokens?.output ?? 0;
|
|
254
272
|
const turnCacheRead = msg.tokens?.cache?.read ?? 0;
|
|
255
273
|
const turnCacheWrite = msg.tokens?.cache?.write ?? 0;
|
|
256
|
-
|
|
274
|
+
// Phase 8 + Phase 9: unified `input + cache + output` formula instead
|
|
275
|
+
// of the previous `input + cache_read + cache_write` (which omitted
|
|
276
|
+
// output and slightly mis-counted vs every other adapter).
|
|
277
|
+
const contextUsed = turnInput + turnCacheRead + turnCacheWrite + turnOutput;
|
|
257
278
|
const contextTotal = getContextWindowSize(this.model || msg.modelID || "default");
|
|
258
|
-
if (contextTotal > 0) {
|
|
279
|
+
if (contextTotal > 0 && contextUsed > 0) {
|
|
259
280
|
this.emit({
|
|
260
281
|
type: "context_usage",
|
|
261
282
|
contextUsedTokens: contextUsed,
|
|
262
283
|
contextTotalTokens: contextTotal,
|
|
263
|
-
|
|
284
|
+
// Phase 8: clamp so a turn that briefly overshoots (e.g. due to
|
|
285
|
+
// a stale total) doesn't render as a 130% gauge in the UI.
|
|
286
|
+
contextPercent: clampContextPercent(contextUsed, contextTotal) ?? 0,
|
|
264
287
|
outputTokens: turnOutput,
|
|
288
|
+
contextFormula: CONTEXT_FORMULA,
|
|
265
289
|
});
|
|
266
290
|
}
|
|
267
291
|
break;
|
|
@@ -264,7 +264,7 @@ function cleanupAgentsMdSymlink(cwd: string): void {
|
|
|
264
264
|
}
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
-
class PiMonoSession implements ProviderSession {
|
|
267
|
+
export class PiMonoSession implements ProviderSession {
|
|
268
268
|
private listeners: Array<(event: ProviderEvent) => void> = [];
|
|
269
269
|
private eventQueue: ProviderEvent[] = [];
|
|
270
270
|
private _sessionId: string | undefined;
|
|
@@ -275,6 +275,14 @@ class PiMonoSession implements ProviderSession {
|
|
|
275
275
|
private logFileHandle: ReturnType<ReturnType<typeof Bun.file>["writer"]>;
|
|
276
276
|
/** Track last emitted message text to avoid duplicates across turns */
|
|
277
277
|
private lastEmittedMessage = "";
|
|
278
|
+
/** Phase 7: wallclock start so we can populate `durationMs` on the cost row. */
|
|
279
|
+
private sessionStartedAt: number = Date.now();
|
|
280
|
+
/**
|
|
281
|
+
* Phase 7: previous output-token total — used to derive per-turn delta for
|
|
282
|
+
* `context_usage.outputTokens` since pi-ai's `getContextUsage()` doesn't
|
|
283
|
+
* surface it directly.
|
|
284
|
+
*/
|
|
285
|
+
private prevOutputTokens = 0;
|
|
278
286
|
|
|
279
287
|
constructor(agentSession: AgentSession, config: ProviderSessionConfig, createdSymlink: boolean) {
|
|
280
288
|
this.agentSession = agentSession;
|
|
@@ -282,6 +290,7 @@ class PiMonoSession implements ProviderSession {
|
|
|
282
290
|
this.createdSymlink = createdSymlink;
|
|
283
291
|
this.logFileHandle = Bun.file(config.logFile).writer();
|
|
284
292
|
this._sessionId = agentSession.sessionId;
|
|
293
|
+
this.sessionStartedAt = Date.now();
|
|
285
294
|
|
|
286
295
|
// Emit session_init immediately
|
|
287
296
|
this.emit({ type: "session_init", sessionId: this._sessionId, provider: "pi" });
|
|
@@ -293,6 +302,18 @@ class PiMonoSession implements ProviderSession {
|
|
|
293
302
|
this.completionPromise = this.runSession();
|
|
294
303
|
}
|
|
295
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Canonical model slug for downstream reporting (latestModel, raw_log envelopes).
|
|
307
|
+
* Composes `${provider}/${id}` from the resolved pi-ai model so the UI snapshot
|
|
308
|
+
* lookup matches (e.g. `openrouter/deepseek/deepseek-v4-flash`). Falls back to
|
|
309
|
+
* the configured model string if the session didn't resolve one.
|
|
310
|
+
*/
|
|
311
|
+
private reportedModel(): string {
|
|
312
|
+
const m = this.agentSession.model;
|
|
313
|
+
if (m) return `${m.provider}/${m.id}`;
|
|
314
|
+
return this.config.model;
|
|
315
|
+
}
|
|
316
|
+
|
|
296
317
|
private emit(event: ProviderEvent): void {
|
|
297
318
|
// Scrub secrets from raw_log / raw_stderr content before egress (log file
|
|
298
319
|
// write, listener dispatch, downstream session-logs push + pretty-print).
|
|
@@ -329,7 +350,7 @@ class PiMonoSession implements ProviderSession {
|
|
|
329
350
|
.trim()
|
|
330
351
|
: String(msg.content || "").trim();
|
|
331
352
|
if (text && text !== this.lastEmittedMessage) {
|
|
332
|
-
const model = this.
|
|
353
|
+
const model = this.reportedModel();
|
|
333
354
|
this.emit({
|
|
334
355
|
type: "raw_log",
|
|
335
356
|
content: JSON.stringify({
|
|
@@ -344,21 +365,30 @@ class PiMonoSession implements ProviderSession {
|
|
|
344
365
|
this.lastEmittedMessage = text;
|
|
345
366
|
}
|
|
346
367
|
}
|
|
347
|
-
// Emit context_usage for dashboard tracking
|
|
368
|
+
// Emit context_usage for dashboard tracking.
|
|
369
|
+
// Phase 7: derive `outputTokens` from `SessionStats` delta (pi-ai's
|
|
370
|
+
// `getContextUsage()` doesn't expose per-turn output tokens, but the
|
|
371
|
+
// session-stats counter is monotonic so a delta is correct).
|
|
348
372
|
const usage = this.agentSession.getContextUsage();
|
|
349
373
|
if (usage && usage.tokens != null) {
|
|
374
|
+
const stats = this.agentSession.getSessionStats();
|
|
375
|
+
const currOutput = stats?.tokens?.output ?? 0;
|
|
376
|
+
const outputDelta = Math.max(0, currOutput - this.prevOutputTokens);
|
|
377
|
+
this.prevOutputTokens = currOutput;
|
|
350
378
|
this.emit({
|
|
351
379
|
type: "context_usage",
|
|
352
380
|
contextUsedTokens: usage.tokens,
|
|
353
381
|
contextTotalTokens: usage.contextWindow,
|
|
354
382
|
contextPercent: usage.percent ?? 0,
|
|
355
|
-
outputTokens:
|
|
383
|
+
outputTokens: outputDelta,
|
|
384
|
+
// Phase 9: pi-ai owns the formula — we just relay its number.
|
|
385
|
+
contextFormula: "pi-delegated",
|
|
356
386
|
});
|
|
357
387
|
}
|
|
358
388
|
break;
|
|
359
389
|
}
|
|
360
390
|
case "tool_execution_start": {
|
|
361
|
-
const model = this.
|
|
391
|
+
const model = this.reportedModel();
|
|
362
392
|
this.emit({
|
|
363
393
|
type: "raw_log",
|
|
364
394
|
content: JSON.stringify({
|
|
@@ -489,9 +519,11 @@ class PiMonoSession implements ProviderSession {
|
|
|
489
519
|
outputTokens: stats.tokens.output,
|
|
490
520
|
cacheReadTokens: stats.tokens.cacheRead,
|
|
491
521
|
cacheWriteTokens: stats.tokens.cacheWrite,
|
|
492
|
-
|
|
522
|
+
// Phase 7: real wallclock duration; pi-ai SessionStats doesn't carry
|
|
523
|
+
// one so we track it on this adapter instance.
|
|
524
|
+
durationMs: Date.now() - this.sessionStartedAt,
|
|
493
525
|
numTurns: stats.userMessages + stats.assistantMessages,
|
|
494
|
-
model: this.
|
|
526
|
+
model: this.reportedModel(),
|
|
495
527
|
isError: false,
|
|
496
528
|
provider: "pi",
|
|
497
529
|
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Pricing sources
|
|
2
|
+
|
|
3
|
+
This page lists the sources that feed the `pricing` table at server boot.
|
|
4
|
+
Operators bumping a rate by hand should also update this file.
|
|
5
|
+
|
|
6
|
+
## Primary: vendored models.dev snapshot
|
|
7
|
+
|
|
8
|
+
- **Path**: `ui/src/lib/modelsdev-cache.json`
|
|
9
|
+
- **Loaded by**: `src/be/seed-pricing.ts` → `seedPricingFromModelsDev()`,
|
|
10
|
+
called from `src/server.ts` after `initDb`.
|
|
11
|
+
- **Projection rules** (see the same module for code-level detail):
|
|
12
|
+
- Anthropic models → rows under `provider='claude'` AND `provider='claude-managed'`.
|
|
13
|
+
Shortnames (`opus`, `sonnet`, `haiku`) ALSO get rows keyed by the current
|
|
14
|
+
default full id (e.g. `opus → claude-opus-4-7`). Pi-mono uses the same
|
|
15
|
+
shortname forms, so they're projected under `provider='pi'` as well.
|
|
16
|
+
- OpenAI models → rows under `provider='codex'`.
|
|
17
|
+
- OpenRouter models → rows under `provider='opencode'`. Any `google/...`
|
|
18
|
+
row additionally gets projected under `provider='gemini'` (both the
|
|
19
|
+
stripped name and the full `google/...` id) so internal-ai callers find
|
|
20
|
+
a hit either way.
|
|
21
|
+
|
|
22
|
+
- **Refresh procedure** (the only place to update the snapshot):
|
|
23
|
+
- Run `bun run scripts/refresh-modelsdev-pricing.ts` (Phase 2 — adds the
|
|
24
|
+
script). It fetches the latest snapshot from models.dev, diffs against
|
|
25
|
+
the vendored copy, prints a summary, and writes the new file.
|
|
26
|
+
- Commit the regenerated `modelsdev-cache.json` together with a bump
|
|
27
|
+
note in the PR description.
|
|
28
|
+
|
|
29
|
+
## Manual overrides
|
|
30
|
+
|
|
31
|
+
Two cost components models.dev doesn't carry are encoded in
|
|
32
|
+
`MANUAL_PRICING_OVERRIDES` inside `src/be/seed-pricing.ts`:
|
|
33
|
+
|
|
34
|
+
| Provider | Model | Token class | Rate | Source | Verified |
|
|
35
|
+
|------------------|-------|----------------|--------------|---------------------------------------------------------------------------------|------------|
|
|
36
|
+
| `claude-managed` | `*` | `runtime_hour` | $0.08 / hour | <https://docs.claude.com/en/api/agent-sdk/managed-runtime#pricing> | 2026-04-28 |
|
|
37
|
+
| `devin` | `*` | `acu` | $2.25 / ACU | <https://devin.ai/pricing> | 2026-04-28 |
|
|
38
|
+
|
|
39
|
+
The `pricePerMillionUsd` column carries these as `rate * 1_000_000` so the
|
|
40
|
+
same schema fits — the adapter scales by the underlying unit (hours / ACUs),
|
|
41
|
+
not by tokens. The unit convention is specific to those `token_class` values.
|
|
42
|
+
|
|
43
|
+
## When a model is missing
|
|
44
|
+
|
|
45
|
+
If `POST /api/session-costs` arrives with a `(provider, model)` pair that has
|
|
46
|
+
no input/output pricing rows at the lookup time, the row is persisted with
|
|
47
|
+
`costSource='unpriced'` (rather than 'harness'). The UI surfaces this as a
|
|
48
|
+
yellow badge.
|
|
49
|
+
|
|
50
|
+
To fix: either add the model to `modelsdev-cache.json` (preferred — the
|
|
51
|
+
upstream snapshot probably needs refreshing) or add a manual override row via
|
|
52
|
+
the existing admin route `POST /api/pricing`.
|
|
@@ -167,8 +167,10 @@ export function createSwarmEventHandler(
|
|
|
167
167
|
|
|
168
168
|
const progressContextUsage = (event: {
|
|
169
169
|
contextUsedTokens: number;
|
|
170
|
-
|
|
171
|
-
|
|
170
|
+
// Migration 063: nullable for adapters that can't resolve a window.
|
|
171
|
+
contextTotalTokens: number | null;
|
|
172
|
+
contextPercent: number | null;
|
|
173
|
+
contextFormula?: string;
|
|
172
174
|
}): void => {
|
|
173
175
|
if (opts.taskId && shouldRun("context-progress", CONTEXT_THROTTLE_MS)) {
|
|
174
176
|
fireAndForget(`${opts.apiUrl}/api/tasks/${encodeURIComponent(opts.taskId)}/context`, {
|
|
@@ -178,8 +180,9 @@ export function createSwarmEventHandler(
|
|
|
178
180
|
eventType: "progress",
|
|
179
181
|
sessionId: sessionId ?? `${opts.sessionIdFallbackPrefix ?? "session"}-${opts.taskId}`,
|
|
180
182
|
contextUsedTokens: event.contextUsedTokens,
|
|
181
|
-
contextTotalTokens: event.contextTotalTokens,
|
|
182
|
-
contextPercent: event.contextPercent,
|
|
183
|
+
contextTotalTokens: event.contextTotalTokens ?? undefined,
|
|
184
|
+
contextPercent: event.contextPercent ?? undefined,
|
|
185
|
+
contextFormula: event.contextFormula,
|
|
183
186
|
}),
|
|
184
187
|
});
|
|
185
188
|
}
|
|
@@ -239,6 +242,7 @@ export function createSwarmEventHandler(
|
|
|
239
242
|
contextUsedTokens: event.contextUsedTokens,
|
|
240
243
|
contextTotalTokens: event.contextTotalTokens,
|
|
241
244
|
contextPercent: event.contextPercent,
|
|
245
|
+
contextFormula: event.contextFormula,
|
|
242
246
|
});
|
|
243
247
|
break;
|
|
244
248
|
}
|
package/src/providers/types.ts
CHANGED
|
@@ -7,18 +7,30 @@ export interface CostData {
|
|
|
7
7
|
inputTokens?: number;
|
|
8
8
|
outputTokens?: number;
|
|
9
9
|
cacheReadTokens?: number;
|
|
10
|
+
/**
|
|
11
|
+
* Migration 063: undefined means "the harness can't report this" (e.g. the
|
|
12
|
+
* Codex SDK has no cache-write field). Zero is reserved for "really zero".
|
|
13
|
+
*/
|
|
10
14
|
cacheWriteTokens?: number;
|
|
15
|
+
/** Migration 063: codex reasoning_output_tokens (and similar) for reasoning models. */
|
|
16
|
+
reasoningOutputTokens?: number;
|
|
17
|
+
/** Migration 063: claude extended-thinking tokens from CLI's `usage.thinking_input_tokens`. */
|
|
18
|
+
thinkingTokens?: number;
|
|
11
19
|
durationMs: number;
|
|
12
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Migration 063: nullable — some adapters (claude when `num_turns` is absent)
|
|
22
|
+
* can't honestly report a turn count; null is preferred over a faked 1.
|
|
23
|
+
*/
|
|
24
|
+
numTurns: number | null;
|
|
13
25
|
model: string;
|
|
14
26
|
isError: boolean;
|
|
15
27
|
/**
|
|
16
|
-
* Phase 6: tells the API which recompute path to
|
|
17
|
-
* `POST /api/session-costs`.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
28
|
+
* Phase 6 (extended migration 063): tells the API which recompute path to
|
|
29
|
+
* use on `POST /api/session-costs`. After Phase 2 the recompute path runs
|
|
30
|
+
* for every provider with seeded pricing rows, so every adapter should
|
|
31
|
+
* populate this field.
|
|
20
32
|
*/
|
|
21
|
-
provider?: "claude" | "codex" | "pi" | "opencode";
|
|
33
|
+
provider?: "claude" | "claude-managed" | "codex" | "pi" | "opencode" | "devin";
|
|
22
34
|
}
|
|
23
35
|
|
|
24
36
|
import type { ProviderName } from "../types";
|
|
@@ -43,14 +55,25 @@ export type ProviderEvent =
|
|
|
43
55
|
| {
|
|
44
56
|
type: "context_usage";
|
|
45
57
|
contextUsedTokens: number;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
58
|
+
// Migration 063: nullable so adapters (e.g. devin without a context API)
|
|
59
|
+
// can emit a snapshot that records cumulative tokens without faking a window.
|
|
60
|
+
contextTotalTokens: number | null;
|
|
61
|
+
// Migration 063: null if contextTotalTokens is missing (no divide-by-zero).
|
|
62
|
+
contextPercent: number | null;
|
|
63
|
+
// Migration 063: null when the adapter can't honestly report output tokens.
|
|
64
|
+
outputTokens: number | null;
|
|
65
|
+
/**
|
|
66
|
+
* Migration 063 — the formula the adapter used to compute
|
|
67
|
+
* contextUsedTokens. See `ContextFormulaSchema` in `src/types.ts` for the
|
|
68
|
+
* canonical value list. Adapters should always populate this going
|
|
69
|
+
* forward; it powers cross-provider apples-to-apples comparison.
|
|
70
|
+
*/
|
|
71
|
+
contextFormula?: string;
|
|
49
72
|
}
|
|
50
73
|
| {
|
|
51
74
|
type: "compaction";
|
|
52
75
|
preCompactTokens: number;
|
|
53
|
-
compactTrigger: "auto" | "manual";
|
|
76
|
+
compactTrigger: "auto" | "manual" | "auto-inferred";
|
|
54
77
|
contextTotalTokens: number;
|
|
55
78
|
};
|
|
56
79
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { stdlib } from "./stdlib";
|
|
2
|
+
import type { SwarmConfig } from "./swarm-config";
|
|
3
|
+
import { createSwarmSdk } from "./swarm-sdk";
|
|
4
|
+
|
|
5
|
+
export type RuntimeCtx = {
|
|
6
|
+
swarm: Record<string, unknown> & { config: SwarmConfig };
|
|
7
|
+
stdlib: typeof stdlib;
|
|
8
|
+
logger: {
|
|
9
|
+
log: (...args: unknown[]) => void;
|
|
10
|
+
warn: (...args: unknown[]) => void;
|
|
11
|
+
error: (...args: unknown[]) => void;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function buildCtx({ swarmConfig }: { swarmConfig: SwarmConfig }): RuntimeCtx {
|
|
16
|
+
const swarm = createSwarmSdk(swarmConfig) as Record<string, unknown> & { config: SwarmConfig };
|
|
17
|
+
swarm.config = swarmConfig;
|
|
18
|
+
return {
|
|
19
|
+
swarm,
|
|
20
|
+
stdlib,
|
|
21
|
+
logger: console,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { buildCtx } from "./ctx";
|
|
2
|
+
import type { SwarmConfigPayload } from "./executors/types";
|
|
3
|
+
import { SwarmConfig } from "./swarm-config";
|
|
4
|
+
|
|
5
|
+
function requiredEnv(name: string): string {
|
|
6
|
+
const value = process.env[name];
|
|
7
|
+
if (!value) throw new Error(`Missing required env ${name}`);
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
const stdin = await Bun.stdin.text();
|
|
13
|
+
if (!stdin.trim()) {
|
|
14
|
+
console.error("Swarm script config payload was empty");
|
|
15
|
+
process.exit(2);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const payload = JSON.parse(stdin) as SwarmConfigPayload;
|
|
19
|
+
const swarmConfig = new SwarmConfig(payload);
|
|
20
|
+
const rawArgs = JSON.parse(await Bun.file(requiredEnv("SWARM_SCRIPT_ARGS_FILE")).text());
|
|
21
|
+
// Accept both shapes: callers may pass an already-serialized JSON string.
|
|
22
|
+
const parsedArgs = typeof rawArgs === "string" ? JSON.parse(rawArgs) : rawArgs;
|
|
23
|
+
const ctx = buildCtx({ swarmConfig });
|
|
24
|
+
|
|
25
|
+
const sourceText = await Bun.file(requiredEnv("SWARM_SCRIPT_SOURCE_FILE")).text();
|
|
26
|
+
const userModulePath = `${requiredEnv("SWARM_SCRIPT_TMPDIR")}/user-script.ts`;
|
|
27
|
+
await Bun.write(userModulePath, sourceText);
|
|
28
|
+
|
|
29
|
+
const mod = await import(userModulePath);
|
|
30
|
+
if (typeof mod.default !== "function") {
|
|
31
|
+
throw new Error("Swarm script must export a default function");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const result = await mod.default(parsedArgs, ctx);
|
|
35
|
+
await Bun.write(requiredEnv("SWARM_SCRIPT_RESULT_FILE"), JSON.stringify(result ?? null));
|
|
36
|
+
} catch (error) {
|
|
37
|
+
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|