@acentric/llm 0.0.0-stage → 0.1.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 (232) hide show
  1. package/README.md +128 -2
  2. package/dist/catalog/anthropic.d.ts +77 -0
  3. package/dist/catalog/anthropic.d.ts.map +1 -0
  4. package/dist/catalog/anthropic.js +73 -0
  5. package/dist/catalog/anthropic.js.map +1 -0
  6. package/dist/catalog/fireworks.d.ts +93 -0
  7. package/dist/catalog/fireworks.d.ts.map +1 -0
  8. package/dist/catalog/fireworks.js +117 -0
  9. package/dist/catalog/fireworks.js.map +1 -0
  10. package/dist/catalog/openai.d.ts +103 -0
  11. package/dist/catalog/openai.d.ts.map +1 -0
  12. package/dist/catalog/openai.js +102 -0
  13. package/dist/catalog/openai.js.map +1 -0
  14. package/dist/client/anthropic/errors.d.ts +4 -0
  15. package/dist/client/anthropic/errors.d.ts.map +1 -0
  16. package/dist/client/anthropic/errors.js +80 -0
  17. package/dist/client/anthropic/errors.js.map +1 -0
  18. package/dist/client/anthropic/request.d.ts +9 -0
  19. package/dist/client/anthropic/request.d.ts.map +1 -0
  20. package/dist/client/anthropic/request.js +232 -0
  21. package/dist/client/anthropic/request.js.map +1 -0
  22. package/dist/client/anthropic/response.d.ts +13 -0
  23. package/dist/client/anthropic/response.d.ts.map +1 -0
  24. package/dist/client/anthropic/response.js +207 -0
  25. package/dist/client/anthropic/response.js.map +1 -0
  26. package/dist/client/direct.d.ts +18 -0
  27. package/dist/client/direct.d.ts.map +1 -0
  28. package/dist/client/direct.js +104 -0
  29. package/dist/client/direct.js.map +1 -0
  30. package/dist/client/gateway.d.ts +43 -0
  31. package/dist/client/gateway.d.ts.map +1 -0
  32. package/dist/client/gateway.js +72 -0
  33. package/dist/client/gateway.js.map +1 -0
  34. package/dist/client/http.d.ts +41 -0
  35. package/dist/client/http.d.ts.map +1 -0
  36. package/dist/client/http.js +190 -0
  37. package/dist/client/http.js.map +1 -0
  38. package/dist/client/llm.d.ts +41 -0
  39. package/dist/client/llm.d.ts.map +1 -0
  40. package/dist/client/llm.js +100 -0
  41. package/dist/client/llm.js.map +1 -0
  42. package/dist/client/responses/errors.d.ts +13 -0
  43. package/dist/client/responses/errors.d.ts.map +1 -0
  44. package/dist/client/responses/errors.js +144 -0
  45. package/dist/client/responses/errors.js.map +1 -0
  46. package/dist/client/responses/profile.d.ts +50 -0
  47. package/dist/client/responses/profile.d.ts.map +1 -0
  48. package/dist/client/responses/profile.js +4 -0
  49. package/dist/client/responses/profile.js.map +1 -0
  50. package/dist/client/responses/providers.d.ts +4 -0
  51. package/dist/client/responses/providers.d.ts.map +1 -0
  52. package/dist/client/responses/providers.js +75 -0
  53. package/dist/client/responses/providers.js.map +1 -0
  54. package/dist/client/responses/request.d.ts +5 -0
  55. package/dist/client/responses/request.d.ts.map +1 -0
  56. package/dist/client/responses/request.js +248 -0
  57. package/dist/client/responses/request.js.map +1 -0
  58. package/dist/client/responses/response.d.ts +10 -0
  59. package/dist/client/responses/response.d.ts.map +1 -0
  60. package/dist/client/responses/response.js +176 -0
  61. package/dist/client/responses/response.js.map +1 -0
  62. package/dist/client/util.d.ts +21 -0
  63. package/dist/client/util.d.ts.map +1 -0
  64. package/dist/client/util.js +63 -0
  65. package/dist/client/util.js.map +1 -0
  66. package/dist/client.d.ts +9 -0
  67. package/dist/client.d.ts.map +1 -0
  68. package/dist/client.js +10 -0
  69. package/dist/client.js.map +1 -0
  70. package/dist/content.d.ts +38 -0
  71. package/dist/content.d.ts.map +1 -0
  72. package/dist/content.js +5 -0
  73. package/dist/content.js.map +1 -0
  74. package/dist/convert/anthropic.d.ts +3 -0
  75. package/dist/convert/anthropic.d.ts.map +1 -0
  76. package/dist/convert/anthropic.js +114 -0
  77. package/dist/convert/anthropic.js.map +1 -0
  78. package/dist/convert/index.d.ts +12 -0
  79. package/dist/convert/index.d.ts.map +1 -0
  80. package/dist/convert/index.js +221 -0
  81. package/dist/convert/index.js.map +1 -0
  82. package/dist/convert/responses.d.ts +3 -0
  83. package/dist/convert/responses.d.ts.map +1 -0
  84. package/dist/convert/responses.js +197 -0
  85. package/dist/convert/responses.js.map +1 -0
  86. package/dist/convert/types.d.ts +88 -0
  87. package/dist/convert/types.d.ts.map +1 -0
  88. package/dist/convert/types.js +3 -0
  89. package/dist/convert/types.js.map +1 -0
  90. package/dist/cost.d.ts +18 -0
  91. package/dist/cost.d.ts.map +1 -0
  92. package/dist/cost.js +49 -0
  93. package/dist/cost.js.map +1 -0
  94. package/dist/failure.d.ts +90 -0
  95. package/dist/failure.d.ts.map +1 -0
  96. package/dist/failure.js +111 -0
  97. package/dist/failure.js.map +1 -0
  98. package/dist/fields.d.ts +14 -0
  99. package/dist/fields.d.ts.map +1 -0
  100. package/dist/fields.js +3 -0
  101. package/dist/fields.js.map +1 -0
  102. package/dist/formats/anthropic.d.ts +152 -0
  103. package/dist/formats/anthropic.d.ts.map +1 -0
  104. package/dist/formats/anthropic.js +7 -0
  105. package/dist/formats/anthropic.js.map +1 -0
  106. package/dist/formats/responses.d.ts +179 -0
  107. package/dist/formats/responses.d.ts.map +1 -0
  108. package/dist/formats/responses.js +9 -0
  109. package/dist/formats/responses.js.map +1 -0
  110. package/dist/index.d.ts +26 -0
  111. package/dist/index.d.ts.map +1 -0
  112. package/dist/index.js +11 -0
  113. package/dist/index.js.map +1 -0
  114. package/dist/json.d.ts +6 -0
  115. package/dist/json.d.ts.map +1 -0
  116. package/dist/json.js +10 -0
  117. package/dist/json.js.map +1 -0
  118. package/dist/messages.d.ts +82 -0
  119. package/dist/messages.d.ts.map +1 -0
  120. package/dist/messages.js +4 -0
  121. package/dist/messages.js.map +1 -0
  122. package/dist/model.d.ts +36 -0
  123. package/dist/model.d.ts.map +1 -0
  124. package/dist/model.js +4 -0
  125. package/dist/model.js.map +1 -0
  126. package/dist/models.d.ts +9 -0
  127. package/dist/models.d.ts.map +1 -0
  128. package/dist/models.js +19 -0
  129. package/dist/models.js.map +1 -0
  130. package/dist/providers/anthropic.d.ts +130 -0
  131. package/dist/providers/anthropic.d.ts.map +1 -0
  132. package/dist/providers/anthropic.js +5 -0
  133. package/dist/providers/anthropic.js.map +1 -0
  134. package/dist/providers/fireworks.d.ts +108 -0
  135. package/dist/providers/fireworks.d.ts.map +1 -0
  136. package/dist/providers/fireworks.js +6 -0
  137. package/dist/providers/fireworks.js.map +1 -0
  138. package/dist/providers/openai.d.ts +193 -0
  139. package/dist/providers/openai.d.ts.map +1 -0
  140. package/dist/providers/openai.js +4 -0
  141. package/dist/providers/openai.js.map +1 -0
  142. package/dist/providers.d.ts +50 -0
  143. package/dist/providers.d.ts.map +1 -0
  144. package/dist/providers.js +5 -0
  145. package/dist/providers.js.map +1 -0
  146. package/dist/read/anthropic.d.ts +15 -0
  147. package/dist/read/anthropic.d.ts.map +1 -0
  148. package/dist/read/anthropic.js +56 -0
  149. package/dist/read/anthropic.js.map +1 -0
  150. package/dist/read/responses.d.ts +26 -0
  151. package/dist/read/responses.d.ts.map +1 -0
  152. package/dist/read/responses.js +121 -0
  153. package/dist/read/responses.js.map +1 -0
  154. package/dist/read.d.ts +32 -0
  155. package/dist/read.d.ts.map +1 -0
  156. package/dist/read.js +73 -0
  157. package/dist/read.js.map +1 -0
  158. package/dist/request.d.ts +23 -0
  159. package/dist/request.d.ts.map +1 -0
  160. package/dist/request.js +4 -0
  161. package/dist/request.js.map +1 -0
  162. package/dist/response.d.ts +38 -0
  163. package/dist/response.d.ts.map +1 -0
  164. package/dist/response.js +4 -0
  165. package/dist/response.js.map +1 -0
  166. package/dist/secrets.d.ts +22 -0
  167. package/dist/secrets.d.ts.map +1 -0
  168. package/dist/secrets.js +35 -0
  169. package/dist/secrets.js.map +1 -0
  170. package/dist/tool-calls.d.ts +28 -0
  171. package/dist/tool-calls.d.ts.map +1 -0
  172. package/dist/tool-calls.js +4 -0
  173. package/dist/tool-calls.js.map +1 -0
  174. package/dist/tools.d.ts +33 -0
  175. package/dist/tools.d.ts.map +1 -0
  176. package/dist/tools.js +4 -0
  177. package/dist/tools.js.map +1 -0
  178. package/dist/usage.d.ts +31 -0
  179. package/dist/usage.d.ts.map +1 -0
  180. package/dist/usage.js +3 -0
  181. package/dist/usage.js.map +1 -0
  182. package/dist/version.d.ts +2 -0
  183. package/dist/version.d.ts.map +1 -0
  184. package/dist/version.js +6 -0
  185. package/dist/version.js.map +1 -0
  186. package/package.json +33 -3
  187. package/src/catalog/anthropic.ts +83 -0
  188. package/src/catalog/fireworks.ts +121 -0
  189. package/src/catalog/openai.ts +133 -0
  190. package/src/client/anthropic/errors.ts +88 -0
  191. package/src/client/anthropic/request.ts +272 -0
  192. package/src/client/anthropic/response.ts +236 -0
  193. package/src/client/direct.ts +157 -0
  194. package/src/client/gateway.ts +120 -0
  195. package/src/client/http.ts +231 -0
  196. package/src/client/llm.ts +151 -0
  197. package/src/client/responses/errors.ts +166 -0
  198. package/src/client/responses/profile.ts +55 -0
  199. package/src/client/responses/providers.ts +87 -0
  200. package/src/client/responses/request.ts +326 -0
  201. package/src/client/responses/response.ts +203 -0
  202. package/src/client/util.ts +81 -0
  203. package/src/client.ts +20 -0
  204. package/src/content.ts +46 -0
  205. package/src/convert/anthropic.ts +115 -0
  206. package/src/convert/index.ts +275 -0
  207. package/src/convert/responses.ts +203 -0
  208. package/src/convert/types.ts +104 -0
  209. package/src/cost.ts +71 -0
  210. package/src/failure.ts +164 -0
  211. package/src/fields.ts +13 -0
  212. package/src/formats/anthropic.ts +191 -0
  213. package/src/formats/responses.ts +207 -0
  214. package/src/index.ts +44 -0
  215. package/src/json.ts +13 -0
  216. package/src/messages.ts +99 -0
  217. package/src/model.ts +41 -0
  218. package/src/models.ts +23 -0
  219. package/src/providers/anthropic.ts +137 -0
  220. package/src/providers/fireworks.ts +140 -0
  221. package/src/providers/openai.ts +275 -0
  222. package/src/providers.ts +59 -0
  223. package/src/read/anthropic.ts +64 -0
  224. package/src/read/responses.ts +159 -0
  225. package/src/read.ts +108 -0
  226. package/src/request.ts +28 -0
  227. package/src/response.ts +44 -0
  228. package/src/secrets.ts +40 -0
  229. package/src/tool-calls.ts +33 -0
  230. package/src/tools.ts +38 -0
  231. package/src/usage.ts +33 -0
  232. package/src/version.ts +6 -0
