@xandreed/plugin-models 0.13.0 → 0.14.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.
Files changed (67) hide show
  1. package/README.md +1 -1
  2. package/dist/auth/anthropicOAuth.d.ts +16 -2
  3. package/dist/auth/anthropicOAuth.js +14 -11
  4. package/dist/auth/credential-input.entity.d.ts +34 -0
  5. package/dist/auth/credential-input.entity.functions.d.ts +8 -0
  6. package/dist/auth/credential-input.entity.functions.js +9 -0
  7. package/dist/auth/credential-input.entity.js +20 -0
  8. package/dist/auth/credential-refresh.entity.d.ts +9 -0
  9. package/dist/auth/credential-refresh.entity.functions.d.ts +15 -0
  10. package/dist/auth/credential-refresh.entity.functions.js +12 -0
  11. package/dist/auth/credential-refresh.entity.functions.test.d.ts +1 -0
  12. package/dist/auth/credential-refresh.entity.js +2 -0
  13. package/dist/auth/localAuth.d.ts +2 -1
  14. package/dist/auth/localAuth.js +41 -43
  15. package/dist/auth/memory-auth.adapter.d.ts +27 -0
  16. package/dist/auth/memory-auth.adapter.js +47 -0
  17. package/dist/auth/memory-auth.adapter.test.d.ts +1 -0
  18. package/dist/auth/openAiCodexOAuth.d.ts +3 -2
  19. package/dist/auth/openAiCodexOAuth.js +9 -8
  20. package/dist/index.d.ts +23 -6
  21. package/dist/index.js +28 -1
  22. package/dist/llm/compat.d.ts +39 -8
  23. package/dist/llm/compat.js +80 -29
  24. package/dist/llm/modelCatalog.d.ts +20 -1
  25. package/dist/llm/modelCatalog.js +19 -7
  26. package/dist/llm/openAiCodex.d.ts +4 -1
  27. package/dist/llm/openAiCodex.js +2 -2
  28. package/dist/llm/openAiCodexWebSocket.d.ts +23 -3
  29. package/dist/llm/openAiCodexWebSocket.js +125 -71
  30. package/dist/llm/openAiCodexWebSocket.test.d.ts +1 -0
  31. package/dist/llm/providers.d.ts +18 -4
  32. package/dist/llm/providers.js +49 -24
  33. package/dist/llm/router.d.ts +11 -1
  34. package/dist/llm/router.js +55 -31
  35. package/dist/llm/sse.d.ts +25 -3
  36. package/dist/llm/sse.js +8 -8
  37. package/dist/llm/utilityLlm.d.ts +1 -1
  38. package/dist/llm/utilityLlm.js +4 -4
  39. package/dist/plugin.adapter.d.ts +64 -4
  40. package/dist/plugin.adapter.js +48 -10
  41. package/dist/ports/host-credentials.port.d.ts +21 -0
  42. package/dist/ports/host-credentials.port.js +4 -0
  43. package/dist/ports/model-endpoints.port.d.ts +16 -3
  44. package/dist/ports/model-endpoints.port.js +7 -3
  45. package/dist/ports/models-transport.port.d.ts +46 -0
  46. package/dist/ports/models-transport.port.js +4 -0
  47. package/dist/settings/localSettings.js +6 -46
  48. package/dist/settings/memory-settings.adapter.d.ts +10 -0
  49. package/dist/settings/memory-settings.adapter.js +11 -0
  50. package/dist/settings/memory-settings.adapter.test.d.ts +1 -0
  51. package/dist/settings/settings-value.entity.d.ts +7 -0
  52. package/dist/settings/settings-value.entity.functions.d.ts +29 -0
  53. package/dist/settings/settings-value.entity.functions.js +43 -0
  54. package/dist/settings/settings-value.entity.functions.test.d.ts +1 -0
  55. package/dist/settings/settings-value.entity.js +2 -0
  56. package/dist/transport/http-proxy.entity.d.ts +90 -0
  57. package/dist/transport/http-proxy.entity.functions.d.ts +51 -0
  58. package/dist/transport/http-proxy.entity.functions.js +112 -0
  59. package/dist/transport/http-proxy.entity.functions.test.d.ts +1 -0
  60. package/dist/transport/http-proxy.entity.js +42 -0
  61. package/dist/transport/models-transport.adapter.d.ts +48 -0
  62. package/dist/transport/models-transport.adapter.js +102 -0
  63. package/dist/transport/models-transport.adapter.test.d.ts +1 -0
  64. package/dist/transport/recording-proxy.testing.d.ts +33 -0
  65. package/dist/transport/undici-fetch.adapter.d.ts +13 -0
  66. package/dist/transport/undici-fetch.adapter.js +1 -0
  67. package/package.json +4 -3
@@ -1,27 +1,65 @@
1
1
  import { homedir } from "node:os";
2
2
  import { LanguageModel } from "effect/ai";
3
- import { Effect, Layer, Option, Schema } from "effect";
4
- import { AuthStore, definePlugin, EngineSettings, ModelCatalog, SessionEnvironment, SettingsError, SettingsStore, UtilityLlm } from "@xandreed/core";
3
+ import { Config as EffectConfig, Context, Effect, Layer, Match, Option, Redacted, Schema } from "effect";
4
+ import { AuthStore, definePlugin, EngineSettings, HarnessError, ModelCatalog, SessionEnvironment, SettingsError, SettingsStore, UtilityLlm } from "@xandreed/core";
5
+ import { CredentialInputs } from "./auth/credential-input.entity.js";
6
+ import { credentialsOf } from "./auth/credential-input.entity.functions.js";
5
7
  import { LocalAuthStoreLive } from "./auth/localAuth.js";
