@xandreed/plugin-models 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/README.md +1 -1
  2. package/dist/auth/anthropicOAuth.d.ts +45 -11
  3. package/dist/auth/anthropicOAuth.js +23 -23
  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 +52 -0
  7. package/dist/auth/credential-input.entity.functions.d.ts +18 -0
  8. package/dist/auth/credential-input.entity.functions.js +12 -0
  9. package/dist/auth/credential-input.entity.js +22 -0
  10. package/dist/auth/credential-refresh.entity.d.ts +17 -0
  11. package/dist/auth/credential-refresh.entity.functions.d.ts +57 -0
  12. package/dist/auth/credential-refresh.entity.functions.js +21 -0
  13. package/dist/auth/credential-refresh.entity.js +2 -0
  14. package/dist/auth/localAuth.d.ts +12 -2
  15. package/dist/auth/localAuth.js +58 -86
  16. package/dist/auth/memory-auth.adapter.d.ts +35 -0
  17. package/dist/auth/memory-auth.adapter.js +35 -0
  18. package/dist/auth/openAiCodexOAuth.d.ts +28 -6
  19. package/dist/auth/openAiCodexOAuth.js +22 -32
  20. package/dist/auth/token-response.entity.d.ts +21 -0
  21. package/dist/auth/token-response.entity.functions.d.ts +20 -0
  22. package/dist/auth/token-response.entity.functions.js +10 -0
  23. package/dist/auth/token-response.entity.js +10 -0
  24. package/dist/compat/index.d.ts +2 -0
  25. package/dist/compat/index.js +1 -0
  26. package/dist/conformance/index.d.ts +6 -0
  27. package/dist/conformance/index.js +3 -0
  28. package/dist/index.d.ts +30 -13
  29. package/dist/index.js +25 -50
  30. package/dist/llm/chat-wire.entity.d.ts +123 -0
  31. package/dist/llm/chat-wire.entity.functions.d.ts +28 -0
  32. package/dist/llm/chat-wire.entity.functions.js +10 -0
  33. package/dist/llm/chat-wire.entity.js +37 -0
  34. package/dist/llm/compat-telemetry.adapter.d.ts +6 -0
  35. package/dist/llm/compat-telemetry.adapter.js +21 -0
  36. package/dist/llm/compat.d.ts +84 -33
  37. package/dist/llm/compat.js +188 -121
  38. package/dist/llm/model-completion.adapter.d.ts +49 -0
  39. package/dist/llm/model-completion.adapter.js +77 -0
  40. package/dist/llm/model-completion.entity.d.ts +4 -0
  41. package/dist/llm/model-completion.entity.functions.d.ts +3 -0
  42. package/dist/llm/model-completion.entity.functions.js +7 -0
  43. package/dist/llm/model-completion.entity.js +7 -0
  44. package/dist/llm/model-retry-policy.entity.d.ts +37 -0
  45. package/dist/llm/model-retry-policy.entity.functions.d.ts +16 -0
  46. package/dist/llm/model-retry-policy.entity.functions.js +33 -0
  47. package/dist/llm/model-retry-policy.entity.js +23 -0
  48. package/dist/llm/model-settings.entity.d.ts +85 -0
  49. package/dist/llm/model-settings.entity.functions.d.ts +11 -0
  50. package/dist/llm/model-settings.entity.functions.js +49 -0
  51. package/dist/llm/model-settings.entity.js +28 -0
  52. package/dist/llm/model-usage.entity.d.ts +61 -0
  53. package/dist/llm/model-usage.entity.functions.d.ts +11 -0
  54. package/dist/llm/model-usage.entity.functions.js +24 -0
  55. package/dist/llm/model-usage.entity.js +19 -0
  56. package/dist/llm/modelCatalog.d.ts +47 -7
  57. package/dist/llm/modelCatalog.js +31 -57
  58. package/dist/llm/openAiCodex.d.ts +18 -7
  59. package/dist/llm/openAiCodex.js +57 -45
  60. package/dist/llm/openAiCodexWebSocket.d.ts +60 -7
  61. package/dist/llm/openAiCodexWebSocket.js +113 -105
  62. package/dist/llm/prompt-cache-frame.entity.d.ts +18 -0
  63. package/dist/llm/prompt-cache-frame.entity.js +10 -0
  64. package/dist/llm/prompt-cache.entity.d.ts +57 -0
  65. package/dist/llm/prompt-cache.entity.functions.d.ts +8 -0
  66. package/dist/llm/prompt-cache.entity.functions.js +73 -0
  67. package/dist/llm/prompt-cache.entity.js +27 -0
  68. package/dist/llm/provider-client.adapter.d.ts +9 -0
  69. package/dist/llm/provider-client.adapter.js +60 -0
  70. package/dist/llm/provider-failure.entity.d.ts +42 -0
  71. package/dist/llm/provider-failure.entity.functions.d.ts +8 -0
  72. package/dist/llm/provider-failure.entity.functions.js +67 -0
  73. package/dist/llm/provider-failure.entity.js +20 -0
  74. package/dist/llm/provider-profile.entity.d.ts +79 -0
  75. package/dist/llm/provider-profile.entity.functions.d.ts +23 -0
  76. package/dist/llm/provider-profile.entity.functions.js +83 -0
  77. package/dist/llm/provider-profile.entity.js +10 -0
  78. package/dist/llm/providers.d.ts +125 -29
  79. package/dist/llm/providers.js +311 -117
  80. package/dist/llm/retry.d.ts +11 -47
  81. package/dist/llm/retry.js +33 -52
  82. package/dist/llm/router.d.ts +56 -28
  83. package/dist/llm/router.js +125 -88
  84. package/dist/llm/sse.d.ts +58 -29
  85. package/dist/llm/sse.js +47 -39
  86. package/dist/llm/thinking-binding.entity.d.ts +49 -0
  87. package/dist/llm/thinking-binding.entity.functions.d.ts +21 -0
  88. package/dist/llm/thinking-binding.entity.functions.js +16 -0
  89. package/dist/llm/thinking-binding.entity.js +11 -0
  90. package/dist/llm/utilityLlm.d.ts +14 -5
  91. package/dist/llm/utilityLlm.js +18 -13
  92. package/dist/plugin.adapter.d.ts +154 -6
  93. package/dist/plugin.adapter.js +53 -10
  94. package/dist/ports/auth-store.port.d.ts +73 -0
  95. package/dist/ports/auth-store.port.js +24 -0
  96. package/dist/ports/host-credentials.port.d.ts +22 -0
  97. package/dist/ports/host-credentials.port.js +4 -0
  98. package/dist/ports/model-call-completion.port.d.ts +9 -0
  99. package/dist/ports/model-call-completion.port.js +2 -0
  100. package/dist/ports/model-endpoints.port.d.ts +66 -19
  101. package/dist/ports/model-endpoints.port.js +22 -4
  102. package/dist/ports/model-retry-policy.port.d.ts +17 -0
  103. package/dist/ports/model-retry-policy.port.js +3 -0
  104. package/dist/ports/model-settings.port.d.ts +32 -0
  105. package/dist/ports/model-settings.port.js +4 -0
  106. package/dist/ports/models-transport.port.d.ts +53 -0
  107. package/dist/ports/models-transport.port.js +7 -0
  108. package/dist/ports/prompt-cache-frame.port.d.ts +21 -0
  109. package/dist/ports/prompt-cache-frame.port.js +4 -0
  110. package/dist/ports/settings-store.port.d.ts +70 -0
  111. package/dist/ports/settings-store.port.js +33 -0
  112. package/dist/ports/thinking-binding.port.d.ts +7 -0
  113. package/dist/ports/thinking-binding.port.js +4 -0
  114. package/dist/settings/localSettings.d.ts +2 -1
  115. package/dist/settings/localSettings.js +18 -61
  116. package/dist/settings/memory-settings.adapter.d.ts +12 -0
  117. package/dist/settings/memory-settings.adapter.js +11 -0
  118. package/dist/settings/roleView.d.ts +3 -1
  119. package/dist/settings/roleView.js +1 -1
  120. package/dist/settings/settings-store.conformance.d.ts +47 -0
  121. package/dist/settings/settings-store.conformance.js +100 -0
  122. package/dist/settings/settings-value.entity.d.ts +15 -0
  123. package/dist/settings/settings-value.entity.functions.d.ts +51 -0
  124. package/dist/settings/settings-value.entity.functions.js +42 -0
  125. package/dist/settings/settings-value.entity.js +2 -0
  126. package/dist/storage/json-file.d.ts +18 -0
  127. package/dist/storage/json-file.js +26 -0
  128. package/dist/transport/http-proxy.entity.d.ts +104 -0
  129. package/dist/transport/http-proxy.entity.functions.d.ts +51 -0
  130. package/dist/transport/http-proxy.entity.functions.js +112 -0
  131. package/dist/transport/http-proxy.entity.js +42 -0
  132. package/dist/transport/model-request.conformance.d.ts +13 -0
  133. package/dist/transport/model-request.conformance.js +36 -0
  134. package/dist/transport/models-transport.adapter.d.ts +49 -0
  135. package/dist/transport/models-transport.adapter.js +99 -0
  136. package/dist/transport/resilient-http-client.adapter.d.ts +73 -0
  137. package/dist/transport/resilient-http-client.adapter.js +177 -0
  138. package/dist/transport/undici-fetch.adapter.d.ts +13 -0
  139. package/dist/transport/undici-fetch.adapter.js +1 -0
  140. package/package.json +13 -6
  141. package/dist/auth/anthropicOAuth.test.d.ts +0 -1
  142. package/dist/auth/localAuth.test.d.ts +0 -1
  143. package/dist/llm/compat.test.d.ts +0 -1
  144. package/dist/llm/modelCatalog.test.d.ts +0 -1
  145. package/dist/llm/providers.test.d.ts +0 -1
  146. package/dist/llm/retry.test.d.ts +0 -1
  147. package/dist/llm/router.test.d.ts +0 -1
  148. package/dist/llm/sse.test.d.ts +0 -1
  149. package/dist/plugin.adapter.test.d.ts +0 -1
  150. package/dist/settings/localSettings.test.d.ts +0 -1
  151. package/dist/settings/roleView.test.d.ts +0 -1