@@ -0,0 +1,272 @@
1
+ // The body of an Anthropic create-message request. Each of the request's
2
+ // messages becomes one Anthropic message. As for the Responses format, every
3
+ // shape is checked as it is written.
4
+
5
+ import type { Json, JsonObject } from '@acentric/types';
6
+
7
+ import { isArray, isObject } from '../../json.js';
8
+ import { findModel } from '../../models.js';
9
+ import type { LlmRequest } from '../../request.js';
10
+ import { invalidRequest, ownFields, plainText, show } from '../util.js';
11
+
12
+ const PROVIDER = 'anthropic';
13
+ const LABEL = 'Anthropic';
14
+
15
+ /** Request fields the request itself sets. */
16
+ const OWNED_OPTIONS = ['model', 'messages', 'system'];
17
+
18
+ /** The output limit for a model the catalog does not know: the Claude 5 maximum. */
19
+ const DEFAULT_MAX_TOKENS = 128_000;
20
+
21
+ export type AnthropicRequest = {
22
+ readonly body: JsonObject;
23
+ /** Beta names, for the `anthropic-beta` header. */
24
+ readonly betas: readonly string[];
25
+ };
26
+
27
+ export function buildRequest(request: LlmRequest<'anthropic'>): AnthropicRequest {
28
+ const options = ownFields(PROVIDER, request.options, OWNED_OPTIONS, 'request', 'options');
29
+ if (options['stream'] === true) {
30
+ throw invalidRequest(
31
+ PROVIDER,
32
+ 'streaming is not supported: a call returns one finished response',
33
+ );
34
+ }
35
+ const { tools: nativeTools, betas, ...rest } = options;
36
+ if (nativeTools !== undefined && !isArray(nativeTools)) {
37
+ throw invalidRequest(PROVIDER, 'options.tools must be an array of native tool definitions');
38
+ }
39
+ const messages: Json = request.messages;
40
+ if (!isArray(messages)) throw invalidRequest(PROVIDER, 'messages must be an array');
41
+ const tools = [
42
+ ...(request.tools ?? []).map((tool, index) => buildTool(tool, `tools[${index}]`)),
43
+ ...(nativeTools ?? []),
44
+ ];
45
+ const system = buildSystem(request.instructions);
46
+ const maxTokens =
47
+ rest['max_tokens'] ?? findModel(PROVIDER, request.model)?.maxOutputTokens ?? DEFAULT_MAX_TOKENS;
48
+ return {
49
+ body: {
50
+ ...rest,
51
+ model: request.model,
52
+ max_tokens: maxTokens,
53
+ ...(system === undefined ? {} : { system }),
54
+ messages: messages.flatMap((message, index) =>
55
+ buildMessage(message, `messages[${index}]`, request.model),
56
+ ),
57
+ ...(tools.length > 0 ? { tools } : {}),
58
+ },
59
+ betas: readBetas(betas),
60
+ };
61
+ }
62
+
63
+ function readBetas(value: Json | undefined): string[] {
64
+ if (value === undefined) return [];
65
+ if (isArray(value)) {
66
+ const names = value.filter(
67
+ (name): name is string => typeof name === 'string' && /^[\w.-]+$/.test(name),
68
+ );
69
+ if (names.length === value.length) return names;
70
+ }
71
+ throw invalidRequest(
72
+ PROVIDER,
73
+ 'options.betas must be an array of beta names, such as "compact-2026-01-12"',
74
+ );
75
+ }
76
+
77
+ /** A string stays a string; text parts become blocks, which may carry `cache_control`. */
78
+ function buildSystem(value: Json | undefined): Json | undefined {
79
+ if (value === undefined || typeof value === 'string') return value;
80
+ if (!isArray(value))
81
+ throw invalidRequest(PROVIDER, 'instructions must be a string or text parts');
82
+ return value.map((part, index) => textBlock(part, `instructions[${index}]`));
83
+ }
84
+
85
+ function buildMessage(message: Json, where: string, model: string): JsonObject[] {
86
+ if (!isObject(message)) throw invalidRequest(PROVIDER, `${where} must be a message`);
87
+ switch (message['role']) {
88
+ case 'user':
89
+ return [{ role: 'user', content: buildParts(message['content'], where) }];
90
+ case 'system': {
91
+ const content = message['content'];
92
+ if (!isArray(content))
93
+ throw invalidRequest(PROVIDER, `${where}.content must be an array of text parts`);
94
+ return [
95
+ {
96
+ role: 'system',
97
+ content: content.map((part, index) => textBlock(part, `${where}.content[${index}]`)),
98
+ },
99
+ ];
100
+ }
101
+ case 'assistant': {
102
+ if (message['provider'] !== PROVIDER) {
103
+ throw invalidRequest(
104
+ PROVIDER,
105
+ `${where} is from ${show(message['provider'])}: convert it before sending it to ${LABEL}`,
106
+ );
107
+ }
108
+ const content = blocks(message['content'], where);
109
+ if (message['model'] === model) return [{ role: 'assistant', content }];
110
+ // Another model's thinking is bound to it: some models read it, others
111
+ // ignore it without a word. Its other blocks are sent as written.
112
+ const kept = content.filter(
113
+ (block) => block['type'] !== 'thinking' && block['type'] !== 'redacted_thinking',
114
+ );
115
+ return kept.length > 0 ? [{ role: 'assistant', content: kept }] : [];
116
+ }
117
+ case 'native':
118
+ if (message['provider'] !== PROVIDER) {
119
+ throw invalidRequest(
120
+ PROVIDER,
121
+ `${where} holds ${show(message['provider'])} items, which ${LABEL} cannot take`,
122
+ );
123
+ }
124
+ return [{ role: 'user', content: blocks(message['content'], where) }];
125
+ case 'tool_result':
126
+ return [{ role: 'user', content: [buildToolResult(message, where)] }];
127
+ case 'custom':
128
+ throw invalidRequest(
129
+ PROVIDER,
130
+ `${where} is the application's own: leave custom messages out`,
131
+ );
132
+ default:
133
+ throw invalidRequest(PROVIDER, `${where} has an unknown role: ${show(message['role'])}`);
134
+ }
135
+ }
136
+
137
+ function blocks(value: Json | undefined, where: string): JsonObject[] {
138
+ if (!isArray(value))
139
+ throw invalidRequest(PROVIDER, `${where}.content must be an array of blocks`);
140
+ return value.map((block, index) => {
141
+ if (!isObject(block) || typeof block['type'] !== 'string') {
142
+ throw invalidRequest(PROVIDER, `${where}.content[${index}] must be a block with a type`);
143
+ }
144
+ return block;
145
+ });
146
+ }
147
+
148
+ function buildToolResult(message: JsonObject, where: string): JsonObject {
149
+ const callId = message['toolCallId'];
150
+ if (typeof callId !== 'string' || callId === '') {
151
+ throw invalidRequest(PROVIDER, `${where}.toolCallId must be a string that is not empty`);
152
+ }
153
+ const toolType = message['toolType'] ?? 'function';
154
+ if (toolType === 'custom')
155
+ throw invalidRequest(PROVIDER, `${where} answers a custom tool, and ${LABEL} has none`);
156
+ if (toolType !== 'function')
157
+ throw invalidRequest(PROVIDER, `${where}.toolType must be function or custom`);
158
+ const isError = message['isError'];
159
+ if (isError !== undefined && typeof isError !== 'boolean') {
160
+ throw invalidRequest(PROVIDER, `${where}.isError must be true or false`);
161
+ }
162
+ const owned = ['type', 'tool_use_id', 'content', 'is_error'];
163
+ return {
164
+ ...ownFields(PROVIDER, message['providerOptions'], owned, where),
165
+ type: 'tool_result',
166
+ tool_use_id: callId,
167
+ content: plainText(message['content']) ?? buildParts(message['content'], where),
168
+ ...(isError === undefined ? {} : { is_error: isError }),
169
+ };
170
+ }
171
+
172
+ function textBlock(part: Json, at: string): JsonObject {
173
+ if (!isObject(part) || part['type'] !== 'text' || typeof part['text'] !== 'string') {
174
+ throw invalidRequest(PROVIDER, `${at} must be a text part`);
175
+ }
176
+ return {
177
+ ...ownFields(PROVIDER, part['providerOptions'], ['type', 'text'], at),
178
+ type: 'text',
179
+ text: part['text'],
180
+ };
181
+ }
182
+
183
+ function buildParts(value: Json | undefined, where: string): JsonObject[] {
184
+ if (!isArray(value)) throw invalidRequest(PROVIDER, `${where}.content must be an array of parts`);
185
+ return value.map((part, index) => buildPart(part, `${where}.content[${index}]`));
186
+ }
187
+
188
+ function buildPart(part: Json, at: string): JsonObject {
189
+ if (!isObject(part)) throw invalidRequest(PROVIDER, `${at} must be a content part`);
190
+ const options = part['providerOptions'];
191
+ switch (part['type']) {
192
+ case 'text':
193
+ return textBlock(part, at);
194
+ case 'image':
195
+ return {
196
+ ...ownFields(PROVIDER, options, ['type', 'source'], at),
197
+ type: 'image',
198
+ source: mediaSource(part['source'], at, false),
199
+ };
200
+ case 'file': {
201
+ const filename = part['filename'];
202
+ if (filename !== undefined && typeof filename !== 'string') {
203
+ throw invalidRequest(PROVIDER, `${at}.filename must be a string`);
204
+ }
205
+ const owned = filename === undefined ? ['type', 'source'] : ['type', 'source', 'title'];
206
+ return {
207
+ ...ownFields(PROVIDER, options, owned, at),
208
+ type: 'document',
209
+ source: mediaSource(part['source'], at, true),
210
+ ...(filename === undefined ? {} : { title: filename }),
211
+ };
212
+ }
213
+ default:
214
+ throw invalidRequest(PROVIDER, `${at} has an unknown type: ${show(part['type'])}`);
215
+ }
216
+ }
217
+
218
+ /** A shared source as Anthropic's. A plain text document goes as text, the only way Anthropic takes it. */
219
+ function mediaSource(source: Json | undefined, at: string, document: boolean): JsonObject {
220
+ if (isObject(source)) {
221
+ const { type, url, id, data, mediaType } = source;
222
+ if (type === 'url' && typeof url === 'string') return { type: 'url', url };
223
+ if (type === 'file_id' && typeof id === 'string') return { type: 'file', file_id: id };
224
+ if (type === 'base64' && typeof data === 'string' && typeof mediaType === 'string') {
225
+ if (document && mediaType === 'text/plain') {
226
+ return { type: 'text', media_type: 'text/plain', data: decodeText(data, at) };
227
+ }
228
+ return { type: 'base64', media_type: mediaType, data };
229
+ }
230
+ }
231
+ throw invalidRequest(PROVIDER, `${at}.source must be a url, base64 or file_id source`);
232
+ }
233
+
234
+ function decodeText(base64: string, at: string): string {
235
+ try {
236
+ const bytes = Uint8Array.from(atob(base64), (char) => char.codePointAt(0) ?? 0);
237
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
238
+ } catch {
239
+ throw invalidRequest(PROVIDER, `${at}.source must hold UTF-8 text in base64 for text/plain`);
240
+ }
241
+ }
242
+
243
+ function buildTool(tool: Json, where: string): JsonObject {
244
+ if (
245
+ !isObject(tool) ||
246
+ typeof tool['name'] !== 'string' ||
247
+ typeof tool['description'] !== 'string'
248
+ ) {
249
+ throw invalidRequest(PROVIDER, `${where} needs a type, a name and a description`);
250
+ }
251
+ const { name, description, parameters, strict } = tool;
252
+ const at = `tool ${name}`;
253
+ if (tool['type'] === 'custom') {
254
+ throw invalidRequest(
255
+ PROVIDER,
256
+ `${at} is a custom tool, which ${LABEL} cannot take: use a function tool`,
257
+ );
258
+ }
259
+ if (tool['type'] !== 'function')
260
+ throw invalidRequest(PROVIDER, `${where} has an unknown type: ${show(tool['type'])}`);
261
+ const owned = ['type', 'name', 'description', 'input_schema', 'strict'];
262
+ const fields = ownFields(PROVIDER, tool['providerOptions'], owned, at);
263
+ if (!isObject(parameters))
264
+ throw invalidRequest(PROVIDER, `${at}: parameters must be a JSON Schema object`);
265
+ return {
266
+ ...fields,
267
+ name,
268
+ description,
269
+ input_schema: parameters,
270
+ ...(strict === undefined ? {} : { strict }),
271
+ };
272
+ }
@@ -0,0 +1,236 @@
1
+ // An Anthropic answer read into an LlmResponse.
2
+
3
+ import type { JsonObject } from '@acentric/types';
4
+
5
+ import { estimateCost } from '../../cost.js';
6
+ import { isArray, isObject } from '../../json.js';
7
+ import { findModel } from '../../models.js';
8
+ import type { LlmRequest } from '../../request.js';
9
+ import type { LlmResponse, StopReason } from '../../response.js';
10
+ import type { Usage, UsageCost } from '../../usage.js';
11
+ import type { JsonAnswer } from '../http.js';
12
+ import { invalidResponse, omit, tokens } from '../util.js';
13
+
14
+ const PROVIDER = 'anthropic';
15
+
16
+ /** Response fields the response already holds elsewhere. */
17
+ const ENVELOPE_FIELDS = ['id', 'type', 'role', 'content', 'model', 'stop_reason', 'usage'];
18
+
19
+ /** Usage fields mapped to `Usage`; the rest of `usage` stays in `native`. */
20
+ const USAGE_FIELDS = [
21
+ 'input_tokens',
22
+ 'output_tokens',
23
+ 'cache_creation_input_tokens',
24
+ 'cache_read_input_tokens',
25
+ 'output_tokens_details',
26
+ ];
27
+
28
+ const STOP_REASONS: { readonly [reason: string]: StopReason } = {
29
+ end_turn: 'stop',
30
+ stop_sequence: 'stop',
31
+ max_tokens: 'length',
32
+ model_context_window_exceeded: 'length',
33
+ tool_use: 'tool_use',
34
+ pause_turn: 'pause_turn',
35
+ // Compaction paused after summarizing; sending the message back goes on.
36
+ compaction: 'pause_turn',
37
+ refusal: 'refusal',
38
+ };
39
+
40
+ /** The only categories in which a refusal before any output is billed. */
41
+ const BILLED_REFUSALS: ReadonlySet<string> = new Set([
42
+ 'bio',
43
+ 'frontier_llm',
44
+ 'reasoning_extraction',
45
+ ]);
46
+
47
+ export function parseResponse(
48
+ request: LlmRequest<'anthropic'>,
49
+ answer: JsonAnswer,
50
+ ): LlmResponse<'anthropic'> {
51
+ const body = answer.body;
52
+ if (!isObject(body) || body['type'] !== 'message' || !isArray(body['content'])) {
53
+ throw invalidResponse(PROVIDER, 'Anthropic did not answer with a message', body);
54
+ }
55
+ const content = body['content'];
56
+ const id = typeof body['id'] === 'string' && body['id'] !== '' ? body['id'] : undefined;
57
+ const stop = typeof body['stop_reason'] === 'string' ? body['stop_reason'] : undefined;
58
+ const stopReason = (stop === undefined ? undefined : STOP_REASONS[stop]) ?? 'stop';
59
+ const served = typeof body['model'] === 'string' ? body['model'] : request.model;
60
+ const rawUsage = isObject(body['usage']) ? body['usage'] : undefined;
61
+ const usage = rawUsage === undefined ? undefined : readUsage(rawUsage);
62
+ const cost =
63
+ rawUsage === undefined || freeRefusal(body) ? undefined : estimate(rawUsage, served, request);
64
+
65
+ // How cache writes split by time to live says nothing when nothing was written.
66
+ const extras =
67
+ rawUsage === undefined
68
+ ? undefined
69
+ : omit(
70
+ rawUsage,
71
+ usage?.cacheWrite === 0 ? [...USAGE_FIELDS, 'cache_creation'] : USAGE_FIELDS,
72
+ );
73
+ const native = {
74
+ ...omit(body, ENVELOPE_FIELDS),
75
+ ...(extras === undefined || Object.keys(extras).length === 0 ? {} : { usage: extras }),
76
+ };
77
+ const response = {
78
+ ...(id === undefined ? {} : { id }),
79
+ provider: PROVIDER,
80
+ model: request.model,
81
+ ...(served === request.model ? {} : { responseModel: served }),
82
+ message: {
83
+ role: 'assistant',
84
+ provider: PROVIDER,
85
+ api: 'anthropic-messages',
86
+ model: request.model,
87
+ ...(id === undefined ? {} : { id }),
88
+ timestamp: answer.timestamp,
89
+ content,
90
+ },
91
+ stopReason,
92
+ ...(stop === undefined || stop === stopReason ? {} : { providerStopReason: stop }),
93
+ ...(usage === undefined ? {} : { usage: cost === undefined ? usage : { ...usage, cost } }),
94
+ native,
95
+ durationMs: answer.durationMs,
96
+ timestamp: answer.timestamp,
97
+ };
98
+ // The content is Anthropic's own blocks, which the format's types describe.
99
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
100
+ return response as unknown as LlmResponse<'anthropic'>;
101
+ }
102
+
103
+ /**
104
+ * Usage as the format reports it: `input_tokens` is only the uncached part.
105
+ * Where a request sampled more than once (compaction, a fallback model), the
106
+ * iterations hold the whole bill and the top-level counts leave some out.
107
+ */
108
+ export function readUsage(raw: JsonObject): Usage {
109
+ const iterations = samplings(raw);
110
+ const counted = iterations === undefined ? counts(raw) : sum(iterations.map((one) => one.usage));
111
+ const details = isObject(raw['output_tokens_details']) ? raw['output_tokens_details'] : {};
112
+ const reasoning = tokens(details['thinking_tokens']);
113
+ return { ...counted, ...(reasoning === undefined ? {} : { reasoning }) };
114
+ }
115
+
116
+ /** One sampling's tokens. */
117
+ function counts(raw: JsonObject): Usage {
118
+ const input = tokens(raw['input_tokens']);
119
+ const output = tokens(raw['output_tokens']);
120
+ // Anthropic leaves the cache counts out when nothing was cached.
121
+ const read = raw['cache_read_input_tokens'];
122
+ const written = raw['cache_creation_input_tokens'];
123
+ const cacheRead = read === undefined || read === null ? 0 : tokens(read);
124
+ const cacheWrite = written === undefined || written === null ? 0 : tokens(written);
125
+ const totalInput =
126
+ input === undefined || cacheRead === undefined || cacheWrite === undefined
127
+ ? undefined
128
+ : input + cacheRead + cacheWrite;
129
+ return {
130
+ ...(totalInput === undefined ? {} : { totalInput }),
131
+ ...(input === undefined ? {} : { input }),
132
+ ...(output === undefined ? {} : { output }),
133
+ ...(cacheRead === undefined ? {} : { cacheRead }),
134
+ ...(cacheWrite === undefined ? {} : { cacheWrite }),
135
+ };
136
+ }
137
+
138
+ type Sampling = {
139
+ readonly model: string | undefined;
140
+ readonly usage: Usage;
141
+ /** Undefined when cache writes are not split by time to live. */
142
+ readonly cacheWrite1h: number | undefined;
143
+ };
144
+
145
+ function samplings(raw: JsonObject): Sampling[] | undefined {
146
+ const iterations = raw['iterations'];
147
+ if (!isArray(iterations) || iterations.length === 0) return undefined;
148
+ return iterations.filter(isObject).map((iteration) => {
149
+ const model = iteration['model'];
150
+ return {
151
+ model: typeof model === 'string' ? model : undefined,
152
+ usage: counts(iteration),
153
+ cacheWrite1h: hourWrites(iteration),
154
+ };
155
+ });
156
+ }
157
+
158
+ function hourWrites(raw: JsonObject): number | undefined {
159
+ if (!raw['cache_creation_input_tokens']) return 0;
160
+ const split = raw['cache_creation'];
161
+ return isObject(split) ? tokens(split['ephemeral_1h_input_tokens'] ?? 0) : undefined;
162
+ }
163
+
164
+ function sum(usages: readonly Usage[]): Usage {
165
+ const keys = ['totalInput', 'input', 'output', 'cacheRead', 'cacheWrite'] as const;
166
+ const total: { [key: string]: number } = {};
167
+ for (const key of keys) {
168
+ if (usages.every((usage) => usage[key] !== undefined)) {
169
+ total[key] = usages.reduce((all, usage) => all + (usage[key] ?? 0), 0);
170
+ }
171
+ }
172
+ return total;
173
+ }
174
+
175
+ /**
176
+ * The cost at standard prices, each sampling at its own model's prices and
177
+ * long-context tier, times the multiplier for where and how it ran. Undefined
178
+ * when any part cannot be priced.
179
+ */
180
+ function estimate(
181
+ raw: JsonObject,
182
+ served: string,
183
+ request: LlmRequest<'anthropic'>,
184
+ ): UsageCost | undefined {
185
+ const multiplier = priceMultiplier(raw, request);
186
+ if (multiplier === undefined) return undefined;
187
+ const each = samplings(raw) ?? [
188
+ { model: served, usage: counts(raw), cacheWrite1h: hourWrites(raw) },
189
+ ];
190
+ const costs: UsageCost[] = [];
191
+ for (const { model, usage, cacheWrite1h } of each) {
192
+ if (cacheWrite1h === undefined) return undefined;
193
+ const pricing = findModel(PROVIDER, model ?? served)?.pricing;
194
+ const cost = estimateCost(usage, pricing, { multiplier, cacheWrite1h });
195
+ if (cost === undefined) return undefined;
196
+ costs.push(cost);
197
+ }
198
+ const [first] = costs;
199
+ if (costs.length === 1 && first !== undefined) return first;
200
+ const parts = ['input', 'output', 'cacheRead', 'cacheWrite', 'total'] as const;
201
+ const total: { [key: string]: number } = {};
202
+ for (const cost of costs) {
203
+ for (const part of parts) {
204
+ const value = cost[part];
205
+ if (value !== undefined) total[part] = (total[part] ?? 0) + value;
206
+ }
207
+ }
208
+ return { source: 'estimated', total: 0, ...total };
209
+ }
210
+
211
+ /**
212
+ * Standard prices hold unless the response says otherwise: inference kept in
213
+ * the US costs 1.1 times as much, fast mode twice, a batch half. Priority Tier
214
+ * has no published price.
215
+ */
216
+ function priceMultiplier(raw: JsonObject, request: LlmRequest<'anthropic'>): number | undefined {
217
+ let multiplier = 1;
218
+ const tier = raw['service_tier'];
219
+ if (tier === 'batch') multiplier *= 0.5;
220
+ else if (tier !== undefined && tier !== null && tier !== 'standard') return undefined;
221
+ if (raw['inference_geo'] === 'us') multiplier *= 1.1;
222
+ const speed = raw['speed'];
223
+ if (speed === 'fast') multiplier *= 2;
224
+ else if ((speed === undefined || speed === null) && request.options?.speed === 'fast')
225
+ return undefined;
226
+ return multiplier;
227
+ }
228
+
229
+ /** A refusal before any output, in a category Anthropic does not bill. */
230
+ function freeRefusal(body: JsonObject): boolean {
231
+ const content = body['content'];
232
+ if (body['stop_reason'] !== 'refusal' || !isArray(content) || content.length > 0) return false;
233
+ const details = body['stop_details'];
234
+ const category = isObject(details) ? details['category'] : undefined;
235
+ return typeof category !== 'string' || !BILLED_REFUSALS.has(category);
236
+ }
@@ -0,0 +1,157 @@
1
+ // One call straight to a provider, with credentials in hand. What the client
2
+ // does when it has a provider's key, and what a gateway runs for each request.
3
+
4
+ import type { Json } from '@acentric/types';
5
+
6
+ import { LlmError } from '../failure.js';
7
+ import { isObject } from '../json.js';
8
+ import type { Credentials, Provider } from '../providers.js';
9
+ import type { LlmRequest } from '../request.js';
10
+ import type { LlmResponse } from '../response.js';
11
+ import { httpFailure as anthropicFailure } from './anthropic/errors.js';
12
+ import { buildRequest as anthropicRequest } from './anthropic/request.js';
13
+ import { parseResponse as anthropicResponse } from './anthropic/response.js';
14
+ import { postJson, withRequestId } from './http.js';
15
+ import type { JsonAnswer } from './http.js';
16
+ import { httpFailure as responsesFailure } from './responses/errors.js';
17
+ import type { ResponsesProfile, ResponsesProvider } from './responses/profile.js';
18
+ import { fireworksProfile, openAiProfile } from './responses/providers.js';
19
+ import { buildRequest as responsesRequest } from './responses/request.js';
20
+ import { parseResponse as responsesResponse } from './responses/response.js';
21
+ import { invalidRequest } from './util.js';
22
+
23
+ /** The only Anthropic Messages API version. */
24
+ const ANTHROPIC_VERSION = '2023-06-01';
25
+
26
+ export type CallOptions = {
27
+ /** Stops the call; it then fails with `cancelled`. */
28
+ readonly signal?: AbortSignal;
29
+ /** A fetch to use instead of the global one, such as in tests. */
30
+ readonly fetch?: typeof fetch;
31
+ };
32
+
33
+ /** One completion from the request's provider. Never retried. */
34
+ export async function callProvider<P extends Provider>(
35
+ request: LlmRequest<P>,
36
+ credentials: Credentials<P>,
37
+ options: CallOptions = {},
38
+ ): Promise<LlmResponse<P>> {
39
+ // Each branch narrows the request to its provider; the result is that provider's.
40
+ const any: LlmRequest = request;
41
+ const name: string = request.provider;
42
+ switch (any.provider) {
43
+ case 'anthropic':
44
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
45
+ return (await callAnthropic(
46
+ any,
47
+ readCredentials('anthropic', credentials),
48
+ options,
49
+ )) as LlmResponse<P>;
50
+ case 'openai':
51
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
52
+ return (await callResponses(
53
+ any,
54
+ readCredentials('openai', credentials),
55
+ openAiProfile,
56
+ options,
57
+ )) as LlmResponse<P>;
58
+ case 'fireworks':
59
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
60
+ return (await callResponses(
61
+ any,
62
+ readCredentials('fireworks', credentials),
63
+ fireworksProfile,
64
+ options,
65
+ )) as LlmResponse<P>;
66
+ default:
67
+ throw invalidRequest(undefined, `there is no provider ${name}`);
68
+ }
69
+ }
70
+
71
+ async function callAnthropic(
72
+ request: LlmRequest<'anthropic'>,
73
+ credentials: Credentials<'anthropic'>,
74
+ options: CallOptions,
75
+ ): Promise<LlmResponse<'anthropic'>> {
76
+ const { body, betas } = anthropicRequest(request);
77
+ const answer = await postJson({
78
+ provider: 'anthropic',
79
+ label: 'Anthropic',
80
+ url: 'https://api.anthropic.com/v1/messages',
81
+ headers: {
82
+ 'x-api-key': credentials.apiKey,
83
+ ...(credentials.workspaceId === undefined
84
+ ? {}
85
+ : { 'anthropic-workspace-id': credentials.workspaceId }),
86
+ 'anthropic-version': ANTHROPIC_VERSION,
87
+ ...(betas.length > 0 ? { 'anthropic-beta': betas.join(',') } : {}),
88
+ },
89
+ body,
90
+ ...common(request, options),
91
+ failed: anthropicFailure,
92
+ });
93
+ return read(answer, () => anthropicResponse(request, answer));
94
+ }
95
+
96
+ async function callResponses<P extends ResponsesProvider>(
97
+ request: LlmRequest<P>,
98
+ credentials: Credentials<P>,
99
+ profile: ResponsesProfile<P>,
100
+ options: CallOptions,
101
+ ): Promise<LlmResponse<P>> {
102
+ const body = responsesRequest(request, profile);
103
+ const answer = await postJson({
104
+ provider: profile.provider,
105
+ label: profile.label,
106
+ url: profile.url,
107
+ headers: profile.headers(credentials),
108
+ body,
109
+ ...common(request, options),
110
+ failed: (http) => responsesFailure(profile, http),
111
+ });
112
+ return read(answer, () => responsesResponse(request, answer, profile));
113
+ }
114
+
115
+ function common(
116
+ request: LlmRequest,
117
+ { signal, fetch }: CallOptions,
118
+ ): { timeoutMs?: number; signal?: AbortSignal; fetch?: typeof fetch } {
119
+ return {
120
+ ...(request.timeoutMs === undefined ? {} : { timeoutMs: request.timeoutMs }),
121
+ ...(signal === undefined ? {} : { signal }),
122
+ ...(fetch === undefined ? {} : { fetch }),
123
+ };
124
+ }
125
+
126
+ /** Reads an answer; a failure while reading names the provider's request ID. */
127
+ function read<T>(answer: JsonAnswer, parse: () => T): T {
128
+ try {
129
+ return parse();
130
+ } catch (error) {
131
+ throw error instanceof LlmError ? withRequestId(error, answer.requestId) : error;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Credentials checked before they are sent, such as the keys a gateway reads
137
+ * from its configuration. A message names the field at fault, never its value.
138
+ */
139
+ export function readCredentials<P extends Provider>(
140
+ provider: P,
141
+ value: Json | undefined,
142
+ ): Credentials<P> {
143
+ if (!isObject(value))
144
+ throw invalidRequest(provider, `the ${provider} credentials must be an object with an apiKey`);
145
+ const fields: { [name: string]: string } = {};
146
+ for (const [name, field] of Object.entries(value)) {
147
+ if (typeof field !== 'string' || field.trim() === '') {
148
+ throw invalidRequest(provider, `credentials.${name} must be a string that is not empty`);
149
+ }
150
+ fields[name] = field.trim();
151
+ }
152
+ if (fields['apiKey'] === undefined)
153
+ throw invalidRequest(provider, 'credentials.apiKey is missing');
154
+ // Every provider's credentials are an apiKey and optional string fields.
155
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
156
+ return fields as unknown as Credentials<P>;
157
+ }