@xandreed/plugin-models 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/README.md +1 -1
  2. package/dist/auth/anthropicOAuth.d.ts +30 -10
  3. package/dist/auth/anthropicOAuth.js +15 -18
  4. package/dist/auth/auth-store.conformance.d.ts +63 -0
  5. package/dist/auth/auth-store.conformance.js +152 -0
  6. package/dist/auth/credential-input.entity.d.ts +23 -5
  7. package/dist/auth/credential-input.entity.functions.d.ts +15 -5
  8. package/dist/auth/credential-input.entity.functions.js +12 -9
  9. package/dist/auth/credential-input.entity.js +5 -3
  10. package/dist/auth/credential-refresh.entity.d.ts +11 -3
  11. package/dist/auth/credential-refresh.entity.functions.d.ts +48 -6
  12. package/dist/auth/credential-refresh.entity.functions.js +12 -3
  13. package/dist/auth/localAuth.d.ts +10 -1
  14. package/dist/auth/localAuth.js +53 -79
  15. package/dist/auth/memory-auth.adapter.d.ts +12 -4
  16. package/dist/auth/memory-auth.adapter.js +13 -25
  17. package/dist/auth/openAiCodexOAuth.d.ts +26 -5
  18. package/dist/auth/openAiCodexOAuth.js +17 -28
  19. package/dist/auth/token-response.entity.d.ts +21 -0
  20. package/dist/auth/token-response.entity.functions.d.ts +20 -0
  21. package/dist/auth/token-response.entity.functions.js +10 -0
  22. package/dist/auth/token-response.entity.js +10 -0
  23. package/dist/compat/index.d.ts +2 -0
  24. package/dist/compat/index.js +1 -0
  25. package/dist/conformance/index.d.ts +6 -0
  26. package/dist/conformance/index.js +3 -0
  27. package/dist/index.d.ts +19 -19
  28. package/dist/index.js +17 -69
  29. package/dist/llm/chat-wire.entity.d.ts +123 -0
  30. package/dist/llm/chat-wire.entity.functions.d.ts +28 -0
  31. package/dist/llm/chat-wire.entity.functions.js +10 -0
  32. package/dist/llm/chat-wire.entity.js +37 -0
  33. package/dist/llm/compat-telemetry.adapter.d.ts +6 -0
  34. package/dist/llm/compat-telemetry.adapter.js +21 -0
  35. package/dist/llm/compat.d.ts +50 -30
  36. package/dist/llm/compat.js +149 -133
  37. package/dist/llm/model-completion.adapter.d.ts +49 -0
  38. package/dist/llm/model-completion.adapter.js +77 -0
  39. package/dist/llm/model-completion.entity.d.ts +4 -0
  40. package/dist/llm/model-completion.entity.functions.d.ts +3 -0
  41. package/dist/llm/model-completion.entity.functions.js +7 -0
  42. package/dist/llm/model-completion.entity.js +7 -0
  43. package/dist/llm/model-retry-policy.entity.d.ts +37 -0
  44. package/dist/llm/model-retry-policy.entity.functions.d.ts +16 -0
  45. package/dist/llm/model-retry-policy.entity.functions.js +33 -0
  46. package/dist/llm/model-retry-policy.entity.js +23 -0
  47. package/dist/llm/model-settings.entity.d.ts +85 -0
  48. package/dist/llm/model-settings.entity.functions.d.ts +11 -0
  49. package/dist/llm/model-settings.entity.functions.js +49 -0
  50. package/dist/llm/model-settings.entity.js +28 -0
  51. package/dist/llm/model-usage.entity.d.ts +61 -0
  52. package/dist/llm/model-usage.entity.functions.d.ts +11 -0
  53. package/dist/llm/model-usage.entity.functions.js +24 -0
  54. package/dist/llm/model-usage.entity.js +19 -0
  55. package/dist/llm/modelCatalog.d.ts +29 -8
  56. package/dist/llm/modelCatalog.js +28 -66
  57. package/dist/llm/openAiCodex.d.ts +15 -7
  58. package/dist/llm/openAiCodex.js +57 -45
  59. package/dist/llm/openAiCodexWebSocket.d.ts +48 -15
  60. package/dist/llm/openAiCodexWebSocket.js +101 -147
  61. package/dist/llm/prompt-cache-frame.entity.d.ts +18 -0
  62. package/dist/llm/prompt-cache-frame.entity.js +10 -0
  63. package/dist/llm/prompt-cache.entity.d.ts +57 -0
  64. package/dist/llm/prompt-cache.entity.functions.d.ts +8 -0
  65. package/dist/llm/prompt-cache.entity.functions.js +73 -0
  66. package/dist/llm/prompt-cache.entity.js +27 -0
  67. package/dist/llm/provider-client.adapter.d.ts +9 -0
  68. package/dist/llm/provider-client.adapter.js +60 -0
  69. package/dist/llm/provider-failure.entity.d.ts +42 -0
  70. package/dist/llm/provider-failure.entity.functions.d.ts +8 -0
  71. package/dist/llm/provider-failure.entity.functions.js +67 -0
  72. package/dist/llm/provider-failure.entity.js +20 -0
  73. package/dist/llm/provider-profile.entity.d.ts +79 -0
  74. package/dist/llm/provider-profile.entity.functions.d.ts +23 -0
  75. package/dist/llm/provider-profile.entity.functions.js +83 -0
  76. package/dist/llm/provider-profile.entity.js +10 -0
  77. package/dist/llm/providers.d.ts +116 -34
  78. package/dist/llm/providers.js +298 -129
  79. package/dist/llm/retry.d.ts +11 -47
  80. package/dist/llm/retry.js +33 -52
  81. package/dist/llm/router.d.ts +54 -36
  82. package/dist/llm/router.js +91 -78
  83. package/dist/llm/sse.d.ts +44 -37
  84. package/dist/llm/sse.js +43 -35
  85. package/dist/llm/thinking-binding.entity.d.ts +49 -0
  86. package/dist/llm/thinking-binding.entity.functions.d.ts +21 -0
  87. package/dist/llm/thinking-binding.entity.functions.js +16 -0
  88. package/dist/llm/thinking-binding.entity.js +11 -0
  89. package/dist/llm/utilityLlm.d.ts +14 -5
  90. package/dist/llm/utilityLlm.js +18 -13
  91. package/dist/plugin.adapter.d.ts +94 -6
  92. package/dist/plugin.adapter.js +18 -13
  93. package/dist/ports/auth-store.port.d.ts +73 -0
  94. package/dist/ports/auth-store.port.js +24 -0
  95. package/dist/ports/host-credentials.port.d.ts +2 -1
  96. package/dist/ports/model-call-completion.port.d.ts +9 -0
  97. package/dist/ports/model-call-completion.port.js +2 -0
  98. package/dist/ports/model-endpoints.port.d.ts +60 -26
  99. package/dist/ports/model-endpoints.port.js +17 -3
  100. package/dist/ports/model-retry-policy.port.d.ts +17 -0
  101. package/dist/ports/model-retry-policy.port.js +3 -0
  102. package/dist/ports/model-settings.port.d.ts +32 -0
  103. package/dist/ports/model-settings.port.js +4 -0
  104. package/dist/ports/models-transport.port.d.ts +9 -2
  105. package/dist/ports/models-transport.port.js +4 -1
  106. package/dist/ports/prompt-cache-frame.port.d.ts +21 -0
  107. package/dist/ports/prompt-cache-frame.port.js +4 -0
  108. package/dist/ports/settings-store.port.d.ts +70 -0
  109. package/dist/ports/settings-store.port.js +33 -0
  110. package/dist/ports/thinking-binding.port.d.ts +7 -0
  111. package/dist/ports/thinking-binding.port.js +4 -0
  112. package/dist/settings/localSettings.d.ts +2 -1
  113. package/dist/settings/localSettings.js +17 -20
  114. package/dist/settings/memory-settings.adapter.d.ts +3 -1
  115. package/dist/settings/memory-settings.adapter.js +1 -1
  116. package/dist/settings/roleView.d.ts +3 -1
  117. package/dist/settings/roleView.js +1 -1
  118. package/dist/settings/settings-store.conformance.d.ts +47 -0
  119. package/dist/settings/settings-store.conformance.js +100 -0
  120. package/dist/settings/settings-value.entity.d.ts +10 -2
  121. package/dist/settings/settings-value.entity.functions.d.ts +31 -9
  122. package/dist/settings/settings-value.entity.functions.js +5 -6
  123. package/dist/storage/json-file.d.ts +18 -0
  124. package/dist/storage/json-file.js +26 -0
  125. package/dist/transport/http-proxy.entity.d.ts +17 -3
  126. package/dist/transport/model-request.conformance.d.ts +13 -0
  127. package/dist/transport/model-request.conformance.js +36 -0
  128. package/dist/transport/models-transport.adapter.d.ts +18 -17
  129. package/dist/transport/models-transport.adapter.js +28 -31
  130. package/dist/transport/resilient-http-client.adapter.d.ts +73 -0
  131. package/dist/transport/resilient-http-client.adapter.js +177 -0
  132. package/package.json +11 -5
  133. package/dist/auth/anthropicOAuth.test.d.ts +0 -1
  134. package/dist/auth/credential-refresh.entity.functions.test.d.ts +0 -1
  135. package/dist/auth/localAuth.test.d.ts +0 -1
  136. package/dist/auth/memory-auth.adapter.test.d.ts +0 -1
  137. package/dist/llm/compat.test.d.ts +0 -1
  138. package/dist/llm/modelCatalog.test.d.ts +0 -1
  139. package/dist/llm/openAiCodexWebSocket.test.d.ts +0 -1
  140. package/dist/llm/providers.test.d.ts +0 -1
  141. package/dist/llm/retry.test.d.ts +0 -1
  142. package/dist/llm/router.test.d.ts +0 -1
  143. package/dist/llm/sse.test.d.ts +0 -1
  144. package/dist/plugin.adapter.test.d.ts +0 -1
  145. package/dist/settings/localSettings.test.d.ts +0 -1
  146. package/dist/settings/memory-settings.adapter.test.d.ts +0 -1
  147. package/dist/settings/roleView.test.d.ts +0 -1
  148. package/dist/settings/settings-value.entity.functions.test.d.ts +0 -1
  149. package/dist/transport/http-proxy.entity.functions.test.d.ts +0 -1
  150. package/dist/transport/models-transport.adapter.test.d.ts +0 -1
  151. package/dist/transport/recording-proxy.testing.d.ts +0 -33
