Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2eacf4381 |
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user