@purista/harness-azure-foundry 1.2.5 → 1.5.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 (2) hide show
  1. package/dist/index.js +53 -71
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { BaseModelProvider, ModelError } from '@purista/harness';
1
+ import { BaseModelProvider, accumulateStreamToolCallDeltas, createStreamToolCallState, finalizeStreamToolCalls, parseProviderJson, safePartialJson, toTokenUsage } from '@purista/harness';
2
2
  import ModelClient, {} from '@azure-rest/ai-inference';
3
3
  import { AzureKeyCredential } from '@azure/core-auth';
4
4
  import { createSseStream } from '@azure/core-sse';
@@ -41,8 +41,9 @@ class AzureFoundryModelProvider extends BaseModelProvider {
41
41
  return {
42
42
  content: choice?.message?.content ?? '',
43
43
  ...(toolCalls ? { toolCalls } : {}),
44
- usage: toUsage(body.usage?.prompt_tokens, body.usage?.completion_tokens, body.usage?.total_tokens),
44
+ usage: toTokenUsage(body.usage?.prompt_tokens, body.usage?.completion_tokens, body.usage?.total_tokens),
45
45
  finishReason: toFinishReason(choice?.finish_reason),
46
+ outcome: toOutcome(choice?.finish_reason),
46
47
  raw: response
47
48
  };
48
49
  }
@@ -50,8 +51,9 @@ class AzureFoundryModelProvider extends BaseModelProvider {
50
51
  req.signal.throwIfAborted();
51
52
  let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
52
53
  let finishReason = 'stop';
53
- const toolState = new Map();
54
- for await (const event of streamChat(this.client, req, false)) {
54
+ let providerFinishReason;
55
+ const toolState = createStreamToolCallState();
56
+ for await (const event of streamChat(this.client, req)) {
55
57
  req.signal.throwIfAborted();
56
58
  const data = parseStreamData(event, req, 'textStream');
57
59
  if (!data)
@@ -61,20 +63,21 @@ class AzureFoundryModelProvider extends BaseModelProvider {
61
63
  yield { kind: 'delta', text: choice.delta.content };
62
64
  }
63
65
  if (choice.delta?.tool_calls) {
64
- accumulateToolCallDeltas(toolState, choice.delta.tool_calls);
66
+ accumulateStreamToolCallDeltas(toolState, choice.delta.tool_calls);
65
67
  }
66
68
  if (choice.finish_reason) {
67
- finishReason = toFinishReason(choice.finish_reason);
69
+ providerFinishReason = choice.finish_reason;
70
+ finishReason = toFinishReason(providerFinishReason);
68
71
  }
69
72
  }
70
73
  if (data.usage) {
71
- usage = toUsage(data.usage.prompt_tokens, data.usage.completion_tokens, data.usage.total_tokens);
74
+ usage = toTokenUsage(data.usage.prompt_tokens, data.usage.completion_tokens, data.usage.total_tokens);
72
75
  }
73
76
  }
74
- for (const call of finalizeStreamToolCalls(toolState, req, 'textStream')) {
77
+ for (const call of finalizeStreamToolCalls(toolState, callContext(req, 'textStream'), MALFORMED_JSON_MESSAGE)) {
75
78
  yield { kind: 'tool_call', call };
76
79
  }
77
- yield { kind: 'finish', usage, finishReason };
80
+ yield { kind: 'finish', usage, finishReason, outcome: streamOutcome(finishReason, providerFinishReason) };
78
81
  }
79
82
  async doObject(req) {
80
83
  req.signal.throwIfAborted();
@@ -86,8 +89,9 @@ class AzureFoundryModelProvider extends BaseModelProvider {
86
89
  return {
87
90
  object: parseJson(text, req, 'object'),
88
91
  ...(toolCalls ? { toolCalls } : {}),
89
- usage: toUsage(body.usage?.prompt_tokens, body.usage?.completion_tokens, body.usage?.total_tokens),
92
+ usage: toTokenUsage(body.usage?.prompt_tokens, body.usage?.completion_tokens, body.usage?.total_tokens),
90
93
  finishReason: toFinishReason(choice?.finish_reason),
94
+ outcome: toOutcome(choice?.finish_reason),
91
95
  raw: response
92
96
  };
93
97
  }
@@ -96,8 +100,9 @@ class AzureFoundryModelProvider extends BaseModelProvider {
96
100
  let partial = '';
97
101
  let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
98
102
  let finishReason = 'stop';
99
- const toolState = new Map();
100
- for await (const event of streamChat(this.client, req, true)) {
103
+ let providerFinishReason;
104
+ const toolState = createStreamToolCallState();
105
+ for await (const event of streamChat(this.client, req)) {
101
106
  req.signal.throwIfAborted();
102
107
  const data = parseStreamData(event, req, 'objectStream');
103
108
  if (!data)
@@ -108,21 +113,22 @@ class AzureFoundryModelProvider extends BaseModelProvider {
108
113
  yield { kind: 'partial', partial: safePartialJson(partial) };
109
114
  }
110
115
  if (choice.delta?.tool_calls) {
111
- accumulateToolCallDeltas(toolState, choice.delta.tool_calls);
116
+ accumulateStreamToolCallDeltas(toolState, choice.delta.tool_calls);
112
117
  }
113
118
  if (choice.finish_reason) {
114
- finishReason = toFinishReason(choice.finish_reason);
119
+ providerFinishReason = choice.finish_reason;
120
+ finishReason = toFinishReason(providerFinishReason);
115
121
  }
116
122
  }
117
123
  if (data.usage) {
118
- usage = toUsage(data.usage.prompt_tokens, data.usage.completion_tokens, data.usage.total_tokens);
124
+ usage = toTokenUsage(data.usage.prompt_tokens, data.usage.completion_tokens, data.usage.total_tokens);
119
125
  }
120
126
  }
121
- for (const call of finalizeStreamToolCalls(toolState, req, 'objectStream')) {
127
+ for (const call of finalizeStreamToolCalls(toolState, callContext(req, 'objectStream'), MALFORMED_JSON_MESSAGE)) {
122
128
  yield { kind: 'tool_call', call };
123
129
  }
124
130
  const object = parseJson(partial || '{}', req, 'objectStream');
125
- yield { kind: 'finish', object, usage, finishReason };
131
+ yield { kind: 'finish', object, usage, finishReason, outcome: streamOutcome(finishReason, providerFinishReason) };
126
132
  }
127
133
  async doEmbed(req) {
128
134
  req.signal.throwIfAborted();
@@ -146,7 +152,7 @@ class AzureFoundryModelProvider extends BaseModelProvider {
146
152
  index: item.index,
147
153
  vector: Array.isArray(item.embedding) ? item.embedding : []
148
154
  })),
149
- usage: toUsage(body.usage?.prompt_tokens, 0, body.usage?.total_tokens),
155
+ usage: toTokenUsage(body.usage?.prompt_tokens, 0, body.usage?.total_tokens),
150
156
  raw: response
151
157
  };
152
158
  }
