@xandreed/plugin-models 0.12.0 → 0.14.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 (67) hide show
  1. package/README.md +1 -1
  2. package/dist/auth/anthropicOAuth.d.ts +16 -2
  3. package/dist/auth/anthropicOAuth.js +14 -11
  4. package/dist/auth/credential-input.entity.d.ts +34 -0
  5. package/dist/auth/credential-input.entity.functions.d.ts +8 -0
  6. package/dist/auth/credential-input.entity.functions.js +9 -0
  7. package/dist/auth/credential-input.entity.js +20 -0
  8. package/dist/auth/credential-refresh.entity.d.ts +9 -0
  9. package/dist/auth/credential-refresh.entity.functions.d.ts +15 -0
  10. package/dist/auth/credential-refresh.entity.functions.js +12 -0
  11. package/dist/auth/credential-refresh.entity.functions.test.d.ts +1 -0
  12. package/dist/auth/credential-refresh.entity.js +2 -0
  13. package/dist/auth/localAuth.d.ts +2 -1
  14. package/dist/auth/localAuth.js +41 -43
  15. package/dist/auth/memory-auth.adapter.d.ts +27 -0
  16. package/dist/auth/memory-auth.adapter.js +47 -0
  17. package/dist/auth/memory-auth.adapter.test.d.ts +1 -0
  18. package/dist/auth/openAiCodexOAuth.d.ts +3 -2
  19. package/dist/auth/openAiCodexOAuth.js +9 -8
  20. package/dist/index.d.ts +23 -6
  21. package/dist/index.js +28 -1
  22. package/dist/llm/compat.d.ts +39 -8
  23. package/dist/llm/compat.js +80 -29
  24. package/dist/llm/modelCatalog.d.ts +20 -1
  25. package/dist/llm/modelCatalog.js +19 -7
  26. package/dist/llm/openAiCodex.d.ts +4 -1
  27. package/dist/llm/openAiCodex.js +2 -2
  28. package/dist/llm/openAiCodexWebSocket.d.ts +23 -3
  29. package/dist/llm/openAiCodexWebSocket.js +125 -71
  30. package/dist/llm/openAiCodexWebSocket.test.d.ts +1 -0
  31. package/dist/llm/providers.d.ts +18 -4
  32. package/dist/llm/providers.js +49 -24
  33. package/dist/llm/router.d.ts +11 -1
  34. package/dist/llm/router.js +55 -31
  35. package/dist/llm/sse.d.ts +25 -3
  36. package/dist/llm/sse.js +8 -8
  37. package/dist/llm/utilityLlm.d.ts +1 -1
  38. package/dist/llm/utilityLlm.js +4 -4
  39. package/dist/plugin.adapter.d.ts +64 -4
  40. package/dist/plugin.adapter.js +48 -10
  41. package/dist/ports/host-credentials.port.d.ts +21 -0
  42. package/dist/ports/host-credentials.port.js +4 -0
  43. package/dist/ports/model-endpoints.port.d.ts +16 -3
  44. package/dist/ports/model-endpoints.port.js +7 -3
  45. package/dist/ports/models-transport.port.d.ts +46 -0
  46. package/dist/ports/models-transport.port.js +4 -0
  47. package/dist/settings/localSettings.js +6 -46
  48. package/dist/settings/memory-settings.adapter.d.ts +10 -0
  49. package/dist/settings/memory-settings.adapter.js +11 -0
  50. package/dist/settings/memory-settings.adapter.test.d.ts +1 -0
  51. package/dist/settings/settings-value.entity.d.ts +7 -0
  52. package/dist/settings/settings-value.entity.functions.d.ts +29 -0
  53. package/dist/settings/settings-value.entity.functions.js +43 -0
  54. package/dist/settings/settings-value.entity.functions.test.d.ts +1 -0
  55. package/dist/settings/settings-value.entity.js +2 -0
  56. package/dist/transport/http-proxy.entity.d.ts +90 -0
  57. package/dist/transport/http-proxy.entity.functions.d.ts +51 -0
  58. package/dist/transport/http-proxy.entity.functions.js +112 -0
  59. package/dist/transport/http-proxy.entity.functions.test.d.ts +1 -0
  60. package/dist/transport/http-proxy.entity.js +42 -0
  61. package/dist/transport/models-transport.adapter.d.ts +48 -0
  62. package/dist/transport/models-transport.adapter.js +102 -0
  63. package/dist/transport/models-transport.adapter.test.d.ts +1 -0
  64. package/dist/transport/recording-proxy.testing.d.ts +33 -0
  65. package/dist/transport/undici-fetch.adapter.d.ts +13 -0
  66. package/dist/transport/undici-fetch.adapter.js +1 -0
  67. package/package.json +10 -9
@@ -1,9 +1,11 @@
1
1
  import { LanguageModel } from "effect/ai";
2
2
  import { HttpClient } from "effect/http";
3
- import { Effect, Redacted } from "effect";
3
+ import { Effect, Option, Redacted } from "effect";
4
4
  import type { Scope } from "effect";
5
5
  import { AuthError } from "@xandreed/core";
6
6
  import type { Credential, ModelSelection } from "@xandreed/core";
7
+ import { usesOpenCodeResponses } from "./modelCatalog.js";
8
+ import type { ModelEndpoint } from "../ports/model-endpoints.port.js";
7
9
  /**
8
10
  * Per-provider `LanguageModel` construction. Built PER REQUEST from a
9
11
  * freshly-resolved key (never captured at layer build), so a credential or
@@ -20,12 +22,13 @@ export declare const OPENCODE_CHAT_URL = "https://opencode.ai/zen/go/v1/chat/com
20
22
  export declare const OPENCODE_RESPONSES_API_URL = "https://opencode.ai/zen/v1";
21
23
  /** Gemini through its OpenAI-compatible chat completions (Bearer API key). */
22
24
  export declare const GOOGLE_CHAT_URL = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions";
23
- /** Pure protocol routing decision; provider details stay inside this adapter. */
24
- export declare const usesOpenCodeResponses: (modelId: string) => boolean;
25
+ export { usesOpenCodeResponses };
25
26
  export interface BuiltProvider {
26
27
  readonly svc: LanguageModel.LanguageModel;
27
28
  /** Anthropic subscription auth requires the Claude Code system block first. */
28
29
  readonly prependClaudeCode: boolean;
30
+ /** The route sends images; when it does not, `buildProvider` has the model send them as label lines. */
31
+ readonly images: boolean;
29
32
  }
30
33
  /** Prepend the Claude Code system block (subscription-auth requirement). */
31
34
  export declare const prependClaudeCode: (options: unknown) => unknown;
@@ -40,5 +43,16 @@ export declare const prependClaudeCode: (options: unknown) => unknown;
40
43
  export declare const withAnthropicCacheBreakpoints: (options: unknown) => unknown;
41
44
  /** `<url>/chat/completions` of an OpenAI-compatible base URL. */
42
45
  export declare const chatCompletionsUrl: (baseUrl: string) => string;
