@xandreed/plugin-models 0.14.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/README.md +1 -1
  2. package/dist/auth/anthropicOAuth.d.ts +30 -10
  3. package/dist/auth/anthropicOAuth.js +15 -18
  4. package/dist/auth/auth-store.conformance.d.ts +63 -0
  5. package/dist/auth/auth-store.conformance.js +152 -0
  6. package/dist/auth/credential-input.entity.d.ts +23 -5
  7. package/dist/auth/credential-input.entity.functions.d.ts +15 -5
  8. package/dist/auth/credential-input.entity.functions.js +12 -9
  9. package/dist/auth/credential-input.entity.js +5 -3
  10. package/dist/auth/credential-refresh.entity.d.ts +11 -3
  11. package/dist/auth/credential-refresh.entity.functions.d.ts +48 -6
  12. package/dist/auth/credential-refresh.entity.functions.js +12 -3
  13. package/dist/auth/localAuth.d.ts +10 -1
  14. package/dist/auth/localAuth.js +53 -79
  15. package/dist/auth/memory-auth.adapter.d.ts +12 -4
  16. package/dist/auth/memory-auth.adapter.js +13 -25
  17. package/dist/auth/openAiCodexOAuth.d.ts +26 -5
  18. package/dist/auth/openAiCodexOAuth.js +17 -28
  19. package/dist/auth/token-response.entity.d.ts +21 -0
  20. package/dist/auth/token-response.entity.functions.d.ts +20 -0
  21. package/dist/auth/token-response.entity.functions.js +10 -0
  22. package/dist/auth/token-response.entity.js +10 -0
  23. package/dist/compat/index.d.ts +2 -0
  24. package/dist/compat/index.js +1 -0
  25. package/dist/conformance/index.d.ts +6 -0
  26. package/dist/conformance/index.js +3 -0
  27. package/dist/index.d.ts +19 -19
  28. package/dist/index.js +17 -69
  29. package/dist/llm/chat-wire.entity.d.ts +123 -0
  30. package/dist/llm/chat-wire.entity.functions.d.ts +28 -0
  31. package/dist/llm/chat-wire.entity.functions.js +10 -0
  32. package/dist/llm/chat-wire.entity.js +37 -0
  33. package/dist/llm/compat-telemetry.adapter.d.ts +6 -0
  34. package/dist/llm/compat-telemetry.adapter.js +21 -0
  35. package/dist/llm/compat.d.ts +50 -30
  36. package/dist/llm/compat.js +149 -133
  37. package/dist/llm/model-completion.adapter.d.ts +49 -0
  38. package/dist/llm/model-completion.adapter.js +77 -0
  39. package/dist/llm/model-completion.entity.d.ts +4 -0
  40. package/dist/llm/model-completion.entity.functions.d.ts +3 -0
  41. package/dist/llm/model-completion.entity.functions.js +7 -0
  42. package/dist/llm/model-completion.entity.js +7 -0
  43. package/dist/llm/model-retry-policy.entity.d.ts +37 -0
  44. package/dist/llm/model-retry-policy.entity.functions.d.ts +16 -0
  45. package/dist/llm/model-retry-policy.entity.functions.js +33 -0
  46. package/dist/llm/model-retry-policy.entity.js +23 -0
  47. package/dist/llm/model-settings.entity.d.ts +85 -0
  48. package/dist/llm/model-settings.entity.functions.d.ts +11 -0
  49. package/dist/llm/model-settings.entity.functions.js +49 -0
  50. package/dist/llm/model-settings.entity.js +28 -0
  51. package/dist/llm/model-usage.entity.d.ts +61 -0
  52. package/dist/llm/model-usage.entity.functions.d.ts +11 -0
  53. package/dist/llm/model-usage.entity.functions.js +24 -0
  54. package/dist/llm/model-usage.entity.js +19 -0
  55. package/dist/llm/modelCatalog.d.ts +29 -8
  56. package/dist/llm/modelCatalog.js +28 -66
  57. package/dist/llm/openAiCodex.d.ts +15 -7
  58. package/dist/llm/openAiCodex.js +57 -45
  59. package/dist/llm/openAiCodexWebSocket.d.ts +48 -15
  60. package/dist/llm/openAiCodexWebSocket.js +101 -147
  61. package/dist/llm/prompt-cache-frame.entity.d.ts +18 -0
  62. package/dist/llm/prompt-cache-frame.entity.js +10 -0
  63. package/dist/llm/prompt-cache.entity.d.ts +57 -0
  64. package/dist/llm/prompt-cache.entity.functions.d.ts +8 -0
  65. package/dist/llm/prompt-cache.entity.functions.js +73 -0
  66. package/dist/llm/prompt-cache.entity.js +27 -0
  67. package/dist/llm/provider-client.adapter.d.ts +9 -0
  68. package/dist/llm/provider-client.adapter.js +60 -0
  69. package/dist/llm/provider-failure.entity.d.ts +42 -0
  70. package/dist/llm/provider-failure.entity.functions.d.ts +8 -0
  71. package/dist/llm/provider-failure.entity.functions.js +67 -0
  72. package/dist/llm/provider-failure.entity.js +20 -0
  73. package/dist/llm/provider-profile.entity.d.ts +79 -0
  74. package/dist/llm/provider-profile.entity.functions.d.ts +23 -0
  75. package/dist/llm/provider-profile.entity.functions.js +83 -0
  76. package/dist/llm/provider-profile.entity.js +10 -0
  77. package/dist/llm/providers.d.ts +116 -34
  78. package/dist/llm/providers.js +298 -129
  79. package/dist/llm/retry.d.ts +11 -47
  80. package/dist/llm/retry.js +33 -52
  81. package/dist/llm/router.d.ts +54 -36
  82. package/dist/llm/router.js +91 -78
  83. package/dist/llm/sse.d.ts +44 -37
  84. package/dist/llm/sse.js +43 -35
  85. package/dist/llm/thinking-binding.entity.d.ts +49 -0
  86. package/dist/llm/thinking-binding.entity.functions.d.ts +21 -0
  87. package/dist/llm/thinking-binding.entity.functions.js +16 -0
  88. package/dist/llm/thinking-binding.entity.js +11 -0
  89. package/dist/llm/utilityLlm.d.ts +14 -5
  90. package/dist/llm/utilityLlm.js +18 -13
  91. package/dist/plugin.adapter.d.ts +94 -6
  92. package/dist/plugin.adapter.js +18 -13
  93. package/dist/ports/auth-store.port.d.ts +73 -0
  94. package/dist/ports/auth-store.port.js +24 -0
  95. package/dist/ports/host-credentials.port.d.ts +2 -1
  96. package/dist/ports/model-call-completion.port.d.ts +9 -0
  97. package/dist/ports/model-call-completion.port.js +2 -0
  98. package/dist/ports/model-endpoints.port.d.ts +60 -26
  99. package/dist/ports/model-endpoints.port.js +17 -3
  100. package/dist/ports/model-retry-policy.port.d.ts +17 -0
  101. package/dist/ports/model-retry-policy.port.js +3 -0
  102. package/dist/ports/model-settings.port.d.ts +32 -0
  103. package/dist/ports/model-settings.port.js +4 -0
  104. package/dist/ports/models-transport.port.d.ts +9 -2
  105. package/dist/ports/models-transport.port.js +4 -1
  106. package/dist/ports/prompt-cache-frame.port.d.ts +21 -0
  107. package/dist/ports/prompt-cache-frame.port.js +4 -0
  108. package/dist/ports/settings-store.port.d.ts +70 -0
  109. package/dist/ports/settings-store.port.js +33 -0
  110. package/dist/ports/thinking-binding.port.d.ts +7 -0
  111. package/dist/ports/thinking-binding.port.js +4 -0
  112. package/dist/settings/localSettings.d.ts +2 -1
  113. package/dist/settings/localSettings.js +17 -20
  114. package/dist/settings/memory-settings.adapter.d.ts +3 -1
  115. package/dist/settings/memory-settings.adapter.js +1 -1
  116. package/dist/settings/roleView.d.ts +3 -1
  117. package/dist/settings/roleView.js +1 -1
  118. package/dist/settings/settings-store.conformance.d.ts +47 -0
  119. package/dist/settings/settings-store.conformance.js +100 -0
  120. package/dist/settings/settings-value.entity.d.ts +10 -2
  121. package/dist/settings/settings-value.entity.functions.d.ts +31 -9
  122. package/dist/settings/settings-value.entity.functions.js +5 -6
  123. package/dist/storage/json-file.d.ts +18 -0
  124. package/dist/storage/json-file.js +26 -0
  125. package/dist/transport/http-proxy.entity.d.ts +17 -3
  126. package/dist/transport/model-request.conformance.d.ts +13 -0
  127. package/dist/transport/model-request.conformance.js +36 -0
  128. package/dist/transport/models-transport.adapter.d.ts +18 -17
  129. package/dist/transport/models-transport.adapter.js +28 -31
  130. package/dist/transport/resilient-http-client.adapter.d.ts +73 -0
  131. package/dist/transport/resilient-http-client.adapter.js +177 -0
  132. package/package.json +11 -5
  133. package/dist/auth/anthropicOAuth.test.d.ts +0 -1
  134. package/dist/auth/credential-refresh.entity.functions.test.d.ts +0 -1
  135. package/dist/auth/localAuth.test.d.ts +0 -1
  136. package/dist/auth/memory-auth.adapter.test.d.ts +0 -1
  137. package/dist/llm/compat.test.d.ts +0 -1
  138. package/dist/llm/modelCatalog.test.d.ts +0 -1
  139. package/dist/llm/openAiCodexWebSocket.test.d.ts +0 -1
  140. package/dist/llm/providers.test.d.ts +0 -1
  141. package/dist/llm/retry.test.d.ts +0 -1
  142. package/dist/llm/router.test.d.ts +0 -1
  143. package/dist/llm/sse.test.d.ts +0 -1
  144. package/dist/plugin.adapter.test.d.ts +0 -1
  145. package/dist/settings/localSettings.test.d.ts +0 -1
  146. package/dist/settings/memory-settings.adapter.test.d.ts +0 -1
  147. package/dist/settings/roleView.test.d.ts +0 -1
  148. package/dist/settings/settings-value.entity.functions.test.d.ts +0 -1
  149. package/dist/transport/http-proxy.entity.functions.test.d.ts +0 -1
  150. package/dist/transport/models-transport.adapter.test.d.ts +0 -1
  151. package/dist/transport/recording-proxy.testing.d.ts +0 -33
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,29 +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>;
46
55
  /**
47
- * The fetch the SDK providers' HTTP client uses on a call: the
48
- * `ModelsTransport`'s (which knows the proxy), when the model was built over
49
- * one. It is provided innermost, inside the replayed build context, so a
50
- * `FetchHttpClient.Fetch` in that context or around the call cannot send the
51
- * call past the proxy. Without a transport the call keeps whatever fetch is
52
- * in force, the global one by default.
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.
53
63
  */