package/dist/llm/retry.js CHANGED
@@ -1,58 +1,39 @@
1
1
  import { AiError } from "effect/ai";
2
- import { Duration, Effect, Ref, Schedule, Stream } from "effect";
3
- import { CurrentEmptyResponseTolerance } from "@xandreed/core";
4
- export const LLM_REQUEST_TIMEOUT_MS = 300000, MAX_HONORED_RETRY_AFTER_MS = 60000;
5
- const retryAfterMillis = (headers) => {
6
- const raw = headers?.["retry-after"];
7
- if (raw === void 0)
8
- return;
9
- const seconds = Number(raw);
10
- if (Number.isFinite(seconds))
11
- return seconds * 1000;
12
- const at = Date.parse(raw);
13
- return Number.isNaN(at) ? void 0 : Math.max(0, at - Date.now());
14
- }, rateLimited = (waitMs) => waitMs !== void 0 && waitMs > MAX_HONORED_RETRY_AFTER_MS ? "permanent" : "transient";
15
- export const classifyLlmError = (error) => {
16
- if (!AiError.isAiError(error)) {
17
- const e = error;
18
- return e?._tag === "HttpClientError" && e.reason?._tag === "TransportError" ? "transient" : "permanent";
19
- }
2
+ import { HttpClientError } from "effect/http";
3
+ import { Effect, Option, Predicate, Stream } from "effect";
4
+ import { CurrentEmptyResponseTolerance, modelFailureOf, stampAiErrorFailure } from "@xandreed/core";
5
+ import { modelRetryDefaults } from "./model-retry-policy.entity.js";
6
+ export const LLM_REQUEST_TIMEOUT_MS = modelRetryDefaults.requestTimeoutMs, LLM_CALL_BUDGET_MS = modelRetryDefaults.totalTimeoutMs, classifyLlmError = (error, _now = 0) => {
7
+ const failure = modelFailureOf(error);
8
+ if (Option.exists(failure, (kind) => ["ContextOverflow", "Authentication", "InvalidRequest", "Quota", "InvalidResponse"].includes(kind)))
9
+ return "permanent";
10
+ if (Option.exists(failure, (kind) => ["RateLimit", "Transient", "Timeout"].includes(kind)))
11
+ return "transient";
12
+ if (!AiError.isAiError(error))
13
+ return HttpClientError.isHttpClientError(error) && error.reason._tag === "TransportError" ? "transient" : "permanent";
20
14
  const reason = error.reason, response = "http" in reason ? reason.http?.response : void 0;
21
- if (response !== void 0) {
22
- if (response.status === 429)
23
- return rateLimited(retryAfterMillis(response.headers));
24
- return response.status >= 500 ? "transient" : "permanent";
25
- }
26
- if (reason._tag === "RateLimitError")
27
- return rateLimited(reason.retryAfter === void 0 ? void 0 : Duration.toMillis(reason.retryAfter));
15
+ if (response !== void 0)
16
+ return response.status === 429 || response.status >= 500 ? "transient" : "permanent";
28
17
  return reason._tag === "InternalProviderError" || reason._tag === "NetworkError" || reason._tag === "UnknownError" ? "transient" : "permanent";
29
18
  };
30
- const fastRetries = Schedule.max([
31
- Schedule.exponential(Duration.seconds(1)).pipe(Schedule.jittered),
32
- Schedule.recurs(3)
33
- ]), routerError = (method, description) => AiError.make({ module: "Router", method, reason: new AiError.UnknownError({ description }) }), timeoutError = (label, method, timeoutMs) => routerError(method, `the ${label} request exceeded ${timeoutMs / 1000}s and was cut off`), emptyError = (label, method) => routerError(method, `${label} returned an empty response (no text, tool call, or reasoning) \u2014 treated as a transient provider failure`), isContentPart = (part) => {
34
- const t = part?.type;
35
- return t === "text" || t === "tool-call" || t === "reasoning";
19
+ const emptyError = (label, method) => stampAiErrorFailure(AiError.make({
20
+ module: "Router",
21
+ method,
22
+ reason: new AiError.UnknownError({ description: `${label} returned an empty response (no text, tool call, or reasoning) \u2014 treated as a transient provider failure` })
23
+ }), "Transient"), partType = (part) => Predicate.hasProperty(part, "type") && Predicate.isString(part.type) ? part.type : "", isContentPart = (part) => {
24
+ const type = partType(part);
25
+ return type === "text" || type === "tool-call" || type === "reasoning";
36
26
  };
37
- export const rejectEmptyResponse = (label) => (effect) => Effect.service(CurrentEmptyResponseTolerance).pipe(Effect.flatMap((tolerant) => tolerant ? effect : effect.pipe(Effect.filterOrFail((res) => res.content.some(isContentPart), () => emptyError(label, "generateText"))))), retryableLlm = (label) => (effect) => effect.pipe(Effect.timeoutOrElse({
38
- duration: Duration.millis(LLM_REQUEST_TIMEOUT_MS),
39
- orElse: () => Effect.fail(timeoutError(label, "generateText", LLM_REQUEST_TIMEOUT_MS))
40
- }), Effect.retry({
41
- schedule: fastRetries,
42
- while: (error) => classifyLlmError(error) === "transient"
43
- }), Effect.tapError((error) => classifyLlmError(error) === "transient" ? Effect.logWarning(`${label}: transient failure exhausted retries: ${String(error)}`) : Effect.void));
44
- const isContentPartEncoded = (part) => {
45
- const p = part;
46
- if (p === null || typeof p !== "object")
47
- return !1;
48
- if (p.type === "tool-call")
27
+ export const rejectEmptyResponse = (label) => (effect) => Effect.service(CurrentEmptyResponseTolerance).pipe(Effect.flatMap((tolerant) => tolerant ? effect : effect.pipe(Effect.filterOrFail((res) => Option.isSome(modelFailureOf(res.content)) || res.content.some(isContentPart), () => emptyError(label, "generateText")))));
28
+ const carriesMetadata = (part) => Predicate.hasProperty(part, "metadata") && Predicate.isObject(part.metadata) && Object.keys(part.metadata).length > 0, isStreamContentPart = (part) => {
29
+ const type = partType(part);
30
+ if (type === "tool-call")
49
31
  return !0;
50
- return (p.type === "text-delta" || p.type === "reasoning-delta") && typeof p.delta === "string" && p.delta.length > 0;
51
- }, isFinishPartEncoded = (part) => part?.type === "finish";
52
- export const retryableLlmStream = (label, timeoutMs = LLM_REQUEST_TIMEOUT_MS) => (stream) => Stream.unwrap(Effect.gen(function* () {
53
- const contentSeen = yield* Ref.make(!1);
54
- return stream.pipe(Stream.mapEffect((part) => isContentPartEncoded(part) ? Ref.set(contentSeen, !0).pipe(Effect.as(part)) : isFinishPartEncoded(part) ? Ref.get(contentSeen).pipe(Effect.flatMap((seen) => seen ? Effect.succeed(part) : Effect.fail(emptyError(label, "streamText")))) : Effect.succeed(part)), Stream.timeoutOrElse({
55
- duration: Duration.millis(timeoutMs),
56
- orElse: () => Stream.fail(timeoutError(label, "streamText", timeoutMs))
57
- }), Stream.retry(fastRetries.pipe(Schedule.while(({ input }) => Ref.get(contentSeen).pipe(Effect.map((seen) => !seen && classifyLlmError(input) === "transient"))))), Stream.tapError((error) => classifyLlmError(error) === "transient" ? Effect.logWarning(`${label}: transient stream failure was final: ${String(error)}`) : Effect.void));
58
- }));
32
+ if ((type === "reasoning-start" || type === "reasoning-delta" || type === "reasoning-end") && carriesMetadata(part))
33
+ return !0;
34
+ return (type === "text-delta" || type === "reasoning-delta") && Predicate.hasProperty(part, "delta") && Predicate.isString(part.delta) && part.delta.length > 0;
35
+ }, isFinishPart = (part) => partType(part) === "finish";
36
+ export const rejectEmptyStream = (label) => (stream) => Stream.unwrap(Effect.map(Effect.service(CurrentEmptyResponseTolerance), (tolerant) => tolerant ? stream : stream.pipe(Stream.mapAccumEffect(() => !1, (seen, part) => {
37
+ const content = seen || Option.isSome(modelFailureOf(part)) || isStreamContentPart(part);
38
+ return !content && isFinishPart(part) ? Effect.fail(emptyError(label, "streamText")) : Effect.succeed([content, [part]]);
39
+ }))));
@@ -1,34 +1,47 @@
1
- import { LanguageModel } from "effect/ai";
1
+ import { AiError, LanguageModel } from "effect/ai";
2
+ import type { Response } from "effect/ai";
2
3
  import { HttpClient } from "effect/http";
3
4
  import { Effect, Layer, Option, Stream } from "effect";
4
- import { AuthStore, SettingsStore } from "@xandreed/core";
5
+ import { AuthStore } from "../ports/auth-store.port.js";
6
+ import { SettingsStore } from "../ports/settings-store.port.js";
5
7
  import type { ModelSelection } from "@xandreed/core";
6
- /** Build + call one provider generateText for an explicit selection.
7
- * `isFallback` only labels telemetry — the fallback rung must be visible
8
- * in Grafana without changing the metric identities. */
9
- export declare const generateWith: (selection: ModelSelection, options: unknown, isFallback?: boolean) => Effect.Effect<{
8
+ import type { EngineSettings } from "../ports/settings-store.port.js";
9
+ import type { PromptOptions, ProviderServices } from "./providers.js";
10
+ import type { ModelRequestCall } from "../transport/resilient-http-client.adapter.js";
11
+ /** Apply the persisted model effort only when a dedicated agent has not
12
+ * already pinned a complete call policy in its own execution profile. */
13
+ export declare const withConfiguredEffort: <A, E, R>(effect: Effect.Effect<A, E, R>, effort: EngineSettings["reasoningEffort"]) => Effect.Effect<A, E, R>;
14
+ /** One routed call's settled response, as {@link generateOver} returns it. */
15
+ type Generated = {
10
16
  readonly content: ReadonlyArray<unknown>;
11
17
  readonly usage: unknown;
12
- }, unknown, AuthStore | HttpClient.HttpClient>;
18
+ };
19
+ /** Build + call one provider generateText for an explicit selection, over
20
+ * the given provider services (a routed model's, read where its layer was
21
+ * built). `isFallback` only labels telemetry — the fallback rung must be
22
+ * visible in Grafana without changing the metric identities. `budget` is
23
+ * the routed call's, shared with its fallback rung; a call made without
24
+ * one gets its own. */
25
+ export declare const generateOver: (services: ProviderServices) => <O extends PromptOptions>(selection: ModelSelection, options: O, isFallback?: boolean, budget?: ModelRequestCall) => Effect.Effect<Generated, AiError.AiError, AuthStore | HttpClient.HttpClient>;
26
+ /** {@link generateOver} over the provider services where it runs
27
+ * (`CurrentModelEndpoints`, `CurrentModelSettings`, `ModelsTransport`), for a call made outside a
28
+ * routed model. */
29
+ export declare const generateWith: <O extends PromptOptions>(selection: ModelSelection, options: O, isFallback?: boolean, budget?: ModelRequestCall) => Effect.Effect<Generated, AiError.AiError, AuthStore | HttpClient.HttpClient>;
30
+ /** The fallback rung uses its reserved last attempt only after eligible primary
31
+ * HTTP failures exhaust their allowance. A committed response, permanent failure
32
+ * or elapsed deadline cannot start another model call. */
33
+ export declare const withFallbackRung: <A, E, R>(primary: ModelSelection, fallback: Option.Option<ModelSelection>, call: (selection: ModelSelection, isFallback: boolean) => Effect.Effect<A, E, R>, budget: ModelRequestCall) => Effect.Effect<A, E | AiError.AiError, R>;
13
34
  /**
14
- * The fallback rung: run the primary; if it fails TRANSIENT after the fast
15
- * retries exhausted and a DIFFERENT fallback selection is configured, run
16
- * the call once more there. Permanent errors (bad request, decode) pass
17
- * through — a fallback can't fix those; an unset or identical fallback is a
18
- * no-op. Exported pure-in-shape so the rung's decision table is testable
19
- * without building providers.
20
- */
21
- export declare const withFallbackRung: <A, R>(primary: ModelSelection, fallback: Option.Option<ModelSelection>, call: (selection: ModelSelection, isFallback: boolean) => Effect.Effect<A, unknown, R>) => Effect.Effect<A, unknown, R>;
22
- /**
23
- * Stamp the RESOLVED selection onto the finish part — the trail must answer
24
- * "which model produced this message" from the db alone. The response MUST be
35
+ * Stamp the RESOLVED selection onto the finish part (core's
36
+ * `stampResponseModel`) — the trail must answer "which model produced this
37
+ * message" from the db alone. The response MUST be
25
38
  * rebuilt as a real `GenerateTextResponse`: it's a class whose
26
39
  * text/finishReason/usage are prototype GETTERS, so a `{...res}` spread
27
40
  * silently strips them — finishReason read as undefined and the loop treated
28
41
  * every tool turn as "done" (the one-tool-call-then-dead bug, live-caught).
29
42
  */
30
43
  export declare const stampResponse: (res: {
31
- readonly content: ReadonlyArray<unknown>;
44
+ readonly content: ReadonlyArray<Response.AnyPart>;
32
45
  }, label: string) => LanguageModel.GenerateTextResponse<never>;
33
46
  /**
34
47
  * The stream twin of `generateWith`'s telemetry: accumulate over the flowing
@@ -37,19 +50,34 @@ export declare const stampResponse: (res: {
37
50
  * content. Outcome + duration finalize per terminal state (`onDone` /
38
51
  * `tapErrorCause`), so retries inside count once, like the settled path.
39
52
  */
40
- export declare const tapStreamTelemetry: (label: string) => <E, R>(stream: Stream.Stream<unknown, E, R>) => Stream.Stream<unknown, E, R>;
41
- /** Build + call one provider streamText for an explicit selection — the
42
- * stream twin of {@link generateWith}: same resolution, same option
43
- * shaping (anthropic cache breakpoints inherit for free), same stamp, the
44
- * stream-aware retry, and finish-part telemetry. */
45
- export declare const streamWith: (selection: ModelSelection, options: unknown) => Stream.Stream<unknown, unknown, AuthStore | HttpClient.HttpClient>;
53
+ export declare const tapStreamTelemetry: (label: string) => <A, E, R>(stream: Stream.Stream<A, E, R>) => Stream.Stream<A, E, R>;
54
+ export declare const throughTransport: (services: ProviderServices) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>;
55
+ /**
56
+ * The provider services of a routed model's layer, read once where it is
57
+ * built: the configured endpoints (`CurrentModelEndpoints`), per-model controls
58
+ * (`CurrentModelSettings`), the transport
59
+ * (`ModelsTransport`), and an OpenAI subscription session cache of the
60
+ * layer's own: each conversation keeps its session id while the layer
61
+ * lives, bounded to the conversations used last, and two layers (two agents
62
+ * in one process) never share one.
63
+ */
64
+ export declare const layerProviderServices: Effect.Effect<ProviderServices>;
46
65
  /**
47
66
  * `LanguageModelLive` — the engine loop's `LanguageModel`, routed per call.
48
67
  * Requires `SettingsStore` + `AuthStore`; brings its own fetch-backed
49
- * `HttpClient` so drivers don't have to.
68
+ * `HttpClient` so drivers don't have to. Reads `CurrentModelEndpoints`,
69
+ * `CurrentModelSettings` and `ModelsTransport` once, where it is built: built over a transport (as the
70
+ * models plugin builds it), every call goes through that transport.
71
+ *
72
+ * @public
50
73
  */
51
- export declare const LanguageModelLive: Layer.Layer<LanguageModel.LanguageModel, never, SettingsStore | AuthStore>;
74
+ export declare const LanguageModelLive: Layer.Layer<LanguageModel.LanguageModel, never, AuthStore | SettingsStore>;
52
75
  /** A profile-pinned routed model. Unlike LanguageModelLive it never consults
53
76
  * SettingsStore; dedicated agents use this so global role edits cannot
54
- * silently change an evalled execution profile. */
77
+ * silently change an evalled execution profile. Like it, it reads its
78
+ * provider services once, where it is built.
79
+ *
80
+ * @public
81
+ */
55
82
  export declare const LanguageModelSelectionLive: (primary: ModelSelection, fallback: Option.Option<ModelSelection>) => Layer.Layer<LanguageModel.LanguageModel, never, AuthStore>;
83
+ export {};
@@ -1,20 +1,31 @@
1
1
  import { AiError, LanguageModel } from "effect/ai";
2
2
  import { FetchHttpClient, HttpClient } from "effect/http";
3
- import { Clock, Duration, Effect, Layer, Metric, Option, Ref, Stream } from "effect";
4
- import { AuthStore, CurrentModelCallPolicy, describeModel, formatModelSelection, HarnessError, parseModelSelection, resolvingModel, SettingsStore } from "@xandreed/core";
5
- import { buildProvider, prependClaudeCode, withAnthropicCacheBreakpoints } from "./providers.js";
6
- import {
7
- classifyLlmError,
8
- rejectEmptyResponse,
9
- retryableLlm,
10
- retryableLlmStream
11
- } from "./retry.js";
3
+ import { Cause, Clock, Config, Context, Duration, Effect, Layer, Metric, Option, Predicate, Ref, References, Result, Stream } from "effect";
4
+ import { CurrentModelCallPolicy, CurrentTraceContent, describeModel, formatModelSelection, harnessError, messagesOf, parseModelSelection, resolvingModel, stampPartModel, stampResponseModel, storedJson } from "@xandreed/core";
5
+ import { AuthError, AuthStore } from "../ports/auth-store.port.js";
6
+ import { SettingsStore } from "../ports/settings-store.port.js";
7
+ import { extractUsage } from "@xandreed/core/internal";
8
+ import { buildProviderOver, endpointOwnKey, modelSettingsFailure, prependClaudeCode, providerServices, resolveMessagesConfiguration } from "./providers.js";
9
+ import { makeOpenAiCodexSessions } from "./openAiCodexWebSocket.js";
10
+ import { transportFetch } from "../transport/models-transport.adapter.js";
11
+ import { rejectEmptyResponse, rejectEmptyStream } from "./retry.js";
12
+ import { CurrentModelRequestCall } from "../ports/model-retry-policy.port.js";
13
+ import { enterModelFallback, makeModelRequestCall } from "../transport/resilient-http-client.adapter.js";
12
14
  const configError = (message) => AiError.make({ module: "Router", method: "selection", reason: new AiError.UnknownError({ description: message }) }), llmInputTokens = Metric.counter("llm.usage.input_tokens", {
13
15
  description: "prompt tokens consumed by routed LLM calls",
14
16
  incremental: !0
15
17
  }), llmOutputTokens = Metric.counter("llm.usage.output_tokens", {
16
18
  description: "completion tokens produced by routed LLM calls",
17
19
  incremental: !0
20
+ }), llmCacheReadTokens = Metric.counter("llm.usage.cache_read_tokens", {
21
+ description: "prompt tokens of routed LLM calls read from the provider's prompt cache",
22
+ incremental: !0
23
+ }), llmCacheWriteTokens = Metric.counter("llm.usage.cache_write_tokens", {
24
+ description: "prompt tokens of routed LLM calls written to the provider's prompt cache",
25
+ incremental: !0
26
+ }), llmReasoningTokens = Metric.counter("llm.usage.reasoning_tokens", {
27
+ description: "completion tokens of routed LLM calls spent on reasoning",
28
+ incremental: !0
18
29
  }), llmRequests = Metric.counter("llm.requests", {
19
30
  description: "routed LLM calls by final outcome (after retries)",
20
31
  incremental: !0
@@ -23,71 +34,96 @@ const configError = (message) => AiError.make({ module: "Router", method: "selec
23
34
  }), byModel = (metric, label) => Metric.withAttributes(metric, { "llm.model": label }), countRequest = (label, outcome, isFallback) => Metric.update(Metric.withAttributes(byModel(llmRequests, label), {
24
35
  outcome,
25
36
  ...isFallback === void 0 ? {} : { fallback: isFallback ? "true" : "false" }
26
- }), 1), usageTotals = (usage) => {
27
- const u = usage;
28
- return { input: u?.inputTokens?.total ?? 0, output: u?.outputTokens?.total ?? 0 };
29
- }, tracedContent = (content) => process.env.EFFERENT_TRACE_CONTENT === "1" ? content.slice(0, 500) : "[redacted]", shapeOptions = (selection, shouldPrependClaudeCode, options) => {
30
- const cached = selection.provider === "anthropic" ? withAnthropicCacheBreakpoints(options) : options;
31
- return shouldPrependClaudeCode ? prependClaudeCode(cached) : cached;
32
- }, currentSelection = Effect.gen(function* () {
37
+ }), 1), usageOf = (usage) => extractUsage(usage, []), usageAttributes = (usage) => ({
38
+ "llm.usage.input_tokens": usage.inputTokens,
39
+ "llm.usage.output_tokens": usage.outputTokens,
40
+ "llm.usage.cache_read_tokens": usage.cacheReadTokens,
41
+ "llm.usage.cache_write_tokens": usage.cacheWriteTokens,
42
+ "llm.usage.reasoning_tokens": usage.reasoningTokens
43
+ }), countUsage = (label, usage) => Effect.all([
44
+ Metric.update(byModel(llmInputTokens, label), usage.inputTokens),
45
+ Metric.update(byModel(llmOutputTokens, label), usage.outputTokens),
46
+ Metric.update(byModel(llmCacheReadTokens, label), usage.cacheReadTokens),
47
+ Metric.update(byModel(llmCacheWriteTokens, label), usage.cacheWriteTokens),
48
+ Metric.update(byModel(llmReasoningTokens, label), usage.reasoningTokens)
49
+ ], { concurrency: 1, discard: !0 }), traceContentVariable = Config.option(Config.String("EFFERENT_TRACE_CONTENT")).pipe(Effect.map(Option.exists((value) => value === "1")), Effect.catch((error) => Effect.logWarning(`EFFERENT_TRACE_CONTENT cannot be read: ${error.message}`).pipe(Effect.as(!1)))), tracedContent = Effect.all([Effect.service(CurrentTraceContent), traceContentVariable], { concurrency: 1 }).pipe(Effect.map(([capture, variable]) => (content) => capture || variable ? content.slice(0, 500) : "[redacted]")), shapeOptions = (built, options) => built.prependClaudeCode ? prependClaudeCode(options) : options, currentSelection = Effect.gen(function* () {
33
50
  const loaded = yield* (yield* SettingsStore).load.pipe(Effect.mapError((e) => configError(`settings load failed: ${e.message}`))), raw = Option.getOrElse(loaded.model, () => "");
34
51
  return {
35
52
  primary: yield* Option.match(parseModelSelection(raw), {
36
- onNone: () => Effect.fail(configError(raw.length === 0 ? "no model configured \u2014 choose /model in the terminal or set the models plugin's model option in efferent.config.json" : `the configured model "${raw}" is not a "<provider>:<modelId>" selection`)),
53
+ onNone: () => Effect.fail(configError(raw.length === 0 ? `no model configured \u2014 set the models plugin's model option, or a SettingsStore model, to "<provider>:<modelId>"` : `the configured model "${raw}" is not a "<provider>:<modelId>" selection`)),
37
54
  onSome: Effect.succeed
38
55
  }),
39
56
  fallback: Option.flatMap(loaded.fallbackModel, parseModelSelection),
40
57
  effort: loaded.reasoningEffort
41
58
  };
42
- }), withConfiguredEffort = (effect, effort) => Effect.service(CurrentModelCallPolicy).pipe(Effect.flatMap((current) => Option.isSome(current) || Option.isNone(effort) ? effect : effect.pipe(Effect.provideService(CurrentModelCallPolicy, Option.some({ effort: effort.value }))))), describeSelection = (primary, fallback, effort) => Effect.service(CurrentModelCallPolicy).pipe(Effect.map((current) => ({
43
- provider: primary.provider,
44
- model: primary.modelId,
45
- settings: {
59
+ });
60
+ export const withConfiguredEffort = (effect, effort) => Effect.service(CurrentModelCallPolicy).pipe(Effect.flatMap((current) => Option.isSome(current) || Option.isNone(effort) ? effect : effect.pipe(Effect.provideService(CurrentModelCallPolicy, Option.some({ effort: effort.value })))));
61
+ const describeSelection = Effect.fnUntraced(function* (primary, fallback, effort, services) {
62
+ const current = yield* CurrentModelCallPolicy, policy = Option.orElse(current, () => Option.map(effort, (value) => ({ effort: value }))), endpoint = Object.hasOwn(services.endpoints, primary.provider) ? Option.fromNullishOr(services.endpoints[primary.provider]) : Option.none(), messages = Option.filter(endpoint, (value) => value.api === "anthropic-messages"), configuration = (configured) => resolveMessagesConfiguration(primary, services.modelSettings, policy, services.thinkingPrefixMismatch, configured), native = yield* Option.match(messages, {
63
+ onSome: () => Effect.fromResult(configuration(messages)).pipe(Effect.map((value) => value.settings), Effect.mapError((error) => harnessError("request.model", error.message, error))),
64
+ onNone: () => Effect.succeed(Option.isNone(endpoint) && primary.provider === "anthropic" ? {
65
+ messagesProfile: { kind: "requested", apiVersion: "2023-06-01", ...Result.match(configuration(Option.none()), {
66
+ onSuccess: (value) => ({ settings: value.settings }),
67
+ onFailure: (error) => ({ controls: Object.hasOwn(services.modelSettings, formatModelSelection(primary)) ? services.modelSettings[formatModelSelection(primary)] ?? {} : {}, unresolved: error.reason })
68
+ }) }
69
+ } : {})
70
+ }), settings = yield* storedJson({
46
71
  fallback: Option.getOrElse(Option.map(fallback, formatModelSelection), () => null),
47
- policy: Option.getOrElse(Option.orElse(current, () => Option.map(effort, (value) => ({ effort: value }))), () => null)
48
- }
49
- })));
50
- export const generateWith = (selection, options, isFallback = !1) => Effect.gen(function* () {
51
- const auth = yield* AuthStore, label = formatModelSelection(selection), credential = Option.getOrUndefined(yield* auth.get(selection.provider)), key = Option.getOrUndefined(yield* auth.resolveKey(selection.provider)), built = yield* buildProvider(selection, credential, key);
52
- return yield* built.svc.generateText(shapeOptions(selection, built.prependClaudeCode, options)).pipe(rejectEmptyResponse(label), retryableLlm(label), Effect.map((res) => stampResponse(res, label)), Effect.tap((res) => Effect.annotateCurrentSpan({
72
+ policy: Option.getOrElse(policy, () => null),
73
+ ...native
74
+ }).pipe(Effect.mapError((error) => harnessError("request.model", `the routed model's settings are not JSON: ${error.message}`, error)));
75
+ return { provider: primary.provider, model: primary.modelId, settings };
76
+ }), credentialFor = Effect.fn("models.credentials")(function* (selection, endpoints) {
77
+ const own = yield* Option.match(Object.hasOwn(endpoints, selection.provider) ? Option.fromNullishOr(endpoints[selection.provider]) : Option.none(), {
78
+ onNone: () => Effect.succeedNone,
79
+ onSome: (endpoint) => endpointOwnKey(endpoint).pipe(Effect.mapError((error) => new AuthError({ provider: selection.provider, message: `cannot read the endpoint's key variable: ${error.message}` })))
80
+ });
81
+ if (Option.isSome(own))
82
+ return { credential: void 0, key: own.value };
83
+ const auth = yield* AuthStore;
84
+ return {
85
+ credential: Option.getOrUndefined(yield* auth.get(selection.provider)),
86
+ key: Option.getOrUndefined(yield* auth.resolveKey(selection.provider))
87
+ };
88
+ }), authenticationFailure = (error) => AiError.make({
89
+ module: "Router",
90
+ method: "credentials",
91
+ reason: new AiError.AuthenticationError({ kind: "Unknown", description: error.message, metadata: { router: { provider: error.provider } } })
92
+ }), providerFor = (selection, services) => credentialFor(selection, services.endpoints).pipe(Effect.mapError(authenticationFailure), Effect.flatMap(({ credential, key }) => buildProviderOver(services)(selection, credential, key).pipe(Effect.mapError((error) => error._tag === "AuthError" ? authenticationFailure(error) : modelSettingsFailure(error))))), recorded = (label, isFallback) => (call) => call.pipe(Effect.tap((res) => countUsage(label, usageOf(res.usage))), Effect.tap(() => countRequest(label, "ok", isFallback)), Effect.tapError(() => countRequest(label, "error", isFallback)), Effect.timed, Effect.tap(([duration]) => Metric.update(byModel(llmDuration, label), duration)), Effect.map(([, res]) => res)), modelAttributes = (selection) => ({ "llm.model": formatModelSelection(selection) }), providerCall = (selection) => (call) => call.pipe(Effect.annotateLogs(modelAttributes(selection)), Effect.withSpan("providers.generate", { attributes: modelAttributes(selection) })), providerStream = (selection) => (stream) => stream.pipe(Stream.updateService(References.CurrentLogAnnotations, (annotations) => ({ ...annotations, ...modelAttributes(selection) })), Stream.withSpan("providers.generate", { attributes: modelAttributes(selection) }));
93
+ export const generateOver = (services) => (selection, options, isFallback = !1, budget) => Effect.gen(function* () {
94
+ const label = formatModelSelection(selection), traced = yield* tracedContent, bound = budget ?? (yield* makeModelRequestCall({ reserveFallback: !1 })), built = yield* providerFor(selection, services);
95
+ return yield* built.svc.generateText(shapeOptions(built, options)).pipe(rejectEmptyResponse(label), Effect.provideService(CurrentModelRequestCall, Option.some(bound)), Effect.map((res) => stampResponse(res, label)), Effect.tap((res) => Effect.annotateCurrentSpan({
53
96
  "llm.finish_reason": String(res.finishReason),
54
- "llm.usage.input_tokens": usageTotals(res.usage).input,
55
- "llm.usage.output_tokens": usageTotals(res.usage).output,
97
+ ...usageAttributes(usageOf(res.usage)),
56
98
  "llm.response_chars": res.text.length,
57
- "llm.tool_calls": res.content.flatMap((part) => {
58
- const p = part;
59
- return p.type === "tool-call" ? [p.name ?? ""] : [];
60
- }).join(","),
61
- "llm.reasoning": tracedContent(res.reasoningText ?? ""),
99
+ "llm.tool_calls": res.content.flatMap((part) => part.type === "tool-call" ? [part.name] : []).join(","),
100
+ "llm.reasoning": traced(res.reasoningText ?? ""),
62
101
  "llm.fallback": isFallback
63
- })), Effect.tap((res) => Effect.all([
64
- Metric.update(byModel(llmInputTokens, label), usageTotals(res.usage).input),
65
- Metric.update(byModel(llmOutputTokens, label), usageTotals(res.usage).output)
66
- ], { discard: !0 })), Effect.tap(() => countRequest(label, "ok", isFallback)), Effect.tapError(() => countRequest(label, "error", isFallback)), Effect.timed, Effect.tap(([duration]) => Metric.update(byModel(llmDuration, label), duration)), Effect.map(([, res]) => res), Effect.withSpan("providers.generate", {
67
- attributes: { "llm.model": label }
68
- }));
69
- }).pipe(Effect.scoped), withFallbackRung = (primary, fallback, call) => call(primary, !1).pipe(Effect.catch((error) => Option.match(Option.filter(fallback, (fb) => formatModelSelection(fb) !== formatModelSelection(primary) && classifyLlmError(error) === "transient"), {
102
+ })), recorded(label, isFallback));
103
+ }).pipe(Effect.scoped, providerCall(selection)), generateWith = (selection, options, isFallback = !1, budget) => Effect.flatMap(providerServices, (services) => generateOver(services)(selection, options, isFallback, budget));
104
+ const objectOver = (services) => (selection, options, isFallback, budget) => Effect.gen(function* () {
105
+ const label = formatModelSelection(selection), built = yield* providerFor(selection, services);
106
+ return yield* built.svc.generateObject(shapeOptions(built, options)).pipe(Effect.provideService(CurrentModelRequestCall, Option.some(budget)), Effect.tap((res) => Effect.annotateCurrentSpan({
107
+ "llm.finish_reason": String(res.finishReason),
108
+ ...usageAttributes(usageOf(res.usage)),
109
+ "llm.fallback": isFallback
110
+ })), recorded(label, isFallback));
111
+ }).pipe(Effect.scoped, providerCall(selection));
112
+ export const withFallbackRung = (primary, fallback, call, budget) => call(primary, !1).pipe(Effect.catch((error) => Effect.flatMap(enterModelFallback(budget, error), (again) => Option.match(Option.filter(fallback, (fb) => again && formatModelSelection(fb) !== formatModelSelection(primary)), {
70
113
  onNone: () => Effect.fail(error),
71
- onSome: (fb) => Effect.logWarning(`${formatModelSelection(primary)} exhausted retries (${String(error).slice(0, 200)}) \u2014 falling back to ${formatModelSelection(fb)}`).pipe(Effect.andThen(call(fb, !0)))
72
- })));
73
- const stampModel = (part, label) => {
74
- if (typeof part !== "object" || part === null)
75
- return part;
76
- const p = part;
77
- if (p.type !== "finish")
78
- return part;
79
- const metadata = typeof p.metadata === "object" && p.metadata !== null ? p.metadata : {};
80
- return { ...p, metadata: { ...metadata, router: { model: label } } };
81
- };
82
- export const stampResponse = (res, label) => new LanguageModel.GenerateTextResponse(res.content.map((part) => stampModel(part, label)));
83
- const accumulateStats = (stats, part) => {
84
- const p = part;
85
- if (p.type === "text-delta")
86
- return { ...stats, textChars: stats.textChars + (p.delta ?? "").length };
87
- if (p.type === "reasoning-delta")
88
- return { ...stats, reasoning: stats.reasoning + (p.delta ?? "") };
89
- if (p.type === "tool-call")
90
- return { ...stats, toolNames: [...stats.toolNames, p.name ?? ""] };
114
+ onSome: (fb) => Effect.logWarning(`exhausted retries (${String(error).slice(0, 200)}) \u2014 falling back`).pipe(Effect.annotateLogs({ ...modelAttributes(primary), "llm.fallback_model": formatModelSelection(fb) }), Effect.andThen(call(fb, !0)))
115
+ })))), stampResponse = (res, label) => new LanguageModel.GenerateTextResponse(stampResponseModel(res.content, label));
116
+ const fieldOf = (part, key) => Predicate.hasProperty(part, key) ? part[key] : void 0, textOf = (part, key) => {
117
+ const value = fieldOf(part, key);
118
+ return Predicate.isString(value) ? value : "";
119
+ }, accumulateStats = (stats, part) => {
120
+ const type = textOf(part, "type");
121
+ if (type === "text-delta")
122
+ return { ...stats, textChars: stats.textChars + textOf(part, "delta").length };
123
+ if (type === "reasoning-delta")
124
+ return { ...stats, reasoning: stats.reasoning + textOf(part, "delta") };
125
+ if (type === "tool-call")
126
+ return { ...stats, toolNames: [...stats.toolNames, textOf(part, "name")] };
91
127
  return stats;
92
128
  };
93
129
  export const tapStreamTelemetry = (label) => (stream) => Stream.unwrap(Effect.gen(function* () {
@@ -95,47 +131,48 @@ export const tapStreamTelemetry = (label) => (stream) => Stream.unwrap(Effect.ge
95
131
  textChars: 0,
96
132
  reasoning: "",
97
133
  toolNames: []
98
- }), startedAt = yield* Clock.currentTimeMillis, recordDuration = Clock.currentTimeMillis.pipe(Effect.flatMap((now) => Metric.update(byModel(llmDuration, label), Duration.millis(now - startedAt))));
134
+ }), startedAt = yield* Clock.currentTimeMillis, traced = yield* tracedContent, recordDuration = Clock.currentTimeMillis.pipe(Effect.flatMap((now) => Metric.update(byModel(llmDuration, label), Duration.millis(now - startedAt))));
99
135
  return stream.pipe(Stream.mapEffect((part) => {
100
- const p = part;
101
- if (p.type !== "finish")
136
+ if (textOf(part, "type") !== "finish")
102
137
  return Ref.update(stats, (s) => accumulateStats(s, part)).pipe(Effect.as(part));
138
+ const usage = usageOf(fieldOf(part, "usage"));
103
139
  return Ref.get(stats).pipe(Effect.flatMap((s) => Effect.annotateCurrentSpan({
104
- "llm.finish_reason": String(p.reason),
105
- "llm.usage.input_tokens": usageTotals(p.usage).input,
106
- "llm.usage.output_tokens": usageTotals(p.usage).output,
140
+ "llm.finish_reason": String(fieldOf(part, "reason")),
141
+ ...usageAttributes(usage),
107
142
  "llm.response_chars": s.textChars,
108
143
  "llm.tool_calls": s.toolNames.join(","),
109
- "llm.reasoning": tracedContent(s.reasoning)
110
- })), Effect.andThen(Effect.all([
111
- Metric.update(byModel(llmInputTokens, label), usageTotals(p.usage).input),
112
- Metric.update(byModel(llmOutputTokens, label), usageTotals(p.usage).output)
113
- ], { discard: !0 })), Effect.as(part));
144
+ "llm.reasoning": traced(s.reasoning)
145
+ })), Effect.andThen(countUsage(label, usage)), Effect.as(part));
114
146
  }), Stream.onEnd(countRequest(label, "ok").pipe(Effect.andThen(recordDuration))), Stream.tapCause(() => countRequest(label, "error").pipe(Effect.andThen(recordDuration))));