43
- /** Preserve adapter descriptors and describe the public configuration of SDK-backed models. */
46
+ /**
47
+ * An endpoint's own key: `apiKey`, then the environment variable `apiKeyEnv`
48
+ * names when it is set. An empty one is still the endpoint's own: it sends
49
+ * no key, and the AuthStore's is not used.
50
+ */
51
+ export declare const endpointOwnKey: (endpoint: ModelEndpoint) => Option.Option<Redacted.Redacted<string>>;
52
+ /**
53
+ * Preserve adapter descriptors and describe the public configuration of
54
+ * SDK-backed models. On a route that reads no images (`imageInputFor`, a
55
+ * configured endpoint's `images`), every request is sent with its images as
56
+ * label lines (`promptWithImageLabels`) instead of a provider error.
57
+ */
44
58
  export declare const buildProvider: (selection: ModelSelection, credential: Credential | undefined, key: Redacted.Redacted<string> | undefined) => Effect.Effect<BuiltProvider, AuthError, HttpClient.HttpClient | Scope.Scope>;
@@ -3,14 +3,29 @@ import { AnthropicClient, AnthropicLanguageModel } from "@effect/ai-anthropic";
3
3
  import { OpenAiClient, OpenAiLanguageModel } from "@effect/ai-openai";
4
4
  import { HttpClient, HttpClientRequest } from "effect/http";
5
5
  import { Effect, Option, Redacted } from "effect";
6
- import { AuthError, CurrentModelCallPolicy, CurrentPromptCacheKey, describeModel, modelRequestDescriptorOf } from "@xandreed/core";
6
+ import { AuthError, CurrentModelCallPolicy, CurrentPromptCacheKey, describeModel, formatModelSelection, modelRequestDescriptorOf } from "@xandreed/core";
7
7
  import { ANTHROPIC_OAUTH_BETA, CLAUDE_CODE_SYSTEM } from "../auth/anthropicOAuth.js";
8
- import { makeCompatLanguageModel } from "./compat.js";
9
- import { makeOpenAiCodexLanguageModel } from "./openAiCodex.js";
8
+ import { makeCompatLanguageModel, promptWithImageLabels } from "./compat.js";
9
+ import { imageInputFor, usesOpenCodeResponses } from "./modelCatalog.js";
10
+ import { makeOpenAiCodexLanguageModel, OPENAI_CODEX_API_URL } from "./openAiCodex.js";
10
11
  import { openAiCodexAccountId } from "../auth/openAiCodexOAuth.js";
11
12
  import { CurrentModelEndpoints } from "../ports/model-endpoints.port.js";
12
- export const OPENCODE_CHAT_URL = "https://opencode.ai/zen/go/v1/chat/completions", OPENCODE_RESPONSES_API_URL = "https://opencode.ai/zen/v1", GOOGLE_CHAT_URL = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", usesOpenCodeResponses = (modelId) => /^gpt-/i.test(modelId);
13
- const anthropicOAuthTransform = (access) => (client) => client.pipe(HttpClient.mapRequest((req) => req.pipe(HttpClientRequest.setHeaders({
13
+ import { ModelsTransport } from "../ports/models-transport.port.js";
14
+ export const OPENCODE_CHAT_URL = "https://opencode.ai/zen/go/v1/chat/completions", OPENCODE_RESPONSES_API_URL = "https://opencode.ai/zen/v1", GOOGLE_CHAT_URL = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions";
15
+
16
+ export { usesOpenCodeResponses };
17
+ const withImageLabels = (model) => {
18
+ const relabeled = (options) => ({
19
+ ...options,
20
+ prompt: promptWithImageLabels(Prompt.make(options.prompt))
21
+ });
22
+ return {
23
+ ...model,
24
+ generateText: (options) => model.generateText(relabeled(options)),
25
+ generateObject: (options) => model.generateObject(relabeled(options)),
26
+ streamText: (options) => model.streamText(relabeled(options))
27
+ };
28
+ }, anthropicOAuthTransform = (access) => (client) => client.pipe(HttpClient.mapRequest((req) => req.pipe(HttpClientRequest.setHeaders({
14
29
  Authorization: `Bearer ${Redacted.value(access)}`,
15
30
  "anthropic-beta": ANTHROPIC_OAUTH_BETA
16
31
  })))), claudeCodePrompt = Prompt.make([{ role: "system", content: CLAUDE_CODE_SYSTEM }]);
@@ -45,7 +60,7 @@ export const prependClaudeCode = (options) => ({
45
60
  };
46
61
  const missingKey = (selection) => new AuthError({
47
62
  provider: selection.provider,
48
- message: `no credential for ${selection.provider} \u2014 add one to ~/.efferent/auth.json`
63
+ message: `no credential for ${selection.provider} \u2014 sign in through the AuthStore (storage "local" keeps it in ~/.efferent/runtime/auth.json), or give the models plugin one in credentials or credentialEnv (storage "memory")`
49
64
  }), openCodeResponsesConfig = (cacheKey, policy) => ({
50
65
  strictJsonSchema: !1,
51
66
  prompt_cache_key: Option.getOrElse(cacheKey, () => "efferent"),
@@ -59,22 +74,27 @@ const missingKey = (selection) => new AuthError({
59
74
  })
60
75
  })
61
76
  });
62
- export const chatCompletionsUrl = (baseUrl) => `${baseUrl.replace(/\/+$/, "")}/chat/completions`;
63
- const endpointKey = (endpoint, stored) => Option.fromNullishOr(endpoint.apiKey).pipe(Option.orElse(() => Option.flatMap(Option.fromNullishOr(endpoint.apiKeyEnv), (name) => Option.fromNullishOr(process.env[name]))), Option.orElse(() => Option.map(Option.fromNullishOr(stored), Redacted.value)), Option.filter((value) => value.length > 0)), endpointModel = (selection, url, key, standardOnly) => makeCompatLanguageModel({
77
+ export const chatCompletionsUrl = (baseUrl) => `${baseUrl.replace(/\/+$/, "")}/chat/completions`, endpointOwnKey = (endpoint) => Option.fromNullishOr(endpoint.apiKey).pipe(Option.map((key) => Redacted.isRedacted(key) ? key : Redacted.make(key)), Option.orElse(() => Option.flatMap(Option.fromNullishOr(endpoint.apiKeyEnv), (name) => Option.map(Option.fromNullishOr(process.env[name]), (value) => Redacted.make(value)))));
78
+ const endpointKey = (endpoint, stored) => endpointOwnKey(endpoint).pipe(Option.orElse(() => Option.fromNullishOr(stored)), Option.map(Redacted.value), Option.filter((value) => value.length > 0)), compatFetch = (transport) => Option.match(transport, { onNone: () => ({}), onSome: ({ fetch }) => ({ fetchImpl: fetch }) }), codexWebSocketProxy = (transport) => Option.match(Option.flatMap(transport, (found) => Option.fromNullishOr(found.webSocketAgent)), {
79
+ onNone: () => Effect.succeed(Option.none()),
80
+ onSome: (route) => route(new URL(OPENAI_CODEX_API_URL)).pipe(Effect.mapError((error) => new AuthError({ provider: "openai-codex", message: error.message })))
81
+ }), endpointModel = (selection, url, key, standardOnly, images, transport) => makeCompatLanguageModel({
64
82
  moduleName: selection.provider,
65
83
  chatUrl: chatCompletionsUrl(url),
66
84
  model: selection.modelId,
67
85
  standardOnly,
86
+ images,
87
+ ...compatFetch(transport),
68
88
  ...Option.match(key, { onNone: () => ({}), onSome: (apiKey) => ({ apiKey }) })
69
- }).pipe(Effect.map((svc) => ({ svc, prependClaudeCode: !1 }))), buildProviderService = (selection, credential, key) => Effect.flatMap(Effect.service(CurrentModelEndpoints), (endpoints) => {
89
+ }).pipe(Effect.map((svc) => ({ svc, prependClaudeCode: !1, images }))), buildProviderService = (selection, credential, key) => Effect.flatMap(Effect.all({ endpoints: Effect.service(CurrentModelEndpoints), transport: Effect.serviceOption(ModelsTransport) }), ({ endpoints, transport }) => {
70
90
  const endpoint = Option.fromNullishOr(endpoints[selection.provider]);
71
91
  if (Option.isSome(endpoint))
72
- return endpointModel(selection, endpoint.value.url, endpointKey(endpoint.value, key), endpoint.value.standardOnly ?? !0);
92
+ return endpointModel(selection, endpoint.value.url, endpointKey(endpoint.value, key), endpoint.value.standardOnly ?? !0, endpoint.value.images ?? !1, transport);
73
93
  if (credential?.type === "local" && credential.baseUrl !== void 0)
74
- return endpointModel(selection, credential.baseUrl, Option.none(), !0);
75
- return buildBuiltInProvider(selection, credential, key);
76
- }), buildBuiltInProvider = (selection, credential, key) => {
77
- const oauth = credential?.type === "oauth";
94
+ return endpointModel(selection, credential.baseUrl, Option.none(), !0, !1, transport);
95
+ return buildBuiltInProvider(selection, credential, key, transport);
96
+ }), buildBuiltInProvider = (selection, credential, key, transport) => {
97
+ const oauth = credential?.type === "oauth", images = imageInputFor(formatModelSelection(selection));
78
98
  if (selection.provider === "opencode") {
79
99
  if (key === void 0)
80
100
  return Effect.fail(missingKey(selection));
@@ -85,13 +105,15 @@ const endpointKey = (endpoint, stored) => Option.fromNullishOr(endpoint.apiKey).
85
105
  }).pipe(Effect.flatMap(({ cacheKey, policy }) => OpenAiClient.make({ apiKey: key, apiUrl: OPENCODE_RESPONSES_API_URL }).pipe(Effect.flatMap((client) => OpenAiLanguageModel.make({
86
106
  model: selection.modelId,
87
107
  config: openCodeResponsesConfig(cacheKey, policy)
88
- }).pipe(Effect.provideService(OpenAiClient.OpenAiClient, client))), Effect.map((svc) => ({ svc, prependClaudeCode: !1 })))));
108
+ }).pipe(Effect.provideService(OpenAiClient.OpenAiClient, client))), Effect.map((svc) => ({ svc, prependClaudeCode: !1, images })))));
89
109
  return makeCompatLanguageModel({
90
110
  moduleName: "OpenCode",
91
111
  chatUrl: OPENCODE_CHAT_URL,
92
112
  apiKey: Redacted.value(key),
93
- model: selection.modelId
94
- }).pipe(Effect.map((svc) => ({ svc, prependClaudeCode: !1 })));
113
+ model: selection.modelId,
114
+ images,
115
+ ...compatFetch(transport)
116
+ }).pipe(Effect.map((svc) => ({ svc, prependClaudeCode: !1, images })));
95
117
  }