54
- export declare const transportFetchOf: Effect.Effect<Option.Option<typeof fetch>, never, never>;
64
+ export declare const layerProviderServices: Effect.Effect<ProviderServices>;
55
65
  /**
56
66
  * `LanguageModelLive` — the engine loop's `LanguageModel`, routed per call.
57
67
  * Requires `SettingsStore` + `AuthStore`; brings its own fetch-backed
58
- * `HttpClient` so drivers don't have to. Built over a `ModelsTransport`
59
- * (as the models plugin builds it), every call goes through that transport.
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
60
73
  */
61
- export declare const LanguageModelLive: Layer.Layer<LanguageModel.LanguageModel, never, SettingsStore | AuthStore>;
74
+ export declare const LanguageModelLive: Layer.Layer<LanguageModel.LanguageModel, never, AuthStore | SettingsStore>;
62
75
  /** A profile-pinned routed model. Unlike LanguageModelLive it never consults
63
76
  * SettingsStore; dedicated agents use this so global role edits cannot
64
- * 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
+ */
65
82
  export declare const LanguageModelSelectionLive: (primary: ModelSelection, fallback: Option.Option<ModelSelection>) => Layer.Layer<LanguageModel.LanguageModel, never, AuthStore>;
83
+ export {};
@@ -1,17 +1,16 @@
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, CurrentTraceContent, describeModel, extractUsage, formatModelSelection, HarnessError, parseModelSelection, replayableContext, resolvingModel, SettingsStore } from "@xandreed/core";
5
- import { buildProvider, endpointOwnKey, prependClaudeCode, withAnthropicCacheBreakpoints } from "./providers.js";
6
- import { CurrentModelEndpoints } from "../ports/model-endpoints.port.js";
7
- import { ModelsTransport } from "../ports/models-transport.port.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";
8
10
  import { transportFetch } from "../transport/models-transport.adapter.js";
