@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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +5 -0
  3. package/dist/auth/anthropicOAuth.d.ts +50 -0
  4. package/dist/auth/anthropicOAuth.js +90 -0
  5. package/dist/auth/anthropicOAuth.test.d.ts +1 -0
  6. package/dist/auth/localAuth.d.ts +8 -0
  7. package/dist/auth/localAuth.js +90 -0
  8. package/dist/auth/localAuth.test.d.ts +1 -0
  9. package/dist/auth/openAiCodexOAuth.d.ts +25 -0
  10. package/dist/auth/openAiCodexOAuth.js +58 -0
  11. package/dist/index.d.ts +19 -0
  12. package/dist/index.js +53 -0
  13. package/dist/llm/compat.d.ts +77 -0
  14. package/dist/llm/compat.js +215 -0
  15. package/dist/llm/compat.test.d.ts +1 -0
  16. package/dist/llm/modelCatalog.d.ts +10 -0
  17. package/dist/llm/modelCatalog.js +58 -0
  18. package/dist/llm/modelCatalog.test.d.ts +1 -0
  19. package/dist/llm/openAiCodex.d.ts +18 -0
  20. package/dist/llm/openAiCodex.js +99 -0
  21. package/dist/llm/openAiCodexWebSocket.d.ts +14 -0
  22. package/dist/llm/openAiCodexWebSocket.js +126 -0
  23. package/dist/llm/providers.d.ts +37 -0
  24. package/dist/llm/providers.js +114 -0
  25. package/dist/llm/providers.test.d.ts +1 -0
  26. package/dist/llm/retry.d.ts +54 -0
  27. package/dist/llm/retry.js +61 -0
  28. package/dist/llm/retry.test.d.ts +1 -0
  29. package/dist/llm/router.d.ts +55 -0
  30. package/dist/llm/router.js +125 -0
  31. package/dist/llm/router.test.d.ts +1 -0
  32. package/dist/llm/sse.d.ts +45 -0
  33. package/dist/llm/sse.js +116 -0
  34. package/dist/llm/sse.test.d.ts +1 -0
  35. package/dist/llm/utilityLlm.d.ts +8 -0
  36. package/dist/llm/utilityLlm.js +33 -0
  37. package/dist/plugin.adapter.d.ts +2 -0
  38. package/dist/plugin.adapter.js +27 -0
  39. package/dist/plugin.adapter.test.d.ts +1 -0
  40. package/dist/settings/localSettings.d.ts +3 -0
  41. package/dist/settings/localSettings.js +72 -0
  42. package/dist/settings/localSettings.test.d.ts +1 -0
  43. package/dist/settings/roleView.d.ts +11 -0
  44. package/dist/settings/roleView.js +11 -0
  45. package/dist/settings/roleView.test.d.ts +1 -0
  46. 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>;
@@ -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));
@@ -0,0 +1,2 @@
1
+ export declare const modelsPlugin: import("@xandreed/core").Plugin;
2
+ export default modelsPlugin;