96
118
  if (selection.provider === "google")
97
119
  return key === void 0 ? Effect.fail(missingKey(selection)) : makeCompatLanguageModel({
@@ -99,23 +121,26 @@ const endpointKey = (endpoint, stored) => Option.fromNullishOr(endpoint.apiKey).
99
121
  chatUrl: GOOGLE_CHAT_URL,
100
122
  apiKey: Redacted.value(key),
101
123
  model: selection.modelId,
102
- standardOnly: !0
103
- }).pipe(Effect.map((svc) => ({ svc, prependClaudeCode: !1 })));
124
+ standardOnly: !0,
125
+ usage: { reasoning: !1 },
126
+ images,
127
+ ...compatFetch(transport)
128
+ }).pipe(Effect.map((svc) => ({ svc, prependClaudeCode: !1, images })));
104
129
  if (selection.provider === "anthropic") {
105
130
  if (key === void 0)
106
131
  return Effect.fail(missingKey(selection));
107
132
  return (oauth ? AnthropicClient.make({
108
133
  apiKey: void 0,
109
134
  transformClient: anthropicOAuthTransform(key)
110
- }) : 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 })));
135
+ }) : 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, images })));
111
136
  }
112
137
  if (selection.provider === "openai-codex") {
113
138
  if (key === void 0 || credential?.type !== "oauth")
114
139
  return Effect.fail(missingKey(selection));
115
140
  const accountId = Option.orElse(Option.fromNullishOr(credential.accountId), () => openAiCodexAccountId(Redacted.value(key)));
116
141
  if (Option.isNone(accountId))
117
- 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" }));
118
- return makeOpenAiCodexLanguageModel({ model: selection.modelId, accessToken: key, accountId: accountId.value }).pipe(Effect.map((svc) => ({ svc, prependClaudeCode: !1 })));
142
+ return Effect.fail(new AuthError({ provider: selection.provider, message: "the OpenAI subscription token has no ChatGPT account id \u2014 sign in to the subscription again and store the new token" }));
143
+ return codexWebSocketProxy(transport).pipe(Effect.flatMap((webSocketProxy) => makeOpenAiCodexLanguageModel({ model: selection.modelId, accessToken: key, accountId: accountId.value, webSocketProxy })), Effect.map((svc) => ({ svc, prependClaudeCode: !1, images })));
119
144
  }
120
145
  if (selection.provider === "openai")
