@anchrd/intel-api 0.14.0 → 0.15.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/dist/adapters/cloudflare/cloudflare.js +0 -68
- package/dist/adapters/cloudflare/cloudflare.types.d.ts +0 -39
- package/dist/adapters/db/db-flows.js +1 -1
- package/dist/adapters/db/db-grants.js +1 -1
- package/dist/adapters/db/db.js +16 -113
- package/dist/bundle/bundle.js +28 -141
- package/dist/cli/cli.js +3 -9
- package/dist/http/http.js +1 -206
- package/dist/http/http.types.d.ts +0 -8
- package/dist/indexing/indexing.js +18 -89
- package/dist/intel/intel.js +4 -9
- package/dist/intel/intel.types.d.ts +0 -6
- package/dist/mcp/mcp.js +3 -292
- package/dist/mcp/mcp.types.d.ts +2 -7
- package/dist/nodes/document-links/document-links.d.ts +6 -8
- package/dist/nodes/document-links/document-links.js +8 -31
- package/dist/nodes/nodes.js +20 -886
- package/dist/nodes/nodes.types.d.ts +10 -169
- package/dist/tools/tools.js +37 -148
- package/dist/tools/tools.types.d.ts +0 -21
- package/migrations/0018_no_context_policy_at_last.sql +13 -6
- package/migrations/0019_one_name_for_the_grants.sql +52 -0
- package/package.json +2 -2
- package/dist/adapters/cloudflare-api/cloudflare-api.d.ts +0 -22
- package/dist/adapters/cloudflare-api/cloudflare-api.js +0 -306
- package/dist/adapters/cloudflare-api/cloudflare-api.types.d.ts +0 -64
- package/dist/adapters/cloudflare-api/cloudflare-api.types.js +0 -1
- package/dist/adapters/gate-applications/gate-applications.d.ts +0 -23
- package/dist/adapters/gate-applications/gate-applications.js +0 -88
- package/dist/adapters/tool-delegation/tool-delegation.d.ts +0 -22
- package/dist/adapters/tool-delegation/tool-delegation.js +0 -90
- package/dist/agent-costs/agent-costs.d.ts +0 -16
- package/dist/agent-costs/agent-costs.js +0 -105
- package/dist/agent-costs/agent-costs.types.d.ts +0 -30
- package/dist/agent-costs/agent-costs.types.js +0 -1
- package/dist/agent-runtime/agent-runtime.d.ts +0 -16
- package/dist/agent-runtime/agent-runtime.js +0 -150
- package/dist/agent-runtime/agent-runtime.types.d.ts +0 -122
- package/dist/agent-runtime/agent-runtime.types.js +0 -1
- package/dist/model-catalog/model-catalog.d.ts +0 -2
- package/dist/model-catalog/model-catalog.js +0 -99
- package/dist/model-catalog/model-catalog.types.d.ts +0 -15
- package/dist/model-catalog/model-catalog.types.js +0 -1
- package/dist/nodes/board/board.d.ts +0 -61
- package/dist/nodes/board/board.js +0 -826
- package/dist/nodes/board/board.types.d.ts +0 -38
- package/dist/nodes/board/board.types.js +0 -1
- package/migrations/0013_agents_in_the_tree.sql +0 -76
- package/migrations/0014_agent_applications.sql +0 -25
- package/migrations/0015_tools_delegated_from_a_connection.sql +0 -15
- package/migrations/0016_boards_in_the_tree.sql +0 -80
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
-- #392: the grant table takes the tree's name, and the table nobody reads goes.
|
|
2
|
+
--
|
|
3
|
+
-- Two changes to the same subject, in one file because they are one subject. After Agents and Board
|
|
4
|
+
-- were parked (#385) `tree_grants` is the ONLY grant table left, and it stands beside
|
|
5
|
+
-- `node_versions`, `node_links`, `node_vectors` and `node_index_state` under a name from a different
|
|
6
|
+
-- vocabulary. `node_grants` is the name somebody would guess without looking.
|
|
7
|
+
--
|
|
8
|
+
-- ⚠️ It also closes a split that ran through four layers. D1 said `grants`, the contract says
|
|
9
|
+
-- `ListGrantsInput`/`RevokeGrantInput`, HTTP says `/nodes/:nodeId/grants` — and the MCP surface
|
|
10
|
+
-- alone said `share`. anchrd/intel#396 pulls the tools onto `grant`; this is the other half of the
|
|
11
|
+
-- same move, and afterwards the same thing has the same name everywhere.
|
|
12
|
+
--
|
|
13
|
+
-- ⚠️ A rename is safe HERE and would not be one table up. `ALTER TABLE ... RENAME` makes every
|
|
14
|
+
-- `REFERENCES` clause pointing AT the renamed table follow it — which is the trap the `nodes`
|
|
15
|
+
-- rebuild exists to avoid (see `packages/api/CLAUDE.md`). `tree_grants` is a child: nothing points
|
|
16
|
+
-- at it, so nothing can follow it anywhere. The direction is what makes the difference, not the
|
|
17
|
+
-- statement.
|
|
18
|
+
--
|
|
19
|
+
-- ⚠️ Indexes do NOT follow a rename. They keep working — an index is bound to its table, not to its
|
|
20
|
+
-- table's name — but they keep the old name in `sqlite_master` and would be the last place
|
|
21
|
+
-- `tree_` survives. That is the lesson `0011` wrote down, and dropping and recreating one is free:
|
|
22
|
+
-- an index holds no rows of its own and nothing points at it.
|
|
23
|
+
ALTER TABLE tree_grants RENAME TO node_grants;
|
|
24
|
+
|
|
25
|
+
DROP INDEX tree_grants_principal_idx;
|
|
26
|
+
DROP INDEX tree_grants_node_idx;
|
|
27
|
+
|
|
28
|
+
CREATE INDEX node_grants_principal_idx ON node_grants(
|
|
29
|
+
principal_type,
|
|
30
|
+
principal_id,
|
|
31
|
+
verb,
|
|
32
|
+
expires_at
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
CREATE INDEX node_grants_node_idx ON node_grants(node_id, verb);
|
|
36
|
+
|
|
37
|
+
-- `resource_grants` has been dead since `0003`, which said so in its own header:
|
|
38
|
+
--
|
|
39
|
+
-- > `resource_grants` is deliberately left in place and untouched. The previous version of the
|
|
40
|
+
-- > Worker reads it and keeps answering correctly until it is replaced; nothing in this version
|
|
41
|
+
-- > reads it any more. Once no old Worker is left, the table holds only history and can be
|
|
42
|
+
-- > dropped.
|
|
43
|
+
--
|
|
44
|
+
-- There is no old Worker. The condition has been met for five migrations and nobody announced the
|
|
45
|
+
-- moment, because nothing forced one — and a customer should not receive a schema carrying a table
|
|
46
|
+
-- no code has read since `0003`.
|
|
47
|
+
--
|
|
48
|
+
-- ⚠️ Dropped here rather than removed from `0000`, and that is deliberate. `0003` reads it: it is
|
|
49
|
+
-- the source every `tree_grants` row was migrated FROM. Taking it out of `0000` would leave `0003`
|
|
50
|
+
-- selecting from a table that never existed, so the history has to keep it and the end of the chain
|
|
51
|
+
-- is where it can go. `0001` also deletes rows from it — same reason, same answer.
|
|
52
|
+
DROP TABLE resource_grants;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@anchrd/gate-sdk": "^0.7.0",
|
|
46
|
-
"@anchrd/intel-contract": "^0.
|
|
46
|
+
"@anchrd/intel-contract": "^0.13.0",
|
|
47
47
|
"@cfworker/json-schema": "^4.1.1",
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
49
49
|
"fflate": "^0.8.3",
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import type { CloudflareAccountApi } from "./cloudflare-api.types.js";
|
|
2
|
-
/**
|
|
3
|
-
* ⚠️ `Authorization: Bearer`, and NOT `cf-aig-authorization`. The two hosts take different headers
|
|
4
|
-
* and the agent runtime uses the other one: `gateway.ai.cloudflare.com` reads
|
|
5
|
-
* `cf-aig-authorization`, this REST host reads the plain header. Swapping them produces a 401 that
|
|
6
|
-
* says nothing about which of the two was wrong.
|
|
7
|
-
*/
|
|
8
|
-
export interface CloudflareApiDeps {
|
|
9
|
-
accountId: string;
|
|
10
|
-
gatewayId: string;
|
|
11
|
-
/**
|
|
12
|
-
* A Cloudflare API token, read-only by intent.
|
|
13
|
-
*
|
|
14
|
-
* ⚠️ It is NOT the `AI_GATEWAY_TOKEN` the agent runtime holds. That one carries
|
|
15
|
-
* `AI Gateway: Run` and buys inference; this one carries `AI Gateway: Read` (and, for the model
|
|
16
|
-
* catalog, `Workers AI: Read`) and buys nothing at all. One credential for both would put a
|
|
17
|
-
* spending permission into the Worker that only ever reads.
|
|
18
|
-
*/
|
|
19
|
-
token: string;
|
|
20
|
-
fetch: typeof fetch;
|
|
21
|
-
}
|
|
22
|
-
export declare function createCloudflareApi(deps: CloudflareApiDeps): CloudflareAccountApi;
|
|
@@ -1,306 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
import { IntelError } from "../../shared/intel-error/intel-error.js";
|
|
3
|
-
const ApiOrigin = "https://api.cloudflare.com/client/v4";
|
|
4
|
-
/**
|
|
5
|
-
* How many pages of gateway log are read before the answer is declared a floor.
|
|
6
|
-
*
|
|
7
|
-
* A run is several model turns and a five-minute schedule is 8 640 runs a month, so "read
|
|
8
|
-
* everything" is not on the table. Twenty pages of fifty is the last thirty days of a busy agent
|
|
9
|
-
* or the last few days of a very busy one; past that the screen says "at least this much" rather
|
|
10
|
-
* than a number nobody can check.
|
|
11
|
-
*
|
|
12
|
-
* ⚠️ `PerPage` is Cloudflare's ceiling, not a chosen number. The endpoint answers `per_page=100`
|
|
13
|
-
* with `HTTP 400` and `Number must be less than or equal to 50`, the adapter turns that into
|
|
14
|
-
* `cloudflare_api_refused`, and the screen says `unreadable` — so the whole cost view read nothing,
|
|
15
|
-
* ever, and looked like an outage while doing it (#294). Raising it back is not an optimisation;
|
|
16
|
-
* it switches the feature off. `MaxPages` carries the reach instead: the product of the two is what
|
|
17
|
-
* "the last thousand calls" means, and lowering one without raising the other halves the window
|
|
18
|
-
* silently.
|
|
19
|
-
*/
|
|
20
|
-
const MaxPages = 20;
|
|
21
|
-
const PerPage = 50;
|
|
22
|
-
/**
|
|
23
|
-
* ⚠️ Cloudflare's ceiling on `/ai/models/search`, and a DIFFERENT number from `PerPage` above —
|
|
24
|
-
* the limit belongs to the endpoint, not to the account. Neither may be copied onto the other's
|
|
25
|
-
* call: 50 on the model catalog halves it, 100 on the log is refused outright.
|
|
26
|
-
*
|
|
27
|
-
* And the two fail in opposite ways, of which this is the worse one. The log endpoint REFUSES with
|
|
28
|
-
* `HTTP 400 Number must be less than or equal to 50` — loud, and found in a day (#294). This one
|
|
29
|
-
* IGNORES: measured against the live account (#297), `per_page=200` and `per_page=1000` both answer
|
|
30
|
-
* `HTTP 200` with no error and `result_info.per_page: 100`.
|
|
31
|
-
*
|
|
32
|
-
* ⚠️ `result_info.total_count` cannot be used to notice a short answer either. The same account
|
|
33
|
-
* reports `total_count: 286` and returns 61 entries on page 1, with page 2 empty — a reader that
|
|
34
|
-
* paginated on that figure would loop over empty pages and call the result partial. The truthful
|
|
35
|
-
* signal is a page that came back FULL, which the log reader already uses and this one does not
|
|
36
|
-
* yet (anchrd/intel#330).
|
|
37
|
-
*/
|
|
38
|
-
const ModelPerPage = 100;
|
|
39
|
-
/**
|
|
40
|
-
* How many pages of the model catalog are read before the reader gives up.
|
|
41
|
-
*
|
|
42
|
-
* ⚠️ It is a runaway brake and not a window, which is the opposite of `MaxPages` above. The log has
|
|
43
|
-
* more calls than anybody wants to read; the catalog is finite and small — 61 entries on this
|
|
44
|
-
* account — so the loop normally ends on the first short page and this number is never reached.
|
|
45
|
-
* What it guards against is an endpoint that answers a full page forever, and a loop inside a
|
|
46
|
-
* request nobody is watching.
|
|
47
|
-
*/
|
|
48
|
-
const ModelMaxPages = 10;
|
|
49
|
-
/**
|
|
50
|
-
* The gateway's log entry, read tolerantly.
|
|
51
|
-
*
|
|
52
|
-
* ⚠️ `metadata` arrives as an object on some responses and as a JSON string on others, and neither
|
|
53
|
-
* is documented as the one shape. Both are accepted; anything else means the call is unattributed,
|
|
54
|
-
* which is a state the caller can see rather than a parse failure that blanks the whole window.
|
|
55
|
-
*/
|
|
56
|
-
const LogEntry = z.object({
|
|
57
|
-
cost: z.number().nullish(),
|
|
58
|
-
model: z.string().nullish(),
|
|
59
|
-
created_at: z.string().nullish(),
|
|
60
|
-
metadata: z.union([z.string(), z.record(z.string(), z.unknown())]).nullish(),
|
|
61
|
-
});
|
|
62
|
-
/**
|
|
63
|
-
* ⚠️ `result` is required, only its contents may be null. An optional field would let ANY JSON body
|
|
64
|
-
* parse as an empty page — and an empty page reads as "this agent cost nothing", which is the one
|
|
65
|
-
* answer this whole path exists to avoid giving by accident.
|
|
66
|
-
*/
|
|
67
|
-
const LogResponse = z.object({
|
|
68
|
-
success: z.boolean().nullish(),
|
|
69
|
-
result: z.array(LogEntry).nullable(),
|
|
70
|
-
});
|
|
71
|
-
function readMetadata(raw) {
|
|
72
|
-
if (typeof raw === "string") {
|
|
73
|
-
try {
|
|
74
|
-
const parsed = JSON.parse(raw);
|
|
75
|
-
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
76
|
-
? parsed
|
|
77
|
-
: {};
|
|
78
|
-
}
|
|
79
|
-
catch {
|
|
80
|
-
return {};
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
return raw && typeof raw === "object" && !Array.isArray(raw)
|
|
84
|
-
? raw
|
|
85
|
-
: {};
|
|
86
|
-
}
|
|
87
|
-
/**
|
|
88
|
-
* ⚠️ Cloudflare's `properties` are a list of `{property_id, value}` pairs, not fields, and `value`
|
|
89
|
-
* is a string for the scalars and an array of `{unit, price, currency}` for the price. The units are
|
|
90
|
-
* the provider's own wording — "per M input tokens" — so they are matched loosely and never parsed
|
|
91
|
-
* as a contract: an unrecognised unit costs a missing price, and a missing price shows nothing,
|
|
92
|
-
* which is the behaviour #257 asks for anyway.
|
|
93
|
-
*/
|
|
94
|
-
const PriceEntry = z.object({
|
|
95
|
-
unit: z.string(),
|
|
96
|
-
price: z.union([z.number(), z.string()]),
|
|
97
|
-
currency: z.string().nullish(),
|
|
98
|
-
});
|
|
99
|
-
const ModelProperty = z.object({
|
|
100
|
-
property_id: z.string(),
|
|
101
|
-
value: z.union([z.string(), z.number(), z.boolean(), z.array(PriceEntry)]),
|
|
102
|
-
});
|
|
103
|
-
const ModelEntry = z.object({
|
|
104
|
-
name: z.string(),
|
|
105
|
-
properties: z.array(ModelProperty).nullish(),
|
|
106
|
-
});
|
|
107
|
-
// Required for the same reason `LogResponse.result` is: a body this reader does not recognise must
|
|
108
|
-
// not come out as "the account offers no models".
|
|
109
|
-
const ModelResponse = z.object({
|
|
110
|
-
success: z.boolean().nullish(),
|
|
111
|
-
result: z.array(ModelEntry).nullable(),
|
|
112
|
-
});
|
|
113
|
-
function propertyOf(properties, id) {
|
|
114
|
-
return properties.find((property) => property.property_id === id)?.value;
|
|
115
|
-
}
|
|
116
|
-
function priceFor(entries, side) {
|
|
117
|
-
const found = entries.find((entry) => {
|
|
118
|
-
const unit = entry.unit.toLowerCase();
|
|
119
|
-
return unit.includes(side) && unit.includes("token") && /\bm\b|million/.test(unit);
|
|
120
|
-
});
|
|
121
|
-
if (!found)
|
|
122
|
-
return undefined;
|
|
123
|
-
const value = typeof found.price === "string" ? Number(found.price) : found.price;
|
|
124
|
-
return Number.isFinite(value) ? value : undefined;
|
|
125
|
-
}
|
|
126
|
-
function readModel(entry) {
|
|
127
|
-
const properties = entry.properties ?? [];
|
|
128
|
-
const context = Number(propertyOf(properties, "context_window"));
|
|
129
|
-
const rawPrice = propertyOf(properties, "price");
|
|
130
|
-
const prices = Array.isArray(rawPrice) ? rawPrice : [];
|
|
131
|
-
const input = priceFor(prices, "input");
|
|
132
|
-
const output = priceFor(prices, "output");
|
|
133
|
-
return {
|
|
134
|
-
name: entry.name,
|
|
135
|
-
contextTokens: Number.isFinite(context) && context > 0 ? context : null,
|
|
136
|
-
// ⚠️ Both halves or neither. A model shown with an input price and no output price reads as if
|
|
137
|
-
// its answers were free, which is a worse statement than saying nothing.
|
|
138
|
-
price: input !== undefined && output !== undefined
|
|
139
|
-
? { inputPerMillion: input, outputPerMillion: output }
|
|
140
|
-
: null,
|
|
141
|
-
functionCalling: String(propertyOf(properties, "function_calling") ?? "") === "true",
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
export function createCloudflareApi(deps) {
|
|
145
|
-
/**
|
|
146
|
-
* ⚠️ Neither the URL nor the body of a refusal is quoted onward. The URL carries the account id
|
|
147
|
-
* and the body carries whatever Cloudflare wrote about a token; this message is read by a person
|
|
148
|
-
* on a screen and by a model through the MCP surface alike. The status is kept, because it is the
|
|
149
|
-
* whole of what an operator can act on: 401/403 is the token's permissions, 404 is the gateway id,
|
|
150
|
-
* 429 is a limit.
|
|
151
|
-
*/
|
|
152
|
-
async function get(path, query) {
|
|
153
|
-
const url = new URL(`${ApiOrigin}${path}`);
|
|
154
|
-
for (const [key, value] of Object.entries(query))
|
|
155
|
-
url.searchParams.set(key, value);
|
|
156
|
-
let response;
|
|
157
|
-
try {
|
|
158
|
-
response = await deps.fetch(url, {
|
|
159
|
-
headers: { authorization: `Bearer ${deps.token}`, accept: "application/json" },
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
catch {
|
|
163
|
-
throw new IntelError(502, "cloudflare_api_unreachable", "The Cloudflare API did not answer");
|
|
164
|
-
}
|
|
165
|
-
if (!response.ok) {
|
|
166
|
-
throw new IntelError(502, "cloudflare_api_refused", `The Cloudflare API refused this read (HTTP ${response.status})`);
|
|
167
|
-
}
|
|
168
|
-
return await response.json().catch(() => null);
|
|
169
|
-
}
|
|
170
|
-
return {
|
|
171
|
-
async gatewayCalls(query) {
|
|
172
|
-
const logPath = `/accounts/${encodeURIComponent(deps.accountId)}/ai-gateway/gateways/${encodeURIComponent(deps.gatewayId)}/logs`;
|
|
173
|
-
/**
|
|
174
|
-
* One window of the log, optionally cut to this agent by the gateway itself.
|
|
175
|
-
*
|
|
176
|
-
* The encoding Cloudflare documents nowhere, measured against the live gateway (#274):
|
|
177
|
-
* `filters` is a URL-encoded JSON array, and `value` is an ARRAY even for a single value.
|
|
178
|
-
*
|
|
179
|
-
* filters=[{"key":"metadata.value","operator":"eq","value":["<id>"]}] → 200, filtered
|
|
180
|
-
* value as a scalar string → 400 `Expected array, received string`
|
|
181
|
-
* filters as a JSON object → 400 `Expected array, received object`
|
|
182
|
-
* filters[0][key]=… (brackets) and filters.0.key=… (dots) → 200, SILENTLY IGNORED
|
|
183
|
-
*
|
|
184
|
-
* ⚠️ The last line is why this was never guessed at: two of the five spellings answer 200
|
|
185
|
-
* with the whole unfiltered window, which reads exactly like a filter that matched
|
|
186
|
-
* everything. Two filters combine with AND (measured with `metadata.key` plus `runId`).
|
|
187
|
-
*/
|
|
188
|
-
async function read(pages, filtered) {
|
|
189
|
-
const calls = [];
|
|
190
|
-
let partial = false;
|
|
191
|
-
for (let index = 1; index <= pages; index += 1) {
|
|
192
|
-
const body = await get(logPath, {
|
|
193
|
-
page: String(index),
|
|
194
|
-
per_page: String(PerPage),
|
|
195
|
-
start_date: query.since.toISOString(),
|
|
196
|
-
end_date: query.until.toISOString(),
|
|
197
|
-
order_by: "created_at",
|
|
198
|
-
order_by_direction: "desc",
|
|
199
|
-
...(filtered
|
|
200
|
-
? {
|
|
201
|
-
filters: JSON.stringify([
|
|
202
|
-
{ key: "metadata.value", operator: "eq", value: [query.agentId] },
|
|
203
|
-
]),
|
|
204
|
-
}
|
|
205
|
-
: {}),
|
|
206
|
-
});
|
|
207
|
-
const parsed = LogResponse.safeParse(body);
|
|
208
|
-
// A shape this reader cannot make sense of is a failure, not an empty window: an empty
|
|
209
|
-
// window reads as "this agent cost nothing".
|
|
210
|
-
if (!parsed.success) {
|
|
211
|
-
throw new IntelError(502, "cloudflare_api_unreadable", "The Cloudflare API answered in a shape this version does not understand");
|
|
212
|
-
}
|
|
213
|
-
const entries = parsed.data.result ?? [];
|
|
214
|
-
// ⚠️ The local check stays even under the server-side filter, and it is no longer belt
|
|
215
|
-
// and braces: it is the only thing that could notice the filter matching the wrong rows.
|
|
216
|
-
for (const entry of entries) {
|
|
217
|
-
const metadata = readMetadata(entry.metadata);
|
|
218
|
-
if (metadata.agentId !== query.agentId)
|
|
219
|
-
continue;
|
|
220
|
-
calls.push({
|
|
221
|
-
runId: typeof metadata.runId === "string" ? metadata.runId : null,
|
|
222
|
-
model: entry.model ?? "",
|
|
223
|
-
cost: entry.cost ?? 0,
|
|
224
|
-
at: entry.created_at ?? query.until.toISOString(),
|
|
225
|
-
});
|
|
226
|
-
}
|
|
227
|
-
if (entries.length < PerPage)
|
|
228
|
-
return { calls, partial: false };
|
|
229
|
-
partial = index === pages;
|
|
230
|
-
}
|
|
231
|
-
return { calls, partial };
|
|
232
|
-
}
|
|
233
|
-
const answer = await read(MaxPages, true);
|
|
234
|
-
/**
|
|
235
|
-
* ⚠️ Anything at all is taken at face value, and that is a decision with a hole in it. A
|
|
236
|
-
* filter that degraded PARTLY — matching some of this agent's rows and not others — returns
|
|
237
|
-
* here, and the under-count is then handed out as `partial: false`, which claims to be a
|
|
238
|
-
* total. The probe below cannot see that case, because the probe is only reached when the
|
|
239
|
-
* filtered read found nothing. It is the price of not reading the whole log twice on every
|
|
240
|
-
* request, and it is worth knowing before somebody reads `partial: false` as "complete".
|
|
241
|
-
*/
|
|
242
|
-
if (answer.calls.length > 0)
|
|
243
|
-
return answer;
|
|
244
|
-
/**
|
|
245
|
-
* ⚠️ Zero is the ONE answer this reader may not take at face value, and the reason is the
|
|
246
|
-
* way `filters` fails. Measured: `cached` filtered to `["false"]` answers 200 with zero rows
|
|
247
|
-
* although every row in that window carries `cached: false` — a value the index does not
|
|
248
|
-
* match empties the page instead of being refused. So the day metadata stops being indexed
|
|
249
|
-
* the way it is today, this would report "cost nothing" for an agent that spent money, under
|
|
250
|
-
* `status: "read"`. That is precisely the sentence #251 exists for.
|
|
251
|
-
*
|
|
252
|
-
* One unfiltered page is the probe: if the newest calls contain any of this agent's, the
|
|
253
|
-
* filter is lying and the honest answer is that the log could not be read. It costs one
|
|
254
|
-
* request and only in the empty case, which is a fresh agent or a quiet window — and in the
|
|
255
|
-
* case it replaces, a quiet agent on a busy gateway, it costs 2 requests where reading the
|
|
256
|
-
* whole log unfiltered used to cost 20.
|
|
257
|
-
*
|
|
258
|
-
* ⚠️ It is a probe and not a proof: an agent whose only calls are older than the newest
|
|
259
|
-
* `PerPage` of the whole gateway is invisible to it, and that case still reports zero. What
|
|
260
|
-
* it buys is that a filter which stopped working ENTIRELY cannot pass as silence.
|
|
261
|
-
*
|
|
262
|
-
* ⚠️ A probe that cannot run leaves the answer standing, deliberately. The read this is
|
|
263
|
-
* checking SUCCEEDED and said "no calls"; letting a second opinion that never arrived turn
|
|
264
|
-
* that into `unreadable` would report an outage for the most ordinary state there is — a
|
|
265
|
-
* fresh agent — because one extra request met a 429. The guard is a second opinion, not the
|
|
266
|
-
* answer.
|
|
267
|
-
*/
|
|
268
|
-
const probe = await read(1, false).catch(() => null);
|
|
269
|
-
if (probe === null || probe.calls.length === 0)
|
|
270
|
-
return answer;
|
|
271
|
-
throw new IntelError(502, "cloudflare_api_unreadable", "The Cloudflare API answered no calls for this agent while its own log holds some");
|
|
272
|
-
},
|
|
273
|
-
async workersAiModels() {
|
|
274
|
-
const models = [];
|
|
275
|
-
for (let index = 1; index <= ModelMaxPages; index += 1) {
|
|
276
|
-
const body = await get(`/accounts/${encodeURIComponent(deps.accountId)}/ai/models/search`, {
|
|
277
|
-
page: String(index),
|
|
278
|
-
per_page: String(ModelPerPage),
|
|
279
|
-
hide_experimental: "true",
|
|
280
|
-
});
|
|
281
|
-
const parsed = ModelResponse.safeParse(body);
|
|
282
|
-
if (!parsed.success) {
|
|
283
|
-
throw new IntelError(502, "cloudflare_api_unreadable", "The Cloudflare API answered in a shape this version does not understand");
|
|
284
|
-
}
|
|
285
|
-
const entries = parsed.data.result ?? [];
|
|
286
|
-
models.push(...entries.map(readModel));
|
|
287
|
-
// ⚠️ A SHORT page is the end, and a full one is not a reason to believe the answer was
|
|
288
|
-
// complete (#330). Before this the reader took page one and stopped: a model past position
|
|
289
|
-
// 100 was simply absent from the select, `liveStatus` still said `read`, and nothing said
|
|
290
|
-
// the list was shorter than the account. A missing entry has no `source` to mark stale
|
|
291
|
-
// with, so the per-entry labelling that covers a fallback cannot cover this at all.
|
|
292
|
-
//
|
|
293
|
-
// ⚠️ `result_info.total_count` is NOT the test, which is why it is not parsed. Measured on
|
|
294
|
-
// the live account (#297): it reports 286 while page one returns 61 and page two is empty.
|
|
295
|
-
// A reader that paginated on that figure would loop over empty pages and call the result
|
|
296
|
-
// partial. The full page is the only honest signal.
|
|
297
|
-
if (entries.length < ModelPerPage)
|
|
298
|
-
return models;
|
|
299
|
-
}
|
|
300
|
-
// Ten full pages is not a big account, it is an endpoint that stopped ending. Refusing is the
|
|
301
|
-
// honest answer: the catalog service falls back to its built-in table and the screen says the
|
|
302
|
-
// figures may be old — better than a list that is silently missing whatever came after.
|
|
303
|
-
throw new IntelError(502, "cloudflare_api_unreadable", "The Cloudflare API kept answering full pages of models past the read limit");
|
|
304
|
-
},
|
|
305
|
-
};
|
|
306
|
-
}
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The two things Intel reads out of the Cloudflare account, and nothing else.
|
|
3
|
-
*
|
|
4
|
-
* ⚠️ Both are READS, and the port says so by having no other verb. The token behind it is
|
|
5
|
-
* account-wide — Cloudflare offers no per-gateway scope for `AI Gateway: Read`, the same limitation
|
|
6
|
-
* `AI Gateway: Run` already had in #239 — so the narrowness has to come from what this interface
|
|
7
|
-
* can express rather than from what the credential allows.
|
|
8
|
-
*/
|
|
9
|
-
export interface CloudflareAccountApi {
|
|
10
|
-
/**
|
|
11
|
-
* The gateway's own log lines for one agent, within a window.
|
|
12
|
-
*
|
|
13
|
-
* ⚠️ `cost` here is the **debit from the Cloudflare balance**, 1:1 — measured on 2026-08-07
|
|
14
|
-
* against the running installation: balance $19.77 + spend $0.23 = the $20.00 that was loaded.
|
|
15
|
-
* Cloudflare takes its 5 % when the balance is topped up and passes inference through without a
|
|
16
|
-
* markup, so this number means "what this costs us" and needs no conversion. A reader who
|
|
17
|
-
* multiplied it by anything would be inventing a second, wrong price.
|
|
18
|
-
*/
|
|
19
|
-
gatewayCalls(query: GatewayCallQuery): Promise<GatewayCallPage>;
|
|
20
|
-
/**
|
|
21
|
-
* What Cloudflare currently charges for the models it serves itself (#257).
|
|
22
|
-
*
|
|
23
|
-
* ⚠️ Workers AI only. Cloudflare publishes no price list for the Anthropic models it resells
|
|
24
|
-
* through Unified Billing, so those figures have no live source and stay a table — which is the
|
|
25
|
-
* whole reason the catalog says, per entry, where its numbers came from.
|
|
26
|
-
*/
|
|
27
|
-
workersAiModels(): Promise<WorkersAiModel[]>;
|
|
28
|
-
}
|
|
29
|
-
export interface GatewayCallQuery {
|
|
30
|
-
/** The value stamped as `cf-aig-metadata.agentId` by the agent runtime. */
|
|
31
|
-
agentId: string;
|
|
32
|
-
since: Date;
|
|
33
|
-
until: Date;
|
|
34
|
-
}
|
|
35
|
-
export interface GatewayCall {
|
|
36
|
-
/** From `cf-aig-metadata.runId`. `null` for a call made before the stamp existed. */
|
|
37
|
-
runId: string | null;
|
|
38
|
-
model: string;
|
|
39
|
-
/** US dollars, as billed. */
|
|
40
|
-
cost: number;
|
|
41
|
-
at: string;
|
|
42
|
-
}
|
|
43
|
-
export interface GatewayCallPage {
|
|
44
|
-
calls: GatewayCall[];
|
|
45
|
-
/**
|
|
46
|
-
* The window was cut off at the page cap, so every total built from it is a floor rather than a
|
|
47
|
-
* total.
|
|
48
|
-
*
|
|
49
|
-
* ⚠️ It exists so the screen can say "at least". A sum that silently stopped counting is the same
|
|
50
|
-
* failure as a missing number pretending to be zero, only harder to notice.
|
|
51
|
-
*/
|
|
52
|
-
partial: boolean;
|
|
53
|
-
}
|
|
54
|
-
export interface WorkersAiModel {
|
|
55
|
-
/** The full `@cf/...` id, exactly as a definition names it. */
|
|
56
|
-
name: string;
|
|
57
|
-
contextTokens: number | null;
|
|
58
|
-
price: {
|
|
59
|
-
inputPerMillion: number;
|
|
60
|
-
outputPerMillion: number;
|
|
61
|
-
} | null;
|
|
62
|
-
/** Whether this model can call a tool at all. An agent is a tool loop; one that cannot is useless. */
|
|
63
|
-
functionCalling: boolean;
|
|
64
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import type { AgentApplications } from "../../nodes/nodes.types.js";
|
|
2
|
-
/**
|
|
3
|
-
* Intel's door to Gate's Applications surface (`anchrd/gate#223`/`#224`, Gate 0.10.x).
|
|
4
|
-
*
|
|
5
|
-
* ⚠️ This is the one Gate call Intel makes with the CALLER's bearer instead of its service key, and
|
|
6
|
-
* that is not a shortcut — the service key does not open this door. A Gate service key authorizes
|
|
7
|
-
* `/api/v1/authorization` and `/api/v1/schema` and nothing else (Gate's two-token rule); the
|
|
8
|
-
* Applications routes are admin-gated and resolve a real principal, so the person creating an agent
|
|
9
|
-
* needs `applications:write` in Gate and their act is audited in Gate under their own name. A
|
|
10
|
-
* service key here would have made Intel the author of every machine principal an installation ever
|
|
11
|
-
* grew, which is the opposite of what an audit trail is for.
|
|
12
|
-
*
|
|
13
|
-
* ⚠️ Nothing this module receives from Gate is logged, wrapped into a message, or returned other
|
|
14
|
-
* than through the one typed answer below. `create` is handed a plain-text key, and the shortest
|
|
15
|
-
* path from here to a leak is an error that quotes the response body — so no refusal names anything
|
|
16
|
-
* but the status Gate answered with.
|
|
17
|
-
*/
|
|
18
|
-
export interface GateApplicationsDeps {
|
|
19
|
-
fetch: (url: string, init?: RequestInit) => Promise<Response>;
|
|
20
|
-
gateUrl: string;
|
|
21
|
-
timeoutMs?: number;
|
|
22
|
-
}
|
|
23
|
-
export declare function createGateApplications(deps: GateApplicationsDeps): AgentApplications;
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
import { IntelError } from "../../shared/intel-error/intel-error.js";
|
|
3
|
-
// Tolerant on purpose, unlike Intel's own contracts: this is somebody else's wire format, and a
|
|
4
|
-
// field Gate adds tomorrow must not stop an installation from creating an agent. Only what Intel
|
|
5
|
-
// actually reads is named.
|
|
6
|
-
const CreatedApplication = z.object({ id: z.string().min(1), key: z.string().min(1) });
|
|
7
|
-
// The same tolerance, for the same reason. Only the key is read: the ID is the one Intel asked with.
|
|
8
|
-
const RotatedApplication = z.object({ key: z.string().min(1) });
|
|
9
|
-
const DefaultTimeoutMs = 10_000;
|
|
10
|
-
export function createGateApplications(deps) {
|
|
11
|
-
const base = deps.gateUrl.replace(/\/+$/, "");
|
|
12
|
-
const timeoutMs = deps.timeoutMs ?? DefaultTimeoutMs;
|
|
13
|
-
// Gate's refusals, translated once. A 401/403 is the caller's missing `applications:write` and is
|
|
14
|
-
// permanent until somebody acts in Gate, so it is answered as a refusal rather than as an outage;
|
|
15
|
-
// everything else — a 5xx, a timeout, a DNS failure — is "Gate did not answer", and the caller is
|
|
16
|
-
// told that no agent was created rather than left to guess.
|
|
17
|
-
function refusal(status) {
|
|
18
|
-
if (status === 401 || status === 403) {
|
|
19
|
-
return new IntelError(403, "agent_application_forbidden", "Gate refused this account the management of applications — creating an agent needs the applications permission in Gate");
|
|
20
|
-
}
|
|
21
|
-
return new IntelError(502, "agent_application_unavailable", `Gate could not manage this agent's application (${status})`);
|
|
22
|
-
}
|
|
23
|
-
async function call(path, token, body) {
|
|
24
|
-
try {
|
|
25
|
-
return await deps.fetch(`${base}/api/v1/applications${path}`, {
|
|
26
|
-
method: "POST",
|
|
27
|
-
headers: {
|
|
28
|
-
"content-type": "application/json",
|
|
29
|
-
authorization: `Bearer ${token}`,
|
|
30
|
-
},
|
|
31
|
-
body: JSON.stringify(body),
|
|
32
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
catch {
|
|
36
|
-
// ⚠️ The caught error is dropped rather than described. A fetch failure carries the URL, and
|
|
37
|
-
// the URL is the one place the bearer could still be if a caller ever put it in a query.
|
|
38
|
-
throw new IntelError(502, "agent_application_unavailable", "Gate did not answer, so no agent application was created or changed");
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
return {
|
|
42
|
-
async create(input) {
|
|
43
|
-
const response = await call("", input.token, { name: input.name });
|
|
44
|
-
if (!response.ok)
|
|
45
|
-
throw refusal(response.status);
|
|
46
|
-
const parsed = CreatedApplication.safeParse(await response.json().catch(() => null));
|
|
47
|
-
if (!parsed.success) {
|
|
48
|
-
// ⚠️ A 2xx Intel cannot read means a principal MAY exist in Gate that Intel cannot record.
|
|
49
|
-
// Nothing has been written on this side yet, so the agent does not come into being; the
|
|
50
|
-
// operator finds an unused application in Gate's list rather than an agent that half works.
|
|
51
|
-
throw new IntelError(502, "agent_application_unavailable", "Gate answered the application creation in a shape Intel cannot read");
|
|
52
|
-
}
|
|
53
|
-
return { id: parsed.data.id, key: parsed.data.key };
|
|
54
|
-
},
|
|
55
|
-
async rotateKey(input) {
|
|
56
|
-
// Gate issues the replacement FIRST and only then revokes what was there, so a rotation that
|
|
57
|
-
// fails leaves the old key working rather than locking the agent out (`applications.ts` in
|
|
58
|
-
// `anchrd/gate`). Intel relies on that: the handover to the runtime happens after this call,
|
|
59
|
-
// and until it succeeds the agent keeps running on the key it had.
|
|
60
|
-
const response = await call(`/${encodeURIComponent(input.applicationId)}/rotate-key`, input.token, {});
|
|
61
|
-
if (response.status === 404) {
|
|
62
|
-
throw new IntelError(502, "agent_application_missing", "Gate does not know this agent's application any more");
|
|
63
|
-
}
|
|
64
|
-
if (!response.ok)
|
|
65
|
-
throw refusal(response.status);
|
|
66
|
-
const parsed = RotatedApplication.safeParse(await response.json().catch(() => null));
|
|
67
|
-
if (!parsed.success) {
|
|
68
|
-
// ⚠️ A 2xx Intel cannot read means the OLD key is already revoked in Gate and the new one is
|
|
69
|
-
// lost. The agent is broken either way, so the caller is told the rotation failed and rotates
|
|
70
|
-
// again — which is safe, because rotating twice is just another new key.
|
|
71
|
-
throw new IntelError(502, "agent_application_unavailable", "Gate answered the key rotation in a shape Intel cannot read");
|
|
72
|
-
}
|
|
73
|
-
return { key: parsed.data.key };
|
|
74
|
-
},
|
|
75
|
-
async setEnabled(input) {
|
|
76
|
-
// Both routes are idempotent in Gate, which is what lets a retried archive heal a run that
|
|
77
|
-
// failed between the two writes instead of needing a repair path of its own.
|
|
78
|
-
const response = await call(`/${encodeURIComponent(input.applicationId)}/${input.enabled ? "enable" : "disable"}`, input.token, {});
|
|
79
|
-
// A 404 is the one status worth separating: the Application behind this agent is gone from
|
|
80
|
-
// Gate, and telling the operator that is more use than a generic outage they would retry.
|
|
81
|
-
if (response.status === 404) {
|
|
82
|
-
throw new IntelError(502, "agent_application_missing", "Gate does not know this agent's application any more");
|
|
83
|
-
}
|
|
84
|
-
if (!response.ok)
|
|
85
|
-
throw refusal(response.status);
|
|
86
|
-
},
|
|
87
|
-
};
|
|
88
|
-
}
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import type { ContentStore } from "../../nodes/nodes.types.js";
|
|
2
|
-
import type { ToolAuditEvent, ToolDelegation } from "../../tools/tools.types.js";
|
|
3
|
-
import type { D1Database } from "../db/db.types.js";
|
|
4
|
-
/**
|
|
5
|
-
* The two D1 statements the delegated tool path needs (D30): who is acting, and a record that they
|
|
6
|
-
* did.
|
|
7
|
-
*
|
|
8
|
-
* ⚠️ Its own adapter rather than a method on the node repository, and deliberately so. The question
|
|
9
|
-
* is asked with a **Gate Application id** and no Intel actor at all — there is no ACL to apply,
|
|
10
|
-
* because the answer is not "may you read this agent" but "which agent are you". Hanging it off the
|
|
11
|
-
* tree's repository would put an unauthorized read next to authorized ones, which is the shape a
|
|
12
|
-
* later reader copies by accident.
|
|
13
|
-
*/
|
|
14
|
-
export declare function createToolDelegation(deps: {
|
|
15
|
-
db: D1Database;
|
|
16
|
-
content: ContentStore;
|
|
17
|
-
id(): string;
|
|
18
|
-
now(): Date;
|
|
19
|
-
}): {
|
|
20
|
-
resolve(applicationId: string): Promise<ToolDelegation | null>;
|
|
21
|
-
audit(event: ToolAuditEvent): Promise<void>;
|
|
22
|
-
};
|