package/dist/llm/sse.js CHANGED
@@ -1,20 +1,22 @@
1
1
  import { AiError } from "effect/ai";
2
- import { Effect, Option, Result, Stream } from "effect";
2
+ import { Cause, Effect, Exit, Match, Option, Result, Schema, Stream } from "effect";
3
+ import { isTurnError, reasonOf } from "@xandreed/core";
4
+ import { completeModelAttempt, modelOutcomeOfExit, observeModelAttempt } from "./model-completion.adapter.js";
5
+ import { chunkErrorOf, decodeStreamChunk, decodeToolArguments, isQuotaExhausted } from "./chat-wire.entity.functions.js";
6
+ import { redactCredentials } from "../transport/http-proxy.entity.functions.js";
7
+ import { classifyAiError } from "./provider-failure.entity.functions.js";
3
8
  export const usageFromCompletion = (usage, reading = {}) => {
4
9
  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
10
  return {
6
11
  inputTokens: { total: input, uncached: Math.max(0, input - cached - written), cacheRead: cached, cacheWrite: written },
7
12
  outputTokens: { total: output, text: Math.max(0, output - reasoning), reasoning }
8
13
  };
9
- }, finishReasonFromWire = (raw) => {
10
- if (raw === "tool_calls")
11
- return "tool-calls";
12
- if (raw === "length")
13
- return "length";
14
- if (raw === "content_filter")
15
- return "content-filter";
16
- return "stop";
17
14
  };
15
+ const endOfWord = (word) => Match.value(word.trim().toLowerCase()).pipe(Match.whenOr("stop", "tool_calls", "function_call", "tool_use", "tool_call", "end_turn", "stop_sequence", "eos", () => "settled"), Match.whenOr("length", "max_tokens", () => "length"), Match.when("content_filter", () => "content-filter"), Match.whenOr("error", "insufficient_system_resource", () => "error"), Match.orElse(() => "other")), namedReason = (raw) => Option.filter(Option.fromNullishOr(raw), (word) => word.trim().length > 0);
16
+ export const finishReasonFromWire = (raw, hasToolCalls) => {
17
+ const end = Option.match(Option.flatMap(raw, namedReason), { onNone: () => "settled", onSome: endOfWord });
18
+ return end === "settled" ? hasToolCalls ? "tool-calls" : "stop" : end;
19
+ }, toolCallParams = (args, reason, invalid) => decodeToolArguments(args).pipe(Effect.catchTag("SchemaError", () => reason === "stop" || reason === "tool-calls" ? Effect.fail(invalid()) : Effect.succeed({})));
18
20
  const initialState = {
19
21
  textOpen: Option.none(),
20
22
  reasoningOpen: Option.none(),
@@ -68,51 +70,57 @@ const initialState = {
68
70
  args: call.args + (fragment.function?.arguments ?? "")
69
71
  } : call);
