@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,55 @@
1
+ // What one provider changes about the Responses format: where to send it, how
2
+ // to sign it, and what is its own. The format's adapter does the rest.
3
+
4
+ import type { JsonObject } from '@acentric/types';
5
+
6
+ import type { LlmFailureKind } from '../../failure.js';
7
+ import type { Credentials, ProviderOf } from '../../providers.js';
8
+
9
+ /** The providers called through the Responses format. */
10
+ export type ResponsesProvider = ProviderOf<'openai-responses'>;
11
+
12
+ export type ResponsesProfile<P extends ResponsesProvider> = {
13
+ readonly provider: P;
14
+ /** Names the provider in messages, such as "OpenAI". */
15
+ readonly label: string;
16
+ /** The create-response URL. */
17
+ readonly url: string;
18
+ /** Authentication and other headers. */
19
+ headers(credentials: Credentials<P>): { readonly [name: string]: string };
20
+ /** The role mid-conversation system messages are sent with. */
21
+ readonly systemRole: 'system' | 'developer';
22
+ /** Whether file parts work. When not, they are refused, since the provider drops them without a word. */
23
+ readonly files: boolean;
24
+ /** Whether images and files may be a file ID in the provider's own storage. */
25
+ readonly fileIds: boolean;
26
+ /** Whether custom tools work. When not, they and their results are refused. */
27
+ readonly customTools: boolean;
28
+ /** Response fields that only repeat the request, left out of `native`. */
29
+ readonly echoFields: readonly string[];
30
+ /** Fields the provider writes on output items but refuses on input, left out when sending back. */
31
+ readonly outputOnlyFields: readonly string[];
32
+ /**
33
+ * What to multiply standard prices by for how the response was served, or
34
+ * undefined when that is not known, which means no cost estimate.
35
+ */
36
+ priceMultiplier(native: JsonObject, options: JsonObject): number | undefined;
37
+ /** The provider's own reading of an error the format does not recognize. */
38
+ classifyError?(error: ErrorInput): ErrorReading | undefined;
39
+ };
40
+
41
+ /** What a profile sees of an error. */
42
+ export type ErrorInput = {
43
+ /** Left out for a failed response that came with HTTP 200. */
44
+ readonly status?: number;
45
+ readonly code?: string;
46
+ readonly type?: string;
47
+ readonly message?: string;
48
+ };
49
+
50
+ /** What an error means, where the provider's own code does not say. */
51
+ export type ErrorReading = {
52
+ readonly kind: LlmFailureKind;
53
+ /** The request field at fault, when the error does not name it. */
54
+ readonly param?: string;
55
+ };
@@ -0,0 +1,87 @@
1
+ // The providers on the Responses format: OpenAI and Fireworks.
2
+
3
+ import type { ResponsesProfile } from './profile.js';
4
+
5
+ /** OpenAI's prices for the tier that served a response. Other tiers' are not published. */
6
+ const OPENAI_TIERS: { readonly [tier: string]: number } = { default: 1, flex: 0.5 };
7
+
8
+ export const openAiProfile: ResponsesProfile<'openai'> = {
9
+ provider: 'openai',
10
+ label: 'OpenAI',
11
+ url: 'https://api.openai.com/v1/responses',
12
+ systemRole: 'developer',
13
+ files: true,
14
+ fileIds: true,
15
+ customTools: true,
16
+ echoFields: ['prompt', 'prompt_cache_retention', 'prompt_cache_options'],
17
+ // Written on output items and refused on input.
18
+ outputOnlyFields: ['created_by'],
19
+
20
+ headers({ apiKey, organization, project }) {
21
+ return {
22
+ authorization: `Bearer ${apiKey}`,
23
+ ...(organization === undefined ? {} : { 'openai-organization': organization }),
24
+ ...(project === undefined ? {} : { 'openai-project': project }),
25
+ };
26
+ },
27
+
28
+ priceMultiplier(native) {
29
+ const tier = native['service_tier'];
30
+ return OPENAI_TIERS[typeof tier === 'string' ? tier : 'default'];
31
+ },
32
+
33
+ classifyError({ status, message }) {
34
+ // Some context window errors come without the context_length_exceeded code.
35
+ if (
36
+ status === 400 &&
37
+ message !== undefined &&
38
+ /exceeds the context window|maximum context length/i.test(message)
39
+ ) {
40
+ return { kind: 'context_overflow' };
41
+ }
42
+ return undefined;
43
+ },
44
+ };
45
+
46
+ export const fireworksProfile: ResponsesProfile<'fireworks'> = {
47
+ provider: 'fireworks',
48
+ label: 'Fireworks',
49
+ url: 'https://api.fireworks.ai/inference/v1/responses',
50
+ systemRole: 'system',
51
+ // Fireworks drops file parts without an error, has no file storage, and
52
+ // takes custom tools its models never call.
53
+ files: false,
54
+ fileIds: false,
55
+ customTools: false,
56
+ // Fireworks repeats the reasoning settings it was sent; they say nothing new.
57
+ echoFields: ['reasoning'],
58
+ outputOnlyFields: ['created_by'],
59
+
60
+ headers({ apiKey }) {
61
+ return { authorization: `Bearer ${apiKey}` };
62
+ },
63
+
64
+ // The response does not say which tier served it. Standard prices hold
65
+ // unless the request asked for another tier, whose prices differ by model.
66
+ priceMultiplier(_native, options) {
67
+ const tier = options['service_tier'];
68
+ return tier === undefined || tier === null || tier === 'default' ? 1 : undefined;
69
+ },
70
+
71
+ classifyError({ status, message }) {
72
+ // A previous response that is unknown, expired or not stored: a bare 404 `{ detail }`.
73
+ if (status === 404 && message !== undefined && /^previous response not found/i.test(message)) {
74
+ return { kind: 'not_found', param: 'previous_response_id' };
75
+ }
76
+ if (
77
+ message !== undefined &&
78
+ /prompt is too long|exceeds maximum context length/i.test(message)
79
+ ) {
80
+ return { kind: 'context_overflow' };
81
+ }
82
+ if (status === 402) return { kind: 'quota_exceeded' };
83
+ // A suspended account.
84
+ if (status === 412) return { kind: 'auth' };
85
+ return undefined;
86
+ },
87
+ };
@@ -0,0 +1,326 @@
1
+ // The body of a Responses create-response request. Requests may come from
2
+ // a log or, at the gateway, from anyone, so every shape is checked as it is
3
+ // written, and anything the provider would drop without a word is refused.
4
+
5
+ import type { Json, JsonObject } from '@acentric/types';
6
+
7
+ import { isArray, isObject } from '../../json.js';
8
+ import type { LlmRequest } from '../../request.js';
9
+ import { invalidRequest, ownFields, plainText, show } from '../util.js';
10
+ import type { ResponsesProfile, ResponsesProvider } from './profile.js';
11
+
12
+ /** Request fields the request itself sets. */
13
+ const OWNED_OPTIONS = ['model', 'input', 'instructions'];
14
+
15
+ export function buildRequest<P extends ResponsesProvider>(
16
+ request: LlmRequest<P>,
17
+ profile: ResponsesProfile<P>,
18
+ ): JsonObject {
19
+ const { provider } = profile;
20
+ const options = ownFields(provider, request.options, OWNED_OPTIONS, 'request', 'options');
21
+ if (options['stream'] === true) {
22
+ throw invalidRequest(
23
+ provider,
24
+ 'streaming is not supported: a call returns one finished response',
25
+ );
26
+ }
27
+ if (options['background'] === true) {
28
+ throw invalidRequest(
29
+ provider,
30
+ 'background mode is not supported: a call returns one finished response',
31
+ );
32
+ }
33
+ const { tools: nativeTools, ...rest } = options;
34
+ if (nativeTools !== undefined && !isArray(nativeTools)) {
35
+ throw invalidRequest(provider, 'options.tools must be an array of native tool definitions');
36
+ }
37
+ const messages: Json = request.messages;
38
+ if (!isArray(messages)) throw invalidRequest(provider, 'messages must be an array');
39
+ const instructions = buildInstructions(provider, request.instructions);
40
+ const tools = [
41
+ ...(request.tools ?? []).map((tool, index) => buildTool(tool, `tools[${index}]`, profile)),
42
+ ...(nativeTools ?? []),
43
+ ];
44
+ return {
45
+ ...rest,
46
+ model: request.model,
47
+ ...(instructions === undefined ? {} : { instructions }),
48
+ input: messages.flatMap((message, index) => buildItems(message, `messages[${index}]`, profile)),
49
+ ...(tools.length > 0 ? { tools } : {}),
50
+ };
51
+ }
52
+
53
+ function buildInstructions(provider: string, value: Json | undefined): string | undefined {
54
+ if (value === undefined || typeof value === 'string') return value;
55
+ if (!isArray(value))
56
+ throw invalidRequest(provider, 'instructions must be a string or text parts');
57
+ const texts = value.map((part, index) => {
58
+ if (!isObject(part) || part['type'] !== 'text' || typeof part['text'] !== 'string') {
59
+ throw invalidRequest(provider, `instructions[${index}] must be a text part`);
60
+ }
61
+ if (part['providerOptions'] !== undefined) {
62
+ throw invalidRequest(
63
+ provider,
64
+ `instructions[${index}].providerOptions is not taken: instructions are one string here. ` +
65
+ 'Send a system message for options such as a cache breakpoint',
66
+ );
67
+ }
68
+ return part['text'];
69
+ });
70
+ return texts.length > 0 ? texts.join('\n\n') : undefined;
71
+ }
72
+
73
+ function buildItems<P extends ResponsesProvider>(
74
+ message: Json,
75
+ where: string,
76
+ profile: ResponsesProfile<P>,
77
+ ): Json[] {
78
+ const { provider, label } = profile;
79
+ if (!isObject(message)) throw invalidRequest(provider, `${where} must be a message`);
80
+ switch (message['role']) {
81
+ case 'user':
82
+ return [
83
+ { type: 'message', role: 'user', content: buildParts(message['content'], where, profile) },
84
+ ];
85
+ case 'system':
86
+ return [
87
+ {
88
+ type: 'message',
89
+ role: profile.systemRole,
90
+ content: buildParts(message['content'], where, profile, true),
91
+ },
92
+ ];
93
+ case 'assistant':
94
+ if (message['provider'] !== provider) {
95
+ throw invalidRequest(
96
+ provider,
97
+ `${where} is from ${show(message['provider'])}: convert it before sending it to ${label}`,
98
+ );
99
+ }
100
+ return items(provider, message['content'], where).map((item) =>
101
+ outputOnly(item, profile.outputOnlyFields),
102
+ );
103
+ case 'native':
104
+ if (message['provider'] !== provider) {
105
+ throw invalidRequest(
106
+ provider,
107
+ `${where} holds ${show(message['provider'])} items, which ${label} cannot take`,
108
+ );
109
+ }
110
+ return items(provider, message['content'], where);
111
+ case 'tool_result':
112
+ return [buildToolResult(message, where, profile)];
113
+ case 'custom':
114
+ throw invalidRequest(
115
+ provider,
116
+ `${where} is the application's own: leave custom messages out`,
117
+ );
118
+ default:
119
+ throw invalidRequest(provider, `${where} has an unknown role: ${show(message['role'])}`);
120
+ }
121
+ }
122
+
123
+ function items(provider: string, value: Json | undefined, where: string): JsonObject[] {
124
+ if (!isArray(value)) throw invalidRequest(provider, `${where}.content must be an array of items`);
125
+ return value.map((item, index) => {
126
+ if (!isObject(item) || typeof item['type'] !== 'string') {
127
+ throw invalidRequest(provider, `${where}.content[${index}] must be an item with a type`);
128
+ }
129
+ return item;
130
+ });
131
+ }
132
+
133
+ /** An output item without the fields the provider writes but refuses back. */
134
+ function outputOnly(item: JsonObject, fields: readonly string[]): JsonObject {
135
+ if (!fields.some((field) => field in item)) return item;
136
+ return Object.fromEntries(Object.entries(item).filter(([key]) => !fields.includes(key)));
137
+ }
138
+
139
+ function buildToolResult<P extends ResponsesProvider>(
140
+ message: JsonObject,
141
+ where: string,
142
+ profile: ResponsesProfile<P>,
143
+ ): JsonObject {
144
+ const { provider, label } = profile;
145
+ const callId = message['toolCallId'];
146
+ if (typeof callId !== 'string' || callId === '') {
147
+ throw invalidRequest(provider, `${where}.toolCallId must be a string that is not empty`);
148
+ }
149
+ const toolType = message['toolType'] ?? 'function';
150
+ if (toolType !== 'function' && toolType !== 'custom') {
151
+ throw invalidRequest(provider, `${where}.toolType must be function or custom`);
152
+ }
153
+ if (toolType === 'custom' && !profile.customTools) {
154
+ throw invalidRequest(provider, `${where} answers a custom tool, and ${label} has none`);
155
+ }
156
+ const fields = ownFields(
157
+ provider,
158
+ message['providerOptions'],
159
+ ['type', 'call_id', 'output'],
160
+ where,
161
+ );
162
+ return {
163
+ ...fields,
164
+ type: toolType === 'function' ? 'function_call_output' : 'custom_tool_call_output',
165
+ call_id: callId,
166
+ output: plainText(message['content']) ?? buildParts(message['content'], where, profile),
167
+ };
168
+ }
169
+
170
+ function buildParts<P extends ResponsesProvider>(
171
+ value: Json | undefined,
172
+ where: string,
173
+ profile: ResponsesProfile<P>,
174
+ textOnly = false,
175
+ ): JsonObject[] {
176
+ const { provider } = profile;
177
+ if (!isArray(value)) throw invalidRequest(provider, `${where}.content must be an array of parts`);
178
+ return value.map((part, index) => {
179
+ const at = `${where}.content[${index}]`;
180
+ if (!isObject(part)) throw invalidRequest(provider, `${at} must be a content part`);
181
+ if (textOnly && part['type'] !== 'text')
182
+ throw invalidRequest(provider, `${at} must be a text part`);
183
+ return buildPart(part, at, profile);
184
+ });
185
+ }
186
+
187
+ function buildPart<P extends ResponsesProvider>(
188
+ part: JsonObject,
189
+ at: string,
190
+ profile: ResponsesProfile<P>,
191
+ ): JsonObject {
192
+ const { provider, label } = profile;
193
+ const options = part['providerOptions'];
194
+ switch (part['type']) {
195
+ case 'text': {
196
+ const text = part['text'];
197
+ if (typeof text !== 'string') throw invalidRequest(provider, `${at}.text must be a string`);
198
+ return { ...ownFields(provider, options, ['type', 'text'], at), type: 'input_text', text };
199
+ }
200
+ case 'image': {
201
+ const fields = ownFields(provider, options, ['type', 'image_url', 'file_id'], at);
202
+ const source = checkSource(part['source'], at, profile);
203
+ return {
204
+ ...fields,
205
+ type: 'input_image',
206
+ ...(source.type === 'url'
207
+ ? { image_url: source.url }
208
+ : source.type === 'base64'
209
+ ? { image_url: dataUrl(source.mediaType, source.data) }
210
+ : { file_id: source.id }),
211
+ };
212
+ }
213
+ case 'file': {
214
+ if (!profile.files) {
215
+ throw invalidRequest(provider, `${at} is a file, which ${label} would drop without a word`);
216
+ }
217
+ const owned = ['type', 'file_url', 'file_data', 'file_id', 'filename'];
218
+ const fields = ownFields(provider, options, owned, at);
219
+ const source = checkSource(part['source'], at, profile);
220
+ const filename = part['filename'];
221
+ if (filename !== undefined && typeof filename !== 'string') {
222
+ throw invalidRequest(provider, `${at}.filename must be a string`);
223
+ }
224
+ return {
225
+ ...fields,
226
+ type: 'input_file',
227
+ ...(source.type === 'url'
228
+ ? { file_url: source.url }
229
+ : source.type === 'base64'
230
+ ? { file_data: dataUrl(source.mediaType, source.data) }
231
+ : { file_id: source.id }),
232
+ ...(filename === undefined ? {} : { filename }),
233
+ };
234
+ }
235
+ default:
236
+ throw invalidRequest(provider, `${at} has an unknown type: ${show(part['type'])}`);
237
+ }
238
+ }
239
+
240
+ type Source =
241
+ | { readonly type: 'url'; readonly url: string }
242
+ | { readonly type: 'base64'; readonly mediaType: string; readonly data: string }
243
+ | { readonly type: 'file_id'; readonly id: string };
244
+
245
+ function checkSource<P extends ResponsesProvider>(
246
+ source: Json | undefined,
247
+ at: string,
248
+ profile: ResponsesProfile<P>,
249
+ ): Source {
250
+ const { provider, label } = profile;
251
+ if (isObject(source)) {
252
+ const { type, url, id, data, mediaType } = source;
253
+ if (type === 'url' && typeof url === 'string') return { type, url };
254
+ if (type === 'base64' && typeof data === 'string' && typeof mediaType === 'string') {
255
+ return { type, mediaType, data };
256
+ }
257
+ if (type === 'file_id' && typeof id === 'string') {
258
+ if (!profile.fileIds) {
259
+ throw invalidRequest(
260
+ provider,
261
+ `${at}.source is a file ID, which ${label} cannot take: send a URL or base64 data`,
262
+ );
263
+ }
264
+ return { type, id };
265
+ }
266
+ }
267
+ throw invalidRequest(provider, `${at}.source must be a url, base64 or file_id source`);
268
+ }
269
+
270
+ function dataUrl(mediaType: string, base64: string): string {
271
+ return `data:${mediaType};base64,${base64}`;
272
+ }
273
+
274
+ function buildTool<P extends ResponsesProvider>(
275
+ tool: Json,
276
+ where: string,
277
+ profile: ResponsesProfile<P>,
278
+ ): JsonObject {
279
+ const { provider, label } = profile;
280
+ if (
281
+ !isObject(tool) ||
282
+ typeof tool['name'] !== 'string' ||
283
+ typeof tool['description'] !== 'string'
284
+ ) {
285
+ throw invalidRequest(provider, `${where} needs a type, a name and a description`);
286
+ }
287
+ const { name, description, parameters, strict, format } = tool;
288
+ const at = `tool ${name}`;
289
+ if (tool['type'] === 'function') {
290
+ const owned = ['type', 'name', 'description', 'parameters', 'strict'];
291
+ const fields = ownFields(provider, tool['providerOptions'], owned, at);
292
+ if (!isObject(parameters))
293
+ throw invalidRequest(provider, `${at}: parameters must be a JSON Schema object`);
294
+ // A `strict` left out stays out: the provider treats it differently from false.
295
+ return {
296
+ ...fields,
297
+ type: 'function',
298
+ name,
299
+ description,
300
+ parameters,
301
+ ...(strict === undefined ? {} : { strict }),
302
+ };
303
+ }
304
+ if (tool['type'] === 'custom') {
305
+ if (!profile.customTools) {
306
+ throw invalidRequest(
307
+ provider,
308
+ `${at} is a custom tool, which ${label} cannot take: use a function tool`,
309
+ );
310
+ }
311
+ const fields = ownFields(
312
+ provider,
313
+ tool['providerOptions'],
314
+ ['type', 'name', 'description', 'format'],
315
+ at,
316
+ );
317
+ return {
318
+ ...fields,
319
+ type: 'custom',
320
+ name,
321
+ description,
322
+ ...(format === undefined ? {} : { format }),
323
+ };
324
+ }
325
+ throw invalidRequest(provider, `${where} has an unknown type: ${show(tool['type'])}`);
326
+ }
@@ -0,0 +1,203 @@
1
+ // A Responses answer read into an LlmResponse.
2
+
3
+ import type { Json, JsonObject } from '@acentric/types';
4
+
5
+ import { estimateCost } from '../../cost.js';
6
+ import { LlmError } from '../../failure.js';
7
+ import { isArray, isObject } from '../../json.js';
8
+ import { findModel } from '../../models.js';
9
+ import { pendingToolCalls } from '../../read/responses.js';
10
+ import type { LlmRequest } from '../../request.js';
11
+ import type { LlmResponse, StopReason } from '../../response.js';
12
+ import type { Usage } from '../../usage.js';
13
+ import type { JsonAnswer } from '../http.js';
14
+ import { invalidResponse, omit, tokens, show } from '../util.js';
15
+ import { failedResponse } from './errors.js';
16
+ import type { ResponsesProfile, ResponsesProvider } from './profile.js';
17
+
18
+ /** Response fields every Responses provider repeats from the request, or that the response already holds. */
19
+ const ECHO_FIELDS = [
20
+ 'id',
21
+ 'object',
22
+ 'model',
23
+ 'output',
24
+ 'output_text',
25
+ 'usage',
26
+ 'error',
27
+ 'instructions',
28
+ 'tools',
29
+ 'tool_choice',
30
+ 'parallel_tool_calls',
31
+ 'max_output_tokens',
32
+ 'max_tool_calls',
33
+ 'temperature',
34
+ 'top_p',
35
+ 'top_logprobs',
36
+ 'frequency_penalty',
37
+ 'presence_penalty',
38
+ 'text',
39
+ 'truncation',
40
+ 'store',
41
+ 'background',
42
+ 'previous_response_id',
43
+ 'conversation',
44
+ 'prompt_cache_key',
45
+ 'safety_identifier',
46
+ 'user',
47
+ 'metadata',
48
+ 'include',
49
+ ];
50
+
51
+ /** Usage fields `readUsage` maps; anything else in `usage` stays in `native.usage`. */
52
+ const TOKEN_FIELDS: { readonly [field: string]: readonly string[] | true } = {
53
+ input_tokens: true,
54
+ output_tokens: true,
55
+ total_tokens: true,
56
+ input_tokens_details: ['cached_tokens', 'cache_write_tokens'],
57
+ output_tokens_details: ['reasoning_tokens'],
58
+ };
59
+
60
+ export function parseResponse<P extends ResponsesProvider>(
61
+ request: LlmRequest<P>,
62
+ answer: JsonAnswer,
63
+ profile: ResponsesProfile<P>,
64
+ ): LlmResponse<P> {
65
+ const { provider, label } = profile;
66
+ const body = answer.body;
67
+ if (!isObject(body))
68
+ throw invalidResponse(provider, `${label} did not answer with a response`, body);
69
+ const id = typeof body['id'] === 'string' && body['id'] !== '' ? body['id'] : undefined;
70
+ const rawUsage = body['usage'];
71
+ const usage = isObject(rawUsage) ? readUsage(rawUsage) : undefined;
72
+
73
+ // A failure can come with HTTP 200, as a failed response or a bare error.
74
+ const error = body['error'];
75
+ if (body['status'] === 'failed' || (error !== null && error !== undefined)) {
76
+ throw failedResponse(profile, error ?? null, id, usage);
77
+ }
78
+ const output = body['output'];
79
+ const status = body['status'];
80
+ if (body['object'] !== 'response' || !isArray(output)) {
81
+ throw invalidResponse(provider, `${label} did not answer with a response`, body);
82
+ }
83
+ if (status === 'cancelled') {
84
+ throw new LlmError(`${label} cancelled the response`, {
85
+ kind: 'cancelled',
86
+ provider,
87
+ ...(id === undefined ? {} : { responseId: id }),
88
+ });
89
+ }
90
+ if (status !== 'completed' && status !== 'incomplete') {
91
+ throw invalidResponse(
92
+ provider,
93
+ `${label} answered with a ${show(status)} response, not a finished one`,
94
+ body,
95
+ );
96
+ }
97
+
98
+ const details = body['incomplete_details'];
99
+ const incomplete =
100
+ isObject(details) && typeof details['reason'] === 'string' ? details['reason'] : undefined;
101
+ const extras = isObject(rawUsage) ? usageExtras(rawUsage) : undefined;
102
+ const native = {
103
+ ...omit(body, [...ECHO_FIELDS, ...profile.echoFields]),
104
+ ...(extras === undefined ? {} : { usage: extras }),
105
+ };
106
+ const served = typeof body['model'] === 'string' ? body['model'] : undefined;
107
+ const pricing = (findModel(provider, request.model) ?? findModel(provider, served ?? ''))
108
+ ?.pricing;
109
+ const multiplier = profile.priceMultiplier(native, request.options ?? {});
110
+ const cost =
111
+ usage === undefined || multiplier === undefined
112
+ ? undefined
113
+ : estimateCost(usage, pricing, { multiplier });
114
+ const response = {
115
+ ...(id === undefined ? {} : { id }),
116
+ provider,
117
+ model: request.model,
118
+ ...(served === undefined || served === request.model ? {} : { responseModel: served }),
119
+ message: {
120
+ role: 'assistant',
121
+ provider,
122
+ api: 'openai-responses',
123
+ model: request.model,
124
+ ...(id === undefined ? {} : { id }),
125
+ timestamp: answer.timestamp,
126
+ content: output,
127
+ },
128
+ stopReason: stopReason(status, incomplete, output),
129
+ ...(incomplete === undefined ? {} : { providerStopReason: incomplete }),
130
+ ...(usage === undefined ? {} : { usage: cost === undefined ? usage : { ...usage, cost } }),
131
+ native,
132
+ durationMs: answer.durationMs,
133
+ timestamp: answer.timestamp,
134
+ };
135
+ // The output is the provider's own items, which the format's types describe.
136
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
137
+ return response as unknown as LlmResponse<P>;
138
+ }
139
+
140
+ /** Usage as the format reports it: `input_tokens` is every prompt token, cache reads and writes among them. */
141
+ export function readUsage(raw: JsonObject): Usage {
142
+ const details = isObject(raw['input_tokens_details']) ? raw['input_tokens_details'] : {};
143
+ const outputDetails = isObject(raw['output_tokens_details']) ? raw['output_tokens_details'] : {};
144
+ const totalInput = tokens(raw['input_tokens']);
145
+ const cacheRead = tokens(details['cached_tokens']);
146
+ const cacheWrite = tokens(details['cache_write_tokens']);
147
+ const output = tokens(raw['output_tokens']);
148
+ const reasoning = tokens(outputDetails['reasoning_tokens']);
149
+ // A model with no cache writes reports none; the uncached rest is still known.
150
+ const cached = (cacheRead ?? 0) + (cacheWrite ?? 0);
151
+ const input =
152
+ totalInput !== undefined && cacheRead !== undefined && cached <= totalInput
153
+ ? totalInput - cached
154
+ : undefined;
155
+ return {
156
+ ...(totalInput === undefined ? {} : { totalInput }),
157
+ ...(input === undefined ? {} : { input }),
158
+ ...(output === undefined ? {} : { output }),
159
+ ...(reasoning === undefined ? {} : { reasoning }),
160
+ ...(cacheRead === undefined ? {} : { cacheRead }),
161
+ ...(cacheWrite === undefined ? {} : { cacheWrite }),
162
+ };
163
+ }
164
+
165
+ /** What the provider reports in `usage` beyond the counts `readUsage` maps, or undefined. */
166
+ function usageExtras(raw: JsonObject): JsonObject | undefined {
167
+ const extras: { [key: string]: Json } = {};
168
+ for (const [key, value] of Object.entries(raw)) {
169
+ const mapped = TOKEN_FIELDS[key];
170
+ if (mapped === true || value === null) continue;
171
+ if (mapped === undefined || !isObject(value)) {
172
+ extras[key] = value;
173
+ continue;
174
+ }
175
+ const rest = omit(value, mapped);
176
+ if (Object.keys(rest).length > 0) extras[key] = rest;
177
+ }
178
+ return Object.keys(extras).length > 0 ? extras : undefined;
179
+ }
180
+
181
+ /**
182
+ * The format has no stop reason: it reports a status, and the output shows
183
+ * the rest.
184
+ */
185
+ function stopReason(
186
+ status: 'completed' | 'incomplete',
187
+ incomplete: string | undefined,
188
+ output: readonly Json[],
189
+ ): StopReason {
190
+ if (status === 'incomplete') return incomplete === 'content_filter' ? 'content_filter' : 'length';
191
+ if (pendingToolCalls(output).length > 0) return 'tool_use';
192
+ const messages = output.filter((item) => isObject(item) && item['type'] === 'message');
193
+ const refused = messages.some((message) => {
194
+ const content = isObject(message) ? message['content'] : undefined;
195
+ return isArray(content) && content.some((part) => isObject(part) && part['type'] === 'refusal');
196
+ });
197
+ if (refused) return 'refusal';
198
+ const answered = messages.some(
199
+ (message) => isObject(message) && message['phase'] !== 'commentary',
200
+ );
201
+ // Work with no final answer (hosted calls, a program, commentary) goes on when sent back.
202
+ return answered || output.length === 0 ? 'stop' : 'pause_turn';
203
+ }