@xandreed/plugin-models 0.14.0 → 0.15.1

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.
Files changed (151) hide show
  1. package/README.md +1 -1
  2. package/dist/auth/anthropicOAuth.d.ts +30 -10
  3. package/dist/auth/anthropicOAuth.js +15 -18
  4. package/dist/auth/auth-store.conformance.d.ts +63 -0
  5. package/dist/auth/auth-store.conformance.js +152 -0
  6. package/dist/auth/credential-input.entity.d.ts +23 -5
  7. package/dist/auth/credential-input.entity.functions.d.ts +15 -5
  8. package/dist/auth/credential-input.entity.functions.js +12 -9
  9. package/dist/auth/credential-input.entity.js +5 -3
  10. package/dist/auth/credential-refresh.entity.d.ts +11 -3
  11. package/dist/auth/credential-refresh.entity.functions.d.ts +48 -6
  12. package/dist/auth/credential-refresh.entity.functions.js +12 -3
  13. package/dist/auth/localAuth.d.ts +10 -1
  14. package/dist/auth/localAuth.js +53 -79
  15. package/dist/auth/memory-auth.adapter.d.ts +12 -4
  16. package/dist/auth/memory-auth.adapter.js +13 -25
  17. package/dist/auth/openAiCodexOAuth.d.ts +26 -5
  18. package/dist/auth/openAiCodexOAuth.js +17 -28
  19. package/dist/auth/token-response.entity.d.ts +21 -0
  20. package/dist/auth/token-response.entity.functions.d.ts +20 -0
  21. package/dist/auth/token-response.entity.functions.js +10 -0
  22. package/dist/auth/token-response.entity.js +10 -0
  23. package/dist/compat/index.d.ts +2 -0
  24. package/dist/compat/index.js +1 -0
  25. package/dist/conformance/index.d.ts +6 -0
  26. package/dist/conformance/index.js +3 -0
  27. package/dist/index.d.ts +19 -19
  28. package/dist/index.js +17 -69
  29. package/dist/llm/chat-wire.entity.d.ts +123 -0
  30. package/dist/llm/chat-wire.entity.functions.d.ts +28 -0
  31. package/dist/llm/chat-wire.entity.functions.js +10 -0
  32. package/dist/llm/chat-wire.entity.js +37 -0
  33. package/dist/llm/compat-telemetry.adapter.d.ts +6 -0
  34. package/dist/llm/compat-telemetry.adapter.js +21 -0
  35. package/dist/llm/compat.d.ts +50 -30
  36. package/dist/llm/compat.js +149 -133
  37. package/dist/llm/model-completion.adapter.d.ts +49 -0
  38. package/dist/llm/model-completion.adapter.js +77 -0
  39. package/dist/llm/model-completion.entity.d.ts +4 -0
  40. package/dist/llm/model-completion.entity.functions.d.ts +3 -0
  41. package/dist/llm/model-completion.entity.functions.js +7 -0
  42. package/dist/llm/model-completion.entity.js +7 -0
  43. package/dist/llm/model-retry-policy.entity.d.ts +37 -0
  44. package/dist/llm/model-retry-policy.entity.functions.d.ts +16 -0
  45. package/dist/llm/model-retry-policy.entity.functions.js +33 -0
  46. package/dist/llm/model-retry-policy.entity.js +23 -0
  47. package/dist/llm/model-settings.entity.d.ts +85 -0
  48. package/dist/llm/model-settings.entity.functions.d.ts +11 -0
  49. package/dist/llm/model-settings.entity.functions.js +49 -0
  50. package/dist/llm/model-settings.entity.js +28 -0
  51. package/dist/llm/model-usage.entity.d.ts +61 -0
  52. package/dist/llm/model-usage.entity.functions.d.ts +11 -0
  53. package/dist/llm/model-usage.entity.functions.js +24 -0
  54. package/dist/llm/model-usage.entity.js +19 -0
  55. package/dist/llm/modelCatalog.d.ts +29 -8
  56. package/dist/llm/modelCatalog.js +28 -66
  57. package/dist/llm/openAiCodex.d.ts +15 -7
  58. package/dist/llm/openAiCodex.js +57 -45
  59. package/dist/llm/openAiCodexWebSocket.d.ts +48 -15
  60. package/dist/llm/openAiCodexWebSocket.js +101 -147
  61. package/dist/llm/prompt-cache-frame.entity.d.ts +18 -0
  62. package/dist/llm/prompt-cache-frame.entity.js +10 -0
  63. package/dist/llm/prompt-cache.entity.d.ts +57 -0
  64. package/dist/llm/prompt-cache.entity.functions.d.ts +8 -0
  65. package/dist/llm/prompt-cache.entity.functions.js +73 -0
  66. package/dist/llm/prompt-cache.entity.js +27 -0
  67. package/dist/llm/provider-client.adapter.d.ts +9 -0
  68. package/dist/llm/provider-client.adapter.js +60 -0
  69. package/dist/llm/provider-failure.entity.d.ts +42 -0
  70. package/dist/llm/provider-failure.entity.functions.d.ts +8 -0
  71. package/dist/llm/provider-failure.entity.functions.js +67 -0
  72. package/dist/llm/provider-failure.entity.js +20 -0
  73. package/dist/llm/provider-profile.entity.d.ts +79 -0
  74. package/dist/llm/provider-profile.entity.functions.d.ts +23 -0
  75. package/dist/llm/provider-profile.entity.functions.js +83 -0
  76. package/dist/llm/provider-profile.entity.js +10 -0
  77. package/dist/llm/providers.d.ts +116 -34
  78. package/dist/llm/providers.js +298 -129
  79. package/dist/llm/retry.d.ts +11 -47
  80. package/dist/llm/retry.js +33 -52
  81. package/dist/llm/router.d.ts +54 -36
  82. package/dist/llm/router.js +91 -78
  83. package/dist/llm/sse.d.ts +44 -37
  84. package/dist/llm/sse.js +43 -35
  85. package/dist/llm/thinking-binding.entity.d.ts +49 -0
  86. package/dist/llm/thinking-binding.entity.functions.d.ts +21 -0
  87. package/dist/llm/thinking-binding.entity.functions.js +16 -0
  88. package/dist/llm/thinking-binding.entity.js +11 -0
  89. package/dist/llm/utilityLlm.d.ts +14 -5
  90. package/dist/llm/utilityLlm.js +18 -13
  91. package/dist/plugin.adapter.d.ts +94 -6
  92. package/dist/plugin.adapter.js +18 -13
  93. package/dist/ports/auth-store.port.d.ts +73 -0
  94. package/dist/ports/auth-store.port.js +24 -0
  95. package/dist/ports/host-credentials.port.d.ts +2 -1
  96. package/dist/ports/model-call-completion.port.d.ts +9 -0
  97. package/dist/ports/model-call-completion.port.js +2 -0
  98. package/dist/ports/model-endpoints.port.d.ts +60 -26
  99. package/dist/ports/model-endpoints.port.js +17 -3
  100. package/dist/ports/model-retry-policy.port.d.ts +17 -0
  101. package/dist/ports/model-retry-policy.port.js +3 -0
  102. package/dist/ports/model-settings.port.d.ts +32 -0
  103. package/dist/ports/model-settings.port.js +4 -0
  104. package/dist/ports/models-transport.port.d.ts +9 -2
  105. package/dist/ports/models-transport.port.js +4 -1
  106. package/dist/ports/prompt-cache-frame.port.d.ts +21 -0
  107. package/dist/ports/prompt-cache-frame.port.js +4 -0
  108. package/dist/ports/settings-store.port.d.ts +70 -0
  109. package/dist/ports/settings-store.port.js +33 -0
  110. package/dist/ports/thinking-binding.port.d.ts +7 -0
  111. package/dist/ports/thinking-binding.port.js +4 -0
  112. package/dist/settings/localSettings.d.ts +2 -1
  113. package/dist/settings/localSettings.js +17 -20
  114. package/dist/settings/memory-settings.adapter.d.ts +3 -1
  115. package/dist/settings/memory-settings.adapter.js +1 -1
  116. package/dist/settings/roleView.d.ts +3 -1
  117. package/dist/settings/roleView.js +1 -1
  118. package/dist/settings/settings-store.conformance.d.ts +47 -0
  119. package/dist/settings/settings-store.conformance.js +100 -0
  120. package/dist/settings/settings-value.entity.d.ts +10 -2
  121. package/dist/settings/settings-value.entity.functions.d.ts +31 -9
  122. package/dist/settings/settings-value.entity.functions.js +5 -6
  123. package/dist/storage/json-file.d.ts +18 -0
  124. package/dist/storage/json-file.js +26 -0
  125. package/dist/transport/http-proxy.entity.d.ts +17 -3
  126. package/dist/transport/model-request.conformance.d.ts +13 -0
  127. package/dist/transport/model-request.conformance.js +36 -0
  128. package/dist/transport/models-transport.adapter.d.ts +18 -17
  129. package/dist/transport/models-transport.adapter.js +28 -31
  130. package/dist/transport/resilient-http-client.adapter.d.ts +73 -0
  131. package/dist/transport/resilient-http-client.adapter.js +177 -0
  132. package/package.json +11 -5
  133. package/dist/auth/anthropicOAuth.test.d.ts +0 -1
  134. package/dist/auth/credential-refresh.entity.functions.test.d.ts +0 -1
  135. package/dist/auth/localAuth.test.d.ts +0 -1
  136. package/dist/auth/memory-auth.adapter.test.d.ts +0 -1
  137. package/dist/llm/compat.test.d.ts +0 -1
  138. package/dist/llm/modelCatalog.test.d.ts +0 -1
  139. package/dist/llm/openAiCodexWebSocket.test.d.ts +0 -1
  140. package/dist/llm/providers.test.d.ts +0 -1
  141. package/dist/llm/retry.test.d.ts +0 -1
  142. package/dist/llm/router.test.d.ts +0 -1
  143. package/dist/llm/sse.test.d.ts +0 -1
  144. package/dist/plugin.adapter.test.d.ts +0 -1
  145. package/dist/settings/localSettings.test.d.ts +0 -1
  146. package/dist/settings/memory-settings.adapter.test.d.ts +0 -1
  147. package/dist/settings/roleView.test.d.ts +0 -1
  148. package/dist/settings/settings-value.entity.functions.test.d.ts +0 -1
  149. package/dist/transport/http-proxy.entity.functions.test.d.ts +0 -1
  150. package/dist/transport/models-transport.adapter.test.d.ts +0 -1
  151. 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
