@xandreed/plugin-models 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/auth/anthropicOAuth.d.ts +30 -10
- package/dist/auth/anthropicOAuth.js +15 -18
- package/dist/auth/auth-store.conformance.d.ts +63 -0
- package/dist/auth/auth-store.conformance.js +152 -0
- package/dist/auth/credential-input.entity.d.ts +23 -5
- package/dist/auth/credential-input.entity.functions.d.ts +15 -5
- package/dist/auth/credential-input.entity.functions.js +12 -9
- package/dist/auth/credential-input.entity.js +5 -3
- package/dist/auth/credential-refresh.entity.d.ts +11 -3
- package/dist/auth/credential-refresh.entity.functions.d.ts +48 -6
- package/dist/auth/credential-refresh.entity.functions.js +12 -3
- package/dist/auth/localAuth.d.ts +10 -1
- package/dist/auth/localAuth.js +53 -79
- package/dist/auth/memory-auth.adapter.d.ts +12 -4
- package/dist/auth/memory-auth.adapter.js +13 -25
- package/dist/auth/openAiCodexOAuth.d.ts +26 -5
- package/dist/auth/openAiCodexOAuth.js +17 -28
- package/dist/auth/token-response.entity.d.ts +21 -0
- package/dist/auth/token-response.entity.functions.d.ts +20 -0
- package/dist/auth/token-response.entity.functions.js +10 -0
- package/dist/auth/token-response.entity.js +10 -0
- package/dist/compat/index.d.ts +2 -0
- package/dist/compat/index.js +1 -0
- package/dist/conformance/index.d.ts +6 -0
- package/dist/conformance/index.js +3 -0
- package/dist/index.d.ts +19 -19
- package/dist/index.js +17 -69
- package/dist/llm/chat-wire.entity.d.ts +123 -0
- package/dist/llm/chat-wire.entity.functions.d.ts +28 -0
- package/dist/llm/chat-wire.entity.functions.js +10 -0
- package/dist/llm/chat-wire.entity.js +37 -0
- package/dist/llm/compat-telemetry.adapter.d.ts +6 -0
- package/dist/llm/compat-telemetry.adapter.js +21 -0
- package/dist/llm/compat.d.ts +50 -30
- package/dist/llm/compat.js +149 -133
- package/dist/llm/model-completion.adapter.d.ts +49 -0
- package/dist/llm/model-completion.adapter.js +77 -0
- package/dist/llm/model-completion.entity.d.ts +4 -0
- package/dist/llm/model-completion.entity.functions.d.ts +3 -0
- package/dist/llm/model-completion.entity.functions.js +7 -0
- package/dist/llm/model-completion.entity.js +7 -0
- package/dist/llm/model-retry-policy.entity.d.ts +37 -0
- package/dist/llm/model-retry-policy.entity.functions.d.ts +16 -0
- package/dist/llm/model-retry-policy.entity.functions.js +33 -0
- package/dist/llm/model-retry-policy.entity.js +23 -0
- package/dist/llm/model-settings.entity.d.ts +85 -0
- package/dist/llm/model-settings.entity.functions.d.ts +11 -0
- package/dist/llm/model-settings.entity.functions.js +49 -0
- package/dist/llm/model-settings.entity.js +28 -0
- package/dist/llm/model-usage.entity.d.ts +61 -0
- package/dist/llm/model-usage.entity.functions.d.ts +11 -0
- package/dist/llm/model-usage.entity.functions.js +24 -0
- package/dist/llm/model-usage.entity.js +19 -0
- package/dist/llm/modelCatalog.d.ts +29 -8
- package/dist/llm/modelCatalog.js +28 -66
- package/dist/llm/openAiCodex.d.ts +15 -7
- package/dist/llm/openAiCodex.js +57 -45
- package/dist/llm/openAiCodexWebSocket.d.ts +48 -15
- package/dist/llm/openAiCodexWebSocket.js +101 -147
- package/dist/llm/prompt-cache-frame.entity.d.ts +18 -0
- package/dist/llm/prompt-cache-frame.entity.js +10 -0
- package/dist/llm/prompt-cache.entity.d.ts +57 -0
- package/dist/llm/prompt-cache.entity.functions.d.ts +8 -0
- package/dist/llm/prompt-cache.entity.functions.js +73 -0
- package/dist/llm/prompt-cache.entity.js +27 -0
- package/dist/llm/provider-client.adapter.d.ts +9 -0
- package/dist/llm/provider-client.adapter.js +60 -0
- package/dist/llm/provider-failure.entity.d.ts +42 -0
- package/dist/llm/provider-failure.entity.functions.d.ts +8 -0
- package/dist/llm/provider-failure.entity.functions.js +67 -0
- package/dist/llm/provider-failure.entity.js +20 -0
- package/dist/llm/provider-profile.entity.d.ts +79 -0
- package/dist/llm/provider-profile.entity.functions.d.ts +23 -0
- package/dist/llm/provider-profile.entity.functions.js +83 -0
- package/dist/llm/provider-profile.entity.js +10 -0
- package/dist/llm/providers.d.ts +116 -34
- package/dist/llm/providers.js +298 -129
- package/dist/llm/retry.d.ts +11 -47
- package/dist/llm/retry.js +33 -52
- package/dist/llm/router.d.ts +54 -36
- package/dist/llm/router.js +91 -78
- package/dist/llm/sse.d.ts +44 -37
- package/dist/llm/sse.js +43 -35
- package/dist/llm/thinking-binding.entity.d.ts +49 -0
- package/dist/llm/thinking-binding.entity.functions.d.ts +21 -0
- package/dist/llm/thinking-binding.entity.functions.js +16 -0
- package/dist/llm/thinking-binding.entity.js +11 -0
- package/dist/llm/utilityLlm.d.ts +14 -5
- package/dist/llm/utilityLlm.js +18 -13
- package/dist/plugin.adapter.d.ts +94 -6
- package/dist/plugin.adapter.js +18 -13
- package/dist/ports/auth-store.port.d.ts +73 -0
- package/dist/ports/auth-store.port.js +24 -0
- package/dist/ports/host-credentials.port.d.ts +2 -1
- package/dist/ports/model-call-completion.port.d.ts +9 -0
- package/dist/ports/model-call-completion.port.js +2 -0
- package/dist/ports/model-endpoints.port.d.ts +60 -26
- package/dist/ports/model-endpoints.port.js +17 -3
- package/dist/ports/model-retry-policy.port.d.ts +17 -0
- package/dist/ports/model-retry-policy.port.js +3 -0
- package/dist/ports/model-settings.port.d.ts +32 -0
- package/dist/ports/model-settings.port.js +4 -0
- package/dist/ports/models-transport.port.d.ts +9 -2
- package/dist/ports/models-transport.port.js +4 -1
- package/dist/ports/prompt-cache-frame.port.d.ts +21 -0
- package/dist/ports/prompt-cache-frame.port.js +4 -0
- package/dist/ports/settings-store.port.d.ts +70 -0
- package/dist/ports/settings-store.port.js +33 -0
- package/dist/ports/thinking-binding.port.d.ts +7 -0
- package/dist/ports/thinking-binding.port.js +4 -0
- package/dist/settings/localSettings.d.ts +2 -1
- package/dist/settings/localSettings.js +17 -20
- package/dist/settings/memory-settings.adapter.d.ts +3 -1
- package/dist/settings/memory-settings.adapter.js +1 -1
- package/dist/settings/roleView.d.ts +3 -1
- package/dist/settings/roleView.js +1 -1
- package/dist/settings/settings-store.conformance.d.ts +47 -0
- package/dist/settings/settings-store.conformance.js +100 -0
- package/dist/settings/settings-value.entity.d.ts +10 -2
- package/dist/settings/settings-value.entity.functions.d.ts +31 -9
- package/dist/settings/settings-value.entity.functions.js +5 -6
- package/dist/storage/json-file.d.ts +18 -0
- package/dist/storage/json-file.js +26 -0
- package/dist/transport/http-proxy.entity.d.ts +17 -3
- package/dist/transport/model-request.conformance.d.ts +13 -0
- package/dist/transport/model-request.conformance.js +36 -0
- package/dist/transport/models-transport.adapter.d.ts +18 -17
- package/dist/transport/models-transport.adapter.js +28 -31
- package/dist/transport/resilient-http-client.adapter.d.ts +73 -0
- package/dist/transport/resilient-http-client.adapter.js +177 -0
- package/package.json +11 -5
- package/dist/auth/anthropicOAuth.test.d.ts +0 -1
- package/dist/auth/credential-refresh.entity.functions.test.d.ts +0 -1
- package/dist/auth/localAuth.test.d.ts +0 -1
- package/dist/auth/memory-auth.adapter.test.d.ts +0 -1
- package/dist/llm/compat.test.d.ts +0 -1
- package/dist/llm/modelCatalog.test.d.ts +0 -1
- package/dist/llm/openAiCodexWebSocket.test.d.ts +0 -1
- package/dist/llm/providers.test.d.ts +0 -1
- package/dist/llm/retry.test.d.ts +0 -1
- package/dist/llm/router.test.d.ts +0 -1
- package/dist/llm/sse.test.d.ts +0 -1
- package/dist/plugin.adapter.test.d.ts +0 -1
- package/dist/settings/localSettings.test.d.ts +0 -1
- package/dist/settings/memory-settings.adapter.test.d.ts +0 -1
- package/dist/settings/roleView.test.d.ts +0 -1
- package/dist/settings/settings-value.entity.functions.test.d.ts +0 -1
- package/dist/transport/http-proxy.entity.functions.test.d.ts +0 -1
- package/dist/transport/models-transport.adapter.test.d.ts +0 -1
- package/dist/transport/recording-proxy.testing.d.ts +0 -33
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Effect, Option } from "effect";
|
|
2
|
+
import { canonicalJson, harnessCodeOf, parseModelSelection } from "@xandreed/core";
|
|
3
|
+
import { check, ConformanceFailure } from "@xandreed/core/conformance";
|
|
4
|
+
import { randomUuid } from "@xandreed/core/internal";
|
|
5
|
+
import { SETTINGS_KEYS } from "../ports/settings-store.port.js";
|
|
6
|
+
const fail = (id, message) => Effect.fail(new ConformanceFailure({ check: id, message })), expect = (id, holds, message) => holds ? Effect.void : fail(id, message), ROLE_FIELDS = { general: "model", code: "codeModel", fast: "fastModel" }, ROLES = ["general", "code", "fast"], FIELDS = ["model", "codeModel", "fastModel", ...SETTINGS_KEYS], fieldOf = (settings, field) => settings[field], valueOf = (settings, field) => Option.map(fieldOf(settings, field), String), snapshot = (settings) => canonicalJson(Object.fromEntries(FIELDS.map((field) => [field, Option.getOrNull(valueOf(settings, field))]))), others = (settings, except) => canonicalJson(Object.fromEntries(FIELDS.filter((field) => field !== except).map((field) => [field, Option.getOrNull(valueOf(settings, field))]))), selection = (label) => Effect.map(randomUuid, (id) => `conformance:${label}-${id}`), SELECTIONS = new Set(["model", "codeModel", "fastModel", "fallbackModel"]), COUNTS = new Set(["maxAttempts", "budgetMillis"]), restorable = (field, value) => SELECTIONS.has(field) ? Option.isSome(parseModelSelection(value)) : COUNTS.has(field) ? Number.isInteger(Number(value)) && Number(value) >= 1 : !0, restoring = (store, id, field, write) => (body) => store.load.pipe(Effect.flatMap((before) => Option.match(Option.filter(valueOf(before, field), (value) => !restorable(field, value)), {
|
|
7
|
+
onSome: (value) => fail(id, `the store holds ${field} "${value}", which a conforming store refuses to write, so the kit could not put it back; it changed nothing. Correct the value, then run the kit`),
|
|
8
|
+
onNone: () => body.pipe(Effect.ensuring(store.load.pipe(Effect.flatMap((after) => Option.getOrNull(valueOf(after, field)) === Option.getOrNull(valueOf(before, field)) ? Effect.void : write(valueOf(before, field))), Effect.ignore({ log: "Warn", message: `the kit could not put back ${field}` }))))
|
|
9
|
+
}))), restoringRole = (store, id, role) => restoring(store, id, ROLE_FIELDS[role], (value) => store.setRole(role, value)), restoringKey = (store, id, key) => restoring(store, id, key, (value) => store.set(key, value)), refused = (store, id, what, write) => Effect.gen(function* () {
|
|
10
|
+
const before = snapshot(yield* store.load), result = yield* Effect.result(write);
|
|
11
|
+
if (result._tag === "Success")
|
|
12
|
+
return yield* fail(id, `${what} was accepted`);
|
|
13
|
+
const tag = typeof result.failure === "object" && result.failure !== null && "_tag" in result.failure ? String(result.failure._tag) : "an error";
|
|
14
|
+
yield* expect(id, tag === "SettingsError", `${what} failed with ${tag}, not a SettingsError`);
|
|
15
|
+
yield* expect(id, snapshot(yield* store.load) === before, `${what} changed the settings`);
|
|
16
|
+
}), CANONICAL = [
|
|
17
|
+
{ key: "maxAttempts", text: "3", reads: 3 },
|
|
18
|
+
{ key: "budgetMillis", text: "60000", reads: 60000 },
|
|
19
|
+
{ key: "reasoningEffort", text: "high", reads: "high" }
|
|
20
|
+
], CLEARED = [
|
|
21
|
+
{ key: "maxAttempts", texts: ["4", "5"] },
|
|
22
|
+
{ key: "budgetMillis", texts: ["1000", "2000"] },
|
|
23
|
+
{ key: "reasoningEffort", texts: ["low", "medium"] }
|
|
24
|
+
], BAD = [
|
|
25
|
+
{ key: "fallbackModel", text: "not-a-selection" },
|
|
26
|
+
{ key: "maxAttempts", text: "0" },
|
|
27
|
+
{ key: "maxAttempts", text: "two" },
|
|
28
|
+
{ key: "budgetMillis", text: "1.5" },
|
|
29
|
+
{ key: "reasoningEffort", text: "extreme" }
|
|
30
|
+
], corrupted = (what, effect) => Effect.result(effect).pipe(Effect.flatMap((result) => {
|
|
31
|
+
if (result._tag === "Success")
|
|
32
|
+
return fail("corrupt", `${what} succeeded on a store whose backing data cannot be read`);
|
|
33
|
+
const error = result.failure, tag = typeof error === "object" && error !== null && "_tag" in error ? String(error._tag) : "an error", code = Option.getOrElse(harnessCodeOf(error), () => "no code");
|
|
34
|
+
return expect("corrupt", tag === "SettingsError" && code === "settings.corrupt", `${what} failed with ${tag} (${code}), not a SettingsError of code settings.corrupt`);
|
|
35
|
+
})), corruptCheck = (store, writable, corrupt) => check("a store whose backing data cannot be read fails load and every write with a SettingsError of code settings.corrupt, never reads it as defaults", "corrupt", () => {
|
|
36
|
+
const body = Effect.gen(function* () {
|
|
37
|
+
const before = snapshot(yield* store.load);
|
|
38
|
+
yield* Effect.scoped(Effect.gen(function* () {
|
|
39
|
+
yield* corrupt;
|
|
40
|
+
yield* corrupted("load", store.load);
|
|
41
|
+
yield* writable ? Effect.all([
|
|
42
|
+
selection("corrupt").pipe(Effect.flatMap((value) => corrupted("setRole", store.setRole("general", Option.some(value))))),
|
|
43
|
+
corrupted("set", store.set("maxAttempts", Option.some("3")))
|
|
44
|
+
], { concurrency: 1, discard: !0 }) : Effect.void;
|
|
45
|
+
}));
|
|
46
|
+
yield* expect("corrupt", snapshot(yield* store.load) === before, "once the backing data is repaired, the store does not read what it held before");
|
|
47
|
+
});
|
|
48
|
+
return writable ? body.pipe(restoringRole(store, "corrupt", "general"), restoringKey(store, "corrupt", "maxAttempts")) : body;
|
|
49
|
+
});
|
|
50
|
+
export const settingsStoreConformance = (store, options) => [
|
|
51
|
+
check("load is stable: two loads with no write between them agree", "load", () => Effect.gen(function* () {
|
|
52
|
+
const first = snapshot(yield* store.load);
|
|
53
|
+
yield* expect("load", snapshot(yield* store.load) === first, "two loads disagree");
|
|
54
|
+
})),
|
|
55
|
+
...options.writable ? writableChecks(store) : readOnlyChecks(store),
|
|
56
|
+
...Option.match(Option.fromNullishOr(options.corrupt), { onNone: () => [], onSome: (corrupt) => [corruptCheck(store, options.writable, corrupt)] })
|
|
57
|
+
];
|
|
58
|
+
const writableChecks = (store) => [
|
|
59
|
+
check("each role reads back what setRole wrote, and clearing it returns the value before the set", "role", () => Effect.forEach(ROLES, (role) => Effect.gen(function* () {
|
|
60
|
+
const field = ROLE_FIELDS[role];
|
|
61
|
+
yield* store.setRole(role, Option.none());
|
|
62
|
+
const base = yield* store.load, value = yield* selection(role);
|
|
63
|
+
yield* store.setRole(role, Option.some(value));
|
|
64
|
+
const set = yield* store.load;
|
|
65
|
+
yield* expect("role", Option.getOrUndefined(valueOf(set, field)) === value, `the ${role} role reads ${Option.getOrElse(valueOf(set, field), () => "nothing")}, not what was set`);
|
|
66
|
+
yield* expect("role", others(set, field) === others(base, field), `setting the ${role} role changed another value`);
|
|
67
|
+
yield* store.setRole(role, Option.none());
|
|
68
|
+
yield* expect("role", snapshot(yield* store.load) === snapshot(base), `clearing the ${role} role did not return the value before the set`);
|
|
69
|
+
}).pipe(restoringRole(store, "role", role)), { concurrency: 1, discard: !0 })),
|
|
70
|
+
check("set accepts each key's canonical string form and load reads it back typed", "values", () => selection("fallback").pipe(Effect.flatMap((fallback) => Effect.forEach([
|
|
71
|
+
...CANONICAL,
|
|
72
|
+
{ key: "fallbackModel", text: fallback, reads: void 0 }
|
|
73
|
+
], ({ key, text, reads }) => Effect.gen(function* () {
|
|
74
|
+
const before = yield* store.load;
|
|
75
|
+
yield* store.set(key, Option.some(text));
|
|
76
|
+
const after = yield* store.load, expected = reads ?? text, read = Option.getOrNull(fieldOf(after, key));
|
|
77
|
+
yield* expect("values", read === expected, `${key} "${text}" reads back as ${JSON.stringify(read)}`);
|
|
78
|
+
yield* expect("values", others(after, key) === others(before, key), `setting ${key} changed another value`);
|
|
79
|
+
}).pipe(restoringKey(store, "values", key)), { concurrency: 1, discard: !0 })))),
|
|
80
|
+
check("None clears a key back to the value before the set", "clear", () => Effect.forEach(CLEARED, ({ key, texts }) => Effect.gen(function* () {
|
|
81
|
+
yield* store.set(key, Option.none());
|
|
82
|
+
const base = yield* store.load, text = texts.find((candidate) => Option.getOrNull(valueOf(base, key)) !== candidate) ?? texts[0];
|
|
83
|
+
yield* store.set(key, Option.some(text));
|
|
84
|
+
yield* store.set(key, Option.none());
|
|
85
|
+
yield* expect("clear", snapshot(yield* store.load) === snapshot(base), `clearing ${key} did not return the value before the set`);
|
|
86
|
+
}).pipe(restoringKey(store, "clear", key)), { concurrency: 1, discard: !0 })),
|
|
87
|
+
check("set refuses a value that does not parse with a SettingsError and changes nothing", "bad-values", () => Effect.forEach(BAD, ({ key, text }) => refused(store, "bad-values", `${key} "${text}"`, store.set(key, Option.some(text))).pipe(restoringKey(store, "bad-values", key)), { concurrency: 1, discard: !0 })),
|
|
88
|
+
check('setRole refuses a selection that is not "<provider>:<modelId>" with a SettingsError and changes nothing', "bad-role", () => Effect.forEach(ROLES, (role) => Effect.forEach(["not-a-selection", ":model", "provider:"], (text) => refused(store, "bad-role", `the ${role} role "${text}"`, store.setRole(role, Option.some(text))), { concurrency: 1, discard: !0 }).pipe(restoringRole(store, "bad-role", role)), { concurrency: 1, discard: !0 }))
|
|
89
|
+
], readOnlyChecks = (store) => [
|
|
90
|
+
check("a read-only store refuses every write with a SettingsError and changes nothing", "read-only", () => Effect.gen(function* () {
|
|
91
|
+
yield* Effect.forEach(ROLES, (role) => Effect.all([
|
|
92
|
+
selection(role).pipe(Effect.flatMap((value) => refused(store, "read-only", `setRole ${role}`, store.setRole(role, Option.some(value))))),
|
|
93
|
+
refused(store, "read-only", `clearing the ${role} role`, store.setRole(role, Option.none()))
|
|
94
|
+
], { concurrency: 1 }), { concurrency: 1, discard: !0 });
|
|
95
|
+
yield* Effect.forEach(CANONICAL, ({ key, text }) => Effect.all([
|
|
96
|
+
refused(store, "read-only", `set ${key}`, store.set(key, Option.some(text))),
|
|
97
|
+
refused(store, "read-only", `clearing ${key}`, store.set(key, Option.none()))
|
|
98
|
+
], { concurrency: 1 }), { concurrency: 1, discard: !0 });
|
|
99
|
+
}))
|
|
100
|
+
];
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { Schema } from "effect";
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* A settings value as a config file holds it: a selection or other text, a switch, a count.
|
|
4
|
+
*
|
|
5
|
+
* @public
|
|
6
|
+
*/
|
|
3
7
|
export declare const SettingValue: Schema.Union<readonly [Schema.String, Schema.Boolean, Schema.Number]>;
|
|
4
8
|
export type SettingValue = typeof SettingValue.Type;
|
|
5
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* The settings field each model role is kept under.
|
|
11
|
+
*
|
|
12
|
+
* @public
|
|
13
|
+
*/
|
|
6
14
|
export declare const RoleField: Schema.Literals<readonly ["model", "codeModel", "fastModel"]>;
|
|
7
15
|
export type RoleField = typeof RoleField.Type;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Effect, Option } from "effect";
|
|
2
|
-
import { EngineSettings, SettingsError } from "
|
|
3
|
-
import type { ModelRole, SettingsKey } from "
|
|
2
|
+
import { EngineSettings, SettingsError } from "../ports/settings-store.port.js";
|
|
3
|
+
import type { ModelRole, SettingsKey } from "../ports/settings-store.port.js";
|
|
4
4
|
import type { RoleField, SettingValue } from "./settings-value.entity.js";
|
|
5
5
|
/**
|
|
6
6
|
* The settings vocabulary every SettingsStore of this package shares: how a
|
|
@@ -8,22 +8,44 @@ import type { RoleField, SettingValue } from "./settings-value.entity.js";
|
|
|
8
8
|
* `set` receives: "true", "3", "provider:model") is checked and coerced, and
|
|
9
9
|
* how one key or role changes a settings value.
|
|
10
10
|
*/
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* The field each model role is kept under.
|
|
13
|
+
*
|
|
14
|
+
* @public
|
|
15
|
+
*/
|
|
12
16
|
export declare const ROLE_FIELDS: Readonly<Record<ModelRole, RoleField>>;
|
|
13
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Settings from a config record: values of the wrong type read as unset, keys it does not name are not read; the legacy `openAiReasoningEffort` key backs `reasoningEffort`.
|
|
19
|
+
*
|
|
20
|
+
* @public
|
|
21
|
+
*/
|
|
14
22
|
export declare const settingsFromRecord: (record: Readonly<Record<string, unknown>>) => EngineSettings;
|
|
15
23
|
/** Settings as a config record: the set fields only. */
|
|
16
24
|
export declare const recordOfSettings: (settings: EngineSettings) => Readonly<Record<string, SettingValue>>;
|
|
17
25
|
/**
|
|
18
26
|
* One key's value from its string form, checked: a selection must be
|
|
19
|
-
* `"<provider>:<modelId>"`,
|
|
20
|
-
*
|
|
27
|
+
* `"<provider>:<modelId>"`, an effort one the models accept, a count a
|
|
28
|
+
* positive integer. A value that does not parse is
|
|
21
29
|
* refused, not written and then ignored on load.
|
|
30
|
+
*
|
|
31
|
+
* @public
|
|
22
32
|
*/
|
|
23
33
|
export declare const coerceSettingValue: (key: SettingsKey, raw: string) => Effect.Effect<SettingValue, SettingsError>;
|
|
24
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* A role's selection, checked: `None` clears the role; a value must be `"<provider>:<modelId>"`.
|
|
36
|
+
*
|
|
37
|
+
* @public
|
|
38
|
+
*/
|
|
25
39
|
export declare const roleSelection: (role: ModelRole, selection: Option.Option<string>) => Effect.Effect<Option.Option<string>, SettingsError>;
|
|
26
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* `settings` with one key set from its string form (checked, as `set` takes it), or cleared with `None`.
|
|
42
|
+
*
|
|
43
|
+
* @public
|
|
44
|
+
*/
|
|
27
45
|
export declare const applySetting: (settings: EngineSettings, key: SettingsKey, value: Option.Option<string>) => Effect.Effect<EngineSettings, SettingsError>;
|
|
28
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* `settings` with one role's selection set (checked, as `setRole` takes it), or cleared with `None`.
|
|
48
|
+
*
|
|
49
|
+
* @public
|
|
50
|
+
*/
|
|
29
51
|
export declare const applyRole: (settings: EngineSettings, role: ModelRole, selection: Option.Option<string>) => Effect.Effect<EngineSettings, SettingsError>;
|
|
@@ -1,18 +1,17 @@
|
|
|
1
|
-
import { Effect, Match, Option } from "effect";
|
|
2
|
-
import {
|
|
1
|
+
import { Effect, Match, Option, Schema } from "effect";
|
|
2
|
+
import { parseModelSelection, ReasoningEffort } from "@xandreed/core";
|
|
3
|
+
import { EngineSettings, SETTINGS_KEYS, SettingsError } from "../ports/settings-store.port.js";
|
|
3
4
|
export const ROLE_FIELDS = {
|
|
4
5
|
general: "model",
|
|
5
6
|
code: "codeModel",
|
|
6
7
|
fast: "fastModel"
|
|
7
8
|
};
|
|
8
|
-
const FIELDS = ["model", "codeModel", "fastModel", ...SETTINGS_KEYS], asString =
|
|
9
|
+
const FIELDS = ["model", "codeModel", "fastModel", ...SETTINGS_KEYS], asString = Schema.decodeUnknownOption(Schema.NonEmptyString), asNumber = Schema.decodeUnknownOption(Schema.Finite), asReasoningEffort = Schema.decodeUnknownOption(ReasoningEffort);
|
|
9
10
|
export const settingsFromRecord = (record) => new EngineSettings({
|
|
10
11
|
model: asString(record.model),
|
|
11
12
|
codeModel: asString(record.codeModel),
|
|
12
13
|
fastModel: asString(record.fastModel),
|
|
13
14
|
fallbackModel: asString(record.fallbackModel),
|
|
14
|
-
sandbox: asBoolean(record.sandbox),
|
|
15
|
-
viMode: asBoolean(record.viMode),
|
|
16
15
|
maxAttempts: asNumber(record.maxAttempts),
|
|
17
16
|
budgetMillis: asNumber(record.budgetMillis),
|
|
18
17
|
reasoningEffort: Option.orElse(asReasoningEffort(record.reasoningEffort), () => asReasoningEffort(record.openAiReasoningEffort)),
|
|
@@ -26,7 +25,7 @@ const fieldOf = (settings, field) => settings[field], refuse = (message) => Effe
|
|
|
26
25
|
export const coerceSettingValue = (key, raw) => Match.value(key).pipe(Match.when("fallbackModel", () => Option.match(parseModelSelection(raw), {
|
|
27
26
|
onNone: () => refuse(`fallbackModel must be "<provider>:<modelId>" \u2014 got "${raw}"`),
|
|
28
27
|
onSome: () => Effect.succeed(raw)
|
|
29
|
-
})), Match.whenOr("
|
|
28
|
+
})), Match.whenOr("reasoningEffort", "codeReasoningEffort", "fastReasoningEffort", (effort) => Option.match(asReasoningEffort(raw), {
|
|
30
29
|
onNone: () => refuse(`${effort} is not supported: "${raw}"`),
|
|
31
30
|
onSome: (value) => Effect.succeed(value)
|
|
32
31
|
})), Match.whenOr("maxAttempts", "budgetMillis", (count) => Number.isInteger(Number(raw)) && Number(raw) >= 1 ? Effect.succeed(Number(raw)) : refuse(`${count} must be a positive integer \u2014 got "${raw}"`)), Match.exhaustive), roleSelection = (role, selection) => Option.match(selection, {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Effect, Option, Schema, Semaphore } from "effect";
|
|
2
|
+
/**
|
|
3
|
+
* Read the JSON object in `path`: `None` when no file is there. A file that
|
|
4
|
+
* cannot be read (any error but ENOENT: permissions, a directory) or does
|
|
5
|
+
* not hold a JSON object fails with `unusable(reason)`.
|
|
6
|
+
*/
|
|
7
|
+
export declare const readJsonObject: <E>(path: string, unusable: (reason: string) => E) => Effect.Effect<Option.Option<Schema.JsonObject>, E>;
|
|
8
|
+
/**
|
|
9
|
+
* Replace `path` with `value`: written to a temp file of its own beside it
|
|
10
|
+
* (named by the process and a random number, so concurrent writes never
|
|
11
|
+
* share one), given `mode` when there is one, then renamed over. A failed
|
|
12
|
+
* write removes its temp file.
|
|
13
|
+
*/
|
|
14
|
+
export declare const writeJsonObject: <E>(path: string, value: Record<string, unknown>, mode: Option.Option<number>, failed: (reason: string) => E) => Effect.Effect<void, E>;
|
|
15
|
+
/** A lock of its own for `name` at `path` (one refresh of a provider's token kept there, say). */
|
|
16
|
+
export declare const namedLock: (path: string, name: string) => Effect.Effect<Semaphore.Semaphore>;
|
|
17
|
+
/** Run `effect` holding the lock of `path`, which every store in the process that writes the file shares. */
|
|
18
|
+
export declare const withFileLock: (path: string) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { Effect, Option, Random, Schema, Semaphore } from "effect";
|
|
4
|
+
import { errorCodeOf } from "@xandreed/core";
|
|
5
|
+
const decodeJsonObject = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.JsonObject)), isMissing = (cause) => Option.contains(errorCodeOf(cause), "ENOENT");
|
|
6
|
+
export const readJsonObject = (path, unusable) => Effect.tryPromise({ try: () => readFile(path, "utf-8"), catch: (cause) => cause }).pipe(Effect.map(Option.some), Effect.catchIf(isMissing, () => Effect.succeed(Option.none())), Effect.mapError((cause) => unusable(`could not be read (${Option.getOrElse(errorCodeOf(cause), () => String(cause))})`)), Effect.flatMap(Option.match({
|
|
7
|
+
onNone: () => Effect.succeed(Option.none()),
|
|
8
|
+
onSome: (text) => decodeJsonObject(text).pipe(Effect.map(Option.some), Effect.mapError(() => unusable("does not hold a JSON object")))
|
|
9
|
+
}))), writeJsonObject = (path, value, mode, failed) => Random.nextIntBetween(0, Number.MAX_SAFE_INTEGER).pipe(Effect.flatMap((suffix) => {
|
|
10
|
+
const tmp = `${path}.tmp-${process.pid}-${suffix.toString(36)}`;
|
|
11
|
+
return Effect.tryPromise({
|
|
12
|
+
try: async () => {
|
|
13
|
+
await mkdir(dirname(path), { recursive: !0 });
|
|
14
|
+
await writeFile(tmp, JSON.stringify(value, null, 2), "utf-8");
|
|
15
|
+
await Option.match(mode, { onNone: () => Promise.resolve(), onSome: (bits) => chmod(tmp, bits) });
|
|
16
|
+
await rename(tmp, path);
|
|
17
|
+
},
|
|
18
|
+
catch: (cause) => failed(String(cause))
|
|
19
|
+
}).pipe(Effect.tapError(() => Effect.tryPromise({ try: () => rm(tmp, { force: !0 }), catch: (cause) => cause }).pipe(Effect.catch((cause) => Effect.logWarning(`could not remove the temporary file ${tmp}`, cause)))));
|
|
20
|
+
}));
|
|
21
|
+
const locks = new Map, lockOf = (key) => Effect.sync(() => Option.getOrElse(Option.fromNullishOr(locks.get(key)), () => {
|
|
22
|
+
const made = Semaphore.makeUnsafe(1);
|
|
23
|
+
locks.set(key, made);
|
|
24
|
+
return made;
|
|
25
|
+
}));
|
|
26
|
+
export const namedLock = (path, name) => lockOf(`${name}:${resolve(path)}`), withFileLock = (path) => (effect) => Effect.flatMap(namedLock(path, "file"), (lock) => lock.withPermit(effect));
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { Schema } from "effect";
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* A proxy URL as an option takes it: a string, or a `Redacted` one when it carries credentials.
|
|
4
|
+
*
|
|
5
|
+
* @public
|
|
6
|
+
*/
|
|
3
7
|
export declare const ProxyUrlOption: Schema.Union<readonly [Schema.String, Schema.Redacted<Schema.String>]>;
|
|
4
8
|
export type ProxyUrlOption = typeof ProxyUrlOption.Type;
|
|
5
9
|
/**
|
|
@@ -11,6 +15,8 @@ export type ProxyUrlOption = typeof ProxyUrlOption.Type;
|
|
|
11
15
|
* `HTTPS_PROXY` and `http_proxy` / `HTTP_PROXY` apply. `noProxy` lists the
|
|
12
16
|
* hosts that go direct, in NO_PROXY syntax, added to the environment's
|
|
13
17
|
* `no_proxy` / `NO_PROXY`; `"*"` turns the proxy off.
|
|
18
|
+
*
|
|
19
|
+
* @public
|
|
14
20
|
*/
|
|
15
21
|
export declare const HttpProxyOptions: Schema.Struct<{
|
|
16
22
|
readonly proxy: Schema.optionalKey<Schema.Union<readonly [Schema.String, Schema.Redacted<Schema.String>]>>;
|
|
@@ -74,13 +80,21 @@ declare const ProxyInvalid_base: Schema.Class<ProxyInvalid, Schema.TaggedStruct<
|
|
|
74
80
|
readonly source: Schema.String;
|
|
75
81
|
readonly message: Schema.String;
|
|
76
82
|
}>, import("effect/Cause").YieldableError>;
|
|
77
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* A proxy URL that cannot be used: not a URL, or not `http://` / `https://`. `source` names where it came from.
|
|
85
|
+
*
|
|
86
|
+
* @public
|
|
87
|
+
*/
|
|
78
88
|
export declare class ProxyInvalid extends ProxyInvalid_base {
|
|
79
89
|
}
|
|
80
90
|
declare const ProxyUnsupported_base: Schema.Class<ProxyUnsupported, Schema.TaggedStruct<"ProxyUnsupported", {
|
|
81
91
|
readonly message: Schema.String;
|
|
82
92
|
}>, import("effect/Cause").YieldableError>;
|
|
83
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* A proxy applies, but this runtime cannot send the call through it: the call is refused rather than sent direct.
|
|
95
|
+
*
|
|
96
|
+
* @public
|
|
97
|
+
*/
|
|
84
98
|
export declare class ProxyUnsupported extends ProxyUnsupported_base {
|
|
85
99
|
}
|
|
86
100
|
/** The first Bun whose native `WebSocket` sends through its `proxy` option (1.3.0 to 1.3.5 connect directly). */
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
3
|
+
import type { ConformanceCheck } from "@xandreed/core/conformance";
|
|
4
|
+
import type { ResilientHttpError, ResilientHttpResponse } from "./resilient-http-client.adapter.js";
|
|
5
|
+
import type { ProviderFailureProtocol } from "../llm/provider-failure.entity.js";
|
|
6
|
+
/** A fresh logical request over a supplied raw transport; request retries belong to the subject. @public */
|
|
7
|
+
export interface ModelRequestSubject {
|
|
8
|
+
readonly request: (client: HttpClient.HttpClient, request: HttpClientRequest.HttpClientRequest, options: {
|
|
9
|
+
readonly protocol: ProviderFailureProtocol;
|
|
10
|
+
}) => Effect.Effect<Pick<ResilientHttpResponse, "text" | "status">, ResilientHttpError>;
|
|
11
|
+
}
|
|
12
|
+
/** Bounded request-policy contract, independent of provider credentials or a real network. @public */
|
|
13
|
+
export declare const modelRequestConformance: (subject?: ModelRequestSubject) => ReadonlyArray<ConformanceCheck>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Effect, Exit, Option, Ref } from "effect";
|
|
2
|
+
import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http";
|
|
3
|
+
import { modelFailureOf } from "@xandreed/core";
|
|
4
|
+
import { check, ConformanceFailure } from "@xandreed/core/conformance";
|
|
5
|
+
import { modelRetryDefaults } from "../llm/model-retry-policy.entity.js";
|
|
6
|
+
import { CurrentModelRetryPolicy } from "../ports/model-retry-policy.port.js";
|
|
7
|
+
import { resilientRequest } from "./resilient-http-client.adapter.js";
|
|
8
|
+
const policy = { ...modelRetryDefaults, totalTimeoutMs: 2000, requestTimeoutMs: 100, bodyIdleTimeoutMs: 100, backoff: { initialMs: 0, maxMs: 0, jitter: 0 } }, request = HttpClientRequest.post("https://conformance.invalid/messages").pipe(HttpClientRequest.bodyJsonUnsafe({ fixture: !0 })), expect = (id, holds, message) => holds ? Effect.void : Effect.fail(new ConformanceFailure({ check: id, message })), response = (request, status, type) => HttpClientResponse.fromWeb(request, Response.json({ error: { type, message: "conformance fixture" } }, { status }));
|
|
9
|
+
export const modelRequestConformance = (subject = { request: resilientRequest }) => [
|
|
10
|
+
check("uses one counter for transport failures and rejected statuses", "mixed-attempts", () => Effect.gen(function* () {
|
|
11
|
+
const sends = yield* Ref.make(0), client = HttpClient.make((request) => Effect.flatMap(Ref.getAndUpdate(sends, (n) => n + 1), (ordinal) => ordinal === 0 ? Effect.fail(new HttpClientError.HttpClientError({ reason: new HttpClientError.TransportError({ request, description: "fixture" }) })) : Effect.succeed(ordinal < 3 ? response(request, ordinal === 1 ? 429 : 529, ordinal === 1 ? "rate_limit_error" : "overloaded_error") : HttpClientResponse.fromWeb(request, new Response("ok"))))), result = yield* subject.request(client, request, { protocol: "anthropic" }).pipe(Effect.flatMap((reply) => reply.text), Effect.provideService(CurrentModelRetryPolicy, policy));
|
|
12
|
+
yield* expect("mixed-attempts", result === "ok" && (yield* Ref.get(sends)) === 4, "mixed failures did not reach one fourth-attempt success");
|
|
13
|
+
})),
|
|
14
|
+
check("classifies a structured permanent error before retrying its status", "permanent-status", () => Effect.gen(function* () {
|
|
15
|
+
const sends = yield* Ref.make(0), client = HttpClient.make((request) => Ref.update(sends, (n) => n + 1).pipe(Effect.as(response(request, 429, "invalid_request_error")))), result = yield* Effect.exit(subject.request(client, request, { protocol: "compat" }).pipe(Effect.provideService(CurrentModelRetryPolicy, policy)));
|
|
16
|
+
yield* expect("permanent-status", Exit.isFailure(result) && (yield* Ref.get(sends)) === 1 && Option.contains(modelFailureOf(result.cause.reasons.find((reason) => reason._tag === "Fail")?.error), "InvalidRequest"), "a permanent request error was retried or lost its semantic kind");
|
|
17
|
+
})),
|
|
18
|
+
check("reads a call's policy at execution and obeys its total attempt limit", "dynamic-policy", () => Effect.gen(function* () {
|
|
19
|
+
const run = (maxAttempts) => Effect.gen(function* () {
|
|
20
|
+
const sends = yield* Ref.make(0), client = HttpClient.make((request) => Ref.update(sends, (n) => n + 1).pipe(Effect.as(response(request, 503, "overloaded_error"))));
|
|
21
|
+
yield* Effect.exit(subject.request(client, request, { protocol: "anthropic" }).pipe(Effect.provideService(CurrentModelRetryPolicy, { ...policy, maxAttempts })));
|
|
22
|
+
return yield* Ref.get(sends);
|
|
23
|
+
}), one = yield* run(1), three = yield* run(3);
|
|
24
|
+
yield* expect("dynamic-policy", one === 1 && three === 3, "a call ignored its policy or repeated beyond its total attempt cap");
|
|
25
|
+
})),
|
|
26
|
+
check("never sends a settled request again after its pending headers time out", "settled-headers", () => Effect.gen(function* () {
|
|
27
|
+
const sends = yield* Ref.make(0), client = HttpClient.make(() => Ref.update(sends, (n) => n + 1).pipe(Effect.andThen(Effect.never))), result = yield* subject.request(client, request, { protocol: "anthropic" }).pipe(Effect.provideService(CurrentModelRetryPolicy, { ...policy, requestTimeoutMs: 50, totalTimeoutMs: 300 }), Effect.exit, Effect.timeoutOption("2 seconds")), timedOut = Option.exists(result, (exit) => Exit.isFailure(exit) && Option.contains(modelFailureOf(exit.cause.reasons.find((reason) => reason._tag === "Fail")?.error), "Timeout"));
|
|
28
|
+
yield* expect("settled-headers", timedOut && (yield* Ref.get(sends)) === 1, "a settled request was cut before its deadline and sent again, or never timed out");
|
|
29
|
+
})),
|
|
30
|
+
check("reads a successful body that loses its connection as a transient failure", "lost-body", () => Effect.gen(function* () {
|
|
31
|
+
const client = HttpClient.make((request) => Effect.sync(() => HttpClientResponse.fromWeb(request, new Response(new ReadableStream({ start(controller) {
|
|
32
|
+
controller.error(Error("conformance connection reset"));
|
|
33
|
+
} }))))), result = yield* subject.request(client, request, { protocol: "anthropic" }).pipe(Effect.flatMap((reply) => reply.text), Effect.exit, Effect.provideService(CurrentModelRetryPolicy, policy));
|
|
34
|
+
yield* expect("lost-body", Exit.isFailure(result) && Option.contains(modelFailureOf(result.cause.reasons.find((reason) => reason._tag === "Fail")?.error), "Transient"), "a lost connection after handoff became a permanent or unclassified failure");
|
|
35
|
+
}))
|
|
36
|
+
];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { Config, Effect, Layer, Option } from "effect";
|
|
2
|
-
import type {
|
|
1
|
+
import { Config, Context, Effect, Layer, Option, Redacted } from "effect";
|
|
2
|
+
import type { Scope } from "effect";
|
|
3
3
|
import { ModelsTransport } from "../ports/models-transport.port.js";
|
|
4
|
+
import type { ModelsFetch, WebSocketProxy } from "../ports/models-transport.port.js";
|
|
4
5
|
import { ProxyUnsupported } from "./http-proxy.entity.js";
|
|
5
6
|
import type { HttpProxyOptions, ProxyInvalid } from "./http-proxy.entity.js";
|
|
6
7
|
/** The runtime the plugin runs on, told apart without a Bun-only import (the subscription's WebSocket checks its route against it too). */
|
|
@@ -14,26 +15,26 @@ export type Runtime = {
|
|
|
14
15
|
readonly _tag: "Other";
|
|
15
16
|
};
|
|
16
17
|
export declare const currentRuntime: () => Runtime;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
export declare const modelsTransportFor: (options: HttpProxyOptions) => Effect.Effect<Option.Option<Context.Service.Shape<typeof ModelsTransport>>, ProxyInvalid | ProxyUnsupported | Config.ConfigError, Scope.Scope>;
|
|
18
|
+
export declare const modelsTransportContextFor: (options: {
|
|
19
|
+
readonly proxy?: string | Redacted.Redacted<string>;
|
|
20
|
+
readonly noProxy?: string;
|
|
21
|
+
}) => Effect.Effect<Context.Context<never>, ProxyInvalid | ProxyUnsupported | Config.ConfigError, Scope.Scope>;
|
|
22
|
+
/** The scoped transport: a proxy's, or the managed direct one on Node and Bun; none on another runtime without a proxy. */
|
|
23
|
+
export declare const modelsTransportFor: (options: {
|
|
24
|
+
readonly proxy?: string | Redacted.Redacted<string>;
|
|
25
|
+
readonly noProxy?: string;
|
|
26
|
+
}) => Effect.Effect<Option.Option<{
|
|
27
|
+
readonly fetch: ModelsFetch;
|
|
28
|
+
readonly webSocketAgent?: (target: URL) => Effect.Effect<Option.Option<WebSocketProxy>, ProxyUnsupported>;
|
|
29
|
+
}>, ProxyInvalid | ProxyUnsupported | Config.ConfigError, Scope.Scope>;
|
|
30
30
|
/**
|
|
31
31
|
* `ModelsTransportLive(options)` — the models plugin's network path from a
|
|
32
32
|
* proxy setting: `options.proxy` (or `https_proxy` / `HTTPS_PROXY` and
|
|
33
33
|
* `http_proxy` / `HTTP_PROXY` from the environment) for every call, except
|
|
34
34
|
* loopback hosts and the `noProxy` / `NO_PROXY` hosts, which go direct (see
|
|
35
|
-
* `resolveProxy`).
|
|
36
|
-
*
|
|
35
|
+
* `resolveProxy`). Node always uses a scoped direct undici Agent with its
|
|
36
|
+
* header/body timeouts disabled; Bun uses its native fetch with fetch's own
|
|
37
|
+
* timeout turned off. A proxy URL that cannot
|
|
37
38
|
* be used fails with `ProxyInvalid`; a runtime that is neither Node nor Bun
|
|
38
39
|
* fails with `ProxyUnsupported` when a proxy applies. Its connection pools
|
|
39
40
|
* close with the layer's scope. Hosts use it for the OAuth login helpers too:
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import { Agent as HttpAgent } from "node:http";
|
|
2
2
|
import { Agent as HttpsAgent } from "node:https";
|
|
3
|
-
import { Config, Effect, Layer, Match, Option, Redacted } from "effect";
|
|
4
|
-
import {
|
|
3
|
+
import { Config, Context, Effect, Layer, Match, Option, Redacted } from "effect";
|
|
4
|
+
import { errorCodeOf } from "@xandreed/core";
|
|
5
|
+
import { CurrentManagedModelsTransport, ModelsTransport } from "../ports/models-transport.port.js";
|
|
5
6
|
import { BUN_WEBSOCKET_PROXY_VERSION, NODE_WEBSOCKET_PROXY_VERSION, PROXY_VARIABLES, ProxyUnsupported } from "./http-proxy.entity.js";
|
|
6
|
-
import { atLeastVersion, displayProxy, proxyFor, redactCredentials, resolveProxy,
|
|
7
|
+
import { atLeastVersion, displayProxy, proxyFor, redactCredentials, resolveProxy, usesProxy } from "./http-proxy.entity.functions.js";
|
|
7
8
|
import { undiciFetch } from "./undici-fetch.adapter.js";
|
|
8
9
|
export const currentRuntime = () => {
|
|
9
10
|
const versions = typeof process > "u" ? {} : process.versions, bun = versions.bun, node = versions.node;
|
|
10
11
|
return bun !== void 0 ? { _tag: "Bun", version: bun } : node !== void 0 ? { _tag: "Node", version: node } : { _tag: "Other" };
|
|
11
12
|
};
|
|
12
|
-
const readEnvironment = Effect.forEach(PROXY_VARIABLES, (name) => Config.option(Config.String(name)).pipe(Effect.map((value) => Option.toArray(Option.map(value, (text) => [name, text]))))).pipe(Effect.map((entries) => Object.fromEntries(entries.flat()))), targetOf = (input) => typeof input === "string" ? URL.canParse(input) ? Option.some(new URL(input)) : Option.none() : Option.some(input), isSecure = (target) => target.protocol === "https:" || target.protocol === "wss:", describeFailure = (cause) => {
|
|
13
|
+
const readEnvironment = Effect.forEach(PROXY_VARIABLES, (name) => Config.option(Config.String(name)).pipe(Effect.map((value) => Option.toArray(Option.map(value, (text) => [name, text])))), { concurrency: 1 }).pipe(Effect.map((entries) => Object.fromEntries(entries.flat()))), targetOf = (input) => typeof input === "string" ? URL.canParse(input) ? Option.some(new URL(input)) : Option.none() : Option.some(input), isSecure = (target) => target.protocol === "https:" || target.protocol === "wss:", describeFailure = (cause) => {
|
|
13
14
|
const own = cause instanceof Error ? cause.message : String(cause), inner = cause instanceof Error && cause.cause !== void 0 ? cause.cause instanceof Error ? cause.cause.message : String(cause.cause) : "";
|
|
14
15
|
return redactCredentials(inner.length > 0 && inner !== own ? `${own}: ${inner}` : own);
|
|
15
|
-
}, throughProxy = (target, proxy, request) => request.then((response) => response, (cause) => Promise.reject(cause instanceof Error && (cause.name === "AbortError" || cause.name === "TimeoutError") ? cause : TypeError(`the request to ${target.protocol}//${target.host} through the proxy ${displayProxy(proxy)} failed: ${describeFailure(cause)}`, { cause }))), directTransport = ModelsTransport.of({ fetch: (input, init) => globalThis.fetch(input, init) }), webSocketRefused = (target, why) => new ProxyUnsupported({
|
|
16
|
+
}, throughProxy = (target, proxy, request) => request.then((response) => response, (cause) => Promise.reject(cause instanceof Error && (cause.name === "AbortError" || cause.name === "TimeoutError") ? cause : TypeError(`the request to ${target.protocol}//${target.host} through the proxy ${displayProxy(proxy)} failed: ${describeFailure(cause)}`, { cause }))), directTransport = ModelsTransport.of({ fetch: (input, init) => globalThis.fetch(input, init) }), untimed = (init) => ({ ...init, timeout: !1 }), bunDirectTransport = ModelsTransport.of({ fetch: (input, init) => globalThis.fetch(input, untimed(init)) }), webSocketRefused = (target, why) => new ProxyUnsupported({
|
|
16
17
|
message: `${why}, so the WebSocket to ${target.host} is refused instead of connecting directly past the proxy`
|
|
17
18
|
}), refuseProxyAuthentication = (response) => response.status === 407 ? (response.body === null ? Promise.resolve() : response.body.cancel().then(() => {
|
|
18
19
|
return;
|
|
@@ -20,29 +21,25 @@ const readEnvironment = Effect.forEach(PROXY_VARIABLES, (name) => Config.option(
|
|
|
20
21
|
return;
|
|
21
22
|
})).then(() => Promise.reject(Error("the proxy refused its credentials (407 Proxy Authentication Required)"))) : Promise.resolve(response), bunTransport = (config, version) => ModelsTransport.of({
|
|
22
23
|
fetch: (input, init) => Option.match(Option.flatMap(targetOf(input), (target) => Option.map(proxyFor(target, config), (proxy) => [target, proxy])), {
|
|
23
|
-
onNone: () => globalThis.fetch(input, init),
|
|
24
|
-
onSome: ([target, proxy]) => throughProxy(target, proxy, globalThis.fetch(input, { ...init, proxy: Redacted.value(proxy) }).then(refuseProxyAuthentication))
|
|
24
|
+
onNone: () => globalThis.fetch(input, untimed(init)),
|
|
25
|
+
onSome: ([target, proxy]) => throughProxy(target, proxy, globalThis.fetch(input, { ...untimed(init), proxy: Redacted.value(proxy) }).then(refuseProxyAuthentication))
|
|
25
26
|
}),
|
|
26
27
|
webSocketAgent: (target) => Option.match(proxyFor(target, config), {
|
|
27
28
|
onNone: () => Effect.succeedNone,
|
|
28
29
|
onSome: (url) => atLeastVersion(version, BUN_WEBSOCKET_PROXY_VERSION) ? Effect.succeedSome({ _tag: "Proxy", url }) : Effect.fail(webSocketRefused(target, `Bun ${version} cannot send a WebSocket through a proxy (Bun ${BUN_WEBSOCKET_PROXY_VERSION} and later can)`))
|
|
29
30
|
})
|
|
30
|
-
}), isConnector = (value) => typeof value === "function",
|
|
31
|
+
}), isConnector = (value) => typeof value === "function", tunnelOnce = (connect, proxy) => (options, callback) => connect(options, (...result) => {
|
|
31
32
|
const [error] = result;
|
|
32
|
-
return error !== null &&
|
|
33
|
+
return error !== null && Option.contains(errorCodeOf(error), "UND_ERR_SOCKET") ? callback(Error(`the proxy ${displayProxy(proxy)} closed the connection while opening a tunnel to ${options.hostname}:${options.port.length > 0 ? options.port : options.protocol === "https:" ? "443" : "80"} (${error.message})`, { cause: error }), null) : callback(...result);
|
|
33
34
|
}), loadUndici = Effect.tryPromise({
|
|
34
35
|
try: () => import("undici"),
|
|
35
|
-
catch: (cause) => new ProxyUnsupported({ message: `undici, which sends the models plugin's calls on Node
|
|
36
|
-
}), nodeDirect = (undici) => Effect.gen(function* () {
|
|
37
|
-
const dispatcher = new undici.Agent, sockets = { https: new HttpsAgent, http: new HttpAgent };
|
|
36
|
+
catch: (cause) => new ProxyUnsupported({ message: `undici, which sends the models plugin's calls on Node, could not be loaded: ${describeFailure(cause)}` })
|
|
37
|
+
}), destroyDispatcher = (dispatcher) => Effect.tryPromise({ try: () => dispatcher.destroy(), catch: (cause) => cause }).pipe(Effect.catch((cause) => Effect.logDebug("models transport: a connection pool did not close", cause))), nodeDirect = (undici) => Effect.gen(function* () {
|
|
38
|
+
const dispatcher = new undici.Agent({ headersTimeout: 0, bodyTimeout: 0 }), sockets = { https: new HttpsAgent, http: new HttpAgent };
|
|
38
39
|
yield* Effect.addFinalizer(() => Effect.all([
|
|
39
|
-
|
|
40
|
-
return;
|
|
41
|
-
}, () => {
|
|
42
|
-
return;
|
|
43
|
-
})),
|
|
40
|
+
destroyDispatcher(dispatcher),
|
|
44
41
|
Effect.sync(() => [sockets.https, sockets.http].forEach((agent) => agent.destroy()))
|
|
45
|
-
], { discard: !0 }));
|
|
42
|
+
], { concurrency: 1, discard: !0 }));
|
|
46
43
|
return {
|
|
47
44
|
fetch: undiciFetch(undici.fetch, dispatcher),
|
|
48
45
|
webSocket: (target) => ({ _tag: "Agent", agent: isSecure(target) ? sockets.https : sockets.http })
|
|
@@ -53,9 +50,10 @@ const readEnvironment = Effect.forEach(PROXY_VARIABLES, (name) => Config.option(
|
|
|
53
50
|
}), nodeTransport = (config, version) => Effect.gen(function* () {
|
|
54
51
|
const undici = yield* loadUndici, direct = yield* nodeDirect(undici), proxyAgent = (proxy) => new undici.ProxyAgent({
|
|
55
52
|
uri: Redacted.value(proxy),
|
|
53
|
+
clientFactory: (origin, options) => new undici.Pool(origin, { ...options, headersTimeout: 0, bodyTimeout: 0 }),
|
|
56
54
|
factory: (origin, options) => {
|
|
57
55
|
const connect = Reflect.get(options, "connect");
|
|
58
|
-
return new undici.Pool(origin, { ...options, ...isConnector(connect) ? { connect: tunnelOnce(connect, proxy) } : {} });
|
|
56
|
+
return new undici.Pool(origin, { ...options, ...isConnector(connect) ? { connect: tunnelOnce(connect, proxy) } : {}, headersTimeout: 0, bodyTimeout: 0 });
|
|
59
57
|
}
|
|
60
58
|
}), sameUrl = Option.isSome(config.https) && Option.isSome(config.http) && Redacted.value(config.https.value) === Redacted.value(config.http.value), route = (proxy) => {
|
|
61
59
|
const dispatcher = proxyAgent(proxy);
|
|
@@ -65,13 +63,9 @@ const readEnvironment = Effect.forEach(PROXY_VARIABLES, (name) => Config.option(
|
|
|
65
63
|
http: Option.map(config.http, (proxy) => new HttpAgent({ proxyEnv: { HTTP_PROXY: Redacted.value(proxy) } }))
|
|
66
64
|
}, dispatchers = [...Option.toArray(Option.map(httpsRoute, (route) => route.dispatcher)), ...sameUrl ? [] : Option.toArray(Option.map(httpRoute, (route) => route.dispatcher))];
|
|
67
65
|
yield* Effect.addFinalizer(() => Effect.all([
|
|
68
|
-
Effect.forEach(dispatchers,
|
|
69
|
-
return;
|
|
70
|
-
}, () => {
|
|
71
|
-
return;
|
|
72
|
-
})), { discard: !0 }),
|
|
66
|
+
Effect.forEach(dispatchers, destroyDispatcher, { concurrency: 1, discard: !0 }),
|
|
73
67
|
Effect.sync(() => [...Option.toArray(socketAgents.https), ...Option.toArray(socketAgents.http)].forEach((agent) => agent.destroy()))
|
|
74
|
-
], { discard: !0 }));
|
|
68
|
+
], { concurrency: 1, discard: !0 }));
|
|
75
69
|
const routeFor = (target) => Option.flatMap(proxyFor(target, config), () => isSecure(target) ? httpsRoute : httpRoute), fetch = (input, init) => Option.match(Option.flatMap(targetOf(input), (target) => Option.map(routeFor(target), (route) => [target, route])), {
|
|
76
70
|
onNone: () => direct.fetch(input, init),
|
|
77
71
|
onSome: ([target, route]) => throughProxy(target, route.proxy, route.fetch(input, init))
|
|
@@ -83,15 +77,18 @@ const readEnvironment = Effect.forEach(PROXY_VARIABLES, (name) => Config.option(
|
|
|
83
77
|
onSome: (agent) => atLeastVersion(version, NODE_WEBSOCKET_PROXY_VERSION) ? Effect.succeedSome({ _tag: "Agent", agent }) : Effect.fail(webSocketRefused(target, `Node ${version} ignores the proxyEnv option of https.Agent (Node ${NODE_WEBSOCKET_PROXY_VERSION} and later honour it)`))
|
|
84
78
|
})
|
|
85
79
|
});
|
|
86
|
-
});
|
|
87
|
-
export const
|
|
80
|
+
}), managedDirect = (transport) => Context.make(ModelsTransport, transport).pipe(Context.add(CurrentManagedModelsTransport, Option.some(transport)));
|
|
81
|
+
export const modelsTransportContextFor = Effect.fnUntraced(function* (options) {
|
|
88
82
|
const config = yield* Effect.fromResult(resolveProxy(options, yield* readEnvironment)), runtime = currentRuntime();
|
|
89
83
|
if (!usesProxy(config))
|
|
90
|
-
return yield* Match.value(runtime).pipe(Match.tag("Node", () =>
|
|
91
|
-
|
|
84
|
+
return yield* Match.value(runtime).pipe(Match.tag("Node", () => Effect.map(nodeDirectTransport, managedDirect)), Match.tag("Bun", () => Effect.succeed(managedDirect(bunDirectTransport))), Match.orElse(() => Effect.succeed(Context.empty())));
|
|
85
|
+
const transport = yield* Match.value(runtime).pipe(Match.tag("Bun", ({ version }) => Effect.succeed(bunTransport(config, version))), Match.tag("Node", ({ version }) => nodeTransport(config, version)), Match.tag("Other", () => Effect.fail(new ProxyUnsupported({
|
|
92
86
|
message: "a proxy is configured, but this runtime is neither Node nor Bun: the models plugin cannot send its calls through a proxy here"
|
|
93
|
-
}))), Match.exhaustive)
|
|
94
|
-
|
|
87
|
+
}))), Match.exhaustive);
|
|
88
|
+
return Context.make(ModelsTransport, transport).pipe(Context.add(CurrentManagedModelsTransport, Option.none()));
|
|
89
|
+
}), modelsTransportFor = Effect.fnUntraced(function* (options) {
|
|
90
|
+
return Context.getOption(yield* modelsTransportContextFor(options), ModelsTransport);
|
|
91
|
+
}), ModelsTransportLive = (options = {}) => Layer.effectContext(Effect.map(modelsTransportContextFor(options), (context) => Context.add(context, ModelsTransport, Option.getOrElse(Context.getOption(context, ModelsTransport), () => directTransport)))), transportFetch = (transport) => Object.assign((input, init) => input instanceof Request ? input.arrayBuffer().then((body) => transport.fetch(input.url, {
|
|
95
92
|
method: input.method,
|
|
96
93
|
headers: input.headers,
|
|
97
94
|
body: body.byteLength === 0 ? null : body,
|