8
+ import { MemoryAuthStoreLive } from "./auth/memory-auth.adapter.js";
6
9
  import { LanguageModelLive } from "./llm/router.js";
7
10
  import { UtilityLlmLive } from "./llm/utilityLlm.js";
8
11
  import { ConfiguredModelCatalogLive } from "./llm/modelCatalog.js";
9
12
  import { LocalSettingsStoreLive } from "./settings/localSettings.js";
13
+ import { MemorySettingsStoreLive } from "./settings/memory-settings.adapter.js";
14
+ import { HostCredentials } from "./ports/host-credentials.port.js";
10
15
  import { CurrentModelEndpoints, ModelEndpoints } from "./ports/model-endpoints.port.js";
11
- const Config = Schema.Struct({ model: Schema.String, fastModel: Schema.String, fallbackModel: Schema.String, inheritPrevious: Schema.Boolean, endpoints: ModelEndpoints });
16
+ import { ModelsTransport } from "./ports/models-transport.port.js";
17
+ import { ProxyUrlOption } from "./transport/http-proxy.entity.js";
18
+ import { modelsTransportFor } from "./transport/models-transport.adapter.js";
19
+ const Config = Schema.Struct({
20
+ model: Schema.String,
21
+ fastModel: Schema.String,
22
+ fallbackModel: Schema.String,
23
+ inheritPrevious: Schema.Boolean,
24
+ endpoints: ModelEndpoints,
25
+ storage: Schema.Literals(["local", "memory"]),
26
+ credentials: CredentialInputs,
27
+ credentialEnv: Schema.Record(Schema.String, Schema.String),
28
+ proxy: ProxyUrlOption,
29
+ noProxy: Schema.String
30
+ }).annotate({ messageUnexpectedKey: "not an option of the models plugin" }).check(Schema.makeFilter((config) => config.storage === "memory" || Object.keys(config.credentials).length === 0 && Object.keys(config.credentialEnv).length === 0 ? void 0 : 'credentials and credentialEnv need storage: "memory"')), optional = (value) => value.length === 0 ? Option.none() : Option.some(value), overlaid = (config) => (previous) => new EngineSettings({
31
+ ...previous,
32
+ model: Option.orElse(optional(config.model), () => previous.model),
33
+ fastModel: Option.orElse(optional(config.fastModel), () => previous.fastModel),
34
+ fallbackModel: Option.orElse(optional(config.fallbackModel), () => previous.fallbackModel)
35
+ }), memoryStores = (config) => Layer.unwrap(Effect.gen(function* () {
36
+ const fromEnv = yield* Effect.forEach(Object.entries(config.credentialEnv), ([provider, name]) => EffectConfig.option(EffectConfig.Redacted(name)).pipe(Effect.map(Option.map((key) => [provider, { type: "api_key", key: Redacted.value(key) }])))), seed = new Map([...fromEnv.flatMap(Option.toArray), ...credentialsOf(config.credentials)]);
37
+ return Layer.mergeAll(MemoryAuthStoreLive(seed), MemorySettingsStoreLive(overlaid(config)(new EngineSettings({}))));
38
+ })), readOnly = (what) => () => Effect.fail(new SettingsError({ message: `Edit the models plugin configuration to change ${what}` })), localStores = (config, workspace) => {
39
+ const home = homedir(), files = Effect.service(SettingsStore).pipe(Effect.provide(LocalSettingsStoreLive(workspace, home))), settings = Layer.effect(SettingsStore, Effect.map(files, (stored) => SettingsStore.of({
40
+ load: config.inheritPrevious ? Effect.map(stored.load, overlaid(config)) : Effect.succeed(overlaid(config)(new EngineSettings({}))),
41
+ setRole: readOnly("a model"),
42
+ set: readOnly("settings")
43
+ })));
44
+ return Layer.mergeAll(settings, LocalAuthStoreLive(workspace, home, ".efferent/runtime", config.inheritPrevious ? ".efferent" : void 0));
45
+ }, optionError = (message) => new HarnessError({ code: "config.options", plugin: "@xandreed/plugin-models", message }), transportLayer = (config) => Effect.flatMap(Effect.serviceOption(ModelsTransport), Option.match({
46
+ onSome: (host) => Redacted.isRedacted(config.proxy) || config.proxy.length > 0 || config.noProxy.length > 0 ? Effect.fail(optionError("the host provides ModelsTransport, so the proxy and noProxy options would not apply: give the proxy to the host's transport")) : Effect.succeed(Layer.succeed(ModelsTransport, host)),
47
+ onNone: () => Effect.succeed(Layer.effectContext(modelsTransportFor({ proxy: config.proxy, noProxy: config.noProxy }).pipe(Effect.map(Option.match({
48
+ onNone: () => Context.empty(),
49
+ onSome: (transport) => Context.make(ModelsTransport, transport)
50
+ })), Effect.mapError((error) => optionError(error.message)))))
51
+ }));
12
52
  export const modelsPlugin = definePlugin({
13
53
  id: "@xandreed/plugin-models",
14
- version: "0.13.0",
54
+ version: "0.14.0",
55
+ scope: "runtime",
15
56
  config: Config,
16
- defaults: { model: "", fastModel: "", fallbackModel: "", inheritPrevious: !0, endpoints: {} },
57
+ defaults: { model: "", fastModel: "", fallbackModel: "", inheritPrevious: !0, endpoints: {}, storage: "local", credentials: {}, credentialEnv: {}, proxy: "", noProxy: "" },
17
58
  requires: [SessionEnvironment],
59
+ optional: [HostCredentials, ModelsTransport],
18
60
  provides: [LanguageModel.LanguageModel, UtilityLlm, AuthStore, SettingsStore, ModelCatalog],
19
61
  layer: (config) => Layer.unwrap(Effect.gen(function* () {
20
- const { workspace } = yield* SessionEnvironment, previous = config.inheritPrevious ? yield* SettingsStore.pipe(Effect.flatMap((settings) => settings.load), Effect.provide(LocalSettingsStoreLive(workspace, homedir()))) : new EngineSettings({}), optional = (value) => value.length === 0 ? Option.none() : Option.some(value), settings = Layer.succeed(SettingsStore, {
21
- load: Effect.succeed(new EngineSettings({ ...previous, model: Option.orElse(optional(config.model), () => previous.model), fastModel: Option.orElse(optional(config.fastModel), () => previous.fastModel), fallbackModel: Option.orElse(optional(config.fallbackModel), () => previous.fallbackModel) })),
22
- setRole: () => Effect.fail(new SettingsError({ message: "Edit the models plugin configuration to change a model" })),
23
- set: () => Effect.fail(new SettingsError({ message: "Edit the models plugin configuration to change settings" }))
24
- }), base = Layer.mergeAll(settings, LocalAuthStoreLive(workspace, homedir(), ".efferent/runtime", config.inheritPrevious ? ".efferent" : void 0), Layer.succeed(CurrentModelEndpoints, config.endpoints));
62
+ const { workspace } = yield* SessionEnvironment, host = yield* Effect.serviceOption(HostCredentials), stores = yield* Match.value(config.storage).pipe(Match.when("memory", () => Effect.succeed(memoryStores(config))), Match.when("local", () => Option.isSome(host) ? Effect.fail(optionError('the host provides HostCredentials, which needs storage: "memory"')) : Effect.succeed(localStores(config, workspace))), Match.exhaustive), transport = yield* transportLayer(config), base = Layer.mergeAll(stores, Layer.succeed(CurrentModelEndpoints, config.endpoints)).pipe(Layer.provideMerge(transport));
25
63
  return Layer.mergeAll(LanguageModelLive, UtilityLlmLive, ConfiguredModelCatalogLive).pipe(Layer.provideMerge(base));
26
64
  }))
27
65
  });
