@foldrun/core 5.0.0 → 6.0.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/src/evals.js +11 -2
- package/dist/src/model-env.d.ts +12 -3
- package/dist/src/model-env.js +44 -6
- package/dist/src/platform.d.ts +24 -0
- package/dist/src/platform.js +35 -1
- package/dist/src/runner.d.ts +2 -0
- package/dist/src/translator.d.ts +8 -2
- package/dist/src/translator.js +73 -0
- package/dist/src/webhook.js +46 -6
- package/package.json +1 -1
- package/src/evals.ts +9 -2
- package/src/model-env.ts +42 -7
- package/src/platform.ts +45 -1
- package/src/translator.ts +83 -2
- package/src/webhook.ts +44 -5
package/dist/src/evals.js
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
// Deterministic checks run first and cost nothing. The judge only runs if
|
|
31
31
|
// they pass — no point paying a model to grade output already known to be
|
|
32
32
|
// wrong.
|
|
33
|
-
import { accountModelEnv, withModelEnv } from "./model-env.js";
|
|
33
|
+
import { accountModelEnv, withModelEnv, ModelKeyError } from "./model-env.js";
|
|
34
34
|
import { sdkEnv } from "./step-exec.js";
|
|
35
35
|
import { platformModelEnv } from "./runner.js";
|
|
36
36
|
import fs from "node:fs";
|
|
@@ -582,7 +582,16 @@ export async function checkAssertion(assertion, output, agentDir, model, effort,
|
|
|
582
582
|
catch (err) {
|
|
583
583
|
return { assertion, passed: false, detail: `judge not run — ${err instanceof Error ? err.message : String(err)}` };
|
|
584
584
|
}
|
|
585
|
-
|
|
585
|
+
let said;
|
|
586
|
+
try {
|
|
587
|
+
said = await withModelEnv(m, (env) => judgeCall(assertion.value, output, model, effort, env));
|
|
588
|
+
}
|
|
589
|
+
catch (err) {
|
|
590
|
+
// The provider's URL refused on the host (FOL-95), like a missing key above.
|
|
591
|
+
if (!(err instanceof ModelKeyError))
|
|
592
|
+
throw err;
|
|
593
|
+
return { assertion, passed: false, detail: `judge not run — ${err.message}` };
|
|
594
|
+
}
|
|
586
595
|
const { passed, detail } = judgeVerdict(said.text);
|
|
587
596
|
return { assertion, passed, detail, costUsd: said.costUsd };
|
|
588
597
|
}
|
package/dist/src/model-env.d.ts
CHANGED
|
@@ -19,11 +19,20 @@ export declare function accountModelEnv(tenant: string, workspace: string, platf
|
|
|
19
19
|
translator: TranslatorSpec | null;
|
|
20
20
|
};
|
|
21
21
|
/**
|
|
22
|
-
* Run `fn` with the model env, through a translator on this host
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* Run `fn` with the model env, through a translator on this host — the one a
|
|
23
|
+
* step uses (runner.ts) when the provider speaks Chat Completions or
|
|
24
|
+
* Responses, and a passthrough when it speaks Anthropic's Messages (FOL-95):
|
|
25
|
+
* either way the SDK talks only to loopback, and the tenant's endpoint is
|
|
26
|
+
* reached through fetchUntrusted, after the platform's check of its URL.
|
|
27
|
+
* The platform's own credential (`supply: "platform"`) goes as it always did.
|
|
28
|
+
* Started for this call and closed after it.
|
|
25
29
|
*/
|
|
26
30
|
export declare function withModelEnv<T>(m: {
|
|
27
31
|
env: Record<string, string | undefined>;
|
|
28
32
|
translator?: TranslatorSpec | null;
|
|
33
|
+
supply?: "provider" | "platform";
|
|
29
34
|
}, fn: (env: Record<string, string | undefined>) => Promise<T>): Promise<T>;
|
|
35
|
+
/** The passthrough for an Anthropic-format provider, read back from the env
|
|
36
|
+
* providerEnvFor (store.ts) built for it: the base URL, the key in the slot
|
|
37
|
+
* that names its header, and ANTHROPIC_CUSTOM_HEADERS. Null with no base. */
|
|
38
|
+
export declare function anthropicPassthrough(env: Record<string, string | undefined>): TranslatorSpec | null;
|
package/dist/src/model-env.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { accountDir, workspaceDir, parseProvider, providerEnvFor } from "./store.js";
|
|
9
9
|
import { readAgentsMd } from "./runner.js";
|
|
10
10
|
import { getSecret } from "./secrets.js";
|
|
11
|
-
import { platform } from "./platform.js";
|
|
11
|
+
import { platform, hostUrlProblem } from "./platform.js";
|
|
12
12
|
import { accountModelBlock, isClaudeLoginToken } from "./model-credential.js";
|
|
13
13
|
import { providerPreset } from "./providers.js";
|
|
14
14
|
import { startTranslator, translatorSpecFor } from "./translator.js";
|
|
@@ -39,6 +39,13 @@ export function accountModelEnv(tenant, workspace, platformEnv) {
|
|
|
39
39
|
throw new ModelKeyError(NO_MODEL_KEY);
|
|
40
40
|
return { env: platformEnv(), supply: "platform", format: "anthropic", translator: null };
|
|
41
41
|
}
|
|
42
|
+
// A host-side call to a URL the tenant wrote: on a platform, https only
|
|
43
|
+
// (platform.ts hostUrlProblem); withModelEnv asks the platform's own check
|
|
44
|
+
// too, and the guard sees every byte (FOL-95). A step resolves its
|
|
45
|
+
// provider elsewhere (runner.ts) and is not touched by this.
|
|
46
|
+
const refused = hostUrlProblem(spec.baseUrl);
|
|
47
|
+
if (refused)
|
|
48
|
+
throw new ModelKeyError(`provider base_url refused: ${refused}`, 422);
|
|
42
49
|
const sub = (text) => text.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => getSecret(tenant, name, workspace)?.value ?? whole);
|
|
43
50
|
const token = sub(spec.token);
|
|
44
51
|
if (token.includes("${"))
|
|
@@ -65,14 +72,22 @@ export function accountModelEnv(tenant, workspace, platformEnv) {
|
|
|
65
72
|
};
|
|
66
73
|
}
|
|
67
74
|
/**
|
|
68
|
-
* Run `fn` with the model env, through a translator on this host
|
|
69
|
-
*
|
|
70
|
-
*
|
|
75
|
+
* Run `fn` with the model env, through a translator on this host — the one a
|
|
76
|
+
* step uses (runner.ts) when the provider speaks Chat Completions or
|
|
77
|
+
* Responses, and a passthrough when it speaks Anthropic's Messages (FOL-95):
|
|
78
|
+
* either way the SDK talks only to loopback, and the tenant's endpoint is
|
|
79
|
+
* reached through fetchUntrusted, after the platform's check of its URL.
|
|
80
|
+
* The platform's own credential (`supply: "platform"`) goes as it always did.
|
|
81
|
+
* Started for this call and closed after it.
|
|
71
82
|
*/
|
|
72
83
|
export async function withModelEnv(m, fn) {
|
|
73
|
-
|
|
84
|
+
const spec = m.translator ?? (m.supply === "provider" ? anthropicPassthrough(m.env) : null);
|
|
85
|
+
if (!spec)
|
|
74
86
|
return fn(m.env);
|
|
75
|
-
const
|
|
87
|
+
const why = await platform.checkUntrustedUrl(spec.upstreamBase);
|
|
88
|
+
if (why)
|
|
89
|
+
throw new ModelKeyError(`provider base_url refused: ${why}`, 422);
|
|
90
|
+
const t = await startTranslator(spec);
|
|
76
91
|
try {
|
|
77
92
|
return await fn({ ...m.env, ...t.env });
|
|
78
93
|
}
|
|
@@ -80,3 +95,26 @@ export async function withModelEnv(m, fn) {
|
|
|
80
95
|
await t.close();
|
|
81
96
|
}
|
|
82
97
|
}
|
|
98
|
+
/** The passthrough for an Anthropic-format provider, read back from the env
|
|
99
|
+
* providerEnvFor (store.ts) built for it: the base URL, the key in the slot
|
|
100
|
+
* that names its header, and ANTHROPIC_CUSTOM_HEADERS. Null with no base. */
|
|
101
|
+
export function anthropicPassthrough(env) {
|
|
102
|
+
const base = env.ANTHROPIC_BASE_URL;
|
|
103
|
+
if (!base)
|
|
104
|
+
return null;
|
|
105
|
+
const apiKey = env.ANTHROPIC_API_KEY ?? "";
|
|
106
|
+
const headers = {};
|
|
107
|
+
for (const line of (env.ANTHROPIC_CUSTOM_HEADERS ?? "").split("\n")) {
|
|
108
|
+
const at = line.indexOf(":");
|
|
109
|
+
if (at > 0)
|
|
110
|
+
headers[line.slice(0, at).trim()] = line.slice(at + 1).trim();
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
wire: "anthropic",
|
|
114
|
+
upstreamBase: base,
|
|
115
|
+
upstreamKey: apiKey || (env.ANTHROPIC_AUTH_TOKEN ?? ""),
|
|
116
|
+
upstreamAuth: apiKey ? "x-api-key" : "bearer",
|
|
117
|
+
headers,
|
|
118
|
+
label: base,
|
|
119
|
+
};
|
|
120
|
+
}
|
package/dist/src/platform.d.ts
CHANGED
|
@@ -175,6 +175,16 @@ export interface PlatformHooks {
|
|
|
175
175
|
fetchUntrusted(url: string, init?: RequestInit, opts?: {
|
|
176
176
|
maxBytes?: number;
|
|
177
177
|
}): Promise<Response>;
|
|
178
|
+
/**
|
|
179
|
+
* May the host call this URL a tenant chose — a provider's base_url,
|
|
180
|
+
* before an eval's judge or a dashboard draft talks to it? A reason it may
|
|
181
|
+
* not, or null. This is the early refusal people can read; the guarantee
|
|
182
|
+
* is that every byte of such a call goes through fetchUntrusted (FOL-95).
|
|
183
|
+
* Default: hostUrlProblem below — https on an install that guards
|
|
184
|
+
* untrusted fetches, anything on a laptop (its own localhost included).
|
|
185
|
+
* A platform may bind a stricter check (the addresses a name resolves to).
|
|
186
|
+
*/
|
|
187
|
+
checkUntrustedUrl(url: string): Promise<string | null>;
|
|
178
188
|
/**
|
|
179
189
|
* Hand a notify: webhook to whatever delivers it. The platform records
|
|
180
190
|
* the delivery, makes the first attempt now and retries on a backoff from
|
|
@@ -207,6 +217,20 @@ export interface WebhookDeliveryOutcome {
|
|
|
207
217
|
retrying?: boolean;
|
|
208
218
|
}
|
|
209
219
|
export declare const platform: PlatformHooks;
|
|
220
|
+
/** True when this process fetches tenant-chosen URLs through a guard: a
|
|
221
|
+
* platform bound fetchUntrusted. A laptop has not, and calls its own LAN on
|
|
222
|
+
* purpose. */
|
|
223
|
+
export declare function guardsUntrustedUrls(): boolean;
|
|
224
|
+
/**
|
|
225
|
+
* Why the host may not call a tenant's model endpoint at this URL, or null.
|
|
226
|
+
* On an install that guards untrusted fetches the endpoint must be https: the
|
|
227
|
+
* key rides in every request, and a plain-http base is also how a tenant
|
|
228
|
+
* names a cluster service. Private and internal addresses are refused by the
|
|
229
|
+
* guard itself, on every hop. FOLDRUN_EGRESS_ALLOW_PRIVATE=1 — the operator's
|
|
230
|
+
* own install calling its own network — lifts this as it lifts the guard. A
|
|
231
|
+
* laptop: always null.
|
|
232
|
+
*/
|
|
233
|
+
export declare function hostUrlProblem(url: string): string | null;
|
|
210
234
|
/** Install the platform's implementations. Partial: what is not given keeps its local default. */
|
|
211
235
|
export declare function registerPlatform(hooks: Partial<PlatformHooks>): void;
|
|
212
236
|
/**
|
package/dist/src/platform.js
CHANGED
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
// and calls registerPlatform() once at boot. A process that forgets to is a
|
|
13
13
|
// local install — which is exactly the failure that is safe.
|
|
14
14
|
import { matchFilter } from "./trigger-filter.js";
|
|
15
|
+
/** Marks the laptop's fetchUntrusted. A symbol from the global registry, not
|
|
16
|
+
* the function itself: every bundle has its own copy of this module (see
|
|
17
|
+
* KEY below), and only the shared object says which one was registered. */
|
|
18
|
+
const LAPTOP_FETCH = Symbol.for("foldrun.laptop-fetch");
|
|
15
19
|
const local = {
|
|
16
20
|
edition: () => "self-hosted",
|
|
17
21
|
async enqueueFlowRun(tenant, workspace, steps, flowName, modelOverride, tags = [], startedBy = null, opts = {}) {
|
|
@@ -40,7 +44,8 @@ const local = {
|
|
|
40
44
|
platformKeyAllowed: () => true,
|
|
41
45
|
galleryDir: () => null,
|
|
42
46
|
noteOAuthRefresh: async () => undefined,
|
|
43
|
-
fetchUntrusted: (url, init) => fetch(url, init),
|
|
47
|
+
fetchUntrusted: Object.assign((url, init) => fetch(url, init), { [LAPTOP_FETCH]: true }),
|
|
48
|
+
checkUntrustedUrl: async (url) => hostUrlProblem(url),
|
|
44
49
|
async deliverWebhook(tenant, workspace, delivery) {
|
|
45
50
|
const { attemptWebhook, newDeliveryId } = await import("./notify.js");
|
|
46
51
|
const deliveryId = newDeliveryId();
|
|
@@ -60,6 +65,35 @@ const local = {
|
|
|
60
65
|
const KEY = Symbol.for("foldrun.platform");
|
|
61
66
|
const g = globalThis;
|
|
62
67
|
export const platform = (g[KEY] ??= { ...local });
|
|
68
|
+
/** True when this process fetches tenant-chosen URLs through a guard: a
|
|
69
|
+
* platform bound fetchUntrusted. A laptop has not, and calls its own LAN on
|
|
70
|
+
* purpose. */
|
|
71
|
+
export function guardsUntrustedUrls() {
|
|
72
|
+
return !platform.fetchUntrusted[LAPTOP_FETCH];
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Why the host may not call a tenant's model endpoint at this URL, or null.
|
|
76
|
+
* On an install that guards untrusted fetches the endpoint must be https: the
|
|
77
|
+
* key rides in every request, and a plain-http base is also how a tenant
|
|
78
|
+
* names a cluster service. Private and internal addresses are refused by the
|
|
79
|
+
* guard itself, on every hop. FOLDRUN_EGRESS_ALLOW_PRIVATE=1 — the operator's
|
|
80
|
+
* own install calling its own network — lifts this as it lifts the guard. A
|
|
81
|
+
* laptop: always null.
|
|
82
|
+
*/
|
|
83
|
+
export function hostUrlProblem(url) {
|
|
84
|
+
if (!guardsUntrustedUrls() || process.env.FOLDRUN_EGRESS_ALLOW_PRIVATE === "1")
|
|
85
|
+
return null;
|
|
86
|
+
let u;
|
|
87
|
+
try {
|
|
88
|
+
u = new URL(url);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return `${url} is not a URL`;
|
|
92
|
+
}
|
|
93
|
+
if (u.protocol !== "https:")
|
|
94
|
+
return `${u.origin} is not https — this platform calls a model endpoint only over https`;
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
63
97
|
/** Install the platform's implementations. Partial: what is not given keeps its local default. */
|
|
64
98
|
export function registerPlatform(hooks) {
|
|
65
99
|
Object.assign(platform, hooks, {
|
package/dist/src/runner.d.ts
CHANGED
|
@@ -387,6 +387,7 @@ grantsOnly?: boolean): {
|
|
|
387
387
|
upstreamBase: string;
|
|
388
388
|
wire?: import("./translator.ts").TranslatorWire;
|
|
389
389
|
upstreamKey: string;
|
|
390
|
+
upstreamAuth?: "bearer" | "x-api-key";
|
|
390
391
|
headers?: Record<string, string>;
|
|
391
392
|
maxTokensParam?: "max_tokens" | "max_completion_tokens";
|
|
392
393
|
reasoningEffort?: boolean;
|
|
@@ -400,6 +401,7 @@ grantsOnly?: boolean): {
|
|
|
400
401
|
upstreamBase: string;
|
|
401
402
|
wire?: import("./translator.ts").TranslatorWire;
|
|
402
403
|
upstreamKey: string;
|
|
404
|
+
upstreamAuth?: "bearer" | "x-api-key";
|
|
403
405
|
headers?: Record<string, string>;
|
|
404
406
|
maxTokensParam?: "max_tokens" | "max_completion_tokens";
|
|
405
407
|
reasoningEffort?: boolean;
|
package/dist/src/translator.d.ts
CHANGED
|
@@ -11,15 +11,21 @@ export declare function anthropicUsage(u: Json | undefined, wire: TranslatorWire
|
|
|
11
11
|
cached: number;
|
|
12
12
|
output: number;
|
|
13
13
|
};
|
|
14
|
-
|
|
14
|
+
/** `anthropic` is no translation at all: the host-side passthrough for an
|
|
15
|
+
* endpoint that already speaks Messages, so the SDK on the host talks only
|
|
16
|
+
* to loopback and every byte to the tenant's URL goes through the guard
|
|
17
|
+
* (FOL-95). A step never gets it (translatorSpecFor returns null there). */
|
|
18
|
+
export type TranslatorWire = "chat" | "responses" | "anthropic";
|
|
15
19
|
export interface TranslatorSpec {
|
|
16
20
|
/** The provider's base, e.g. https://api.openai.com/v1 — `/chat/completions`
|
|
17
21
|
* or `/responses` is appended, by `wire`. */
|
|
18
22
|
upstreamBase: string;
|
|
19
23
|
/** Which OpenAI shape the endpoint speaks. Default chat. */
|
|
20
24
|
wire?: TranslatorWire;
|
|
21
|
-
/** Already resolved. Sent as `Authorization: Bearer
|
|
25
|
+
/** Already resolved. Sent as `Authorization: Bearer`, or as `x-api-key`
|
|
26
|
+
* where `upstreamAuth` says so (the anthropic wire only). */
|
|
22
27
|
upstreamKey: string;
|
|
28
|
+
upstreamAuth?: "bearer" | "x-api-key";
|
|
23
29
|
/** Extra headers the provider wants, already resolved. */
|
|
24
30
|
headers?: Record<string, string>;
|
|
25
31
|
maxTokensParam?: "max_tokens" | "max_completion_tokens";
|
package/dist/src/translator.js
CHANGED
|
@@ -893,6 +893,8 @@ export async function startTranslator(spec) {
|
|
|
893
893
|
req.on("end", () => resolve(body));
|
|
894
894
|
req.on("error", reject);
|
|
895
895
|
});
|
|
896
|
+
if (spec.wire === "anthropic")
|
|
897
|
+
return await passthrough(spec, base, label, req, url, raw, res, log, started);
|
|
896
898
|
if (req.method === "POST" && url.pathname === "/v1/messages/count_tokens") {
|
|
897
899
|
// No endpoint on the other side counts for us; four characters a
|
|
898
900
|
// token is the estimate every tokenizer lands near for English.
|
|
@@ -1058,6 +1060,77 @@ export async function startTranslator(spec) {
|
|
|
1058
1060
|
}),
|
|
1059
1061
|
};
|
|
1060
1062
|
}
|
|
1063
|
+
/** Request headers the SDK's own call carries that the endpoint is owed:
|
|
1064
|
+
* the content type, and Anthropic's version and beta flags. Its credential
|
|
1065
|
+
* (the translator's key) is not one of them. */
|
|
1066
|
+
const PASSED_HEADERS = /^(content-type|accept|anthropic-[a-z0-9-]+)$/;
|
|
1067
|
+
/** Response headers that describe the bytes as they came off the wire —
|
|
1068
|
+
* fetch has already decoded them, so they would lie on the way back. */
|
|
1069
|
+
const HOP_HEADERS = new Set(["content-encoding", "content-length", "transfer-encoding", "connection", "keep-alive"]);
|
|
1070
|
+
/**
|
|
1071
|
+
* The anthropic wire: the SDK's request, as it is, to the same path under the
|
|
1072
|
+
* tenant's base URL, with the tenant's key put on here — through
|
|
1073
|
+
* fetchUntrusted, so no private or cluster address, no redirect that takes
|
|
1074
|
+
* the key to another origin, and the answer read to UPSTREAM_MAX_BYTES. The
|
|
1075
|
+
* answer streams back as it arrives, status and headers included.
|
|
1076
|
+
*/
|
|
1077
|
+
async function passthrough(spec, base, label, req, url, raw, res, log, started) {
|
|
1078
|
+
// Lowercase keys, so a header named twice in different case is one header,
|
|
1079
|
+
// and the provider's own headers last: one that carries its credential
|
|
1080
|
+
// itself (an Authorization or x-api-key in `headers:`) wins over the key
|
|
1081
|
+
// slot, as on the Chat Completions wire above.
|
|
1082
|
+
const headers = {};
|
|
1083
|
+
for (const [k, v] of Object.entries(req.headers))
|
|
1084
|
+
if (PASSED_HEADERS.test(k) && typeof v === "string")
|
|
1085
|
+
headers[k] = v;
|
|
1086
|
+
if (spec.upstreamKey) {
|
|
1087
|
+
if (spec.upstreamAuth === "x-api-key")
|
|
1088
|
+
headers["x-api-key"] = spec.upstreamKey;
|
|
1089
|
+
else
|
|
1090
|
+
headers.authorization = `Bearer ${spec.upstreamKey}`;
|
|
1091
|
+
}
|
|
1092
|
+
for (const [k, v] of Object.entries(spec.headers ?? {}))
|
|
1093
|
+
headers[k.toLowerCase()] = v;
|
|
1094
|
+
const method = req.method ?? "GET";
|
|
1095
|
+
const target = `${base}${url.pathname}${url.search}`;
|
|
1096
|
+
let upstream;
|
|
1097
|
+
try {
|
|
1098
|
+
upstream = await fetchUntrusted(target, { method, headers, body: method === "GET" || method === "HEAD" ? undefined : raw }, { sameOrigin: true, maxBytes: UPSTREAM_MAX_BYTES });
|
|
1099
|
+
}
|
|
1100
|
+
catch (err) {
|
|
1101
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1102
|
+
log.push(`${method} ${label}${url.pathname} → unreachable: ${message}`);
|
|
1103
|
+
res.writeHead(502, { "content-type": "application/json" });
|
|
1104
|
+
res.end(errorBody(502, `translator: could not reach ${label}: ${message}`));
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
const out = {};
|
|
1108
|
+
upstream.headers.forEach((v, k) => {
|
|
1109
|
+
if (!HOP_HEADERS.has(k))
|
|
1110
|
+
out[k] = v;
|
|
1111
|
+
});
|
|
1112
|
+
res.writeHead(upstream.status, out);
|
|
1113
|
+
log.push(`${method} ${label}${url.pathname} → ${upstream.status} (${Date.now() - started}ms)`);
|
|
1114
|
+
const reader = upstream.body?.getReader();
|
|
1115
|
+
if (!reader)
|
|
1116
|
+
return void res.end();
|
|
1117
|
+
try {
|
|
1118
|
+
for (;;) {
|
|
1119
|
+
const { value, done } = await reader.read();
|
|
1120
|
+
if (done)
|
|
1121
|
+
break;
|
|
1122
|
+
res.write(value);
|
|
1123
|
+
}
|
|
1124
|
+
res.end();
|
|
1125
|
+
}
|
|
1126
|
+
catch (err) {
|
|
1127
|
+
// Mid-answer (the cap, a dropped connection): an Anthropic error event,
|
|
1128
|
+
// so a stream reads as failed, not as a reply that merely stopped.
|
|
1129
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1130
|
+
log.push(`translator error: ${message}`);
|
|
1131
|
+
res.end(`event: error\ndata: ${errorBody(500, `translator: ${message}`)}\n\n`);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1061
1134
|
/**
|
|
1062
1135
|
* What a provider block needs from the translator, or null when the block
|
|
1063
1136
|
* speaks Anthropic and needs none. Values only — this crosses into the run
|
package/dist/src/webhook.js
CHANGED
|
@@ -115,8 +115,8 @@ export function recordDelivery(tenant, workspace, delivery) {
|
|
|
115
115
|
const file = deliveriesFile(tenant, workspace);
|
|
116
116
|
if (!fs.existsSync(path.dirname(file)))
|
|
117
117
|
return; // no workspace, nothing to log against
|
|
118
|
-
if (UNAUTHENTICATED.includes(delivery.outcome)
|
|
119
|
-
return;
|
|
118
|
+
if (UNAUTHENTICATED.includes(delivery.outcome))
|
|
119
|
+
return recordRefusal(file, tenant, workspace, delivery);
|
|
120
120
|
fs.appendFileSync(file, JSON.stringify(delivery) + "\n");
|
|
121
121
|
// Bounded: compact to the newest KEEP when it doubles. Amortised cheap,
|
|
122
122
|
// and a log that grows forever is a disk-full incident with a delay.
|
|
@@ -132,9 +132,42 @@ export function recordDelivery(tenant, workspace, delivery) {
|
|
|
132
132
|
// compaction is best-effort; the append already landed
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* A refusal — anyone's to send — never changes the workspace's directory: no
|
|
137
|
+
* new file, no tmp file, no rename. The directory's mtime is the workspace's
|
|
138
|
+
* deployedAt (store.ts listWorkspaces), and a stranger posting a bad token
|
|
139
|
+
* moved it (FOL-102). So a refusal goes into a log that already exists, in
|
|
140
|
+
* place (an append, or the last line rewritten), and is never the write that
|
|
141
|
+
* compacts it; a workspace with no log yet gets a server log line instead,
|
|
142
|
+
* at most one a minute per workspace.
|
|
143
|
+
*/
|
|
144
|
+
function recordRefusal(file, tenant, workspace, delivery) {
|
|
145
|
+
if (!fs.existsSync(file))
|
|
146
|
+
return noteRefusal(tenant, workspace, delivery);
|
|
147
|
+
if (collapseInto(file, delivery))
|
|
148
|
+
return;
|
|
149
|
+
fs.appendFileSync(file, JSON.stringify(delivery) + "\n");
|
|
150
|
+
}
|
|
151
|
+
const REFUSAL_NOTE_MS = 60_000;
|
|
152
|
+
const refusalNotes = new Map();
|
|
153
|
+
function noteRefusal(tenant, workspace, delivery) {
|
|
154
|
+
const key = `${tenant}/${workspace}`;
|
|
155
|
+
const now = Date.now();
|
|
156
|
+
const seen = refusalNotes.get(key);
|
|
157
|
+
if (seen && now - seen.at < REFUSAL_NOTE_MS) {
|
|
158
|
+
seen.since++;
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const more = seen?.since ? ` (and ${seen.since} more since the last note)` : "";
|
|
162
|
+
console.warn(`[hooks] refused a delivery to ${key}/${delivery.flow}: ${delivery.outcome}${more}`);
|
|
163
|
+
refusalNotes.set(key, { at: now, since: 0 });
|
|
164
|
+
if (refusalNotes.size > 10_000)
|
|
165
|
+
refusalNotes.clear(); // bounded: a note lost beats memory
|
|
166
|
+
}
|
|
135
167
|
/** Fold a refusal into the log's last line when that line is a refusal
|
|
136
168
|
* too. The flow name is not part of the match: it comes from the URL, so
|
|
137
|
-
* a flood that varies it would otherwise never collapse. True when folded.
|
|
169
|
+
* a flood that varies it would otherwise never collapse. True when folded.
|
|
170
|
+
* Rewritten in place, never through a rename (recordRefusal says why). */
|
|
138
171
|
function collapseInto(file, delivery) {
|
|
139
172
|
let text;
|
|
140
173
|
try {
|
|
@@ -160,9 +193,16 @@ function collapseInto(file, delivery) {
|
|
|
160
193
|
first: last.first ?? last.t,
|
|
161
194
|
...(last.detail === "more than one flow name" || last.flow !== delivery.flow ? { detail: "more than one flow name" } : {}),
|
|
162
195
|
};
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
fs.
|
|
196
|
+
const line = Buffer.from(JSON.stringify(merged) + "\n");
|
|
197
|
+
const at = Buffer.byteLength(end.slice(0, cut));
|
|
198
|
+
const fd = fs.openSync(file, "r+");
|
|
199
|
+
try {
|
|
200
|
+
fs.writeSync(fd, line, 0, line.length, at);
|
|
201
|
+
fs.ftruncateSync(fd, at + line.length);
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
fs.closeSync(fd);
|
|
205
|
+
}
|
|
166
206
|
return true;
|
|
167
207
|
}
|
|
168
208
|
export function readDeliveries(tenant, workspace, limit = 20) {
|
package/package.json
CHANGED
package/src/evals.ts
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
// they pass — no point paying a model to grade output already known to be
|
|
32
32
|
// wrong.
|
|
33
33
|
|
|
34
|
-
import { accountModelEnv, withModelEnv } from "./model-env.ts";
|
|
34
|
+
import { accountModelEnv, withModelEnv, ModelKeyError } from "./model-env.ts";
|
|
35
35
|
import type { TranslatorSpec } from "./translator.ts";
|
|
36
36
|
import { sdkEnv } from "./step-exec.ts";
|
|
37
37
|
import { platformModelEnv } from "./runner.ts";
|
|
@@ -746,7 +746,14 @@ export async function checkAssertion(
|
|
|
746
746
|
} catch (err) {
|
|
747
747
|
return { assertion, passed: false, detail: `judge not run — ${err instanceof Error ? err.message : String(err)}` };
|
|
748
748
|
}
|
|
749
|
-
|
|
749
|
+
let said: Awaited<ReturnType<typeof judgeCall>>;
|
|
750
|
+
try {
|
|
751
|
+
said = await withModelEnv(m, (env) => judgeCall(assertion.value, output, model, effort, env));
|
|
752
|
+
} catch (err) {
|
|
753
|
+
// The provider's URL refused on the host (FOL-95), like a missing key above.
|
|
754
|
+
if (!(err instanceof ModelKeyError)) throw err;
|
|
755
|
+
return { assertion, passed: false, detail: `judge not run — ${err.message}` };
|
|
756
|
+
}
|
|
750
757
|
const { passed, detail } = judgeVerdict(said.text);
|
|
751
758
|
return { assertion, passed, detail, costUsd: said.costUsd };
|
|
752
759
|
}
|
package/src/model-env.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { accountDir, workspaceDir, parseProvider, providerEnvFor } from "./store.ts";
|
|
10
10
|
import { readAgentsMd } from "./runner.ts";
|
|
11
11
|
import { getSecret } from "./secrets.ts";
|
|
12
|
-
import { platform } from "./platform.ts";
|
|
12
|
+
import { platform, hostUrlProblem } from "./platform.ts";
|
|
13
13
|
import { accountModelBlock, isClaudeLoginToken } from "./model-credential.ts";
|
|
14
14
|
import { providerPreset } from "./providers.ts";
|
|
15
15
|
import { startTranslator, translatorSpecFor, type TranslatorSpec } from "./translator.ts";
|
|
@@ -53,6 +53,12 @@ export function accountModelEnv(
|
|
|
53
53
|
if (!platform.platformKeyAllowed(tenant)) throw new ModelKeyError(NO_MODEL_KEY);
|
|
54
54
|
return { env: platformEnv(), supply: "platform", format: "anthropic", translator: null };
|
|
55
55
|
}
|
|
56
|
+
// A host-side call to a URL the tenant wrote: on a platform, https only
|
|
57
|
+
// (platform.ts hostUrlProblem); withModelEnv asks the platform's own check
|
|
58
|
+
// too, and the guard sees every byte (FOL-95). A step resolves its
|
|
59
|
+
// provider elsewhere (runner.ts) and is not touched by this.
|
|
60
|
+
const refused = hostUrlProblem(spec.baseUrl);
|
|
61
|
+
if (refused) throw new ModelKeyError(`provider base_url refused: ${refused}`, 422);
|
|
56
62
|
const sub = (text: string) =>
|
|
57
63
|
text.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => getSecret(tenant, name, workspace)?.value ?? whole);
|
|
58
64
|
const token = sub(spec.token);
|
|
@@ -80,19 +86,48 @@ export function accountModelEnv(
|
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
/**
|
|
83
|
-
* Run `fn` with the model env, through a translator on this host
|
|
84
|
-
*
|
|
85
|
-
*
|
|
89
|
+
* Run `fn` with the model env, through a translator on this host — the one a
|
|
90
|
+
* step uses (runner.ts) when the provider speaks Chat Completions or
|
|
91
|
+
* Responses, and a passthrough when it speaks Anthropic's Messages (FOL-95):
|
|
92
|
+
* either way the SDK talks only to loopback, and the tenant's endpoint is
|
|
93
|
+
* reached through fetchUntrusted, after the platform's check of its URL.
|
|
94
|
+
* The platform's own credential (`supply: "platform"`) goes as it always did.
|
|
95
|
+
* Started for this call and closed after it.
|
|
86
96
|
*/
|
|
87
97
|
export async function withModelEnv<T>(
|
|
88
|
-
m: { env: Record<string, string | undefined>; translator?: TranslatorSpec | null },
|
|
98
|
+
m: { env: Record<string, string | undefined>; translator?: TranslatorSpec | null; supply?: "provider" | "platform" },
|
|
89
99
|
fn: (env: Record<string, string | undefined>) => Promise<T>,
|
|
90
100
|
): Promise<T> {
|
|
91
|
-
|
|
92
|
-
|
|
101
|
+
const spec = m.translator ?? (m.supply === "provider" ? anthropicPassthrough(m.env) : null);
|
|
102
|
+
if (!spec) return fn(m.env);
|
|
103
|
+
const why = await platform.checkUntrustedUrl(spec.upstreamBase);
|
|
104
|
+
if (why) throw new ModelKeyError(`provider base_url refused: ${why}`, 422);
|
|
105
|
+
const t = await startTranslator(spec);
|
|
93
106
|
try {
|
|
94
107
|
return await fn({ ...m.env, ...t.env });
|
|
95
108
|
} finally {
|
|
96
109
|
await t.close();
|
|
97
110
|
}
|
|
98
111
|
}
|
|
112
|
+
|
|
113
|
+
/** The passthrough for an Anthropic-format provider, read back from the env
|
|
114
|
+
* providerEnvFor (store.ts) built for it: the base URL, the key in the slot
|
|
115
|
+
* that names its header, and ANTHROPIC_CUSTOM_HEADERS. Null with no base. */
|
|
116
|
+
export function anthropicPassthrough(env: Record<string, string | undefined>): TranslatorSpec | null {
|
|
117
|
+
const base = env.ANTHROPIC_BASE_URL;
|
|
118
|
+
if (!base) return null;
|
|
119
|
+
const apiKey = env.ANTHROPIC_API_KEY ?? "";
|
|
120
|
+
const headers: Record<string, string> = {};
|
|
121
|
+
for (const line of (env.ANTHROPIC_CUSTOM_HEADERS ?? "").split("\n")) {
|
|
122
|
+
const at = line.indexOf(":");
|
|
123
|
+
if (at > 0) headers[line.slice(0, at).trim()] = line.slice(at + 1).trim();
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
wire: "anthropic",
|
|
127
|
+
upstreamBase: base,
|
|
128
|
+
upstreamKey: apiKey || (env.ANTHROPIC_AUTH_TOKEN ?? ""),
|
|
129
|
+
upstreamAuth: apiKey ? "x-api-key" : "bearer",
|
|
130
|
+
headers,
|
|
131
|
+
label: base,
|
|
132
|
+
};
|
|
133
|
+
}
|
package/src/platform.ts
CHANGED
|
@@ -177,6 +177,16 @@ export interface PlatformHooks {
|
|
|
177
177
|
* caps it again, so a laptop's plain fetch may ignore it).
|
|
178
178
|
*/
|
|
179
179
|
fetchUntrusted(url: string, init?: RequestInit, opts?: { maxBytes?: number }): Promise<Response>;
|
|
180
|
+
/**
|
|
181
|
+
* May the host call this URL a tenant chose — a provider's base_url,
|
|
182
|
+
* before an eval's judge or a dashboard draft talks to it? A reason it may
|
|
183
|
+
* not, or null. This is the early refusal people can read; the guarantee
|
|
184
|
+
* is that every byte of such a call goes through fetchUntrusted (FOL-95).
|
|
185
|
+
* Default: hostUrlProblem below — https on an install that guards
|
|
186
|
+
* untrusted fetches, anything on a laptop (its own localhost included).
|
|
187
|
+
* A platform may bind a stricter check (the addresses a name resolves to).
|
|
188
|
+
*/
|
|
189
|
+
checkUntrustedUrl(url: string): Promise<string | null>;
|
|
180
190
|
/**
|
|
181
191
|
* Hand a notify: webhook to whatever delivers it. The platform records
|
|
182
192
|
* the delivery, makes the first attempt now and retries on a backoff from
|
|
@@ -210,6 +220,11 @@ export interface WebhookDeliveryOutcome {
|
|
|
210
220
|
retrying?: boolean;
|
|
211
221
|
}
|
|
212
222
|
|
|
223
|
+
/** Marks the laptop's fetchUntrusted. A symbol from the global registry, not
|
|
224
|
+
* the function itself: every bundle has its own copy of this module (see
|
|
225
|
+
* KEY below), and only the shared object says which one was registered. */
|
|
226
|
+
const LAPTOP_FETCH = Symbol.for("foldrun.laptop-fetch");
|
|
227
|
+
|
|
213
228
|
const local: PlatformHooks = {
|
|
214
229
|
edition: () => "self-hosted",
|
|
215
230
|
async enqueueFlowRun(tenant, workspace, steps, flowName, modelOverride, tags = [], startedBy = null, opts = {}) {
|
|
@@ -237,7 +252,8 @@ const local: PlatformHooks = {
|
|
|
237
252
|
platformKeyAllowed: () => true,
|
|
238
253
|
galleryDir: () => null,
|
|
239
254
|
noteOAuthRefresh: async () => undefined,
|
|
240
|
-
fetchUntrusted: (url, init) => fetch(url, init),
|
|
255
|
+
fetchUntrusted: Object.assign((url: string, init?: RequestInit) => fetch(url, init), { [LAPTOP_FETCH]: true }),
|
|
256
|
+
checkUntrustedUrl: async (url) => hostUrlProblem(url),
|
|
241
257
|
async deliverWebhook(tenant, workspace, delivery) {
|
|
242
258
|
const { attemptWebhook, newDeliveryId } = await import("./notify.ts");
|
|
243
259
|
const deliveryId = newDeliveryId();
|
|
@@ -259,6 +275,34 @@ const KEY = Symbol.for("foldrun.platform");
|
|
|
259
275
|
const g = globalThis as unknown as Record<symbol, PlatformHooks | undefined>;
|
|
260
276
|
export const platform: PlatformHooks = (g[KEY] ??= { ...local });
|
|
261
277
|
|
|
278
|
+
/** True when this process fetches tenant-chosen URLs through a guard: a
|
|
279
|
+
* platform bound fetchUntrusted. A laptop has not, and calls its own LAN on
|
|
280
|
+
* purpose. */
|
|
281
|
+
export function guardsUntrustedUrls(): boolean {
|
|
282
|
+
return !(platform.fetchUntrusted as unknown as Record<symbol, unknown>)[LAPTOP_FETCH];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Why the host may not call a tenant's model endpoint at this URL, or null.
|
|
287
|
+
* On an install that guards untrusted fetches the endpoint must be https: the
|
|
288
|
+
* key rides in every request, and a plain-http base is also how a tenant
|
|
289
|
+
* names a cluster service. Private and internal addresses are refused by the
|
|
290
|
+
* guard itself, on every hop. FOLDRUN_EGRESS_ALLOW_PRIVATE=1 — the operator's
|
|
291
|
+
* own install calling its own network — lifts this as it lifts the guard. A
|
|
292
|
+
* laptop: always null.
|
|
293
|
+
*/
|
|
294
|
+
export function hostUrlProblem(url: string): string | null {
|
|
295
|
+
if (!guardsUntrustedUrls() || process.env.FOLDRUN_EGRESS_ALLOW_PRIVATE === "1") return null;
|
|
296
|
+
let u: URL;
|
|
297
|
+
try {
|
|
298
|
+
u = new URL(url);
|
|
299
|
+
} catch {
|
|
300
|
+
return `${url} is not a URL`;
|
|
301
|
+
}
|
|
302
|
+
if (u.protocol !== "https:") return `${u.origin} is not https — this platform calls a model endpoint only over https`;
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
262
306
|
/** Install the platform's implementations. Partial: what is not given keeps its local default. */
|
|
263
307
|
export function registerPlatform(hooks: Partial<PlatformHooks>): void {
|
|
264
308
|
Object.assign(platform, hooks, {
|
package/src/translator.ts
CHANGED
|
@@ -68,7 +68,11 @@ function usageBlock(u: { input: number; cached: number; output: number } | null)
|
|
|
68
68
|
};
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
/** `anthropic` is no translation at all: the host-side passthrough for an
|
|
72
|
+
* endpoint that already speaks Messages, so the SDK on the host talks only
|
|
73
|
+
* to loopback and every byte to the tenant's URL goes through the guard
|
|
74
|
+
* (FOL-95). A step never gets it (translatorSpecFor returns null there). */
|
|
75
|
+
export type TranslatorWire = "chat" | "responses" | "anthropic";
|
|
72
76
|
|
|
73
77
|
export interface TranslatorSpec {
|
|
74
78
|
/** The provider's base, e.g. https://api.openai.com/v1 — `/chat/completions`
|
|
@@ -76,8 +80,10 @@ export interface TranslatorSpec {
|
|
|
76
80
|
upstreamBase: string;
|
|
77
81
|
/** Which OpenAI shape the endpoint speaks. Default chat. */
|
|
78
82
|
wire?: TranslatorWire;
|
|
79
|
-
/** Already resolved. Sent as `Authorization: Bearer
|
|
83
|
+
/** Already resolved. Sent as `Authorization: Bearer`, or as `x-api-key`
|
|
84
|
+
* where `upstreamAuth` says so (the anthropic wire only). */
|
|
80
85
|
upstreamKey: string;
|
|
86
|
+
upstreamAuth?: "bearer" | "x-api-key";
|
|
81
87
|
/** Extra headers the provider wants, already resolved. */
|
|
82
88
|
headers?: Record<string, string>;
|
|
83
89
|
maxTokensParam?: "max_tokens" | "max_completion_tokens";
|
|
@@ -943,6 +949,8 @@ export async function startTranslator(spec: TranslatorSpec): Promise<RunningTran
|
|
|
943
949
|
req.on("error", reject);
|
|
944
950
|
});
|
|
945
951
|
|
|
952
|
+
if (spec.wire === "anthropic") return await passthrough(spec, base, label, req, url, raw, res, log, started);
|
|
953
|
+
|
|
946
954
|
if (req.method === "POST" && url.pathname === "/v1/messages/count_tokens") {
|
|
947
955
|
// No endpoint on the other side counts for us; four characters a
|
|
948
956
|
// token is the estimate every tokenizer lands near for English.
|
|
@@ -1105,6 +1113,79 @@ export async function startTranslator(spec: TranslatorSpec): Promise<RunningTran
|
|
|
1105
1113
|
};
|
|
1106
1114
|
}
|
|
1107
1115
|
|
|
1116
|
+
/** Request headers the SDK's own call carries that the endpoint is owed:
|
|
1117
|
+
* the content type, and Anthropic's version and beta flags. Its credential
|
|
1118
|
+
* (the translator's key) is not one of them. */
|
|
1119
|
+
const PASSED_HEADERS = /^(content-type|accept|anthropic-[a-z0-9-]+)$/;
|
|
1120
|
+
/** Response headers that describe the bytes as they came off the wire —
|
|
1121
|
+
* fetch has already decoded them, so they would lie on the way back. */
|
|
1122
|
+
const HOP_HEADERS = new Set(["content-encoding", "content-length", "transfer-encoding", "connection", "keep-alive"]);
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* The anthropic wire: the SDK's request, as it is, to the same path under the
|
|
1126
|
+
* tenant's base URL, with the tenant's key put on here — through
|
|
1127
|
+
* fetchUntrusted, so no private or cluster address, no redirect that takes
|
|
1128
|
+
* the key to another origin, and the answer read to UPSTREAM_MAX_BYTES. The
|
|
1129
|
+
* answer streams back as it arrives, status and headers included.
|
|
1130
|
+
*/
|
|
1131
|
+
async function passthrough(
|
|
1132
|
+
spec: TranslatorSpec,
|
|
1133
|
+
base: string,
|
|
1134
|
+
label: string,
|
|
1135
|
+
req: http.IncomingMessage,
|
|
1136
|
+
url: URL,
|
|
1137
|
+
raw: string,
|
|
1138
|
+
res: http.ServerResponse,
|
|
1139
|
+
log: string[],
|
|
1140
|
+
started: number,
|
|
1141
|
+
): Promise<void> {
|
|
1142
|
+
// Lowercase keys, so a header named twice in different case is one header,
|
|
1143
|
+
// and the provider's own headers last: one that carries its credential
|
|
1144
|
+
// itself (an Authorization or x-api-key in `headers:`) wins over the key
|
|
1145
|
+
// slot, as on the Chat Completions wire above.
|
|
1146
|
+
const headers: Record<string, string> = {};
|
|
1147
|
+
for (const [k, v] of Object.entries(req.headers)) if (PASSED_HEADERS.test(k) && typeof v === "string") headers[k] = v;
|
|
1148
|
+
if (spec.upstreamKey) {
|
|
1149
|
+
if (spec.upstreamAuth === "x-api-key") headers["x-api-key"] = spec.upstreamKey;
|
|
1150
|
+
else headers.authorization = `Bearer ${spec.upstreamKey}`;
|
|
1151
|
+
}
|
|
1152
|
+
for (const [k, v] of Object.entries(spec.headers ?? {})) headers[k.toLowerCase()] = v;
|
|
1153
|
+
const method = req.method ?? "GET";
|
|
1154
|
+
const target = `${base}${url.pathname}${url.search}`;
|
|
1155
|
+
let upstream: Response;
|
|
1156
|
+
try {
|
|
1157
|
+
upstream = await fetchUntrusted(target, { method, headers, body: method === "GET" || method === "HEAD" ? undefined : raw }, { sameOrigin: true, maxBytes: UPSTREAM_MAX_BYTES });
|
|
1158
|
+
} catch (err) {
|
|
1159
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1160
|
+
log.push(`${method} ${label}${url.pathname} → unreachable: ${message}`);
|
|
1161
|
+
res.writeHead(502, { "content-type": "application/json" });
|
|
1162
|
+
res.end(errorBody(502, `translator: could not reach ${label}: ${message}`));
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
const out: Record<string, string> = {};
|
|
1166
|
+
upstream.headers.forEach((v, k) => {
|
|
1167
|
+
if (!HOP_HEADERS.has(k)) out[k] = v;
|
|
1168
|
+
});
|
|
1169
|
+
res.writeHead(upstream.status, out);
|
|
1170
|
+
log.push(`${method} ${label}${url.pathname} → ${upstream.status} (${Date.now() - started}ms)`);
|
|
1171
|
+
const reader = upstream.body?.getReader();
|
|
1172
|
+
if (!reader) return void res.end();
|
|
1173
|
+
try {
|
|
1174
|
+
for (;;) {
|
|
1175
|
+
const { value, done } = await reader.read();
|
|
1176
|
+
if (done) break;
|
|
1177
|
+
res.write(value);
|
|
1178
|
+
}
|
|
1179
|
+
res.end();
|
|
1180
|
+
} catch (err) {
|
|
1181
|
+
// Mid-answer (the cap, a dropped connection): an Anthropic error event,
|
|
1182
|
+
// so a stream reads as failed, not as a reply that merely stopped.
|
|
1183
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1184
|
+
log.push(`translator error: ${message}`);
|
|
1185
|
+
res.end(`event: error\ndata: ${errorBody(500, `translator: ${message}`)}\n\n`);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1108
1189
|
/**
|
|
1109
1190
|
* What a provider block needs from the translator, or null when the block
|
|
1110
1191
|
* speaks Anthropic and needs none. Values only — this crosses into the run
|
package/src/webhook.ts
CHANGED
|
@@ -163,7 +163,7 @@ const KEEP_DELIVERIES = 500;
|
|
|
163
163
|
export function recordDelivery(tenant: string, workspace: string, delivery: HookDelivery) {
|
|
164
164
|
const file = deliveriesFile(tenant, workspace);
|
|
165
165
|
if (!fs.existsSync(path.dirname(file))) return; // no workspace, nothing to log against
|
|
166
|
-
if (UNAUTHENTICATED.includes(delivery.outcome)
|
|
166
|
+
if (UNAUTHENTICATED.includes(delivery.outcome)) return recordRefusal(file, tenant, workspace, delivery);
|
|
167
167
|
fs.appendFileSync(file, JSON.stringify(delivery) + "\n");
|
|
168
168
|
// Bounded: compact to the newest KEEP when it doubles. Amortised cheap,
|
|
169
169
|
// and a log that grows forever is a disk-full incident with a delay.
|
|
@@ -179,9 +179,42 @@ export function recordDelivery(tenant: string, workspace: string, delivery: Hook
|
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* A refusal — anyone's to send — never changes the workspace's directory: no
|
|
184
|
+
* new file, no tmp file, no rename. The directory's mtime is the workspace's
|
|
185
|
+
* deployedAt (store.ts listWorkspaces), and a stranger posting a bad token
|
|
186
|
+
* moved it (FOL-102). So a refusal goes into a log that already exists, in
|
|
187
|
+
* place (an append, or the last line rewritten), and is never the write that
|
|
188
|
+
* compacts it; a workspace with no log yet gets a server log line instead,
|
|
189
|
+
* at most one a minute per workspace.
|
|
190
|
+
*/
|
|
191
|
+
function recordRefusal(file: string, tenant: string, workspace: string, delivery: HookDelivery) {
|
|
192
|
+
if (!fs.existsSync(file)) return noteRefusal(tenant, workspace, delivery);
|
|
193
|
+
if (collapseInto(file, delivery)) return;
|
|
194
|
+
fs.appendFileSync(file, JSON.stringify(delivery) + "\n");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const REFUSAL_NOTE_MS = 60_000;
|
|
198
|
+
const refusalNotes = new Map<string, { at: number; since: number }>();
|
|
199
|
+
|
|
200
|
+
function noteRefusal(tenant: string, workspace: string, delivery: HookDelivery) {
|
|
201
|
+
const key = `${tenant}/${workspace}`;
|
|
202
|
+
const now = Date.now();
|
|
203
|
+
const seen = refusalNotes.get(key);
|
|
204
|
+
if (seen && now - seen.at < REFUSAL_NOTE_MS) {
|
|
205
|
+
seen.since++;
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const more = seen?.since ? ` (and ${seen.since} more since the last note)` : "";
|
|
209
|
+
console.warn(`[hooks] refused a delivery to ${key}/${delivery.flow}: ${delivery.outcome}${more}`);
|
|
210
|
+
refusalNotes.set(key, { at: now, since: 0 });
|
|
211
|
+
if (refusalNotes.size > 10_000) refusalNotes.clear(); // bounded: a note lost beats memory
|
|
212
|
+
}
|
|
213
|
+
|
|
182
214
|
/** Fold a refusal into the log's last line when that line is a refusal
|
|
183
215
|
* too. The flow name is not part of the match: it comes from the URL, so
|
|
184
|
-
* a flood that varies it would otherwise never collapse. True when folded.
|
|
216
|
+
* a flood that varies it would otherwise never collapse. True when folded.
|
|
217
|
+
* Rewritten in place, never through a rename (recordRefusal says why). */
|
|
185
218
|
function collapseInto(file: string, delivery: HookDelivery): boolean {
|
|
186
219
|
let text: string;
|
|
187
220
|
try {
|
|
@@ -204,9 +237,15 @@ function collapseInto(file: string, delivery: HookDelivery): boolean {
|
|
|
204
237
|
first: last.first ?? last.t,
|
|
205
238
|
...(last.detail === "more than one flow name" || last.flow !== delivery.flow ? { detail: "more than one flow name" } : {}),
|
|
206
239
|
};
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
fs.
|
|
240
|
+
const line = Buffer.from(JSON.stringify(merged) + "\n");
|
|
241
|
+
const at = Buffer.byteLength(end.slice(0, cut));
|
|
242
|
+
const fd = fs.openSync(file, "r+");
|
|
243
|
+
try {
|
|
244
|
+
fs.writeSync(fd, line, 0, line.length, at);
|
|
245
|
+
fs.ftruncateSync(fd, at + line.length);
|
|
246
|
+
} finally {
|
|
247
|
+
fs.closeSync(fd);
|
|
248
|
+
}
|
|
210
249
|
return true;
|
|
211
250
|
}
|
|
212
251
|
|