@hunterzhu/pulse-adapters 0.4.1 → 0.4.3

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.
@@ -0,0 +1,98 @@
1
+ export class DynamicCapabilityProbe {
2
+ unsupportedReasoning = new Set();
3
+ key(provider, model) {
4
+ return `${provider.toLowerCase()}:${(model ?? '').toLowerCase()}`;
5
+ }
6
+ isReasoningUnsupported(provider, model) {
7
+ return this.unsupportedReasoning.has(this.key(provider, model));
8
+ }
9
+ markReasoningUnsupported(provider, model) {
10
+ this.unsupportedReasoning.add(this.key(provider, model));
11
+ }
12
+ reasoningParameterRejected(cause) {
13
+ if (!(cause instanceof Error))
14
+ return false;
15
+ const code = cause.code;
16
+ if (code !== 'PROVIDER_HTTP_400')
17
+ return false;
18
+ return /reasoning_effort/i.test(cause.message);
19
+ }
20
+ }
21
+ export const globalProbe = new DynamicCapabilityProbe();
22
+ export function adaptCanonicalRequest(original, capability, probe = globalProbe, toolNameAliases = new Map()) {
23
+ const messages = [...original.messages.map((m) => ({ ...m }))];
24
+ const request = {
25
+ ...original,
26
+ messages,
27
+ ...(original.tools ? { tools: [...original.tools] } : {}),
28
+ };
29
+ let clientSchemaValidationRequired = false;
30
+ // 1. Structured Output Adaptation
31
+ if (request.structuredOutput) {
32
+ if (capability.structuredOutput === 'json_object_only') {
33
+ const schemaPrompt = `Return only a JSON object matching this schema. The application will validate the result:\n${JSON.stringify(request.structuredOutput.schema)}`;
34
+ injectSystemInstruction(messages, schemaPrompt);
35
+ request.structuredOutput = {
36
+ ...request.structuredOutput,
37
+ mode: 'json_object',
38
+ };
39
+ clientSchemaValidationRequired = true;
40
+ }
41
+ else if (capability.structuredOutput === 'unsupported') {
42
+ const schemaPrompt = `Return only a JSON object matching this schema. The application will validate the result:\n${JSON.stringify(request.structuredOutput.schema)}`;
43
+ injectSystemInstruction(messages, schemaPrompt);
44
+ delete request.structuredOutput;
45
+ clientSchemaValidationRequired = true;
46
+ }
47
+ else {
48
+ request.structuredOutput = {
49
+ ...request.structuredOutput,
50
+ mode: 'json_schema',
51
+ };
52
+ if (capability.structuredOutput === 'native_loose') {
53
+ clientSchemaValidationRequired = true;
54
+ }
55
+ }
56
+ }
57
+ // 2. Reasoning / Thinking Adaptation
58
+ const reasoningDisabled = probe.isReasoningUnsupported(capability.provider, request.model) ||
59
+ capability.reasoning === 'unsupported';
60
+ if (reasoningDisabled || !request.reasoning?.effort) {
61
+ delete request.reasoning;
62
+ }
63
+ else if (capability.reasoning === 'budget_tokens') {
64
+ const maxTokens = request.maxTokens ?? 4096;
65
+ const requested = request.reasoning.effort === 'high' ? 8_000 : request.reasoning.effort === 'medium' ? 2_048 : 1_024;
66
+ const budget = Math.min(requested, maxTokens - 1_024);
67
+ if (budget >= 1_024 && budget < maxTokens) {
68
+ request.reasoning = {
69
+ ...request.reasoning,
70
+ budgetTokens: budget,
71
+ enabled: true,
72
+ };
73
+ if (capability.constraints.disallowTemperatureWithReasoning) {
74
+ request.temperature = capability.constraints.defaultTemperatureWithReasoning ?? 1.0;
75
+ }
76
+ }
77
+ else {
78
+ delete request.reasoning;
79
+ }
80
+ }
81
+ else if (capability.reasoning === 'effort_param') {
82
+ if (capability.constraints.disallowTemperatureWithReasoning) {
83
+ delete request.temperature;
84
+ }
85
+ }
86
+ else if (capability.reasoning === 'prompt_mode') {
87
+ delete request.reasoning;
88
+ }
89
+ return {
90
+ request,
91
+ capability,
92
+ clientSchemaValidationRequired,
93
+ toolNameAliases,
94
+ };
95
+ }
96
+ function injectSystemInstruction(messages, instruction) {
97
+ messages.unshift({ role: 'system', content: instruction });
98
+ }
@@ -0,0 +1,109 @@
1
+ import type { JsonValue } from '@hunterzhu/pulse-runtime';
2
+ import type { ProviderToolChoice } from '../types.js';
3
+ export type CanonicalRole = 'system' | 'user' | 'assistant' | 'tool';
4
+ export interface CanonicalTextContent {
5
+ type: 'text';
6
+ text: string;
7
+ }
8
+ export interface CanonicalImageContent {
9
+ type: 'image';
10
+ source: {
11
+ type: 'base64' | 'url';
12
+ mediaType: string;
13
+ data: string;
14
+ };
15
+ }
16
+ export type CanonicalContentPart = CanonicalTextContent | CanonicalImageContent;
17
+ export interface CanonicalToolCall {
18
+ id: string;
19
+ name: string;
20
+ arguments: string;
21
+ }
22
+ export interface CanonicalMessage {
23
+ role: CanonicalRole;
24
+ content: string | null | CanonicalContentPart[];
25
+ name?: string;
26
+ toolCallId?: string;
27
+ toolCalls?: CanonicalToolCall[];
28
+ reasoningContent?: string;
29
+ }
30
+ export interface CanonicalToolDefinition {
31
+ name: string;
32
+ description?: string;
33
+ parameters: JsonValue;
34
+ }
35
+ export type CanonicalToolChoice = ProviderToolChoice;
36
+ export interface CanonicalStructuredOutput {
37
+ name?: string;
38
+ schema: JsonValue;
39
+ strict?: boolean;
40
+ description?: string;
41
+ mode?: 'json_schema' | 'json_object';
42
+ }
43
+ export interface CanonicalReasoningConfig {
44
+ effort?: 'low' | 'medium' | 'high';
45
+ budgetTokens?: number;
46
+ enabled?: boolean;
47
+ }
48
+ export interface CanonicalLLMRequest {
49
+ model?: string;
50
+ messages: CanonicalMessage[];
51
+ tools?: CanonicalToolDefinition[];
52
+ toolChoice?: CanonicalToolChoice;
53
+ temperature?: number;
54
+ topP?: number;
55
+ maxTokens?: number;
56
+ stop?: string[];
57
+ reasoning?: CanonicalReasoningConfig;
58
+ structuredOutput?: CanonicalStructuredOutput;
59
+ stream?: boolean;
60
+ }
61
+ export type CanonicalStreamEvent = {
62
+ type: 'text_delta';
63
+ text: string;
64
+ } | {
65
+ type: 'reasoning_delta';
66
+ text: string;
67
+ } | {
68
+ type: 'tool_call_delta';
69
+ index: number;
70
+ id?: string;
71
+ name?: string;
72
+ argumentsDelta: string;
73
+ } | {
74
+ type: 'refusal_delta';
75
+ refusal: string;
76
+ } | {
77
+ type: 'usage';
78
+ usage: {
79
+ inputTokens?: number;
80
+ outputTokens?: number;
81
+ reasoningTokens?: number;
82
+ cachedInputTokens?: number;
83
+ };
84
+ } | {
85
+ type: 'finish';
86
+ reason: string;
87
+ };
88
+ export type StructuredOutputCapability = 'native_strict' | 'native_loose' | 'json_object_only' | 'unsupported';
89
+ export type ReasoningCapability = 'effort_param' | 'budget_tokens' | 'prompt_mode' | 'unsupported';
90
+ export interface ModelCapabilityConstraints {
91
+ disallowTemperatureWithReasoning?: boolean;
92
+ defaultTemperatureWithReasoning?: number;
93
+ maxTokensParamName?: 'max_tokens' | 'max_completion_tokens';
94
+ supportsSystemMessage?: boolean;
95
+ supportsParallelTools?: boolean;
96
+ }
97
+ export interface ModelCapabilityProfile {
98
+ id: string;
99
+ provider: string;
100
+ structuredOutput: StructuredOutputCapability;
101
+ reasoning: ReasoningCapability;
102
+ constraints: ModelCapabilityConstraints;
103
+ }
104
+ export interface AdaptedCanonicalRequest {
105
+ request: CanonicalLLMRequest;
106
+ capability: ModelCapabilityProfile;
107
+ clientSchemaValidationRequired: boolean;
108
+ toolNameAliases: ReadonlyMap<string, string>;
109
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -116,10 +116,31 @@ export function normalizeOpenAIResponse(response, toolNameAliases, provider = 'o
116
116
  throw providerResponseError('OpenAI response must contain at least one choice');
117
117
  const choice = providerRecord(root.choices[0], 'OpenAI choice');
118
118
  const message = providerRecord(choice.message, 'OpenAI message');
119
- const toolCalls = choice.finish_reason === 'length' || message.tool_calls === undefined ? [] : normalizeOpenAIToolCalls(message.tool_calls, toolNameAliases);
119
+ const nativeToolCalls = choice.finish_reason === 'length' || message.tool_calls === undefined ? [] : normalizeOpenAIToolCalls(message.tool_calls, toolNameAliases);
120
120
  const refusal = message.refusal == null ? undefined : requiredProviderString(message.refusal, 'OpenAI refusal');
121
- const text = providerText(message.content, 'OpenAI message content');
122
- const finishReason = normalizeOpenAIFinishReason(choice.finish_reason, refusal, toolCalls.length > 0);
121
+ let text = providerText(message.content, 'OpenAI message content');
122
+ let finishReason = normalizeOpenAIFinishReason(choice.finish_reason, refusal, nativeToolCalls.length > 0);
123
+ let toolCalls = nativeToolCalls;
124
+ if (provider === 'deepseek' && (finishReason === 'stop' || nativeToolCalls.length > 0)) {
125
+ const recovered = recoverDeepSeekDsml(text, toolNameAliases, nativeToolCalls.length === 0);
126
+ text = recovered.text;
127
+ if (nativeToolCalls.length === 0 && recovered.truncated) {
128
+ toolCalls = [];
129
+ finishReason = 'length';
130
+ }
131
+ else if (nativeToolCalls.length === 0 && recovered.calls.length > 0) {
132
+ toolCalls = recovered.calls.map((call, index) => ({ toolCallId: `pulse-tool-${index + 1}`, providerToolCallId: `dsml-${index + 1}`, providerToolName: call.providerToolName, providerToolArguments: call.providerToolArguments, name: call.name, input: call.input }));
133
+ finishReason = 'tool_calls';
134
+ }
135
+ }
136
+ if (provider === 'deepseek' && nativeToolCalls.length === 0 && finishReason === 'stop' && toolCalls.length === 0) {
137
+ const jsonCalls = recoverDeepSeekJsonToolCalls(text, toolNameAliases);
138
+ if (jsonCalls !== undefined) {
139
+ text = '';
140
+ toolCalls = jsonCalls.map((call, index) => ({ toolCallId: `pulse-tool-${index + 1}`, providerToolCallId: `json-${index + 1}`, providerToolName: call.providerToolName, providerToolArguments: call.providerToolArguments, name: call.name, input: call.input }));
141
+ finishReason = 'tool_calls';
142
+ }
143
+ }
123
144
  const rawUsage = root.usage === undefined ? undefined : providerRecord(root.usage, 'OpenAI usage');
124
145
  const promptDetails = rawUsage?.prompt_tokens_details === undefined ? undefined : providerRecord(rawUsage.prompt_tokens_details, 'OpenAI prompt token details');
125
146
  const usage = normalizeUsage(rawUsage === undefined ? undefined : { ...rawUsage, cached_tokens: rawUsage.cached_tokens ?? promptDetails?.cached_tokens ?? rawUsage.cache_read_input_tokens, reasoning_tokens: rawUsage.completion_tokens_details?.reasoning_tokens ?? rawUsage.reasoning_tokens }, { input: 'prompt_tokens', output: 'completion_tokens', cached: 'cached_tokens', reasoning: 'reasoning_tokens' }, 'OpenAI');
@@ -127,6 +148,202 @@ export function normalizeOpenAIResponse(response, toolNameAliases, provider = 'o
127
148
  const measuredUsage = usage === undefined ? undefined : { ...usage, visibleOutputChars: text.length };
128
149
  return { text, ...(parseStructured(text) === undefined ? {} : { structured: parseStructured(text) }), toolCalls, ...(refusal === undefined ? {} : { refusal }), finishReason, ...(measuredUsage === undefined ? {} : { usage: measuredUsage }), ...(provider !== 'deepseek' || reasoningContent === undefined ? {} : { providerContinuation: { provider, reasoningContent } }) };
129
150
  }
151
+ /** DeepSeek V4/V4.1 may emit DSML tool markup in content instead of tool_calls. One or more U+FF5C bars are accepted. */
152
+ function recoverDeepSeekDsml(text, toolNameAliases, parseCalls) {
153
+ const blocks = takeDsmlBlocks(text);
154
+ if (!blocks.changed)
155
+ return { text, calls: [], truncated: false };
156
+ if (blocks.truncated || !parseCalls)
157
+ return { text: blocks.text, calls: [], truncated: blocks.truncated };
158
+ const calls = [];
159
+ for (const inner of blocks.inners) {
160
+ const parsed = parseDsmlInvokes(inner, toolNameAliases);
161
+ if (parsed === undefined)
162
+ return { text: blocks.text, calls: [], truncated: true };
163
+ calls.push(...parsed);
164
+ }
165
+ return { text: blocks.text, calls, truncated: false };
166
+ }
167
+ function takeDsmlBlocks(text) {
168
+ const inners = [];
169
+ let cursor = 0;
170
+ let cleaned = '';
171
+ let changed = false;
172
+ while (cursor < text.length) {
173
+ const open = nextDsmlMatch(DSML_BLOCK_OPEN, text, cursor);
174
+ if (open === undefined)
175
+ break;
176
+ const close = nextDsmlMatch(DSML_BLOCK_CLOSE, text, open.end);
177
+ const nested = nextDsmlMatch(DSML_BLOCK_OPEN, text, open.end);
178
+ cleaned += text.slice(cursor, open.start);
179
+ changed = true;
180
+ if (close === undefined || (nested !== undefined && nested.start < close.start)) {
181
+ return { text: cleaned.trim(), inners: [], truncated: true, changed: true };
182
+ }
183
+ inners.push(text.slice(open.end, close.start));
184
+ cursor = close.end;
185
+ }
186
+ if (!changed)
187
+ return { text, inners, truncated: false, changed: false };
188
+ cleaned += text.slice(cursor);
189
+ return { text: cleaned.trim(), inners, truncated: false, changed: true };
190
+ }
191
+ function parseDsmlInvokes(inner, toolNameAliases) {
192
+ const opened = countDsmlTags(DSML_INVOKE_OPEN, inner);
193
+ if (opened !== countDsmlTags(DSML_INVOKE_CLOSE, inner))
194
+ return undefined;
195
+ const calls = [];
196
+ const invoke = new RegExp(DSML_INVOKE_PATTERN.source, 'g');
197
+ let match;
198
+ while ((match = invoke.exec(inner)) !== null) {
199
+ const providerToolName = requiredProviderString(match[1], 'OpenAI tool name');
200
+ const parameters = parseDsmlParameters(match[2] ?? '');
201
+ if (parameters === undefined)
202
+ return undefined;
203
+ calls.push({ providerToolName, name: toolNameAliases?.get(providerToolName) ?? providerToolName, input: parameters, providerToolArguments: JSON.stringify(parameters) });
204
+ }
205
+ return calls.length === opened ? calls : undefined;
206
+ }
207
+ function parseDsmlParameters(body) {
208
+ const opened = countDsmlTags(DSML_PARAMETER_OPEN, body);
209
+ if (opened !== countDsmlTags(DSML_PARAMETER_CLOSE, body))
210
+ return undefined;
211
+ const input = {};
212
+ const parameter = new RegExp(DSML_PARAMETER_PATTERN.source, 'g');
213
+ let match;
214
+ while ((match = parameter.exec(body)) !== null) {
215
+ const name = requiredProviderString(match[1], 'OpenAI tool name');
216
+ if (match[2] === 'true')
217
+ input[name] = match[3] ?? '';
218
+ else {
219
+ const parsed = parseDsmlJson(match[3] ?? '');
220
+ if (parsed === undefined)
221
+ return undefined;
222
+ input[name] = parsed;
223
+ }
224
+ }
225
+ return Object.keys(input).length === opened ? input : undefined;
226
+ }
227
+ function parseDsmlJson(value) {
228
+ try {
229
+ return JSON.parse(value);
230
+ }
231
+ catch {
232
+ return undefined;
233
+ }
234
+ }
235
+ /** DeepSeek sometimes writes only JSON tool objects instead of tool_calls. Each object needs an explicit tool name; argument-shaped JSON and trailing prose stay text. */
236
+ function recoverDeepSeekJsonToolCalls(text, toolNameAliases) {
237
+ const leading = takeLeadingJsonValues(text.trim());
238
+ if (leading === undefined || leading.values.length === 0 || leading.rest.trim().length > 0)
239
+ return undefined;
240
+ const objects = leading.values.filter((value) => !isEmptyJsonValue(value));
241
+ if (objects.length === 0)
242
+ return undefined;
243
+ if (objects.length === 1 && objects[0] && typeof objects[0] === 'object' && !Array.isArray(objects[0])) {
244
+ const batch = objects[0].tool_calls ?? objects[0].toolCalls;
245
+ if (Array.isArray(batch)) {
246
+ const calls = batch.map((item) => jsonToolCall(item, toolNameAliases));
247
+ return calls.every((call) => call !== undefined) && calls.length > 0 ? calls : undefined;
248
+ }
249
+ }
250
+ const calls = objects.map((value) => jsonToolCall(value, toolNameAliases));
251
+ return calls.every((call) => call !== undefined) ? calls : undefined;
252
+ }
253
+ function isEmptyJsonValue(value) {
254
+ if (Array.isArray(value))
255
+ return value.length === 0;
256
+ return value !== null && typeof value === 'object' && Object.keys(value).length === 0;
257
+ }
258
+ function jsonToolCall(value, toolNameAliases) {
259
+ if (!value || typeof value !== 'object' || Array.isArray(value))
260
+ return undefined;
261
+ const record = value;
262
+ const named = typeof record.tool === 'string' && record.tool.length > 0 ? record.tool : typeof record.name === 'string' && record.name.length > 0 ? record.name : undefined;
263
+ const providerToolName = named;
264
+ if (providerToolName === undefined)
265
+ return undefined;
266
+ const raw = record.arguments ?? record.input ?? record.parameters;
267
+ const input = raw && typeof raw === 'object' && !Array.isArray(raw) ? { ...raw } : { ...record };
268
+ if (raw === undefined) {
269
+ delete input.tool;
270
+ delete input.name;
271
+ }
272
+ return { providerToolName, name: toolNameAliases?.get(providerToolName) ?? providerToolName, input, providerToolArguments: JSON.stringify(input) };
273
+ }
274
+ function takeLeadingJsonValues(text) {
275
+ if (!text.startsWith('{') && !text.startsWith('['))
276
+ return undefined;
277
+ const values = [];
278
+ let cursor = 0;
279
+ while (cursor < text.length) {
280
+ while (cursor < text.length && /\s/.test(text[cursor]))
281
+ cursor++;
282
+ if (cursor >= text.length)
283
+ break;
284
+ if (text[cursor] !== '{' && text[cursor] !== '[')
285
+ break;
286
+ const end = jsonValueEnd(text, cursor);
287
+ if (end === undefined)
288
+ return undefined;
289
+ try {
290
+ values.push(JSON.parse(text.slice(cursor, end)));
291
+ }
292
+ catch {
293
+ return undefined;
294
+ }
295
+ cursor = end;
296
+ }
297
+ return values.length > 0 ? { values, rest: text.slice(cursor) } : undefined;
298
+ }
299
+ function jsonValueEnd(text, start) {
300
+ const opening = text[start];
301
+ if (opening !== '{' && opening !== '[')
302
+ return undefined;
303
+ const stack = [];
304
+ let inString = false;
305
+ let escaped = false;
306
+ for (let index = start; index < text.length; index++) {
307
+ const char = text[index];
308
+ if (inString) {
309
+ if (escaped)
310
+ escaped = false;
311
+ else if (char === '\\')
312
+ escaped = true;
313
+ else if (char === '"')
314
+ inString = false;
315
+ continue;
316
+ }
317
+ if (char === '"') {
318
+ inString = true;
319
+ continue;
320
+ }
321
+ if (char === '{' || char === '[')
322
+ stack.push(char === '{' ? '}' : ']');
323
+ else if (char === '}' || char === ']') {
324
+ if (stack.pop() !== char)
325
+ return undefined;
326
+ if (stack.length === 0)
327
+ return index + 1;
328
+ }
329
+ }
330
+ return undefined;
331
+ }
332
+ function countDsmlTags(pattern, value) {
333
+ return value.match(new RegExp(pattern.source, 'g'))?.length ?? 0;
334
+ }
335
+ function nextDsmlMatch(pattern, value, from) {
336
+ const match = new RegExp(pattern.source, 'g').exec(value.slice(from));
337
+ return match === null ? undefined : { start: from + match.index, end: from + match.index + match[0].length };
338
+ }
339
+ const DSML_BLOCK_OPEN = /<\uFF5C+DSML\uFF5C+\s*(?:tool_calls|calls)>/;
340
+ const DSML_BLOCK_CLOSE = /<\/\uFF5C+DSML\uFF5C+\s*(?:tool_calls|calls)>/;
341
+ const DSML_INVOKE_OPEN = /<\uFF5C+DSML\uFF5C+\s*invoke\b/;
342
+ const DSML_INVOKE_CLOSE = /<\/\uFF5C+DSML\uFF5C+\s*invoke>/;
343
+ const DSML_INVOKE_PATTERN = /<\uFF5C+DSML\uFF5C+\s*invoke\s+name="([^"]+)"\s*\/?>([\s\S]*?)<\/\uFF5C+DSML\uFF5C+\s*invoke>/;
344
+ const DSML_PARAMETER_OPEN = /<\uFF5C+DSML\uFF5C+\s*parameter\b/;
345
+ const DSML_PARAMETER_CLOSE = /<\/\uFF5C+DSML\uFF5C+\s*parameter>/;
346
+ const DSML_PARAMETER_PATTERN = /<\uFF5C+DSML\uFF5C+\s*parameter\s+name="([^"]+)"\s+string="(true|false)"\s*>([\s\S]*?)<\/\uFF5C+DSML\uFF5C+\s*parameter>/;
130
347
  export function normalizeAnthropicResponse(response) {
131
348
  const root = providerRecord(response, 'Anthropic response');
132
349
  if (!Array.isArray(root.content))
@@ -5,7 +5,7 @@ export declare class OpenAICompatibleAdapter implements ProviderAdapter {
5
5
  private readonly config;
6
6
  readonly name = "OpenAI Compatible";
7
7
  private readonly baseURL;
8
- private reasoningUnsupported;
8
+ private readonly probe;
9
9
  constructor(id: string, config: ProviderPresetConfig);
10
10
  executeAttempt(params: {
11
11
  request: LLMRequestProjection;
@@ -1,18 +1,11 @@
1
1
  import { consumeProviderSse, normalizeOpenAIResponse, parseProviderJson, providerHttpErrorFromResponse, providerNetworkError } from './normalize.js';
2
- function reasoningParameterRejected(cause) {
3
- if (!(cause instanceof Error))
4
- return false;
5
- const code = cause.code;
6
- if (code !== 'PROVIDER_HTTP_400')
7
- return false;
8
- return /reasoning_effort|unknown parameter|unrecognized (?:request )?argument|extra fields? not permitted/i.test(cause.message);
9
- }
2
+ import { adaptCanonicalRequest, DynamicCapabilityProbe, lowerProjectionToCanonical, parseOpenAISseEvents, resolveModelCapability, toOpenAIPayload } from './canonical/index.js';
10
3
  export class OpenAICompatibleAdapter {
11
4
  id;
12
5
  config;
13
6
  name = 'OpenAI Compatible';
14
7
  baseURL;
15
- reasoningUnsupported = false;
8
+ probe = new DynamicCapabilityProbe();
16
9
  constructor(id, config) {
17
10
  this.id = id;
18
11
  this.config = config;
@@ -20,16 +13,19 @@ export class OpenAICompatibleAdapter {
20
13
  }
21
14
  async executeAttempt(params) {
22
15
  const streaming = params.onObservation !== undefined;
23
- const { definitions: tools, aliases: toolNameAliases } = toolDefinitions(params.request);
24
- const includeReasoning = Boolean(this.config.reasoningEffort) && !this.reasoningUnsupported;
25
- const messages = toMessages(params.request, this.config.provider);
26
- const deepSeekJsonMode = params.outputSchema !== undefined && this.config.provider === 'deepseek';
27
- if (deepSeekJsonMode)
28
- messages.unshift({ role: 'system', content: `Return only a JSON object matching this schema. The application will validate the result:\n${JSON.stringify(params.outputSchema)}` });
29
- const responseFormat = params.outputSchema === undefined ? undefined : deepSeekJsonMode
30
- ? { type: 'json_object' }
31
- : { type: 'json_schema', json_schema: { name: 'pulse_output', strict: true, schema: params.outputSchema } };
32
- const body = { ...(params.model ?? this.config.defaultModel ? { model: params.model ?? this.config.defaultModel } : {}), ...((params.maxOutputTokens ?? this.config.maxOutputTokens) === undefined ? {} : { max_tokens: params.maxOutputTokens ?? this.config.maxOutputTokens }), ...(includeReasoning ? { reasoning_effort: this.config.reasoningEffort } : {}), messages, ...(tools.length ? { tools, ...(this.config.toolChoice === undefined ? {} : { tool_choice: this.config.toolChoice }) } : {}), ...(responseFormat === undefined ? {} : { response_format: responseFormat }), ...(streaming ? { stream: true, stream_options: { include_usage: true } } : {}) };
16
+ const { canonical, toolNameAliases } = lowerProjectionToCanonical({
17
+ request: params.request,
18
+ model: params.model ?? this.config.defaultModel,
19
+ outputSchema: params.outputSchema,
20
+ maxOutputTokens: params.maxOutputTokens ?? this.config.maxOutputTokens,
21
+ reasoningEffort: this.config.reasoningEffort,
22
+ toolChoice: this.config.toolChoice,
23
+ stream: streaming,
24
+ });
25
+ const capability = resolveModelCapability(canonical.model, this.config.provider);
26
+ const { request: adapted } = adaptCanonicalRequest(canonical, capability, this.probe, toolNameAliases);
27
+ const sentReasoning = Boolean(adapted.reasoning?.effort);
28
+ const body = toOpenAIPayload(adapted, capability, this.config.provider);
33
29
  try {
34
30
  const response = await fetch(`${this.baseURL.replace(/\/$/, '')}/chat/completions`, { method: 'POST', signal: params.signal, headers: { 'content-type': 'application/json', ...(this.config.apiKey ? { authorization: `Bearer ${this.config.apiKey}` } : {}), ...(this.config.extraHeaders ?? {}) }, body: JSON.stringify(body) });
35
31
  if (!response.ok)
@@ -37,11 +33,13 @@ export class OpenAICompatibleAdapter {
37
33
  if (!streaming || !response.headers.get('content-type')?.includes('text/event-stream'))
38
34
  return normalizeOpenAIResponse(await parseProviderJson(response), toolNameAliases, this.config.provider);
39
35
  const events = await consumeProviderSse(response, (event) => {
40
- const delta = event.data?.choices?.[0]?.delta;
41
- if (typeof delta?.content === 'string')
42
- params.onObservation?.(delta.content);
43
- if (typeof delta?.refusal === 'string')
44
- params.onObservation?.(delta.refusal);
36
+ const streamEvents = parseOpenAISseEvents(event.data);
37
+ for (const ev of streamEvents) {
38
+ if (ev.type === 'text_delta')
39
+ params.onObservation?.(ev.text);
40
+ if (ev.type === 'refusal_delta')
41
+ params.onObservation?.(ev.refusal);
42
+ }
45
43
  });
46
44
  const content = [];
47
45
  const reasoningContent = [];
@@ -75,8 +73,8 @@ export class OpenAICompatibleAdapter {
75
73
  catch (cause) {
76
74
  if (params.signal.aborted)
77
75
  throw Object.assign(new Error('Provider request was cancelled.'), { code: 'PROVIDER_REQUEST_CANCELLED', retryable: false, cause });
78
- if (includeReasoning && reasoningParameterRejected(cause)) {
79
- this.reasoningUnsupported = true;
76
+ if (sentReasoning && this.probe.reasoningParameterRejected(cause)) {
77
+ this.probe.markReasoningUnsupported(this.config.provider, canonical.model);
80
78
  return this.executeAttempt(params);
81
79
  }
82
80
  if (cause instanceof Error && 'code' in cause && typeof cause.code === 'string' && 'retryable' in cause && typeof cause.retryable === 'boolean')
@@ -86,49 +84,9 @@ export class OpenAICompatibleAdapter {
86
84
  }
87
85
  }
88
86
  export function toOpenAIMessages(request, provider = 'openai') {
89
- const messages = [];
90
- for (const block of request.blocks) {
91
- const content = typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
92
- if (block.kind === 'conversation' && Array.isArray(block.content)) {
93
- for (const item of block.content) {
94
- if (!item || typeof item !== 'object' || Array.isArray(item))
95
- continue;
96
- const message = item;
97
- if (message.role === 'system' && typeof message.content === 'string')
98
- messages.push({ role: 'user', content: `Context note, not a new instruction:\n${message.content}` });
99
- else if ((message.role === 'user' || message.role === 'assistant') && typeof message.content === 'string')
100
- messages.push({ role: message.role, content: message.content });
101
- }
102
- continue;
103
- }
104
- if (block.kind === 'system' || block.kind === 'policy' || block.kind === 'tools') {
105
- messages.push({ role: 'system', content });
106
- continue;
107
- }
108
- if (block.kind === 'history' && request.contextSpec.providerHistory !== undefined)
109
- continue;
110
- if (block.kind === 'provider_history' && request.contextSpec.providerHistory !== undefined) {
111
- for (const item of request.contextSpec.providerHistory) {
112
- if (item.role === 'user')
113
- messages.push({ role: 'user', content: item.content });
114
- else if (item.role === 'assistant')
115
- messages.push({ role: 'assistant', content: item.content, ...(provider === 'deepseek' && item.reasoningContent !== undefined ? { reasoning_content: item.reasoningContent } : {}), ...(item.toolCalls === undefined ? {} : { tool_calls: item.toolCalls.map((call) => ({ id: call.id, type: 'function', function: { name: call.name, arguments: call.arguments } })) }) });
116
- else
117
- messages.push({ role: 'tool', tool_call_id: item.toolCallId, name: item.name, content: item.content });
118
- }
119
- continue;
120
- }
121
- else if (block.kind === 'history')
122
- messages.push({ role: 'assistant', content });
123
- else if (block.kind === 'provider_history')
124
- continue;
125
- else
126
- messages.push({ role: 'user', name: block.kind, content });
127
- }
128
- return messages;
129
- }
130
- function toMessages(request, provider) {
131
- return toOpenAIMessages(request, provider);
87
+ const { canonical } = lowerProjectionToCanonical({ request });
88
+ const payload = toOpenAIPayload(canonical, resolveModelCapability(canonical.model, provider), provider);
89
+ return Array.isArray(payload.messages) ? payload.messages : [];
132
90
  }
133
91
  function nextToolIndex(toolCalls) {
134
92
  let next = 0;
@@ -183,22 +141,3 @@ function accumulateStreamToolCall(toolCalls, call) {
183
141
  current.arguments += args;
184
142
  toolCalls.set(index, current);
185
143
  }
186
- function toolDefinitions(request) {
187
- const block = request.blocks.find((candidate) => candidate.kind === 'tools');
188
- const content = block?.content;
189
- const values = Array.isArray(content) ? content : content && typeof content === 'object' && !Array.isArray(content) && Array.isArray(content.tools) ? content.tools : [];
190
- const aliases = new Map();
191
- const used = new Set();
192
- const definitions = values.filter((value) => typeof value === 'object' && value !== null && !Array.isArray(value) && typeof value.name === 'string').map((value, index) => {
193
- const originalName = value.name;
194
- const baseName = originalName.replace(/[^a-zA-Z0-9_-]/g, '_') || `tool_${index + 1}`;
195
- let providerName = baseName;
196
- let suffix = 2;
197
- while (used.has(providerName))
198
- providerName = `${baseName}_${suffix++}`;
199
- used.add(providerName);
200
- aliases.set(providerName, originalName);
201
- return { type: 'function', function: { name: providerName, ...(typeof value.description === 'string' ? { description: value.description } : {}), parameters: (value.inputSchema ?? value.parameters ?? {}) } };
202
- });
203
- return { definitions, aliases };
204
- }
@@ -31,8 +31,14 @@ export class FilesystemTool {
31
31
  this.root = root;
32
32
  this.lockTimeoutMs = lockTimeoutMs;
33
33
  }
34
- safe(path) { const target = resolve(this.root, path); if (isAbsolute(path) || relative(resolve(this.root), target).startsWith('..'))
35
- throw filesystemError('PATH_OUTSIDE_SANDBOX'); return target; }
34
+ safe(path) {
35
+ const root = resolve(this.root);
36
+ const target = resolve(root, path);
37
+ const within = relative(root, target);
38
+ if (within.startsWith('..') || isAbsolute(within))
39
+ throw filesystemError('PATH_OUTSIDE_SANDBOX');
40
+ return target;
41
+ }
36
42
  async existing(path) {
37
43
  const target = this.safe(path);
38
44
  const [root, resolved] = await Promise.all([realpath(this.root), realpath(target)]);
@@ -122,8 +128,11 @@ export class FilesystemTool {
122
128
  await handle.close();
123
129
  }
124
130
  }
125
- async list(path = '.', signal) { if (signal?.aborted)
126
- throw filesystemError('ABORTED'); return readdir(await this.existing(path)); }
131
+ async list(path = '.', signal) {
132
+ if (signal?.aborted)
133
+ throw filesystemError('ABORTED');
134
+ return readdir(await this.existing(path));
135
+ }
127
136
  async write(path, content, signal) { if (signal?.aborted)
128
137
  throw filesystemError('ABORTED'); await writeFile(await this.writable(path), content, 'utf8'); }
129
138
  async move(source, destination, expectedHash, signal) {