@tangle-network/create-agent-app 0.46.43 → 0.46.44

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/create-agent-app",
3
- "version": "0.46.43",
3
+ "version": "0.46.44",
4
4
  "description": "Create a tested Tangle agent application with either a tool loop or a complete chat interface.",
5
5
  "keywords": [
6
6
  "tangle",
@@ -21,17 +21,17 @@
21
21
  "@tangle-network/agent-app": "__AGENT_APP_VERSION__"
22
22
  },
23
23
  "peerDependencies": {
24
- "@tangle-network/agent-eval": "0.172.1",
24
+ "@tangle-network/agent-eval": "0.173.1",
25
25
  "@tangle-network/agent-integrations": ">=0.53.55 <0.54.0",
26
- "@tangle-network/agent-interface": "2.2.0",
27
- "@tangle-network/agent-runtime": "0.189.0"
26
+ "@tangle-network/agent-interface": "2.3.0",
27
+ "@tangle-network/agent-runtime": "0.191.0"
28
28
  },
29
29
  "devDependencies": {
30
- "@tangle-network/agent-eval": "0.172.1",
30
+ "@tangle-network/agent-eval": "0.173.1",
31
31
  "@tangle-network/agent-integrations": "0.53.55",
32
- "@tangle-network/agent-interface": "2.2.0",
33
- "@tangle-network/agent-knowledge": "13.0.0",
34
- "@tangle-network/agent-runtime": "0.189.0",
32
+ "@tangle-network/agent-interface": "2.3.0",
33
+ "@tangle-network/agent-knowledge": "13.0.1",
34
+ "@tangle-network/agent-runtime": "0.191.0",
35
35
  "@tangle-network/sandbox": "0.36.4",
36
36
  "@types/node": "^22.20.1",
37
37
  "typescript": "^7.0.2",
@@ -3,9 +3,10 @@
3
3
  You are a coding agent working in a project generated by `create-agent-app --chat`.
4
4
  This project is a thin customization layer on top of `@tangle-network/agent-app`
5
5
  (the shell): the whole server chat vertical — auth, thread/message persistence,
6
- streaming turns with buffered replay, multimodal uploads, human-in-the-loop asks
7
- is ASSEMBLED from shell factories, not written here. Walk this contract before you
8
- touch anything. It is a checklist, not prose — follow it in order.
6
+ streaming turns with buffered replay, multimodal uploads, human-in-the-loop asks,
7
+ and API access — is ASSEMBLED from shell factories, not written here.
8
+ Walk this contract before you touch anything.
9
+ It is a checklist, not prose — follow it in order.
9
10
 
10
11
  ## 0. Orient
11
12
 
@@ -30,14 +31,16 @@ touch anything. It is a checklist, not prose — follow it in order.
30
31
  ## 2. DATA vs CODE — know which file you're in
31
32
 
32
33
  - [ ] DATA → `agent.config.ts`: name, system prompt, model default + effort,
33
- harness, renderable ask kinds. Plain values. If you're writing an `if`,
34
- you're in the wrong file.
34
+ provider input limit, harness, gateway pricing, and renderable ask kinds.
35
+ Plain values. If you're writing an `if`, you're in the wrong file.
35
36
  - [ ] DATA → `prompts/system.md`: the persona. State intents and hard rules,
36
37
  never implementations — no shell commands, CLI flags, or install scripts.
37
38
  The executing agent chooses tools at execution time.
38
39
  - [ ] CODE → `src/chat.ts`: the COMPOSER. Wires config + env into the shell's
39
40
  factories. Extend seams here (billing hooks, `transformFinalText`,
40
41
  `onTurnComplete`); never re-implement what a factory already does.
42
+ - [ ] CODE → `src/gateway.ts`: API keys and OpenAI-compatible requests enter
43
+ the same owned thread and chat route.
41
44
  - [ ] CODE → `src/sandbox.ts`: the sandbox lane (box naming, credentials,
42
45
  profile). All agent intelligence lives IN the sandbox; this file only
43
46
  reaches it.
@@ -26,6 +26,8 @@ Discovery: **Whose job does this agent do, in whose voice, under what hard rules
26
26
  Discovery: **Which model answers by default, at what effort, on which harness?**
27
27
 
28
28
  - [ ] Set `model.default` to a model your Tangle Router key can reach.
29
+ - [ ] Set `gateway.maxProviderInputTokens` to that model's full input limit.
30
+ This covers retained tool and sidecar history that the transcript omits.
29
31
  - [ ] Pick `harness` (`opencode` default; vendor-locked harnesses like
30
32
  `claude-code` must pair with their own provider's models).
31
33
  - [ ] Define the selectable profile catalog for the UI and map each selected
@@ -43,9 +45,11 @@ Discovery: **Where does this app live and what may it spend?**
43
45
  - [ ] `wrangler d1 create <name>` → paste `database_id` into `wrangler.toml`.
44
46
  - [ ] Copy `.dev.vars.example` → `.dev.vars`; fill `BETTER_AUTH_SECRET`,
45
47
  `TANGLE_API_KEY`, `SANDBOX_API_KEY`, `SANDBOX_GATEWAY_URL`.
46
- - [ ] `pnpm db:migrate:local` — applies `migrations/0001_init.sql` (auth +
47
- chat + turn buffer). The e2e test executes this same file, so it cannot
48
- silently drift from the schema.
48
+ - [ ] `pnpm db:migrate:local` — applies both migrations (auth, chat, turn
49
+ buffer, API keys, usage, and request limits).
50
+ The e2e test executes these same files, so they cannot drift from the schema.
51
+ - [ ] Existing generated app: copy and apply `0002_agent_gateway.sql`.
52
+ Never edit its already-applied `0001_init.sql`.
49
53
  - [ ] Existing app only: add `sandbox_prewarm_claims` in a new migration.
50
54
  Do not edit an applied `0001_init.sql`; Wrangler will not run it again.
51
55
  - [ ] R2 stays commented out unless the product stores artifacts.
@@ -59,6 +63,9 @@ Discovery: **Does a real message round-trip through a real box?**
59
63
  and a usage receipt, and a second turn continues the same agent session.
60
64
  - [ ] Kill the tab mid-turn, reopen the thread — the persisted row is intact
61
65
  (the turn keeps running server-side and buffers for replay).
66
+ - [ ] Create a key through the signed-in `/api/keys` route.
67
+ - [ ] Call `/v1/agents/<slug>/chat/completions` with that key.
68
+ Open the returned `X-Tangle-Thread-Url` and confirm it shows the same durable conversation.
62
69
 
63
70
  ## ⑤ The product UI — replace the dev page
64
71
 
@@ -4,7 +4,8 @@ A multimodal chat agent product scaffolded with `create-agent-app --chat`, built
4
4
  on [`@tangle-network/agent-app`](https://github.com/tangle-network/agent-app):
5
5
  the whole server chat vertical — better-auth sessions, thread/message
6
6
  persistence with typed parts + usage receipts, streaming turns with buffered
7
- replay, file uploads, human-in-the-loop asks assembled from shell factories.
7
+ replay, file uploads, human-in-the-loop asks, personal API keys, and
8
+ OpenAI-compatible access — assembled from shell factories.
8
9
  The agent itself runs in a Tangle sandbox (a full harness: skills, tools, bash,
9
10
  MCP); this app coordinates UI, durability, and access around it.
10
11
 
@@ -15,9 +16,11 @@ MCP); this app coordinates UI, durability, and access around it.
15
16
  | `agent.config.ts` | DATA — name, model default, harness, ask kinds | defining the agent |
16
17
  | `prompts/system.md` | DATA — the persona (imported as a Text module) | shaping behavior |
17
18
  | `src/chat.ts` | CODE — the composer (factories → the chat vertical) | extending seams |
19
+ | `src/gateway.ts` | CODE — API keys → the same chat vertical | API pricing or publication changes |
18
20
  | `src/sandbox.ts` | CODE — the sandbox lane (boxes, credentials, profile) | provisioning changes |
19
21
  | `src/worker.ts` | CODE — HTTP routing only | adding an endpoint |
20
22
  | `src/db/schema.ts` | CODE — auth tables + `createChatTables()` | schema changes (+ migration) |
23
+ | `migrations/0002_agent_gateway.sql` | CODE — API keys, usage, and request claims | upgrading an existing generated app |
21
24
  | `migrations/` | SQL the e2e test executes for real | schema changes |
22
25
  | `public/index.html` | the dev chat page (not the product UI) | never — replace it (CUSTOMIZE ⑤) |
23
26
  | `tests/` | the e2e turn gate this app ships with | extending coverage |
@@ -20,21 +20,23 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@tangle-network/agent-app": "__AGENT_APP_VERSION__",
23
- "@tangle-network/agent-interface": "2.2.0",
24
- "@tangle-network/agent-runtime": "0.189.0",
23
+ "@tangle-network/agent-gateway": "0.8.16",
24
+ "@tangle-network/agent-interface": "2.3.0",
25
+ "@tangle-network/agent-runtime": "0.191.0",
25
26
  "@tangle-network/sandbox": "0.36.4",
26
27
  "better-auth": "^1.7.2",
27
28
  "drizzle-orm": "^0.45.2",
29
+ "hono": "^4.13.5",
28
30
  "viem": "^2.0.0"
29
31
  },
30
32
  "peerDependencies": {
31
- "@tangle-network/agent-eval": "0.172.1",
33
+ "@tangle-network/agent-eval": "0.173.1",
32
34
  "@tangle-network/agent-integrations": ">=0.53.55 <0.54.0"
33
35
  },
34
36
  "devDependencies": {
35
- "@tangle-network/agent-eval": "0.172.1",
37
+ "@tangle-network/agent-eval": "0.173.1",
36
38
  "@tangle-network/agent-integrations": "0.53.55",
37
- "@tangle-network/agent-knowledge": "13.0.0",
39
+ "@tangle-network/agent-knowledge": "13.0.1",
38
40
  "@cloudflare/workers-types": "^5.20260827.1",
39
41
  "@types/better-sqlite3": "^9.6.0",
40
42
  "@types/node": "^22.20.1",
@@ -44,7 +44,7 @@ export const config = {
44
44
  * and review it with your default: a stale ladder degrades to today's
45
45
  * behavior (the turn fails), never to a silent wrong-model answer.
46
46
  */
47
- fallbacks: ['gemini-2.5-flash-lite', 'gpt-5-mini'],
47
+ fallbacks: ['gemini-3.7-flash', 'glm-5.3'],
48
48
  /** Default reasoning effort for turns that don't specify one. */
49
49
  effort: 'auto',
50
50
  },
@@ -62,12 +62,35 @@ export const config = {
62
62
  * a card no client will show.
63
63
  */
64
64
  interactions: { question: true, plan: true },
65
+
66
+ /** API-key access to this workspace agent. The browser and API share one
67
+ * thread store and one sandbox turn path. */
68
+ gateway: {
69
+ enabled: true,
70
+ description: '__PROJECT_NAME__ workspace agent',
71
+ pricePerTokenUsd: 0.00002,
72
+ platformFeePercent: 0.20,
73
+ /** Maximum provider input for the configured model. Include retained
74
+ * sidecar history and tool output that is absent from the transcript. */
75
+ maxProviderInputTokens: 1_000_000,
76
+ defaultOutputTokens: 1024,
77
+ maxOutputTokens: 4096,
78
+ },
65
79
  } as const satisfies {
66
80
  name: string
67
81
  systemPrompt: string
68
82
  model: { default: string; fallbacks: readonly string[]; effort: 'auto' | 'low' | 'medium' | 'high' }
69
83
  harness: Harness
70
84
  interactions: { question?: boolean; permission?: boolean; plan?: boolean }
85
+ gateway: {
86
+ enabled: boolean
87
+ description: string
88
+ pricePerTokenUsd: number
89
+ platformFeePercent: number
90
+ maxProviderInputTokens: number
91
+ defaultOutputTokens: number
92
+ maxOutputTokens: number
93
+ }
71
94
  }
72
95
 
73
96
  export type Config = typeof config
@@ -0,0 +1,65 @@
1
+ -- External OpenAI-compatible API storage from @tangle-network/agent-gateway.
2
+ -- Keep this separate from 0001 so an existing generated app can apply it.
3
+
4
+ CREATE TABLE IF NOT EXISTS agent_api_key (
5
+ id TEXT PRIMARY KEY,
6
+ user_id TEXT NOT NULL,
7
+ name TEXT NOT NULL,
8
+ key_hash TEXT NOT NULL,
9
+ key_prefix TEXT NOT NULL,
10
+ scopes TEXT NOT NULL,
11
+ rate_limit INTEGER NOT NULL,
12
+ daily_limit INTEGER NOT NULL,
13
+ spending_limit_cents BIGINT,
14
+ spent_cents BIGINT NOT NULL DEFAULT 0,
15
+ last_used_at BIGINT,
16
+ expires_at BIGINT,
17
+ created_at BIGINT NOT NULL
18
+ );
19
+ CREATE INDEX IF NOT EXISTS idx_agent_api_key_user ON agent_api_key (user_id, created_at);
20
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_api_key_hash_unique ON agent_api_key (key_hash);
21
+
22
+ CREATE TABLE IF NOT EXISTS agent_api_key_usage (
23
+ request_id TEXT PRIMARY KEY,
24
+ key_id TEXT NOT NULL,
25
+ cost_cents BIGINT NOT NULL,
26
+ created_at BIGINT NOT NULL,
27
+ FOREIGN KEY (key_id) REFERENCES agent_api_key(id) ON DELETE CASCADE
28
+ );
29
+ CREATE INDEX IF NOT EXISTS idx_agent_api_key_usage_key ON agent_api_key_usage (key_id, created_at);
30
+
31
+ CREATE TABLE IF NOT EXISTS agent_api_key_request (
32
+ request_id TEXT PRIMARY KEY,
33
+ key_id TEXT NOT NULL,
34
+ claim_sequence BIGINT NOT NULL,
35
+ day_bucket BIGINT NOT NULL,
36
+ created_at BIGINT NOT NULL,
37
+ FOREIGN KEY (key_id) REFERENCES agent_api_key(id) ON DELETE CASCADE
38
+ );
39
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_api_key_request_sequence
40
+ ON agent_api_key_request (key_id, claim_sequence);
41
+ CREATE INDEX IF NOT EXISTS idx_agent_api_key_request_window
42
+ ON agent_api_key_request (key_id, created_at);
43
+ CREATE INDEX IF NOT EXISTS idx_agent_api_key_request_day
44
+ ON agent_api_key_request (key_id, day_bucket);
45
+
46
+ CREATE TABLE IF NOT EXISTS agent_gateway_usage (
47
+ request_id TEXT PRIMARY KEY,
48
+ agent_id TEXT NOT NULL,
49
+ agent_slug TEXT NOT NULL,
50
+ consumer_id TEXT NOT NULL,
51
+ payment_method TEXT NOT NULL,
52
+ input_tokens INTEGER NOT NULL,
53
+ output_tokens INTEGER NOT NULL,
54
+ reasoning_tokens INTEGER,
55
+ tool_tokens INTEGER,
56
+ tool_call_count INTEGER,
57
+ provider_cost_nanodollars BIGINT,
58
+ total_cost_nanodollars BIGINT NOT NULL,
59
+ owner_earned_nanodollars BIGINT NOT NULL,
60
+ platform_fee_nanodollars BIGINT NOT NULL,
61
+ duration_ms BIGINT NOT NULL,
62
+ settlement_basis TEXT,
63
+ created_at BIGINT NOT NULL
64
+ );
65
+ CREATE INDEX IF NOT EXISTS idx_agent_gateway_usage_agent ON agent_gateway_usage (agent_id, created_at);
@@ -73,6 +73,7 @@
73
73
 
74
74
  <script type="module">
75
75
  const $ = (id) => document.getElementById(id)
76
+ const initialThreadId = new URL(location.href).searchParams.get('threadId')
76
77
  const state = { threadId: null, parts: [], signUp: false }
77
78
 
78
79
  const api = async (path, init) => {
@@ -148,6 +149,9 @@ function renderParts(container, parts = []) {
148
149
 
149
150
  async function openThread(id) {
150
151
  state.threadId = id
152
+ const url = new URL(location.href)
153
+ url.searchParams.set('threadId', id)
154
+ history.replaceState(null, '', url)
151
155
  const { messages } = await api(`/api/threads/${id}/messages`)
152
156
  $('transcript').replaceChildren()
153
157
  for (const m of messages) {
@@ -227,10 +231,25 @@ $('composer').onsubmit = async (e) => {
227
231
  await runTurn(content, parts).catch((err) => { $('error').textContent = err.message })
228
232
  }
229
233
 
230
- $('new-thread').onclick = () => { state.threadId = null; $('transcript').replaceChildren() }
234
+ $('new-thread').onclick = () => {
235
+ state.threadId = null
236
+ const url = new URL(location.href)
237
+ url.searchParams.delete('threadId')
238
+ history.replaceState(null, '', url)
239
+ $('transcript').replaceChildren()
240
+ }
231
241
 
232
242
  await ensureSession()
233
243
  await loadThreads()
244
+ if (initialThreadId) {
245
+ await openThread(initialThreadId).catch((err) => {
246
+ state.threadId = null
247
+ $('error').textContent = err.message
248
+ const url = new URL(location.href)
249
+ url.searchParams.delete('threadId')
250
+ history.replaceState(null, '', url)
251
+ })
252
+ }
234
253
  </script>
235
254
  </body>
236
255
  </html>
@@ -58,6 +58,9 @@ export interface ChatAppOverrides {
58
58
  produce?: (args: ChatTurnProduceArgs<void>) => ChatTurnRouteProducer | Promise<ChatTurnRouteProducer>
59
59
  /** Test seam: where large uploads land. Default: the workspace box's fs. */
60
60
  uploadSink?: (scope: { workspaceId: string; userId: string }) => Promise<SandboxUploadSink | null>
61
+ /** Server-trusted identity for a private gateway chat assembly. Never read
62
+ * this value from a request body or header. */
63
+ trustedUserId?: string
61
64
  }
62
65
 
63
66
  export interface ChatApp {
@@ -94,6 +97,9 @@ export function buildChatApp(env: AppEnv, overrides: ChatAppOverrides = {}): Cha
94
97
  /** Session → identity + thread access, for both routes and seams. Guards
95
98
  * throw JSON Responses; `guardResolution` adapts them to `{ ok, response }`. */
96
99
  async function requireUser(request: Request) {
100
+ if (overrides.trustedUserId) {
101
+ return { ok: true as const, value: { user: { id: overrides.trustedUserId } } }
102
+ }
97
103
  return guardResolution(() => auth.requireApiUser(request))
98
104
  }
99
105
 
@@ -0,0 +1,162 @@
1
+ /**
2
+ * API-key access to the same persisted chat path the browser uses.
3
+ *
4
+ * The gateway translates OpenAI-compatible requests. The chat route still owns
5
+ * the sandbox turn, transcript, replay buffer, and failure handling.
6
+ */
7
+
8
+ import { config } from '../agent.config'
9
+ import { streamChatRouteAsSandboxEvents } from '@tangle-network/agent-app/chat-routes'
10
+ import {
11
+ createAgentGateway,
12
+ createApiKeyRequestClaim,
13
+ createApiKeyRoutes,
14
+ createApiKeyUsageSettlement,
15
+ d1ToSqlAdapter,
16
+ SqlApiKeyStore,
17
+ SqlGatewayUsageStore,
18
+ verifyApiKeyFromStore,
19
+ type SqlAdapter,
20
+ } from '@tangle-network/agent-gateway'
21
+ import { Hono } from 'hono'
22
+ import { buildChatApp, type ChatApp } from './chat'
23
+ import type { AppEnv } from './env'
24
+ import { appSlug } from './sandbox'
25
+
26
+ export interface GatewayAppOptions {
27
+ /** Keep the persisted chat turn alive after an API client disconnects. */
28
+ waitUntil?: (promise: Promise<unknown>) => void
29
+ /** Test override. Production uses the D1 adapter from agent-gateway. */
30
+ sql?: SqlAdapter
31
+ /** Test override. Production builds one private, trusted chat assembly. */
32
+ createTrustedChatApp?: (ownerId: string) => ChatApp
33
+ }
34
+
35
+ async function sessionUserId(app: ChatApp, request: Request): Promise<string | null> {
36
+ try {
37
+ return (await app.auth.getSession(request))?.user.id ?? null
38
+ } catch {
39
+ return null
40
+ }
41
+ }
42
+
43
+ async function ensureOwnedThread(
44
+ app: ChatApp,
45
+ threadId: string,
46
+ ownerId: string,
47
+ firstMessage: string,
48
+ ): Promise<void> {
49
+ const existing = await app.store.getThread(threadId)
50
+ if (existing) {
51
+ if (existing.workspaceId !== ownerId) throw new Error('Gateway thread is unavailable')
52
+ return
53
+ }
54
+
55
+ try {
56
+ await app.store.createThread({ id: threadId, workspaceId: ownerId, firstMessage })
57
+ } catch (error) {
58
+ // Two requests can create the same caller-supplied conversation at once.
59
+ // The primary key chooses one winner; the loser adopts that owned row.
60
+ const raced = await app.store.getThread(threadId)
61
+ if (!raced || raced.workspaceId !== ownerId) throw error
62
+ }
63
+ }
64
+
65
+ /** Build the external API surface. The Worker mounts only these Hono routes. */
66
+ export function buildGatewayApp(
67
+ env: AppEnv,
68
+ chatApp: ChatApp,
69
+ options: GatewayAppOptions = {},
70
+ ) {
71
+ const sql = options.sql ?? d1ToSqlAdapter(env.DB)
72
+ const apiKeys = new SqlApiKeyStore(sql)
73
+ const usage = new SqlGatewayUsageStore(sql)
74
+ const trustedChatApp = options.createTrustedChatApp
75
+ ?? ((ownerId: string) => buildChatApp(env, { trustedUserId: ownerId }))
76
+
77
+ const verifyKey = (authorization: string) =>
78
+ verifyApiKeyFromStore(authorization, apiKeys)
79
+
80
+ const gateway = createAgentGateway({
81
+ resolveAgent: async (slug) => slug === appSlug && config.gateway.enabled
82
+ ? {
83
+ id: appSlug,
84
+ ownerId: appSlug,
85
+ slug: appSlug,
86
+ systemPrompt: config.systemPrompt,
87
+ pricePerTokenUsd: config.gateway.pricePerTokenUsd,
88
+ platformFeePercent: config.gateway.platformFeePercent,
89
+ sandboxEndpoint: null,
90
+ remoteSandboxId: null,
91
+ remoteBearerToken: null,
92
+ enabled: true,
93
+ harness: config.harness,
94
+ harnessModel: config.model.default,
95
+ description: config.gateway.description,
96
+ }
97
+ : null,
98
+ verifyApiKey: verifyKey,
99
+ claimApiKeyRequest: createApiKeyRequestClaim(apiKeys),
100
+ apiKeyPrefix: 'ak_',
101
+ conversationMode: 'thread',
102
+ // A2A task control remains off until the shared gateway owns durable
103
+ // cross-isolate cancel and replay. OpenAI-compatible calls are independent.
104
+ a2a: false,
105
+ authorizeConsumer: async (_agent, consumer) => {
106
+ if (consumer.method !== 'apikey' || !consumer.ownerId || !consumer.threadId) {
107
+ return { allow: false, reason: 'API key owner is unavailable', code: 'owner_unavailable' }
108
+ }
109
+ const thread = await chatApp.store.getThread(consumer.threadId)
110
+ if (thread && thread.workspaceId !== consumer.ownerId) {
111
+ return { allow: false, reason: 'Thread is unavailable', code: 'thread_unavailable' }
112
+ }
113
+ return { allow: true }
114
+ },
115
+ // A sandbox session can retain private tool output that is absent from the
116
+ // visible transcript. The model input limit is the only complete bound.
117
+ unauthenticatedInputTokenBound: config.gateway.maxProviderInputTokens,
118
+ getSandbox: async (_agent, context) => {
119
+ const ownerId = context?.keyInfo?.ownerId
120
+ const threadId = context?.threadId
121
+ if (!ownerId || !threadId) throw new Error('Gateway request has no verified owner or thread')
122
+
123
+ const firstMessage = context.messages
124
+ .filter((message) => message.role === 'user')
125
+ .at(-1)?.content ?? ''
126
+ const app = trustedChatApp(ownerId)
127
+ await ensureOwnedThread(app, threadId, ownerId, firstMessage)
128
+
129
+ return {
130
+ streamPrompt: (message, streamOptions) => streamChatRouteAsSandboxEvents({
131
+ routes: app.routes,
132
+ request: new Request(`${env.BETTER_AUTH_URL}/api/chat`, {
133
+ headers: { 'X-Gateway-Request-Id': context.requestId },
134
+ }),
135
+ payload: {
136
+ workspaceId: ownerId,
137
+ threadId,
138
+ content: message,
139
+ turnId: context.requestId,
140
+ },
141
+ waitUntil: options.waitUntil,
142
+ signal: streamOptions?.signal,
143
+ executionLimits: streamOptions?.executionBudget,
144
+ }),
145
+ }
146
+ },
147
+ recordUsage: usage.recordUsage,
148
+ ...(options.waitUntil ? { continueOnDisconnect: options.waitUntil } : {}),
149
+ settlePayment: createApiKeyUsageSettlement(apiKeys),
150
+ defaultOutputTokens: config.gateway.defaultOutputTokens,
151
+ maxOutputTokens: config.gateway.maxOutputTokens,
152
+ })
153
+
154
+ const app = new Hono()
155
+ app.route('/api/keys', createApiKeyRoutes({
156
+ store: apiKeys,
157
+ prefix: 'ak_',
158
+ getAuthUserId: (request) => sessionUserId(chatApp, request),
159
+ }))
160
+ app.route('/v1/agents', gateway)
161
+ return app
162
+ }
@@ -107,6 +107,7 @@ export function createSandboxProduce(env: AppEnv) {
107
107
  identity,
108
108
  prompt,
109
109
  executionId,
110
+ executionLimits,
110
111
  }: ChatTurnProduceArgs<void>): Promise<ChatTurnRouteProducer> => {
111
112
  const box = await ensureForegroundWorkspaceSandbox(env, shell, {
112
113
  workspaceId: identity.tenantId,
@@ -131,7 +132,24 @@ export function createSandboxProduce(env: AppEnv) {
131
132
  executionId: attempt === 1 ? executionId : `${executionId}-f${attempt}`,
132
133
  model: attemptModel,
133
134
  signal,
134
- effort: body.effort ?? config.model.effort,
135
+ // A zero reasoning allowance maps to the profile's supported
136
+ // no-reasoning control. Positive limits use token ceilings below.
137
+ effort: executionLimits?.maxReasoningTokens === 0
138
+ ? 'none'
139
+ : (body.effort ?? config.model.effort),
140
+ ...(executionLimits?.maxOutputTokens
141
+ ? { maxOutputTokens: executionLimits.maxOutputTokens }
142
+ : {}),
143
+ ...(executionLimits?.maxReasoningTokens
144
+ ? { maxReasoningTokens: executionLimits.maxReasoningTokens }
145
+ : {}),
146
+ ...(executionLimits?.maxOutputTokens !== undefined
147
+ && executionLimits.maxReasoningTokens !== undefined
148
+ ? {
149
+ maxTotalOutputTokens:
150
+ executionLimits.maxOutputTokens + executionLimits.maxReasoningTokens,
151
+ }
152
+ : {}),
135
153
  harness: config.harness,
136
154
  systemPrompt: config.systemPrompt,
137
155
  // Durable by default: the run keeps executing server-side if the operator
@@ -14,45 +14,120 @@
14
14
  * POST /api/chat/upload multipart upload → prompt parts
15
15
  * GET /api/chat/interactions outstanding agent asks (?threadId=)
16
16
  * POST /api/chat/interactions answer an ask
17
+ * CRUD /api/keys manage personal API keys
18
+ * POST /v1/agents/:slug/chat/completions OpenAI-compatible API
17
19
  */
18
20
 
19
- import { buildChatApp } from './chat'
21
+ import { config } from '../agent.config'
22
+ import { buildChatApp, type ChatApp } from './chat'
20
23
  import type { AppEnv } from './env'
24
+ import {
25
+ buildGatewayApp,
26
+ type GatewayAppOptions,
27
+ } from './gateway'
21
28
 
22
- export default {
23
- async fetch(request: Request, env: AppEnv, ctx: ExecutionContext): Promise<Response> {
24
- // Per-request assembly is the standard Workers pattern: env only exists
25
- // inside fetch, and the factories are cheap closures over it.
26
- const app = buildChatApp(env)
27
- const url = new URL(request.url)
28
- const { pathname } = url
29
- const method = request.method
30
-
31
- if (pathname.startsWith('/api/auth/')) return app.auth.auth.handler(request)
32
-
33
- if (pathname === '/api/chat' && method === 'POST') {
34
- // Pass waitUntil so the turn keeps running (and buffering for replay)
35
- // after a client disconnect.
36
- return app.routes.turn(request, ctx)
37
- }
38
- const replay = pathname.match(/^\/api\/chat\/replay\/([^/]+)$/)
39
- if (replay && method === 'GET') return app.routes.replay(request, { turnId: replay[1]! })
40
- // Reconnect discovery: which turns are still live on a thread, so a page
41
- // reloaded mid-turn re-attaches via /replay instead of losing the run.
42
- if (pathname === '/api/chat/running' && method === 'GET') return app.routes.running(request)
43
- if (pathname === '/api/chat/upload' && method === 'POST') return app.upload(request)
44
- if (pathname === '/api/chat/interactions' && app.routes.interactions) {
45
- if (method === 'GET') return app.routes.interactions.list(request)
46
- if (method === 'POST') return app.routes.interactions.answer(request)
47
- }
48
-
49
- if (pathname === '/api/threads' && method === 'POST') return app.routes.createThread(request)
50
- if (pathname === '/api/threads' && method === 'GET') return app.routes.listThreads(request)
51
- const transcript = pathname.match(/^\/api\/threads\/([^/]+)\/messages$/)
52
- if (transcript && method === 'GET') {
53
- return app.routes.threadMessages(request, { threadId: transcript[1]! })
54
- }
55
-
56
- return Response.json({ error: 'Not found' }, { status: 404 })
57
- },
58
- } satisfies ExportedHandler<AppEnv>
29
+ export interface WorkerAssembly {
30
+ buildChatApp(env: AppEnv): ChatApp
31
+ buildGatewayApp(
32
+ env: AppEnv,
33
+ app: ChatApp,
34
+ options?: GatewayAppOptions,
35
+ ): ReturnType<typeof buildGatewayApp>
36
+ /** Test override. Production follows agent.config.ts. */
37
+ gatewayEnabled?: boolean
38
+ }
39
+
40
+ function attachThreadUrl(response: Response, request: Request): Response {
41
+ const threadId = response.headers.get('X-Tangle-Thread-Id')
42
+ if (!threadId) return response
43
+ const threadUrl = new URL('/', request.url)
44
+ threadUrl.searchParams.set('threadId', threadId)
45
+ const headers = new Headers(response.headers)
46
+ headers.set('X-Tangle-Thread-Url', threadUrl.toString())
47
+ return new Response(response.body, {
48
+ status: response.status,
49
+ statusText: response.statusText,
50
+ headers,
51
+ })
52
+ }
53
+
54
+ const defaultAssembly: WorkerAssembly = {
55
+ buildChatApp,
56
+ buildGatewayApp,
57
+ gatewayEnabled: config.gateway.enabled,
58
+ }
59
+
60
+ /** Build the Worker around one app assembly. Tests inject the real app with a test database. */
61
+ export function createWorker(assembly: WorkerAssembly = defaultAssembly): ExportedHandler<AppEnv> {
62
+ // Reuse the database-backed chat assembly within one Worker environment.
63
+ // Build the gateway per request so its background work binds to that
64
+ // request's ExecutionContext. Durable stores remain authoritative.
65
+ const instances = new WeakMap<object, ChatApp>()
66
+
67
+ const resolveApp = (env: AppEnv) => {
68
+ const key = env as object
69
+ const existing = instances.get(key)
70
+ if (existing) return existing
71
+ const app = assembly.buildChatApp(env)
72
+ instances.set(key, app)
73
+ return app
74
+ }
75
+
76
+ return {
77
+ async fetch(request: Request, env: AppEnv, ctx: ExecutionContext): Promise<Response> {
78
+ const url = new URL(request.url)
79
+ const { pathname } = url
80
+ const method = request.method
81
+
82
+ const isGatewayPath = pathname === '/api/keys'
83
+ || pathname.startsWith('/api/keys/')
84
+ || pathname === '/v1/agents'
85
+ || pathname.startsWith('/v1/agents/')
86
+ const isUnsupportedA2APath = /^\/v1\/agents\/[^/]+(?:\/\.well-known\/agent\.json)?$/.test(pathname)
87
+ if (isGatewayPath && assembly.gatewayEnabled === false) {
88
+ return Response.json({ error: 'Not found' }, { status: 404 })
89
+ }
90
+ // Long-running A2A task control is not mounted until agent-gateway owns
91
+ // durable cross-isolate cancel and replay. OpenAI-compatible calls do
92
+ // not depend on that unfinished path.
93
+ if (isUnsupportedA2APath) return Response.json({ error: 'Not found' }, { status: 404 })
94
+
95
+ const app = resolveApp(env)
96
+ if (isGatewayPath) {
97
+ const gateway = assembly.buildGatewayApp(env, app, {
98
+ waitUntil: (promise) => ctx.waitUntil(promise),
99
+ })
100
+ return attachThreadUrl(await gateway.fetch(request), request)
101
+ }
102
+
103
+ if (pathname.startsWith('/api/auth/')) return app.auth.auth.handler(request)
104
+
105
+ if (pathname === '/api/chat' && method === 'POST') {
106
+ // Pass waitUntil so the turn keeps running (and buffering for replay)
107
+ // after a client disconnect.
108
+ return app.routes.turn(request, ctx)
109
+ }
110
+ const replay = pathname.match(/^\/api\/chat\/replay\/([^/]+)$/)
111
+ if (replay && method === 'GET') return app.routes.replay(request, { turnId: replay[1]! })
112
+ // Reconnect discovery: which turns are still live on a thread, so a page
113
+ // reloaded mid-turn re-attaches via /replay instead of losing the run.
114
+ if (pathname === '/api/chat/running' && method === 'GET') return app.routes.running(request)
115
+ if (pathname === '/api/chat/upload' && method === 'POST') return app.upload(request)
116
+ if (pathname === '/api/chat/interactions' && app.routes.interactions) {
117
+ if (method === 'GET') return app.routes.interactions.list(request)
118
+ if (method === 'POST') return app.routes.interactions.answer(request)
119
+ }
120
+
121
+ if (pathname === '/api/threads' && method === 'POST') return app.routes.createThread(request)
122
+ if (pathname === '/api/threads' && method === 'GET') return app.routes.listThreads(request)
123
+ const transcript = pathname.match(/^\/api\/threads\/([^/]+)\/messages$/)
124
+ if (transcript && method === 'GET') {
125
+ return app.routes.threadMessages(request, { threadId: transcript[1]! })
126
+ }
127
+
128
+ return Response.json({ error: 'Not found' }, { status: 404 })
129
+ },
130
+ }
131
+ }
132
+
133
+ export default createWorker()
@@ -16,7 +16,7 @@
16
16
  * contract (or the migration from the schema). Fix the drift, not the test.
17
17
  */
18
18
 
19
- import { readFileSync } from 'node:fs'
19
+ import { readdirSync, readFileSync } from 'node:fs'
20
20
  import { dirname, join } from 'node:path'
21
21
  import { fileURLToPath } from 'node:url'
22
22
  import Database from 'better-sqlite3'
@@ -26,6 +26,7 @@ import { describe, expect, it } from 'vitest'
26
26
  import {
27
27
  createSandboxChatProducer,
28
28
  normalizeChatPromptForSandbox,
29
+ type ChatTurnProduceArgs,
29
30
  type ChatTurnRouteProducer,
30
31
  } from '@tangle-network/agent-app/chat-routes'
31
32
  import type { ChatDatabase } from '@tangle-network/agent-app/chat-store'
@@ -34,28 +35,59 @@ import {
34
35
  createMemoryTurnEventStore,
35
36
  TURN_EVENTS_MIGRATION_SQL,
36
37
  } from '@tangle-network/agent-app/stream'
38
+ import {
39
+ sqlApiKeyStoreSchemaStatements,
40
+ sqlGatewayUsageStoreSchemaStatements,
41
+ type SqlAdapter,
42
+ } from '@tangle-network/agent-gateway'
37
43
 
38
44
  import { config } from '../agent.config'
39
45
  import { buildChatApp, type ChatApp } from '../src/chat'
40
46
  import type { AppEnv } from '../src/env'
47
+ import { buildGatewayApp } from '../src/gateway'
48
+ import { appSlug } from '../src/sandbox'
49
+ import { createWorker } from '../src/worker'
41
50
 
42
51
  const BASE = 'http://localhost:8787'
43
52
  const MODEL = 'test/model-1'
44
53
 
45
54
  // ── fixtures ────────────────────────────────────────────────────────────────
46
55
 
47
- const MIGRATION = join(dirname(fileURLToPath(import.meta.url)), '..', 'migrations', '0001_init.sql')
56
+ const MIGRATIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
57
+ const MIGRATIONS = readdirSync(MIGRATIONS_DIR)
58
+ .filter((name) => /^\d+.*\.sql$/.test(name))
59
+ .sort()
60
+ .map((name) => join(MIGRATIONS_DIR, name))
61
+ const BASE_MIGRATION = join(MIGRATIONS_DIR, '0001_init.sql')
62
+ const GATEWAY_MIGRATION = join(MIGRATIONS_DIR, '0002_agent_gateway.sql')
48
63
 
49
64
  /** The real migration, executed against a real SQLite database. Every query
50
65
  * the test makes afterwards runs over THESE tables — schema drift between
51
66
  * `migrations/` and `src/db/schema.ts` fails here, not in production. */
52
- function openMigratedDb(): ChatDatabase {
67
+ function openMigratedDb(migrations = MIGRATIONS): {
68
+ db: ChatDatabase
69
+ sql: SqlAdapter
70
+ applyMigration(path: string): void
71
+ } {
53
72
  const sqlite = new Database(':memory:')
54
73
  sqlite.pragma('foreign_keys = ON')
55
- sqlite.exec(readFileSync(MIGRATION, 'utf8'))
74
+ for (const migration of migrations) sqlite.exec(readFileSync(migration, 'utf8'))
56
75
  // better-sqlite3's sync drizzle handle narrows the driver generic; the store
57
76
  // treats sync and async drivers identically (builders are awaited).
58
- return drizzle(sqlite) as unknown as ChatDatabase
77
+ return {
78
+ db: drizzle(sqlite) as unknown as ChatDatabase,
79
+ sql: {
80
+ async exec(statement, params = []) {
81
+ return { rowsAffected: sqlite.prepare(statement).run(...params).changes }
82
+ },
83
+ async query<TRow>(statement: string, params: readonly unknown[] = []) {
84
+ return sqlite.prepare(statement).all(...params) as TRow[]
85
+ },
86
+ },
87
+ applyMigration(path) {
88
+ sqlite.exec(readFileSync(path, 'utf8'))
89
+ },
90
+ }
59
91
  }
60
92
 
61
93
  /** Raw sidecar events, exactly as `streamSandboxPrompt` would yield them from
@@ -84,18 +116,28 @@ const env: AppEnv = {
84
116
 
85
117
  interface Harness {
86
118
  app: ChatApp
119
+ workerFetch(request: Request): Promise<Response>
120
+ sql: SqlAdapter
87
121
  cookie: string
122
+ gatewayBuilds(): number
88
123
  settle(): Promise<unknown>
89
124
  }
90
125
 
91
126
  async function createHarness(
92
- produce: () => ChatTurnRouteProducer = () =>
127
+ produce: (args: ChatTurnProduceArgs<void>) => ChatTurnRouteProducer = () =>
93
128
  createSandboxChatProducer({ events: feed(RAW_TURN_EVENTS), model: MODEL }),
94
129
  ): Promise<Harness> {
95
- const app = buildChatApp(env, {
96
- db: openMigratedDb(),
130
+ const database = openMigratedDb()
131
+ const pending: Promise<unknown>[] = []
132
+ let gatewayBuildCount = 0
133
+ const chatOverrides = {
134
+ db: database.db,
97
135
  turnStore: createMemoryTurnEventStore(),
98
136
  produce,
137
+ uploadSink: async () => null,
138
+ }
139
+ const app = buildChatApp(env, {
140
+ ...chatOverrides,
99
141
  uploadSink: async () => null, // inline uploads only; no box in tests
100
142
  })
101
143
  // Real sign-up through better-auth; the returned cookie is what a browser
@@ -113,11 +155,42 @@ async function createHarness(
113
155
  .map((c) => c.split(';')[0]!)
114
156
  .join('; ')
115
157
 
116
- const pending: Promise<unknown>[] = []
117
158
  const originalTurn = app.routes.turn
118
159
  app.routes.turn = (request) =>
119
160
  originalTurn(request, { waitUntil: (p) => void pending.push(p) })
120
- return { app, cookie, settle: () => Promise.all(pending) }
161
+ const worker = createWorker({
162
+ buildChatApp: () => app,
163
+ buildGatewayApp: (_env, chatApp, options) => {
164
+ gatewayBuildCount += 1
165
+ return buildGatewayApp(env, chatApp, {
166
+ ...options,
167
+ sql: database.sql,
168
+ createTrustedChatApp: (ownerId) => buildChatApp(env, {
169
+ ...chatOverrides,
170
+ trustedUserId: ownerId,
171
+ }),
172
+ })
173
+ },
174
+ })
175
+ const executionContext = {
176
+ waitUntil: (promise: Promise<unknown>) => void pending.push(promise),
177
+ passThroughOnException: () => undefined,
178
+ props: {},
179
+ } as ExecutionContext
180
+ const workerHandler = worker.fetch
181
+ if (!workerHandler) throw new Error('Generated Worker has no fetch handler')
182
+ return {
183
+ app,
184
+ workerFetch: async (request) => workerHandler(
185
+ request as Parameters<typeof workerHandler>[0],
186
+ env,
187
+ executionContext,
188
+ ),
189
+ sql: database.sql,
190
+ cookie,
191
+ gatewayBuilds: () => gatewayBuildCount,
192
+ settle: () => Promise.all(pending),
193
+ }
121
194
  }
122
195
 
123
196
  function post(path: string, cookie: string, body: unknown): Request {
@@ -142,6 +215,22 @@ function eventsOf(lines: Array<Record<string, unknown>>): Array<Record<string, u
142
215
  return lines.map((l) => (l.kind === 'event' ? (l.event as Record<string, unknown>) : l))
143
216
  }
144
217
 
218
+ async function readGatewayText(response: Response): Promise<string> {
219
+ const body = await response.text()
220
+ return body
221
+ .split('\n')
222
+ .filter((line) => line.startsWith('data: ') && line !== 'data: [DONE]')
223
+ .map((line) => JSON.parse(line.slice(6)) as {
224
+ choices?: Array<{ delta?: { content?: string } }>
225
+ error?: { message?: string }
226
+ })
227
+ .map((frame) => {
228
+ if (frame.error) throw new Error(frame.error.message ?? 'gateway stream failed')
229
+ return frame.choices?.[0]?.delta?.content ?? ''
230
+ })
231
+ .join('')
232
+ }
233
+
145
234
  // ── the gate ────────────────────────────────────────────────────────────────
146
235
 
147
236
  describe('e2e: fake sandbox producer → streamed turn → persisted transcript', () => {
@@ -215,7 +304,10 @@ describe('e2e: fake sandbox producer → streamed turn → persisted transcript'
215
304
  | undefined
216
305
  expect(toolCall?.call?.toolName).toBe('record_search')
217
306
  expect(events).toContainEqual(
218
- expect.objectContaining({ type: 'usage', usage: { promptTokens: 40, completionTokens: 20 } }),
307
+ expect.objectContaining({
308
+ type: 'usage',
309
+ usage: expect.objectContaining({ promptTokens: 40, completionTokens: 20 }),
310
+ }),
219
311
  )
220
312
  await settle()
221
313
 
@@ -272,12 +364,337 @@ describe('e2e: fake sandbox producer → streamed turn → persisted transcript'
272
364
 
273
365
  it('the migration carries the turn-buffer DDL the /stream store expects, verbatim', () => {
274
366
  const normalize = (sql: string) => sql.replace(/\s+/g, ' ').trim()
275
- expect(normalize(readFileSync(MIGRATION, 'utf8'))).toContain(normalize(TURN_EVENTS_MIGRATION_SQL))
367
+ expect(normalize(readFileSync(BASE_MIGRATION, 'utf8'))).toContain(normalize(TURN_EVENTS_MIGRATION_SQL))
276
368
  })
277
369
 
278
370
  it('the migration carries the fenced sandbox claim table, verbatim', () => {
279
371
  const normalize = (sql: string) => sql.replace(/\s+/g, ' ').replace(/;$/, '').trim()
280
- expect(normalize(readFileSync(MIGRATION, 'utf8'))).toContain(normalize(PREWARM_CLAIM_TABLE_DDL))
372
+ expect(normalize(readFileSync(BASE_MIGRATION, 'utf8'))).toContain(normalize(PREWARM_CLAIM_TABLE_DDL))
373
+ })
374
+
375
+ it('the migration carries every agent-gateway SQL store statement', () => {
376
+ const migration = readFileSync(GATEWAY_MIGRATION, 'utf8')
377
+ const normalize = (sql: string) => sql.replace(/\s+/g, ' ').replace(/;$/, '').trim()
378
+ const statements = [
379
+ ...sqlApiKeyStoreSchemaStatements(),
380
+ ...sqlGatewayUsageStoreSchemaStatements(),
381
+ ]
382
+
383
+ for (const statement of statements) {
384
+ expect(normalize(migration)).toContain(normalize(statement))
385
+ }
386
+ })
387
+
388
+ it('upgrades a database that already applied the original chat migration', async () => {
389
+ const migrated = openMigratedDb([BASE_MIGRATION])
390
+ const tableNames = async () => (await migrated.sql.query<{ name: string }>(
391
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'agent_%' ORDER BY name",
392
+ )).map((row) => row.name)
393
+
394
+ expect(await tableNames()).toEqual([])
395
+ migrated.applyMigration(GATEWAY_MIGRATION)
396
+ expect(await tableNames()).toEqual([
397
+ 'agent_api_key',
398
+ 'agent_api_key_request',
399
+ 'agent_api_key_usage',
400
+ 'agent_gateway_usage',
401
+ ])
402
+ })
403
+
404
+ it('shares one owned thread across OpenAI-compatible API calls', async () => {
405
+ const { app, workerFetch, sql, cookie, gatewayBuilds, settle } = await createHarness()
406
+ const cardResponse = await workerFetch(new Request(
407
+ `${BASE}/v1/agents/${appSlug}/.well-known/agent.json`,
408
+ ))
409
+ expect(cardResponse.status).toBe(404)
410
+
411
+ const keyResponse = await workerFetch(post('/api/keys', cookie, {
412
+ name: 'coding agent',
413
+ rateLimit: 2,
414
+ dailyLimit: 2,
415
+ }))
416
+ expect(keyResponse.status).toBe(201)
417
+ const { key } = (await keyResponse.json()) as { key: string }
418
+
419
+ const openAiResponse = await workerFetch(new Request(
420
+ `${BASE}/v1/agents/${appSlug}/chat/completions`,
421
+ {
422
+ method: 'POST',
423
+ headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
424
+ body: JSON.stringify({
425
+ messages: [{ role: 'user', content: 'File my lease summary' }],
426
+ stream: true,
427
+ }),
428
+ },
429
+ ))
430
+ expect(openAiResponse.status).toBe(200)
431
+ const threadId = openAiResponse.headers.get('X-Tangle-Thread-Id')
432
+ expect(threadId).toBeTruthy()
433
+ expect(openAiResponse.headers.get('X-Tangle-Thread-Url')).toBe(
434
+ `${BASE}/?threadId=${encodeURIComponent(threadId!)}`,
435
+ )
436
+ expect(await readGatewayText(openAiResponse)).toBe('Filed the summary.')
437
+ await settle()
438
+
439
+ const thread = await app.store.getThread(threadId!)
440
+ expect(thread).toMatchObject({ id: threadId, workspaceId: expect.any(String) })
441
+ expect(await app.store.listMessages(threadId!)).toHaveLength(2)
442
+ expect(await sql.query(`
443
+ SELECT input_tokens, output_tokens, reasoning_tokens, tool_tokens,
444
+ tool_call_count, provider_cost_nanodollars, total_cost_nanodollars,
445
+ settlement_basis
446
+ FROM agent_gateway_usage
447
+ `)).toEqual([{
448
+ input_tokens: 40,
449
+ output_tokens: 20,
450
+ reasoning_tokens: 5,
451
+ tool_tokens: 0,
452
+ tool_call_count: 1,
453
+ provider_cost_nanodollars: 12_300_000,
454
+ total_cost_nanodollars: 12_300_000,
455
+ settlement_basis: 'usage-receipt',
456
+ }])
457
+ expect(await sql.query('SELECT request_id FROM agent_api_key_usage')).toHaveLength(1)
458
+ expect(await sql.query('SELECT request_id FROM agent_api_key_request')).toHaveLength(1)
459
+
460
+ const continuedResponse = await workerFetch(new Request(
461
+ `${BASE}/v1/agents/${appSlug}/chat/completions`,
462
+ {
463
+ method: 'POST',
464
+ headers: {
465
+ Authorization: `Bearer ${key}`,
466
+ 'Content-Type': 'application/json',
467
+ 'X-Tangle-Thread-Id': threadId!,
468
+ },
469
+ body: JSON.stringify({
470
+ messages: [{ role: 'user', content: 'Continue the same work' }],
471
+ stream: true,
472
+ }),
473
+ },
474
+ ))
475
+ expect(continuedResponse.status).toBe(200)
476
+ expect(continuedResponse.headers.get('X-Tangle-Thread-Id')).toBe(threadId)
477
+ expect(await readGatewayText(continuedResponse)).toBe('Filed the summary.')
478
+ await settle()
479
+ expect(await app.store.listMessages(threadId!)).toHaveLength(4)
480
+ expect(await sql.query('SELECT request_id FROM agent_api_key_request')).toHaveLength(2)
481
+ expect(await sql.query('SELECT request_id FROM agent_gateway_usage')).toHaveLength(2)
482
+ expect(await sql.query('SELECT request_id FROM agent_api_key_usage')).toHaveLength(2)
483
+
484
+ await app.store.createThread({
485
+ id: 'another-users-thread',
486
+ workspaceId: 'another-user',
487
+ title: 'Private',
488
+ })
489
+ const probeKeyResponse = await workerFetch(post('/api/keys', cookie, {
490
+ name: 'thread probe',
491
+ rateLimit: 1,
492
+ dailyLimit: 1,
493
+ }))
494
+ expect(probeKeyResponse.status).toBe(201)
495
+ const { key: probeKey } = (await probeKeyResponse.json()) as { key: string }
496
+ const denied = await workerFetch(new Request(
497
+ `${BASE}/v1/agents/${appSlug}/chat/completions`,
498
+ {
499
+ method: 'POST',
500
+ headers: {
501
+ Authorization: `Bearer ${probeKey}`,
502
+ 'Content-Type': 'application/json',
503
+ 'X-Tangle-Thread-Id': 'another-users-thread',
504
+ },
505
+ body: JSON.stringify({ messages: [{ role: 'user', content: 'Open it' }] }),
506
+ },
507
+ ))
508
+ expect(denied.status).toBe(403)
509
+ expect(await sql.query('SELECT request_id FROM agent_api_key_request')).toHaveLength(2)
510
+
511
+ const rateLimited = await workerFetch(new Request(
512
+ `${BASE}/v1/agents/${appSlug}/chat/completions`,
513
+ {
514
+ method: 'POST',
515
+ headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
516
+ body: JSON.stringify({
517
+ messages: [{ role: 'user', content: 'This third turn must not run' }],
518
+ stream: true,
519
+ }),
520
+ },
521
+ ))
522
+ expect(rateLimited.status).toBe(429)
523
+ expect(await app.store.listMessages(threadId!)).toHaveLength(4)
524
+ expect(await sql.query('SELECT request_id FROM agent_api_key_request')).toHaveLength(2)
525
+ expect(gatewayBuilds()).toBe(6)
526
+ })
527
+
528
+ it('finishes the linked browser transcript after the API client disconnects', async () => {
529
+ let releaseTurn = () => {}
530
+ const canFinish = new Promise<void>((resolve) => {
531
+ releaseTurn = resolve
532
+ })
533
+ async function* delayedEvents(): AsyncGenerator<Record<string, unknown>> {
534
+ yield {
535
+ type: 'message.part.updated',
536
+ data: {
537
+ part: { type: 'text', id: 'answer', text: 'Started. ' },
538
+ delta: 'Started. ',
539
+ },
540
+ }
541
+ await canFinish
542
+ yield {
543
+ type: 'message.part.updated',
544
+ data: {
545
+ part: { type: 'text', id: 'answer', text: 'Started. Finished.' },
546
+ delta: 'Finished.',
547
+ },
548
+ }
549
+ yield {
550
+ type: 'message.part.updated',
551
+ data: {
552
+ part: {
553
+ type: 'step-finish',
554
+ reason: 'stop',
555
+ tokens: { input: 7, output: 3, reasoning: 1 },
556
+ cost: 0.00021,
557
+ },
558
+ },
559
+ }
560
+ yield { type: 'result', data: { finalText: 'Started. Finished.' } }
561
+ }
562
+ const { app, workerFetch, sql, cookie, settle } = await createHarness(() =>
563
+ createSandboxChatProducer({ events: delayedEvents(), model: MODEL }))
564
+ const keyResponse = await workerFetch(post('/api/keys', cookie, {
565
+ name: 'disconnect test',
566
+ rateLimit: 1,
567
+ dailyLimit: 1,
568
+ }))
569
+ const { key } = (await keyResponse.json()) as { key: string }
570
+
571
+ const response = await workerFetch(new Request(
572
+ `${BASE}/v1/agents/${appSlug}/chat/completions`,
573
+ {
574
+ method: 'POST',
575
+ headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
576
+ body: JSON.stringify({
577
+ messages: [{ role: 'user', content: 'Complete this after I leave' }],
578
+ stream: true,
579
+ }),
580
+ },
581
+ ))
582
+ expect(response.status).toBe(200)
583
+ const threadId = response.headers.get('X-Tangle-Thread-Id')
584
+ expect(response.headers.get('X-Tangle-Thread-Url')).toBe(
585
+ `${BASE}/?threadId=${encodeURIComponent(threadId!)}`,
586
+ )
587
+
588
+ const reader = response.body!.getReader()
589
+ const decoder = new TextDecoder()
590
+ let visible = ''
591
+ while (!visible.includes('Started.')) {
592
+ const chunk = await reader.read()
593
+ if (chunk.done) throw new Error('Gateway stream ended before its first answer text')
594
+ visible += decoder.decode(chunk.value, { stream: true })
595
+ }
596
+ await reader.cancel()
597
+
598
+ const runningResponse = await app.routes.running(new Request(
599
+ `${BASE}/api/chat/running?threadId=${encodeURIComponent(threadId!)}`,
600
+ { headers: { cookie } },
601
+ ))
602
+ expect(runningResponse.status).toBe(200)
603
+ const { running } = (await runningResponse.json()) as { running: string[] }
604
+ expect(running).toHaveLength(1)
605
+ const replay = await app.routes.replay(
606
+ new Request(`${BASE}/api/chat/replay/${running[0]}?fromSeq=0`, { headers: { cookie } }),
607
+ { turnId: running[0]! },
608
+ )
609
+ const replayLines = readLines(replay)
610
+
611
+ releaseTurn()
612
+ await settle()
613
+
614
+ const messages = await app.store.listMessages(threadId!)
615
+ expect(messages).toHaveLength(2)
616
+ expect(messages[1]).toMatchObject({ role: 'assistant', content: 'Started. Finished.' })
617
+ const events = eventsOf(await replayLines)
618
+ expect(events.filter((event) => event.type === 'text').map((event) => event.text).join(''))
619
+ .toBe('Started. Finished.')
620
+ expect(events.at(-1)).toMatchObject({ type: 'turn_status', status: 'complete' })
621
+ expect(await sql.query(`
622
+ SELECT input_tokens, output_tokens, reasoning_tokens, provider_cost_nanodollars
623
+ FROM agent_gateway_usage
624
+ `)).toEqual([{
625
+ input_tokens: 7,
626
+ output_tokens: 3,
627
+ reasoning_tokens: 1,
628
+ provider_cost_nanodollars: 210_000,
629
+ }])
630
+ expect(await sql.query('SELECT request_id FROM agent_api_key_usage')).toHaveLength(1)
631
+ })
632
+
633
+ it('passes the complete provider budget into the shared chat turn', async () => {
634
+ let receivedLimits: ChatTurnProduceArgs<void>['executionLimits']
635
+ const { workerFetch, cookie, settle } = await createHarness((args) => {
636
+ receivedLimits = args.executionLimits
637
+ return createSandboxChatProducer({ events: feed(RAW_TURN_EVENTS), model: MODEL })
638
+ })
639
+ const keyResponse = await workerFetch(post('/api/keys', cookie, {
640
+ name: 'budget test',
641
+ rateLimit: 1,
642
+ dailyLimit: 1,
643
+ }))
644
+ const { key } = (await keyResponse.json()) as { key: string }
645
+
646
+ const response = await workerFetch(new Request(
647
+ `${BASE}/v1/agents/${appSlug}/chat/completions`,
648
+ {
649
+ method: 'POST',
650
+ headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
651
+ body: JSON.stringify({
652
+ messages: [{ role: 'user', content: 'Use the bounded path' }],
653
+ max_tokens: 321,
654
+ stream: true,
655
+ }),
656
+ },
657
+ ))
658
+
659
+ expect(response.status).toBe(200)
660
+ await readGatewayText(response)
661
+ await settle()
662
+ expect(receivedLimits).toMatchObject({
663
+ maxInputTokens: config.gateway.maxProviderInputTokens,
664
+ maxOutputTokens: 321,
665
+ maxReasoningTokens: 321,
666
+ maxToolTokens: 321,
667
+ maxToolCalls: 8,
668
+ })
669
+ expect(receivedLimits?.maxProviderCostUsd).toBeGreaterThan(0)
670
+ })
671
+
672
+ it('does not mount API-key or agent routes when the gateway is disabled', async () => {
673
+ const database = openMigratedDb()
674
+ const app = buildChatApp(env, {
675
+ db: database.db,
676
+ turnStore: createMemoryTurnEventStore(),
677
+ produce: () => createSandboxChatProducer({ events: feed(RAW_TURN_EVENTS), model: MODEL }),
678
+ })
679
+ const worker = createWorker({
680
+ buildChatApp: () => app,
681
+ buildGatewayApp: (_env, chatApp) => buildGatewayApp(env, chatApp, { sql: database.sql }),
682
+ gatewayEnabled: false,
683
+ })
684
+ const fetch = worker.fetch
685
+ if (!fetch) throw new Error('Generated Worker has no fetch handler')
686
+ const context = {
687
+ waitUntil: () => undefined,
688
+ passThroughOnException: () => undefined,
689
+ props: {},
690
+ } as unknown as ExecutionContext
691
+
692
+ const [keys, agents] = await Promise.all([
693
+ fetch(new Request(`${BASE}/api/keys`), env, context),
694
+ fetch(new Request(`${BASE}/v1/agents/${appSlug}/.well-known/agent.json`), env, context),
695
+ ])
696
+ expect(keys.status).toBe(404)
697
+ expect(agents.status).toBe(404)
281
698
  })
282
699
 
283
700
  it('agent.config carries a real system prompt (prompts/system.md is wired)', () => {