70
72
  }, malformed = (moduleName, description) => AiError.make({ module: moduleName, method: "streamText", reason: new AiError.InvalidOutputError({ description }) }), applyChunk = (moduleName, state, chunk) => {
71
- if (chunk.error !== void 0 && chunk.error !== null)
72
- return Effect.fail(AiError.make({
73
+ if (chunk.error !== void 0 && chunk.error !== null) {
74
+ const error = chunkErrorOf(chunk.error);
75
+ return Effect.fail(classifyAiError(AiError.make({
73
76
  module: moduleName,
74
77
  method: "streamText",
75
- reason: new AiError.UnknownError({
76
- description: `the stream carried an error chunk: ${JSON.stringify(chunk.error).slice(0, 500)}`
78
+ reason: isQuotaExhausted(Option.none(), error) ? new AiError.QuotaExhaustedError({ metadata: { [moduleName]: { ...Option.getOrElse(error, () => ({})) } } }) : new AiError.UnknownError({
79
+ description: `the stream carried an error chunk: ${JSON.stringify(chunk.error).slice(0, 500)}`,
80
+ metadata: { [moduleName]: { ...Option.getOrElse(error, () => ({})) } }
77
81
  })
78
- }));
82
+ }), "compat"));
83
+ }
79
84
  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 = {
80
85
  ...folded,
81
86
  toolCalls: (delta?.tool_calls ?? []).reduce(mergeToolFragment, folded.toolCalls),
82
- finishReason: typeof choice?.finish_reason === "string" ? Option.some(choice.finish_reason) : folded.finishReason,
87
+ finishReason: Option.orElse(namedReason(choice?.finish_reason), () => folded.finishReason),
83
88
  usage: chunk.usage !== null && chunk.usage !== void 0 ? Option.some(chunk.usage) : folded.usage
84
89
  };
85
90
  return Effect.succeed([next, parts]);
86
- }, flush = (moduleName, reading, state) => state.flushed ? Effect.succeed([state, []]) : Effect.gen(function* () {
87
- const [closed, closeParts] = emitThen(closeText(state), closeReasoning), toolParts = yield* Effect.forEach(closed.toolCalls, (call, index) => Effect.try({
88
- try: () => ({
89
- type: "tool-call",
90
- id: call.id.length > 0 ? call.id : `call_${index}`,
91
- name: call.name,
92
- params: JSON.parse(call.args.length > 0 ? call.args : "{}")
93
- }),
94
- catch: () => malformed(moduleName, `tool call ${call.name.length > 0 ? call.name : "?"} carried unparseable JSON arguments`)
95
- })), finish = {
91
+ }, flush = (moduleName, reading, state, end) => state.flushed ? Effect.succeed([state, []]) : Effect.gen(function* () {
92
+ const [closed, closeParts] = emitThen(closeText(state), closeReasoning), reason = end === "eof" && Option.isNone(closed.finishReason) ? "unknown" : finishReasonFromWire(closed.finishReason, closed.toolCalls.length > 0), toolParts = yield* Effect.forEach(closed.toolCalls, (call) => toolCallParams(call.args.length > 0 ? call.args : "{}", reason, () => malformed(moduleName, `tool call ${call.name.length > 0 ? call.name : "?"} carried unparseable JSON arguments`)).pipe(Effect.map((params) => ({
93
+ type: "tool-call",
94
+ id: call.id,
95
+ name: call.name,
96
+ params
97
+ }))), { concurrency: 1 }), finish = {
96
98
  type: "finish",
97
- reason: toolParts.length > 0 ? "tool-calls" : finishReasonFromWire(Option.getOrUndefined(closed.finishReason)),
98
- usage: usageFromCompletion(Option.getOrUndefined(closed.usage), reading)
99
+ reason,
100
+ usage: usageFromCompletion(Option.getOrElse(closed.usage, () => ({})), reading)
99
101
  };
100
102
  return [
101
103
  { ...closed, flushed: !0 },
102
104
  [...closeParts, ...toolParts, finish]
103
105
  ];
104
- }), DONE = "[DONE]", step = (moduleName, reading, state, payload) => payload === DONE ? flush(moduleName, reading, state) : Effect.try({
105
- try: () => JSON.parse(payload),
106
- catch: () => malformed(moduleName, `a data: line was not JSON: ${payload.slice(0, 200)}`)
107
- }).pipe(Effect.flatMap((chunk) => applyChunk(moduleName, state, chunk)));
106
+ }), DONE = "[DONE]", step = (moduleName, reading, state, payload, attempt, observeUsage) => {
107
+ const ended = (end) => Option.match(attempt, { onNone: () => Effect.void, onSome: (value) => completeModelAttempt(value, { _tag: "Completed" }) }).pipe(Effect.andThen(flush(moduleName, reading, state, end)));
108
+ return Match.valueTags(payload, {
109
+ End: () => ended("eof"),
110
+ Data: ({ data }) => data === DONE ? ended("done") : decodeStreamChunk(moduleName, data).pipe(Effect.tap((chunk) => {
111
+ const final = Option.isSome(state.finishReason) || (chunk.choices ?? []).some((choice) => choice.finish_reason != null);
112
+ return Option.match(attempt, { onNone: () => Effect.void, onSome: (value) => observeModelAttempt(value, chunk.usage, final, reading.reasoning ?? !0) }).pipe(Effect.andThen(observeUsage?.(chunk.usage, final) ?? Effect.void));
113
+ }), Effect.flatMap((chunk) => applyChunk(moduleName, state, chunk)))
114
+ });
115
+ };
108
116
  export const sseStreamParts = (options) => Stream.fromReadableStream({
109
117
  evaluate: () => options.body,
110
- onError: (cause) => AiError.make({
118
+ onError: (cause) => AiError.isAiError(cause) || isTurnError(cause) ? cause : AiError.make({
111
119
  module: options.moduleName,
112
120
  method: "streamText",
113
- reason: new AiError.UnknownError({ description: `the response body stream failed: ${String(cause)}` })
121
+ reason: new AiError.UnknownError({ description: `the response body stream failed: ${redactCredentials(reasonOf(Cause.die(cause)))}` })
114
122
  })
115
123
  }).pipe(Stream.decodeText(), Stream.splitLines, Stream.filterMap((line) => {
116
124
  const trimmed = line.trim();
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, options.usage ?? {}, state, payload)));
125
+ return trimmed.startsWith("data:") ? Result.succeed({ _tag: "Data", data: trimmed.slice(5).trim() }) : Result.fail(line);
126
+ }), Stream.concat(Stream.succeed({ _tag: "End" })), Stream.mapAccumEffect(() => initialState, (state, payload) => step(options.moduleName, options.usage ?? {}, state, payload, options.attempt ?? Option.none(), options.observeUsage)), Stream.onExit((exit) => Option.match(options.attempt ?? Option.none(), { onNone: () => Effect.void, onSome: (attempt) => completeModelAttempt(attempt, Exit.isSuccess(exit) ? { _tag: "Interrupted" } : modelOutcomeOfExit(exit)).pipe(Effect.catch((error) => Effect.die(error))) })), Stream.catch((error) => isTurnError(error) ? Stream.die(error) : Stream.fail(error)));
@@ -0,0 +1,49 @@
1
+ import { Schema } from "effect";
2
+ /**
3
+ * What a request tells a model that binds each thinking block to the
4
+ * conversation before it (`ModelProfile.bindsThinking`) to do with a block
5
+ * whose prefix changed: `provider-default` sends nothing and leaves it to
6
+ * the account (an account that enforces the binding refuses the request,
7
+ * one that does not passes the block on); `drop_block` drops that block and
8
+ * the later thinking blocks for the request; `error` refuses the request.
9
+ * Any value but `provider-default` turns the binding on for that request.
10
+ */
11
+ export declare const ThinkingPrefixMismatch: Schema.Literals<readonly ["provider-default", "drop_block", "error"]>;
12
+ export type ThinkingPrefixMismatch = typeof ThinkingPrefixMismatch.Type;
13
+ /** The beta a request needs to carry `thinking.block_binding` (refused without it) and to be told what was dropped. */
14
+ export declare const THINKING_BINDING_BETA = "thinking-binding-controls-2026-08-01";
15
+ /** One change the provider made to a request's input before the model read it (`input_transformations`). */
16
+ export declare const InputTransformation: Schema.Struct<{
17
+ /** `thinking_dropped`, `thinking_mismatch_allowed`, or another the provider adds later. */
18
+ readonly type: Schema.String;
19
+ /** Why: `prefix_binding_mismatch`, `model_binding_mismatch`, `organization_binding_mismatch`. */
20
+ readonly reason: Schema.optionalKey<Schema.String>;
21
+ }>;
22
+ export type InputTransformation = typeof InputTransformation.Type;
23
+ /** What a settled response's body says it changed, its other fields left unread. */
24
+ export declare const TransformedResponse: Schema.Struct<{
25
+ readonly input_transformations: Schema.optionalKey<Schema.$Array<Schema.Struct<{
26
+ /** `thinking_dropped`, `thinking_mismatch_allowed`, or another the provider adds later. */
27
+ readonly type: Schema.String;
28
+ /** Why: `prefix_binding_mismatch`, `model_binding_mismatch`, `organization_binding_mismatch`. */
29
+ readonly reason: Schema.optionalKey<Schema.String>;
30
+ }>>>;
31
+ }>;
32
+ /** A stream's `message_start` event: its message carries the changes (the event itself too, read either way). */
33
+ export declare const TransformedMessageStart: Schema.Struct<{
34
+ readonly type: Schema.Literal<"message_start">;
35
+ readonly input_transformations: Schema.optionalKey<Schema.$Array<Schema.Struct<{
36
+ /** `thinking_dropped`, `thinking_mismatch_allowed`, or another the provider adds later. */
37
+ readonly type: Schema.String;
38
+ /** Why: `prefix_binding_mismatch`, `model_binding_mismatch`, `organization_binding_mismatch`. */
39
+ readonly reason: Schema.optionalKey<Schema.String>;
40
+ }>>>;
41
+ readonly message: Schema.optionalKey<Schema.Struct<{
42
+ readonly input_transformations: Schema.optionalKey<Schema.$Array<Schema.Struct<{
43
+ /** `thinking_dropped`, `thinking_mismatch_allowed`, or another the provider adds later. */
44
+ readonly type: Schema.String;
45
+ /** Why: `prefix_binding_mismatch`, `model_binding_mismatch`, `organization_binding_mismatch`. */
46
+ readonly reason: Schema.optionalKey<Schema.String>;
47
+ }>>>;
48
+ }>>;
49
+ }>;
@@ -0,0 +1,21 @@
1
+ import { Option } from "effect";
2
+ import type { InputTransformation, ThinkingPrefixMismatch } from "./thinking-binding.entity.js";
3
+ /**
4
+ * An `anthropic-beta` header with `betas` added: the values it holds first,
5
+ * in order, then each new one once. Never a replacement: the client's own
6
+ * betas (structured outputs, the subscription's) stay.
7
+ */
8
+ export declare const mergeBetas: (existing: Option.Option<string>, betas: ReadonlyArray<string>) => string;
9
+ /** The binding a request to a model carries: none for a model that does not bind, or with `provider-default`. */
10
+ export declare const bindingFor: (bindsThinking: boolean, mismatch: ThinkingPrefixMismatch) => Option.Option<Exclude<ThinkingPrefixMismatch, "provider-default">>;
11
+ /** The reasons of the thinking blocks the provider dropped, one per block, in order. */
12
+ export declare const droppedReasonsOf: (transformations: ReadonlyArray<InputTransformation>) => ReadonlyArray<string>;
13
+ /** What a settled response's JSON body says the provider dropped; nothing when it is not that JSON. */
14
+ export declare const droppedInBody: (body: string) => ReadonlyArray<string>;
15
+ /**
16
+ * The first event of a server-sent stream, when `text` (the stream so far)
17
+ * holds it whole: its `data` lines joined. None until its blank line came.
18
+ */
19
+ export declare const firstEventData: (text: string) => Option.Option<string>;
20
+ /** What a stream's `message_start` data says the provider dropped (on the event or its message); nothing for another event. */
21
+ export declare const droppedInMessageStart: (data: string) => ReadonlyArray<string>;
@@ -0,0 +1,16 @@
1
+ import { Option, Schema } from "effect";
2
+ import { TransformedMessageStart, TransformedResponse } from "./thinking-binding.entity.js";
3
+ export const mergeBetas = (existing, betas) => {
4
+ return [...Option.match(existing, { onNone: () => [], onSome: (value) => value.split(",").map((beta) => beta.trim()).filter((beta) => beta.length > 0) }), ...betas].filter((beta, index, all) => all.indexOf(beta) === index).join(",");
5
+ }, bindingFor = (bindsThinking, mismatch) => bindsThinking && mismatch !== "provider-default" ? Option.some(mismatch) : Option.none(), droppedReasonsOf = (transformations) => transformations.filter((change) => change.type === "thinking_dropped").map((change) => change.reason ?? "unknown");
6
+ const decodeResponse = Schema.decodeUnknownOption(Schema.fromJsonString(TransformedResponse)), decodeMessageStart = Schema.decodeUnknownOption(Schema.fromJsonString(TransformedMessageStart));
7
+ export const droppedInBody = (body) => Option.match(decodeResponse(body), { onNone: () => [], onSome: (response) => droppedReasonsOf(response.input_transformations ?? []) }), firstEventData = (text) => {
8
+ const end = text.search(/\r?\n\r?\n/);
9
+ if (end < 0)
10
+ return Option.none();
11
+ return Option.some(text.slice(0, end).split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join(`
12
+ `));
13
+ }, droppedInMessageStart = (data) => Option.match(decodeMessageStart(data), {
14
+ onNone: () => [],
15
+ onSome: (event) => droppedReasonsOf([...event.input_transformations ?? [], ...event.message?.input_transformations ?? []])
16
+ });
@@ -0,0 +1,11 @@
1
+ import { Schema } from "effect";
2
+ export const ThinkingPrefixMismatch = Schema.Literals(["provider-default", "drop_block", "error"]), THINKING_BINDING_BETA = "thinking-binding-controls-2026-08-01", InputTransformation = Schema.Struct({
3
+ type: Schema.String,
4
+ reason: Schema.optionalKey(Schema.String)
5
+ }), TransformedResponse = Schema.Struct({
6
+ input_transformations: Schema.optionalKey(Schema.Array(InputTransformation))
7
+ }), TransformedMessageStart = Schema.Struct({
8
+ type: Schema.Literal("message_start"),
9
+ input_transformations: Schema.optionalKey(Schema.Array(InputTransformation)),
10
+ message: Schema.optionalKey(TransformedResponse)
11
+ });
@@ -1,8 +1,17 @@
1
1
  import { Layer } from "effect";
2
- import { AuthStore, SettingsStore, UtilityLlm } from "@xandreed/core";
2
+ import { UtilityLlm } from "@xandreed/core";
3
+ import { AuthStore } from "../ports/auth-store.port.js";
4
+ import { SettingsStore } from "../ports/settings-store.port.js";
3
5
  /**
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. Built over a `ModelsTransport`, its calls go through it.
6
+ * The fast helper tier: one-shot completions on `fastModel ?? model`, at
7
+ * `fastReasoningEffort ?? reasoningEffort` (the fast role, as
8
+ * `roleModelView("fast")` reads it; a call policy the caller pinned wins).
9
+ * No toolkit or loop; a Messages helper prompt carries no automatic cache
10
+ * breakpoint, since a one-shot prompt is never read back.
11
+ * A helper completion cannot park a turn. Like the routed model, it reads
12
+ * its provider services once, where it is built: over a `ModelsTransport`,
13
+ * its calls go through it.
14
+ *
15
+ * @public
7
16
  */
8
- export declare const UtilityLlmLive: Layer.Layer<UtilityLlm, never, SettingsStore | AuthStore>;
17
+ export declare const UtilityLlmLive: Layer.Layer<UtilityLlm, never, AuthStore | SettingsStore>;
@@ -1,25 +1,30 @@
1
- import { Prompt } from "effect/ai";
1
+ import { Prompt, Response } from "effect/ai";
2
2
  import { FetchHttpClient, HttpClient } from "effect/http";
3
- import { Effect, Layer, Option } from "effect";
4
- import { AuthStore, extractUsage, parseModelSelection, replayableContext, SettingsStore, UtilityError, UtilityLlm } from "@xandreed/core";
5
- import { generateWith, transportFetchOf } from "./router.js";
3
+ import { Cause, Context, Effect, Layer, Option } from "effect";
4
+ import { isTurnError, parseModelSelection, reasonOf, responseModelOf, UtilityCompletion, UtilityError, UtilityLlm } from "@xandreed/core";
5
+ import { AuthStore } from "../ports/auth-store.port.js";
6
+ import { SettingsStore } from "../ports/settings-store.port.js";
7
+ import { extractUsage } from "@xandreed/core/internal";
8
+ import { generateOver, layerProviderServices, throughTransport, withConfiguredEffort } from "./router.js";
9
+ import { CurrentModelCallPurpose } from "../ports/model-call-completion.port.js";
6
10
  export const UtilityLlmLive = Layer.effect(UtilityLlm, Effect.gen(function* () {
7
- const context = replayableContext(yield* Effect.context()), http = yield* HttpClient.HttpClient, fetch = yield* transportFetchOf, settings = yield* SettingsStore;
11
+ const stores = Context.pick(AuthStore)(yield* Effect.context()), services = yield* layerProviderServices, http = yield* HttpClient.HttpClient, settings = yield* SettingsStore;
8
12
  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), {
13
+ complete: Effect.fn("models.utility")(function* (prompt) {
14
+ const loaded = yield* settings.load.pipe(Effect.mapError((e) => new UtilityError({ message: e.message, cause: e }))), raw = Option.getOrElse(Option.orElse(loaded.fastModel, () => loaded.model), () => ""), selection = yield* Option.match(parseModelSelection(raw), {
11
15
  onNone: () => Effect.fail(new UtilityError({ message: "no fast/general model configured" })),
12
16
  onSome: Effect.succeed
13
- }), res = yield* generateWith(selection, {
17
+ }), res = yield* withConfiguredEffort(generateOver(services)(selection, {
14
18
  prompt: Prompt.make(prompt),
15
19
  tools: [],
16
20
  toolChoice: "none",
17
21
  responseFormat: { type: "text" }
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
- return { text: res.content.flatMap((p) => {
20
- const part = p;
21
- return part.type === "text" ? [part.text ?? ""] : [];
22
- }).join(""), usage: extractUsage(res.usage, res.content) };
22
+ }), Option.orElse(loaded.fastReasoningEffort, () => loaded.reasoningEffort)).pipe(Effect.provideService(CurrentModelCallPurpose, "Helper"), Effect.catch((error) => isTurnError(error) ? Effect.die(error) : Effect.fail(new UtilityError({ message: reasonOf(Cause.fail(error)), cause: error }))), throughTransport(services), Effect.provide(stores), Effect.provideService(HttpClient.HttpClient, http)), parts = res.content.filter(Response.isPart), text = parts.flatMap((part) => part.type === "text" ? [part.text] : []).join(""), model = Option.flatMap(responseModelOf(parts), parseModelSelection);
23
+ return new UtilityCompletion({
24
+ text,
25
+ usage: extractUsage(res.usage, parts),
26
+ ...Option.match(model, { onNone: () => ({}), onSome: (selection) => ({ model: selection }) })
27
+ });
23
28
  })
24
29
  };
25
30
  })).pipe(Layer.provide(FetchHttpClient.layer));