9
- import {
10
- classifyLlmError,
11
- rejectEmptyResponse,
12
- retryableLlm,
13
- retryableLlmStream
14
- } from "./retry.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";
15
14
  const configError = (message) => AiError.make({ module: "Router", method: "selection", reason: new AiError.UnknownError({ description: message }) }), llmInputTokens = Metric.counter("llm.usage.input_tokens", {
16
15
  description: "prompt tokens consumed by routed LLM calls",
17
16
  incremental: !0
@@ -47,10 +46,7 @@ const configError = (message) => AiError.make({ module: "Router", method: "selec
47
46
  Metric.update(byModel(llmCacheReadTokens, label), usage.cacheReadTokens),
48
47
  Metric.update(byModel(llmCacheWriteTokens, label), usage.cacheWriteTokens),
49
48
  Metric.update(byModel(llmReasoningTokens, label), usage.reasoningTokens)
50
- ], { discard: !0 }), tracedContent = Effect.service(CurrentTraceContent).pipe(Effect.map((capture) => (content) => capture || process.env.EFFERENT_TRACE_CONTENT === "1" ? content.slice(0, 500) : "[redacted]")), shapeOptions = (selection, shouldPrependClaudeCode, options) => {
51
- const cached = selection.provider === "anthropic" ? withAnthropicCacheBreakpoints(options) : options;
52
- return shouldPrependClaudeCode ? prependClaudeCode(cached) : cached;
53
- }, currentSelection = Effect.gen(function* () {
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* () {
54
50
  const loaded = yield* (yield* SettingsStore).load.pipe(Effect.mapError((e) => configError(`settings load failed: ${e.message}`))), raw = Option.getOrElse(loaded.model, () => "");
55
51
  return {
56
52
  primary: yield* Option.match(parseModelSelection(raw), {
@@ -60,15 +56,28 @@ const configError = (message) => AiError.make({ module: "Router", method: "selec
60
56
  fallback: Option.flatMap(loaded.fallbackModel, parseModelSelection),
61
57
  effort: loaded.reasoningEffort
62
58
  };
63
- }), 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) => ({
64
- provider: primary.provider,
65
- model: primary.modelId,
66
- 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({
67
71
  fallback: Option.getOrElse(Option.map(fallback, formatModelSelection), () => null),
68
- policy: Option.getOrElse(Option.orElse(current, () => Option.map(effort, (value) => ({ effort: value }))), () => null)
69
- }
70
- }))), credentialFor = (selection) => Effect.gen(function* () {
71
- const endpoints = yield* Effect.service(CurrentModelEndpoints), own = Option.flatMap(Option.fromNullishOr(endpoints[selection.provider]), endpointOwnKey);
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
+ });
72
81
  if (Option.isSome(own))
73
82
  return { credential: void 0, key: own.value };
74
83
  const auth = yield* AuthStore;
@@ -76,44 +85,45 @@ const configError = (message) => AiError.make({ module: "Router", method: "selec
76
85
  credential: Option.getOrUndefined(yield* auth.get(selection.provider)),
77
86
  key: Option.getOrUndefined(yield* auth.resolveKey(selection.provider))
78
87
  };
79
- });
80
- export const generateWith = (selection, options, isFallback = !1) => Effect.gen(function* () {
81
- const label = formatModelSelection(selection), traced = yield* tracedContent, { credential, key } = yield* credentialFor(selection), built = yield* buildProvider(selection, credential, key);
82
- 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({
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({
83
96
  "llm.finish_reason": String(res.finishReason),
84
97
  ...usageAttributes(usageOf(res.usage)),
85
98
  "llm.response_chars": res.text.length,
86
- "llm.tool_calls": res.content.flatMap((part) => {
87
- const p = part;
88
- return p.type === "tool-call" ? [p.name ?? ""] : [];
89
- }).join(","),
99
+ "llm.tool_calls": res.content.flatMap((part) => part.type === "tool-call" ? [part.name] : []).join(","),
90
100
  "llm.reasoning": traced(res.reasoningText ?? ""),
91
101
  "llm.fallback": isFallback
92
- })), 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), Effect.withSpan("providers.generate", {
93
- attributes: { "llm.model": label }
94
- }));
95
- }).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)), {
96
113
  onNone: () => Effect.fail(error),
97
- 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)))
98
- })));
99
- const stampModel = (part, label) => {
100
- if (typeof part !== "object" || part === null)
101
- return part;
102
- const p = part;
103
- if (p.type !== "finish")
104
- return part;
105
- const metadata = typeof p.metadata === "object" && p.metadata !== null ? p.metadata : {};
106
- return { ...p, metadata: { ...metadata, router: { model: label } } };
107
- };
108
- export const stampResponse = (res, label) => new LanguageModel.GenerateTextResponse(res.content.map((part) => stampModel(part, label)));
109
- const accumulateStats = (stats, part) => {
110
- const p = part;
111
- if (p.type === "text-delta")
112
- return { ...stats, textChars: stats.textChars + (p.delta ?? "").length };
113
- if (p.type === "reasoning-delta")
114
- return { ...stats, reasoning: stats.reasoning + (p.delta ?? "") };
115
- if (p.type === "tool-call")
116
- 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")] };
117
127
  return stats;
118
128
  };
