@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.
- package/README.md +1 -1
- package/dist/auth/anthropicOAuth.d.ts +45 -11
- package/dist/auth/anthropicOAuth.js +23 -23
- package/dist/auth/auth-store.conformance.d.ts +63 -0
- package/dist/auth/auth-store.conformance.js +152 -0
- package/dist/auth/credential-input.entity.d.ts +52 -0
- package/dist/auth/credential-input.entity.functions.d.ts +18 -0
- package/dist/auth/credential-input.entity.functions.js +12 -0
- package/dist/auth/credential-input.entity.js +22 -0
- package/dist/auth/credential-refresh.entity.d.ts +17 -0
- package/dist/auth/credential-refresh.entity.functions.d.ts +57 -0
- package/dist/auth/credential-refresh.entity.functions.js +21 -0
- package/dist/auth/credential-refresh.entity.js +2 -0
- package/dist/auth/localAuth.d.ts +12 -2
- package/dist/auth/localAuth.js +58 -86
- package/dist/auth/memory-auth.adapter.d.ts +35 -0
- package/dist/auth/memory-auth.adapter.js +35 -0
- package/dist/auth/openAiCodexOAuth.d.ts +28 -6
- package/dist/auth/openAiCodexOAuth.js +22 -32
- package/dist/auth/token-response.entity.d.ts +21 -0
- package/dist/auth/token-response.entity.functions.d.ts +20 -0
- package/dist/auth/token-response.entity.functions.js +10 -0
- package/dist/auth/token-response.entity.js +10 -0
- package/dist/compat/index.d.ts +2 -0
- package/dist/compat/index.js +1 -0
- package/dist/conformance/index.d.ts +6 -0
- package/dist/conformance/index.js +3 -0
- package/dist/index.d.ts +30 -13
- package/dist/index.js +25 -50
- package/dist/llm/chat-wire.entity.d.ts +123 -0
- package/dist/llm/chat-wire.entity.functions.d.ts +28 -0
- package/dist/llm/chat-wire.entity.functions.js +10 -0
- package/dist/llm/chat-wire.entity.js +37 -0
- package/dist/llm/compat-telemetry.adapter.d.ts +6 -0
- package/dist/llm/compat-telemetry.adapter.js +21 -0
- package/dist/llm/compat.d.ts +84 -33
- package/dist/llm/compat.js +188 -121
- package/dist/llm/model-completion.adapter.d.ts +49 -0
- package/dist/llm/model-completion.adapter.js +77 -0
- package/dist/llm/model-completion.entity.d.ts +4 -0
- package/dist/llm/model-completion.entity.functions.d.ts +3 -0
- package/dist/llm/model-completion.entity.functions.js +7 -0
- package/dist/llm/model-completion.entity.js +7 -0
- package/dist/llm/model-retry-policy.entity.d.ts +37 -0
- package/dist/llm/model-retry-policy.entity.functions.d.ts +16 -0
- package/dist/llm/model-retry-policy.entity.functions.js +33 -0
- package/dist/llm/model-retry-policy.entity.js +23 -0
- package/dist/llm/model-settings.entity.d.ts +85 -0
- package/dist/llm/model-settings.entity.functions.d.ts +11 -0
- package/dist/llm/model-settings.entity.functions.js +49 -0
- package/dist/llm/model-settings.entity.js +28 -0
- package/dist/llm/model-usage.entity.d.ts +61 -0
- package/dist/llm/model-usage.entity.functions.d.ts +11 -0
- package/dist/llm/model-usage.entity.functions.js +24 -0
- package/dist/llm/model-usage.entity.js +19 -0
- package/dist/llm/modelCatalog.d.ts +47 -7
- package/dist/llm/modelCatalog.js +31 -57
- package/dist/llm/openAiCodex.d.ts +18 -7
- package/dist/llm/openAiCodex.js +57 -45
- package/dist/llm/openAiCodexWebSocket.d.ts +60 -7
- package/dist/llm/openAiCodexWebSocket.js +113 -105
- package/dist/llm/prompt-cache-frame.entity.d.ts +18 -0
- package/dist/llm/prompt-cache-frame.entity.js +10 -0
- package/dist/llm/prompt-cache.entity.d.ts +57 -0
- package/dist/llm/prompt-cache.entity.functions.d.ts +8 -0
- package/dist/llm/prompt-cache.entity.functions.js +73 -0
- package/dist/llm/prompt-cache.entity.js +27 -0
- package/dist/llm/provider-client.adapter.d.ts +9 -0
- package/dist/llm/provider-client.adapter.js +60 -0
- package/dist/llm/provider-failure.entity.d.ts +42 -0
- package/dist/llm/provider-failure.entity.functions.d.ts +8 -0
- package/dist/llm/provider-failure.entity.functions.js +67 -0
- package/dist/llm/provider-failure.entity.js +20 -0
- package/dist/llm/provider-profile.entity.d.ts +79 -0
- package/dist/llm/provider-profile.entity.functions.d.ts +23 -0
- package/dist/llm/provider-profile.entity.functions.js +83 -0
- package/dist/llm/provider-profile.entity.js +10 -0
- package/dist/llm/providers.d.ts +125 -29
- package/dist/llm/providers.js +311 -117
- package/dist/llm/retry.d.ts +11 -47
- package/dist/llm/retry.js +33 -52
- package/dist/llm/router.d.ts +56 -28
- package/dist/llm/router.js +125 -88
- package/dist/llm/sse.d.ts +58 -29
- package/dist/llm/sse.js +47 -39
- package/dist/llm/thinking-binding.entity.d.ts +49 -0
- package/dist/llm/thinking-binding.entity.functions.d.ts +21 -0
- package/dist/llm/thinking-binding.entity.functions.js +16 -0
- package/dist/llm/thinking-binding.entity.js +11 -0
- package/dist/llm/utilityLlm.d.ts +14 -5
- package/dist/llm/utilityLlm.js +18 -13
- package/dist/plugin.adapter.d.ts +154 -6
- package/dist/plugin.adapter.js +53 -10
- package/dist/ports/auth-store.port.d.ts +73 -0
- package/dist/ports/auth-store.port.js +24 -0
- package/dist/ports/host-credentials.port.d.ts +22 -0
- package/dist/ports/host-credentials.port.js +4 -0
- package/dist/ports/model-call-completion.port.d.ts +9 -0
- package/dist/ports/model-call-completion.port.js +2 -0
- package/dist/ports/model-endpoints.port.d.ts +66 -19
- package/dist/ports/model-endpoints.port.js +22 -4
- package/dist/ports/model-retry-policy.port.d.ts +17 -0
- package/dist/ports/model-retry-policy.port.js +3 -0
- package/dist/ports/model-settings.port.d.ts +32 -0
- package/dist/ports/model-settings.port.js +4 -0
- package/dist/ports/models-transport.port.d.ts +53 -0
- package/dist/ports/models-transport.port.js +7 -0
- package/dist/ports/prompt-cache-frame.port.d.ts +21 -0
- package/dist/ports/prompt-cache-frame.port.js +4 -0
- package/dist/ports/settings-store.port.d.ts +70 -0
- package/dist/ports/settings-store.port.js +33 -0
- package/dist/ports/thinking-binding.port.d.ts +7 -0
- package/dist/ports/thinking-binding.port.js +4 -0
- package/dist/settings/localSettings.d.ts +2 -1
- package/dist/settings/localSettings.js +18 -61
- package/dist/settings/memory-settings.adapter.d.ts +12 -0
- package/dist/settings/memory-settings.adapter.js +11 -0
- package/dist/settings/roleView.d.ts +3 -1
- package/dist/settings/roleView.js +1 -1
- package/dist/settings/settings-store.conformance.d.ts +47 -0
- package/dist/settings/settings-store.conformance.js +100 -0
- package/dist/settings/settings-value.entity.d.ts +15 -0
- package/dist/settings/settings-value.entity.functions.d.ts +51 -0
- package/dist/settings/settings-value.entity.functions.js +42 -0
- package/dist/settings/settings-value.entity.js +2 -0
- package/dist/storage/json-file.d.ts +18 -0
- package/dist/storage/json-file.js +26 -0
- package/dist/transport/http-proxy.entity.d.ts +104 -0
- package/dist/transport/http-proxy.entity.functions.d.ts +51 -0
- package/dist/transport/http-proxy.entity.functions.js +112 -0
- package/dist/transport/http-proxy.entity.js +42 -0
- package/dist/transport/model-request.conformance.d.ts +13 -0
- package/dist/transport/model-request.conformance.js +36 -0
- package/dist/transport/models-transport.adapter.d.ts +49 -0
- package/dist/transport/models-transport.adapter.js +99 -0
- package/dist/transport/resilient-http-client.adapter.d.ts +73 -0
- package/dist/transport/resilient-http-client.adapter.js +177 -0
- package/dist/transport/undici-fetch.adapter.d.ts +13 -0
- package/dist/transport/undici-fetch.adapter.js +1 -0
- package/package.json +13 -6
- package/dist/auth/anthropicOAuth.test.d.ts +0 -1
- package/dist/auth/localAuth.test.d.ts +0 -1
- package/dist/llm/compat.test.d.ts +0 -1
- package/dist/llm/modelCatalog.test.d.ts +0 -1
- package/dist/llm/providers.test.d.ts +0 -1
- package/dist/llm/retry.test.d.ts +0 -1
- package/dist/llm/router.test.d.ts +0 -1
- package/dist/llm/sse.test.d.ts +0 -1
- package/dist/plugin.adapter.test.d.ts +0 -1
- package/dist/settings/localSettings.test.d.ts +0 -1
- package/dist/settings/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 {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
if (
|
|
11
|
-
return
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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")))))
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
+
}))));
|
package/dist/llm/router.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
}
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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<
|
|
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<
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
|
|
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,
|
|
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 {};
|
package/dist/llm/router.js
CHANGED
|
@@ -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 {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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),
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
},
|
|
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 ?
|
|
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
|
-
})
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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(
|
|
48
|
-
|
|
49
|
-
})));
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
})),
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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(
|
|
72
|
-
})));
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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(
|
|
105
|
-
|
|
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":
|
|
110
|
-
})), Effect.andThen(Effect.
|
|
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
|
|
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) =>
|
|
125
|
-
generateObject: () => Effect.
|
|
126
|
-
streamText: (options) => withConfiguredEffortStream(
|
|
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
|
|
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.
|
|
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) =>
|
|
137
|
-
return resolvingModel(describeModel(service, currentSelection.pipe(Effect.flatMap(({ primary, fallback, effort }) => describeSelection(primary, fallback, effort)), Effect.provide(
|
|
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
|
|
140
|
-
return selectionModel(primary, fallback, Option.none(),
|
|
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
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
readonly
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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 {};
|