@@ -1,6 +1,8 @@
1
1
  import { LanguageModel } from "effect/ai";
2
2
  import { Config as EffectConfig, Redacted } from "effect";
3
- import { AuthStore, HarnessError, ModelCatalog, SessionEnvironment, SettingsStore, UtilityLlm } from "@xandreed/core";
3
+ import { HarnessError, ModelCatalog, SessionEnvironment, UtilityLlm } from "@xandreed/core";
4
+ import { AuthStore } from "./ports/auth-store.port.js";
5
+ import { SettingsStore } from "./ports/settings-store.port.js";
4
6
  /**
5
7
  * The models plugin: the routed `LanguageModel`, the fast `UtilityLlm`, the
6
8
  * `ModelCatalog`, and the `AuthStore` and `SettingsStore` they read.
@@ -13,6 +15,8 @@ import { AuthStore, HarnessError, ModelCatalog, SessionEnvironment, SettingsStor
13
15
  * refreshes, the OpenAI subscription's WebSocket) goes through one
14
16
  * `ModelsTransport`, the `noProxy` / `NO_PROXY` hosts and loopback direct;
15
17
  * so do they through a host's own transport.
18
+ *
19
+ * @public
16
20
  */
17
21
  export declare const modelsPlugin: import("@xandreed/core").TypedPlugin<{
18
22
  readonly model: string;
@@ -24,8 +28,49 @@ export declare const modelsPlugin: import("@xandreed/core").TypedPlugin<{
24
28
  readonly url: string;
25
29
  readonly images?: boolean;
26
30
  readonly apiKey?: string | Redacted.Redacted<string>;
27
- readonly apiKeyEnv?: string;
31
+ readonly api?: "chat-completions";
28
32
  readonly standardOnly?: boolean;
33
+ readonly apiKeyEnv?: string;
34
+ } | {
35
+ readonly url: string;
36
+ readonly api: "anthropic-messages";
37
+ readonly images?: boolean;
38
+ readonly apiKey?: string | Redacted.Redacted<string>;
39
+ readonly auth?: "x-api-key" | "bearer";
40
+ readonly apiKeyEnv?: string;
41
+ readonly cacheControl?: boolean;
42
+ readonly apiVersion?: string;
43
+ readonly betas?: readonly string[];
44
+ };
45
+ };
46
+ readonly modelSettings: {
47
+ readonly [x: string]: {
48
+ readonly effort?: "none" | "low" | "medium" | "high" | "xhigh" | "max";
49
+ readonly limits?: {
50
+ readonly maxOutputTokens?: number;
51
+ readonly contextWindow?: number;
52
+ };
53
+ readonly thinking?: {
54
+ readonly _tag: "Off";
55
+ } | {
56
+ readonly _tag: "Adaptive";
57
+ readonly display?: "omitted" | "summarized";
58
+ } | {
59
+ readonly _tag: "Budget";
60
+ readonly budgetTokens: number;
61
+ readonly display?: "omitted" | "summarized";
62
+ } | {
63
+ readonly _tag: "BetweenTools";
64
+ };
65
+ readonly promptCache?: {
66
+ readonly _tag: "Auto";
67
+ readonly ttl: "5m" | "1h";
68
+ } | {
69
+ readonly _tag: "TopLevel";
70
+ readonly ttl: "5m" | "1h";
71
+ } | {
72
+ readonly _tag: "Off";
73
+ };
29
74
  };
30
75
  };