121
146
  return key === void 0 ? Effect.fail(missingKey(selection)) : Effect.flatMap(Effect.service(CurrentPromptCacheKey), (cacheKey) => OpenAiClient.make({ apiKey: key }).pipe(Effect.flatMap((client) => OpenAiLanguageModel.make({
@@ -123,7 +148,7 @@ const endpointKey = (endpoint, stored) => Option.fromNullishOr(endpoint.apiKey).
123
148
  config: {
124
149
  prompt_cache_key: Option.getOrElse(cacheKey, () => "efferent")
125
150
  }
126
- }).pipe(Effect.provideService(OpenAiClient.OpenAiClient, client))), Effect.map((svc) => ({ svc, prependClaudeCode: !1 }))));
151
+ }).pipe(Effect.provideService(OpenAiClient.OpenAiClient, client))), Effect.map((svc) => ({ svc, prependClaudeCode: !1, images }))));
127
152
  return Effect.fail(new AuthError({
128
153
  provider: selection.provider,
129
154
  message: `provider "${selection.provider}" is not wired (built in: opencode, google, anthropic, openai, openai-codex; add others as models plugin endpoints)`
@@ -133,6 +158,6 @@ export const buildProvider = (selection, credential, key) => buildProviderServic
133
158
  const cacheKey = yield* Effect.service(CurrentPromptCacheKey), policy = yield* Effect.service(CurrentModelCallPolicy), responses = selection.provider === "opencode" && usesOpenCodeResponses(selection.modelId), settings = selection.provider === "anthropic" ? { systemPrelude: built.prependClaudeCode ? CLAUDE_CODE_SYSTEM : null } : responses ? openCodeResponsesConfig(cacheKey, policy) : { prompt_cache_key: Option.getOrElse(cacheKey, () => "efferent") }, descriptor = { provider: selection.provider, model: selection.modelId, settings };
134
159
  return {
135
160
  ...built,
136
- svc: describeModel(built.svc, modelRequestDescriptorOf(built.svc).pipe(Effect.map((existing) => Option.match(existing, { onNone: () => descriptor, onSome: (value) => ({ ...value, provider: selection.provider }) }))))
161
+ svc: describeModel(built.images ? built.svc : withImageLabels(built.svc), modelRequestDescriptorOf(built.svc).pipe(Effect.map((existing) => Option.match(existing, { onNone: () => descriptor, onSome: (value) => ({ ...value, provider: selection.provider }) }))))
137
162
  };
138
163
  })));