@@ -160,7 +166,10 @@ function createClient(options) {
160
166
  if (!auth) {
161
167
  throw new Error('Azure AI Foundry apiKey or credential is required when no client is injected.');
162
168
  }
163
- return ModelClient(endpoint, auth, clientOptions);
169
+ // The harness owns retry budgets (spec 23): disable the SDK pipeline's
170
+ // default retries while preserving explicit user retryOptions as an escape
171
+ // hatch via the spread below.
172
+ return ModelClient(endpoint, auth, { retryOptions: { maxRetries: 0 }, ...clientOptions });
164
173
  }
165
174
  async function postChat(client, req, stream) {
166
175
  const providerOptions = {
@@ -188,7 +197,7 @@ async function postChat(client, req, stream) {
188
197
  abortSignal: req.signal
189
198
  });
190
199
  }
191
- async function* streamChat(client, req, objectMode) {
200
+ async function* streamChat(client, req) {
192
201
  const response = await postChat(client, req, true);
193
202
  const nodeResponse = typeof response.asNodeStream === 'function' ? await response.asNodeStream() : response;
194
203
  if (nodeResponse.status && nodeResponse.status !== '200' && nodeResponse.status !== 200) {
@@ -299,29 +308,6 @@ function extractToolCalls(toolCalls, req, method) {
299
308
  arguments: parseJson(call.function.arguments ?? '{}', req, method)
300
309
  }));
301
310
  }