119
129
  export const tapStreamTelemetry = (label) => (stream) => Stream.unwrap(Effect.gen(function* () {
@@ -123,43 +133,46 @@ export const tapStreamTelemetry = (label) => (stream) => Stream.unwrap(Effect.ge
123
133
  toolNames: []
124
134
  }), startedAt = yield* Clock.currentTimeMillis, traced = yield* tracedContent, recordDuration = Clock.currentTimeMillis.pipe(Effect.flatMap((now) => Metric.update(byModel(llmDuration, label), Duration.millis(now - startedAt))));
125
135
  return stream.pipe(Stream.mapEffect((part) => {
126
- const p = part;
127
- if (p.type !== "finish")
136
+ if (textOf(part, "type") !== "finish")
128
137
  return Ref.update(stats, (s) => accumulateStats(s, part)).pipe(Effect.as(part));
138
+ const usage = usageOf(fieldOf(part, "usage"));
129
139
  return Ref.get(stats).pipe(Effect.flatMap((s) => Effect.annotateCurrentSpan({
130
- "llm.finish_reason": String(p.reason),
131
- ...usageAttributes(usageOf(p.usage)),
140
+ "llm.finish_reason": String(fieldOf(part, "reason")),
141
+ ...usageAttributes(usage),
132
142
  "llm.response_chars": s.textChars,
133
143
  "llm.tool_calls": s.toolNames.join(","),
134
144
  "llm.reasoning": traced(s.reasoning)
135
- })), Effect.andThen(countUsage(label, usageOf(p.usage))), Effect.as(part));
145
+ })), Effect.andThen(countUsage(label, usage)), Effect.as(part));
136
146
  }), Stream.onEnd(countRequest(label, "ok").pipe(Effect.andThen(recordDuration))), Stream.tapCause(() => countRequest(label, "error").pipe(Effect.andThen(recordDuration))));