- /** A settings value as a config file holds it: a selection or other text, a switch, a count. */
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
- /** The settings field each model role is kept under. */
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 "@xandreed/core";
3
- import type { ModelRole, SettingsKey } from "@xandreed/core";
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
- /** The field each model role is kept under. */
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
- /** Settings from a config record: values of the wrong type read as unset; the legacy `openAiReasoningEffort` key backs `reasoningEffort`. */
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>"`, a switch true/on/false/off, an effort one the
20
- * models accept, a count a positive integer. A value that does not parse is
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
- /** A role's selection, checked: `None` clears the role; a value must be `"<provider>:<modelId>"`. */
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
- /** `settings` with one key set from its string form (checked, as `set` takes it), or cleared with `None`. */
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
- /** `settings` with one role's selection set (checked, as `setRole` takes it), or cleared with `None`. */
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 { EngineSettings, parseModelSelection, SETTINGS_KEYS, SettingsError } from "@xandreed/core";
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 = (value) => typeof value === "string" && value.length > 0 ? Option.some(value) : Option.none(), asBoolean = (value) => typeof value === "boolean" ? Option.some(value) : Option.none(), asNumber = (value) => typeof value === "number" && Number.isFinite(value) ? Option.some(value) : Option.none(), asReasoningEffort = (value) => value === "none" || value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "max" ? Option.some(value) : Option.none();
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("sandbox", "viMode", (flag) => raw === "true" || raw === "on" ? Effect.succeed(!0) : raw === "false" || raw === "off" ? Effect.succeed(!1) : refuse(`${flag} must be true/false \u2014 got "${raw}"`)), Match.whenOr("reasoningEffort", "codeReasoningEffort", "fastReasoningEffort", (effort) => Option.match(asReasoningEffort(raw), {
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
- /** A proxy URL as an option takes it: a string, or a `Redacted` one when it carries credentials. */
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
- /** A proxy URL that cannot be used: not a URL, or not `http://` / `https://`. `source` names where it came from. */
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
- /** A proxy applies, but this runtime cannot send the call through it: the call is refused rather than sent direct. */
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 { Context, Scope } from "effect";
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
- * The transport a proxy setting calls for, or `None` when no proxy applies
19
- * and nothing has to be held direct: then the plugin adds no transport and
20
- * every call uses the runtime's own fetch, as it did before the plugin knew
21
- * proxies (a `FetchHttpClient.Fetch` the host provides still reaches the
22
- * provider SDKs). With the proxy turned off (`*`) on Node it is a transport
23
- * over undici's direct agent, since Node's global fetch and agents apply the
24
- * environment's proxy under `NODE_USE_ENV_PROXY`. A proxy URL that cannot
25
- * be used fails with `ProxyInvalid`; a runtime that is neither Node nor Bun
26
- * fails with `ProxyUnsupported` when a proxy applies. Its connection pools
27
- * close with the scope.
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`). With no proxy at all it is the global fetch, unchanged;
36
- * with `noProxy: "*"` on Node, undici's direct agent. A proxy URL that cannot
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 { ModelsTransport } from "../ports/models-transport.port.js";
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, turnsProxyOff, usesProxy } from "./http-proxy.entity.functions.js";
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", codeOf = (error) => ("code" in error) ? error.code : void 0, tunnelOnce = (connect, proxy) => (options, callback) => connect(options, (...result) => {
31
+ }), isConnector = (value) => typeof value === "function", tunnelOnce = (connect, proxy) => (options, callback) => connect(options, (...result) => {
31
32
  const [error] = result;
32
- return error !== null && codeOf(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
+ 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 under a proxy setting, could not be loaded: ${describeFailure(cause)}` })
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
- Effect.promise(() => dispatcher.destroy().then(() => {
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, (dispatcher) => Effect.promise(() => dispatcher.destroy().then(() => {
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 modelsTransportFor = (options) => Effect.gen(function* () {
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", () => turnsProxyOff(config) ? Effect.map(nodeDirectTransport, Option.some) : Effect.succeedNone), Match.orElse(() => Effect.succeedNone));
91
- return Option.some(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({
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
- }), ModelsTransportLive = (options = {}) => Layer.effect(ModelsTransport, Effect.map(modelsTransportFor(options), Option.getOrElse(() => directTransport))), transportFetch = (transport) => Object.assign((input, init) => input instanceof Request ? input.arrayBuffer().then((body) => transport.fetch(input.url, {
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,