@xandreed/plugin-models 0.3.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/LICENSE +21 -0
- package/README.md +5 -0
- package/dist/auth/anthropicOAuth.d.ts +50 -0
- package/dist/auth/anthropicOAuth.js +90 -0
- package/dist/auth/anthropicOAuth.test.d.ts +1 -0
- package/dist/auth/localAuth.d.ts +8 -0
- package/dist/auth/localAuth.js +90 -0
- package/dist/auth/localAuth.test.d.ts +1 -0
- package/dist/auth/openAiCodexOAuth.d.ts +25 -0
- package/dist/auth/openAiCodexOAuth.js +58 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +53 -0
- package/dist/llm/compat.d.ts +77 -0
- package/dist/llm/compat.js +215 -0
- package/dist/llm/compat.test.d.ts +1 -0
- package/dist/llm/modelCatalog.d.ts +10 -0
- package/dist/llm/modelCatalog.js +58 -0
- package/dist/llm/modelCatalog.test.d.ts +1 -0
- package/dist/llm/openAiCodex.d.ts +18 -0
- package/dist/llm/openAiCodex.js +99 -0
- package/dist/llm/openAiCodexWebSocket.d.ts +14 -0
- package/dist/llm/openAiCodexWebSocket.js +126 -0
- package/dist/llm/providers.d.ts +37 -0
- package/dist/llm/providers.js +114 -0
- package/dist/llm/providers.test.d.ts +1 -0
- package/dist/llm/retry.d.ts +54 -0
- package/dist/llm/retry.js +61 -0
- package/dist/llm/retry.test.d.ts +1 -0
- package/dist/llm/router.d.ts +55 -0
- package/dist/llm/router.js +125 -0
- package/dist/llm/router.test.d.ts +1 -0
- package/dist/llm/sse.d.ts +45 -0
- package/dist/llm/sse.js +116 -0
- package/dist/llm/sse.test.d.ts +1 -0
- package/dist/llm/utilityLlm.d.ts +8 -0
- package/dist/llm/utilityLlm.js +33 -0
- package/dist/plugin.adapter.d.ts +2 -0
- package/dist/plugin.adapter.js +27 -0
- package/dist/plugin.adapter.test.d.ts +1 -0
- package/dist/settings/localSettings.d.ts +3 -0
- package/dist/settings/localSettings.js +72 -0
- package/dist/settings/localSettings.test.d.ts +1 -0
- package/dist/settings/roleView.d.ts +11 -0
- package/dist/settings/roleView.js +11 -0
- package/dist/settings/roleView.test.d.ts +1 -0
- package/package.json +39 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { LanguageModel, Prompt } from "@effect/ai";
|
|
2
|
+
import { AnthropicClient, AnthropicLanguageModel } from "@effect/ai-anthropic";
|
|
3
|
+
import { GoogleClient, GoogleLanguageModel } from "@effect/ai-google";
|
|
4
|
+
import { OpenAiClient, OpenAiLanguageModel } from "@effect/ai-openai";
|
|
5
|
+
import { HttpClient, HttpClientRequest } from "@effect/platform";
|
|
6
|
+
import { Effect, FiberRef, Option, Redacted } from "effect";
|
|
7
|
+
import { AuthError, CurrentModelCallPolicy, CurrentPromptCacheKey } from "@xandreed/core";
|
|
8
|
+
import { ANTHROPIC_OAUTH_BETA, CLAUDE_CODE_SYSTEM } from "../auth/anthropicOAuth.js";
|
|
9
|
+
import { makeCompatLanguageModel } from "./compat.js";
|
|
10
|
+
import { makeOpenAiCodexLanguageModel } from "./openAiCodex.js";
|
|
11
|
+
import { openAiCodexAccountId } from "../auth/openAiCodexOAuth.js";
|
|
12
|
+
export const OPENCODE_CHAT_URL = "https://opencode.ai/zen/go/v1/chat/completions", OPENCODE_RESPONSES_API_URL = "https://opencode.ai/zen/v1", usesOpenCodeResponses = (modelId) => /^gpt-/i.test(modelId);
|
|
13
|
+
const anthropicOAuthTransform = (access) => (client) => client.pipe(HttpClient.mapRequest((req) => req.pipe(HttpClientRequest.setHeaders({
|
|
14
|
+
Authorization: `Bearer ${Redacted.value(access)}`,
|
|
15
|
+
"anthropic-beta": ANTHROPIC_OAUTH_BETA
|
|
16
|
+
})))), claudeCodePrompt = Prompt.make([{ role: "system", content: CLAUDE_CODE_SYSTEM }]);
|
|
17
|
+
export const prependClaudeCode = (options) => ({
|
|
18
|
+
...options,
|
|
19
|
+
prompt: Prompt.merge(claudeCodePrompt, Prompt.make(options.prompt))
|
|
20
|
+
}), withAnthropicCacheBreakpoints = (options) => {
|
|
21
|
+
const messages = Prompt.make(options.prompt).content;
|
|
22
|
+
if (messages.length === 0)
|
|
23
|
+
return options;
|
|
24
|
+
const lastSystem = messages.reduce((acc, m, i) => m.role === "system" ? i : acc, -1), nonSystem = messages.flatMap((m, i) => m.role === "system" ? [] : [i]), stampIdx = new Set([
|
|
25
|
+
...lastSystem >= 0 ? [lastSystem] : [],
|
|
26
|
+
...nonSystem.slice(-2)
|
|
27
|
+
]), stamped = messages.map((msg, i) => {
|
|
28
|
+
if (!stampIdx.has(i))
|
|
29
|
+
return msg;
|
|
30
|
+
const anthropic = msg.options.anthropic ?? {};
|
|
31
|
+
if (anthropic.cacheControl !== void 0)
|
|
32
|
+
return msg;
|
|
33
|
+
return Prompt.makeMessage(msg.role, {
|
|
34
|
+
...msg,
|
|
35
|
+
options: {
|
|
36
|
+
...msg.options,
|
|
37
|
+
anthropic: { ...anthropic, cacheControl: { type: "ephemeral" } }
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
return {
|
|
42
|
+
...options,
|
|
43
|
+
prompt: Prompt.fromMessages(stamped)
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
const missingKey = (selection) => new AuthError({
|
|
47
|
+
provider: selection.provider,
|
|
48
|
+
message: `no credential for ${selection.provider} \u2014 add one to ~/.efferent/auth.json`
|
|
49
|
+
});
|
|
50
|
+
export const buildProvider = (selection, credential, key) => {
|
|
51
|
+
const oauth = credential?.type === "oauth";
|
|
52
|
+
if (selection.provider === "opencode") {
|
|
53
|
+
if (key === void 0)
|
|
54
|
+
return Effect.fail(missingKey(selection));
|
|
55
|
+
if (usesOpenCodeResponses(selection.modelId))
|
|
56
|
+
return Effect.all({
|
|
57
|
+
cacheKey: FiberRef.get(CurrentPromptCacheKey),
|
|
58
|
+
policy: FiberRef.get(CurrentModelCallPolicy)
|
|
59
|
+
}).pipe(Effect.flatMap(({ cacheKey, policy }) => OpenAiClient.make({ apiKey: key, apiUrl: OPENCODE_RESPONSES_API_URL }).pipe(Effect.flatMap((client) => OpenAiLanguageModel.make({
|
|
60
|
+
model: selection.modelId,
|
|
61
|
+
config: {
|
|
62
|
+
strict: !1,
|
|
63
|
+
prompt_cache_key: Option.getOrElse(cacheKey, () => "efferent"),
|
|
64
|
+
...Option.match(policy, {
|
|
65
|
+
onNone: () => ({}),
|
|
66
|
+
onSome: (value) => ({
|
|
67
|
+
...value.maxOutputTokens === void 0 ? {} : { max_output_tokens: value.maxOutputTokens },
|
|
68
|
+
reasoning: {
|
|
69
|
+
effort: value.effort === "xhigh" || value.effort === "max" ? "high" : value.effort === "none" ? "low" : value.effort
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
}).pipe(Effect.provideService(OpenAiClient.OpenAiClient, client))), Effect.map((svc) => ({ svc, prependClaudeCode: !1 })))));
|
|
75
|
+
return makeCompatLanguageModel({
|
|
76
|
+
moduleName: "OpenCode",
|
|
77
|
+
chatUrl: OPENCODE_CHAT_URL,
|
|
78
|
+
apiKey: Redacted.value(key),
|
|
79
|
+
model: selection.modelId
|
|
80
|
+
}).pipe(Effect.map((svc) => ({ svc, prependClaudeCode: !1 })));
|
|
81
|
+
}
|
|
82
|
+
if (selection.provider === "google")
|
|
83
|
+
return key === void 0 ? Effect.fail(missingKey(selection)) : GoogleClient.make({ apiKey: key }).pipe(Effect.flatMap((client) => GoogleLanguageModel.make({
|
|
84
|
+
model: selection.modelId,
|
|
85
|
+
config: { toolConfig: {} }
|
|
86
|
+
}).pipe(Effect.provideService(GoogleClient.GoogleClient, client))), Effect.map((svc) => ({ svc, prependClaudeCode: !1 })));
|
|
87
|
+
if (selection.provider === "anthropic") {
|
|
88
|
+
if (key === void 0)
|
|
89
|
+
return Effect.fail(missingKey(selection));
|
|
90
|
+
return (oauth ? AnthropicClient.make({
|
|
91
|
+
apiKey: void 0,
|
|
92
|
+
transformClient: anthropicOAuthTransform(key)
|
|
93
|
+
}) : AnthropicClient.make({ apiKey: key })).pipe(Effect.flatMap((client) => AnthropicLanguageModel.make({ model: selection.modelId }).pipe(Effect.provideService(AnthropicClient.AnthropicClient, client))), Effect.map((svc) => ({ svc, prependClaudeCode: oauth })));
|
|
94
|
+
}
|
|
95
|
+
if (selection.provider === "openai-codex") {
|
|
96
|
+
if (key === void 0 || credential?.type !== "oauth")
|
|
97
|
+
return Effect.fail(missingKey(selection));
|
|
98
|
+
const accountId = Option.orElse(Option.fromNullable(credential.accountId), () => openAiCodexAccountId(Redacted.value(key)));
|
|
99
|
+
if (Option.isNone(accountId))
|
|
100
|
+
return Effect.fail(new AuthError({ provider: selection.provider, message: "the OpenAI subscription token has no ChatGPT account id \u2014 sign in again with Smith :login" }));
|
|
101
|
+
return makeOpenAiCodexLanguageModel({ model: selection.modelId, accessToken: key, accountId: accountId.value }).pipe(Effect.map((svc) => ({ svc, prependClaudeCode: !1 })));
|
|
102
|
+
}
|
|
103
|
+
if (selection.provider === "openai")
|
|
104
|
+
return key === void 0 ? Effect.fail(missingKey(selection)) : Effect.flatMap(FiberRef.get(CurrentPromptCacheKey), (cacheKey) => OpenAiClient.make({ apiKey: key }).pipe(Effect.flatMap((client) => OpenAiLanguageModel.make({
|
|
105
|
+
model: selection.modelId,
|
|
106
|
+
config: {
|
|
107
|
+
prompt_cache_key: Option.getOrElse(cacheKey, () => "efferent")
|
|
108
|
+
}
|
|
109
|
+
}).pipe(Effect.provideService(OpenAiClient.OpenAiClient, client))), Effect.map((svc) => ({ svc, prependClaudeCode: !1 }))));
|
|
110
|
+
return Effect.fail(new AuthError({
|
|
111
|
+
provider: selection.provider,
|
|
112
|
+
message: `provider "${selection.provider}" is not wired on the new line (v1: opencode, google, anthropic, openai, openai-codex)`
|
|
113
|
+
}));
|
|
114
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { AiError } from "@effect/ai";
|
|
2
|
+
import { Effect, Stream } from "effect";
|
|
3
|
+
/**
|
|
4
|
+
* Transient-failure resilience for every routed LLM call — the survival
|
|
5
|
+
* learnings from the previous line, v1-scoped:
|
|
6
|
+
*
|
|
7
|
+
* - Retry ONLY transients (429 / 5xx / transport / timeout / empty body) —
|
|
8
|
+
* never a 4xx client error or a decode failure, which are permanent.
|
|
9
|
+
* - Every request is bounded by a {@link LLM_REQUEST_TIMEOUT_MS} fiber-level
|
|
10
|
+
* timeout (official provider clients ship none), classified transient.
|
|
11
|
+
* - An HTTP 200 with no text/tool-call/reasoning content is rejected as a
|
|
12
|
+
* transient failure (the "empty response that fake-completes a turn
|
|
13
|
+
* mid-thought" class) and rides the same retries.
|
|
14
|
+
* - A 429 whose `Retry-After` exceeds {@link MAX_HONORED_RETRY_AFTER_MS} is
|
|
15
|
+
* a daily quota, not an outage — {@link classifyLlmError} calls it
|
|
16
|
+
* PERMANENT so it fails fast instead of burning retries into the same
|
|
17
|
+
* wall (the uncapped-Retry-After lesson: never park a run on a quota).
|
|
18
|
+
* The fast-retry schedule itself never sleeps on the header.
|
|
19
|
+
*
|
|
20
|
+
* The previous line's multi-phase patient ladder (30-minute interactive
|
|
21
|
+
* outage waits) is deliberately NOT ported yet — it needs a per-run
|
|
22
|
+
* interaction policy the engine doesn't carry; add it when live use demands.
|
|
23
|
+
*/
|
|
24
|
+
export declare const LLM_REQUEST_TIMEOUT_MS = 300000;
|
|
25
|
+
export declare const MAX_HONORED_RETRY_AFTER_MS = 60000;
|
|
26
|
+
export type ErrorClass = "transient" | "permanent";
|
|
27
|
+
export declare const classifyLlmError: (error: unknown) => ErrorClass;
|
|
28
|
+
/** Reject an HTTP-200-but-empty response so it retries instead of
|
|
29
|
+
* fake-completing the turn — UNLESS the loop has declared tolerance
|
|
30
|
+
* ({@link CurrentEmptyResponseTolerance}): once a run has executed tool
|
|
31
|
+
* calls, a following empty response is the model finishing, and rejecting
|
|
32
|
+
* it would park the turn in the patient outage ladder. */
|
|
33
|
+
export declare const rejectEmptyResponse: (label: string) => <A extends {
|
|
34
|
+
readonly content: ReadonlyArray<unknown>;
|
|
35
|
+
}, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E | AiError.UnknownError, R>;
|
|
36
|
+
/** Timeout + transient-only retries around one routed LLM connect. */
|
|
37
|
+
export declare const retryableLlm: (label: string) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E | AiError.UnknownError, R>;
|
|
38
|
+
/**
|
|
39
|
+
* The STREAM twin of {@link retryableLlm}, with semantics a settled call
|
|
40
|
+
* doesn't need:
|
|
41
|
+
*
|
|
42
|
+
* - Retry covers connection + PRE-first-content failures only. Once a
|
|
43
|
+
* content part has flowed to the consumer, a replayed stream would emit
|
|
44
|
+
* duplicates — so after content, failures are final (a `Ref`-armed gate on
|
|
45
|
+
* the retry schedule).
|
|
46
|
+
* - The empty guard WITHHOLDS the finish part: a finish with zero content
|
|
47
|
+
* parts fails as the transient empty-response error instead of
|
|
48
|
+
* fake-completing the turn — and, being pre-content by definition, it
|
|
49
|
+
* rides the same retries.
|
|
50
|
+
* - The timeout is per-PART idle time (bounds time-to-first-part AND a
|
|
51
|
+
* mid-stream hang), not whole-request — a long healthy stream never trips
|
|
52
|
+
* it while tokens flow.
|
|
53
|
+
*/
|
|
54
|
+
export declare const retryableLlmStream: (label: string, timeoutMs?: number) => <A, E, R>(stream: Stream.Stream<A, E, R>) => Stream.Stream<A, E | AiError.UnknownError, R>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { AiError } from "@effect/ai";
|
|
2
|
+
import { Duration, Effect, FiberRef, 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
|
+
};
|
|
15
|
+
export const classifyLlmError = (error) => {
|
|
16
|
+
const e = error;
|
|
17
|
+
if (e === null || typeof e !== "object")
|
|
18
|
+
return "permanent";
|
|
19
|
+
if (e._tag === "HttpResponseError") {
|
|
20
|
+
const status = e.response?.status ?? 0;
|
|
21
|
+
if (status === 429) {
|
|
22
|
+
const wait = retryAfterMillis(e.response?.headers);
|
|
23
|
+
return wait !== void 0 && wait > MAX_HONORED_RETRY_AFTER_MS ? "permanent" : "transient";
|
|
24
|
+
}
|
|
25
|
+
return status >= 500 ? "transient" : "permanent";
|
|
26
|
+
}
|
|
27
|
+
if (e._tag === "UnknownError" || e._tag === "HttpRequestError")
|
|
28
|
+
return "transient";
|
|
29
|
+
return "permanent";
|
|
30
|
+
};
|
|
31
|
+
const fastRetries = Schedule.exponential(Duration.seconds(1)).pipe(Schedule.jittered, Schedule.intersect(Schedule.recurs(3))), timeoutError = (label, method, timeoutMs) => new AiError.UnknownError({
|
|
32
|
+
module: "Router",
|
|
33
|
+
method,
|
|
34
|
+
description: `the ${label} request exceeded ${timeoutMs / 1000}s and was cut off`
|
|
35
|
+
}), emptyError = (label, method) => new AiError.UnknownError({
|
|
36
|
+
module: "Router",
|
|
37
|
+
method,
|
|
38
|
+
description: `${label} returned an empty response (no text, tool call, or reasoning) \u2014 treated as a transient provider failure`
|
|
39
|
+
}), isContentPart = (part) => {
|
|
40
|
+
const t = part?.type;
|
|
41
|
+
return t === "text" || t === "tool-call" || t === "reasoning";
|
|
42
|
+
};
|
|
43
|
+
export const rejectEmptyResponse = (label) => (effect) => FiberRef.get(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.timeoutFail({
|
|
44
|
+
duration: Duration.millis(LLM_REQUEST_TIMEOUT_MS),
|
|
45
|
+
onTimeout: () => timeoutError(label, "generateText", LLM_REQUEST_TIMEOUT_MS)
|
|
46
|
+
}), Effect.retry({
|
|
47
|
+
schedule: fastRetries,
|
|
48
|
+
while: (error) => classifyLlmError(error) === "transient"
|
|
49
|
+
}), Effect.tapError((error) => classifyLlmError(error) === "transient" ? Effect.logWarning(`${label}: transient failure exhausted retries: ${String(error)}`) : Effect.void));
|
|
50
|
+
const isContentPartEncoded = (part) => {
|
|
51
|
+
const p = part;
|
|
52
|
+
if (p === null || typeof p !== "object")
|
|
53
|
+
return !1;
|
|
54
|
+
if (p.type === "tool-call")
|
|
55
|
+
return !0;
|
|
56
|
+
return (p.type === "text-delta" || p.type === "reasoning-delta") && typeof p.delta === "string" && p.delta.length > 0;
|
|
57
|
+
}, isFinishPartEncoded = (part) => part?.type === "finish";
|
|
58
|
+
export const retryableLlmStream = (label, timeoutMs = LLM_REQUEST_TIMEOUT_MS) => (stream) => Stream.unwrap(Effect.gen(function* () {
|
|
59
|
+
const contentSeen = yield* Ref.make(!1);
|
|
60
|
+
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.timeoutFail(() => timeoutError(label, "streamText", timeoutMs), Duration.millis(timeoutMs)), Stream.retry(fastRetries.pipe(Schedule.whileInputEffect((error) => Ref.get(contentSeen).pipe(Effect.map((seen) => !seen && classifyLlmError(error) === "transient"))))), Stream.tapError((error) => classifyLlmError(error) === "transient" ? Effect.logWarning(`${label}: transient stream failure was final: ${String(error)}`) : Effect.void));
|
|
61
|
+
}));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { LanguageModel } from "@effect/ai";
|
|
2
|
+
import { HttpClient } from "@effect/platform";
|
|
3
|
+
import { Effect, Layer, Option, Stream } from "effect";
|
|
4
|
+
import { AuthStore, SettingsStore } from "@xandreed/core";
|
|
5
|
+
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<{
|
|
10
|
+
readonly content: ReadonlyArray<unknown>;
|
|
11
|
+
readonly usage: unknown;
|
|
12
|
+
}, unknown, AuthStore | HttpClient.HttpClient>;
|
|
13
|
+
/**
|
|
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
|
|
25
|
+
* rebuilt as a real `GenerateTextResponse`: it's a class whose
|
|
26
|
+
* text/finishReason/usage are prototype GETTERS, so a `{...res}` spread
|
|
27
|
+
* silently strips them — finishReason read as undefined and the loop treated
|
|
28
|
+
* every tool turn as "done" (the one-tool-call-then-dead bug, live-caught).
|
|
29
|
+
*/
|
|
30
|
+
export declare const stampResponse: (res: {
|
|
31
|
+
readonly content: ReadonlyArray<unknown>;
|
|
32
|
+
}, label: string) => LanguageModel.GenerateTextResponse<never>;
|
|
33
|
+
/**
|
|
34
|
+
* The stream twin of `generateWith`'s telemetry: accumulate over the flowing
|
|
35
|
+
* parts, and when the finish part passes, write the SAME span attributes and
|
|
36
|
+
* token counters a settled call would — byte-identical values on identical
|
|
37
|
+
* content. Outcome + duration finalize per terminal state (`onDone` /
|
|
38
|
+
* `tapErrorCause`), so retries inside count once, like the settled path.
|
|
39
|
+
*/
|
|
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>;
|
|
46
|
+
/**
|
|
47
|
+
* `LanguageModelLive` — the engine loop's `LanguageModel`, routed per call.
|
|
48
|
+
* Requires `SettingsStore` + `AuthStore`; brings its own fetch-backed
|
|
49
|
+
* `HttpClient` so drivers don't have to.
|
|
50
|
+
*/
|
|
51
|
+
export declare const LanguageModelLive: Layer.Layer<LanguageModel.LanguageModel, never, SettingsStore | AuthStore>;
|
|
52
|
+
/** A profile-pinned routed model. Unlike LanguageModelLive it never consults
|
|
53
|
+
* SettingsStore; dedicated agents use this so global role edits cannot
|
|
54
|
+
* silently change an evalled execution profile. */
|
|
55
|
+
export declare const LanguageModelSelectionLive: (primary: ModelSelection, fallback: Option.Option<ModelSelection>) => Layer.Layer<LanguageModel.LanguageModel, never, AuthStore>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { AiError, LanguageModel } from "@effect/ai";
|
|
2
|
+
import { FetchHttpClient, HttpClient } from "@effect/platform";
|
|
3
|
+
import { Clock, Duration, Effect, FiberRef, Layer, Metric, Option, Ref, Stream } from "effect";
|
|
4
|
+
import { AuthStore, CurrentModelCallPolicy, formatModelSelection, parseModelSelection, SettingsStore } from "@xandreed/core";
|
|
5
|
+
import { buildProvider, prependClaudeCode, withAnthropicCacheBreakpoints } from "./providers.js";
|
|
6
|
+
import {
|
|
7
|
+
classifyLlmError,
|
|
8
|
+
rejectEmptyResponse,
|
|
9
|
+
retryableLlm,
|
|
10
|
+
retryableLlmStream
|
|
11
|
+
} from "./retry.js";
|
|
12
|
+
const configError = (message) => new AiError.UnknownError({ module: "Router", method: "selection", description: message }), llmInputTokens = Metric.counter("llm.usage.input_tokens", {
|
|
13
|
+
description: "prompt tokens consumed by routed LLM calls",
|
|
14
|
+
incremental: !0
|
|
15
|
+
}), llmOutputTokens = Metric.counter("llm.usage.output_tokens", {
|
|
16
|
+
description: "completion tokens produced by routed LLM calls",
|
|
17
|
+
incremental: !0
|
|
18
|
+
}), llmRequests = Metric.counter("llm.requests", {
|
|
19
|
+
description: "routed LLM calls by final outcome (after retries)",
|
|
20
|
+
incremental: !0
|
|
21
|
+
}), llmDuration = Metric.timerWithBoundaries("llm.request.duration", [100, 250, 500, 1000, 2500, 5000, 1e4, 30000, 60000, 120000, 300000]), byModel = (metric, label) => Metric.tagged(metric, "llm.model", label), tracedContent = (content) => process.env.EFFERENT_TRACE_CONTENT === "1" ? content.slice(0, 500) : "[redacted]", shapeOptions = (selection, shouldPrependClaudeCode, options) => {
|
|
22
|
+
const cached = selection.provider === "anthropic" ? withAnthropicCacheBreakpoints(options) : options;
|
|
23
|
+
return shouldPrependClaudeCode ? prependClaudeCode(cached) : cached;
|
|
24
|
+
}, currentSelection = Effect.gen(function* () {
|
|
25
|
+
const loaded = yield* (yield* SettingsStore).load.pipe(Effect.mapError((e) => configError(`settings load failed: ${e.message}`))), raw = Option.getOrElse(loaded.model, () => "");
|
|
26
|
+
return {
|
|
27
|
+
primary: yield* Option.match(parseModelSelection(raw), {
|
|
28
|
+
onNone: () => Effect.fail(configError(raw.length === 0 ? "no model configured \u2014 choose /model in the terminal or set the models plugin's model option in efferent.config.json" : `the configured model "${raw}" is not a "<provider>:<modelId>" selection`)),
|
|
29
|
+
onSome: Effect.succeed
|
|
30
|
+
}),
|
|
31
|
+
fallback: Option.flatMap(loaded.fallbackModel, parseModelSelection),
|
|
32
|
+
effort: loaded.reasoningEffort
|
|
33
|
+
};
|
|
34
|
+
}), withConfiguredEffort = (effect, effort) => FiberRef.get(CurrentModelCallPolicy).pipe(Effect.flatMap((current) => Option.isSome(current) || Option.isNone(effort) ? effect : effect.pipe(Effect.locally(CurrentModelCallPolicy, Option.some({ effort: effort.value })))));
|
|
35
|
+
export const generateWith = (selection, options, isFallback = !1) => Effect.gen(function* () {
|
|
36
|
+
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);
|
|
37
|
+
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({
|
|
38
|
+
"llm.finish_reason": String(res.finishReason),
|
|
39
|
+
"llm.usage.input_tokens": res.usage.inputTokens ?? 0,
|
|
40
|
+
"llm.usage.output_tokens": res.usage.outputTokens ?? 0,
|
|
41
|
+
"llm.response_chars": res.text.length,
|
|
42
|
+
"llm.tool_calls": res.content.flatMap((part) => {
|
|
43
|
+
const p = part;
|
|
44
|
+
return p.type === "tool-call" ? [p.name ?? ""] : [];
|
|
45
|
+
}).join(","),
|
|
46
|
+
"llm.reasoning": tracedContent(res.reasoningText ?? ""),
|
|
47
|
+
"llm.fallback": isFallback
|
|
48
|
+
})), Effect.tap((res) => Effect.all([
|
|
49
|
+
Metric.incrementBy(byModel(llmInputTokens, label), res.usage.inputTokens ?? 0),
|
|
50
|
+
Metric.incrementBy(byModel(llmOutputTokens, label), res.usage.outputTokens ?? 0)
|
|
51
|
+
], { discard: !0 })), Effect.tapBoth({
|
|
52
|
+
onFailure: () => Metric.increment(Metric.tagged(Metric.tagged(byModel(llmRequests, label), "outcome", "error"), "fallback", isFallback ? "true" : "false")),
|
|
53
|
+
onSuccess: () => Metric.increment(Metric.tagged(Metric.tagged(byModel(llmRequests, label), "outcome", "ok"), "fallback", isFallback ? "true" : "false"))
|
|
54
|
+
}), Metric.trackDuration(byModel(llmDuration, label)), Effect.withSpan("providers.generate", {
|
|
55
|
+
attributes: { "llm.model": label }
|
|
56
|
+
}));
|
|
57
|
+
}).pipe(Effect.scoped), withFallbackRung = (primary, fallback, call) => call(primary, !1).pipe(Effect.catchAll((error) => Option.match(Option.filter(fallback, (fb) => formatModelSelection(fb) !== formatModelSelection(primary) && classifyLlmError(error) === "transient"), {
|
|
58
|
+
onNone: () => Effect.fail(error),
|
|
59
|
+
onSome: (fb) => Effect.logWarning(`${formatModelSelection(primary)} exhausted retries (${String(error).slice(0, 200)}) \u2014 falling back to ${formatModelSelection(fb)}`).pipe(Effect.zipRight(call(fb, !0)))
|
|
60
|
+
})));
|
|
61
|
+
const stampModel = (part, label) => {
|
|
62
|
+
if (typeof part !== "object" || part === null)
|
|
63
|
+
return part;
|
|
64
|
+
const p = part;
|
|
65
|
+
if (p.type !== "finish")
|
|
66
|
+
return part;
|
|
67
|
+
const metadata = typeof p.metadata === "object" && p.metadata !== null ? p.metadata : {};
|
|
68
|
+
return { ...p, metadata: { ...metadata, router: { model: label } } };
|
|
69
|
+
};
|
|
70
|
+
export const stampResponse = (res, label) => new LanguageModel.GenerateTextResponse(res.content.map((part) => stampModel(part, label)));
|
|
71
|
+
const accumulateStats = (stats, part) => {
|
|
72
|
+
const p = part;
|
|
73
|
+
if (p.type === "text-delta")
|
|
74
|
+
return { ...stats, textChars: stats.textChars + (p.delta ?? "").length };
|
|
75
|
+
if (p.type === "reasoning-delta")
|
|
76
|
+
return { ...stats, reasoning: stats.reasoning + (p.delta ?? "") };
|
|
77
|
+
if (p.type === "tool-call")
|
|
78
|
+
return { ...stats, toolNames: [...stats.toolNames, p.name ?? ""] };
|
|
79
|
+
return stats;
|
|
80
|
+
};
|
|
81
|
+
export const tapStreamTelemetry = (label) => (stream) => Stream.unwrap(Effect.gen(function* () {
|
|
82
|
+
const stats = yield* Ref.make({
|
|
83
|
+
textChars: 0,
|
|
84
|
+
reasoning: "",
|
|
85
|
+
toolNames: []
|
|
86
|
+
}), startedAt = yield* Clock.currentTimeMillis, recordDuration = Clock.currentTimeMillis.pipe(Effect.flatMap((now) => Metric.update(byModel(llmDuration, label), Duration.millis(now - startedAt))));
|
|
87
|
+
return stream.pipe(Stream.mapEffect((part) => {
|
|
88
|
+
const p = part;
|
|
89
|
+
if (p.type !== "finish")
|
|
90
|
+
return Ref.update(stats, (s) => accumulateStats(s, part)).pipe(Effect.as(part));
|
|
91
|
+
return Ref.get(stats).pipe(Effect.flatMap((s) => Effect.annotateCurrentSpan({
|
|
92
|
+
"llm.finish_reason": String(p.reason),
|
|
93
|
+
"llm.usage.input_tokens": p.usage?.inputTokens ?? 0,
|
|
94
|
+
"llm.usage.output_tokens": p.usage?.outputTokens ?? 0,
|
|
95
|
+
"llm.response_chars": s.textChars,
|
|
96
|
+
"llm.tool_calls": s.toolNames.join(","),
|
|
97
|
+
"llm.reasoning": tracedContent(s.reasoning)
|
|
98
|
+
})), Effect.zipRight(Effect.all([
|
|
99
|
+
Metric.incrementBy(byModel(llmInputTokens, label), p.usage?.inputTokens ?? 0),
|
|
100
|
+
Metric.incrementBy(byModel(llmOutputTokens, label), p.usage?.outputTokens ?? 0)
|
|
101
|
+
], { discard: !0 })), Effect.as(part));
|
|
102
|
+
}), Stream.onDone(() => Metric.increment(Metric.tagged(byModel(llmRequests, label), "outcome", "ok")).pipe(Effect.zipRight(recordDuration))), Stream.tapErrorCause(() => Metric.increment(Metric.tagged(byModel(llmRequests, label), "outcome", "error")).pipe(Effect.zipRight(recordDuration))));
|
|
103
|
+
})), streamWith = (selection, options) => Stream.unwrapScoped(Effect.gen(function* () {
|
|
104
|
+
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);
|
|
105
|
+
return built.svc.streamText(shapeOptions(selection, built.prependClaudeCode, options)).pipe(retryableLlmStream(label), Stream.map((part) => stampModel(part, label)), tapStreamTelemetry(label), Stream.withSpan("providers.generate", {
|
|
106
|
+
attributes: { "llm.model": label }
|
|
107
|
+
}));
|
|
108
|
+
})), LanguageModelLive = Layer.effect(LanguageModel.LanguageModel, Effect.gen(function* () {
|
|
109
|
+
const context = yield* Effect.context(), http = yield* HttpClient.HttpClient;
|
|
110
|
+
return {
|
|
111
|
+
generateText: (options) => currentSelection.pipe(Effect.flatMap(({ effort, fallback, primary }) => withConfiguredEffort(withFallbackRung(primary, fallback, (selection, isFallback) => generateWith(selection, options, isFallback)), effort)), Effect.provide(context), Effect.provideService(HttpClient.HttpClient, http)),
|
|
112
|
+
generateObject: () => Effect.fail(configError("generateObject is not wired on the new line yet")),
|
|
113
|
+
streamText: (options) => Stream.unwrapScoped(currentSelection.pipe(Effect.flatMap(({ effort, primary }) => FiberRef.get(CurrentModelCallPolicy).pipe(Effect.flatMap((current) => {
|
|
114
|
+
const stream = streamWith(primary, options);
|
|
115
|
+
return Option.isSome(current) || Option.isNone(effort) ? Effect.succeed(stream) : Effect.locallyScoped(CurrentModelCallPolicy, Option.some({ effort: effort.value })).pipe(Effect.as(stream));
|
|
116
|
+
}))))).pipe(Stream.provideSomeContext(context), Stream.provideService(HttpClient.HttpClient, http))
|
|
117
|
+
};
|
|
118
|
+
})).pipe(Layer.provide(FetchHttpClient.layer)), LanguageModelSelectionLive = (primary, fallback) => Layer.effect(LanguageModel.LanguageModel, Effect.gen(function* () {
|
|
119
|
+
const context = yield* Effect.context(), http = yield* HttpClient.HttpClient;
|
|
120
|
+
return {
|
|
121
|
+
generateText: (options) => withFallbackRung(primary, fallback, (selection, isFallback) => generateWith(selection, options, isFallback)).pipe(Effect.provide(context), Effect.provideService(HttpClient.HttpClient, http)),
|
|
122
|
+
generateObject: () => Effect.fail(configError("generateObject is not wired on the new line yet")),
|
|
123
|
+
streamText: (options) => streamWith(primary, options).pipe(Stream.provideSomeContext(context), Stream.provideService(HttpClient.HttpClient, http))
|
|
124
|
+
};
|
|
125
|
+
})).pipe(Layer.provide(FetchHttpClient.layer));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { AiError } from "@effect/ai";
|
|
2
|
+
import type { Response } from "@effect/ai";
|
|
3
|
+
import { Stream } from "effect";
|
|
4
|
+
/**
|
|
5
|
+
* The OpenAI-compatible SSE wire → `@effect/ai` `StreamPartEncoded`s, as a
|
|
6
|
+
* PURE state machine over `data:` lines. Owned here so `compat.ts` imports
|
|
7
|
+
* one direction only (this module knows nothing of fetch or auth).
|
|
8
|
+
*
|
|
9
|
+
* Invariant ("content-part identity"): a content part is a NON-EMPTY
|
|
10
|
+
* text/reasoning delta or a completed tool call — empty deltas emit nothing.
|
|
11
|
+
* The retry gate (S2) and the engine fold (S3) both hinge on that.
|
|
12
|
+
*
|
|
13
|
+
* A DONE sentinel is concatenated after the wire lines so the flush (open
|
|
14
|
+
* chunk ends → accumulated tool calls → finish) runs even when the gateway
|
|
15
|
+
* never sends `[DONE]`; a second sentinel is idempotent via `flushed`.
|
|
16
|
+
*/
|
|
17
|
+
/** The usage object OpenAI-compatible completions carry (streaming sends it
|
|
18
|
+
* on the last chunk under `stream_options: {include_usage: true}`). */
|
|
19
|
+
export interface CompletionUsage {
|
|
20
|
+
readonly prompt_tokens?: number;
|
|
21
|
+
readonly completion_tokens?: number;
|
|
22
|
+
readonly total_tokens?: number;
|
|
23
|
+
/** DeepSeek-style cache accounting. */
|
|
24
|
+
readonly prompt_cache_hit_tokens?: number;
|
|
25
|
+
/** Moonshot-style cache accounting. */
|
|
26
|
+
readonly cached_tokens?: number;
|
|
27
|
+
/** OpenAI-native cache accounting. */
|
|
28
|
+
readonly prompt_tokens_details?: {
|
|
29
|
+
readonly cached_tokens?: number;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** The vendor cached-token fallback chain, shared with the non-streaming
|
|
33
|
+
* parser — the gateway fronts upstreams with three cache vocabularies. */
|
|
34
|
+
export declare const usageFromCompletion: (usage: CompletionUsage | undefined) => {
|
|
35
|
+
inputTokens: number;
|
|
36
|
+
outputTokens: number;
|
|
37
|
+
totalTokens: number;
|
|
38
|
+
cachedInputTokens: number;
|
|
39
|
+
};
|
|
40
|
+
export declare const finishReasonFromWire: (raw: string | undefined) => "stop" | "length" | "content-filter" | "tool-calls";
|
|
41
|
+
/** Parse a streaming chat-completions body into encoded stream parts. */
|
|
42
|
+
export declare const sseStreamParts: (options: {
|
|
43
|
+
readonly moduleName: string;
|
|
44
|
+
readonly body: ReadableStream<Uint8Array>;
|
|
45
|
+
}) => Stream.Stream<Response.StreamPartEncoded, AiError.AiError>;
|
package/dist/llm/sse.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { AiError } from "@effect/ai";
|
|
2
|
+
import { Effect, Option, Stream } from "effect";
|
|
3
|
+
export const usageFromCompletion = (usage) => ({
|
|
4
|
+
inputTokens: usage?.prompt_tokens ?? 0,
|
|
5
|
+
outputTokens: usage?.completion_tokens ?? 0,
|
|
6
|
+
totalTokens: usage?.total_tokens ?? 0,
|
|
7
|
+
cachedInputTokens: usage?.prompt_cache_hit_tokens ?? usage?.cached_tokens ?? usage?.prompt_tokens_details?.cached_tokens ?? 0
|
|
8
|
+
}), finishReasonFromWire = (raw) => {
|
|
9
|
+
if (raw === "tool_calls")
|
|
10
|
+
return "tool-calls";
|
|
11
|
+
if (raw === "length")
|
|
12
|
+
return "length";
|
|
13
|
+
if (raw === "content_filter")
|
|
14
|
+
return "content-filter";
|
|
15
|
+
return "stop";
|
|
16
|
+
};
|
|
17
|
+
const initialState = {
|
|
18
|
+
textOpen: Option.none(),
|
|
19
|
+
reasoningOpen: Option.none(),
|
|
20
|
+
textSeq: 0,
|
|
21
|
+
reasoningSeq: 0,
|
|
22
|
+
toolCalls: [],
|
|
23
|
+
finishReason: Option.none(),
|
|
24
|
+
usage: Option.none(),
|
|
25
|
+
flushed: !1
|
|
26
|
+
}, emitThen = (emit, f) => {
|
|
27
|
+
const [state, more] = f(emit[0]);
|
|
28
|
+
return [state, [...emit[1], ...more]];
|
|
29
|
+
}, closeText = (state) => Option.match(state.textOpen, {
|
|
30
|
+
onNone: () => [state, []],
|
|
31
|
+
onSome: (id) => [{ ...state, textOpen: Option.none() }, [{ type: "text-end", id }]]
|
|
32
|
+
}), closeReasoning = (state) => Option.match(state.reasoningOpen, {
|
|
33
|
+
onNone: () => [state, []],
|
|
34
|
+
onSome: (id) => [
|
|
35
|
+
{ ...state, reasoningOpen: Option.none() },
|
|
36
|
+
[{ type: "reasoning-end", id }]
|
|
37
|
+
]
|
|
38
|
+
}), emitReasoning = (state, delta) => emitThen(closeText(state), (closed) => Option.match(closed.reasoningOpen, {
|
|
39
|
+
onNone: () => {
|
|
40
|
+
const id = `reasoning-${closed.reasoningSeq + 1}`;
|
|
41
|
+
return [
|
|
42
|
+
{ ...closed, reasoningOpen: Option.some(id), reasoningSeq: closed.reasoningSeq + 1 },
|
|
43
|
+
[
|
|
44
|
+
{ type: "reasoning-start", id },
|
|
45
|
+
{ type: "reasoning-delta", id, delta }
|
|
46
|
+
]
|
|
47
|
+
];
|
|
48
|
+
},
|
|
49
|
+
onSome: (id) => [closed, [{ type: "reasoning-delta", id, delta }]]
|
|
50
|
+
})), emitText = (state, delta) => emitThen(closeReasoning(state), (closed) => Option.match(closed.textOpen, {
|
|
51
|
+
onNone: () => {
|
|
52
|
+
const id = `text-${closed.textSeq + 1}`;
|
|
53
|
+
return [
|
|
54
|
+
{ ...closed, textOpen: Option.some(id), textSeq: closed.textSeq + 1 },
|
|
55
|
+
[
|
|
56
|
+
{ type: "text-start", id },
|
|
57
|
+
{ type: "text-delta", id, delta }
|
|
58
|
+
]
|
|
59
|
+
];
|
|
60
|
+
},
|
|
61
|
+
onSome: (id) => [closed, [{ type: "text-delta", id, delta }]]
|
|
62
|
+
})), emptyAccum = { id: "", name: "", args: "" }, mergeToolFragment = (calls, fragment) => {
|
|
63
|
+
const index = fragment.index ?? 0;
|
|
64
|
+
return (calls.length > index ? calls : [...calls, ...Array.from({ length: index + 1 - calls.length }, () => emptyAccum)]).map((call, at) => at === index ? {
|
|
65
|
+
id: call.id.length > 0 ? call.id : fragment.id ?? "",
|
|
66
|
+
name: call.name.length > 0 ? call.name : fragment.function?.name ?? "",
|
|
67
|
+
args: call.args + (fragment.function?.arguments ?? "")
|
|
68
|
+
} : call);
|
|
69
|
+
}, malformed = (moduleName, description) => new AiError.MalformedOutput({ module: moduleName, method: "streamText", description }), applyChunk = (moduleName, state, chunk) => {
|
|
70
|
+
if (chunk.error !== void 0 && chunk.error !== null)
|
|
71
|
+
return Effect.fail(new AiError.UnknownError({
|
|
72
|
+
module: moduleName,
|
|
73
|
+
method: "streamText",
|
|
74
|
+
description: `the stream carried an error chunk: ${JSON.stringify(chunk.error).slice(0, 500)}`
|
|
75
|
+
}));
|
|
76
|
+
const choice = chunk.choices?.[0], delta = choice?.delta, reasoning = delta?.reasoning_content ?? delta?.reasoning, content = delta?.content, afterReasoning = typeof reasoning === "string" && reasoning.length > 0 ? emitReasoning(state, reasoning) : [state, []], afterText = emitThen(afterReasoning, (s) => typeof content === "string" && content.length > 0 ? emitText(s, content) : [s, []]), [folded, parts] = afterText, next = {
|
|
77
|
+
...folded,
|
|
78
|
+
toolCalls: (delta?.tool_calls ?? []).reduce(mergeToolFragment, folded.toolCalls),
|
|
79
|
+
finishReason: typeof choice?.finish_reason === "string" ? Option.some(choice.finish_reason) : folded.finishReason,
|
|
80
|
+
usage: chunk.usage !== null && chunk.usage !== void 0 ? Option.some(chunk.usage) : folded.usage
|
|
81
|
+
};
|
|
82
|
+
return Effect.succeed([next, parts]);
|
|
83
|
+
}, flush = (moduleName, state) => state.flushed ? Effect.succeed([state, []]) : Effect.gen(function* () {
|
|
84
|
+
const [closed, closeParts] = emitThen(closeText(state), closeReasoning), toolParts = yield* Effect.forEach(closed.toolCalls, (call, index) => Effect.try({
|
|
85
|
+
try: () => ({
|
|
86
|
+
type: "tool-call",
|
|
87
|
+
id: call.id.length > 0 ? call.id : `call_${index}`,
|
|
88
|
+
name: call.name,
|
|
89
|
+
params: JSON.parse(call.args.length > 0 ? call.args : "{}")
|
|
90
|
+
}),
|
|
91
|
+
catch: () => malformed(moduleName, `tool call ${call.name.length > 0 ? call.name : "?"} carried unparseable JSON arguments`)
|
|
92
|
+
})), finish = {
|
|
93
|
+
type: "finish",
|
|
94
|
+
reason: toolParts.length > 0 ? "tool-calls" : finishReasonFromWire(Option.getOrUndefined(closed.finishReason)),
|
|
95
|
+
usage: usageFromCompletion(Option.getOrUndefined(closed.usage))
|
|
96
|
+
};
|
|
97
|
+
return [
|
|
98
|
+
{ ...closed, flushed: !0 },
|
|
99
|
+
[...closeParts, ...toolParts, finish]
|
|
100
|
+
];
|
|
101
|
+
}), DONE = "[DONE]", step = (moduleName, state, payload) => payload === DONE ? flush(moduleName, state) : Effect.try({
|
|
102
|
+
try: () => JSON.parse(payload),
|
|
103
|
+
catch: () => malformed(moduleName, `a data: line was not JSON: ${payload.slice(0, 200)}`)
|
|
104
|
+
}).pipe(Effect.flatMap((chunk) => applyChunk(moduleName, state, chunk)));
|
|
105
|
+
export const sseStreamParts = (options) => Stream.fromReadableStream({
|
|
106
|
+
evaluate: () => options.body,
|
|
107
|
+
onError: (cause) => new AiError.UnknownError({
|
|
108
|
+
module: options.moduleName,
|
|
109
|
+
method: "streamText",
|
|
110
|
+
description: `the response body stream failed: ${String(cause)}`,
|
|
111
|
+
cause
|
|
112
|
+
})
|
|
113
|
+
}).pipe(Stream.decodeText(), Stream.splitLines, Stream.filterMap((line) => {
|
|
114
|
+
const trimmed = line.trim();
|
|
115
|
+
return trimmed.startsWith("data:") ? Option.some(trimmed.slice(5).trim()) : Option.none();
|
|
116
|
+
}), Stream.concat(Stream.succeed(DONE)), Stream.mapAccumEffect(initialState, (state, payload) => step(options.moduleName, state, payload)), Stream.flattenIterables);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import { AuthStore, SettingsStore, UtilityLlm } from "@xandreed/core";
|
|
3
|
+
/**
|
|
4
|
+
* The fast helper tier: one-shot completions on `fastModel ?? model`. No
|
|
5
|
+
* toolkit, no cache breakpoints, no loop — a title/digest call that can never
|
|
6
|
+
* park a turn.
|
|
7
|
+
*/
|
|
8
|
+
export declare const UtilityLlmLive: Layer.Layer<UtilityLlm, never, SettingsStore | AuthStore>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Prompt } from "@effect/ai";
|
|
2
|
+
import { FetchHttpClient, HttpClient } from "@effect/platform";
|
|
3
|
+
import { Effect, Layer, Option } from "effect";
|
|
4
|
+
import { AuthStore, parseModelSelection, SettingsStore, UtilityError, UtilityLlm } from "@xandreed/core";
|
|
5
|
+
import { generateWith } from "./router.js";
|
|
6
|
+
export const UtilityLlmLive = Layer.effect(UtilityLlm, Effect.gen(function* () {
|
|
7
|
+
const context = yield* Effect.context(), http = yield* HttpClient.HttpClient, settings = yield* SettingsStore;
|
|
8
|
+
return {
|
|
9
|
+
complete: (prompt) => Effect.gen(function* () {
|
|
10
|
+
const loaded = yield* settings.load.pipe(Effect.mapError((e) => new UtilityError({ message: e.message }))), raw = Option.getOrElse(Option.orElse(loaded.fastModel, () => loaded.model), () => ""), selection = yield* Option.match(parseModelSelection(raw), {
|
|
11
|
+
onNone: () => Effect.fail(new UtilityError({ message: "no fast/general model configured" })),
|
|
12
|
+
onSome: Effect.succeed
|
|
13
|
+
}), res = yield* generateWith(selection, {
|
|
14
|
+
prompt: Prompt.make(prompt),
|
|
15
|
+
tools: [],
|
|
16
|
+
toolChoice: "none",
|
|
17
|
+
responseFormat: { type: "text" }
|
|
18
|
+
}).pipe(Effect.mapError((e) => new UtilityError({ message: String(e) })), Effect.provide(context), Effect.provideService(HttpClient.HttpClient, http)), text = res.content.flatMap((p) => {
|
|
19
|
+
const part = p;
|
|
20
|
+
return part.type === "text" ? [part.text ?? ""] : [];
|
|
21
|
+
}).join(""), usage = res.usage;
|
|
22
|
+
return {
|
|
23
|
+
text,
|
|
24
|
+
usage: {
|
|
25
|
+
inputTokens: usage?.inputTokens ?? 0,
|
|
26
|
+
outputTokens: usage?.outputTokens ?? 0,
|
|
27
|
+
totalTokens: usage?.totalTokens ?? 0,
|
|
28
|
+
cacheReadTokens: usage?.cachedInputTokens ?? 0
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
})
|
|
32
|
+
};
|
|
33
|
+
})).pipe(Layer.provide(FetchHttpClient.layer));
|