137
- })), streamWith = (selection, options) => Stream.unwrap(Effect.gen(function* () {
138
- const label = formatModelSelection(selection), { credential, key } = yield* credentialFor(selection), built = yield* buildProvider(selection, credential, key);
139
- return built.svc.streamText(shapeOptions(selection, built.prependClaudeCode, options)).pipe(retryableLlmStream(label), Stream.map((part) => stampModel(part, label)), tapStreamTelemetry(label), Stream.withSpan("providers.generate", {
140
- attributes: { "llm.model": label }
141
- }));
142
147
  }));
143
- 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 }))))));
144
- export const transportFetchOf = Effect.map(Effect.serviceOption(ModelsTransport), Option.map(transportFetch));
145
- const withFetch = (fetch) => (effect) => Option.match(fetch, { onNone: () => effect, onSome: (value) => Effect.provideService(effect, FetchHttpClient.Fetch, value) }), withFetchStream = (fetch) => (stream) => Option.match(fetch, { onNone: () => stream, onSome: (value) => Stream.provideService(stream, FetchHttpClient.Fetch, value) }), selectionModel = (primary, fallback, effort, context, http, fetch) => {
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) => {
146
159
  const service = {
147
160
  [LanguageModel.TypeId]: LanguageModel.TypeId,
148
- generateText: (options) => withConfiguredEffort(withFallbackRung(primary, fallback, (selection, isFallback) => generateWith(selection, options, isFallback)), effort).pipe(withFetch(fetch), Effect.provide(context), Effect.provideService(HttpClient.HttpClient, http)),
149
- generateObject: () => Effect.fail(configError("generateObject is not wired on the new line yet")),
150
- streamText: (options) => withConfiguredEffortStream(streamWith(primary, options), effort).pipe(withFetchStream(fetch), 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))
151
164
  };
