Compare commits

...
Author SHA1 Message Date
atlas-botandClaude Opus 5.5 f2eacf4381 feat(paperclip): cost-ledger-sync CronJob books API-equivalent cost of subscription runs (REN-186)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful
ci/woodpecker/pr/pr-review Pipeline was successful
Paperclip books subscription_included runs at 0 cents, so Dashboard -> Month
Spend reads $0. The run's usageJson.costUsd already holds the API-equivalent
price. A 15-minute CronJob posts one extra cost event per finished run (tokens
0, costCents = round(costUsd*100), billingCode api-equivalent-sync), with a
cursor in a ConfigMap so reruns never double-book. First run = backfill.

Ships suspended until the board key is seeded in OpenBao at
apps/paperclip-cost-ledger-sync#api_key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 09:21:31 +00:00
8 changed files with 293 additions and 0 deletions
@@ -0,0 +1,102 @@
#!/bin/sh
# Books the API-equivalent cost of every finished subscription run (REN-186).
#
# Paperclip books `subscription_included` runs at 0 cents; the Claude CLI's
# API-equivalent price is kept in the run's usageJson.costUsd. For each finished
# run past the cursor this posts one extra cost event: all token fields 0 (the
# tokens are already booked by Paperclip), costCents = round(costUsd * 100),
# billingCode $BILLING_CODE so every event it wrote can be found again.
#
# Idempotency: the cursor "<finishedAt>|<runId>" lives in ConfigMap $STATE_CM
# and is advanced after every single successful POST, so a crash or a rerun
# resumes after the last booked run and never books one twice. Only runs that
# finished more than $SETTLE_SECONDS ago are considered, so a run that is still
# being finalised is picked up by the next pass, not skipped. An empty cursor
# means "book everything": that is the one-time backfill.
#
# Needs: sh, curl, jq, kubectl (alpine/k8s image).
set -eu
API="${PAPERCLIP_API_URL:?}/api"
CID="${PAPERCLIP_COMPANY_ID:?}"
KEY="${PAPERCLIP_API_KEY:?}"
STATE_CM="${STATE_CM:-cost-ledger-sync-state}"
BILLING_CODE="${BILLING_CODE:-api-equivalent-sync}"
SETTLE_SECONDS="${SETTLE_SECONDS:-300}"
PAGE_LIMIT=1000 # server-side maximum of GET heartbeat-runs
DRY_RUN="${DRY_RUN:-0}"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
api_get() {
curl -fsS --max-time 60 -H "Authorization: Bearer $KEY" "$API$1"
}
cursor="$(kubectl get configmap "$STATE_CM" -o jsonpath='{.data.cursor}')"
cutoff="$(date -u -d "@$(( $(date +%s) - SETTLE_SECONDS ))" +%Y-%m-%dT%H:%M:%S.000Z)"
echo "cursor=${cursor:-<empty: backfill>} cutoff=$cutoff"
# Per agent, so the 1000-run page limit applies per agent, not to the company.
api_get "/companies/$CID/agents" | jq -r '.[].id' > "$WORK/agents"
: > "$WORK/runs.jsonl"
while read -r agent; do
api_get "/companies/$CID/heartbeat-runs?agentId=$agent&limit=$PAGE_LIMIT" > "$WORK/page.json"
# A full page whose oldest run is still past the cursor may hide older
# unbooked runs. Stop loudly instead of skipping them.
if jq -e --arg c "$cursor" --argjson n "$PAGE_LIMIT" \
'length >= $n and ((map(.createdAt) | min) > ($c | split("|")[0]))' \
"$WORK/page.json" > /dev/null; then
echo "ERROR: agent $agent returned a full page of $PAGE_LIMIT runs newer than the cursor; older runs may be unbooked" >&2
exit 1
fi
jq -c '.[]' "$WORK/page.json" >> "$WORK/runs.jsonl"
done < "$WORK/agents"
# Finished subscription runs with a cost, past the cursor and settled, oldest first.
jq -sc --arg c "$cursor" --arg cutoff "$cutoff" --arg bc "$BILLING_CODE" '
unique_by(.id)
| map(select(.finishedAt != null and .finishedAt < $cutoff
and .usageJson.billingType == "subscription_included"
and ((.usageJson.costUsd // 0) > 0))
| . + {k: (.finishedAt + "|" + .id)}
| select(.k > $c))
| sort_by(.k)
| .[]
| {k,
body: {agentId, heartbeatRunId: .id,
provider: "anthropic", biller: "anthropic",
billingType: "subscription_included",
billingCode: $bc,
model: (.usageJson.model // "unknown"),
inputTokens: 0, cachedInputTokens: 0, outputTokens: 0,
costCents: (.usageJson.costUsd * 100 | round),
occurredAt: .finishedAt}}
' "$WORK/runs.jsonl" > "$WORK/todo.jsonl"
total="$(wc -l < "$WORK/todo.jsonl" | tr -d ' ')"
echo "runs to book: $total"
[ "$total" -gt 0 ] || exit 0
booked=0
cents=0
while read -r line; do
k="$(printf '%s' "$line" | jq -r .k)"
body="$(printf '%s' "$line" | jq -c .body)"
c="$(printf '%s' "$body" | jq -r .costCents)"
if [ "$DRY_RUN" = "1" ]; then
echo "dry-run: $k ${c}c"
else
if [ "$c" -gt 0 ]; then
curl -fsS --max-time 60 -o /dev/null -X POST \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d "$body" "$API/companies/$CID/cost-events"
fi
kubectl patch configmap "$STATE_CM" --type merge \
-p "$(jq -nc --arg k "$k" '{data: {cursor: $k}}')" > /dev/null
fi
booked=$((booked + 1))
cents=$((cents + c))
done < "$WORK/todo.jsonl"
echo "booked $booked runs, ${cents} cents; cursor now $k"
@@ -0,0 +1,81 @@
# Books the API-equivalent cost of every finished subscription run so that
# Dashboard -> Month Spend shows real cost (REN-186, owner decision on REN-170).
# Plain shell; no LLM, no agent run. Logic and idempotency: cost-ledger-sync.sh.
#
# Ships suspended: it cannot run until the board key is seeded in OpenBao at
# apps/paperclip-cost-ledger-sync#api_key. The follow-up PR flips `suspend`.
# The first unsuspended run is the one-time backfill (empty cursor).
apiVersion: batch/v1
kind: CronJob
metadata:
name: cost-ledger-sync
namespace: paperclip
labels:
app.kubernetes.io/name: cost-ledger-sync
spec:
suspend: true
schedule: "*/15 * * * *"
# Forbid + backoffLimit 0: two passes at once could both book the same run.
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 2
failedJobsHistoryLimit: 3
startingDeadlineSeconds: 600
jobTemplate:
spec:
backoffLimit: 0
activeDeadlineSeconds: 840
ttlSecondsAfterFinished: 86400
template:
metadata:
labels:
app.kubernetes.io/name: cost-ledger-sync
spec:
serviceAccountName: cost-ledger-sync
restartPolicy: Never
enableServiceLinks: false
securityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: sync
image: alpine/k8s:1.35.2
command: ["/bin/sh", "/script/cost-ledger-sync.sh"]
env:
- name: HOME
value: /tmp
- name: PAPERCLIP_API_URL
value: http://paperclip.paperclip.svc.cluster.local:3100
- name: PAPERCLIP_COMPANY_ID
value: 72f7b75a-10b0-4084-ad5e-a0cdf31c7caf
- name: PAPERCLIP_API_KEY
valueFrom:
secretKeyRef:
name: cost-ledger-sync
key: PAPERCLIP_API_KEY
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: script
mountPath: /script
readOnly: true
- name: tmp
mountPath: /tmp
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 128Mi
volumes:
- name: script
configMap:
name: cost-ledger-sync-script
- name: tmp
emptyDir:
sizeLimit: 64Mi
@@ -0,0 +1,22 @@
# Paperclip board API key for POST /cost-events (issued on REN-185; an agent
# key can only book its own costs). Not in paperclip-secrets on purpose: that
# Secret is envFrom'd into the paperclip pod, where every agent could read it.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: cost-ledger-sync
namespace: paperclip
labels:
app.kubernetes.io/name: cost-ledger-sync
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: cost-ledger-sync
data:
- secretKey: PAPERCLIP_API_KEY
remoteRef:
key: apps/paperclip-cost-ledger-sync
property: api_key
@@ -0,0 +1,13 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: paperclip
resources:
- rbac.yaml
- state-configmap.yaml
- externalsecret.yaml
- network-policy.yaml
- cronjob.yaml
configMapGenerator:
- name: cost-ledger-sync-script
files:
- cost-ledger-sync.sh
@@ -0,0 +1,19 @@
# The namespace default (network-policies/paperclip-ns.yaml) already lets this
# pod reach the paperclip Service and DNS. The apiserver, for the cursor
# ConfigMap, is the only addition. Selects the sync pod only.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: cost-ledger-sync-egress
namespace: paperclip
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: cost-ledger-sync
egress:
- toEntities:
- kube-apiserver
toPorts:
- ports:
- port: "6443"
protocol: TCP
@@ -0,0 +1,38 @@
# The sync job's own identity (REN-186). Its only cluster right is to read and
# advance its cursor in one named ConfigMap. It reads no workload, no Secret.
apiVersion: v1
kind: ServiceAccount
metadata:
name: cost-ledger-sync
namespace: paperclip
labels:
app.kubernetes.io/name: cost-ledger-sync
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: cost-ledger-sync-state
namespace: paperclip
labels:
app.kubernetes.io/name: cost-ledger-sync
rules:
- apiGroups: [""]
resources: ["configmaps"]
resourceNames: ["cost-ledger-sync-state"]
verbs: ["get", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: cost-ledger-sync-state
namespace: paperclip
labels:
app.kubernetes.io/name: cost-ledger-sync
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: cost-ledger-sync-state
subjects:
- kind: ServiceAccount
name: cost-ledger-sync
namespace: paperclip
@@ -0,0 +1,17 @@
# The sync cursor: "<finishedAt>|<runId>" of the last booked run. The job
# writes it; Flux only creates it. `ssa: IfNotPresent` stops Flux from
# resetting it to empty on every reconcile, which would re-run the backfill and
# double-book every run. `prune: disabled` keeps it if this directory is
# removed, for the same reason. Empty cursor = book every run (the backfill).
apiVersion: v1
kind: ConfigMap
metadata:
name: cost-ledger-sync-state
namespace: paperclip
labels:
app.kubernetes.io/name: cost-ledger-sync
annotations:
kustomize.toolkit.fluxcd.io/ssa: IfNotPresent
kustomize.toolkit.fluxcd.io/prune: disabled
data:
cursor: ""
@@ -13,3 +13,4 @@ resources:
- gate-service.yaml
- ingress.yaml
- network-policy.yaml
- cost-ledger-sync