@tokagent/tokagentos 2.0.17 → 2.0.19
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/package.json +1 -1
- package/scaffold-patches/packages/agent/src/runtime/core-plugins.ts +44 -0
- package/scaffold-patches/packages/billing/src/auth/api-keys.ts +34 -0
- package/templates/fullstack-app/.env.example +8 -0
- package/templates/fullstack-app/plugins/plugin-tokagent-billing/package.json +1 -1
- package/templates/fullstack-app/plugins/plugin-tokagent-billing/src/dashboard/app.js +22 -2
- package/templates/fullstack-app/plugins/plugin-tokagent-billing/src/middleware/api-key-resolve.ts +14 -1
- package/templates/fullstack-app/plugins/plugin-tokagent-billing/src/routes/keys-routes.ts +18 -2
- package/templates-manifest.json +1 -1
package/package.json
CHANGED
|
@@ -77,6 +77,50 @@ function configureLitellmEnvMirror(): void {
|
|
|
77
77
|
}
|
|
78
78
|
configureLitellmEnvMirror();
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Tokagent billing gateway → OpenAI-compatible chat provider.
|
|
82
|
+
*
|
|
83
|
+
* When the user has set `BILLING_CHAT_KEY=sk-ai-...` in their .env (the
|
|
84
|
+
* minted billing API key) AND `TOKAGENT_GATEWAY_URL` is set (always true on
|
|
85
|
+
* v2.0.7+ scaffolds — defaults to the Tokamak-hosted Railway gateway), wire
|
|
86
|
+
* them onto OPENAI_* so plugin-openai treats the gateway as its provider.
|
|
87
|
+
*
|
|
88
|
+
* Effect: chat composer activates without the user needing to set
|
|
89
|
+
* OPENAI_API_KEY/OPENAI_BASE_URL manually. The minted sk-ai-* key is sent
|
|
90
|
+
* as `Authorization: Bearer sk-ai-...`, which the billing-server's
|
|
91
|
+
* resolveBillingIdentity recognizes (v2.0.18+) and routes through the
|
|
92
|
+
* billing rail (auth → reserve → upstream LiteLLM → commit).
|
|
93
|
+
*
|
|
94
|
+
* Coupled like the LiteLLM mirror — if only one of the two is set, this is
|
|
95
|
+
* a no-op (we don't want to point OpenAI plugin at the gateway with no key,
|
|
96
|
+
* or send the billing key to api.openai.com).
|
|
97
|
+
*
|
|
98
|
+
* Explicit OPENAI_* in .env still wins over BILLING_* — same precedence
|
|
99
|
+
* model as the LiteLLM block above.
|
|
100
|
+
*/
|
|
101
|
+
function configureBillingChatMirror(): void {
|
|
102
|
+
const hasKey = !!process.env.BILLING_CHAT_KEY?.trim();
|
|
103
|
+
const hasUrl = !!process.env.TOKAGENT_GATEWAY_URL?.trim();
|
|
104
|
+
if (!hasKey || !hasUrl) return;
|
|
105
|
+
if (process.env.OPENAI_API_KEY?.trim() || process.env.OPENAI_BASE_URL?.trim()) {
|
|
106
|
+
// User configured OpenAI explicitly — respect their choice.
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
// The billing gateway exposes /v1/chat/completions + /v1/messages at the
|
|
110
|
+
// root of TOKAGENT_GATEWAY_URL. plugin-openai appends `/v1/...` itself, so
|
|
111
|
+
// strip any trailing /v1 the user may have appended and a trailing slash.
|
|
112
|
+
const base = process.env
|
|
113
|
+
.TOKAGENT_GATEWAY_URL!.trim()
|
|
114
|
+
.replace(/\/$/, "")
|
|
115
|
+
.replace(/\/v1$/, "");
|
|
116
|
+
process.env.OPENAI_API_KEY = process.env.BILLING_CHAT_KEY!.trim();
|
|
117
|
+
process.env.OPENAI_BASE_URL = `${base}/v1`;
|
|
118
|
+
console.info(
|
|
119
|
+
"[tokagent] BILLING_CHAT_KEY + TOKAGENT_GATEWAY_URL detected — wired as OpenAI-compatible provider for chat.",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
configureBillingChatMirror();
|
|
123
|
+
|
|
80
124
|
/**
|
|
81
125
|
* Plugins that depend on PTY/native workspace tooling.
|
|
82
126
|
* Keep them out of cloud images where those binaries are intentionally absent.
|
|
@@ -163,6 +163,40 @@ export async function revokeApiKey(
|
|
|
163
163
|
.where(eq(apiKeys.id, id));
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Hard-delete an API key row from the database (vs. {@link revokeApiKey},
|
|
168
|
+
* which sets `revokedAt` and keeps the row for audit trail).
|
|
169
|
+
*
|
|
170
|
+
* Use this when the operator wants to reclaim disk space or when revoked
|
|
171
|
+
* keys would otherwise accumulate unbounded over the lifetime of a wallet.
|
|
172
|
+
* The historical `billing_call_log` rows still reference `apiKeyId` as a
|
|
173
|
+
* plain text column (no FK constraint), so call-log history survives.
|
|
174
|
+
*
|
|
175
|
+
* Authorization: the caller MUST already have proven ownership — same
|
|
176
|
+
* checks as {@link revokeApiKey}.
|
|
177
|
+
*
|
|
178
|
+
* Idempotent: if the key doesn't exist, returns silently (no throw).
|
|
179
|
+
*/
|
|
180
|
+
export async function deleteApiKey(
|
|
181
|
+
db: BillingDatabase,
|
|
182
|
+
id: string,
|
|
183
|
+
wallet: Address,
|
|
184
|
+
): Promise<void> {
|
|
185
|
+
const rows = await db
|
|
186
|
+
.select({ id: apiKeys.id, wallet: apiKeys.wallet })
|
|
187
|
+
.from(apiKeys)
|
|
188
|
+
.where(eq(apiKeys.id, id));
|
|
189
|
+
|
|
190
|
+
if (rows.length === 0) return; // already gone — idempotent
|
|
191
|
+
|
|
192
|
+
const row = rows[0]!;
|
|
193
|
+
if (row.wallet.toLowerCase() !== wallet.toLowerCase()) {
|
|
194
|
+
throw new Error(`deleteApiKey: key ${id} does not belong to wallet ${wallet}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
await db.delete(apiKeys).where(eq(apiKeys.id, id));
|
|
198
|
+
}
|
|
199
|
+
|
|
166
200
|
/**
|
|
167
201
|
* Batch-update `last_used_at` for a set of key IDs.
|
|
168
202
|
* Called by the Phase 5 cron worker, not on the hot request path.
|
|
@@ -202,6 +202,14 @@ BILLING_MODE=client
|
|
|
202
202
|
TOKAGENT_GATEWAY_URL=https://billing-service-production-a8e7.up.railway.app
|
|
203
203
|
# TOKAGENT_GATEWAY_TIMEOUT_MS=30000 # default 30s; bump for slow connections
|
|
204
204
|
|
|
205
|
+
# Paste your minted API key here (from the billing dashboard "Keys" tab) to
|
|
206
|
+
# enable the agent's chat composer. With BILLING_CHAT_KEY + the gateway URL
|
|
207
|
+
# above, the runtime auto-wires OPENAI_API_KEY / OPENAI_BASE_URL so
|
|
208
|
+
# plugin-openai routes inference through the billing rail — every billed
|
|
209
|
+
# call debits PTON from your on-chain balance.
|
|
210
|
+
# Mint a key at: <TOKAGENT_GATEWAY_URL>/v1/billing/dashboard/ (Keys tab)
|
|
211
|
+
# BILLING_CHAT_KEY=sk-ai-...
|
|
212
|
+
|
|
205
213
|
# ─────────────────────────────────────────────────────────────────────
|
|
206
214
|
# ADVANCED — self-host the billing rail
|
|
207
215
|
# ─────────────────────────────────────────────────────────────────────
|
|
@@ -857,8 +857,9 @@ async function renderKeysTable() {
|
|
|
857
857
|
const tr = document.createElement("tr");
|
|
858
858
|
const status = k.revokedAt ? `<span class="outcome-pill outcome-failed">revoked</span>` : `<span class="outcome-pill outcome-success">active</span>`;
|
|
859
859
|
const action = k.revokedAt
|
|
860
|
-
? ""
|
|
861
|
-
: `<button class="btn btn-danger" type="button" data-revoke="${escape(k.id)}">Revoke</button
|
|
860
|
+
? `<button class="btn btn-danger" type="button" data-delete="${escape(k.id)}" title="Permanently remove this revoked key from the database">Delete</button>`
|
|
861
|
+
: `<button class="btn btn-danger" type="button" data-revoke="${escape(k.id)}">Revoke</button>
|
|
862
|
+
<button class="btn btn-ghost" type="button" data-delete="${escape(k.id)}" title="Skip the revoke step and hard-delete immediately">Delete</button>`;
|
|
862
863
|
tr.innerHTML = `
|
|
863
864
|
<td><code>${escape(k.id)}</code></td>
|
|
864
865
|
<td>${escape(k.name ?? "—")}</td>
|
|
@@ -883,6 +884,25 @@ async function renderKeysTable() {
|
|
|
883
884
|
}
|
|
884
885
|
});
|
|
885
886
|
}
|
|
887
|
+
// Hard-delete (?hard=true) removes the row from the DB instead of soft-
|
|
888
|
+
// revoking. Soft revoke keeps the row for audit trail but accumulates
|
|
889
|
+
// unbounded over time; hard delete reclaims the row. Call-log history
|
|
890
|
+
// survives because billing_call_log.api_key_id has no FK constraint.
|
|
891
|
+
for (const btn of tbody.querySelectorAll("[data-delete]")) {
|
|
892
|
+
btn.addEventListener("click", async () => {
|
|
893
|
+
const id = btn.getAttribute("data-delete");
|
|
894
|
+
if (!window.confirm(`Permanently delete API key ${id}?\nThis cannot be undone.`)) return;
|
|
895
|
+
btn.disabled = true;
|
|
896
|
+
try {
|
|
897
|
+
await apiJson(`/v1/keys/${encodeURIComponent(id)}?hard=true`, { method: "DELETE" });
|
|
898
|
+
await renderKeysTable();
|
|
899
|
+
} catch (e) {
|
|
900
|
+
setStatus($("#key-create-status"), `Delete failed: ${e.message}`, "err");
|
|
901
|
+
} finally {
|
|
902
|
+
btn.disabled = false;
|
|
903
|
+
}
|
|
904
|
+
});
|
|
905
|
+
}
|
|
886
906
|
}
|
|
887
907
|
|
|
888
908
|
// ----------------------------- Charts (zero-dep canvas) -----------------------------
|
package/templates/fullstack-app/plugins/plugin-tokagent-billing/src/middleware/api-key-resolve.ts
CHANGED
|
@@ -81,9 +81,22 @@ export async function resolveBillingIdentity(
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
// ---- 2. Authorization: Bearer
|
|
84
|
+
// ---- 2. Authorization: Bearer (API key OR SIWE JWT) ----
|
|
85
|
+
// Anthropic and OpenAI SDKs (plus elizaOS's plugin-openai) send the API key
|
|
86
|
+
// as `Authorization: Bearer <key>`. Try as sk-ai-* API key first so a user
|
|
87
|
+
// can plug the billing gateway into any OpenAI-compatible client by setting
|
|
88
|
+
// OPENAI_API_KEY=sk-ai-... + OPENAI_BASE_URL=<gateway>. Falls back to SIWE
|
|
89
|
+
// JWT for the dashboard's wallet-signed session path.
|
|
85
90
|
const bearer = bearerToken(req);
|
|
86
91
|
if (bearer) {
|
|
92
|
+
if (bearer.startsWith("sk-ai-")) {
|
|
93
|
+
const result = await resolveApiKey(db, bearer, authSecret);
|
|
94
|
+
if (result) {
|
|
95
|
+
return { wallet: result.wallet, apiKeyId: result.id };
|
|
96
|
+
}
|
|
97
|
+
// Fall through to JWT path — defensive, in case some future API key
|
|
98
|
+
// format collides with the prefix check.
|
|
99
|
+
}
|
|
87
100
|
const result = await verifySession(bearer, authSecret);
|
|
88
101
|
if (result) {
|
|
89
102
|
return { wallet: result.wallet };
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
mintApiKey,
|
|
18
18
|
listApiKeys,
|
|
19
19
|
revokeApiKey,
|
|
20
|
+
deleteApiKey,
|
|
20
21
|
} from "@tokagentos/billing";
|
|
21
22
|
import {
|
|
22
23
|
getBillingState,
|
|
@@ -153,9 +154,24 @@ async function handleRevokeKey(
|
|
|
153
154
|
return;
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
// `?hard=true` hard-deletes the row instead of soft-revoking. Soft revoke
|
|
158
|
+
// sets `revoked_at` and leaves the row in `billing_api_keys` for audit
|
|
159
|
+
// trail — useful but accumulates rows over time. Hard delete reclaims the
|
|
160
|
+
// row entirely; the historical `billing_call_log` rows still reference
|
|
161
|
+
// `api_key_id` as a plain text column (no FK), so call-log history is not
|
|
162
|
+
// affected. The default remains soft-revoke for backward compatibility.
|
|
163
|
+
const hardFlag = req.query?.["hard"];
|
|
164
|
+
const hardDelete =
|
|
165
|
+
hardFlag === "true" || hardFlag === "1" || hardFlag === "";
|
|
166
|
+
|
|
156
167
|
try {
|
|
157
|
-
|
|
158
|
-
|
|
168
|
+
if (hardDelete) {
|
|
169
|
+
await deleteApiKey(db, keyId, identity.wallet);
|
|
170
|
+
res.status(200).json({ deleted: true, id: keyId });
|
|
171
|
+
} else {
|
|
172
|
+
await revokeApiKey(db, keyId, identity.wallet);
|
|
173
|
+
res.status(200).json({ revoked: true, id: keyId });
|
|
174
|
+
}
|
|
159
175
|
} catch (err: unknown) {
|
|
160
176
|
const message = err instanceof Error ? err.message : "revoke failed";
|
|
161
177
|
if (message.includes("not found")) {
|