@purista/harness-azure-foundry 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,27 @@
1
+ # @purista/harness-azure-foundry
2
+
3
+ Azure AI Foundry model provider adapter for `@purista/harness`.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @purista/harness @purista/harness-azure-foundry
9
+ ```
10
+
11
+ Configure the provider with an Azure AI Foundry model endpoint and either an API
12
+ key or Azure credential.
13
+
14
+ ```ts
15
+ import { azureFoundry } from '@purista/harness-azure-foundry'
16
+
17
+ azureFoundry({
18
+ endpoint: process.env.AZURE_AI_ENDPOINT!,
19
+ apiKey: process.env.AZURE_AI_API_KEY!
20
+ })
21
+ ```
22
+
23
+ ## Package Format
24
+
25
+ This package is ESM-only and ships compiled JavaScript plus TypeScript
26
+ declarations from `dist/`. Source files, tests, source maps, and local configs
27
+ are not included in the published package.
@@ -0,0 +1,40 @@
1
+ import type { BaseModelProviderOptions, ModelProvider } from '@purista/harness';
2
+ import { type ModelClientOptions } from '@azure-rest/ai-inference';
3
+ import { type KeyCredential, type TokenCredential } from '@azure/core-auth';
4
+ export interface AzureFoundryFactoryOptions extends ModelClientOptions {
5
+ /** Azure AI Foundry model endpoint. Not required when `client` is injected. */
6
+ endpoint?: string;
7
+ /** Azure AI Foundry API key. Ignored when `credential` or `client` is provided. */
8
+ apiKey?: string;
9
+ /** Azure credential, for example `DefaultAzureCredential`. */
10
+ credential?: TokenCredential | KeyCredential;
11
+ /** Optional injected client for tests or custom transport behavior. */
12
+ client?: AzureFoundryClient;
13
+ /** Optional adapter-level logger override. Defaults to the harness logger when registered. */
14
+ harnessLogger?: BaseModelProviderOptions['logger'];
15
+ /** Optional adapter-level telemetry override. Defaults to the harness telemetry shim when registered. */
16
+ telemetry?: BaseModelProviderOptions['telemetry'];
17
+ /** Optional adapter-level timeout override. Defaults to the harness model timeout when registered. */
18
+ harnessTimeoutMs?: number;
19
+ }
20
+ /**
21
+ * Creates an Azure AI Foundry-backed harness `ModelProvider`.
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * import { azureFoundry } from '@purista/harness-azure-foundry'
26
+ *
27
+ * const provider = azureFoundry({
28
+ * endpoint: process.env.AZURE_AI_ENDPOINT,
29
+ * apiKey: process.env.AZURE_AI_API_KEY
30
+ * })
31
+ * ```
32
+ */
33
+ export declare function azureFoundry(options?: AzureFoundryFactoryOptions): ModelProvider;
34
+ export type AzureFoundryClient = {
35
+ path(path: '/chat/completions' | '/embeddings'): {
36
+ post(options: unknown): Promise<any> & {
37
+ asNodeStream?: () => Promise<any>;
38
+ };
39
+ };
40
+ };
package/dist/index.js ADDED
@@ -0,0 +1,324 @@
1
+ import { BaseModelProvider, ModelError } from '@purista/harness';
2
+ import ModelClient, {} from '@azure-rest/ai-inference';
3
+ import { AzureKeyCredential } from '@azure/core-auth';
4
+ import { createSseStream } from '@azure/core-sse';
5
+ /**
6
+ * Creates an Azure AI Foundry-backed harness `ModelProvider`.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * import { azureFoundry } from '@purista/harness-azure-foundry'
11
+ *
12
+ * const provider = azureFoundry({
13
+ * endpoint: process.env.AZURE_AI_ENDPOINT,
14
+ * apiKey: process.env.AZURE_AI_API_KEY
15
+ * })
16
+ * ```
17
+ */
18
+ export function azureFoundry(options = {}) {
19
+ return new AzureFoundryModelProvider(options);
20
+ }
21
+ class AzureFoundryModelProvider extends BaseModelProvider {
22
+ options;
23
+ client;
24
+ constructor(options) {
25
+ super({
26
+ id: 'azure-foundry',
27
+ genAiSystem: 'azure.ai.inference',
28
+ ...(options.harnessLogger ? { logger: options.harnessLogger } : {}),
29
+ ...(options.telemetry ? { telemetry: options.telemetry } : {}),
30
+ ...(options.harnessTimeoutMs !== undefined ? { timeoutMs: options.harnessTimeoutMs } : {})
31
+ });
32
+ this.options = options;
33
+ this.client = options.client ?? createClient(options);
34
+ }
35
+ async doText(req) {
36
+ req.signal.throwIfAborted();
37
+ const response = await postChat(this.client, req, false);
38
+ const body = ensureOk(response);
39
+ const choice = body.choices?.[0];
40
+ const toolCalls = extractToolCalls(choice?.message?.tool_calls, req, 'text');
41
+ return {
42
+ content: choice?.message?.content ?? '',
43
+ ...(toolCalls ? { toolCalls } : {}),
44
+ usage: toUsage(body.usage?.prompt_tokens, body.usage?.completion_tokens, body.usage?.total_tokens),
45
+ finishReason: toFinishReason(choice?.finish_reason),
46
+ raw: response
47
+ };
48
+ }
49
+ async *doTextStream(req) {
50
+ req.signal.throwIfAborted();
51
+ let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
52
+ let finishReason = 'stop';
53
+ for await (const event of streamChat(this.client, req, false)) {
54
+ req.signal.throwIfAborted();
55
+ const data = parseStreamData(event, req, 'textStream');
56
+ if (!data)
57
+ continue;
58
+ for (const choice of data.choices ?? []) {
59
+ if (choice.delta?.content) {
60
+ yield { kind: 'delta', text: choice.delta.content };
61
+ }
62
+ const toolCalls = extractToolCalls(choice.delta?.tool_calls, req, 'textStream');
63
+ for (const call of toolCalls ?? []) {
64
+ yield { kind: 'tool_call', call };
65
+ }
66
+ finishReason = toFinishReason(choice.finish_reason ?? finishReason);
67
+ }
68
+ if (data.usage) {
69
+ usage = toUsage(data.usage.prompt_tokens, data.usage.completion_tokens, data.usage.total_tokens);
70
+ }
71
+ }
72
+ yield { kind: 'finish', usage, finishReason };
73
+ }
74
+ async doObject(req) {
75
+ req.signal.throwIfAborted();
76
+ const response = await postChat(this.client, req, false);
77
+ const body = ensureOk(response);
78
+ const choice = body.choices?.[0];
79
+ const text = choice?.message?.content ?? '{}';
80
+ const toolCalls = extractToolCalls(choice?.message?.tool_calls, req, 'object');
81
+ return {
82
+ object: parseJson(text, req, 'object'),
83
+ ...(toolCalls ? { toolCalls } : {}),
84
+ usage: toUsage(body.usage?.prompt_tokens, body.usage?.completion_tokens, body.usage?.total_tokens),
85
+ finishReason: toFinishReason(choice?.finish_reason),
86
+ raw: response
87
+ };
88
+ }
89
+ async *doObjectStream(req) {
90
+ req.signal.throwIfAborted();
91
+ let partial = '';
92
+ let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
93
+ let finishReason = 'stop';
94
+ for await (const event of streamChat(this.client, req, true)) {
95
+ req.signal.throwIfAborted();
96
+ const data = parseStreamData(event, req, 'objectStream');
97
+ if (!data)
98
+ continue;
99
+ for (const choice of data.choices ?? []) {
100
+ if (choice.delta?.content) {
101
+ partial += choice.delta.content;
102
+ yield { kind: 'partial', partial: safePartialJson(partial) };
103
+ }
104
+ const toolCalls = extractToolCalls(choice.delta?.tool_calls, req, 'objectStream');
105
+ for (const call of toolCalls ?? []) {
106
+ yield { kind: 'tool_call', call };
107
+ }
108
+ finishReason = toFinishReason(choice.finish_reason ?? finishReason);
109
+ }
110
+ if (data.usage) {
111
+ usage = toUsage(data.usage.prompt_tokens, data.usage.completion_tokens, data.usage.total_tokens);
112
+ }
113
+ }
114
+ const object = parseJson(partial || '{}', req, 'objectStream');
115
+ yield { kind: 'finish', object, usage, finishReason };
116
+ }
117
+ async doEmbed(req) {
118
+ req.signal.throwIfAborted();
119
+ const providerOptions = {
120
+ ...(req.call?.providerOptions ?? {})
121
+ };
122
+ const { requestOptions, ...bodyOptions } = providerOptions;
123
+ const response = await this.client.path('/embeddings').post({
124
+ body: {
125
+ model: req.model,
126
+ input: Array.isArray(req.input) ? [...req.input] : [req.input],
127
+ ...(req.dimensions !== undefined ? { dimensions: req.dimensions } : {}),
128
+ ...bodyOptions
129
+ },
130
+ ...requestOptions,
131
+ abortSignal: req.signal
132
+ });
133
+ const body = ensureOk(response);
134
+ return {
135
+ embeddings: body.data.map((item) => ({
136
+ index: item.index,
137
+ vector: Array.isArray(item.embedding) ? item.embedding : []
138
+ })),
139
+ usage: toUsage(body.usage?.prompt_tokens, 0, body.usage?.total_tokens),
140
+ raw: response
141
+ };
142
+ }
143
+ }
144
+ function createClient(options) {
145
+ const { endpoint, apiKey, credential, client: _client, harnessLogger: _harnessLogger, telemetry: _telemetry, harnessTimeoutMs: _harnessTimeoutMs, ...clientOptions } = options;
146
+ if (!endpoint) {
147
+ throw new Error('Azure AI Foundry endpoint is required when no client is injected.');
148
+ }
149
+ const auth = credential ?? (apiKey ? new AzureKeyCredential(apiKey) : undefined);
150
+ if (!auth) {
151
+ throw new Error('Azure AI Foundry apiKey or credential is required when no client is injected.');
152
+ }
153
+ return ModelClient(endpoint, auth, clientOptions);
154
+ }
155
+ async function postChat(client, req, stream) {
156
+ const providerOptions = {
157
+ ...(req.defaults?.providerOptions ?? {}),
158
+ ...(req.call?.providerOptions ?? {})
159
+ };
160
+ const { requestOptions, ...bodyOptions } = providerOptions;
161
+ return client.path('/chat/completions').post({
162
+ body: {
163
+ model: req.model,
164
+ messages: toAzureMessages(req.messages),
165
+ stream,
166
+ tools: toTools(req.tools),
167
+ temperature: req.call?.temperature ?? req.defaults?.temperature,
168
+ max_tokens: req.call?.maxTokens ?? req.defaults?.maxTokens,
169
+ top_p: req.call?.topP ?? req.defaults?.topP,
170
+ stop: req.call?.stopSequences ?? req.defaults?.stopSequences,
171
+ response_format: toResponseFormat(req),
172
+ ...bodyOptions
173
+ },
174
+ ...requestOptions,
175
+ abortSignal: req.signal
176
+ });
177
+ }
178
+ async function* streamChat(client, req, objectMode) {
179
+ const response = await postChat(client, req, true);
180
+ const nodeResponse = typeof response.asNodeStream === 'function' ? await response.asNodeStream() : response;
181
+ if (nodeResponse.status && nodeResponse.status !== '200' && nodeResponse.status !== 200) {
182
+ throw nodeResponse.body?.error ?? new Error('Azure AI Foundry streaming request failed.');
183
+ }
184
+ if (nodeResponse.body?.[Symbol.asyncIterator]) {
185
+ const sses = createSseStream(nodeResponse.body);
186
+ for await (const event of sses) {
187
+ if (event.data === '[DONE]')
188
+ break;
189
+ yield event.data;
190
+ }
191
+ return;
192
+ }
193
+ for await (const event of nodeResponse.body ?? []) {
194
+ yield event;
195
+ }
196
+ }
197
+ function ensureOk(response) {
198
+ if (response.status && response.status !== '200' && response.status !== 200) {
199
+ throw response.body?.error ?? new Error('Azure AI Foundry request failed.');
200
+ }
201
+ return response.body ?? response;
202
+ }
203
+ function toAzureMessages(messages) {
204
+ return messages.map((message) => {
205
+ if (message.role === 'assistant' && message.toolCalls && message.toolCalls.length > 0) {
206
+ return {
207
+ role: 'assistant',
208
+ content: typeof message.content === 'string' ? message.content : '',
209
+ tool_calls: message.toolCalls.map((call) => ({
210
+ id: call.id,
211
+ type: 'function',
212
+ function: {
213
+ name: call.name,
214
+ arguments: JSON.stringify(call.arguments)
215
+ }
216
+ }))
217
+ };
218
+ }
219
+ if (message.role === 'tool') {
220
+ return { role: 'tool', tool_call_id: message.toolCallId, content: message.content };
221
+ }
222
+ return {
223
+ role: message.role,
224
+ content: typeof message.content === 'string' ? message.content : message.content.map(toContentItem)
225
+ };
226
+ });
227
+ }
228
+ function toContentItem(part) {
229
+ if (part.kind === 'text')
230
+ return { type: 'text', text: part.text };
231
+ if (part.kind === 'image')
232
+ return { type: 'image_url', image_url: { url: `data:${part.mimeType};base64,${part.dataBase64}` } };
233
+ if (part.kind === 'image_url')
234
+ return { type: 'image_url', image_url: { url: part.url } };
235
+ if (part.kind === 'audio')
236
+ return { type: 'input_audio', input_audio: { data: part.dataBase64, format: part.mimeType.split('/')[1] ?? 'wav' } };
237
+ return { type: 'text', text: `[unsupported ${part.kind} content omitted]` };
238
+ }
239
+ function toTools(tools) {
240
+ if (!tools || tools.length === 0)
241
+ return undefined;
242
+ return tools.map((tool) => ({
243
+ type: 'function',
244
+ function: {
245
+ name: tool.name,
246
+ description: tool.description,
247
+ parameters: tool.parameters
248
+ }
249
+ }));
250
+ }
251
+ function toResponseFormat(req) {
252
+ if (!('schema' in req))
253
+ return undefined;
254
+ return {
255
+ type: 'json_schema',
256
+ json_schema: {
257
+ name: req.schemaName ?? 'harness_response',
258
+ strict: false,
259
+ schema: req.schema
260
+ }
261
+ };
262
+ }
263
+ function extractToolCalls(toolCalls, req, method) {
264
+ if (!Array.isArray(toolCalls) || toolCalls.length === 0)
265
+ return undefined;
266
+ return toolCalls
267
+ .filter((call) => call?.id && call?.function?.name)
268
+ .map((call) => ({
269
+ id: String(call.id),
270
+ name: String(call.function.name),
271
+ arguments: parseJson(call.function.arguments ?? '{}', req, method)
272
+ }));
273
+ }
274
+ function parseStreamData(event, req, method) {
275
+ if (event === '[DONE]')
276
+ return undefined;
277
+ if (typeof event === 'string')
278
+ return parseJson(event, req, method);
279
+ return event;
280
+ }
281
+ function parseJson(content, req, method) {
282
+ try {
283
+ return JSON.parse(content);
284
+ }
285
+ catch (error) {
286
+ throw malformedResponseError(req, method, 'Azure AI Foundry returned malformed JSON.', content, error);
287
+ }
288
+ }
289
+ function malformedResponseError(req, method, message, body, cause) {
290
+ return new ModelError(message, {
291
+ provider: 'azure-foundry',
292
+ model: req.model,
293
+ method,
294
+ reason: 'malformed_response',
295
+ providerBody: body
296
+ }, cause);
297
+ }
298
+ function safePartialJson(content) {
299
+ try {
300
+ return JSON.parse(content);
301
+ }
302
+ catch {
303
+ return { _partial: content };
304
+ }
305
+ }
306
+ function toUsage(inputTokens, outputTokens, totalTokens) {
307
+ const input = inputTokens ?? 0;
308
+ const output = outputTokens ?? 0;
309
+ return { inputTokens: input, outputTokens: output, totalTokens: totalTokens ?? input + output };
310
+ }
311
+ function toFinishReason(value) {
312
+ switch (value) {
313
+ case 'stop':
314
+ return 'stop';
315
+ case 'length':
316
+ return 'length';
317
+ case 'tool_calls':
318
+ return 'tool_calls';
319
+ case 'content_filter':
320
+ return 'content_filter';
321
+ default:
322
+ return 'error';
323
+ }
324
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@purista/harness-azure-foundry",
3
+ "version": "1.0.0",
4
+ "description": "Azure AI Foundry 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-azure-foundry"
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-azure-foundry'"
41
+ },
42
+ "dependencies": {
43
+ "@azure-rest/ai-inference": "^1.0.0-beta.6",
44
+ "@azure/core-auth": "^1.10.1",
45
+ "@azure/core-sse": "^2.3.0"
46
+ },
47
+ "devDependencies": {
48
+ "@vitest/coverage-v8": "^4.1.5",
49
+ "typescript": "^6.0.3",
50
+ "vitest": "^4.1.5"
51
+ },
52
+ "peerDependencies": {
53
+ "@purista/harness": "*"
54
+ },
55
+ "engines": {
56
+ "node": ">=24.15.0"
57
+ }
58
+ }