@anchrd/intel-api 0.10.0 → 0.12.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/README.md +28 -0
- package/dist/adapters/cloudflare/cloudflare.js +27 -0
- package/dist/adapters/cloudflare/cloudflare.types.d.ts +19 -0
- package/dist/adapters/cloudflare-api/cloudflare-api.d.ts +22 -0
- package/dist/adapters/cloudflare-api/cloudflare-api.js +189 -0
- package/dist/adapters/cloudflare-api/cloudflare-api.types.d.ts +64 -0
- package/dist/adapters/cloudflare-api/cloudflare-api.types.js +1 -0
- package/dist/agent-costs/agent-costs.d.ts +16 -0
- package/dist/agent-costs/agent-costs.js +105 -0
- package/dist/agent-costs/agent-costs.types.d.ts +30 -0
- package/dist/agent-costs/agent-costs.types.js +1 -0
- package/dist/build/build.js +7 -2
- package/dist/bundle/bundle.js +5 -2
- package/dist/flows/flows.js +3 -2
- package/dist/http/http.js +37 -0
- package/dist/http/http.types.d.ts +6 -0
- package/dist/intel/intel.js +3 -0
- package/dist/intel/intel.types.d.ts +4 -0
- package/dist/mcp/mcp.js +23 -0
- package/dist/mcp/mcp.types.d.ts +3 -0
- package/dist/model-catalog/model-catalog.d.ts +2 -0
- package/dist/model-catalog/model-catalog.js +99 -0
- package/dist/model-catalog/model-catalog.types.d.ts +15 -0
- package/dist/model-catalog/model-catalog.types.js +1 -0
- package/dist/nodes/nodes.js +43 -6
- package/dist/shared/plain-title/plain-title.d.ts +16 -0
- package/dist/shared/plain-title/plain-title.js +45 -0
- package/dist/tools/tool-servers/tool-servers.js +69 -6
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -98,6 +98,34 @@ routes answer `503 agent_runtime_not_configured` and nothing else changes. Deplo
|
|
|
98
98
|
before an Intel deployment that declares it — Wrangler refuses a binding to a service that does not
|
|
99
99
|
exist yet.
|
|
100
100
|
|
|
101
|
+
### What an agent costs, and what a model costs
|
|
102
|
+
|
|
103
|
+
Two figures on the agent screen come from Cloudflare rather than from a table in this repository:
|
|
104
|
+
what one agent has actually spent, per run and over the last 7 and 30 days, and what each offered
|
|
105
|
+
model charges per million tokens. Both are read by Intel and never by the browser, because reading
|
|
106
|
+
them needs an account credential and a credential does not belong in a single-page app.
|
|
107
|
+
|
|
108
|
+
Set `AI_GATEWAY_ACCOUNT_ID` and `AI_GATEWAY_ID` — the same account and gateway the agent runtime
|
|
109
|
+
uses — and add a Wrangler secret:
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
wrangler secret put AI_GATEWAY_READ_TOKEN # Cloudflare API token: "AI Gateway: Read" + "Workers AI: Read"
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
⚠️ **This is not the runtime's `AI_GATEWAY_TOKEN`.** That one carries `AI Gateway: Run` and buys
|
|
116
|
+
inference; this one only reads. Cloudflare cannot scope `AI Gateway: Read` to a single gateway — it
|
|
117
|
+
is account-wide — which is worth knowing when the token is created and is not a reason against it.
|
|
118
|
+
|
|
119
|
+
All three are optional. Without them the cost view says *"this installation has no AI Gateway read
|
|
120
|
+
token"* and the model figures come off a built-in table that the interface marks as possibly out of
|
|
121
|
+
date. Neither is an error state: an installation that gives Intel no Cloudflare credential is a
|
|
122
|
+
supported installation. What is never done is show a zero — a missing figure and "cost nothing" are
|
|
123
|
+
different answers, and only one of them is about money.
|
|
124
|
+
|
|
125
|
+
The attribution comes from the agent runtime, which stamps `agentId` and `runId` into
|
|
126
|
+
`cf-aig-metadata` on every model call. Runs made before that stamp existed appear with their token
|
|
127
|
+
counts and without a cost, and say so.
|
|
128
|
+
|
|
101
129
|
### Creating an agent creates its Gate application
|
|
102
130
|
|
|
103
131
|
An agent acts as its own machine principal, so creating an agent node also creates the Gate
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { createGateClient } from "@anchrd/gate-sdk";
|
|
2
2
|
import { ulid } from "ulid";
|
|
3
|
+
import { createAgentCosts } from "../../agent-costs/agent-costs.js";
|
|
3
4
|
import { createAgentRuntimeService } from "../../agent-runtime/agent-runtime.js";
|
|
4
5
|
import { createBrowserAuth } from "../../auth/auth.js";
|
|
5
6
|
import { createBundle } from "../../bundle/bundle.js";
|
|
6
7
|
import { createFlows } from "../../flows/flows.js";
|
|
7
8
|
import { createIndexing, PermanentIndexingError } from "../../indexing/indexing.js";
|
|
8
9
|
import { createIntel } from "../../intel/intel.js";
|
|
10
|
+
import { createModelCatalog } from "../../model-catalog/model-catalog.js";
|
|
9
11
|
import { createNodes } from "../../nodes/nodes.js";
|
|
10
12
|
import { IntelError } from "../../shared/intel-error/intel-error.js";
|
|
11
13
|
import { sha256Hex } from "../../shared/sha256/sha256.js";
|
|
12
14
|
import { createTools } from "../../tools/tools.js";
|
|
15
|
+
import { createCloudflareApi } from "../cloudflare-api/cloudflare-api.js";
|
|
13
16
|
import { createContentStore } from "../content/content.js";
|
|
14
17
|
import { createNodeRepository } from "../db/db.js";
|
|
15
18
|
import { createFlowRepository } from "../db/db-flows.js";
|
|
@@ -133,6 +136,28 @@ export default {
|
|
|
133
136
|
// same question as whether they may see the node, and no second answer is invented here.
|
|
134
137
|
visibleNode: async (actor, nodeId) => await nodes.visibleNode(actor, nodeId),
|
|
135
138
|
});
|
|
139
|
+
// ⚠️ One Cloudflare read client for both of the account's answers — the gateway's cost log
|
|
140
|
+
// (#251) and the Workers AI price list (#257) — because it is one token and one account. It is
|
|
141
|
+
// absent as a whole where any of the three variables is missing: a half-configured client would
|
|
142
|
+
// fail per call with a 401 that reads like a permission problem instead of saying, once, that
|
|
143
|
+
// this deployment was never given a token.
|
|
144
|
+
const gatewayAccountId = env.AI_GATEWAY_ACCOUNT_ID?.trim();
|
|
145
|
+
const gatewayId = env.AI_GATEWAY_ID?.trim();
|
|
146
|
+
const gatewayReadToken = env.AI_GATEWAY_READ_TOKEN?.trim();
|
|
147
|
+
const cloudflareApi = gatewayAccountId && gatewayId && gatewayReadToken
|
|
148
|
+
? createCloudflareApi({
|
|
149
|
+
accountId: gatewayAccountId,
|
|
150
|
+
gatewayId,
|
|
151
|
+
token: gatewayReadToken,
|
|
152
|
+
fetch: globalThis.fetch.bind(globalThis),
|
|
153
|
+
})
|
|
154
|
+
: undefined;
|
|
155
|
+
const agentCosts = createAgentCosts({
|
|
156
|
+
...(cloudflareApi ? { gateway: cloudflareApi } : {}),
|
|
157
|
+
visibleNode: async (actor, nodeId) => await nodes.visibleNode(actor, nodeId),
|
|
158
|
+
now,
|
|
159
|
+
});
|
|
160
|
+
const models = createModelCatalog({ ...(cloudflareApi ? { cloudflare: cloudflareApi } : {}) });
|
|
136
161
|
const nodes = createNodes({
|
|
137
162
|
repository: nodeRepository,
|
|
138
163
|
content: contentStore,
|
|
@@ -223,6 +248,8 @@ export default {
|
|
|
223
248
|
indexing: createIndexQueue(env.INDEXING),
|
|
224
249
|
}),
|
|
225
250
|
agents,
|
|
251
|
+
agentCosts,
|
|
252
|
+
models,
|
|
226
253
|
auth,
|
|
227
254
|
}).fetch(request);
|
|
228
255
|
},
|
|
@@ -39,6 +39,25 @@ export interface CloudflareEnv {
|
|
|
39
39
|
* it is absent, creating an agent refuses by name instead of leaving one without a key.
|
|
40
40
|
*/
|
|
41
41
|
AGENT_HANDOVER_SECRET?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Where the AI Gateway lives and how to READ it (#251, #257).
|
|
44
|
+
*
|
|
45
|
+
* ⚠️ `AI_GATEWAY_READ_TOKEN` is not the agent runtime's `AI_GATEWAY_TOKEN` and must never be the
|
|
46
|
+
* same value. That one carries `AI Gateway: Run` and buys inference; this one carries
|
|
47
|
+
* `AI Gateway: Read` — plus `Workers AI: Read` for the model catalog — and buys nothing at all.
|
|
48
|
+
* One credential doing both would put a spending permission into the Worker that only reads.
|
|
49
|
+
*
|
|
50
|
+
* ⚠️ Cloudflare cannot scope `AI Gateway: Read` to a single gateway; it is account-wide, the same
|
|
51
|
+
* limitation `AI Gateway: Run` already had in #239. It is worth knowing when the token is created
|
|
52
|
+
* and is not a reason against the route: it reads and writes nothing.
|
|
53
|
+
*
|
|
54
|
+
* ⚠️ All three optional, together. A deployment that configures none of them gets a cost view that
|
|
55
|
+
* says "not configured" and a model catalog off the built-in table — both named states, neither an
|
|
56
|
+
* error. Intel without a Cloudflare token is a supported installation.
|
|
57
|
+
*/
|
|
58
|
+
AI_GATEWAY_ACCOUNT_ID?: string;
|
|
59
|
+
AI_GATEWAY_ID?: string;
|
|
60
|
+
AI_GATEWAY_READ_TOKEN?: string;
|
|
42
61
|
TOOL_SOURCE_ORIGINS: string;
|
|
43
62
|
MCP_PORTAL_URL?: string;
|
|
44
63
|
ALLOW_INSECURE_OAUTH?: string;
|
|
@@ -0,0 +1,22 @@
|
|
|
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;
|
|
@@ -0,0 +1,189 @@
|
|
|
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. Ten pages of a hundred 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
|
+
const MaxPages = 10;
|
|
13
|
+
const PerPage = 100;
|
|
14
|
+
/**
|
|
15
|
+
* The gateway's log entry, read tolerantly.
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ `metadata` arrives as an object on some responses and as a JSON string on others, and neither
|
|
18
|
+
* is documented as the one shape. Both are accepted; anything else means the call is unattributed,
|
|
19
|
+
* which is a state the caller can see rather than a parse failure that blanks the whole window.
|
|
20
|
+
*/
|
|
21
|
+
const LogEntry = z.object({
|
|
22
|
+
cost: z.number().nullish(),
|
|
23
|
+
model: z.string().nullish(),
|
|
24
|
+
created_at: z.string().nullish(),
|
|
25
|
+
metadata: z.union([z.string(), z.record(z.string(), z.unknown())]).nullish(),
|
|
26
|
+
});
|
|
27
|
+
/**
|
|
28
|
+
* ⚠️ `result` is required, only its contents may be null. An optional field would let ANY JSON body
|
|
29
|
+
* parse as an empty page — and an empty page reads as "this agent cost nothing", which is the one
|
|
30
|
+
* answer this whole path exists to avoid giving by accident.
|
|
31
|
+
*/
|
|
32
|
+
const LogResponse = z.object({
|
|
33
|
+
success: z.boolean().nullish(),
|
|
34
|
+
result: z.array(LogEntry).nullable(),
|
|
35
|
+
});
|
|
36
|
+
function readMetadata(raw) {
|
|
37
|
+
if (typeof raw === "string") {
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(raw);
|
|
40
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
41
|
+
? parsed
|
|
42
|
+
: {};
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return raw && typeof raw === "object" && !Array.isArray(raw)
|
|
49
|
+
? raw
|
|
50
|
+
: {};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* ⚠️ Cloudflare's `properties` are a list of `{property_id, value}` pairs, not fields, and `value`
|
|
54
|
+
* is a string for the scalars and an array of `{unit, price, currency}` for the price. The units are
|
|
55
|
+
* the provider's own wording — "per M input tokens" — so they are matched loosely and never parsed
|
|
56
|
+
* as a contract: an unrecognised unit costs a missing price, and a missing price shows nothing,
|
|
57
|
+
* which is the behaviour #257 asks for anyway.
|
|
58
|
+
*/
|
|
59
|
+
const PriceEntry = z.object({
|
|
60
|
+
unit: z.string(),
|
|
61
|
+
price: z.union([z.number(), z.string()]),
|
|
62
|
+
currency: z.string().nullish(),
|
|
63
|
+
});
|
|
64
|
+
const ModelProperty = z.object({
|
|
65
|
+
property_id: z.string(),
|
|
66
|
+
value: z.union([z.string(), z.number(), z.boolean(), z.array(PriceEntry)]),
|
|
67
|
+
});
|
|
68
|
+
const ModelEntry = z.object({
|
|
69
|
+
name: z.string(),
|
|
70
|
+
properties: z.array(ModelProperty).nullish(),
|
|
71
|
+
});
|
|
72
|
+
// Required for the same reason `LogResponse.result` is: a body this reader does not recognise must
|
|
73
|
+
// not come out as "the account offers no models".
|
|
74
|
+
const ModelResponse = z.object({
|
|
75
|
+
success: z.boolean().nullish(),
|
|
76
|
+
result: z.array(ModelEntry).nullable(),
|
|
77
|
+
});
|
|
78
|
+
function propertyOf(properties, id) {
|
|
79
|
+
return properties.find((property) => property.property_id === id)?.value;
|
|
80
|
+
}
|
|
81
|
+
function priceFor(entries, side) {
|
|
82
|
+
const found = entries.find((entry) => {
|
|
83
|
+
const unit = entry.unit.toLowerCase();
|
|
84
|
+
return unit.includes(side) && unit.includes("token") && /\bm\b|million/.test(unit);
|
|
85
|
+
});
|
|
86
|
+
if (!found)
|
|
87
|
+
return undefined;
|
|
88
|
+
const value = typeof found.price === "string" ? Number(found.price) : found.price;
|
|
89
|
+
return Number.isFinite(value) ? value : undefined;
|
|
90
|
+
}
|
|
91
|
+
function readModel(entry) {
|
|
92
|
+
const properties = entry.properties ?? [];
|
|
93
|
+
const context = Number(propertyOf(properties, "context_window"));
|
|
94
|
+
const rawPrice = propertyOf(properties, "price");
|
|
95
|
+
const prices = Array.isArray(rawPrice) ? rawPrice : [];
|
|
96
|
+
const input = priceFor(prices, "input");
|
|
97
|
+
const output = priceFor(prices, "output");
|
|
98
|
+
return {
|
|
99
|
+
name: entry.name,
|
|
100
|
+
contextTokens: Number.isFinite(context) && context > 0 ? context : null,
|
|
101
|
+
// ⚠️ Both halves or neither. A model shown with an input price and no output price reads as if
|
|
102
|
+
// its answers were free, which is a worse statement than saying nothing.
|
|
103
|
+
price: input !== undefined && output !== undefined
|
|
104
|
+
? { inputPerMillion: input, outputPerMillion: output }
|
|
105
|
+
: null,
|
|
106
|
+
functionCalling: String(propertyOf(properties, "function_calling") ?? "") === "true",
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
export function createCloudflareApi(deps) {
|
|
110
|
+
/**
|
|
111
|
+
* ⚠️ Neither the URL nor the body of a refusal is quoted onward. The URL carries the account id
|
|
112
|
+
* and the body carries whatever Cloudflare wrote about a token; this message is read by a person
|
|
113
|
+
* on a screen and by a model through the MCP surface alike. The status is kept, because it is the
|
|
114
|
+
* whole of what an operator can act on: 401/403 is the token's permissions, 404 is the gateway id,
|
|
115
|
+
* 429 is a limit.
|
|
116
|
+
*/
|
|
117
|
+
async function get(path, query) {
|
|
118
|
+
const url = new URL(`${ApiOrigin}${path}`);
|
|
119
|
+
for (const [key, value] of Object.entries(query))
|
|
120
|
+
url.searchParams.set(key, value);
|
|
121
|
+
let response;
|
|
122
|
+
try {
|
|
123
|
+
response = await deps.fetch(url, {
|
|
124
|
+
headers: { authorization: `Bearer ${deps.token}`, accept: "application/json" },
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
throw new IntelError(502, "cloudflare_api_unreachable", "The Cloudflare API did not answer");
|
|
129
|
+
}
|
|
130
|
+
if (!response.ok) {
|
|
131
|
+
throw new IntelError(502, "cloudflare_api_refused", `The Cloudflare API refused this read (HTTP ${response.status})`);
|
|
132
|
+
}
|
|
133
|
+
return await response.json().catch(() => null);
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
async gatewayCalls(query) {
|
|
137
|
+
const calls = [];
|
|
138
|
+
let partial = false;
|
|
139
|
+
for (let page = 1; page <= MaxPages; page += 1) {
|
|
140
|
+
const body = await get(`/accounts/${encodeURIComponent(deps.accountId)}/ai-gateway/gateways/${encodeURIComponent(deps.gatewayId)}/logs`, {
|
|
141
|
+
page: String(page),
|
|
142
|
+
per_page: String(PerPage),
|
|
143
|
+
start_date: query.since.toISOString(),
|
|
144
|
+
end_date: query.until.toISOString(),
|
|
145
|
+
order_by: "created_at",
|
|
146
|
+
order_by_direction: "desc",
|
|
147
|
+
// ⚠️ Only documented scalar parameters travel. The endpoint also takes a `filters` array
|
|
148
|
+
// whose query encoding Cloudflare documents nowhere — neither the reference nor the
|
|
149
|
+
// curl example shows it — so a guess at it would either be ignored (a slow read) or
|
|
150
|
+
// rejected (no read at all), and there is no way to tell those apart from the status.
|
|
151
|
+
// The agent is therefore picked out below, from the metadata the runtime stamped.
|
|
152
|
+
});
|
|
153
|
+
const parsed = LogResponse.safeParse(body);
|
|
154
|
+
// A shape this reader cannot make sense of is a failure, not an empty window: an empty
|
|
155
|
+
// window reads as "this agent cost nothing".
|
|
156
|
+
if (!parsed.success) {
|
|
157
|
+
throw new IntelError(502, "cloudflare_api_unreadable", "The Cloudflare API answered in a shape this version does not understand");
|
|
158
|
+
}
|
|
159
|
+
const entries = parsed.data.result ?? [];
|
|
160
|
+
for (const entry of entries) {
|
|
161
|
+
const metadata = readMetadata(entry.metadata);
|
|
162
|
+
if (metadata.agentId !== query.agentId)
|
|
163
|
+
continue;
|
|
164
|
+
calls.push({
|
|
165
|
+
runId: typeof metadata.runId === "string" ? metadata.runId : null,
|
|
166
|
+
model: entry.model ?? "",
|
|
167
|
+
cost: entry.cost ?? 0,
|
|
168
|
+
at: entry.created_at ?? query.until.toISOString(),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
if (entries.length < PerPage)
|
|
172
|
+
return { calls, partial: false };
|
|
173
|
+
partial = page === MaxPages;
|
|
174
|
+
}
|
|
175
|
+
return { calls, partial };
|
|
176
|
+
},
|
|
177
|
+
async workersAiModels() {
|
|
178
|
+
const body = await get(`/accounts/${encodeURIComponent(deps.accountId)}/ai/models/search`, {
|
|
179
|
+
per_page: "200",
|
|
180
|
+
hide_experimental: "true",
|
|
181
|
+
});
|
|
182
|
+
const parsed = ModelResponse.safeParse(body);
|
|
183
|
+
if (!parsed.success) {
|
|
184
|
+
throw new IntelError(502, "cloudflare_api_unreadable", "The Cloudflare API answered in a shape this version does not understand");
|
|
185
|
+
}
|
|
186
|
+
return (parsed.data.result ?? []).map(readModel);
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { AgentCostsDeps, AgentCostsService } from "./agent-costs.types.js";
|
|
2
|
+
/**
|
|
3
|
+
* What this agent has cost, read out of Cloudflare's AI Gateway log rather than computed here.
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ Nothing in this file multiplies tokens by a price, and nothing may start to. The gateway
|
|
6
|
+
* publishes the billed figure per call and it is the debit from the account balance, 1:1 — measured
|
|
7
|
+
* on 2026-08-07 (#251). A price table beside it would be a second, self-maintained answer to a
|
|
8
|
+
* question that already has a first one, and the day the two disagree the wrong one is the one
|
|
9
|
+
* on screen.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ The three checks below are in the same order as the runtime proxy's, and for the same reasons:
|
|
12
|
+
* the capability first so a refusal costs no storage read; the resource ACL second, because seeing
|
|
13
|
+
* an agent and seeing its bill are the same question; the reader last, so nobody learns whether this
|
|
14
|
+
* deployment holds a Cloudflare token before they are allowed to ask.
|
|
15
|
+
*/
|
|
16
|
+
export declare function createAgentCosts(deps: AgentCostsDeps): AgentCostsService;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { IntelError } from "../shared/intel-error/intel-error.js";
|
|
2
|
+
/**
|
|
3
|
+
* The windows the profile shows, longest last.
|
|
4
|
+
*
|
|
5
|
+
* Two, and both of them, because they answer different questions: thirty days is what an agent
|
|
6
|
+
* costs to keep, seven is whether that changed. One number alone cannot say "this got more
|
|
7
|
+
* expensive last week".
|
|
8
|
+
*/
|
|
9
|
+
const Windows = [7, 30];
|
|
10
|
+
const DayMs = 24 * 60 * 60 * 1_000;
|
|
11
|
+
/**
|
|
12
|
+
* What this agent has cost, read out of Cloudflare's AI Gateway log rather than computed here.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ Nothing in this file multiplies tokens by a price, and nothing may start to. The gateway
|
|
15
|
+
* publishes the billed figure per call and it is the debit from the account balance, 1:1 — measured
|
|
16
|
+
* on 2026-08-07 (#251). A price table beside it would be a second, self-maintained answer to a
|
|
17
|
+
* question that already has a first one, and the day the two disagree the wrong one is the one
|
|
18
|
+
* on screen.
|
|
19
|
+
*
|
|
20
|
+
* ⚠️ The three checks below are in the same order as the runtime proxy's, and for the same reasons:
|
|
21
|
+
* the capability first so a refusal costs no storage read; the resource ACL second, because seeing
|
|
22
|
+
* an agent and seeing its bill are the same question; the reader last, so nobody learns whether this
|
|
23
|
+
* deployment holds a Cloudflare token before they are allowed to ask.
|
|
24
|
+
*/
|
|
25
|
+
export function createAgentCosts(deps) {
|
|
26
|
+
return {
|
|
27
|
+
async read(actor, agentId) {
|
|
28
|
+
if (!actor.canRun) {
|
|
29
|
+
throw new IntelError(403, "permission_required", "Permission required");
|
|
30
|
+
}
|
|
31
|
+
const node = await deps.visibleNode(actor, agentId);
|
|
32
|
+
// Not found and not visible answer alike, as everywhere else in the tree.
|
|
33
|
+
if (node?.kind !== "agent") {
|
|
34
|
+
throw new IntelError(404, "agent_not_found", "Agent was not found");
|
|
35
|
+
}
|
|
36
|
+
if (!deps.gateway)
|
|
37
|
+
return empty("not_configured");
|
|
38
|
+
const until = deps.now();
|
|
39
|
+
const longest = Windows[Windows.length - 1] ?? 30;
|
|
40
|
+
const since = new Date(until.getTime() - longest * DayMs);
|
|
41
|
+
let page;
|
|
42
|
+
try {
|
|
43
|
+
page = await deps.gateway.gatewayCalls({ agentId, since, until });
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// ⚠️ Swallowed on purpose, and named rather than rethrown. The run list beside these figures
|
|
47
|
+
// is answered by the runtime and is perfectly readable; letting a Cloudflare outage take the
|
|
48
|
+
// whole screen down would hide the tokens too — and the tokens are the half that never
|
|
49
|
+
// depended on Cloudflare being reachable.
|
|
50
|
+
return empty("unreadable");
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
status: "read",
|
|
54
|
+
currency: "USD",
|
|
55
|
+
runs: perRun(page.calls),
|
|
56
|
+
windows: Windows.map((days) => window(days, page.calls, until)),
|
|
57
|
+
partial: page.partial,
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function empty(status) {
|
|
63
|
+
return {
|
|
64
|
+
status,
|
|
65
|
+
currency: "USD",
|
|
66
|
+
// ⚠️ Empty, and the status is what stops it reading as "free". Nothing here invents a zero:
|
|
67
|
+
// `runs: []` with `status: "read"` really does mean this agent has cost nothing yet.
|
|
68
|
+
runs: [],
|
|
69
|
+
windows: Windows.map((days) => ({ days, cost: 0, calls: 0, models: [] })),
|
|
70
|
+
partial: false,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function perRun(calls) {
|
|
74
|
+
const byRun = new Map();
|
|
75
|
+
for (const call of calls) {
|
|
76
|
+
// A call the runtime could not stamp — anything from before #251 — belongs to no run and is
|
|
77
|
+
// dropped from the per-run view. It still counts towards the windows below: it is the agent's
|
|
78
|
+
// money either way, and only its place in the list is unknown.
|
|
79
|
+
if (!call.runId)
|
|
80
|
+
continue;
|
|
81
|
+
const known = byRun.get(call.runId);
|
|
82
|
+
if (known) {
|
|
83
|
+
known.cost += call.cost;
|
|
84
|
+
known.calls += 1;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
byRun.set(call.runId, { runId: call.runId, cost: call.cost, calls: 1 });
|
|
88
|
+
}
|
|
89
|
+
return [...byRun.values()];
|
|
90
|
+
}
|
|
91
|
+
function window(days, calls, until) {
|
|
92
|
+
const from = until.getTime() - days * DayMs;
|
|
93
|
+
const inside = calls.filter((call) => {
|
|
94
|
+
const at = Date.parse(call.at);
|
|
95
|
+
// An unparseable timestamp counts towards the longest window rather than being dropped: the
|
|
96
|
+
// money was spent, and a total that quietly omits it is the wrong kind of wrong.
|
|
97
|
+
return Number.isNaN(at) ? days === Windows[Windows.length - 1] : at >= from;
|
|
98
|
+
});
|
|
99
|
+
return {
|
|
100
|
+
days,
|
|
101
|
+
cost: inside.reduce((total, call) => total + call.cost, 0),
|
|
102
|
+
calls: inside.length,
|
|
103
|
+
models: [...new Set(inside.map((call) => call.model).filter((model) => model.length > 0))],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { AgentCosts, Node } from "@anchrd/intel-contract";
|
|
2
|
+
import type { CloudflareAccountApi } from "../adapters/cloudflare-api/cloudflare-api.types.js";
|
|
3
|
+
import type { AgentActor } from "../agent-runtime/agent-runtime.types.js";
|
|
4
|
+
import type { Actor } from "../nodes/nodes.types.js";
|
|
5
|
+
export interface AgentCostsDeps {
|
|
6
|
+
/**
|
|
7
|
+
* Absent where the deployment configured no account id, gateway id or read token.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ Absent is a NAMED state and never an error: an installation that never wants Intel to hold a
|
|
10
|
+
* Cloudflare token is a supported installation, and it should read "not configured" rather than
|
|
11
|
+
* a 502 somebody spends an afternoon on.
|
|
12
|
+
*/
|
|
13
|
+
gateway?: Pick<CloudflareAccountApi, "gatewayCalls">;
|
|
14
|
+
/**
|
|
15
|
+
* The tree's own visibility lookup, unchanged on the way through — the same one the runtime proxy
|
|
16
|
+
* uses. What an agent spends is as sensitive as what it does, and neither answer may be kinder
|
|
17
|
+
* than the other.
|
|
18
|
+
*/
|
|
19
|
+
visibleNode(actor: Actor, nodeId: string): Promise<Node | null>;
|
|
20
|
+
now(): Date;
|
|
21
|
+
}
|
|
22
|
+
export interface AgentCostsService {
|
|
23
|
+
/**
|
|
24
|
+
* ⚠️ The answer shape lives in `@anchrd/intel-contract` and not here, because the browser parses
|
|
25
|
+
* it. A second declaration in this package is how the screen and the service start disagreeing
|
|
26
|
+
* about what `status` can say — and `status` is the field that separates "cost nothing" from "not
|
|
27
|
+
* known" (#251).
|
|
28
|
+
*/
|
|
29
|
+
read(actor: AgentActor, agentId: string): Promise<AgentCosts>;
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/build/build.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { UI_LANGUAGES } from "@anchrd/intel-contract";
|
|
1
2
|
import { z } from "zod";
|
|
2
3
|
const UiConfig = z.strictObject({
|
|
3
4
|
theme: z.string().min(1).optional(),
|
|
@@ -100,8 +101,12 @@ export function createBuild(deps) {
|
|
|
100
101
|
}
|
|
101
102
|
catalogs[language] = candidateParsed.data;
|
|
102
103
|
}
|
|
103
|
-
|
|
104
|
-
|
|
104
|
+
// A built-in language needs no entry in ui.languages — its catalog is already in the UI.
|
|
105
|
+
// Without this exception, `ui.defaultLanguage: "de"` would force the customer to list a copy
|
|
106
|
+
// of de.json that rots at every UI update, and nobody maintains that copy.
|
|
107
|
+
const builtIn = UI_LANGUAGES;
|
|
108
|
+
if (!builtIn.includes(config.defaultLanguage) && !(config.defaultLanguage in catalogs)) {
|
|
109
|
+
throw new Error(`ui.defaultLanguage ${JSON.stringify(config.defaultLanguage)} is neither built in (${UI_LANGUAGES.join(", ")}) nor listed in ui.languages`);
|
|
105
110
|
}
|
|
106
111
|
const theme = config.theme
|
|
107
112
|
? `/* Generated by \`intel build\` from ${asComment(config.theme)}. */\n${await readRequired(config.theme, "ui.theme")}\n`
|
package/dist/bundle/bundle.js
CHANGED
|
@@ -3,6 +3,7 @@ import { Unzip, UnzipInflate, Zip, ZipDeflate, ZipPassThrough } from "fflate";
|
|
|
3
3
|
import { documentLinkTargets } from "../nodes/document-links/document-links.js";
|
|
4
4
|
import { parseCsv } from "../shared/csv/csv.js";
|
|
5
5
|
import { IntelError } from "../shared/intel-error/intel-error.js";
|
|
6
|
+
import { plainTitle } from "../shared/plain-title/plain-title.js";
|
|
6
7
|
// What every bundle leaves out, by decision rather than by accident (#136). The manifest says so,
|
|
7
8
|
// because a backup that is silent about what it does not hold will be trusted with exactly that.
|
|
8
9
|
const Excluded = ["version-history", "grants", "flow-runs", "archived-nodes"];
|
|
@@ -866,7 +867,7 @@ export function createBundle(deps) {
|
|
|
866
867
|
flows.push({
|
|
867
868
|
id: entry.newId,
|
|
868
869
|
parentId,
|
|
869
|
-
title: entry.title,
|
|
870
|
+
title: plainTitle(entry.title),
|
|
870
871
|
description: entry.description,
|
|
871
872
|
ownerId: actor.id,
|
|
872
873
|
currentVersionId: versionId,
|
|
@@ -953,7 +954,9 @@ export function createBundle(deps) {
|
|
|
953
954
|
parentId,
|
|
954
955
|
// Flows took the `continue` above; what reaches here is one of the five node kinds.
|
|
955
956
|
kind: entry.kind,
|
|
956
|
-
title
|
|
957
|
+
// A manifest title and a file name are both somebody else's text, and an export written
|
|
958
|
+
// by an escaping chain carries the entity in both (#202).
|
|
959
|
+
title: plainTitle(entry.title),
|
|
957
960
|
description: entry.description,
|
|
958
961
|
ownerId: actor.id,
|
|
959
962
|
currentVersionId: version?.id ?? null,
|
package/dist/flows/flows.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { flowNodeLayer } from "@anchrd/intel-contract";
|
|
2
2
|
import { IntelError } from "../shared/intel-error/intel-error.js";
|
|
3
|
+
import { plainTitle } from "../shared/plain-title/plain-title.js";
|
|
3
4
|
function invalid(detail) {
|
|
4
5
|
throw new IntelError(400, "flow_graph_invalid", detail);
|
|
5
6
|
}
|
|
@@ -1071,7 +1072,7 @@ export function createFlows(deps) {
|
|
|
1071
1072
|
flow: {
|
|
1072
1073
|
id: deps.id(),
|
|
1073
1074
|
parentId: input.parentId,
|
|
1074
|
-
title: input.title,
|
|
1075
|
+
title: plainTitle(input.title),
|
|
1075
1076
|
description: input.description,
|
|
1076
1077
|
ownerId: actor.id,
|
|
1077
1078
|
currentVersionId: null,
|
|
@@ -1099,7 +1100,7 @@ export function createFlows(deps) {
|
|
|
1099
1100
|
flow: {
|
|
1100
1101
|
...current,
|
|
1101
1102
|
parentId: input.parentId === undefined ? current.parentId : input.parentId,
|
|
1102
|
-
title: input.title
|
|
1103
|
+
title: input.title === undefined ? current.title : plainTitle(input.title),
|
|
1103
1104
|
description: input.description === undefined ? current.description : input.description,
|
|
1104
1105
|
updatedAt: deps.now().toISOString(),
|
|
1105
1106
|
},
|
package/dist/http/http.js
CHANGED
|
@@ -144,6 +144,26 @@ export function createHttp(deps) {
|
|
|
144
144
|
app.get("/capabilities", (context) => {
|
|
145
145
|
return context.json({ agentRuntime: deps.agents.available() });
|
|
146
146
|
});
|
|
147
|
+
/**
|
|
148
|
+
* What the models on offer cost and how much they hold (#257).
|
|
149
|
+
*
|
|
150
|
+
* ⚠️ Intel asks Cloudflare, never the browser. The account endpoint needs a token and a token does
|
|
151
|
+
* not belong in a SPA — which is the sentence the hard-coded table in `packages/ui` used to carry
|
|
152
|
+
* as the reason it stayed hard-coded. This route is the answer to it: the figures cross the wire,
|
|
153
|
+
* the credential does not.
|
|
154
|
+
*
|
|
155
|
+
* ⚠️ No capability beyond being signed in. It is a fact about the installation and about nobody's
|
|
156
|
+
* data — the same footing `/capabilities` stands on.
|
|
157
|
+
*
|
|
158
|
+
* ⚠️ The `cache-control` is the caching the ticket asks for, and it is the caching that matters:
|
|
159
|
+
* the waste it names is "one fetch every time the select is opened", and that fetch is the
|
|
160
|
+
* browser's. A Worker isolate has nowhere durable to keep a table between requests, and inventing
|
|
161
|
+
* storage for a list of prices would cost more than the request it saved.
|
|
162
|
+
*/
|
|
163
|
+
app.get("/models", async (context) => {
|
|
164
|
+
context.header("cache-control", "private, max-age=3600");
|
|
165
|
+
return context.json(await deps.models.list());
|
|
166
|
+
});
|
|
147
167
|
/**
|
|
148
168
|
* The agent runtime, reached through Intel.
|
|
149
169
|
*
|
|
@@ -171,6 +191,23 @@ export function createHttp(deps) {
|
|
|
171
191
|
{ method: "POST", pattern: "/resume", path: () => "/resume" },
|
|
172
192
|
{ method: "POST", pattern: "/run", path: () => "/run" },
|
|
173
193
|
];
|
|
194
|
+
/**
|
|
195
|
+
* What this agent has cost — answered by Intel itself, not forwarded (#251).
|
|
196
|
+
*
|
|
197
|
+
* ⚠️ It stands under `/agents/:agentId` and is NOT in `runtimeRoutes`, which is the whole point of
|
|
198
|
+
* writing it out here. The runtime has no idea what anything costs: the figures come from
|
|
199
|
+
* Cloudflare's AI Gateway log, which needs an `AI Gateway: Read` token, and that token belongs in
|
|
200
|
+
* Intel rather than in the Worker that executes model-driven tool calls — the runtime's own
|
|
201
|
+
* `CLAUDE.md` keeps the number of credentials there deliberately small. The runtime's part is the
|
|
202
|
+
* stamp on the model call and nothing more.
|
|
203
|
+
*
|
|
204
|
+
* ⚠️ `agents/run`, matching the run list this stands beside. What an agent spends is as sensitive
|
|
205
|
+
* as what it did, and the two must not be reachable on different terms.
|
|
206
|
+
*/
|
|
207
|
+
app.get("/agents/:agentId/costs", async (context) => {
|
|
208
|
+
const authorization = context.get("authorization");
|
|
209
|
+
return context.json(await deps.agentCosts.read(asAgentActor(authorization), context.req.param("agentId")));
|
|
210
|
+
});
|
|
174
211
|
for (const route of runtimeRoutes) {
|
|
175
212
|
app.on(route.method, `/agents/:agentId${route.pattern}`, async (context) => {
|
|
176
213
|
const authorization = context.get("authorization");
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { GateClient } from "@anchrd/gate-sdk";
|
|
2
|
+
import type { AgentCostsService } from "../agent-costs/agent-costs.types.js";
|
|
2
3
|
import type { AgentRuntimeService } from "../agent-runtime/agent-runtime.types.js";
|
|
3
4
|
import type { BrowserAuth } from "../auth/auth.types.js";
|
|
4
5
|
import type { BundleService } from "../bundle/bundle.types.js";
|
|
5
6
|
import type { FlowService } from "../flows/flows.types.js";
|
|
7
|
+
import type { ModelCatalogService } from "../model-catalog/model-catalog.types.js";
|
|
6
8
|
import type { NodeService } from "../nodes/nodes.types.js";
|
|
7
9
|
import type { ToolService } from "../tools/tools.types.js";
|
|
8
10
|
export interface HttpDeps {
|
|
@@ -12,6 +14,10 @@ export interface HttpDeps {
|
|
|
12
14
|
tools: ToolService;
|
|
13
15
|
bundle: BundleService;
|
|
14
16
|
agents: AgentRuntimeService;
|
|
17
|
+
/** What an agent has cost, read out of the AI Gateway log rather than computed here (#251). */
|
|
18
|
+
agentCosts: AgentCostsService;
|
|
19
|
+
/** What the models on offer cost, read from Cloudflare rather than typed out here (#257). */
|
|
20
|
+
models: ModelCatalogService;
|
|
15
21
|
resource: string;
|
|
16
22
|
resourceMetadataUrl: string;
|
|
17
23
|
auth?: Pick<BrowserAuth, "resolve">;
|
package/dist/intel/intel.js
CHANGED
|
@@ -73,6 +73,8 @@ export function createIntel(deps) {
|
|
|
73
73
|
tools: deps.tools,
|
|
74
74
|
bundle: deps.bundle,
|
|
75
75
|
agents: deps.agents,
|
|
76
|
+
agentCosts: deps.agentCosts,
|
|
77
|
+
models: deps.models,
|
|
76
78
|
resource,
|
|
77
79
|
resourceMetadataUrl,
|
|
78
80
|
...(deps.auth ? { auth: deps.auth } : {}),
|
|
@@ -91,6 +93,7 @@ export function createIntel(deps) {
|
|
|
91
93
|
// returned a credential beside its answer would be one somebody logs.
|
|
92
94
|
bearer: bearer(context.req.raw.headers) ?? "",
|
|
93
95
|
agents: deps.agents,
|
|
96
|
+
agentCosts: deps.agentCosts,
|
|
94
97
|
flows: deps.flows,
|
|
95
98
|
nodes: deps.nodes,
|
|
96
99
|
tools: deps.tools,
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { GateClient } from "@anchrd/gate-sdk";
|
|
2
|
+
import type { AgentCostsService } from "../agent-costs/agent-costs.types.js";
|
|
2
3
|
import type { AgentRuntimeService } from "../agent-runtime/agent-runtime.types.js";
|
|
3
4
|
import type { BrowserAuth } from "../auth/auth.types.js";
|
|
4
5
|
import type { BundleService } from "../bundle/bundle.types.js";
|
|
5
6
|
import type { FlowService } from "../flows/flows.types.js";
|
|
7
|
+
import type { ModelCatalogService } from "../model-catalog/model-catalog.types.js";
|
|
6
8
|
import type { NodeService } from "../nodes/nodes.types.js";
|
|
7
9
|
import type { ToolService } from "../tools/tools.types.js";
|
|
8
10
|
export interface IntelDeps {
|
|
@@ -14,5 +16,7 @@ export interface IntelDeps {
|
|
|
14
16
|
tools: ToolService;
|
|
15
17
|
bundle: BundleService;
|
|
16
18
|
agents: AgentRuntimeService;
|
|
19
|
+
agentCosts: AgentCostsService;
|
|
20
|
+
models: ModelCatalogService;
|
|
17
21
|
auth?: BrowserAuth;
|
|
18
22
|
}
|
package/dist/mcp/mcp.js
CHANGED
|
@@ -912,6 +912,29 @@ export async function handleMcp(request, deps) {
|
|
|
912
912
|
path: "/resume",
|
|
913
913
|
body: "{}",
|
|
914
914
|
})));
|
|
915
|
+
/**
|
|
916
|
+
* ⚠️ The MCP half of the profile's cost figures, in the same delivery slice — the repository's
|
|
917
|
+
* "every user-visible capability has an Intel MCP equivalent" rule.
|
|
918
|
+
*
|
|
919
|
+
* ⚠️ Under `agents/run` beside pause and resume, not under `knowledge/read`. What an agent
|
|
920
|
+
* spends is as sensitive as what it did, and this answer stands beside the run list rather than
|
|
921
|
+
* beside the node.
|
|
922
|
+
*
|
|
923
|
+
* ⚠️ `status` travels with the numbers and is not flattened away. A client that read an empty
|
|
924
|
+
* `runs` list as "this agent is free" would be reporting a Cloudflare outage as a saving.
|
|
925
|
+
*/
|
|
926
|
+
server.registerTool("agent_costs", {
|
|
927
|
+
title: "Agent costs",
|
|
928
|
+
description: "What one agent has actually cost, read from Cloudflare's AI Gateway log: per run, and as a total over the last 7 and 30 days. `status` says whether the numbers were read at all — `not_configured` and `unreadable` both mean no figures, never zero cost. `partial` means the read hit its page limit, so the totals are a floor.",
|
|
929
|
+
inputSchema: PauseAgentInput,
|
|
930
|
+
annotations: {
|
|
931
|
+
title: "Agent costs",
|
|
932
|
+
readOnlyHint: true,
|
|
933
|
+
destructiveHint: false,
|
|
934
|
+
idempotentHint: true,
|
|
935
|
+
openWorldHint: false,
|
|
936
|
+
},
|
|
937
|
+
}, async (input) => text(await deps.agentCosts.read(agentActor, input.nodeId)));
|
|
915
938
|
server.registerTool("agent_run_now", {
|
|
916
939
|
title: "Run agent now",
|
|
917
940
|
description: "Fire one of the agent's own schedules straight away, against the document or flow it names. The run appears in the agent's run list; a target the agent does not schedule is refused.",
|
package/dist/mcp/mcp.types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Authorized } from "@anchrd/gate-sdk";
|
|
2
|
+
import type { AgentCostsService } from "../agent-costs/agent-costs.types.js";
|
|
2
3
|
import type { AgentRuntimeService } from "../agent-runtime/agent-runtime.types.js";
|
|
3
4
|
import type { BundleService } from "../bundle/bundle.types.js";
|
|
4
5
|
import type { FlowService } from "../flows/flows.types.js";
|
|
@@ -16,4 +17,6 @@ export interface McpDeps {
|
|
|
16
17
|
tools: ToolService;
|
|
17
18
|
bundle: BundleService;
|
|
18
19
|
agents: AgentRuntimeService;
|
|
20
|
+
/** What an agent has cost, out of the AI Gateway log — the MCP half of the profile's figures (#251). */
|
|
21
|
+
agentCosts: AgentCostsService;
|
|
19
22
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The figures this package knows without asking anybody, and the ONLY place they are written down.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ It moved here out of `packages/ui` with #257, and the move is the point rather than the
|
|
5
|
+
* tidying. The comment it used to carry accused itself correctly — *"it is a copy and it ages
|
|
6
|
+
* silently"* — and named the reason it stayed a copy: the Cloudflare endpoint needs a token, and a
|
|
7
|
+
* token does not belong in a SPA. It belongs here. What the browser gets is the answer, never the
|
|
8
|
+
* credential, and the Workers AI half of this table is overwritten by the live one on every read.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ The Anthropic entries have no live source and will not get one. Cloudflare resells those models
|
|
11
|
+
* through Unified Billing and publishes no price list for them, so they stay `builtin` even on a
|
|
12
|
+
* perfectly healthy read — which is exactly why `source` is per entry and not per response.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ Workers AI models without `function_calling` are deliberately absent from the live merge. An
|
|
15
|
+
* agent is a tool loop; a model that cannot call a tool cannot run one.
|
|
16
|
+
*
|
|
17
|
+
* Collected 2026-08-06 from `GET /accounts/{id}/ai/models/search` and from Anthropic's model list.
|
|
18
|
+
*/
|
|
19
|
+
const builtin = [
|
|
20
|
+
entry("anthropic", "claude-opus-5", "Opus 5", 1_000_000, 5, 25),
|
|
21
|
+
entry("anthropic", "claude-sonnet-5", "Sonnet 5", 1_000_000, 3, 15),
|
|
22
|
+
entry("anthropic", "claude-sonnet-4", "Sonnet 4", 200_000, 3, 15),
|
|
23
|
+
entry("anthropic", "claude-haiku-4-5", "Haiku 4.5", 200_000, 1, 5),
|
|
24
|
+
entry("workers-ai", "@cf/openai/gpt-oss-120b", "GPT-OSS 120B", 128_000, 0.35, 0.75),
|
|
25
|
+
entry("workers-ai", "@cf/openai/gpt-oss-20b", "GPT-OSS 20B", 128_000, 0.2, 0.3),
|
|
26
|
+
entry("workers-ai", "@cf/moonshotai/kimi-k2.6", "Kimi K2.6", 262_144, 0.95, 4),
|
|
27
|
+
entry("workers-ai", "@cf/moonshotai/kimi-k2.7-code", "Kimi K2.7 Code", 262_144, 0.95, 4),
|
|
28
|
+
entry("workers-ai", "@cf/zai-org/glm-5.2", "GLM 5.2", 262_144, 1.4, 4.4),
|
|
29
|
+
entry("workers-ai", "@cf/zai-org/glm-4.7-flash", "GLM 4.7 Flash", 131_072, 0.0605, 0.4),
|
|
30
|
+
entry("workers-ai", "@cf/google/gemma-4-26b-a4b-it", "Gemma 4 26B", 256_000, 0.1, 0.3),
|
|
31
|
+
entry("workers-ai", "@cf/nvidia/nemotron-3-120b-a12b", "Nemotron 3 120B", 256_000, 0.5, 1.5),
|
|
32
|
+
entry("workers-ai", "@cf/meta/llama-4-scout-17b-16e-instruct", "Llama 4 Scout", 131_000, 0.27, 0.85),
|
|
33
|
+
entry("workers-ai", "@cf/meta/llama-3.3-70b-instruct-fp8-fast", "Llama 3.3 70B Fast", 24_000, 0.293, 2.253),
|
|
34
|
+
entry("workers-ai", "@cf/mistralai/mistral-small-3.1-24b-instruct", "Small 3.1 24B", 128_000, 0.351, 0.555),
|
|
35
|
+
entry("workers-ai", "@cf/ibm-granite/granite-4.0-h-micro", "Granite 4.0 Micro", 131_000, 0.017, 0.112),
|
|
36
|
+
entry("workers-ai", "@cf/qwen/qwen3-30b-a3b-fp8", "Qwen3 30B", 32_768, 0.0509, 0.335),
|
|
37
|
+
];
|
|
38
|
+
function entry(provider, model, name, contextTokens, inputPerMillion, outputPerMillion) {
|
|
39
|
+
return {
|
|
40
|
+
provider,
|
|
41
|
+
model,
|
|
42
|
+
name,
|
|
43
|
+
contextTokens,
|
|
44
|
+
price: { inputPerMillion, outputPerMillion },
|
|
45
|
+
source: "builtin",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The model's own name, taken from its id rather than invented.
|
|
50
|
+
*
|
|
51
|
+
* A live Workers AI model that is not in the table above has no written-out name anywhere, and
|
|
52
|
+
* "@cf/qwen/qwen3-30b-a3b-fp8" prettified by any rule short enough to write here comes out wrong —
|
|
53
|
+
* so the last segment of the id is used as it stands. A raw name is easier to recognise as raw than
|
|
54
|
+
* a wrong one is to recognise as wrong.
|
|
55
|
+
*/
|
|
56
|
+
function nameFromId(model) {
|
|
57
|
+
const segments = model.split("/");
|
|
58
|
+
return segments[segments.length - 1] ?? model;
|
|
59
|
+
}
|
|
60
|
+
export function createModelCatalog(deps) {
|
|
61
|
+
return {
|
|
62
|
+
async list() {
|
|
63
|
+
if (!deps.cloudflare)
|
|
64
|
+
return { entries: builtin, liveStatus: "not_configured" };
|
|
65
|
+
try {
|
|
66
|
+
const live = await deps.cloudflare.workersAiModels();
|
|
67
|
+
const merged = new Map(builtin.map((known) => [`${known.provider}:${known.model}`, known]));
|
|
68
|
+
for (const model of live) {
|
|
69
|
+
// ⚠️ A model that cannot call a tool is skipped rather than listed without figures. It
|
|
70
|
+
// would be an offer that produces a broken agent for whoever took it.
|
|
71
|
+
if (!model.functionCalling)
|
|
72
|
+
continue;
|
|
73
|
+
const key = `workers-ai:${model.name}`;
|
|
74
|
+
const known = merged.get(key);
|
|
75
|
+
merged.set(key, {
|
|
76
|
+
provider: "workers-ai",
|
|
77
|
+
model: model.name,
|
|
78
|
+
name: known?.name ?? nameFromId(model.name),
|
|
79
|
+
// ⚠️ The live figure wins, and a live figure that is MISSING wins too — falling back to
|
|
80
|
+
// the table for one of the two halves would produce an entry that is half fresh and
|
|
81
|
+
// half years old, labelled fresh. Either the account answered for this model or it did
|
|
82
|
+
// not.
|
|
83
|
+
contextTokens: model.contextTokens,
|
|
84
|
+
price: model.price,
|
|
85
|
+
source: "cloudflare",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return { entries: [...merged.values()], liveStatus: "read" };
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// ⚠️ Named, not thrown. The select has to render either way — an agent whose model cannot be
|
|
92
|
+
// chosen is an agent nobody can repair from that screen — and the alternative to a stale
|
|
93
|
+
// price here is no price at all, which helps nobody choose. What the caller gets is the
|
|
94
|
+
// table plus the reason it is the table, and the screen says so.
|
|
95
|
+
return { entries: builtin, liveStatus: "unreadable" };
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ModelCatalog } from "@anchrd/intel-contract";
|
|
2
|
+
import type { CloudflareAccountApi } from "../adapters/cloudflare-api/cloudflare-api.types.js";
|
|
3
|
+
export interface ModelCatalogDeps {
|
|
4
|
+
/** Absent where the deployment configured no Cloudflare read token. A named state, not an error. */
|
|
5
|
+
cloudflare?: Pick<CloudflareAccountApi, "workersAiModels">;
|
|
6
|
+
}
|
|
7
|
+
export interface ModelCatalogService {
|
|
8
|
+
/**
|
|
9
|
+
* ⚠️ The answer shape lives in `@anchrd/intel-contract`, because the select in the browser parses
|
|
10
|
+
* it. Its `source` field is per ENTRY on purpose: Cloudflare publishes figures for the models it
|
|
11
|
+
* serves itself and none for the Anthropic models it resells, so a healthy read still leaves half
|
|
12
|
+
* the list on a written-out table (#257).
|
|
13
|
+
*/
|
|
14
|
+
list(): Promise<ModelCatalog>;
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/nodes/nodes.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { AgentDefinition, AgentMediaType, TableMediaType, } from "@anchrd/intel-contract";
|
|
1
|
+
import { AgentDefinition, AgentMediaType, agentReferenceAccepts, TableMediaType, } from "@anchrd/intel-contract";
|
|
2
2
|
import { encodeCsv, parseCsv } from "../shared/csv/csv.js";
|
|
3
3
|
import { IntelError } from "../shared/intel-error/intel-error.js";
|
|
4
|
+
import { plainTitle } from "../shared/plain-title/plain-title.js";
|
|
4
5
|
import { documentLinkTargets } from "./document-links/document-links.js";
|
|
5
6
|
// ⚠️ The R2 key of a version written before #125 begins `knowledge/`, and it stays that way. A key
|
|
6
7
|
// is stored in `node_versions.content_key` and read back from there; nothing derives one from ids,
|
|
@@ -327,6 +328,36 @@ export function createNodes(deps) {
|
|
|
327
328
|
throw new IntelError(500, "content_missing", "Version content is missing");
|
|
328
329
|
return { node, version, definition: parseStoredDefinition(body), applicationId };
|
|
329
330
|
}
|
|
331
|
+
/**
|
|
332
|
+
* Whether every reference names a node its role can actually use (#255).
|
|
333
|
+
*
|
|
334
|
+
* ⚠️ Here and not in the screen. The screen offers only what a role accepts, but a definition can
|
|
335
|
+
* be written straight over the Intel MCP surface — and a `memory` reference pointing at a
|
|
336
|
+
* document is not a cosmetic mistake: `agent_remember` writes into what it is given, so the agent
|
|
337
|
+
* would overwrite the document somebody handed it.
|
|
338
|
+
*
|
|
339
|
+
* ⚠️ A reference to a node this actor cannot see is left alone, deliberately. Refusing it would
|
|
340
|
+
* answer "there is a node with that id and it is the wrong kind" to somebody who may not know the
|
|
341
|
+
* node exists (#41), and accepting it changes nothing: the runtime reads with the AGENT's grants,
|
|
342
|
+
* and what it may not read it reports as an unavailable reference. What is checked is what the
|
|
343
|
+
* writer can see, which is exactly what they chose.
|
|
344
|
+
*
|
|
345
|
+
* ⚠️ Ids are deduplicated before the reads. The contract allows two hundred references and the
|
|
346
|
+
* same folder twice; one lookup per row would make a large definition pay for the repetition.
|
|
347
|
+
*/
|
|
348
|
+
async function checkReferences(actor, definition) {
|
|
349
|
+
const kinds = new Map();
|
|
350
|
+
for (const reference of definition.references) {
|
|
351
|
+
let kind = kinds.get(reference.nodeId);
|
|
352
|
+
if (kind === undefined) {
|
|
353
|
+
kind = (await deps.repository.getVisible(actor, reference.nodeId))?.kind ?? null;
|
|
354
|
+
kinds.set(reference.nodeId, kind);
|
|
355
|
+
}
|
|
356
|
+
if (kind !== null && !agentReferenceAccepts(reference.role, kind)) {
|
|
357
|
+
throw new IntelError(422, "agent_reference_kind_not_allowed", `A ${kind} cannot be used as ${reference.role}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
330
361
|
/**
|
|
331
362
|
* The one place a caller's asked-for definition becomes the definition Intel stores (D30).
|
|
332
363
|
*
|
|
@@ -635,7 +666,7 @@ export function createNodes(deps) {
|
|
|
635
666
|
id: deps.id(),
|
|
636
667
|
parentId: input.parentId,
|
|
637
668
|
kind: input.kind,
|
|
638
|
-
title: input.title,
|
|
669
|
+
title: plainTitle(input.title),
|
|
639
670
|
description: input.description,
|
|
640
671
|
ownerId: actor.id,
|
|
641
672
|
currentVersionId: null,
|
|
@@ -765,15 +796,20 @@ export function createNodes(deps) {
|
|
|
765
796
|
throw new IntelError(403, "node_forbidden", "Parent folder cannot be edited");
|
|
766
797
|
}
|
|
767
798
|
// Before Gate, for the same reason the runtime check is before Gate: a delegation the saver
|
|
768
|
-
// cannot back
|
|
799
|
+
// cannot back — or a reference no role can use (#255) — must not leave a machine principal
|
|
800
|
+
// behind for an agent that was never created.
|
|
801
|
+
await checkReferences(actor, input.definition);
|
|
769
802
|
const definition = await delegationOf(actor, input.definition);
|
|
770
803
|
const nodeId = deps.id();
|
|
804
|
+
// One title for both, and it is the unescaped one: Gate's name is minted before the node row
|
|
805
|
+
// and can never be brought back into line with it afterwards (#202).
|
|
806
|
+
const title = plainTitle(input.title);
|
|
771
807
|
// The Application's name is what a person reads in Gate's list, so it has to be enough to
|
|
772
808
|
// recognise the agent by. Title alone would leave two agents called "Research" indis-
|
|
773
809
|
// tinguishable; the node ID is what the runtime's key map is keyed by anyway.
|
|
774
810
|
const application = await deps.applications.create({
|
|
775
811
|
token: caller.token,
|
|
776
|
-
name: `Intel agent ${
|
|
812
|
+
name: `Intel agent ${title} (${nodeId})`,
|
|
777
813
|
});
|
|
778
814
|
// ⚠️ The handover happens BEFORE the node is written, for the same reason Gate is asked before
|
|
779
815
|
// it: at this moment nothing exists on Intel's side, so a runtime that does not take the key
|
|
@@ -799,7 +835,7 @@ export function createNodes(deps) {
|
|
|
799
835
|
id: nodeId,
|
|
800
836
|
parentId: input.parentId,
|
|
801
837
|
kind: "agent",
|
|
802
|
-
title
|
|
838
|
+
title,
|
|
803
839
|
description: input.description,
|
|
804
840
|
ownerId: actor.id,
|
|
805
841
|
currentVersionId: null,
|
|
@@ -921,6 +957,7 @@ export function createNodes(deps) {
|
|
|
921
957
|
if (node.currentVersionId !== input.baseVersionId) {
|
|
922
958
|
throw new IntelError(409, "version_conflict", "A newer version already exists");
|
|
923
959
|
}
|
|
960
|
+
await checkReferences(actor, input.definition);
|
|
924
961
|
const saved = await writeAgentVersion(actor, node, await delegationOf(actor, input.definition), input.baseVersionId, input.idempotencyKey);
|
|
925
962
|
await deps.agentSchedules.sync({ token: caller.token, agentId: node.id });
|
|
926
963
|
return saved;
|
|
@@ -1192,7 +1229,7 @@ export function createNodes(deps) {
|
|
|
1192
1229
|
node: {
|
|
1193
1230
|
...current,
|
|
1194
1231
|
parentId: input.parentId === undefined ? current.parentId : input.parentId,
|
|
1195
|
-
title: input.title
|
|
1232
|
+
title: input.title === undefined ? current.title : plainTitle(input.title),
|
|
1196
1233
|
description: input.description === undefined ? current.description : input.description,
|
|
1197
1234
|
updatedAt,
|
|
1198
1235
|
},
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⚠️ A title is plain text, and an HTML entity in one is never what somebody meant.
|
|
3
|
+
*
|
|
4
|
+
* Intel escapes nothing, anywhere — the entity arrives already in the value (#202). The chain that
|
|
5
|
+
* carries a title to a write can HTML-escape what it hands to a model: the GitHub MCP server, for
|
|
6
|
+
* one, returns every string with `&` as `&` and `"` as `"`, so a model that copies a name
|
|
7
|
+
* out of one tool result into the next call writes the entity, not the character. Refusing the
|
|
8
|
+
* write would be the louder answer and the wrong one — the model reads the same escaped value
|
|
9
|
+
* again on its retry and cannot get past it. So the write boundary accepts it and stores what was
|
|
10
|
+
* meant.
|
|
11
|
+
*
|
|
12
|
+
* Repeated until it stops changing rather than once, because that is the failure the ticket names:
|
|
13
|
+
* a title read back escaped and written again is escaped twice, and a single round would leave
|
|
14
|
+
* `&amp;` as `&` — still an entity, still spreading.
|
|
15
|
+
*/
|
|
16
|
+
export declare function plainTitle(title: string): string;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Exactly what an HTML escaper emits, and nothing else. `"` and `'` are Go's
|
|
2
|
+
// `html.EscapeString` spelling of the quotes — the fingerprint of the tool that produced the
|
|
3
|
+
// titles this exists for. A general entity table is deliberately not here: ` ` or `ä` in
|
|
4
|
+
// a title is a person's choice, an escaped `&` never is.
|
|
5
|
+
const Escapes = [
|
|
6
|
+
[/</g, "<"],
|
|
7
|
+
[/>/g, ">"],
|
|
8
|
+
[/"/g, '"'],
|
|
9
|
+
[/�*34;/g, '"'],
|
|
10
|
+
[/'/g, "'"],
|
|
11
|
+
[/�*39;/g, "'"],
|
|
12
|
+
// Last in the pass, so one round of unescaping is the exact inverse of one round of escaping:
|
|
13
|
+
// `&lt;` becomes `<` here and only the NEXT round turns it into `<`.
|
|
14
|
+
[/&/g, "&"],
|
|
15
|
+
];
|
|
16
|
+
// A title that survived one more escaping than any writer intended. Eight is far past anything a
|
|
17
|
+
// real chain produces and stops a crafted title from spinning here.
|
|
18
|
+
const MaxRounds = 8;
|
|
19
|
+
/**
|
|
20
|
+
* ⚠️ A title is plain text, and an HTML entity in one is never what somebody meant.
|
|
21
|
+
*
|
|
22
|
+
* Intel escapes nothing, anywhere — the entity arrives already in the value (#202). The chain that
|
|
23
|
+
* carries a title to a write can HTML-escape what it hands to a model: the GitHub MCP server, for
|
|
24
|
+
* one, returns every string with `&` as `&` and `"` as `"`, so a model that copies a name
|
|
25
|
+
* out of one tool result into the next call writes the entity, not the character. Refusing the
|
|
26
|
+
* write would be the louder answer and the wrong one — the model reads the same escaped value
|
|
27
|
+
* again on its retry and cannot get past it. So the write boundary accepts it and stores what was
|
|
28
|
+
* meant.
|
|
29
|
+
*
|
|
30
|
+
* Repeated until it stops changing rather than once, because that is the failure the ticket names:
|
|
31
|
+
* a title read back escaped and written again is escaped twice, and a single round would leave
|
|
32
|
+
* `&amp;` as `&` — still an entity, still spreading.
|
|
33
|
+
*/
|
|
34
|
+
export function plainTitle(title) {
|
|
35
|
+
let current = title;
|
|
36
|
+
for (let round = 0; round < MaxRounds; round += 1) {
|
|
37
|
+
let next = current;
|
|
38
|
+
for (const [pattern, character] of Escapes)
|
|
39
|
+
next = next.replace(pattern, character);
|
|
40
|
+
if (next === current)
|
|
41
|
+
break;
|
|
42
|
+
current = next;
|
|
43
|
+
}
|
|
44
|
+
return current;
|
|
45
|
+
}
|
|
@@ -15,9 +15,10 @@ export const ServerDirectoryTool = "portal_list_servers";
|
|
|
15
15
|
*
|
|
16
16
|
* ⚠️ Only the shape is assumed, never the vocabulary. The portal is somebody else's product and its
|
|
17
17
|
* payload is not part of any contract Intel owns, so every field is optional and the answer is
|
|
18
|
-
* accepted from `structuredContent`, from `{ servers: [...] }
|
|
19
|
-
*
|
|
20
|
-
* tool list
|
|
18
|
+
* accepted from `structuredContent`, from `{ servers: [...] }`, from a JSON text block, or — the
|
|
19
|
+
* form the live portal actually uses — from the prose block `proseRows` reads. What makes a row
|
|
20
|
+
* usable is not that it parsed but that its identifier is confirmed against the live tool list
|
|
21
|
+
* below.
|
|
21
22
|
*/
|
|
22
23
|
const DirectoryRow = z.looseObject({
|
|
23
24
|
id: z.string().optional(),
|
|
@@ -26,10 +27,71 @@ const DirectoryRow = z.looseObject({
|
|
|
26
27
|
});
|
|
27
28
|
const DirectoryRows = z.array(DirectoryRow);
|
|
28
29
|
const DirectoryEnvelope = z.looseObject({ servers: DirectoryRows });
|
|
30
|
+
/** A line the portal meant as a directory entry: `- <display name> (<handle>): <state>`. */
|
|
31
|
+
const ProseRow = /^\s*[-*•]\s+(.+?)\s*\(([^()\s]+)\)\s*:\s*(.*)$/;
|
|
32
|
+
/** What claims to be an entry at all. Everything else in the block is a heading or a hint. */
|
|
33
|
+
const ProseBullet = /^\s*[-*•]\s/;
|
|
34
|
+
/**
|
|
35
|
+
* ⚠️ Never the ✓. A tick is decoration: a different locale, a plain-text client or a later version
|
|
36
|
+
* of the portal writes the same fact another way, and a reading that hangs on one code point would
|
|
37
|
+
* silently switch every server off. What is read is the word, and the word alone.
|
|
38
|
+
*
|
|
39
|
+
* It has to be the WHOLE state, not a word inside it, because "disabled" contains "enabled" and
|
|
40
|
+
* "not enabled" is built from it — a substring test would read both as switched on, and that is the
|
|
41
|
+
* one direction this must never get wrong. Anything else the portal might write ("aktiviert",
|
|
42
|
+
* "enabled (3 tools)") is therefore not offerable, and stays declared.
|
|
43
|
+
*/
|
|
44
|
+
function readsAsEnabled(state) {
|
|
45
|
+
const words = state.toLowerCase().match(/\p{L}+/gu);
|
|
46
|
+
return words !== null && words.length === 1 && words[0] === "enabled";
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The portal's prose form: a heading, one bullet per server, a closing hint. It is what the live
|
|
50
|
+
* portal actually answers (#233), and it is read only after every JSON reading in `rows` has failed.
|
|
51
|
+
*
|
|
52
|
+
* ⚠️ This reading hangs on somebody else's wording and can break without warning. The portal owes
|
|
53
|
+
* Intel no format at all, so a reworded state, a translated page or a changed layout is a normal
|
|
54
|
+
* event here rather than a defect. Two things keep that from becoming a quiet widening:
|
|
55
|
+
*
|
|
56
|
+
* - **A bullet that does not read as a row refuses the whole answer.** Skipping it would hand back
|
|
57
|
+
* a directory with one server missing, and a missing row is not a smaller list — it lets that
|
|
58
|
+
* server's tools fall back onto the shorter handle that encloses them, which is exactly the
|
|
59
|
+
* widening the split between `declared` and `servers` exists to prevent. The price is that a
|
|
60
|
+
* portal which one day bullets its closing hint fails loudly with the same closed
|
|
61
|
+
* `tool_servers_unavailable` that #233 found; that is the intended direction.
|
|
62
|
+
* - **Nothing read here can invent a server.** A handle no live tool confirms is never offered, so
|
|
63
|
+
* a misread line costs an offer, never a delegation.
|
|
64
|
+
*/
|
|
65
|
+
function proseRows(answer) {
|
|
66
|
+
const parsed = [];
|
|
67
|
+
for (const block of answer.content) {
|
|
68
|
+
if (typeof block !== "object" || block === null || !("text" in block))
|
|
69
|
+
continue;
|
|
70
|
+
const text = block.text;
|
|
71
|
+
if (typeof text !== "string")
|
|
72
|
+
continue;
|
|
73
|
+
for (const line of text.split("\n")) {
|
|
74
|
+
if (!ProseBullet.test(line))
|
|
75
|
+
continue;
|
|
76
|
+
const [, name, handle, state] = ProseRow.exec(line) ?? [];
|
|
77
|
+
if (name === undefined || handle === undefined)
|
|
78
|
+
return null;
|
|
79
|
+
// `id` carries the handle and `name` the display name, but neither is believed on its own:
|
|
80
|
+
// both stay candidates in `toolServersFrom`, and the live tool list decides which one
|
|
81
|
+
// actually namespaces.
|
|
82
|
+
parsed.push({ id: handle, name, enabled: readsAsEnabled(state ?? "") });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return parsed.length > 0 ? parsed : null;
|
|
86
|
+
}
|
|
29
87
|
/**
|
|
30
88
|
* Every place the portal could reasonably have put its list, tried in order. An MCP tool result is
|
|
31
89
|
* either structured or text, and a server that answers with `{ servers: [...] }` is as likely as
|
|
32
90
|
* one that answers with a bare array.
|
|
91
|
+
*
|
|
92
|
+
* ⚠️ The prose reading comes last and is ADDITIONAL, never replacing. The portal is somebody else's
|
|
93
|
+
* product: it answers in prose today and may answer structured tomorrow, and a structured answer
|
|
94
|
+
* must keep winning on the day it arrives beside a human sentence.
|
|
33
95
|
*/
|
|
34
96
|
function rows(answer) {
|
|
35
97
|
const candidates = [answer.structuredContent];
|
|
@@ -42,8 +104,9 @@ function rows(answer) {
|
|
|
42
104
|
candidates.push(JSON.parse(text));
|
|
43
105
|
}
|
|
44
106
|
catch {
|
|
45
|
-
// A text block that is not JSON
|
|
46
|
-
//
|
|
107
|
+
// A text block that is not JSON may still be the directory — see `proseRows` above. It is
|
|
108
|
+
// not fed to the JSON readings here, and it does not make them fail either: the portal may
|
|
109
|
+
// well add a human sentence beside the payload.
|
|
47
110
|
}
|
|
48
111
|
}
|
|
49
112
|
}
|
|
@@ -55,7 +118,7 @@ function rows(answer) {
|
|
|
55
118
|
if (wrapped.success)
|
|
56
119
|
return wrapped.data.servers;
|
|
57
120
|
}
|
|
58
|
-
return
|
|
121
|
+
return proseRows(answer);
|
|
59
122
|
}
|
|
60
123
|
/**
|
|
61
124
|
* ⚠️ The rule itself — longest match against declared handles, never a cut at an underscore — is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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.9.0",
|
|
47
47
|
"@cfworker/json-schema": "^4.1.1",
|
|
48
48
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
49
49
|
"fflate": "^0.8.3",
|