@@ -43,10 +43,20 @@ export declare const tapStreamTelemetry: (label: string) => <E, R>(stream: Strea
43
43
  * shaping (anthropic cache breakpoints inherit for free), same stamp, the
44
44
  * stream-aware retry, and finish-part telemetry. */
45
45
  export declare const streamWith: (selection: ModelSelection, options: unknown) => Stream.Stream<unknown, unknown, AuthStore | HttpClient.HttpClient>;
46
+ /**
47
+ * The fetch the SDK providers' HTTP client uses on a call: the
48
+ * `ModelsTransport`'s (which knows the proxy), when the model was built over
49
+ * one. It is provided innermost, inside the replayed build context, so a
50
+ * `FetchHttpClient.Fetch` in that context or around the call cannot send the
51
+ * call past the proxy. Without a transport the call keeps whatever fetch is
52
+ * in force, the global one by default.
53
+ */
54
+ export declare const transportFetchOf: Effect.Effect<Option.Option<typeof fetch>, never, never>;
46
55
  /**
47
56
  * `LanguageModelLive` — the engine loop's `LanguageModel`, routed per call.
48
57
  * Requires `SettingsStore` + `AuthStore`; brings its own fetch-backed
49
- * `HttpClient` so drivers don't have to.
58
+ * `HttpClient` so drivers don't have to. Built over a `ModelsTransport`
59
+ * (as the models plugin builds it), every call goes through that transport.
50
60
  */
51
61
  export declare const LanguageModelLive: Layer.Layer<LanguageModel.LanguageModel, never, SettingsStore | AuthStore>;
52
62
  /** A profile-pinned routed model. Unlike LanguageModelLive it never consults
@@ -1,8 +1,11 @@
1
1
  import { AiError, LanguageModel } from "effect/ai";
2
2
  import { FetchHttpClient, HttpClient } from "effect/http";
3
3
  import { Clock, Duration, Effect, Layer, Metric, Option, Ref, Stream } from "effect";
4
- import { AuthStore, CurrentModelCallPolicy, describeModel, formatModelSelection, HarnessError, parseModelSelection, resolvingModel, SettingsStore } from "@xandreed/core";
5
- import { buildProvider, prependClaudeCode, withAnthropicCacheBreakpoints } from "./providers.js";
4
+ import { AuthStore, CurrentModelCallPolicy, CurrentTraceContent, describeModel, extractUsage, formatModelSelection, HarnessError, parseModelSelection, replayableContext, resolvingModel, SettingsStore } from "@xandreed/core";
5
+ import { buildProvider, endpointOwnKey, prependClaudeCode, withAnthropicCacheBreakpoints } from "./providers.js";
6
+ import { CurrentModelEndpoints } from "../ports/model-endpoints.port.js";
7
+ import { ModelsTransport } from "../ports/models-transport.port.js";
8
+ import { transportFetch } from "../transport/models-transport.adapter.js";
6
9
  import {
7
10
  classifyLlmError,
8
11
  rejectEmptyResponse,
@@ -15,6 +18,15 @@ const configError = (message) => AiError.make({ module: "Router", method: "selec
15
18
  }), llmOutputTokens = Metric.counter("llm.usage.output_tokens", {
16
19
  description: "completion tokens produced by routed LLM calls",
17
20
  incremental: !0
21
+ }), llmCacheReadTokens = Metric.counter("llm.usage.cache_read_tokens", {
22
+ description: "prompt tokens of routed LLM calls read from the provider's prompt cache",
23
+ incremental: !0
24
+ }), llmCacheWriteTokens = Metric.counter("llm.usage.cache_write_tokens", {
25
+ description: "prompt tokens of routed LLM calls written to the provider's prompt cache",
26
+ incremental: !0
27
+ }), llmReasoningTokens = Metric.counter("llm.usage.reasoning_tokens", {
28
+ description: "completion tokens of routed LLM calls spent on reasoning",
29
+ incremental: !0
18
30
  }), llmRequests = Metric.counter("llm.requests", {
19
31
  description: "routed LLM calls by final outcome (after retries)",
20
32
  incremental: !0
@@ -23,17 +35,26 @@ const configError = (message) => AiError.make({ module: "Router", method: "selec
23
35
  }), byModel = (metric, label) => Metric.withAttributes(metric, { "llm.model": label }), countRequest = (label, outcome, isFallback) => Metric.update(Metric.withAttributes(byModel(llmRequests, label), {
24
36
  outcome,
25
37
  ...isFallback === void 0 ? {} : { fallback: isFallback ? "true" : "false" }
26
- }), 1), usageTotals = (usage) => {
27
- const u = usage;
28
- return { input: u?.inputTokens?.total ?? 0, output: u?.outputTokens?.total ?? 0 };
29
- }, tracedContent = (content) => process.env.EFFERENT_TRACE_CONTENT === "1" ? content.slice(0, 500) : "[redacted]", shapeOptions = (selection, shouldPrependClaudeCode, options) => {
38
+ }), 1), usageOf = (usage) => extractUsage(usage, []), usageAttributes = (usage) => ({
39
+ "llm.usage.input_tokens": usage.inputTokens,
40
+ "llm.usage.output_tokens": usage.outputTokens,
41
+ "llm.usage.cache_read_tokens": usage.cacheReadTokens,
42
+ "llm.usage.cache_write_tokens": usage.cacheWriteTokens,
43
+ "llm.usage.reasoning_tokens": usage.reasoningTokens
44
+ }), countUsage = (label, usage) => Effect.all([
45
+ Metric.update(byModel(llmInputTokens, label), usage.inputTokens),
46
+ Metric.update(byModel(llmOutputTokens, label), usage.outputTokens),
47
+ Metric.update(byModel(llmCacheReadTokens, label), usage.cacheReadTokens),
48
+ Metric.update(byModel(llmCacheWriteTokens, label), usage.cacheWriteTokens),
49
+ Metric.update(byModel(llmReasoningTokens, label), usage.reasoningTokens)
50
+ ], { discard: !0 }), tracedContent = Effect.service(CurrentTraceContent).pipe(Effect.map((capture) => (content) => capture || process.env.EFFERENT_TRACE_CONTENT === "1" ? content.slice(0, 500) : "[redacted]")), shapeOptions = (selection, shouldPrependClaudeCode, options) => {
30
51
  const cached = selection.provider === "anthropic" ? withAnthropicCacheBreakpoints(options) : options;
31
52
  return shouldPrependClaudeCode ? prependClaudeCode(cached) : cached;
32
53
  }, currentSelection = Effect.gen(function* () {
33
54
  const loaded = yield* (yield* SettingsStore).load.pipe(Effect.mapError((e) => configError(`settings load failed: ${e.message}`))), raw = Option.getOrElse(loaded.model, () => "");
34
55
  return {
35
56
  primary: yield* Option.match(parseModelSelection(raw), {
36
- onNone: () => Effect.fail(configError(raw.length === 0 ? "no model configured \u2014 choose /model in the terminal or set the models plugin's model option in efferent.config.json" : `the configured model "${raw}" is not a "<provider>:<modelId>" selection`)),
57
+ 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
58
  onSome: Effect.succeed
38
59
  }),
39
60
  fallback: Option.flatMap(loaded.fallbackModel, parseModelSelection),
@@ -46,24 +67,29 @@ const configError = (message) => AiError.make({ module: "Router", method: "selec
46
67
  fallback: Option.getOrElse(Option.map(fallback, formatModelSelection), () => null),
47
68
  policy: Option.getOrElse(Option.orElse(current, () => Option.map(effort, (value) => ({ effort: value }))), () => null)
48
69
  }
49
- })));
70
+ }))), credentialFor = (selection) => Effect.gen(function* () {
71
+ const endpoints = yield* Effect.service(CurrentModelEndpoints), own = Option.flatMap(Option.fromNullishOr(endpoints[selection.provider]), endpointOwnKey);
72
+ if (Option.isSome(own))
73
+ return { credential: void 0, key: own.value };
74
+ const auth = yield* AuthStore;
75
+ return {
76
+ credential: Option.getOrUndefined(yield* auth.get(selection.provider)),
77
+ key: Option.getOrUndefined(yield* auth.resolveKey(selection.provider))
78
+ };
79
+ });
50
80
  export const generateWith = (selection, options, isFallback = !1) => Effect.gen(function* () {
51
- const auth = yield* AuthStore, label = formatModelSelection(selection), credential = Option.getOrUndefined(yield* auth.get(selection.provider)), key = Option.getOrUndefined(yield* auth.resolveKey(selection.provider)), built = yield* buildProvider(selection, credential, key);
81
+ const label = formatModelSelection(selection), traced = yield* tracedContent, { credential, key } = yield* credentialFor(selection), built = yield* buildProvider(selection, credential, key);
52
82
  return yield* built.svc.generateText(shapeOptions(selection, built.prependClaudeCode, options)).pipe(rejectEmptyResponse(label), retryableLlm(label), Effect.map((res) => stampResponse(res, label)), Effect.tap((res) => Effect.annotateCurrentSpan({
53
83
  "llm.finish_reason": String(res.finishReason),
54
- "llm.usage.input_tokens": usageTotals(res.usage).input,
55
- "llm.usage.output_tokens": usageTotals(res.usage).output,
84
+ ...usageAttributes(usageOf(res.usage)),
56
85
  "llm.response_chars": res.text.length,
57
86
  "llm.tool_calls": res.content.flatMap((part) => {
58
87
  const p = part;
59
88
  return p.type === "tool-call" ? [p.name ?? ""] : [];
60
89
  }).join(","),
61
- "llm.reasoning": tracedContent(res.reasoningText ?? ""),
90
+ "llm.reasoning": traced(res.reasoningText ?? ""),
62
91
  "llm.fallback": isFallback
63
- })), Effect.tap((res) => Effect.all([
64
- Metric.update(byModel(llmInputTokens, label), usageTotals(res.usage).input),
65
- Metric.update(byModel(llmOutputTokens, label), usageTotals(res.usage).output)
66
- ], { discard: !0 })), Effect.tap(() => countRequest(label, "ok", isFallback)), Effect.tapError(() => countRequest(label, "error", isFallback)), Effect.timed, Effect.tap(([duration]) => Metric.update(byModel(llmDuration, label), duration)), Effect.map(([, res]) => res), Effect.withSpan("providers.generate", {
92
+ })), Effect.tap((res) => countUsage(label, usageOf(res.usage))), Effect.tap(() => countRequest(label, "ok", isFallback)), Effect.tapError(() => countRequest(label, "error", isFallback)), Effect.timed, Effect.tap(([duration]) => Metric.update(byModel(llmDuration, label), duration)), Effect.map(([, res]) => res), Effect.withSpan("providers.generate", {
67
93
  attributes: { "llm.model": label }
68
94
  }));
69
95
  }).pipe(Effect.scoped), withFallbackRung = (primary, fallback, call) => call(primary, !1).pipe(Effect.catch((error) => Option.match(Option.filter(fallback, (fb) => formatModelSelection(fb) !== formatModelSelection(primary) && classifyLlmError(error) === "transient"), {
@@ -95,40 +121,38 @@ export const tapStreamTelemetry = (label) => (stream) => Stream.unwrap(Effect.ge
95
121
  textChars: 0,
96
122
  reasoning: "",
97
123
  toolNames: []
98
- }), startedAt = yield* Clock.currentTimeMillis, recordDuration = Clock.currentTimeMillis.pipe(Effect.flatMap((now) => Metric.update(byModel(llmDuration, label), Duration.millis(now - startedAt))));
124
+ }), startedAt = yield* Clock.currentTimeMillis, traced = yield* tracedContent, recordDuration = Clock.currentTimeMillis.pipe(Effect.flatMap((now) => Metric.update(byModel(llmDuration, label), Duration.millis(now - startedAt))));
99
125
  return stream.pipe(Stream.mapEffect((part) => {
100
126
  const p = part;
101
127
  if (p.type !== "finish")
102
128
  return Ref.update(stats, (s) => accumulateStats(s, part)).pipe(Effect.as(part));
103
129
  return Ref.get(stats).pipe(Effect.flatMap((s) => Effect.annotateCurrentSpan({
104
130
  "llm.finish_reason": String(p.reason),
105
- "llm.usage.input_tokens": usageTotals(p.usage).input,
106
- "llm.usage.output_tokens": usageTotals(p.usage).output,
131
+ ...usageAttributes(usageOf(p.usage)),
107
132
  "llm.response_chars": s.textChars,
108
133
  "llm.tool_calls": s.toolNames.join(","),
109
- "llm.reasoning": tracedContent(s.reasoning)
110
- })), Effect.andThen(Effect.all([
111
- Metric.update(byModel(llmInputTokens, label), usageTotals(p.usage).input),
112
- Metric.update(byModel(llmOutputTokens, label), usageTotals(p.usage).output)
113
- ], { discard: !0 })), Effect.as(part));
134
+ "llm.reasoning": traced(s.reasoning)
135
+ })), Effect.andThen(countUsage(label, usageOf(p.usage))), Effect.as(part));
114
136
  }), Stream.onEnd(countRequest(label, "ok").pipe(Effect.andThen(recordDuration))), Stream.tapCause(() => countRequest(label, "error").pipe(Effect.andThen(recordDuration))));
115
137
  })), 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);
138
+ const label = formatModelSelection(selection), { credential, key } = yield* credentialFor(selection), built = yield* buildProvider(selection, credential, key);
117
139
  return built.svc.streamText(shapeOptions(selection, built.prependClaudeCode, options)).pipe(retryableLlmStream(label), Stream.map((part) => stampModel(part, label)), tapStreamTelemetry(label), Stream.withSpan("providers.generate", {
118
140
  attributes: { "llm.model": label }
119
141
  }));
120
142
  }));
121
- const withConfiguredEffortStream = (stream, effort) => Stream.unwrap(Effect.service(CurrentModelCallPolicy).pipe(Effect.map((current) => Option.isSome(current) || Option.isNone(effort) ? stream : stream.pipe(Stream.provideService(CurrentModelCallPolicy, Option.some({ effort: effort.value })))))), selectionModel = (primary, fallback, effort, context, http) => {
143
+ const withConfiguredEffortStream = (stream, effort) => Stream.unwrap(Effect.service(CurrentModelCallPolicy).pipe(Effect.map((current) => Option.isSome(current) || Option.isNone(effort) ? stream : stream.pipe(Stream.provideService(CurrentModelCallPolicy, Option.some({ effort: effort.value }))))));
144
+ export const transportFetchOf = Effect.map(Effect.serviceOption(ModelsTransport), Option.map(transportFetch));
145
+ const withFetch = (fetch) => (effect) => Option.match(fetch, { onNone: () => effect, onSome: (value) => Effect.provideService(effect, FetchHttpClient.Fetch, value) }), withFetchStream = (fetch) => (stream) => Option.match(fetch, { onNone: () => stream, onSome: (value) => Stream.provideService(stream, FetchHttpClient.Fetch, value) }), selectionModel = (primary, fallback, effort, context, http, fetch) => {
122
146
  const service = {
123
147
  [LanguageModel.TypeId]: LanguageModel.TypeId,
124
- generateText: (options) => withConfiguredEffort(withFallbackRung(primary, fallback, (selection, isFallback) => generateWith(selection, options, isFallback)), effort).pipe(Effect.provide(context), Effect.provideService(HttpClient.HttpClient, http)),
148
+ generateText: (options) => withConfiguredEffort(withFallbackRung(primary, fallback, (selection, isFallback) => generateWith(selection, options, isFallback)), effort).pipe(withFetch(fetch), Effect.provide(context), Effect.provideService(HttpClient.HttpClient, http)),
125
149
  generateObject: () => Effect.fail(configError("generateObject is not wired on the new line yet")),
126
- streamText: (options) => withConfiguredEffortStream(streamWith(primary, options), effort).pipe(Stream.provideContext(context), Stream.provideService(HttpClient.HttpClient, http))
150
+ streamText: (options) => withConfiguredEffortStream(streamWith(primary, options), effort).pipe(withFetchStream(fetch), Stream.provideContext(context), Stream.provideService(HttpClient.HttpClient, http))
127
151
  };
128
152
  return describeModel(service, describeSelection(primary, fallback, effort));
129
153
  };
130
154
  export const LanguageModelLive = Layer.effect(LanguageModel.LanguageModel, Effect.gen(function* () {
131
- const context = yield* Effect.context(), http = yield* HttpClient.HttpClient, selected = currentSelection.pipe(Effect.map(({ primary, fallback, effort }) => selectionModel(primary, fallback, effort, context, http)), Effect.provide(context)), service = {
155
+ const context = replayableContext(yield* Effect.context()), http = yield* HttpClient.HttpClient, fetch = yield* transportFetchOf, selected = currentSelection.pipe(Effect.map(({ primary, fallback, effort }) => selectionModel(primary, fallback, effort, context, http, fetch)), Effect.provide(context)), service = {
132
156
  [LanguageModel.TypeId]: LanguageModel.TypeId,
133
157
  generateText: (options) => selected.pipe(Effect.flatMap((model) => model.generateText(options))),
134
158
  generateObject: () => Effect.fail(configError("generateObject is not wired on the new line yet")),
@@ -136,6 +160,6 @@ export const LanguageModelLive = Layer.effect(LanguageModel.LanguageModel, Effec
136
160
  }, asHarness = (error) => new HarnessError({ code: "request.model", message: String(error) });
137
161
  return resolvingModel(describeModel(service, currentSelection.pipe(Effect.flatMap(({ primary, fallback, effort }) => describeSelection(primary, fallback, effort)), Effect.provide(context), Effect.mapError(asHarness))), selected.pipe(Effect.mapError(asHarness)));
138
162
  })).pipe(Layer.provide(FetchHttpClient.layer)), LanguageModelSelectionLive = (primary, fallback) => Layer.effect(LanguageModel.LanguageModel, Effect.gen(function* () {
139
- const context = yield* Effect.context(), http = yield* HttpClient.HttpClient;
140
- return selectionModel(primary, fallback, Option.none(), context, http);
163
+ const context = replayableContext(yield* Effect.context()), http = yield* HttpClient.HttpClient;
164
+ return selectionModel(primary, fallback, Option.none(), context, http, yield* transportFetchOf);
141
165
  })).pipe(Layer.provide(FetchHttpClient.layer));
package/dist/llm/sse.d.ts CHANGED
@@ -24,22 +24,42 @@ export interface CompletionUsage {
24
24
  readonly prompt_cache_hit_tokens?: number;
25
25
  /** Moonshot-style cache accounting. */
26
26
  readonly cached_tokens?: number;
27
- /** OpenAI-native cache accounting. */
27
+ /** OpenAI-native cache reads; gateways (OpenCode's among them) add the cache writes. */
28
28
  readonly prompt_tokens_details?: {
29
29
  readonly cached_tokens?: number;
30
+ readonly cache_write_tokens?: number;
30
31
  };
32
+ /** OpenAI-native reasoning accounting, counted inside `completion_tokens`. */
33
+ readonly completion_tokens_details?: {
34
+ readonly reasoning_tokens?: number;
35
+ };
36
+ }
37
+ /** How a compat endpoint's usage is read. */
38
+ export interface UsageReading {
39
+ /**
40
+ * Read `completion_tokens_details.reasoning_tokens` (default true). The
41
+ * built-in `google` provider sets false: Gemini's `completion_tokens` may
42
+ * leave its thoughts out, which would break reasoning inside the output,
43
+ * so its reasoning reads 0. Configured endpoints and `local` credentials
44
+ * read it.
45
+ */
46
+ readonly reasoning?: boolean;
31
47
  }