302
- function accumulateToolCallDeltas(state, deltas) {
303
- for (const delta of deltas) {
304
- const index = typeof delta?.index === 'number' ? delta.index : 0;
305
- const existing = state.get(index) ?? { args: '' };
306
- if (delta?.id)
307
- existing.id = String(delta.id);
308
- if (delta?.function?.name)
309
- existing.name = String(delta.function.name);
310
- if (typeof delta?.function?.arguments === 'string')
311
- existing.args += delta.function.arguments;
312
- state.set(index, existing);
313
- }
314
- }
315
- function finalizeStreamToolCalls(state, req, method) {
316
- return [...state.entries()]
317
- .sort((a, b) => a[0] - b[0])
318
- .filter(([, call]) => call.id && call.name)
319
- .map(([, call]) => ({
320
- id: call.id,
321
- name: call.name,
322
- arguments: parseJson(call.args || '{}', req, method)
323
- }));
324
- }
325
311
  function parseStreamData(event, req, method) {
326
312
  if (event === '[DONE]')
327
313
  return undefined;
@@ -329,35 +315,12 @@ function parseStreamData(event, req, method) {
329
315
  return parseJson(event, req, method);
330
316
  return event;
331
317
  }
332
- function parseJson(content, req, method) {
333
- try {
334
- return JSON.parse(content);
335
- }
336
- catch (error) {
337
- throw malformedResponseError(req, method, 'Azure AI Foundry returned malformed JSON.', content, error);
338
- }
339
- }
340
- function malformedResponseError(req, method, message, body, cause) {
341
- return new ModelError(message, {
342
- provider: 'azure-foundry',
343
- model: req.model,
344
- method,
345
- reason: 'malformed_response',
346
- providerBody: body
347
- }, cause);
348
- }
349
- function safePartialJson(content) {
350
- try {
351
- return JSON.parse(content);
352
- }
353
- catch {
354
- return { _partial: content };
355
- }
318
+ const MALFORMED_JSON_MESSAGE = 'Azure AI Foundry returned malformed JSON.';
319
+ function callContext(req, method) {
320
+ return { provider: 'azure-foundry', model: req.model, method };
356
321
  }
357
- function toUsage(inputTokens, outputTokens, totalTokens) {
358
- const input = inputTokens ?? 0;
359
- const output = outputTokens ?? 0;
360
- return { inputTokens: input, outputTokens: output, totalTokens: totalTokens ?? input + output };
322
+ function parseJson(content, req, method) {
323
+ return parseProviderJson(content, callContext(req, method), MALFORMED_JSON_MESSAGE);
361
324
  }
362
325
  function toFinishReason(value) {
363
326
  switch (value) {
@@ -369,7 +332,26 @@ function toFinishReason(value) {
369
332
  return 'tool_calls';
370
333
  case 'content_filter':
371
334
  return 'content_filter';
335
+ case 'function_call':
336
+ return 'tool_calls';
372
337
  default:
373
338
  return 'error';
374
339
  }
375
340
  }
341
+ function toOutcome(value) {
342
+ const finishReason = toFinishReason(value);
343
+ return {
344
+ finishReason,
345
+ ...(typeof value === 'string' ? { providerFinishReason: value } : {})
346
+ };
347
+ }
348
+ /**
349
+ * Stream outcome built from the tracked finish reason; `providerFinishReason`
350
+ * is omitted entirely when the provider never sent a finish reason.
351
+ */
352
+ function streamOutcome(finishReason, providerFinishReason) {
353
+ return {
354
+ finishReason,
355
+ ...(typeof providerFinishReason === 'string' ? { providerFinishReason } : {})
356
+ };
357
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@purista/harness-azure-foundry",
3
- "version": "1.2.5",
3
+ "version": "1.5.0",
4
4
  "description": "Azure AI Foundry model provider adapter for @purista/harness.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -50,7 +50,7 @@
50
50
  "vitest": "^4.1.8"
51
51
  },
52
52
  "peerDependencies": {
53
- "@purista/harness": "*"
53
+ "@purista/harness": "^1.5.0"
54
54
  },
55
55
  "engines": {
56
56
  "node": ">=24.15.0"