@@ -0,0 +1,21 @@
1
+ import { Context } from "effect";
2
+ import type { Effect, Option } from "effect";
3
+ import type { AuthError, ProviderId } from "@xandreed/core";
4
+ import type { CredentialInput } from "../auth/credential-input.entity.js";
5
+ declare const HostCredentials_base: Context.ServiceClass<HostCredentials, "@xandreed/plugin-models/HostCredentials", {
6
+ readonly load: Effect.Effect<ReadonlyMap<string, CredentialInput>, AuthError>;
7
+ readonly save: (provider: ProviderId, credential: Option.Option<CredentialInput>) => Effect.Effect<void, AuthError>;
8
+ }>;
9
+ /**
10
+ * Credentials a host keeps itself (a secrets manager, a database, its own
11
+ * session store), for the models plugin with `storage: "memory"`. `load` is
12
+ * read once, when the plugin starts; `save` is called after every change the
13
+ * AuthStore makes — a login (`set`), a logout (`None`), an OAuth token
14
+ * refreshed in memory — so the host can keep the new value. Secrets cross
15
+ * this port `Redacted`. A failed `save` fails a login or logout (memory is
16
+ * left as it was) but not a refresh: the refreshed token is already the
17
+ * only valid one, so it stays in memory and the failure is logged.
18
+ */
19
+ export declare class HostCredentials extends HostCredentials_base {
20
+ }
21
+ export {};
@@ -0,0 +1,4 @@
1
+ import { Context } from "effect";
2
+
3
+ export class HostCredentials extends Context.Service()("@xandreed/plugin-models/HostCredentials") {
4
+ }
@@ -5,28 +5,41 @@ import { Context, Schema } from "effect";
5
5
  * optional — a local server or a private gateway may need none. It comes
6
6
  * from `apiKey`, then the environment variable `apiKeyEnv` names, then the
7
7
  * AuthStore's key for `<name>`; with none, no Authorization header is sent.
8
+ * An endpoint with its own key (`apiKey`, or a set `apiKeyEnv`) never reads
9
+ * the AuthStore; an empty one (`apiKey: ""`, or a variable set to "") sends
10
+ * no key. A host's code passes `apiKey` as a `Redacted` string so it
11
+ * prints as `<redacted>` in errors and graph fingerprints; JSON
12
+ * configuration names an environment variable in `apiKeyEnv` instead.
8
13
  * `standardOnly` (default true) sends only standard chat-completions fields.
14
+ * `images` (default false) says its models read images: a user's images are
15
+ * then sent as `image_url` parts, and a tool result's in one user message
16
+ * after the tool results; without it they are label lines.
17
+ * The endpoint's usage is read in full: cache reads and writes from
18
+ * `prompt_tokens_details`, reasoning from `completion_tokens_details`.
9
19
  */
10
20
  export declare const ModelEndpoint: Schema.Struct<{
11
21
  readonly url: Schema.String;
12
- readonly apiKey: Schema.optionalKey<Schema.String>;
22
+ readonly apiKey: Schema.optionalKey<Schema.Union<readonly [Schema.String, Schema.Redacted<Schema.String>]>>;
13
23
  readonly apiKeyEnv: Schema.optionalKey<Schema.String>;
14
24
  readonly standardOnly: Schema.optionalKey<Schema.Boolean>;
25
+ readonly images: Schema.optionalKey<Schema.Boolean>;
15
26
  }>;
16
27
  export type ModelEndpoint = typeof ModelEndpoint.Type;
17
28
  /** Endpoints by provider name; a name that matches a built-in provider replaces it. */