31
76
  readonly storage: "local" | "memory";
@@ -38,8 +83,8 @@ export declare const modelsPlugin: import("@xandreed/core").TypedPlugin<{
38
83
  readonly access: Redacted.Redacted<string>;
39
84
  readonly refresh: Redacted.Redacted<string>;
40
85
  readonly expires: number;
41
- readonly accountId?: string;
42
- readonly installationId?: string;
86
+ readonly accountId?: string & import("effect/Brand").Brand<"AccountId">;
87
+ readonly installationId?: string & import("effect/Brand").Brand<"InstallationId">;
43
88
  } | {
44
89
  readonly type: "local";
45
90
  readonly baseUrl?: string;
@@ -50,6 +95,7 @@ export declare const modelsPlugin: import("@xandreed/core").TypedPlugin<{
50
95
  };
51
96
  readonly proxy: string | Redacted.Redacted<string>;
52
97
  readonly noProxy: string;
98
+ readonly thinkingPrefixMismatch: "error" | "provider-default" | "drop_block";
53
99
  }, {
54
100
  readonly model: string;
55
101
  readonly fastModel: string;
@@ -60,8 +106,49 @@ export declare const modelsPlugin: import("@xandreed/core").TypedPlugin<{
60
106
  readonly url: string;
61
107
  readonly images?: boolean;
62
108
  readonly apiKey?: string | Redacted.Redacted<string>;
63
- readonly apiKeyEnv?: string;
109
+ readonly api?: "chat-completions";
64
110
  readonly standardOnly?: boolean;
111
+ readonly apiKeyEnv?: string;
112
+ } | {
113
+ readonly url: string;
114
+ readonly api: "anthropic-messages";
115
+ readonly images?: boolean;
116
+ readonly apiKey?: string | Redacted.Redacted<string>;
117
+ readonly auth?: "x-api-key" | "bearer";
118
+ readonly apiKeyEnv?: string;
119
+ readonly cacheControl?: boolean;
120
+ readonly apiVersion?: string;
121
+ readonly betas?: readonly string[];
122
+ };
123
+ };
124
+ readonly modelSettings: {
125
+ readonly [x: string]: {
126
+ readonly effort?: "none" | "low" | "medium" | "high" | "xhigh" | "max";
127
+ readonly limits?: {
128
+ readonly maxOutputTokens?: number;
129
+ readonly contextWindow?: number;
130
+ };
131
+ readonly thinking?: {
132
+ readonly _tag: "Adaptive";
133
+ readonly display?: "omitted" | "summarized";
134
+ } | {
135
+ readonly _tag: "Budget";
136
+ readonly budgetTokens: number;
137
+ readonly display?: "omitted" | "summarized";
138
+ } | {
139
+ readonly _tag: "BetweenTools";
140
+ } | {
141
+ readonly _tag: "Off";
142
+ };
143
+ readonly promptCache?: {
144
+ readonly _tag: "Off";
145
+ } | {
146
+ readonly _tag: "Auto";
147
+ readonly ttl: "5m" | "1h";
148
+ } | {
149
+ readonly _tag: "TopLevel";
150
+ readonly ttl: "5m" | "1h";
151
+ };
65
152
  };
66
153
  };
