ada-agent 0.9.0 → 0.10.0
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/docs/deploy.md +21 -8
- package/package.json +3 -1
- package/src/worker/index.ts +213 -0
- package/src/worker/providers.ts +65 -0
- package/src/worker/schema.sql +36 -0
- package/src/worker/store.ts +157 -0
- package/src/worker/tsconfig.json +9 -0
- package/tsconfig.json +2 -1
package/docs/deploy.md
CHANGED
|
@@ -62,17 +62,30 @@ unified logging, caching, rate-limiting), point `CLOUDFLARE_BASE_URL` at your ga
|
|
|
62
62
|
is stateless-leaning — durable seat/usage/audit state wants **R2/D1**, not a container disk. For a
|
|
63
63
|
Cloudflare-native, stateful deploy, prefer the port below.
|
|
64
64
|
|
|
65
|
-
###
|
|
65
|
+
### Cloudflare Worker (edge-native) — `src/worker/`
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
An edge-native port of the backend ships in `src/worker/` (config: `wrangler.toml`, schema:
|
|
68
|
+
`src/worker/schema.sql`). It's a self-contained Workers `fetch` handler: auth (D1 seats + admin key),
|
|
69
|
+
the org model-allowlist, and provider passthrough with server-side metering — **Cloudflare Workers AI
|
|
70
|
+
(`@cf/*`) is the first-class provider**. Use *either* this Worker *or* the container, not both.
|
|
68
71
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
```bash
|
|
73
|
+
npx wrangler d1 create ada # → paste the id into wrangler.toml
|
|
74
|
+
npx wrangler d1 execute ada --file src/worker/schema.sql --remote
|
|
75
|
+
npx wrangler secret put ADA_ADMIN_KEY # bootstrap admin
|
|
76
|
+
npx wrangler secret put CLOUDFLARE_ACCOUNT_ID # for @cf/* Workers AI models
|
|
77
|
+
npx wrangler secret put CLOUDFLARE_API_TOKEN
|
|
78
|
+
npx wrangler deploy
|
|
79
|
+
# then create seats: curl -X POST -H "Authorization: Bearer $ADA_ADMIN_KEY" -d '{"name":"alice"}' https://<worker>/v1/users
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Endpoints match the Node backend (`/v1/models`, `/v1/chat/completions`, `/v1/embeddings`, and the
|
|
83
|
+
admin `/v1/users` · `/v1/policy` · `/v1/usage` · `/v1/audit`). Stores are strongly-consistent **D1**.
|
|
74
84
|
|
|
75
|
-
|
|
85
|
+
**Deferred to a follow-up** (the Worker returns a clear error meanwhile): **native Anthropic** — reach
|
|
86
|
+
Claude via OpenRouter (`anthropic/claude-…`) or point `CLOUDFLARE_BASE_URL` at an AI Gateway; and
|
|
87
|
+
**OIDC SSO**, which needs a Web Crypto port of `oidc.ts` (`node:crypto`/`node:net` aren't on Workers).
|
|
88
|
+
Metering is a `TransformStream` tee today; an **AI Gateway** in front gives it to you for free.
|
|
76
89
|
|
|
77
90
|
## Hardening
|
|
78
91
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ada-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "A from-zero terminal coding agent with a Cursor-style routing backend, ~285 skills, MCP connectors, and ask/plan/auto modes",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"start": "tsx src/client/cli.ts",
|
|
50
50
|
"server": "tsx src/server/index.ts",
|
|
51
51
|
"typecheck": "tsc --noEmit",
|
|
52
|
+
"typecheck:worker": "tsc -p src/worker/tsconfig.json --noEmit",
|
|
52
53
|
"selfcheck": "tsx src/selfcheck.ts",
|
|
53
54
|
"bench:swebench": "node bench/swebench.mjs",
|
|
54
55
|
"catalog:refresh": "node scripts/refresh-catalog.mjs",
|
|
@@ -63,6 +64,7 @@
|
|
|
63
64
|
"node-pty": "^1.1.0"
|
|
64
65
|
},
|
|
65
66
|
"devDependencies": {
|
|
67
|
+
"@cloudflare/workers-types": "^4.20260702.1",
|
|
66
68
|
"@types/node": "^26.0.1",
|
|
67
69
|
"typescript": "^6.0.3"
|
|
68
70
|
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// ada backend on Cloudflare Workers (R4 v1). A self-contained edge port of the Node routing server:
|
|
2
|
+
// auth (D1 seats + admin key) → org model-allowlist → provider passthrough with server-side metering.
|
|
3
|
+
// Cloudflare Workers AI (@cf/*) is the first-class provider. See docs/deploy.md.
|
|
4
|
+
//
|
|
5
|
+
// v1 scope: /health, /v1/models, /v1/chat/completions, /v1/embeddings, and the admin control plane
|
|
6
|
+
// (/v1/users, /v1/policy, /v1/usage, /v1/audit). DEFERRED (follow-ups): native Anthropic (use Claude
|
|
7
|
+
// via OpenRouter or a Cloudflare AI Gateway meanwhile), OIDC SSO (needs a Web Crypto port of oidc.ts).
|
|
8
|
+
|
|
9
|
+
import { providerKey, providers, route } from "./providers.ts";
|
|
10
|
+
import { appendAudit, appendUsage, createSeat, disableSeat, extractLastUsage, identifySeat, listSeats, loadPolicy, modelAllowed, savePolicy, seatCount, usageSummary, type Identity, type Policy } from "./store.ts";
|
|
11
|
+
|
|
12
|
+
interface Env {
|
|
13
|
+
DB: D1Database;
|
|
14
|
+
ADA_ADMIN_KEY?: string;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function json(status: number, obj: unknown): Response {
|
|
19
|
+
return new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Tee a response stream to accumulate the upstream's reported token usage, recorded after the body
|
|
23
|
+
* drains (ctx.waitUntil). Works for streamed SSE and one-shot JSON alike. */
|
|
24
|
+
function meterStream(env: Env, ctx: ExecutionContext, who: Identity, model: string, provider: string, body: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
|
|
25
|
+
let tail = "";
|
|
26
|
+
const dec = new TextDecoder();
|
|
27
|
+
const ts = new TransformStream<Uint8Array, Uint8Array>({
|
|
28
|
+
transform(chunk, controller) {
|
|
29
|
+
tail = (tail + dec.decode(chunk, { stream: true })).slice(-16_384);
|
|
30
|
+
controller.enqueue(chunk);
|
|
31
|
+
},
|
|
32
|
+
flush() {
|
|
33
|
+
const u = extractLastUsage(tail);
|
|
34
|
+
if (u) ctx.waitUntil(appendUsage(env, { ts: Date.now(), user: who.user, model, provider, promptTokens: u.promptTokens, completionTokens: u.completionTokens }));
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
return body.pipeThrough(ts);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function proxyChat(env: Env, ctx: ExecutionContext, who: Identity, body: Record<string, unknown>): Promise<Response> {
|
|
41
|
+
const model = String(body.model ?? "");
|
|
42
|
+
if (!model) return json(400, { error: { message: "missing 'model'" } });
|
|
43
|
+
|
|
44
|
+
const policy: Policy = await loadPolicy(env);
|
|
45
|
+
if (!modelAllowed(model, policy)) {
|
|
46
|
+
ctx.waitUntil(appendAudit(env, { ts: Date.now(), user: who.user, event: "policy_denied_model", detail: model }));
|
|
47
|
+
return json(403, { error: { message: `model '${model}' is not allowed by org policy (allowed: ${policy.models!.join(", ")})` } });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// With an allowlist active, ignore the client's provider hint (route by model id only).
|
|
51
|
+
const explicit = policy.models?.length ? undefined : typeof body.provider === "string" ? body.provider : undefined;
|
|
52
|
+
const provider = route(model, explicit);
|
|
53
|
+
const def = providers(env)[provider];
|
|
54
|
+
if (provider === "anthropic" && def.baseURL.includes("api.anthropic.com")) {
|
|
55
|
+
return json(400, { error: { message: "native Anthropic isn't available on the Worker backend yet — use Claude via OpenRouter (model 'anthropic/claude-…') or set CLOUDFLARE_BASE_URL to an AI Gateway." } });
|
|
56
|
+
}
|
|
57
|
+
const key = providerKey(env, def);
|
|
58
|
+
if (def.keyEnv && !key) return json(400, { error: { message: `provider '${provider}' not configured — set the ${def.keyEnv} secret` } });
|
|
59
|
+
|
|
60
|
+
if (body.stream) body.stream_options = { ...((body.stream_options as Record<string, unknown>) ?? {}), include_usage: true };
|
|
61
|
+
const prefix = `${provider}/`;
|
|
62
|
+
const outModel = typeof body.model === "string" && body.model.startsWith(prefix) ? body.model.slice(prefix.length) : body.model;
|
|
63
|
+
const outBody: Record<string, unknown> = { ...body, model: outModel };
|
|
64
|
+
delete outBody.provider;
|
|
65
|
+
|
|
66
|
+
let upstream: Response;
|
|
67
|
+
try {
|
|
68
|
+
upstream = await fetch(`${def.baseURL}/chat/completions`, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: { "content-type": "application/json", ...(key ? { authorization: `Bearer ${key}` } : {}) },
|
|
71
|
+
body: JSON.stringify(outBody),
|
|
72
|
+
});
|
|
73
|
+
} catch (e) {
|
|
74
|
+
return json(502, { error: { message: `could not reach ${provider}: ${e instanceof Error ? e.message : String(e)}` } });
|
|
75
|
+
}
|
|
76
|
+
if (!upstream.ok || !upstream.body) {
|
|
77
|
+
const text = await upstream.text().catch(() => "");
|
|
78
|
+
return new Response(text || JSON.stringify({ error: { message: `upstream error ${upstream.status}` } }), { status: upstream.status || 502, headers: { "content-type": "application/json" } });
|
|
79
|
+
}
|
|
80
|
+
const metered = meterStream(env, ctx, who, model, provider, upstream.body);
|
|
81
|
+
return new Response(metered, { status: 200, headers: { "content-type": upstream.headers.get("content-type") ?? "text/event-stream", "cache-control": "no-cache" } });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function proxyEmbeddings(env: Env, ctx: ExecutionContext, who: Identity, body: Record<string, unknown>, raw: string): Promise<Response> {
|
|
85
|
+
const model = String(body.model ?? "");
|
|
86
|
+
const policy = await loadPolicy(env);
|
|
87
|
+
if (model && !modelAllowed(model, policy)) {
|
|
88
|
+
ctx.waitUntil(appendAudit(env, { ts: Date.now(), user: who.user, event: "policy_denied_model", detail: `embeddings:${model}` }));
|
|
89
|
+
return json(403, { error: { message: `embedding model '${model}' is not allowed by org policy` } });
|
|
90
|
+
}
|
|
91
|
+
const provider = route(model);
|
|
92
|
+
const def = providers(env)[provider];
|
|
93
|
+
const key = providerKey(env, def);
|
|
94
|
+
if (def.keyEnv && !key) return json(400, { error: { message: `provider '${provider}' not configured — set the ${def.keyEnv} secret` } });
|
|
95
|
+
let upstream: Response;
|
|
96
|
+
try {
|
|
97
|
+
upstream = await fetch(`${def.baseURL}/embeddings`, { method: "POST", headers: { "content-type": "application/json", ...(key ? { authorization: `Bearer ${key}` } : {}) }, body: raw });
|
|
98
|
+
} catch (e) {
|
|
99
|
+
return json(502, { error: { message: `could not reach ${provider}: ${e instanceof Error ? e.message : String(e)}` } });
|
|
100
|
+
}
|
|
101
|
+
const text = await upstream.text();
|
|
102
|
+
const u = extractLastUsage(text);
|
|
103
|
+
if (u) ctx.waitUntil(appendUsage(env, { ts: Date.now(), user: who.user, model, provider, promptTokens: u.promptTokens, completionTokens: 0 }));
|
|
104
|
+
return new Response(text, { status: upstream.status, headers: { "content-type": "application/json" } });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function listModels(env: Env): Promise<Response> {
|
|
108
|
+
const table = providers(env);
|
|
109
|
+
const data: Array<{ id: string; object: "model"; owned_by: string }> = [];
|
|
110
|
+
await Promise.all((Object.keys(table) as Array<keyof typeof table>).map(async (p) => {
|
|
111
|
+
const def = table[p];
|
|
112
|
+
const key = providerKey(env, def);
|
|
113
|
+
if (def.keyEnv && !key) return; // not configured
|
|
114
|
+
try {
|
|
115
|
+
const r = await fetch(`${def.baseURL}/models`, { headers: key ? { authorization: `Bearer ${key}` } : {} });
|
|
116
|
+
if (!r.ok) return;
|
|
117
|
+
const j = (await r.json()) as { data?: Array<{ id?: unknown }> };
|
|
118
|
+
for (const m of j.data ?? []) if (typeof m.id === "string") data.push({ id: m.id, object: "model", owned_by: String(p) });
|
|
119
|
+
} catch { /* skip a provider that errors */ }
|
|
120
|
+
}));
|
|
121
|
+
return json(200, { object: "list", data });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export default {
|
|
125
|
+
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
|
126
|
+
try {
|
|
127
|
+
const url = new URL(request.url);
|
|
128
|
+
const path = url.pathname;
|
|
129
|
+
if (path === "/" || path === "/health") return new Response("ada worker ok\n", { headers: { "content-type": "text/plain" } });
|
|
130
|
+
|
|
131
|
+
const auth = request.headers.get("authorization") ?? "";
|
|
132
|
+
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
|
|
133
|
+
const who = token ? await identifySeat(env, token) : null;
|
|
134
|
+
if (!who) return json(401, { error: { message: "unauthorized — invalid seat key or admin key" } });
|
|
135
|
+
|
|
136
|
+
const readBody = async (): Promise<{ raw: string; body: Record<string, unknown> } | null> => {
|
|
137
|
+
const raw = await request.text();
|
|
138
|
+
try {
|
|
139
|
+
return { raw, body: JSON.parse(raw) as Record<string, unknown> };
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
const adminOnly = (): Response | null => (who.role === "admin" ? null : json(403, { error: { message: "admin only" } }));
|
|
145
|
+
|
|
146
|
+
if (request.method === "GET" && path === "/v1/whoami") return json(200, { ok: true, user: who.user, role: who.role });
|
|
147
|
+
if (request.method === "GET" && path === "/v1/models") return await listModels(env);
|
|
148
|
+
if (request.method === "POST" && path === "/v1/chat/completions") {
|
|
149
|
+
const b = await readBody();
|
|
150
|
+
return b ? await proxyChat(env, ctx, who, b.body) : json(400, { error: { message: "invalid JSON body" } });
|
|
151
|
+
}
|
|
152
|
+
if (request.method === "POST" && path === "/v1/embeddings") {
|
|
153
|
+
const b = await readBody();
|
|
154
|
+
return b ? await proxyEmbeddings(env, ctx, who, b.body, b.raw) : json(400, { error: { message: "invalid JSON body" } });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---- control plane ----
|
|
158
|
+
if (path === "/v1/policy") {
|
|
159
|
+
if (request.method === "GET") return json(200, await loadPolicy(env));
|
|
160
|
+
if (request.method === "PUT") {
|
|
161
|
+
const deny = adminOnly();
|
|
162
|
+
if (deny) return deny;
|
|
163
|
+
const b = await readBody();
|
|
164
|
+
if (!b) return json(400, { error: { message: "invalid JSON body" } });
|
|
165
|
+
const models = b.body.models;
|
|
166
|
+
if (models !== undefined && (!Array.isArray(models) || models.some((m) => typeof m !== "string" || !m.trim()))) return json(400, { error: { message: "models must be an array of non-empty strings" } });
|
|
167
|
+
await savePolicy(env, b.body as Policy);
|
|
168
|
+
return json(200, { ok: true });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (path === "/v1/users") {
|
|
172
|
+
const deny = adminOnly();
|
|
173
|
+
if (deny) return deny;
|
|
174
|
+
if (request.method === "GET") return json(200, { users: await listSeats(env) });
|
|
175
|
+
if (request.method === "POST") {
|
|
176
|
+
const b = await readBody();
|
|
177
|
+
const name = String((b?.body.name as string) ?? "").trim();
|
|
178
|
+
const role: "admin" | "dev" = b?.body.role === "admin" ? "admin" : "dev";
|
|
179
|
+
if (!name) return json(400, { error: { message: "missing 'name'" } });
|
|
180
|
+
return json(200, { key: await createSeat(env, name, role), name, role, note: "shown once — store it now" });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
{
|
|
184
|
+
const m = request.method === "DELETE" && path.match(/^\/v1\/users\/([\w]+)$/);
|
|
185
|
+
if (m) {
|
|
186
|
+
const deny = adminOnly();
|
|
187
|
+
if (deny) return deny;
|
|
188
|
+
const name = await disableSeat(env, m[1]!);
|
|
189
|
+
return json(name ? 200 : 404, name ? { ok: true, disabled: name } : { error: { message: "unknown or ambiguous key prefix (send ≥12 chars)" } });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (request.method === "GET" && path === "/v1/usage") {
|
|
193
|
+
const deny = adminOnly();
|
|
194
|
+
if (deny) return deny;
|
|
195
|
+
return json(200, await usageSummary(env, Math.min(Number(url.searchParams.get("days")) || 30, 365)));
|
|
196
|
+
}
|
|
197
|
+
if (request.method === "GET" && path === "/v1/audit") {
|
|
198
|
+
const deny = adminOnly();
|
|
199
|
+
if (deny) return deny;
|
|
200
|
+
const limit = Math.min(Number(url.searchParams.get("limit")) || 200, 2000);
|
|
201
|
+
const { results } = await env.DB.prepare("SELECT ts, user, event, detail FROM audit ORDER BY ts DESC LIMIT ?1").bind(limit).all();
|
|
202
|
+
return json(200, { events: (results ?? []).reverse() });
|
|
203
|
+
}
|
|
204
|
+
if (request.method === "GET" && path === "/v1/enterprise") {
|
|
205
|
+
return json(200, { seats: await seatCount(env), adminKey: !!env.ADA_ADMIN_KEY });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return json(404, { error: { message: "not found" } });
|
|
209
|
+
} catch (err) {
|
|
210
|
+
return json(500, { error: { message: err instanceof Error ? err.message : String(err) } });
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Provider table + router for the Cloudflare Worker backend. A self-contained copy of the Node
|
|
2
|
+
// server's PROVIDERS/route() (src/server/{config,router}.ts) with keys read from Worker `env`
|
|
3
|
+
// bindings instead of process.env — the Node modules can't be imported here (they touch node:fs).
|
|
4
|
+
|
|
5
|
+
export type ProviderName =
|
|
6
|
+
| "openai" | "anthropic" | "google" | "mistral" | "openrouter" | "groq"
|
|
7
|
+
| "deepseek" | "together" | "xai" | "dashscope" | "copilot" | "cloudflare" | "ollama";
|
|
8
|
+
|
|
9
|
+
export interface ProviderDef {
|
|
10
|
+
baseURL: string;
|
|
11
|
+
keyEnv: string; // "" = keyless
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const NAMES: ReadonlySet<string> = new Set([
|
|
15
|
+
"openai", "anthropic", "google", "mistral", "openrouter", "groq",
|
|
16
|
+
"deepseek", "together", "xai", "dashscope", "copilot", "cloudflare", "ollama",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
/** Build the provider table from env (some base URLs are env-driven, e.g. a Cloudflare AI Gateway). */
|
|
20
|
+
export function providers(env: Record<string, unknown>): Record<ProviderName, ProviderDef> {
|
|
21
|
+
const s = (k: string): string | undefined => (typeof env[k] === "string" ? (env[k] as string) : undefined);
|
|
22
|
+
return {
|
|
23
|
+
openai: { baseURL: "https://api.openai.com/v1", keyEnv: "OPENAI_API_KEY" },
|
|
24
|
+
anthropic: { baseURL: "https://api.anthropic.com/v1", keyEnv: "ANTHROPIC_API_KEY" },
|
|
25
|
+
google: { baseURL: "https://generativelanguage.googleapis.com/v1beta/openai", keyEnv: "GEMINI_API_KEY" },
|
|
26
|
+
mistral: { baseURL: "https://api.mistral.ai/v1", keyEnv: "MISTRAL_API_KEY" },
|
|
27
|
+
openrouter: { baseURL: "https://openrouter.ai/api/v1", keyEnv: "OPENROUTER_API_KEY" },
|
|
28
|
+
groq: { baseURL: "https://api.groq.com/openai/v1", keyEnv: "GROQ_API_KEY" },
|
|
29
|
+
deepseek: { baseURL: "https://api.deepseek.com", keyEnv: "DEEPSEEK_API_KEY" },
|
|
30
|
+
together: { baseURL: "https://api.together.xyz/v1", keyEnv: "TOGETHER_API_KEY" },
|
|
31
|
+
xai: { baseURL: "https://api.x.ai/v1", keyEnv: "XAI_API_KEY" },
|
|
32
|
+
dashscope: { baseURL: s("DASHSCOPE_BASE_URL") ?? "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", keyEnv: "DASHSCOPE_API_KEY" },
|
|
33
|
+
copilot: { baseURL: s("COPILOT_BASE_URL") ?? "https://api.githubcopilot.com", keyEnv: "COPILOT_API_KEY" },
|
|
34
|
+
// Cloudflare Workers AI (OpenAI-compatible) — the first-class provider on this backend. Point
|
|
35
|
+
// CLOUDFLARE_BASE_URL at an AI Gateway to get caching/analytics/rate-limiting across providers.
|
|
36
|
+
cloudflare: { baseURL: s("CLOUDFLARE_BASE_URL") ?? `https://api.cloudflare.com/client/v4/accounts/${s("CLOUDFLARE_ACCOUNT_ID") ?? ""}/ai/v1`, keyEnv: "CLOUDFLARE_API_TOKEN" },
|
|
37
|
+
ollama: { baseURL: s("OLLAMA_BASE_URL") ?? "http://localhost:11434/v1", keyEnv: "" },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Model id (+ optional explicit provider) → provider. Identical rules to the Node router. */
|
|
42
|
+
export function route(model: string, explicit?: string): ProviderName {
|
|
43
|
+
if (explicit && NAMES.has(explicit)) return explicit as ProviderName;
|
|
44
|
+
const m = model.toLowerCase();
|
|
45
|
+
if (m.startsWith("@cf/")) return "cloudflare";
|
|
46
|
+
if (m.startsWith("copilot/")) return "copilot";
|
|
47
|
+
if (m.startsWith("groq/")) return "groq";
|
|
48
|
+
if (m.startsWith("together/")) return "together";
|
|
49
|
+
if (m.includes("/")) return "openrouter";
|
|
50
|
+
if (m.includes(":")) return "ollama";
|
|
51
|
+
if (/^(gpt|o1|o3|o4|chatgpt|text-|davinci)/.test(m)) return "openai";
|
|
52
|
+
if (m.startsWith("claude")) return "anthropic";
|
|
53
|
+
if (m.startsWith("gemini") || m.startsWith("gemma")) return "google";
|
|
54
|
+
if (/^(mistral|codestral|magistral|ministral|devstral|pixtral|open-mi)/.test(m)) return "mistral";
|
|
55
|
+
if (m.startsWith("grok")) return "xai";
|
|
56
|
+
if (m.startsWith("deepseek")) return "deepseek";
|
|
57
|
+
if (m.startsWith("qwen") || m.startsWith("qwq")) return "dashscope";
|
|
58
|
+
return "openrouter";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function providerKey(env: Record<string, unknown>, def: ProviderDef): string | undefined {
|
|
62
|
+
if (!def.keyEnv) return undefined;
|
|
63
|
+
const v = env[def.keyEnv];
|
|
64
|
+
return typeof v === "string" && v ? v : undefined;
|
|
65
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
-- D1 schema for the ada Worker backend (seats / policy / usage / audit).
|
|
2
|
+
-- npx wrangler d1 execute ada --file src/worker/schema.sql --remote
|
|
3
|
+
-- Prototype-safe auth by construction: seat lookup is a parameterized `WHERE key = ?` bind.
|
|
4
|
+
|
|
5
|
+
CREATE TABLE IF NOT EXISTS seats (
|
|
6
|
+
key TEXT PRIMARY KEY,
|
|
7
|
+
name TEXT NOT NULL,
|
|
8
|
+
role TEXT NOT NULL DEFAULT 'dev',
|
|
9
|
+
disabled INTEGER NOT NULL DEFAULT 0,
|
|
10
|
+
external_id TEXT, -- OIDC iss#sub (reserved for the SSO follow-up)
|
|
11
|
+
iss TEXT,
|
|
12
|
+
created TEXT NOT NULL
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
CREATE TABLE IF NOT EXISTS policy (
|
|
16
|
+
id INTEGER PRIMARY KEY CHECK (id = 1), -- single row
|
|
17
|
+
json TEXT NOT NULL
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
CREATE TABLE IF NOT EXISTS usage (
|
|
21
|
+
ts INTEGER NOT NULL,
|
|
22
|
+
user TEXT NOT NULL,
|
|
23
|
+
model TEXT NOT NULL,
|
|
24
|
+
provider TEXT NOT NULL,
|
|
25
|
+
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
|
26
|
+
completion_tokens INTEGER NOT NULL DEFAULT 0
|
|
27
|
+
);
|
|
28
|
+
CREATE INDEX IF NOT EXISTS usage_ts ON usage (ts);
|
|
29
|
+
|
|
30
|
+
CREATE TABLE IF NOT EXISTS audit (
|
|
31
|
+
ts INTEGER NOT NULL,
|
|
32
|
+
user TEXT NOT NULL,
|
|
33
|
+
event TEXT NOT NULL,
|
|
34
|
+
detail TEXT NOT NULL DEFAULT ''
|
|
35
|
+
);
|
|
36
|
+
CREATE INDEX IF NOT EXISTS audit_ts ON audit (ts);
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// D1-backed control-plane store for the Worker backend — the edge equivalent of the Node
|
|
2
|
+
// enterprise.ts (seats / policy / usage), strongly consistent via D1 SQL. Schema in schema.sql.
|
|
3
|
+
//
|
|
4
|
+
// Auth is prototype-safe by construction: seat lookup is a parameterized `WHERE key = ?` bind, so a
|
|
5
|
+
// token like "__proto__" is just a string that matches no row. Admin compare is constant-time.
|
|
6
|
+
|
|
7
|
+
export interface Identity {
|
|
8
|
+
user: string;
|
|
9
|
+
role: "admin" | "dev";
|
|
10
|
+
}
|
|
11
|
+
export interface Policy {
|
|
12
|
+
models?: string[];
|
|
13
|
+
permissions?: Array<{ tool?: string; pattern?: string; action: "allow" | "ask" | "deny" }>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface Env {
|
|
17
|
+
DB: D1Database;
|
|
18
|
+
ADA_ADMIN_KEY?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Constant-time string compare (Workers has no crypto.timingSafeEqual). */
|
|
22
|
+
function ctEqual(a: string, b: string): boolean {
|
|
23
|
+
if (a.length !== b.length) return false;
|
|
24
|
+
let r = 0;
|
|
25
|
+
for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
26
|
+
return r === 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function hex(bytes: Uint8Array): string {
|
|
30
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Resolve a bearer token → identity, or null. Admin key (env) first, then the seats table. */
|
|
34
|
+
export async function identifySeat(env: Env, token: string): Promise<Identity | null> {
|
|
35
|
+
const admin = env.ADA_ADMIN_KEY;
|
|
36
|
+
if (admin && ctEqual(token, admin)) return { user: "admin", role: "admin" };
|
|
37
|
+
if (!token.startsWith("ada_sk_")) return null; // format guard
|
|
38
|
+
const row = await env.DB.prepare("SELECT name, role, disabled FROM seats WHERE key = ?1").bind(token).first<{ name: string; role: string; disabled: number }>();
|
|
39
|
+
if (!row || row.disabled) return null;
|
|
40
|
+
return { user: row.name, role: row.role === "admin" ? "admin" : "dev" };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function createSeat(env: Env, name: string, role: "admin" | "dev"): Promise<string> {
|
|
44
|
+
const key = "ada_sk_" + hex(crypto.getRandomValues(new Uint8Array(24)));
|
|
45
|
+
await env.DB.prepare("INSERT INTO seats (key, name, role, disabled, created) VALUES (?1, ?2, ?3, 0, ?4)")
|
|
46
|
+
.bind(key, name, role, new Date().toISOString()).run();
|
|
47
|
+
await appendAudit(env, { ts: Date.now(), user: "-", event: "seat_created", detail: `${name} (${role})` });
|
|
48
|
+
return key;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function listSeats(env: Env): Promise<Array<{ name: string; role: string; disabled: number; created: string; keyPrefix: string }>> {
|
|
52
|
+
const { results } = await env.DB.prepare("SELECT key, name, role, disabled, created FROM seats ORDER BY created").all<{ key: string; name: string; role: string; disabled: number; created: string }>();
|
|
53
|
+
return (results ?? []).map((r) => ({ name: r.name, role: r.role, disabled: r.disabled, created: r.created, keyPrefix: r.key.slice(0, 14) }));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function disableSeat(env: Env, prefix: string): Promise<string | null> {
|
|
57
|
+
if (prefix.length < 12) return null;
|
|
58
|
+
const rows = await env.DB.prepare("SELECT key, name FROM seats WHERE key LIKE ?1").bind(prefix + "%").all<{ key: string; name: string }>();
|
|
59
|
+
if ((rows.results?.length ?? 0) !== 1) return null;
|
|
60
|
+
const seat = rows.results![0]!;
|
|
61
|
+
await env.DB.prepare("UPDATE seats SET disabled = 1 WHERE key = ?1").bind(seat.key).run();
|
|
62
|
+
await appendAudit(env, { ts: Date.now(), user: "-", event: "seat_disabled", detail: seat.name });
|
|
63
|
+
return seat.name;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function loadPolicy(env: Env): Promise<Policy> {
|
|
67
|
+
const row = await env.DB.prepare("SELECT json FROM policy WHERE id = 1").first<{ json: string }>();
|
|
68
|
+
if (!row) return {};
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(row.json) as Policy;
|
|
71
|
+
} catch {
|
|
72
|
+
return {}; // a corrupt single row shouldn't wedge routing; treat as no-allowlist
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function savePolicy(env: Env, p: Policy): Promise<void> {
|
|
77
|
+
await env.DB.prepare("INSERT INTO policy (id, json) VALUES (1, ?1) ON CONFLICT(id) DO UPDATE SET json = ?1").bind(JSON.stringify(p)).run();
|
|
78
|
+
await appendAudit(env, { ts: Date.now(), user: "-", event: "policy_updated", detail: JSON.stringify(p).slice(0, 300) });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function globMatch(pattern: string, s: string): boolean {
|
|
82
|
+
const re = new RegExp("^" + pattern.split("*").map((x) => x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$", "i");
|
|
83
|
+
return re.test(s);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function modelAllowed(model: string, policy: Policy): boolean {
|
|
87
|
+
if (!Array.isArray(policy.models) || !policy.models.length) return true;
|
|
88
|
+
return policy.models.some((p) => globMatch(p, model));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface UsageRow { ts: number; user: string; model: string; provider: string; promptTokens: number; completionTokens: number }
|
|
92
|
+
|
|
93
|
+
export async function appendUsage(env: Env, r: UsageRow): Promise<void> {
|
|
94
|
+
try {
|
|
95
|
+
await env.DB.prepare("INSERT INTO usage (ts, user, model, provider, prompt_tokens, completion_tokens) VALUES (?1,?2,?3,?4,?5,?6)")
|
|
96
|
+
.bind(r.ts, r.user, r.model, r.provider, r.promptTokens, r.completionTokens).run();
|
|
97
|
+
} catch { /* metering is best-effort; never fail a request over it */ }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function appendAudit(env: Env, r: { ts: number; user: string; event: string; detail: string }): Promise<void> {
|
|
101
|
+
try {
|
|
102
|
+
await env.DB.prepare("INSERT INTO audit (ts, user, event, detail) VALUES (?1,?2,?3,?4)").bind(r.ts, r.user, r.event, r.detail).run();
|
|
103
|
+
} catch { /* best-effort */ }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function usageSummary(env: Env, days: number): Promise<unknown> {
|
|
107
|
+
const since = Date.now() - days * 86_400_000;
|
|
108
|
+
const agg = async (groupBy: string): Promise<Record<string, unknown>> => {
|
|
109
|
+
const { results } = await env.DB.prepare(`SELECT ${groupBy} AS k, COUNT(*) AS requests, SUM(prompt_tokens) AS promptTokens, SUM(completion_tokens) AS completionTokens FROM usage WHERE ts >= ?1 GROUP BY ${groupBy}`).bind(since).all<{ k: string; requests: number; promptTokens: number; completionTokens: number }>();
|
|
110
|
+
const out: Record<string, unknown> = {};
|
|
111
|
+
for (const r of results ?? []) out[r.k] = { requests: r.requests, promptTokens: r.promptTokens ?? 0, completionTokens: r.completionTokens ?? 0 };
|
|
112
|
+
return out;
|
|
113
|
+
};
|
|
114
|
+
const totalsRow = await env.DB.prepare("SELECT COUNT(*) AS requests, SUM(prompt_tokens) AS promptTokens, SUM(completion_tokens) AS completionTokens FROM usage WHERE ts >= ?1").bind(since).first<{ requests: number; promptTokens: number; completionTokens: number }>();
|
|
115
|
+
return {
|
|
116
|
+
since,
|
|
117
|
+
totals: { requests: totalsRow?.requests ?? 0, promptTokens: totalsRow?.promptTokens ?? 0, completionTokens: totalsRow?.completionTokens ?? 0 },
|
|
118
|
+
byUser: await agg("user"),
|
|
119
|
+
byModel: await agg("model"),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function seatCount(env: Env): Promise<number> {
|
|
124
|
+
const row = await env.DB.prepare("SELECT COUNT(*) AS n FROM seats WHERE disabled = 0").first<{ n: number }>();
|
|
125
|
+
return row?.n ?? 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Pull the LAST real `"usage": {…}` object from streamed/response text (copy of enterprise.ts). */
|
|
129
|
+
export function extractLastUsage(text: string): { promptTokens: number; completionTokens: number } | null {
|
|
130
|
+
const matchBraces = (t: string, start: number): string | null => {
|
|
131
|
+
let depth = 0, inStr = false, esc = false;
|
|
132
|
+
for (let i = start; i < t.length; i++) {
|
|
133
|
+
const c = t[i];
|
|
134
|
+
if (inStr) { if (esc) esc = false; else if (c === "\\") esc = true; else if (c === '"') inStr = false; }
|
|
135
|
+
else if (c === '"') inStr = true;
|
|
136
|
+
else if (c === "{") depth++;
|
|
137
|
+
else if (c === "}" && --depth === 0) return t.slice(start, i + 1);
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
};
|
|
141
|
+
let at = text.lastIndexOf('"usage"');
|
|
142
|
+
while (at >= 0) {
|
|
143
|
+
const brace = text.indexOf("{", at + 7);
|
|
144
|
+
const colon = text.indexOf(":", at + 7);
|
|
145
|
+
if (brace >= 0 && colon >= 0 && text.slice(colon + 1, brace).trim() === "") {
|
|
146
|
+
const obj = matchBraces(text, brace);
|
|
147
|
+
if (obj) {
|
|
148
|
+
try {
|
|
149
|
+
const u = JSON.parse(obj) as { prompt_tokens?: number; completion_tokens?: number };
|
|
150
|
+
if (u.prompt_tokens != null || u.completion_tokens != null) return { promptTokens: u.prompt_tokens ?? 0, completionTokens: u.completion_tokens ?? 0 };
|
|
151
|
+
} catch { /* keep scanning back */ }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
at = text.lastIndexOf('"usage"', at - 1);
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|