152
- return describeModel(service, describeSelection(primary, fallback, effort));
165
+ return describeModel(service, describeSelection(primary, fallback, effort, services));
153
166
  };
154
167
  export const LanguageModelLive = Layer.effect(LanguageModel.LanguageModel, Effect.gen(function* () {
155
- const context = replayableContext(yield* Effect.context()), http = yield* HttpClient.HttpClient, fetch = yield* transportFetchOf, selected = currentSelection.pipe(Effect.map(({ primary, fallback, effort }) => selectionModel(primary, fallback, effort, context, http, fetch)), 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 = {
156
169
  [LanguageModel.TypeId]: LanguageModel.TypeId,
157
170
  generateText: (options) => selected.pipe(Effect.flatMap((model) => model.generateText(options))),
158
- generateObject: () => Effect.fail(configError("generateObject is not wired on the new line yet")),
171
+ generateObject: (options) => selected.pipe(Effect.flatMap((model) => model.generateObject(options))),
159
172
  streamText: (options) => Stream.unwrap(selected.pipe(Effect.map((model) => model.streamText(options))))
160
- }, asHarness = (error) => new HarnessError({ code: "request.model", message: String(error) });
161
- 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)));
162
175
  })).pipe(Layer.provide(FetchHttpClient.layer)), LanguageModelSelectionLive = (primary, fallback) => Layer.effect(LanguageModel.LanguageModel, Effect.gen(function* () {
163
- const context = replayableContext(yield* Effect.context()), http = yield* HttpClient.HttpClient;
164
- return selectionModel(primary, fallback, Option.none(), context, http, yield* transportFetchOf);
176
+ const stores = Context.pick(AuthStore)(yield* Effect.context()), http = yield* HttpClient.HttpClient;
177
+ return selectionModel(primary, fallback, Option.none(), stores, http, yield* layerProviderServices);
165
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,30 +12,17 @@ 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 reads; gateways (OpenCode's among them) add the cache writes. */
28
- readonly prompt_tokens_details?: {
29
- readonly cached_tokens?: number;
30
- readonly cache_write_tokens?: number;
31
- };
32
- /** OpenAI-native reasoning accounting, counted inside `completion_tokens`. */
33
- readonly completion_tokens_details?: {
34
- readonly reasoning_tokens?: number;
35
- };
36
- }
37
26
  /** How a compat endpoint's usage is read. */
38
27
  export interface UsageReading {
39
28
  /**
@@ -49,24 +38,42 @@ export interface UsageReading {
49
38
  * parser — the gateway fronts upstreams with three cache vocabularies.
50
39
  * `prompt_tokens` already counts the cached and written tokens: it is the
51
40
  * full input, and `completion_tokens` the full output, reasoning included. */
52
- export declare const usageFromCompletion: (usage: CompletionUsage | undefined, reading?: UsageReading) => {
53
- inputTokens: {
54
- total: number;
55
- uncached: number;
56
- cacheRead: number;
57
- cacheWrite: number;
58
- };
59
- outputTokens: {
60
- total: number;
61
- text: number;
62
- reasoning: number;
63
- };
64
- };
65
- export declare const finishReasonFromWire: (raw: string | undefined) => "stop" | "length" | "content-filter" | "tool-calls";
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>;
66
69
  /** Parse a streaming chat-completions body into encoded stream parts. */
67
70
  export declare const sseStreamParts: (options: {
68
71
  readonly moduleName: string;
69
72
  readonly body: ReadableStream<Uint8Array>;
73
+ readonly attempt?: Option.Option<ModelAttempt>;
70
74
  /** How the final chunk's usage is read (see `usageFromCompletion`). */
71
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>;
72
78
  }) => Stream.Stream<Response.StreamPartEncoded, AiError.AiError>;
79
+ export {};