67
154
  readonly storage: "local" | "memory";
@@ -86,5 +173,6 @@ export declare const modelsPlugin: import("@xandreed/core").TypedPlugin<{
86
173
  };
87
174
  readonly proxy: string | Redacted.Redacted<string>;
88
175
  readonly noProxy: string;
89
- }, SettingsStore | AuthStore | ModelCatalog | UtilityLlm | LanguageModel.LanguageModel, HarnessError | import("@xandreed/core").AuthError | EffectConfig.ConfigError, SessionEnvironment>;
176
+ readonly thinkingPrefixMismatch: "error" | "provider-default" | "drop_block";
177
+ }, ModelCatalog | UtilityLlm | LanguageModel.LanguageModel | AuthStore | SettingsStore, HarnessError | import("./index.js").AuthError | EffectConfig.ConfigError, SessionEnvironment>;
90
178
  export default modelsPlugin;
@@ -1,7 +1,9 @@
1
1
  import { homedir } from "node:os";
2
2
  import { LanguageModel } from "effect/ai";
3
3
  import { Config as EffectConfig, Context, Effect, Layer, Match, Option, Redacted, Schema } from "effect";
4
- import { AuthStore, definePlugin, EngineSettings, HarnessError, ModelCatalog, SessionEnvironment, SettingsError, SettingsStore, UtilityLlm } from "@xandreed/core";
4
+ import { definePlugin, HarnessError, ModelCatalog, SessionEnvironment, UtilityLlm } from "@xandreed/core";
5
+ import { AuthStore } from "./ports/auth-store.port.js";
6
+ import { EngineSettings, SettingsError, SettingsStore } from "./ports/settings-store.port.js";
5
7
  import { CredentialInputs } from "./auth/credential-input.entity.js";
6
8
  import { credentialsOf } from "./auth/credential-input.entity.functions.js";
7
9
  import { LocalAuthStoreLive } from "./auth/localAuth.js";
@@ -13,27 +15,33 @@ import { LocalSettingsStoreLive } from "./settings/localSettings.js";
13
15
  import { MemorySettingsStoreLive } from "./settings/memory-settings.adapter.js";
14
16
  import { HostCredentials } from "./ports/host-credentials.port.js";