32
48
  /** The vendor cached-token fallback chain, shared with the non-streaming
33
49
  * parser — the gateway fronts upstreams with three cache vocabularies.
34
- * `prompt_tokens` already counts the cached tokens: it is the full input. */
35
- export declare const usageFromCompletion: (usage: CompletionUsage | undefined) => {
50
+ * `prompt_tokens` already counts the cached and written tokens: it is the
51
+ * full input, and `completion_tokens` the full output, reasoning included. */
52
+ export declare const usageFromCompletion: (usage: CompletionUsage | undefined, reading?: UsageReading) => {
36
53
  inputTokens: {
37
54
  total: number;
38
55
  uncached: number;
39
56
  cacheRead: number;
57
+ cacheWrite: number;
40
58
  };
41
59
  outputTokens: {
42
60
  total: number;
61
+ text: number;
62
+ reasoning: number;
43
63
  };
44
64
  };
45
65
  export declare const finishReasonFromWire: (raw: string | undefined) => "stop" | "length" | "content-filter" | "tool-calls";
@@ -47,4 +67,6 @@ export declare const finishReasonFromWire: (raw: string | undefined) => "stop" |
47
67
  export declare const sseStreamParts: (options: {
48
68
  readonly moduleName: string;
49
69
  readonly body: ReadableStream<Uint8Array>;
70
+ /** How the final chunk's usage is read (see `usageFromCompletion`). */
71
+ readonly usage?: UsageReading;
50
72
  }) => Stream.Stream<Response.StreamPartEncoded, AiError.AiError>;
package/dist/llm/sse.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { AiError } from "effect/ai";
2
2
  import { Effect, Option, Result, Stream } from "effect";
3
- export const usageFromCompletion = (usage) => {
4
- const input = usage?.prompt_tokens ?? 0, cached = usage?.prompt_cache_hit_tokens ?? usage?.cached_tokens ?? usage?.prompt_tokens_details?.cached_tokens ?? 0;
3
+ export const usageFromCompletion = (usage, reading = {}) => {
4
+ const input = usage?.prompt_tokens ?? 0, cached = usage?.prompt_cache_hit_tokens ?? usage?.cached_tokens ?? usage?.prompt_tokens_details?.cached_tokens ?? 0, written = usage?.prompt_tokens_details?.cache_write_tokens ?? 0, output = usage?.completion_tokens ?? 0, reasoning = reading.reasoning === !1 ? 0 : usage?.completion_tokens_details?.reasoning_tokens ?? 0;
5
5
  return {
6
- inputTokens: { total: input, uncached: Math.max(0, input - cached), cacheRead: cached },
7
- outputTokens: { total: usage?.completion_tokens ?? 0 }
6
+ inputTokens: { total: input, uncached: Math.max(0, input - cached - written), cacheRead: cached, cacheWrite: written },
7
+ outputTokens: { total: output, text: Math.max(0, output - reasoning), reasoning }
8
8
  };
9
9
  }, finishReasonFromWire = (raw) => {
10
10
  if (raw === "tool_calls")
@@ -83,7 +83,7 @@ const initialState = {
83
83
  usage: chunk.usage !== null && chunk.usage !== void 0 ? Option.some(chunk.usage) : folded.usage
84
84
  };
85
85
  return Effect.succeed([next, parts]);
86
- }, flush = (moduleName, state) => state.flushed ? Effect.succeed([state, []]) : Effect.gen(function* () {
86
+ }, flush = (moduleName, reading, state) => state.flushed ? Effect.succeed([state, []]) : Effect.gen(function* () {
87
87
  const [closed, closeParts] = emitThen(closeText(state), closeReasoning), toolParts = yield* Effect.forEach(closed.toolCalls, (call, index) => Effect.try({
88
88
  try: () => ({
89
89
  type: "tool-call",
@@ -95,13 +95,13 @@ const initialState = {
95
95
  })), finish = {
96
96
  type: "finish",
97
97
  reason: toolParts.length > 0 ? "tool-calls" : finishReasonFromWire(Option.getOrUndefined(closed.finishReason)),
98
- usage: usageFromCompletion(Option.getOrUndefined(closed.usage))
98
+ usage: usageFromCompletion(Option.getOrUndefined(closed.usage), reading)
99
99
  };
100
100
  return [
101
101
  { ...closed, flushed: !0 },
102
102
  [...closeParts, ...toolParts, finish]
103
103
  ];
104
- }), DONE = "[DONE]", step = (moduleName, state, payload) => payload === DONE ? flush(moduleName, state) : Effect.try({
104
+ }), DONE = "[DONE]", step = (moduleName, reading, state, payload) => payload === DONE ? flush(moduleName, reading, state) : Effect.try({
105
105
  try: () => JSON.parse(payload),
106
106
  catch: () => malformed(moduleName, `a data: line was not JSON: ${payload.slice(0, 200)}`)
107
107
  }).pipe(Effect.flatMap((chunk) => applyChunk(moduleName, state, chunk)));
@@ -115,4 +115,4 @@ export const sseStreamParts = (options) => Stream.fromReadableStream({
115
115
  }).pipe(Stream.decodeText(), Stream.splitLines, Stream.filterMap((line) => {
116
116
  const trimmed = line.trim();
117
117
  return trimmed.startsWith("data:") ? Result.succeed(trimmed.slice(5).trim()) : Result.fail(line);
118
- }), Stream.concat(Stream.succeed(DONE)), Stream.mapAccumEffect(() => initialState, (state, payload) => step(options.moduleName, state, payload)));
118
+ }), Stream.concat(Stream.succeed(DONE)), Stream.mapAccumEffect(() => initialState, (state, payload) => step(options.moduleName, options.usage ?? {}, state, payload)));
@@ -3,6 +3,6 @@ import { AuthStore, SettingsStore, UtilityLlm } from "@xandreed/core";
3
3
  /**
4
4
  * The fast helper tier: one-shot completions on `fastModel ?? model`. No
5
5
  * toolkit, no cache breakpoints, no loop — a title/digest call that can never
6
- * park a turn.
6
+ * park a turn. Built over a `ModelsTransport`, its calls go through it.
7
7
  */
8
8
  export declare const UtilityLlmLive: Layer.Layer<UtilityLlm, never, SettingsStore | AuthStore>;
@@ -1,10 +1,10 @@
1
1
  import { Prompt } from "effect/ai";
2
2
  import { FetchHttpClient, HttpClient } from "effect/http";
3
3
  import { Effect, Layer, Option } from "effect";
4
- import { AuthStore, extractUsage, parseModelSelection, SettingsStore, UtilityError, UtilityLlm } from "@xandreed/core";
5
- import { generateWith } from "./router.js";
4
+ import { AuthStore, extractUsage, parseModelSelection, replayableContext, SettingsStore, UtilityError, UtilityLlm } from "@xandreed/core";
5
+ import { generateWith, transportFetchOf } from "./router.js";
6
6
  export const UtilityLlmLive = Layer.effect(UtilityLlm, Effect.gen(function* () {
7
- const context = yield* Effect.context(), http = yield* HttpClient.HttpClient, settings = yield* SettingsStore;
7
+ const context = replayableContext(yield* Effect.context()), http = yield* HttpClient.HttpClient, fetch = yield* transportFetchOf, settings = yield* SettingsStore;
8
8
  return {
9
9
  complete: (prompt) => Effect.gen(function* () {
10
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), {
@@ -15,7 +15,7 @@ export const UtilityLlmLive = Layer.effect(UtilityLlm, Effect.gen(function* () {
15
15
  tools: [],
16
16
  toolChoice: "none",
17
17
  responseFormat: { type: "text" }
18
- }).pipe(Effect.mapError((e) => new UtilityError({ message: String(e) })), Effect.provide(context), Effect.provideService(HttpClient.HttpClient, http));
18
+ }).pipe(Effect.mapError((e) => new UtilityError({ message: String(e) })), (call) => Option.match(fetch, { onNone: () => call, onSome: (value) => Effect.provideService(call, FetchHttpClient.Fetch, value) }), Effect.provide(context), Effect.provideService(HttpClient.HttpClient, http));
19
19
  return { text: res.content.flatMap((p) => {
20
20
  const part = p;
21
21
  return part.type === "text" ? [part.text ?? ""] : [];
@@ -1,5 +1,19 @@
1
1
  import { LanguageModel } from "effect/ai";
2
- import { AuthStore, ModelCatalog, SessionEnvironment, SettingsError, SettingsStore, UtilityLlm } from "@xandreed/core";
2
+ import { Config as EffectConfig, Redacted } from "effect";
3
+ import { AuthStore, HarnessError, ModelCatalog, SessionEnvironment, SettingsStore, UtilityLlm } from "@xandreed/core";
4
+ /**
5
+ * The models plugin: the routed `LanguageModel`, the fast `UtilityLlm`, the
6
+ * `ModelCatalog`, and the `AuthStore` and `SettingsStore` they read.
7
+ * Runtime-scoped: built once per `Agent.define`, so credentials, refreshed
8
+ * tokens and settings edits held in memory last across turns. A turn's own
9
+ * services win over it (a per-turn `LanguageModel` replaces the routed one).
10
+ * When a proxy applies (the `proxy` option, else the environment's
11
+ * `https_proxy` / `HTTPS_PROXY` and `http_proxy` / `HTTP_PROXY`), every call
12
+ * it makes (the providers, the OpenAI-compatible endpoints, OAuth token
13
+ * refreshes, the OpenAI subscription's WebSocket) goes through one
14
+ * `ModelsTransport`, the `noProxy` / `NO_PROXY` hosts and loopback direct;
15
+ * so do they through a host's own transport.
16
+ */
3
17
  export declare const modelsPlugin: import("@xandreed/core").TypedPlugin<{
4
18
  readonly model: string;
5
19
  readonly fastModel: string;
@@ -8,11 +22,34 @@ export declare const modelsPlugin: import("@xandreed/core").TypedPlugin<{
8
22
  readonly endpoints: {
9
23
  readonly [x: string]: {
10
24
  readonly url: string;
11
- readonly apiKey?: string;
25
+ readonly images?: boolean;
26
+ readonly apiKey?: string | Redacted.Redacted<string>;
12
27
  readonly apiKeyEnv?: string;
13
28
  readonly standardOnly?: boolean;
14
29
  };
15
30
  };
31
+ readonly storage: "local" | "memory";
32
+ readonly credentials: {
33
+ readonly [x: string]: {
34
+ readonly type: "api_key";
35
+ readonly key: Redacted.Redacted<string>;
36
+ } | {
37
+ readonly type: "oauth";
38
+ readonly access: Redacted.Redacted<string>;
39
+ readonly refresh: Redacted.Redacted<string>;
40
+ readonly expires: number;
41
+ readonly accountId?: string;
42
+ readonly installationId?: string;
43
+ } | {
44
+ readonly type: "local";
45
+ readonly baseUrl?: string;
46
+ };
47
+ };
48
+ readonly credentialEnv: {
49
+ readonly [x: string]: string;
50
+ };
51
+ readonly proxy: string | Redacted.Redacted<string>;
52
+ readonly noProxy: string;
16
53
  }, {
17
54
  readonly model: string;
18
55
  readonly fastModel: string;
@@ -21,10 +58,33 @@ export declare const modelsPlugin: import("@xandreed/core").TypedPlugin<{
21
58
  readonly endpoints: {
22
59
  readonly [x: string]: {
23
60
  readonly url: string;
24
- readonly apiKey?: string;
61
+ readonly images?: boolean;
62
+ readonly apiKey?: string | Redacted.Redacted<string>;
25
63
  readonly apiKeyEnv?: string;
26
64
  readonly standardOnly?: boolean;
27
65
  };
28
66
  };
29
- }, SettingsStore | AuthStore | ModelCatalog | UtilityLlm | LanguageModel.LanguageModel, SettingsError, SessionEnvironment>;
67
+ readonly storage: "local" | "memory";
68
+ readonly credentials: {
69
+ readonly [x: string]: {
70
+ readonly type: "api_key";
71
+ readonly key: Redacted.Redacted<string>;
72
+ } | {
73
+ readonly type: "oauth";
74
+ readonly access: Redacted.Redacted<string>;
75
+ readonly refresh: Redacted.Redacted<string>;
76
+ readonly expires: number;
77
+ readonly accountId?: string;
78
+ readonly installationId?: string;
79
+ } | {
80
+ readonly type: "local";
81
+ readonly baseUrl?: string;
82
+ };
83
+ };
84
+ readonly credentialEnv: {
85
+ readonly [x: string]: string;
86
+ };
87
+ readonly proxy: string | Redacted.Redacted<string>;
88
+ readonly noProxy: string;
89
+ }, SettingsStore | AuthStore | ModelCatalog | UtilityLlm | LanguageModel.LanguageModel, HarnessError | import("@xandreed/core").AuthError | EffectConfig.ConfigError, SessionEnvironment>;
30
90
  export default modelsPlugin;