@purista/harness-anthropic 1.0.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.
package/LICENSE ADDED
@@ -0,0 +1,4 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
package/README.md ADDED
@@ -0,0 +1,25 @@
1
+ # @purista/harness-anthropic
2
+
3
+ Anthropic model provider adapter for `@purista/harness`.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @purista/harness @purista/harness-anthropic
9
+ ```
10
+
11
+ Configure the provider with an Anthropic API key in your application
12
+ environment. The adapter is designed for use through the typed
13
+ `@purista/harness` model provider port.
14
+
15
+ ```ts
16
+ import { anthropic } from '@purista/harness-anthropic'
17
+
18
+ anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
19
+ ```
20
+
21
+ ## Package Format
22
+
23
+ This package is ESM-only and ships compiled JavaScript plus TypeScript
24
+ declarations from `dist/`. Source files, tests, source maps, and local configs
25
+ are not included in the published package.
@@ -0,0 +1,30 @@
1
+ import type { BaseModelProviderOptions, ModelProvider } from '@purista/harness';
2
+ import { type ClientOptions } from '@anthropic-ai/sdk';
3
+ export interface AnthropicFactoryOptions extends ClientOptions {
4
+ /** Optional injected client for tests or custom transport behavior. */
5
+ client?: AnthropicClient;
6
+ /** Optional adapter-level logger override. Defaults to the harness logger when registered. */
7
+ harnessLogger?: BaseModelProviderOptions['logger'];
8
+ /** Optional adapter-level telemetry override. Defaults to the harness telemetry shim when registered. */
9
+ telemetry?: BaseModelProviderOptions['telemetry'];
10
+ /** Optional adapter-level timeout override. Defaults to the harness model timeout when registered. */
11
+ harnessTimeoutMs?: number;
12
+ }
13
+ /**
14
+ * Creates an Anthropic-backed harness `ModelProvider`.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * import { anthropic } from '@purista/harness-anthropic'
19
+ *
20
+ * const provider = anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
21
+ * ```
22
+ */
23
+ export declare function anthropic(options?: AnthropicFactoryOptions): ModelProvider;
24
+ export type AnthropicClient = {
25
+ messages: {
26
+ create(payload: unknown, options?: {
27
+ signal?: AbortSignal;
28
+ }): Promise<any>;
29
+ };
30
+ };
package/dist/index.js ADDED
@@ -0,0 +1,263 @@
1
+ import { BaseModelProvider, ModelError } from '@purista/harness';
2
+ import Anthropic, {} from '@anthropic-ai/sdk';
3
+ /**
4
+ * Creates an Anthropic-backed harness `ModelProvider`.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * import { anthropic } from '@purista/harness-anthropic'
9
+ *
10
+ * const provider = anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
11
+ * ```
12
+ */
13
+ export function anthropic(options = {}) {
14
+ return new AnthropicModelProvider(options);
15
+ }
16
+ class AnthropicModelProvider extends BaseModelProvider {
17
+ options;
18
+ client;
19
+ constructor(options) {
20
+ super({
21
+ id: 'anthropic',
22
+ genAiSystem: 'anthropic',
23
+ ...(options.harnessLogger ? { logger: options.harnessLogger } : {}),
24
+ ...(options.telemetry ? { telemetry: options.telemetry } : {}),
25
+ ...(options.harnessTimeoutMs !== undefined ? { timeoutMs: options.harnessTimeoutMs } : options.timeout !== undefined ? { timeoutMs: options.timeout } : {})
26
+ });
27
+ this.options = options;
28
+ this.client = options.client ?? new Anthropic(toClientOptions(options));
29
+ }
30
+ async doText(req) {
31
+ req.signal.throwIfAborted();
32
+ const response = await createMessage(this.client, req, false);
33
+ const toolCalls = extractToolCalls(response, req, 'text');
34
+ return {
35
+ content: response.content?.filter((block) => block.type === 'text').map((block) => block.text).join('') ?? '',
36
+ ...(toolCalls ? { toolCalls } : {}),
37
+ usage: toUsage(response.usage?.input_tokens, response.usage?.output_tokens),
38
+ finishReason: toFinishReason(response.stop_reason),
39
+ raw: response
40
+ };
41
+ }
42
+ async *doTextStream(req) {
43
+ req.signal.throwIfAborted();
44
+ const stream = await createMessage(this.client, req, true);
45
+ const toolState = new Map();
46
+ let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
47
+ let finishReason = 'stop';
48
+ for await (const event of stream) {
49
+ req.signal.throwIfAborted();
50
+ if (event.type === 'message_start') {
51
+ usage = toUsage(event.message?.usage?.input_tokens, event.message?.usage?.output_tokens);
52
+ }
53
+ else if (event.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
54
+ toolState.set(event.index, {
55
+ id: String(event.content_block.id),
56
+ name: String(event.content_block.name),
57
+ input: JSON.stringify(event.content_block.input ?? {})
58
+ });
59
+ }
60
+ else if (event.type === 'content_block_delta') {
61
+ if (event.delta?.type === 'text_delta') {
62
+ yield { kind: 'delta', text: event.delta.text };
63
+ }
64
+ else if (event.delta?.type === 'input_json_delta') {
65
+ const state = toolState.get(event.index);
66
+ if (state)
67
+ state.input += event.delta.partial_json;
68
+ }
69
+ }
70
+ else if (event.type === 'content_block_stop') {
71
+ const state = toolState.get(event.index);
72
+ if (state) {
73
+ yield { kind: 'tool_call', call: { id: state.id, name: state.name, arguments: parseJson(state.input, req, 'textStream') } };
74
+ toolState.delete(event.index);
75
+ }
76
+ }
77
+ else if (event.type === 'message_delta') {
78
+ finishReason = toFinishReason(event.delta?.stop_reason);
79
+ usage = toUsage(usage.inputTokens, event.usage?.output_tokens ?? usage.outputTokens);
80
+ }
81
+ }
82
+ yield { kind: 'finish', usage, finishReason };
83
+ }
84
+ async doObject(req) {
85
+ req.signal.throwIfAborted();
86
+ const response = await createMessage(this.client, req, false, true);
87
+ const toolUse = response.content?.find((block) => block.type === 'tool_use' && block.name === 'harness_response');
88
+ const object = (toolUse?.input ?? parseJson(response.content?.filter((block) => block.type === 'text').map((block) => block.text).join('') || '{}', req, 'object'));
89
+ return {
90
+ object,
91
+ usage: toUsage(response.usage?.input_tokens, response.usage?.output_tokens),
92
+ finishReason: toFinishReason(response.stop_reason),
93
+ raw: response
94
+ };
95
+ }
96
+ async *doObjectStream(req) {
97
+ req.signal.throwIfAborted();
98
+ const stream = await createMessage(this.client, req, true, true);
99
+ let text = '';
100
+ let objectInput = '';
101
+ let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
102
+ let finishReason = 'stop';
103
+ for await (const event of stream) {
104
+ req.signal.throwIfAborted();
105
+ if (event.type === 'message_start') {
106
+ usage = toUsage(event.message?.usage?.input_tokens, event.message?.usage?.output_tokens);
107
+ }
108
+ else if (event.type === 'content_block_start' && event.content_block?.type === 'tool_use' && event.content_block.name === 'harness_response') {
109
+ objectInput = JSON.stringify(event.content_block.input ?? {});
110
+ }
111
+ else if (event.type === 'content_block_delta') {
112
+ if (event.delta?.type === 'text_delta') {
113
+ text += event.delta.text;
114
+ yield { kind: 'partial', partial: safePartialJson(text) };
115
+ }
116
+ else if (event.delta?.type === 'input_json_delta') {
117
+ objectInput += event.delta.partial_json;
118
+ yield { kind: 'partial', partial: safePartialJson(objectInput) };
119
+ }
120
+ }
121
+ else if (event.type === 'message_delta') {
122
+ finishReason = toFinishReason(event.delta?.stop_reason);
123
+ usage = toUsage(usage.inputTokens, event.usage?.output_tokens ?? usage.outputTokens);
124
+ }
125
+ }
126
+ const object = parseJson(objectInput || text || '{}', req, 'objectStream');
127
+ yield { kind: 'finish', object, usage, finishReason };
128
+ }
129
+ }
130
+ function toClientOptions(options) {
131
+ const { client: _client, harnessLogger: _harnessLogger, telemetry: _telemetry, harnessTimeoutMs: _harnessTimeoutMs, ...clientOptions } = options;
132
+ return clientOptions;
133
+ }
134
+ async function createMessage(client, req, stream, forceObject = false) {
135
+ const providerOptions = {
136
+ ...(req.defaults?.providerOptions ?? {}),
137
+ ...(req.call?.providerOptions ?? {})
138
+ };
139
+ const { requestOptions, ...bodyOptions } = providerOptions;
140
+ const { system, messages } = toAnthropicMessages(req.messages);
141
+ const tools = forceObject ? [toObjectTool(req)] : toTools(req.tools);
142
+ return client.messages.create({
143
+ model: req.model,
144
+ messages,
145
+ stream,
146
+ max_tokens: req.call?.maxTokens ?? req.defaults?.maxTokens ?? 1024,
147
+ ...(system ? { system } : {}),
148
+ ...(tools ? { tools } : {}),
149
+ ...(forceObject ? { tool_choice: { type: 'tool', name: 'harness_response' } } : {}),
150
+ ...(req.call?.temperature ?? req.defaults?.temperature !== undefined ? { temperature: req.call?.temperature ?? req.defaults?.temperature } : {}),
151
+ ...(req.call?.topP ?? req.defaults?.topP !== undefined ? { top_p: req.call?.topP ?? req.defaults?.topP } : {}),
152
+ ...(req.call?.stopSequences ?? req.defaults?.stopSequences ? { stop_sequences: req.call?.stopSequences ?? req.defaults?.stopSequences } : {}),
153
+ ...bodyOptions
154
+ }, { ...requestOptions, signal: req.signal });
155
+ }
156
+ function toAnthropicMessages(messages) {
157
+ const system = messages.filter((message) => message.role === 'system').map((message) => message.content).join('\n\n');
158
+ const converted = messages.filter((message) => message.role !== 'system').map((message) => {
159
+ if (message.role === 'tool') {
160
+ return {
161
+ role: 'user',
162
+ content: [{ type: 'tool_result', tool_use_id: message.toolCallId, content: message.content }]
163
+ };
164
+ }
165
+ if (message.role === 'assistant' && message.toolCalls && message.toolCalls.length > 0) {
166
+ return {
167
+ role: 'assistant',
168
+ content: [
169
+ ...(typeof message.content === 'string' && message.content ? [{ type: 'text', text: message.content }] : []),
170
+ ...message.toolCalls.map((call) => ({ type: 'tool_use', id: call.id, name: call.name, input: call.arguments }))
171
+ ]
172
+ };
173
+ }
174
+ return {
175
+ role: message.role,
176
+ content: typeof message.content === 'string' ? message.content : message.content.map(toContentBlock)
177
+ };
178
+ });
179
+ return {
180
+ ...(system ? { system } : {}),
181
+ messages: converted
182
+ };
183
+ }
184
+ function toContentBlock(part) {
185
+ if (part.kind === 'text')
186
+ return { type: 'text', text: part.text };
187
+ if (part.kind === 'image') {
188
+ return { type: 'image', source: { type: 'base64', media_type: part.mimeType, data: part.dataBase64 } };
189
+ }
190
+ if (part.kind === 'image_url') {
191
+ return { type: 'image', source: { type: 'url', url: part.url } };
192
+ }
193
+ return { type: 'text', text: `[unsupported ${part.kind} content omitted]` };
194
+ }
195
+ function toTools(tools) {
196
+ if (!tools || tools.length === 0)
197
+ return undefined;
198
+ return tools.map((tool) => ({
199
+ name: tool.name,
200
+ description: tool.description,
201
+ input_schema: tool.parameters
202
+ }));
203
+ }
204
+ function toObjectTool(req) {
205
+ return {
206
+ name: 'harness_response',
207
+ description: 'Return the structured response object.',
208
+ input_schema: req.schema
209
+ };
210
+ }
211
+ function extractToolCalls(response, req, method) {
212
+ const calls = response.content?.filter((block) => block.type === 'tool_use' && block.name && block.id);
213
+ if (!calls || calls.length === 0)
214
+ return undefined;
215
+ return calls.map((call) => ({
216
+ id: String(call.id),
217
+ name: String(call.name),
218
+ arguments: typeof call.input === 'string' ? parseJson(call.input, req, method) : call.input ?? {}
219
+ }));
220
+ }
221
+ function parseJson(content, req, method) {
222
+ try {
223
+ return JSON.parse(content);
224
+ }
225
+ catch (error) {
226
+ throw malformedResponseError(req, method, 'Anthropic returned malformed structured JSON.', content, error);
227
+ }
228
+ }
229
+ function malformedResponseError(req, method, message, body, cause) {
230
+ return new ModelError(message, {
231
+ provider: 'anthropic',
232
+ model: req.model,
233
+ method,
234
+ reason: 'malformed_response',
235
+ providerBody: body
236
+ }, cause);
237
+ }
238
+ function safePartialJson(content) {
239
+ try {
240
+ return JSON.parse(content);
241
+ }
242
+ catch {
243
+ return { _partial: content };
244
+ }
245
+ }
246
+ function toUsage(inputTokens, outputTokens) {
247
+ const input = inputTokens ?? 0;
248
+ const output = outputTokens ?? 0;
249
+ return { inputTokens: input, outputTokens: output, totalTokens: input + output };
250
+ }
251
+ function toFinishReason(value) {
252
+ switch (value) {
253
+ case 'end_turn':
254
+ case 'stop_sequence':
255
+ return 'stop';
256
+ case 'max_tokens':
257
+ return 'length';
258
+ case 'tool_use':
259
+ return 'tool_calls';
260
+ default:
261
+ return 'error';
262
+ }
263
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@purista/harness-anthropic",
3
+ "version": "1.0.0",
4
+ "description": "Anthropic model provider adapter for @purista/harness.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist/**/*.js",
16
+ "dist/**/*.d.ts",
17
+ "package.json",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "license": "Apache-2.0",
22
+ "sideEffects": false,
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/puristajs/harness",
26
+ "directory": "packages/harness-anthropic"
27
+ },
28
+ "bugs": {
29
+ "url": "https://github.com/puristajs/harness/issues"
30
+ },
31
+ "homepage": "https://github.com/puristajs/harness#readme",
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "scripts": {
36
+ "clean": "rm -rf dist",
37
+ "build": "npm run clean && tsc -p tsconfig.json",
38
+ "typecheck": "tsc -p tsconfig.typecheck.json",
39
+ "test": "vitest run",
40
+ "lint": "echo 'no lint configured for @purista/harness-anthropic'"
41
+ },
42
+ "dependencies": {
43
+ "@anthropic-ai/sdk": "^0.95.1"
44
+ },
45
+ "devDependencies": {
46
+ "@vitest/coverage-v8": "^4.1.5",
47
+ "typescript": "^6.0.3",
48
+ "vitest": "^4.1.5"
49
+ },
50
+ "peerDependencies": {
51
+ "@purista/harness": "*"
52
+ },
53
+ "engines": {
54
+ "node": ">=24.15.0"
55
+ }
56
+ }