15
17
  import { CurrentModelEndpoints, ModelEndpoints } from "./ports/model-endpoints.port.js";
16
- import { ModelsTransport } from "./ports/models-transport.port.js";
18
+ import { CurrentModelSettings } from "./ports/model-settings.port.js";
19
+ import { ModelSettingsMap } from "./llm/model-settings.entity.js";
20
+ import { CurrentThinkingPrefixMismatch } from "./ports/thinking-binding.port.js";
21
+ import { ThinkingPrefixMismatch } from "./llm/thinking-binding.entity.js";
22
+ import { CurrentManagedModelsTransport, ModelsTransport } from "./ports/models-transport.port.js";
17
23
  import { ProxyUrlOption } from "./transport/http-proxy.entity.js";
18
- import { modelsTransportFor } from "./transport/models-transport.adapter.js";
24
+ import { modelsTransportContextFor } from "./transport/models-transport.adapter.js";
19
25
  const Config = Schema.Struct({
20
26
  model: Schema.String,
21
27
  fastModel: Schema.String,
22
28
  fallbackModel: Schema.String,
23
29
  inheritPrevious: Schema.Boolean,
24
30
  endpoints: ModelEndpoints,
31
+ modelSettings: ModelSettingsMap,
25
32
  storage: Schema.Literals(["local", "memory"]),
26
33
  credentials: CredentialInputs,
27
34
  credentialEnv: Schema.Record(Schema.String, Schema.String),
28
35
  proxy: ProxyUrlOption,
29
- noProxy: Schema.String
36
+ noProxy: Schema.String,
37
+ thinkingPrefixMismatch: ThinkingPrefixMismatch
30
38
  }).annotate({ messageUnexpectedKey: "not an option of the models plugin" }).check(Schema.makeFilter((config) => config.storage === "memory" || Object.keys(config.credentials).length === 0 && Object.keys(config.credentialEnv).length === 0 ? void 0 : 'credentials and credentialEnv need storage: "memory"')), optional = (value) => value.length === 0 ? Option.none() : Option.some(value), overlaid = (config) => (previous) => new EngineSettings({
31
39
  ...previous,
32
40
  model: Option.orElse(optional(config.model), () => previous.model),
33
41
  fastModel: Option.orElse(optional(config.fastModel), () => previous.fastModel),
34
42
  fallbackModel: Option.orElse(optional(config.fallbackModel), () => previous.fallbackModel)
35
43
  }), memoryStores = (config) => Layer.unwrap(Effect.gen(function* () {
36
- const fromEnv = yield* Effect.forEach(Object.entries(config.credentialEnv), ([provider, name]) => EffectConfig.option(EffectConfig.Redacted(name)).pipe(Effect.map(Option.map((key) => [provider, { type: "api_key", key: Redacted.value(key) }])))), seed = new Map([...fromEnv.flatMap(Option.toArray), ...credentialsOf(config.credentials)]);
44
+ const fromEnv = yield* Effect.forEach(Object.entries(config.credentialEnv), ([provider, name]) => EffectConfig.option(EffectConfig.Redacted(name)).pipe(Effect.map(Option.map((key) => [provider, { type: "api_key", key }]))), { concurrency: 1 }), seed = new Map([...fromEnv.flatMap(Option.toArray), ...credentialsOf(config.credentials)]);
37
45
  return Layer.mergeAll(MemoryAuthStoreLive(seed), MemorySettingsStoreLive(overlaid(config)(new EngineSettings({}))));
38
46
  })), readOnly = (what) => () => Effect.fail(new SettingsError({ message: `Edit the models plugin configuration to change ${what}` })), localStores = (config, workspace) => {
39
47
  const home = homedir(), files = Effect.service(SettingsStore).pipe(Effect.provide(LocalSettingsStoreLive(workspace, home))), settings = Layer.effect(SettingsStore, Effect.map(files, (stored) => SettingsStore.of({
@@ -43,23 +51,20 @@ const Config = Schema.Struct({
43
51
  })));
44
52
  return Layer.mergeAll(settings, LocalAuthStoreLive(workspace, home, ".efferent/runtime", config.inheritPrevious ? ".efferent" : void 0));
45
53
  }, optionError = (message) => new HarnessError({ code: "config.options", plugin: "@xandreed/plugin-models", message }), transportLayer = (config) => Effect.flatMap(Effect.serviceOption(ModelsTransport), Option.match({
46
- onSome: (host) => Redacted.isRedacted(config.proxy) || config.proxy.length > 0 || config.noProxy.length > 0 ? Effect.fail(optionError("the host provides ModelsTransport, so the proxy and noProxy options would not apply: give the proxy to the host's transport")) : Effect.succeed(Layer.succeed(ModelsTransport, host)),
47
- onNone: () => Effect.succeed(Layer.effectContext(modelsTransportFor({ proxy: config.proxy, noProxy: config.noProxy }).pipe(Effect.map(Option.match({
48
- onNone: () => Context.empty(),
49
- onSome: (transport) => Context.make(ModelsTransport, transport)
50
- })), Effect.mapError((error) => optionError(error.message)))))
54
+ onSome: (host) => Redacted.isRedacted(config.proxy) || config.proxy.length > 0 || config.noProxy.length > 0 ? Effect.fail(optionError("the host provides ModelsTransport, so the proxy and noProxy options would not apply: give the proxy to the host's transport")) : Effect.succeed(Layer.succeedContext(Context.make(ModelsTransport, host).pipe(Context.add(CurrentManagedModelsTransport, Option.none())))),
55
+ onNone: () => Effect.succeed(Layer.effectContext(modelsTransportContextFor({ proxy: config.proxy, noProxy: config.noProxy }).pipe(Effect.mapError((error) => optionError(error.message)))))
51
56
  }));
52
57
  export const modelsPlugin = definePlugin({
53
58
  id: "@xandreed/plugin-models",
54
- version: "0.14.0",
59
+ version: "0.15.0",
55
60
  scope: "runtime",
56
61
  config: Config,
57
- defaults: { model: "", fastModel: "", fallbackModel: "", inheritPrevious: !0, endpoints: {}, storage: "local", credentials: {}, credentialEnv: {}, proxy: "", noProxy: "" },
62
+ defaults: { model: "", fastModel: "", fallbackModel: "", inheritPrevious: !0, endpoints: {}, modelSettings: {}, storage: "local", credentials: {}, credentialEnv: {}, proxy: "", noProxy: "", thinkingPrefixMismatch: "drop_block" },
58
63
  requires: [SessionEnvironment],
59
64
  optional: [HostCredentials, ModelsTransport],
60
65
  provides: [LanguageModel.LanguageModel, UtilityLlm, AuthStore, SettingsStore, ModelCatalog],
61
66
  layer: (config) => Layer.unwrap(Effect.gen(function* () {
62
- const { workspace } = yield* SessionEnvironment, host = yield* Effect.serviceOption(HostCredentials), stores = yield* Match.value(config.storage).pipe(Match.when("memory", () => Effect.succeed(memoryStores(config))), Match.when("local", () => Option.isSome(host) ? Effect.fail(optionError('the host provides HostCredentials, which needs storage: "memory"')) : Effect.succeed(localStores(config, workspace))), Match.exhaustive), transport = yield* transportLayer(config), base = Layer.mergeAll(stores, Layer.succeed(CurrentModelEndpoints, config.endpoints)).pipe(Layer.provideMerge(transport));
67
+ const { workspace } = yield* SessionEnvironment, host = yield* Effect.serviceOption(HostCredentials), stores = yield* Match.value(config.storage).pipe(Match.when("memory", () => Effect.succeed(memoryStores(config))), Match.when("local", () => Option.isSome(host) ? Effect.fail(optionError('the host provides HostCredentials, which needs storage: "memory"')) : Effect.succeed(localStores(config, workspace))), Match.exhaustive), transport = yield* transportLayer(config), base = Layer.mergeAll(stores, Layer.succeed(CurrentModelEndpoints, config.endpoints), Layer.succeed(CurrentModelSettings, config.modelSettings), Layer.succeed(CurrentThinkingPrefixMismatch, config.thinkingPrefixMismatch)).pipe(Layer.provideMerge(transport));
63
68
  return Layer.mergeAll(LanguageModelLive, UtilityLlmLive, ConfiguredModelCatalogLive).pipe(Layer.provideMerge(base));
64
69
  }))
65
70
  });
@@ -0,0 +1,73 @@
1
+ import { Context, Option, Schema } from "effect";
2
+ import type { Effect, Redacted } from "effect";
3
+ import type { ProviderId } from "@xandreed/core";
4
+ /**
5
+ * A stored provider credential: a raw API key, an OAuth subscription (with
6
+ * refresh material and absolute expiry, epoch ms), or a local provider that
7
+ * needs no key. Its secrets (`key`, `access`, `refresh`) are `Redacted`, so
8
+ * they print as `<redacted>` in errors, logs and fingerprints; unwrap one
9
+ * with `Redacted.value` only where a request is built. The encoded form is
10
+ * the on-disk `auth.json` vocabulary the previous line established, secrets
11
+ * in the clear, so existing credentials keep working. Store a credential
12
+ * through `Schema.encodeEffect(Credential)`, never `JSON.stringify`, which
13
+ * writes `"<redacted>"` in place of each secret.
14
+ *
15
+ * @public
16
+ */
17
+ export declare const Credential: Schema.Union<readonly [Schema.Struct<{
18
+ readonly type: Schema.Literal<"api_key">;
19
+ readonly key: Schema.RedactedFromValue<Schema.String>;
20
+ }>, Schema.Struct<{
21
+ readonly type: Schema.Literal<"oauth">;
22
+ readonly access: Schema.RedactedFromValue<Schema.String>;
23
+ readonly refresh: Schema.RedactedFromValue<Schema.String>;
24
+ readonly expires: Schema.Number;
25
+ readonly accountId: Schema.optionalKey<Schema.String>;
26
+ readonly installationId: Schema.optionalKey<Schema.String>;
27
+ }>, Schema.Struct<{
28
+ readonly type: Schema.Literal<"local">;
29
+ readonly baseUrl: Schema.optionalKey<Schema.String>;
30
+ }>]>;
31
+ export type Credential = typeof Credential.Type;
32
+ declare const AuthError_base: Schema.Class<AuthError, Schema.TaggedStruct<"AuthError", {
33
+ readonly provider: Schema.String;
34
+ readonly message: Schema.String;
35
+ /**
36
+ * `auth.corrupt`: the store's backing data could not be read or decoded.
37
+ * Nothing was read as absent and nothing was written over it.
38
+ */
39
+ readonly code: Schema.optionalKey<Schema.Literal<"auth.corrupt">>;
40
+ }>, import("effect/Cause").YieldableError>;
41
+ export declare class AuthError extends AuthError_base {
42
+ }
43
+ declare const AuthStore_base: Context.ServiceClass<AuthStore, "@xandreed/core/AuthStore", {
44
+ readonly all: Effect.Effect<ReadonlyMap<string, Credential>, AuthError>;
45
+ readonly get: (p: ProviderId) => Effect.Effect<Option.Option<Credential>, AuthError>;
46
+ readonly resolveKey: (p: ProviderId) => Effect.Effect<Option.Option<Redacted.Redacted<string>>, AuthError>;
47
+ /** Persist a credential (a login). */
48
+ readonly set: (p: ProviderId, credential: Credential) => Effect.Effect<void, AuthError>;
49
+ /** Remove a provider's credential everywhere it is stored (a logout). */
50
+ readonly remove: (p: ProviderId) => Effect.Effect<void, AuthError>;
51
+ }>;
52
+ /**
53
+ * The credential store, read lazily per request: `resolveKey` returns a
54
+ * usable secret (refreshing a near-expiry OAuth token first, and persisting
55
+ * it where the store keeps credentials), so a credential added mid-session
56
+ * takes effect on the next call with no restart. `None` means the provider
57
+ * is not configured. Every secret it hands out, through `get` and `all` as
58
+ * through `resolveKey`, is `Redacted`.
59
+ *
60
+ * One refresh of a credential runs at a time: concurrent calls on one
61
+ * expired token send one token request and share its result. A `set` or
62
+ * `remove` made while that request is in flight wins over the refreshed
63
+ * token. A store whose backing data cannot be read or decoded fails every
64
+ * call with an `AuthError` of code `auth.corrupt` instead of reading it as
65
+ * empty, so no write replaces credentials it could not read.
66
+ * `authStoreConformance` (`@xandreed/plugin-models/conformance`) checks a
67
+ * store against this contract. Its key stays `@xandreed/core/AuthStore`, the
68
+ * package it was first declared in: plugins' `provides` and hosts' bindings
69
+ * name it, so it never changes.
70
+ */
71
+ export declare class AuthStore extends AuthStore_base {
72
+ }
73
+ export {};
@@ -0,0 +1,24 @@
1
+ import { Context, Option, Schema } from "effect";
2
+ const Secret = Schema.RedactedFromValue(Schema.String);
3
+ export const Credential = Schema.Union([
4
+ Schema.Struct({ type: Schema.Literal("api_key"), key: Secret }),
5
+ Schema.Struct({
6
+ type: Schema.Literal("oauth"),
7
+ access: Secret,
8
+ refresh: Secret,
9
+ expires: Schema.Number,
10
+ accountId: Schema.optionalKey(Schema.String),
11
+ installationId: Schema.optionalKey(Schema.String)
12
+ }),
13
+ Schema.Struct({ type: Schema.Literal("local"), baseUrl: Schema.optionalKey(Schema.String) })
14
+ ]);
15
+
16
+ export class AuthError extends Schema.TaggedError()("AuthError", {
17
+ provider: Schema.String,
18
+ message: Schema.String,
19
+ code: Schema.optionalKey(Schema.Literal("auth.corrupt"))
20
+ }) {
21
+ }
22
+
23
+ export class AuthStore extends Context.Service()("@xandreed/core/AuthStore") {
24
+ }