18
29
  export declare const ModelEndpoints: Schema.$Record<Schema.String, Schema.Struct<{
19
30
  readonly url: Schema.String;
20
- readonly apiKey: Schema.optionalKey<Schema.String>;
31
+ readonly apiKey: Schema.optionalKey<Schema.Union<readonly [Schema.String, Schema.Redacted<Schema.String>]>>;
21
32
  readonly apiKeyEnv: Schema.optionalKey<Schema.String>;
22
33
  readonly standardOnly: Schema.optionalKey<Schema.Boolean>;
34
+ readonly images: Schema.optionalKey<Schema.Boolean>;
23
35
  }>>;
24
36
  export type ModelEndpoints = typeof ModelEndpoints.Type;
25
37
  /** The endpoints the models plugin was configured with; none by default. */
26
38
  export declare const CurrentModelEndpoints: Context.Reference<{
27
39
  readonly [x: string]: {
28
40
  readonly url: string;
29
- readonly apiKey?: string;
41
+ readonly images?: boolean;
42
+ readonly apiKey?: string | import("effect/Redacted").Redacted<string>;
30
43
  readonly apiKeyEnv?: string;
31
44
  readonly standardOnly?: boolean;
32
45
  };
@@ -1,9 +1,13 @@
1
1
  import { Context, Schema } from "effect";
2
2
  export const ModelEndpoint = Schema.Struct({
3
3
  url: Schema.String,
4
- apiKey: Schema.optionalKey(Schema.String),
4
+ apiKey: Schema.optionalKey(Schema.Union([
5
+ Schema.String,
6
+ Schema.Redacted(Schema.String).annotate({ message: "apiKey is a string or a Redacted string" })
7
+ ]).annotate({ message: "apiKey is a string or a Redacted string" })),
5
8
  apiKeyEnv: Schema.optionalKey(Schema.String),
6
- standardOnly: Schema.optionalKey(Schema.Boolean)
7
- }), ModelEndpoints = Schema.Record(Schema.String, ModelEndpoint), CurrentModelEndpoints = Context.Reference("@xandreed/plugin-models/CurrentModelEndpoints", {
9
+ standardOnly: Schema.optionalKey(Schema.Boolean),
10
+ images: Schema.optionalKey(Schema.Boolean)
11
+ }).annotate({ messageUnexpectedKey: "not an option of a model endpoint" }), ModelEndpoints = Schema.Record(Schema.String, ModelEndpoint), CurrentModelEndpoints = Context.Reference("@xandreed/plugin-models/CurrentModelEndpoints", {
8
12
  defaultValue: () => ({})
9
13
  });
@@ -0,0 +1,46 @@
1
+ import { Context } from "effect";
2
+ import type { Effect, Option, Redacted } from "effect";
3
+ import type { Agent } from "node:http";
4
+ import type { ProxyUnsupported } from "../transport/http-proxy.entity.js";
5
+ /**
6
+ * A fetch for the models plugin's HTTP calls: a URL (string or `URL`) and a
7
+ * `RequestInit`, as the provider clients, the OpenAI-compatible client and
8
+ * the OAuth token requests call it. Any `fetch` is one.
9
+ */
10
+ export type ModelsFetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
11
+ /**
12
+ * How a WebSocket reaches its target. `Agent` is for Node only: the agent
13
+ * the `ws` client connects with (one that tunnels through the proxy with
14
+ * CONNECT, or a plain one for a direct connection). `Proxy` is for Bun only:
15
+ * the proxy URL Bun's own `WebSocket` takes as its `proxy` option. A route
16
+ * the runtime cannot use (`Proxy` outside Bun, `Agent` on Bun, whose `ws`
17
+ * stand-in takes neither) fails the call rather than connecting past the
18
+ * proxy.
19
+ */
20
+ export type WebSocketProxy = {
21
+ readonly _tag: "Agent";
22
+ readonly agent: Agent;
23
+ } | {
24
+ readonly _tag: "Proxy";
25
+ readonly url: Redacted.Redacted<string>;
26
+ };
27
+ declare const ModelsTransport_base: Context.ServiceClass<ModelsTransport, "@xandreed/plugin-models/ModelsTransport", {
28
+ readonly fetch: ModelsFetch;
29
+ readonly webSocketAgent?: (target: URL) => Effect.Effect<Option.Option<WebSocketProxy>, ProxyUnsupported>;
30
+ }>;
31
+ /**
32
+ * The network path of the models plugin: every HTTP call it makes (the
33
+ * provider SDKs, the OpenAI-compatible client, OAuth token requests) goes
34
+ * through `fetch`, and the OpenAI subscription's WebSocket asks
35
+ * `webSocketAgent` how to reach its target: `None` connects the runtime's
36
+ * default way (direct, unless the runtime applies an environment proxy
37
+ * itself, as Node's global agents do under `NODE_USE_ENV_PROXY`),
38
+ * `ProxyUnsupported` refuses (a proxy applies that this runtime cannot
39
+ * tunnel a WebSocket through). Without `webSocketAgent`, WebSockets connect
40
+ * the default way. `ModelsTransportLive` builds one from a proxy setting and
41
+ * the environment; a host provides its own (a fetch with client
42
+ * certificates, say) through `Agent.define({ services })`.
43
+ */
44
+ export declare class ModelsTransport extends ModelsTransport_base {
45
+ }
46
+ export {};
@@ -0,0 +1,4 @@
1
+ import { Context } from "effect";
2
+
3
+ export class ModelsTransport extends Context.Service()("@xandreed/plugin-models/ModelsTransport") {
4
+ }
@@ -1,33 +1,12 @@
1
1
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
  import { Effect, Layer, Option } from "effect";
4
- import { asJsonRecord, EngineSettings, parseJsonWarn, parseModelSelection, SettingsError, SettingsStore } from "@xandreed/core";
4
+ import { asJsonRecord, parseJsonWarn, SettingsError, SettingsStore } from "@xandreed/core";
5
+ import { coerceSettingValue, ROLE_FIELDS, roleSelection, settingsFromRecord } from "./settings-value.entity.functions.js";
5
6
  const configPaths = (cwd, home) => [
6
7
  join(home, ".efferent", "config.json"),
7
8
  join(cwd, ".efferent", "config.json")
8
- ], readConfig = (path) => Effect.tryPromise({ try: () => readFile(path, "utf-8"), catch: () => "missing" }).pipe(Effect.flatMap((text) => parseJsonWarn(text, path)), Effect.map(asJsonRecord), Effect.orElseSucceed(() => ({}))), 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(), coerceSettingValue = (key, raw) => {
9
- if (key === "fallbackModel")
10
- return Option.match(parseModelSelection(raw), {
11
- onNone: () => Effect.fail(new SettingsError({
12
- message: `fallbackModel must be "<provider>:<modelId>" \u2014 got "${raw}"`
13
- })),
14
- onSome: () => Effect.succeed(raw)
15
- });
16
- if (key === "sandbox" || key === "viMode") {
17
- if (raw === "true" || raw === "on")
18
- return Effect.succeed(!0);
19
- if (raw === "false" || raw === "off")
20
- return Effect.succeed(!1);
21
- return Effect.fail(new SettingsError({ message: `${key} must be true/false \u2014 got "${raw}"` }));
22
- }
23
- if (key === "reasoningEffort" || key === "codeReasoningEffort" || key === "fastReasoningEffort")
24
- return Option.match(asReasoningEffort(raw), {
25
- onNone: () => Effect.fail(new SettingsError({ message: `reasoningEffort is not supported: "${raw}"` })),
26
- onSome: Effect.succeed
27
- });
28
- const parsed = Number(raw);
29
- return Number.isInteger(parsed) && parsed >= 1 ? Effect.succeed(parsed) : Effect.fail(new SettingsError({ message: `${key} must be a positive integer \u2014 got "${raw}"` }));
30
- }, writeKey = (cwd, key, value) => Effect.gen(function* () {
9
+ ], readConfig = (path) => Effect.tryPromise({ try: () => readFile(path, "utf-8"), catch: () => "missing" }).pipe(Effect.flatMap((text) => parseJsonWarn(text, path)), Effect.map(asJsonRecord), Effect.orElseSucceed(() => ({}))), writeKey = (cwd, key, value) => Effect.gen(function* () {
31
10
  const path = join(cwd, ".efferent", "config.json"), current = yield* readConfig(path), next = Option.match(value, {
32
11
  onNone: () => Object.fromEntries(Object.entries(current).filter(([k]) => k !== key)),
33
12
  onSome: (v) => ({ ...current, [key]: v })
@@ -35,33 +14,14 @@ const configPaths = (cwd, home) => [
35
14
  yield* writeConfigAtomic(path, next);
36
15
  });
37
16
  export const LocalSettingsStoreLive = (cwd, home) => Layer.succeed(SettingsStore, {
38
- load: Effect.gen(function* () {
39
- const merged = (yield* Effect.forEach(configPaths(cwd, home), readConfig)).reduce((acc, c) => ({ ...acc, ...c }), {});
40
- return new EngineSettings({
41
- model: asString(merged.model),
42
- codeModel: asString(merged.codeModel),
43
- fastModel: asString(merged.fastModel),
44
- fallbackModel: asString(merged.fallbackModel),
45
- sandbox: asBoolean(merged.sandbox),
46
- viMode: asBoolean(merged.viMode),
47
- maxAttempts: asNumber(merged.maxAttempts),
48
- budgetMillis: asNumber(merged.budgetMillis),
49
- reasoningEffort: Option.orElse(asReasoningEffort(merged.reasoningEffort), () => asReasoningEffort(merged.openAiReasoningEffort)),
50
- codeReasoningEffort: asReasoningEffort(merged.codeReasoningEffort),
51
- fastReasoningEffort: asReasoningEffort(merged.fastReasoningEffort)
52
- });
53
- }),
54
- setRole: (role, selection) => writeKey(cwd, ROLE_KEYS[role], selection),
17
+ load: Effect.forEach(configPaths(cwd, home), readConfig).pipe(Effect.map((configs) => settingsFromRecord(configs.reduce((acc, c) => ({ ...acc, ...c }), {})))),
18
+ setRole: (role, selection) => roleSelection(role, selection).pipe(Effect.flatMap((checked) => writeKey(cwd, ROLE_FIELDS[role], checked))),
55
19
  set: (key, value) => Option.match(value, {
56
20
  onNone: () => writeKey(cwd, key, Option.none()),
57
21
  onSome: (raw) => coerceSettingValue(key, raw).pipe(Effect.flatMap((coerced) => writeKey(cwd, key, Option.some(coerced))))
58
22
  })
59
23
  });
60
- const ROLE_KEYS = {
61
- general: "model",
62
- code: "codeModel",
63
- fast: "fastModel"
64
- }, writeConfigAtomic = (path, config) => Effect.tryPromise({
24
+ const writeConfigAtomic = (path, config) => Effect.tryPromise({
65
25
  try: async () => {
66
26
  await mkdir(dirname(path), { recursive: !0 });
67
27
  const tmp = `${path}.tmp-${process.pid}`;
@@ -0,0 +1,10 @@
1
+ import { Layer } from "effect";
2
+ import { EngineSettings, SettingsStore } from "@xandreed/core";
3
+ /**
4
+ * `MemorySettingsStoreLive(initial)` — a SettingsStore that never touches
5
+ * disk: one writable value for the layer's lifetime, starting at `initial`.
6
+ * `setRole` and `set` check their value the way the file store does
7
+ * (a `"<provider>:<modelId>"` selection; each key's string form) and refuse
8
+ * one that does not parse, leaving the settings as they were.
9
+ */
10
+ export declare const MemorySettingsStoreLive: (initial?: EngineSettings) => Layer.Layer<SettingsStore>;
@@ -0,0 +1,11 @@
1
+ import { Effect, Layer, SynchronizedRef } from "effect";
2
+ import { EngineSettings, SettingsStore } from "@xandreed/core";
3
+ import { applyRole, applySetting } from "./settings-value.entity.functions.js";
4
+ export const MemorySettingsStoreLive = (initial = new EngineSettings({})) => Layer.effect(SettingsStore, Effect.gen(function* () {
5
+ const state = yield* SynchronizedRef.make(initial);
6
+ return SettingsStore.of({
7
+ load: SynchronizedRef.get(state),
8
+ setRole: (role, selection) => SynchronizedRef.updateEffect(state, (settings) => applyRole(settings, role, selection)),
9
+ set: (key, value) => SynchronizedRef.updateEffect(state, (settings) => applySetting(settings, key, value))
10
+ });
11
+ }));
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,7 @@
1
+ import { Schema } from "effect";
2
+ /** A settings value as a config file holds it: a selection or other text, a switch, a count. */
3
+ export declare const SettingValue: Schema.Union<readonly [Schema.String, Schema.Boolean, Schema.Number]>;
4
+ export type SettingValue = typeof SettingValue.Type;
5
+ /** The settings field each model role is kept under. */
6
+ export declare const RoleField: Schema.Literals<readonly ["model", "codeModel", "fastModel"]>;
7
+ export type RoleField = typeof RoleField.Type;
@@ -0,0 +1,29 @@
1
+ import { Effect, Option } from "effect";
2
+ import { EngineSettings, SettingsError } from "@xandreed/core";
3
+ import type { ModelRole, SettingsKey } from "@xandreed/core";
4
+ import type { RoleField, SettingValue } from "./settings-value.entity.js";
5
+ /**
6
+ * The settings vocabulary every SettingsStore of this package shares: how a
7
+ * config record reads as `EngineSettings`, how a value's string form (what
8
+ * `set` receives: "true", "3", "provider:model") is checked and coerced, and
9
+ * how one key or role changes a settings value.
10
+ */
11
+ /** The field each model role is kept under. */
12
+ 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`. */
14
+ export declare const settingsFromRecord: (record: Readonly<Record<string, unknown>>) => EngineSettings;
15
+ /** Settings as a config record: the set fields only. */
16
+ export declare const recordOfSettings: (settings: EngineSettings) => Readonly<Record<string, SettingValue>>;
17
+ /**
18
+ * 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
21
+ * refused, not written and then ignored on load.
22
+ */
23
+ 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>"`. */
25
+ 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`. */
27
+ 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`. */
29
+ export declare const applyRole: (settings: EngineSettings, role: ModelRole, selection: Option.Option<string>) => Effect.Effect<EngineSettings, SettingsError>;
@@ -0,0 +1,43 @@
1
+ import { Effect, Match, Option } from "effect";
2
+ import { EngineSettings, parseModelSelection, SETTINGS_KEYS, SettingsError } from "@xandreed/core";
3
+ export const ROLE_FIELDS = {
4
+ general: "model",
5
+ code: "codeModel",
6
+ fast: "fastModel"
7
+ };
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
+ export const settingsFromRecord = (record) => new EngineSettings({
10
+ model: asString(record.model),
11
+ codeModel: asString(record.codeModel),
12
+ fastModel: asString(record.fastModel),
13
+ fallbackModel: asString(record.fallbackModel),
14
+ sandbox: asBoolean(record.sandbox),
15
+ viMode: asBoolean(record.viMode),
16
+ maxAttempts: asNumber(record.maxAttempts),
17
+ budgetMillis: asNumber(record.budgetMillis),
18
+ reasoningEffort: Option.orElse(asReasoningEffort(record.reasoningEffort), () => asReasoningEffort(record.openAiReasoningEffort)),
19
+ codeReasoningEffort: asReasoningEffort(record.codeReasoningEffort),
20
+ fastReasoningEffort: asReasoningEffort(record.fastReasoningEffort)
21
+ }), recordOfSettings = (settings) => Object.fromEntries(FIELDS.flatMap((field) => Option.match(fieldOf(settings, field), {
22
+ onNone: () => [],
23
+ onSome: (value) => [[field, value]]
24
+ })));
25
+ const fieldOf = (settings, field) => settings[field], refuse = (message) => Effect.fail(new SettingsError({ message }));
26
+ export const coerceSettingValue = (key, raw) => Match.value(key).pipe(Match.when("fallbackModel", () => Option.match(parseModelSelection(raw), {
27
+ onNone: () => refuse(`fallbackModel must be "<provider>:<modelId>" \u2014 got "${raw}"`),
28
+ 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), {
30
+ onNone: () => refuse(`${effort} is not supported: "${raw}"`),
31
+ onSome: (value) => Effect.succeed(value)
32
+ })), 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, {
33
+ onNone: () => Effect.succeed(Option.none()),
34
+ onSome: (raw) => Option.isSome(parseModelSelection(raw)) ? Effect.succeed(Option.some(raw)) : refuse(`the ${role} model must be "<provider>:<modelId>" \u2014 got "${raw}"`)
35
+ });
36
+ const withField = (settings, field, value) => {
37
+ const others = Object.fromEntries(Object.entries(recordOfSettings(settings)).filter(([key]) => key !== field));
38
+ return settingsFromRecord(Option.match(value, { onNone: () => others, onSome: (set) => ({ ...others, [field]: set }) }));
39
+ };
40
+ export const applySetting = (settings, key, value) => Option.match(value, {
41
+ onNone: () => Effect.succeed(withField(settings, key, Option.none())),
42
+ onSome: (raw) => coerceSettingValue(key, raw).pipe(Effect.map((coerced) => withField(settings, key, Option.some(coerced))))
43
+ }), applyRole = (settings, role, selection) => roleSelection(role, selection).pipe(Effect.map((checked) => withField(settings, ROLE_FIELDS[role], checked)));
@@ -0,0 +1,2 @@
1
+ import { Schema } from "effect";
2
+ export const SettingValue = Schema.Union([Schema.String, Schema.Boolean, Schema.Number]), RoleField = Schema.Literals(["model", "codeModel", "fastModel"]);
@@ -0,0 +1,90 @@
1
+ import { Schema } from "effect";
2
+ /** A proxy URL as an option takes it: a string, or a `Redacted` one when it carries credentials. */
3
+ export declare const ProxyUrlOption: Schema.Union<readonly [Schema.String, Schema.Redacted<Schema.String>]>;
4
+ export type ProxyUrlOption = typeof ProxyUrlOption.Type;
5
+ /**
6
+ * The proxy a host gives the models plugin, as its `proxy` and `noProxy`
7
+ * options. `proxy` is one URL for every model and OAuth call (`http://` or
8
+ * `https://`; a value without a scheme reads as `http://`); it may carry
9
+ * credentials (`http://user:pass@proxy:8080`), so a host's code can pass it
10
+ * `Redacted`. Empty or absent, the environment's `https_proxy` /
11
+ * `HTTPS_PROXY` and `http_proxy` / `HTTP_PROXY` apply. `noProxy` lists the
12
+ * hosts that go direct, in NO_PROXY syntax, added to the environment's
13
+ * `no_proxy` / `NO_PROXY`; `"*"` turns the proxy off.
14
+ */
15
+ export declare const HttpProxyOptions: Schema.Struct<{
16
+ readonly proxy: Schema.optionalKey<Schema.Union<readonly [Schema.String, Schema.Redacted<Schema.String>]>>;
17
+ readonly noProxy: Schema.optionalKey<Schema.String>;
18
+ }>;
19
+ export type HttpProxyOptions = typeof HttpProxyOptions.Type;
20
+ /**
21
+ * The proxy variables of the environment, as read (an empty value counts as
22
+ * unset). Lower case is read before upper case, as undici (Node's
23
+ * environment proxy) and curl read them. `ALL_PROXY` is not read: SOCKS
24
+ * proxies are not supported.
25
+ */
26
+ export declare const ProxyEnvironment: Schema.Struct<{
27
+ readonly https_proxy: Schema.optionalKey<Schema.String>;
28
+ readonly HTTPS_PROXY: Schema.optionalKey<Schema.String>;
29
+ readonly http_proxy: Schema.optionalKey<Schema.String>;
30
+ readonly HTTP_PROXY: Schema.optionalKey<Schema.String>;
31
+ readonly no_proxy: Schema.optionalKey<Schema.String>;
32
+ readonly NO_PROXY: Schema.optionalKey<Schema.String>;
33
+ }>;
34
+ export type ProxyEnvironment = typeof ProxyEnvironment.Type;
35
+ /** The environment variables a `ProxyEnvironment` is read from. */
36
+ export declare const PROXY_VARIABLES: readonly ["https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY", "no_proxy", "NO_PROXY"];
37
+ /**
38
+ * One NO_PROXY entry: every host (`*`); a host name and its subdomains
39
+ * (`example.com`, `.example.com` and `*.example.com` alike); or an IP
40
+ * address, exact (`10.1.2.3`, `::1`, `[::1]`) or a range (`10.0.0.0/8`,
41
+ * `fd00::/8`, as `prefix` leading bits). A name or an exact address may
42
+ * carry a port (`example.com:8443`, `[::1]:8080`), which then must match.
43
+ */
44
+ export declare const NoProxyRule: Schema.Union<readonly [Schema.TaggedStruct<"Everything", {}>, Schema.TaggedStruct<"Domain", {
45
+ readonly name: Schema.String;
46
+ readonly port: Schema.Option<Schema.Int>;
47
+ }>, Schema.TaggedStruct<"Address", {
48
+ readonly bytes: Schema.$Array<Schema.Int>;
49
+ readonly prefix: Schema.Int;
50
+ readonly port: Schema.Option<Schema.Int>;
51
+ }>]>;
52
+ export type NoProxyRule = typeof NoProxyRule.Type;
53
+ declare const ProxyConfig_base: Schema.Class<ProxyConfig, Schema.Struct<{
54
+ readonly https: Schema.Option<Schema.Redacted<Schema.String>>;
55
+ readonly http: Schema.Option<Schema.Redacted<Schema.String>>;
56
+ readonly noProxy: Schema.$Array<Schema.Union<readonly [Schema.TaggedStruct<"Everything", {}>, Schema.TaggedStruct<"Domain", {
57
+ readonly name: Schema.String;
58
+ readonly port: Schema.Option<Schema.Int>;
59
+ }>, Schema.TaggedStruct<"Address", {
60
+ readonly bytes: Schema.$Array<Schema.Int>;
61
+ readonly prefix: Schema.Int;
62
+ readonly port: Schema.Option<Schema.Int>;
63
+ }>]>>;
64
+ }>, {}>;
65
+ /**
66
+ * A resolved proxy setting: the proxy for `https:` (and `wss:`) targets,
67
+ * the one for `http:` (and `ws:`) targets, and the hosts that go direct.
68
+ * Proxy URLs stay `Redacted`, so a printed setting never shows credentials.
69
+ * Loopback hosts always go direct.
70
+ */
71
+ export declare class ProxyConfig extends ProxyConfig_base {
72
+ }
73
+ declare const ProxyInvalid_base: Schema.Class<ProxyInvalid, Schema.TaggedStruct<"ProxyInvalid", {
74
+ readonly source: Schema.String;
75
+ readonly message: Schema.String;
76
+ }>, 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. */
78
+ export declare class ProxyInvalid extends ProxyInvalid_base {
79
+ }
80
+ declare const ProxyUnsupported_base: Schema.Class<ProxyUnsupported, Schema.TaggedStruct<"ProxyUnsupported", {
81
+ readonly message: Schema.String;
82
+ }>, 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. */
84
+ export declare class ProxyUnsupported extends ProxyUnsupported_base {
85
+ }
86
+ /** The first Bun whose native `WebSocket` sends through its `proxy` option (1.3.0 to 1.3.5 connect directly). */
87
+ export declare const BUN_WEBSOCKET_PROXY_VERSION = "1.3.6";
88
+ /** The first Node whose `https.Agent` tunnels through its `proxyEnv` option (earlier ones connect directly). */
89
+ export declare const NODE_WEBSOCKET_PROXY_VERSION = "24.5.0";
90
+ export {};
@@ -0,0 +1,51 @@
1
+ import { Option, Redacted, Result } from "effect";
2
+ import { ProxyConfig, ProxyInvalid } from "./http-proxy.entity.js";
3
+ import type { HttpProxyOptions, NoProxyRule, ProxyEnvironment } from "./http-proxy.entity.js";
4
+ /**
5
+ * Credentials in the URLs of `text` (`scheme://user:pass@host`) replaced by
6
+ * `<redacted>`, for error messages that may quote a proxy URL.
7
+ */
8
+ export declare const redactCredentials: (text: string) => string;
9
+ /** A proxy URL as a message may show it: its scheme and host, its credentials (if any) as `<redacted>`. */
10
+ export declare const displayProxy: (url: Redacted.Redacted<string>) => string;
11
+ /**
12
+ * One proxy URL, checked: `None` when blank; a value without a scheme
13
+ * (`proxy.corp:8080`) reads as `http://`; anything that is not an `http://`
14
+ * or `https://` URL with a host fails with `ProxyInvalid` naming `source`,
15
+ * credentials redacted.
16
+ */
17
+ export declare const proxyUrl: (raw: string, source: string) => Result.Result<Option.Option<Redacted.Redacted<string>>, ProxyInvalid>;
18
+ /** The IPv4 address `text` spells, as its 4 bytes. */
19
+ export declare const parseIpv4: (text: string) => Option.Option<ReadonlyArray<number>>;
20
+ /** The IPv6 address `text` spells (brackets and a zone id allowed), as its 16 bytes. */
21
+ export declare const parseIpv6: (text: string) => Option.Option<ReadonlyArray<number>>;
22
+ /** A host is loopback: `localhost` (and its subdomains), `127.0.0.0/8`, `::1`, or IPv4 loopback mapped into IPv6. */
23
+ export declare const isLoopback: (hostname: string) => boolean;
24
+ /** A NO_PROXY value (entries separated by commas or white space), parsed. */
25
+ export declare const parseNoProxy: (value: string) => ReadonlyArray<NoProxyRule>;
26
+ /** Whether a NO_PROXY rule sends `target` direct. */
27
+ export declare const bypassesProxy: (rules: ReadonlyArray<NoProxyRule>, target: URL) => boolean;
28
+ /**
29
+ * The proxy settings of the models plugin's options over the environment's:
30
+ * an explicit `proxy` serves every target; otherwise `https_proxy` (then
31
+ * `HTTPS_PROXY`) serves `https:` targets, `http_proxy` (then `HTTP_PROXY`)
32
+ * `http:` ones, and an `https:` target falls back to the `http:` proxy, as
33
+ * undici's environment proxy (Node's, under `NODE_USE_ENV_PROXY`) does. The
34
+ * direct hosts are `no_proxy` (then `NO_PROXY`) and the `noProxy` option
35
+ * together; with `*` among them the environment's proxy variables are not
36
+ * read at all. Lower case comes first, as undici and curl read them. A proxy
37
+ * URL that cannot be used fails with `ProxyInvalid`.
38
+ */
39
+ export declare const resolveProxy: (options: HttpProxyOptions, env: ProxyEnvironment) => Result.Result<ProxyConfig, ProxyInvalid>;
40
+ /** Whether any target can go through a proxy. */
41
+ export declare const usesProxy: (config: ProxyConfig) => boolean;
42
+ /** Whether the setting turns the proxy off for every host (`*` in `noProxy` or `NO_PROXY`). */
43
+ export declare const turnsProxyOff: (config: ProxyConfig) => boolean;
44
+ /**
45
+ * The proxy a request to `target` goes through, or `None` for direct:
46
+ * loopback hosts and the `noProxy` hosts go direct; `https:` and `wss:`
47
+ * targets use the https proxy, `http:` and `ws:` ones the http proxy.
48
+ */
49
+ export declare const proxyFor: (target: URL, config: ProxyConfig) => Option.Option<Redacted.Redacted<string>>;
50
+ /** Whether a runtime `version` (`24.16.0`, `1.3.6`) is at least `minimum`; an unreadable one is not. */
51
+ export declare const atLeastVersion: (version: string, minimum: string) => boolean;