115
- })), streamWith = (selection, options) => Stream.unwrap(Effect.gen(function* () {
116
- const auth = yield* AuthStore, label = formatModelSelection(selection), credential = Option.getOrUndefined(yield* auth.get(selection.provider)), key = Option.getOrUndefined(yield* auth.resolveKey(selection.provider)), built = yield* buildProvider(selection, credential, key);
117
- return built.svc.streamText(shapeOptions(selection, built.prependClaudeCode, options)).pipe(retryableLlmStream(label), Stream.map((part) => stampModel(part, label)), tapStreamTelemetry(label), Stream.withSpan("providers.generate", {
118
- attributes: { "llm.model": label }
119
- }));
120
147
  }));
121
- const withConfiguredEffortStream = (stream, effort) => Stream.unwrap(Effect.service(CurrentModelCallPolicy).pipe(Effect.map((current) => Option.isSome(current) || Option.isNone(effort) ? stream : stream.pipe(Stream.provideService(CurrentModelCallPolicy, Option.some({ effort: effort.value })))))), selectionModel = (primary, fallback, effort, context, http) => {
148
+ const streamOver = (services) => (selection, options, budget) => Stream.unwrap(Effect.gen(function* () {
149
+ const label = formatModelSelection(selection), built = yield* providerFor(selection, services);
150
+ return built.svc.streamText(shapeOptions(built, options)).pipe(rejectEmptyStream(label), Stream.provideService(CurrentModelRequestCall, Option.some(budget)), Stream.map((part) => stampPartModel(part, label)), tapStreamTelemetry(label));
151
+ })).pipe(providerStream(selection)), withConfiguredEffortStream = (stream, effort) => Stream.unwrap(Effect.service(CurrentModelCallPolicy).pipe(Effect.map((current) => Option.isSome(current) || Option.isNone(effort) ? stream : stream.pipe(Stream.provideService(CurrentModelCallPolicy, Option.some({ effort: effort.value })))))), requestFetch = (services) => Effect.map(Effect.context(), (context) => Option.match(services.transport, {
152
+ onNone: () => Context.get(context, FetchHttpClient.Fetch),
153
+ onSome: (transport) => Option.getOrElse(Option.filter(Option.fromNullishOr(Context.getOrUndefined(context, FetchHttpClient.Fetch)), () => Option.contains(services.managedTransport, transport)), () => transportFetch(transport))
154
+ }));
155
+ export const throughTransport = (services) => (effect) => Effect.flatMap(requestFetch(services), (fetch) => Effect.provideService(effect, FetchHttpClient.Fetch, fetch));
156
+ const throughTransportStream = (services) => (stream) => Stream.unwrap(Effect.map(requestFetch(services), (fetch) => Stream.provideService(stream, FetchHttpClient.Fetch, fetch)));
157
+ export const layerProviderServices = Effect.all([providerServices, makeOpenAiCodexSessions()], { concurrency: 1 }).pipe(Effect.map(([services, sessions]) => ({ ...services, sessions: Option.some(sessions) })));
158
+ const selectionModel = (primary, fallback, effort, stores, http, services) => {
122
159
  const service = {
123
160
  [LanguageModel.TypeId]: LanguageModel.TypeId,
124
- generateText: (options) => withConfiguredEffort(withFallbackRung(primary, fallback, (selection, isFallback) => generateWith(selection, options, isFallback)), effort).pipe(Effect.provide(context), Effect.provideService(HttpClient.HttpClient, http)),
125
- generateObject: () => Effect.fail(configError("generateObject is not wired on the new line yet")),
126
- streamText: (options) => withConfiguredEffortStream(streamWith(primary, options), effort).pipe(Stream.provideContext(context), Stream.provideService(HttpClient.HttpClient, http))
161
+ generateText: (options) => makeModelRequestCall({ reserveFallback: Option.exists(fallback, (value) => formatModelSelection(value) !== formatModelSelection(primary)) }).pipe(Effect.flatMap((budget) => withConfiguredEffort(withFallbackRung(primary, fallback, (selection, isFallback) => generateOver(services)(selection, options, isFallback, budget), budget), effort)), throughTransport(services), Effect.provide(stores), Effect.provideService(HttpClient.HttpClient, http)),
162
+ generateObject: (options) => makeModelRequestCall({ reserveFallback: Option.exists(fallback, (value) => formatModelSelection(value) !== formatModelSelection(primary)) }).pipe(Effect.flatMap((budget) => withConfiguredEffort(withFallbackRung(primary, fallback, (selection, isFallback) => objectOver(services)(selection, options, isFallback, budget), budget), effort)), throughTransport(services), Effect.provide(stores), Effect.provideService(HttpClient.HttpClient, http)),
163
+ streamText: (options) => withConfiguredEffortStream(Stream.unwrap(Effect.map(makeModelRequestCall({ reserveFallback: !1 }), (budget) => streamOver(services)(primary, options, budget))), effort).pipe(throughTransportStream(services), Stream.provideContext(stores), Stream.provideService(HttpClient.HttpClient, http))
127
164
  };
128
- return describeModel(service, describeSelection(primary, fallback, effort));
165
+ return describeModel(service, describeSelection(primary, fallback, effort, services));
129
166
  };
130
167
  export const LanguageModelLive = Layer.effect(LanguageModel.LanguageModel, Effect.gen(function* () {
131
- const context = yield* Effect.context(), http = yield* HttpClient.HttpClient, selected = currentSelection.pipe(Effect.map(({ primary, fallback, effort }) => selectionModel(primary, fallback, effort, context, http)), Effect.provide(context)), service = {
168
+ const stores = Context.pick(AuthStore, SettingsStore)(yield* Effect.context()), services = yield* layerProviderServices, http = yield* HttpClient.HttpClient, selected = currentSelection.pipe(Effect.map(({ primary, fallback, effort }) => selectionModel(primary, fallback, effort, stores, http, services)), Effect.provide(stores)), service = {
132
169
  [LanguageModel.TypeId]: LanguageModel.TypeId,
133
170
  generateText: (options) => selected.pipe(Effect.flatMap((model) => model.generateText(options))),
134
- generateObject: () => Effect.fail(configError("generateObject is not wired on the new line yet")),
171
+ generateObject: (options) => selected.pipe(Effect.flatMap((model) => model.generateObject(options))),
135
172
  streamText: (options) => Stream.unwrap(selected.pipe(Effect.map((model) => model.streamText(options))))
136
- }, asHarness = (error) => new HarnessError({ code: "request.model", message: String(error) });
137
- return resolvingModel(describeModel(service, currentSelection.pipe(Effect.flatMap(({ primary, fallback, effort }) => describeSelection(primary, fallback, effort)), Effect.provide(context), Effect.mapError(asHarness))), selected.pipe(Effect.mapError(asHarness)));
173
+ }, asHarness = (error) => harnessError("request.model", messagesOf(Cause.fail(error)), error);
174
+ return resolvingModel(describeModel(service, currentSelection.pipe(Effect.mapError(asHarness), Effect.flatMap(({ primary, fallback, effort }) => describeSelection(primary, fallback, effort, services)), Effect.provide(stores))), selected.pipe(Effect.mapError(asHarness)));
138
175
  })).pipe(Layer.provide(FetchHttpClient.layer)), LanguageModelSelectionLive = (primary, fallback) => Layer.effect(LanguageModel.LanguageModel, Effect.gen(function* () {
139
- const context = yield* Effect.context(), http = yield* HttpClient.HttpClient;
140
- return selectionModel(primary, fallback, Option.none(), context, http);
176
+ const stores = Context.pick(AuthStore)(yield* Effect.context()), http = yield* HttpClient.HttpClient;
177
+ return selectionModel(primary, fallback, Option.none(), stores, http, yield* layerProviderServices);
141
178
  })).pipe(Layer.provide(FetchHttpClient.layer));
package/dist/llm/sse.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { AiError } from "effect/ai";
2
2
  import type { Response } from "effect/ai";
3
- import { Stream } from "effect";
3
+ import { Effect, Option, Schema, Stream } from "effect";
4
+ import type { ModelAttempt } from "./model-completion.adapter.js";
5
+ import type { CompletionUsage } from "./chat-wire.entity.js";
4
6
  /**
5
7
  * The OpenAI-compatible SSE wire → `@effect/ai` `StreamPartEncoded`s, as a
6
8
  * PURE state machine over `data:` lines. Owned here so `compat.ts` imports
@@ -10,41 +12,68 @@ import { Stream } from "effect";
10
12
  * text/reasoning delta or a completed tool call — empty deltas emit nothing.
11
13
  * The retry gate (S2) and the engine fold (S3) both hinge on that.
12
14
  *
13
- * A DONE sentinel is concatenated after the wire lines so the flush (open
15
+ * An end marker is concatenated after the wire lines so the flush (open
14
16
  * chunk ends → accumulated tool calls → finish) runs even when the gateway
15
- * never sends `[DONE]`; a second sentinel is idempotent via `flushed`.
17
+ * never sends `[DONE]`; a second flush is idempotent via `flushed`. A body
18
+ * that ends with neither `[DONE]` nor a finish reason was cut off on the
19
+ * way (a dropped connection, a gateway that gave up): its finish is
20
+ * `unknown`, which the loop treats as a cut when the reply holds tool calls.
21
+ *
22
+ * Each chunk is decoded (`chat-wire.entity.ts`): a `data:` line that is no
23
+ * chunk (not JSON, `null`, a field of the wrong type) fails the stream with
24
+ * the provider's `InvalidResponseError`, never the model's invalid output.
16
25
  */
17
- /** The usage object OpenAI-compatible completions carry (streaming sends it
18
- * on the last chunk under `stream_options: {include_usage: true}`). */
19
- export interface CompletionUsage {
20
- readonly prompt_tokens?: number;
21
- readonly completion_tokens?: number;
22
- readonly total_tokens?: number;
23
- /** DeepSeek-style cache accounting. */
24
- readonly prompt_cache_hit_tokens?: number;
25
- /** Moonshot-style cache accounting. */
26
- readonly cached_tokens?: number;
27
- /** OpenAI-native cache accounting. */
28
- readonly prompt_tokens_details?: {
29
- readonly cached_tokens?: number;
30
- };
26
+ /** How a compat endpoint's usage is read. */
27
+ export interface UsageReading {
28
+ /**
29
+ * Read `completion_tokens_details.reasoning_tokens` (default true). The
30
+ * built-in `google` provider sets false: Gemini's `completion_tokens` may
31
+ * leave its thoughts out, which would break reasoning inside the output,
32
+ * so its reasoning reads 0. Configured endpoints and `local` credentials
33
+ * read it.
34
+ */
35
+ readonly reasoning?: boolean;
31
36
  }
32
37
  /** The vendor cached-token fallback chain, shared with the non-streaming
33
38
  * parser — the gateway fronts upstreams with three cache vocabularies.
34
- * `prompt_tokens` already counts the cached tokens: it is the full input. */
35
- export declare const usageFromCompletion: (usage: CompletionUsage | undefined) => {
36
- inputTokens: {
37
- total: number;
38
- uncached: number;
39
- cacheRead: number;
40
- };
41
- outputTokens: {
42
- total: number;
43
- };
44
- };
45
- export declare const finishReasonFromWire: (raw: string | undefined) => "stop" | "length" | "content-filter" | "tool-calls";
39
+ * `prompt_tokens` already counts the cached and written tokens: it is the
40
+ * full input, and `completion_tokens` the full output, reasoning included. */
41
+ export declare const usageFromCompletion: (usage: CompletionUsage | null | undefined, reading?: UsageReading) => typeof Response.Usage.Encoded;
42
+ /** The finish reasons that cut a response short, as `@effect/ai` names them. */
43
+ type WireCut = "length" | "content-filter" | "error" | "other";
44
+ /**
45
+ * A wire response's finish reason: one that ended on its own (`stop`,
46
+ * `tool-calls`), a cut, or `unknown`: a stream that ended with neither a
47
+ * finish reason nor `[DONE]`, which may have been cut off anywhere.
48
+ */
49
+ export type WireFinishReason = "stop" | "tool-calls" | WireCut | "unknown";
50
+ /**
51
+ * The wire's `finish_reason` as `@effect/ai`'s finish reason, for the settled
52
+ * and the streamed parser alike, decided before any tool call's arguments are
53
+ * read. A response that ended on its own (a settling word, or no reason at
54
+ * all on a whole completion or a stream the server ended with `[DONE]`)
55
+ * finishes on `tool-calls` when it holds tool calls, else on `stop`. A cut
56
+ * stays a cut whatever the response holds, so the loop runs no call of a
57
+ * cut reply. A stream that ended with neither is the flush's `unknown`.
58
+ */
59
+ export declare const finishReasonFromWire: (raw: Option.Option<string>, hasToolCalls: boolean) => WireFinishReason;
60
+ /**
61
+ * A tool call's params from its wire arguments, once the response's finish
62
+ * reason is known. Arguments that do not parse fail a response that ended on
63
+ * its own with the model's invalid output (`invalid`: the loop's corrective
64
+ * path). A cut response's call, or an `unknown` one's, is kept with params
65
+ * `{}`: its arguments may end mid-JSON, and the loop answers each call of a
66
+ * cut reply as cut and never runs it.
67
+ */
68
+ export declare const toolCallParams: (args: string, reason: WireFinishReason, invalid: () => AiError.AiError) => Effect.Effect<Schema.Json, AiError.AiError>;
46
69
  /** Parse a streaming chat-completions body into encoded stream parts. */
47
70
  export declare const sseStreamParts: (options: {
48
71
  readonly moduleName: string;
49
72
  readonly body: ReadableStream<Uint8Array>;
73
+ readonly attempt?: Option.Option<ModelAttempt>;
74
+ /** How the final chunk's usage is read (see `usageFromCompletion`). */
75
+ readonly usage?: UsageReading;
76
+ /** Observe raw cumulative counters before SDK defaults; a finish signal establishes output finality. */
77
+ readonly observeUsage?: (usage: unknown, final: boolean) => Effect.Effect<void>;
50
78
  }) => Stream.Stream<Response.StreamPartEncoded, AiError.AiError>;
79
+ export {};