@hunterzhu/pulse-adapters 0.4.2 → 0.4.4
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/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/providers/canonical/capabilities.d.ts +19 -0
- package/dist/providers/canonical/capabilities.js +164 -0
- package/dist/providers/canonical/index.d.ts +6 -0
- package/dist/providers/canonical/index.js +6 -0
- package/dist/providers/canonical/lowering.d.ts +17 -0
- package/dist/providers/canonical/lowering.js +122 -0
- package/dist/providers/canonical/mappers/anthropic.d.ts +6 -0
- package/dist/providers/canonical/mappers/anthropic.js +153 -0
- package/dist/providers/canonical/mappers/openai.d.ts +3 -0
- package/dist/providers/canonical/mappers/openai.js +140 -0
- package/dist/providers/canonical/polyfill.d.ts +10 -0
- package/dist/providers/canonical/polyfill.js +98 -0
- package/dist/providers/canonical/types.d.ts +109 -0
- package/dist/providers/canonical/types.js +1 -0
- package/dist/providers/normalize.js +66 -8
- package/dist/providers/openai-compat.d.ts +1 -1
- package/dist/providers/openai-compat.js +27 -88
- package/dist/providers/runtime-executor.js +52 -3
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ModelCapabilityProfile } from './types.js';
|
|
2
|
+
export declare const DEFAULT_OPENAI_PROFILE: ModelCapabilityProfile;
|
|
3
|
+
export declare const OPENAI_REASONING_PROFILE: ModelCapabilityProfile;
|
|
4
|
+
export declare const ANTHROPIC_THINKING_PROFILE: ModelCapabilityProfile;
|
|
5
|
+
export declare const DEFAULT_ANTHROPIC_PROFILE: ModelCapabilityProfile;
|
|
6
|
+
export declare const DEEPSEEK_PROFILE: ModelCapabilityProfile;
|
|
7
|
+
export declare const DEFAULT_COMPATIBLE_PROFILE: ModelCapabilityProfile;
|
|
8
|
+
declare class CapabilityRegistry {
|
|
9
|
+
private readonly customProfiles;
|
|
10
|
+
registerProfile(pattern: string, profile: Partial<ModelCapabilityProfile>): void;
|
|
11
|
+
unregisterProfile(pattern: string): void;
|
|
12
|
+
resolve(model?: string, provider?: string, override?: Partial<ModelCapabilityProfile>): ModelCapabilityProfile;
|
|
13
|
+
private matchCustom;
|
|
14
|
+
private resolveBase;
|
|
15
|
+
private mergeProfile;
|
|
16
|
+
}
|
|
17
|
+
export declare const capabilityRegistry: CapabilityRegistry;
|
|
18
|
+
export declare function resolveModelCapability(model?: string, provider?: string, override?: Partial<ModelCapabilityProfile>): ModelCapabilityProfile;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
export const DEFAULT_OPENAI_PROFILE = {
|
|
2
|
+
id: 'openai-default',
|
|
3
|
+
provider: 'openai',
|
|
4
|
+
structuredOutput: 'native_strict',
|
|
5
|
+
reasoning: 'effort_param',
|
|
6
|
+
constraints: {
|
|
7
|
+
disallowTemperatureWithReasoning: false,
|
|
8
|
+
maxTokensParamName: 'max_tokens',
|
|
9
|
+
supportsSystemMessage: true,
|
|
10
|
+
supportsParallelTools: true,
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
export const OPENAI_REASONING_PROFILE = {
|
|
14
|
+
id: 'openai-reasoning',
|
|
15
|
+
provider: 'openai',
|
|
16
|
+
structuredOutput: 'native_strict',
|
|
17
|
+
reasoning: 'effort_param',
|
|
18
|
+
constraints: {
|
|
19
|
+
disallowTemperatureWithReasoning: true,
|
|
20
|
+
defaultTemperatureWithReasoning: 1.0,
|
|
21
|
+
maxTokensParamName: 'max_completion_tokens',
|
|
22
|
+
supportsSystemMessage: true,
|
|
23
|
+
supportsParallelTools: true,
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
export const ANTHROPIC_THINKING_PROFILE = {
|
|
27
|
+
id: 'anthropic-thinking',
|
|
28
|
+
provider: 'anthropic',
|
|
29
|
+
structuredOutput: 'native_loose',
|
|
30
|
+
reasoning: 'budget_tokens',
|
|
31
|
+
constraints: {
|
|
32
|
+
disallowTemperatureWithReasoning: true,
|
|
33
|
+
defaultTemperatureWithReasoning: 1.0,
|
|
34
|
+
maxTokensParamName: 'max_tokens',
|
|
35
|
+
supportsSystemMessage: true,
|
|
36
|
+
supportsParallelTools: true,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
export const DEFAULT_ANTHROPIC_PROFILE = {
|
|
40
|
+
id: 'anthropic-default',
|
|
41
|
+
provider: 'anthropic',
|
|
42
|
+
structuredOutput: 'native_loose',
|
|
43
|
+
reasoning: 'unsupported',
|
|
44
|
+
constraints: {
|
|
45
|
+
disallowTemperatureWithReasoning: false,
|
|
46
|
+
maxTokensParamName: 'max_tokens',
|
|
47
|
+
supportsSystemMessage: true,
|
|
48
|
+
supportsParallelTools: true,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
export const DEEPSEEK_PROFILE = {
|
|
52
|
+
id: 'deepseek-default',
|
|
53
|
+
provider: 'deepseek',
|
|
54
|
+
structuredOutput: 'json_object_only',
|
|
55
|
+
reasoning: 'effort_param',
|
|
56
|
+
constraints: {
|
|
57
|
+
disallowTemperatureWithReasoning: false,
|
|
58
|
+
maxTokensParamName: 'max_tokens',
|
|
59
|
+
supportsSystemMessage: true,
|
|
60
|
+
supportsParallelTools: true,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
export const DEFAULT_COMPATIBLE_PROFILE = {
|
|
64
|
+
id: 'openai-compatible-default',
|
|
65
|
+
provider: 'openai-compatible',
|
|
66
|
+
structuredOutput: 'native_strict',
|
|
67
|
+
reasoning: 'effort_param',
|
|
68
|
+
constraints: {
|
|
69
|
+
disallowTemperatureWithReasoning: false,
|
|
70
|
+
maxTokensParamName: 'max_tokens',
|
|
71
|
+
supportsSystemMessage: true,
|
|
72
|
+
supportsParallelTools: true,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
class CapabilityRegistry {
|
|
76
|
+
customProfiles = new Map();
|
|
77
|
+
registerProfile(pattern, profile) {
|
|
78
|
+
const key = pattern.trim().toLowerCase();
|
|
79
|
+
if (!key)
|
|
80
|
+
return;
|
|
81
|
+
this.customProfiles.set(key, profile);
|
|
82
|
+
}
|
|
83
|
+
unregisterProfile(pattern) {
|
|
84
|
+
this.customProfiles.delete(pattern.trim().toLowerCase());
|
|
85
|
+
}
|
|
86
|
+
resolve(model, provider, override) {
|
|
87
|
+
const normProvider = (provider ?? 'openai').toLowerCase();
|
|
88
|
+
const normModel = (model ?? '').toLowerCase();
|
|
89
|
+
const custom = this.matchCustom(normProvider, normModel);
|
|
90
|
+
const base = this.resolveBase(normModel, normProvider);
|
|
91
|
+
return this.mergeProfile(base, custom, override);
|
|
92
|
+
}
|
|
93
|
+
matchCustom(normProvider, normModel) {
|
|
94
|
+
let best;
|
|
95
|
+
for (const [pattern, profile] of this.customProfiles) {
|
|
96
|
+
if (!profileMatches(normProvider, normModel, pattern))
|
|
97
|
+
continue;
|
|
98
|
+
if (!best || pattern.length > best.length)
|
|
99
|
+
best = { length: pattern.length, profile };
|
|
100
|
+
}
|
|
101
|
+
return best?.profile;
|
|
102
|
+
}
|
|
103
|
+
resolveBase(normModel, normProvider) {
|
|
104
|
+
if (normProvider === 'anthropic') {
|
|
105
|
+
if (normModel.includes('claude-3-7') || normModel.includes('thinking')) {
|
|
106
|
+
return { ...ANTHROPIC_THINKING_PROFILE, provider: normProvider, id: `anthropic:${normModel}` };
|
|
107
|
+
}
|
|
108
|
+
return { ...DEFAULT_ANTHROPIC_PROFILE, provider: normProvider, id: `anthropic:${normModel || 'default'}` };
|
|
109
|
+
}
|
|
110
|
+
// DeepSeek json_object mode follows the protocol id. A model name that
|
|
111
|
+
// merely contains "deepseek" keeps the caller's structured-output contract.
|
|
112
|
+
if (normProvider === 'deepseek') {
|
|
113
|
+
return { ...DEEPSEEK_PROFILE, provider: normProvider, id: `deepseek:${normModel || 'default'}` };
|
|
114
|
+
}
|
|
115
|
+
if (normProvider === 'openai' || normProvider === 'openai-compatible') {
|
|
116
|
+
if (isOpenAIReasoningModel(normModel)) {
|
|
117
|
+
return { ...OPENAI_REASONING_PROFILE, provider: normProvider, id: `${normProvider}:${normModel}` };
|
|
118
|
+
}
|
|
119
|
+
return { ...DEFAULT_OPENAI_PROFILE, provider: normProvider, id: `${normProvider}:${normModel || 'default'}` };
|
|
120
|
+
}
|
|
121
|
+
return { ...DEFAULT_COMPATIBLE_PROFILE, provider: normProvider, id: `${normProvider}:${normModel || 'default'}` };
|
|
122
|
+
}
|
|
123
|
+
mergeProfile(base, ...overrides) {
|
|
124
|
+
let result = { ...base, constraints: { ...base.constraints } };
|
|
125
|
+
for (const over of overrides) {
|
|
126
|
+
if (!over)
|
|
127
|
+
continue;
|
|
128
|
+
result = {
|
|
129
|
+
...result,
|
|
130
|
+
...over,
|
|
131
|
+
constraints: {
|
|
132
|
+
...result.constraints,
|
|
133
|
+
...(over.constraints ?? {}),
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
export const capabilityRegistry = new CapabilityRegistry();
|
|
141
|
+
export function resolveModelCapability(model, provider, override) {
|
|
142
|
+
return capabilityRegistry.resolve(model, provider, override);
|
|
143
|
+
}
|
|
144
|
+
/** o1/o3 and their suffixed ids. `o10` and `gpt-4o` do not match. */
|
|
145
|
+
function isOpenAIReasoningModel(model) {
|
|
146
|
+
return /^o[13](?:$|[-_.])/.test(model);
|
|
147
|
+
}
|
|
148
|
+
function profileMatches(provider, model, pattern) {
|
|
149
|
+
if (!pattern)
|
|
150
|
+
return false;
|
|
151
|
+
if (pattern === `${provider}:${model}`)
|
|
152
|
+
return true;
|
|
153
|
+
return modelMatchesPattern(model, pattern);
|
|
154
|
+
}
|
|
155
|
+
function modelMatchesPattern(model, pattern) {
|
|
156
|
+
if (!pattern || !model)
|
|
157
|
+
return false;
|
|
158
|
+
if (model === pattern)
|
|
159
|
+
return true;
|
|
160
|
+
if (!model.startsWith(pattern))
|
|
161
|
+
return false;
|
|
162
|
+
const next = model.charAt(pattern.length);
|
|
163
|
+
return next === '-' || next === '_' || next === '.' || next === '/' || next === ':';
|
|
164
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { JsonValue, LLMRequestProjection } from '@hunterzhu/pulse-runtime';
|
|
2
|
+
import type { ProviderToolChoice } from '../types.js';
|
|
3
|
+
import type { CanonicalLLMRequest } from './types.js';
|
|
4
|
+
export interface LoweringParams {
|
|
5
|
+
request: LLMRequestProjection;
|
|
6
|
+
model?: string | undefined;
|
|
7
|
+
outputSchema?: JsonValue | undefined;
|
|
8
|
+
maxOutputTokens?: number | undefined;
|
|
9
|
+
reasoningEffort?: 'low' | 'medium' | 'high' | undefined;
|
|
10
|
+
toolChoice?: ProviderToolChoice | undefined;
|
|
11
|
+
stream?: boolean | undefined;
|
|
12
|
+
}
|
|
13
|
+
export interface LoweringResult {
|
|
14
|
+
canonical: CanonicalLLMRequest;
|
|
15
|
+
toolNameAliases: ReadonlyMap<string, string>;
|
|
16
|
+
}
|
|
17
|
+
export declare function lowerProjectionToCanonical(params: LoweringParams): LoweringResult;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
export function lowerProjectionToCanonical(params) {
|
|
2
|
+
const { request } = params;
|
|
3
|
+
const { definitions: tools, aliases: toolNameAliases } = extractCanonicalTools(request);
|
|
4
|
+
const messages = extractCanonicalMessages(request);
|
|
5
|
+
const canonical = {
|
|
6
|
+
...(params.model ? { model: params.model } : {}),
|
|
7
|
+
messages,
|
|
8
|
+
...(tools.length ? { tools } : {}),
|
|
9
|
+
...(params.toolChoice !== undefined ? { toolChoice: params.toolChoice } : {}),
|
|
10
|
+
...(params.maxOutputTokens !== undefined ? { maxTokens: params.maxOutputTokens } : {}),
|
|
11
|
+
...(params.reasoningEffort ? { reasoning: { effort: params.reasoningEffort, enabled: true } } : {}),
|
|
12
|
+
...(params.outputSchema !== undefined
|
|
13
|
+
? {
|
|
14
|
+
structuredOutput: {
|
|
15
|
+
name: 'pulse_output',
|
|
16
|
+
schema: params.outputSchema,
|
|
17
|
+
strict: true,
|
|
18
|
+
mode: 'json_schema',
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
: {}),
|
|
22
|
+
...(params.stream ? { stream: true } : {}),
|
|
23
|
+
};
|
|
24
|
+
return { canonical, toolNameAliases };
|
|
25
|
+
}
|
|
26
|
+
function extractCanonicalMessages(request) {
|
|
27
|
+
const messages = [];
|
|
28
|
+
for (const block of request.blocks) {
|
|
29
|
+
const content = typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
|
|
30
|
+
if (block.kind === 'conversation' && Array.isArray(block.content)) {
|
|
31
|
+
for (const item of block.content) {
|
|
32
|
+
if (!item || typeof item !== 'object' || Array.isArray(item))
|
|
33
|
+
continue;
|
|
34
|
+
const message = item;
|
|
35
|
+
if (message.role === 'system' && typeof message.content === 'string') {
|
|
36
|
+
messages.push({ role: 'user', content: `Context note, not a new instruction:\n${message.content}` });
|
|
37
|
+
}
|
|
38
|
+
else if ((message.role === 'user' || message.role === 'assistant') && typeof message.content === 'string') {
|
|
39
|
+
messages.push({ role: message.role, content: message.content });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (block.kind === 'system' || block.kind === 'policy' || block.kind === 'tools') {
|
|
45
|
+
messages.push({ role: 'system', content });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (block.kind === 'history' && request.contextSpec.providerHistory !== undefined)
|
|
49
|
+
continue;
|
|
50
|
+
if (block.kind === 'provider_history' && request.contextSpec.providerHistory !== undefined) {
|
|
51
|
+
for (const item of request.contextSpec.providerHistory) {
|
|
52
|
+
if (item.role === 'user') {
|
|
53
|
+
messages.push({ role: 'user', content: item.content });
|
|
54
|
+
}
|
|
55
|
+
else if (item.role === 'assistant') {
|
|
56
|
+
messages.push({
|
|
57
|
+
role: 'assistant',
|
|
58
|
+
content: item.content ?? null,
|
|
59
|
+
...(item.reasoningContent !== undefined ? { reasoningContent: item.reasoningContent } : {}),
|
|
60
|
+
...(item.toolCalls === undefined
|
|
61
|
+
? {}
|
|
62
|
+
: {
|
|
63
|
+
toolCalls: item.toolCalls.map((call) => ({
|
|
64
|
+
id: call.id,
|
|
65
|
+
name: call.name,
|
|
66
|
+
arguments: call.arguments,
|
|
67
|
+
})),
|
|
68
|
+
}),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
messages.push({
|
|
73
|
+
role: 'tool',
|
|
74
|
+
toolCallId: item.toolCallId,
|
|
75
|
+
name: item.name,
|
|
76
|
+
content: item.content,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
else if (block.kind === 'history') {
|
|
83
|
+
messages.push({ role: 'assistant', content });
|
|
84
|
+
}
|
|
85
|
+
else if (block.kind === 'provider_history') {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
messages.push({ role: 'user', name: block.kind, content });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return messages;
|
|
93
|
+
}
|
|
94
|
+
function extractCanonicalTools(request) {
|
|
95
|
+
const block = request.blocks.find((candidate) => candidate.kind === 'tools');
|
|
96
|
+
const content = block?.content;
|
|
97
|
+
const values = Array.isArray(content)
|
|
98
|
+
? content
|
|
99
|
+
: content && typeof content === 'object' && !Array.isArray(content) && Array.isArray(content.tools)
|
|
100
|
+
? content.tools
|
|
101
|
+
: [];
|
|
102
|
+
const aliases = new Map();
|
|
103
|
+
const used = new Set();
|
|
104
|
+
const definitions = values
|
|
105
|
+
.filter((value) => typeof value === 'object' && value !== null && !Array.isArray(value) && typeof value.name === 'string')
|
|
106
|
+
.map((value, index) => {
|
|
107
|
+
const originalName = value.name;
|
|
108
|
+
const baseName = originalName.replace(/[^a-zA-Z0-9_-]/g, '_') || `tool_${index + 1}`;
|
|
109
|
+
let providerName = baseName;
|
|
110
|
+
let suffix = 2;
|
|
111
|
+
while (used.has(providerName))
|
|
112
|
+
providerName = `${baseName}_${suffix++}`;
|
|
113
|
+
used.add(providerName);
|
|
114
|
+
aliases.set(providerName, originalName);
|
|
115
|
+
return {
|
|
116
|
+
name: providerName,
|
|
117
|
+
...(typeof value.description === 'string' ? { description: value.description } : {}),
|
|
118
|
+
parameters: (value.inputSchema ?? value.parameters ?? {}),
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
return { definitions, aliases };
|
|
122
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CanonicalLLMRequest, CanonicalStreamEvent, ModelCapabilityProfile } from '../types.js';
|
|
2
|
+
export declare function toAnthropicPayload(canonical: CanonicalLLMRequest, _capability: ModelCapabilityProfile): Record<string, unknown>;
|
|
3
|
+
export declare function parseAnthropicSseEvents(event: {
|
|
4
|
+
event?: string;
|
|
5
|
+
data: any;
|
|
6
|
+
}): CanonicalStreamEvent[];
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
export function toAnthropicPayload(canonical, _capability) {
|
|
2
|
+
const systemParts = [];
|
|
3
|
+
const messages = [];
|
|
4
|
+
const appendBlocks = (role, blocks) => {
|
|
5
|
+
if (blocks.length === 0)
|
|
6
|
+
return;
|
|
7
|
+
const previous = messages.at(-1);
|
|
8
|
+
if (previous?.role === role)
|
|
9
|
+
previous.content.push(...blocks);
|
|
10
|
+
else
|
|
11
|
+
messages.push({ role, content: [...blocks] });
|
|
12
|
+
};
|
|
13
|
+
for (const msg of canonical.messages) {
|
|
14
|
+
if (msg.role === 'system') {
|
|
15
|
+
const text = textFromContent(msg.content);
|
|
16
|
+
if (text !== undefined)
|
|
17
|
+
systemParts.push(text);
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (msg.role === 'tool') {
|
|
21
|
+
appendBlocks('user', [{ type: 'tool_result', tool_use_id: msg.toolCallId ?? '', content: textFromContent(msg.content) ?? '' }]);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (msg.role === 'assistant') {
|
|
25
|
+
const blocks = [];
|
|
26
|
+
const text = textFromContent(msg.content);
|
|
27
|
+
if (text !== undefined)
|
|
28
|
+
blocks.push({ type: 'text', text });
|
|
29
|
+
for (const call of msg.toolCalls ?? []) {
|
|
30
|
+
blocks.push({ type: 'tool_use', id: call.id, name: call.name, input: parseToolInput(call.arguments) });
|
|
31
|
+
}
|
|
32
|
+
appendBlocks('assistant', blocks);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const text = textFromContent(msg.content);
|
|
36
|
+
if (text !== undefined)
|
|
37
|
+
appendBlocks('user', [{ type: 'text', text }]);
|
|
38
|
+
}
|
|
39
|
+
const system = systemParts.join('\n');
|
|
40
|
+
const maxTokens = canonical.maxTokens ?? 4096;
|
|
41
|
+
const body = {
|
|
42
|
+
...(canonical.model ? { model: canonical.model } : {}),
|
|
43
|
+
max_tokens: maxTokens,
|
|
44
|
+
...(canonical.temperature !== undefined ? { temperature: canonical.temperature } : {}),
|
|
45
|
+
...(system.length > 0 ? { system } : {}),
|
|
46
|
+
messages,
|
|
47
|
+
};
|
|
48
|
+
// Thinking
|
|
49
|
+
if (canonical.reasoning?.budgetTokens) {
|
|
50
|
+
body.thinking = {
|
|
51
|
+
type: 'enabled',
|
|
52
|
+
budget_tokens: canonical.reasoning.budgetTokens,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
// Tools
|
|
56
|
+
if (canonical.tools && canonical.tools.length > 0) {
|
|
57
|
+
body.tools = canonical.tools.map((t) => ({
|
|
58
|
+
name: t.name,
|
|
59
|
+
...(t.description ? { description: t.description } : {}),
|
|
60
|
+
input_schema: t.parameters,
|
|
61
|
+
}));
|
|
62
|
+
const toolChoice = canonical.toolChoice === undefined ? undefined : mapAnthropicToolChoice(canonical.toolChoice);
|
|
63
|
+
if (toolChoice !== undefined)
|
|
64
|
+
body.tool_choice = toolChoice;
|
|
65
|
+
}
|
|
66
|
+
// Structured output
|
|
67
|
+
if (canonical.structuredOutput?.schema) {
|
|
68
|
+
body.output_format = {
|
|
69
|
+
type: 'json_schema',
|
|
70
|
+
schema: canonical.structuredOutput.schema,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
// Streaming
|
|
74
|
+
if (canonical.stream) {
|
|
75
|
+
body.stream = true;
|
|
76
|
+
}
|
|
77
|
+
return body;
|
|
78
|
+
}
|
|
79
|
+
function textFromContent(content) {
|
|
80
|
+
if (typeof content === 'string')
|
|
81
|
+
return content;
|
|
82
|
+
if (!Array.isArray(content))
|
|
83
|
+
return undefined;
|
|
84
|
+
const text = content.flatMap((part) => part.type === 'text' ? [part.text] : []).join('');
|
|
85
|
+
return text.length > 0 ? text : undefined;
|
|
86
|
+
}
|
|
87
|
+
function parseToolInput(argumentsText) {
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(argumentsText);
|
|
90
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
91
|
+
return parsed;
|
|
92
|
+
return { arguments: parsed };
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return { arguments: argumentsText };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function mapAnthropicToolChoice(choice) {
|
|
99
|
+
if (choice === 'auto')
|
|
100
|
+
return { type: 'auto' };
|
|
101
|
+
if (choice === 'required')
|
|
102
|
+
return { type: 'any' };
|
|
103
|
+
if (choice === 'none')
|
|
104
|
+
return undefined;
|
|
105
|
+
if (typeof choice === 'object' && 'function' in choice) {
|
|
106
|
+
return { type: 'tool', name: choice.function.name };
|
|
107
|
+
}
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
export function parseAnthropicSseEvents(event) {
|
|
111
|
+
const events = [];
|
|
112
|
+
if (!event.data || typeof event.data !== 'object')
|
|
113
|
+
return events;
|
|
114
|
+
const data = event.data;
|
|
115
|
+
if (event.event === 'content_block_delta' && data.delta && typeof data.delta === 'object') {
|
|
116
|
+
if (data.delta.type === 'text_delta' && typeof data.delta.text === 'string') {
|
|
117
|
+
events.push({ type: 'text_delta', text: data.delta.text });
|
|
118
|
+
}
|
|
119
|
+
if (data.delta.type === 'thinking_delta' && typeof data.delta.thinking === 'string') {
|
|
120
|
+
events.push({ type: 'reasoning_delta', text: data.delta.thinking });
|
|
121
|
+
}
|
|
122
|
+
if (data.delta.type === 'input_json_delta' && typeof data.delta.partial_json === 'string') {
|
|
123
|
+
events.push({
|
|
124
|
+
type: 'tool_call_delta',
|
|
125
|
+
index: Number(data.index ?? 0),
|
|
126
|
+
argumentsDelta: data.delta.partial_json,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (event.event === 'message_delta') {
|
|
131
|
+
if (typeof data.delta?.stop_reason === 'string') {
|
|
132
|
+
events.push({ type: 'finish', reason: data.delta.stop_reason });
|
|
133
|
+
}
|
|
134
|
+
if (data.usage && typeof data.usage === 'object') {
|
|
135
|
+
events.push({
|
|
136
|
+
type: 'usage',
|
|
137
|
+
usage: {
|
|
138
|
+
...(typeof data.usage.output_tokens === 'number' ? { outputTokens: data.usage.output_tokens } : {}),
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (event.event === 'message_start' && data.message?.usage && typeof data.message.usage === 'object') {
|
|
144
|
+
events.push({
|
|
145
|
+
type: 'usage',
|
|
146
|
+
usage: {
|
|
147
|
+
...(typeof data.message.usage.input_tokens === 'number' ? { inputTokens: data.message.usage.input_tokens } : {}),
|
|
148
|
+
...(typeof data.message.usage.output_tokens === 'number' ? { outputTokens: data.message.usage.output_tokens } : {}),
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return events;
|
|
153
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { CanonicalLLMRequest, CanonicalStreamEvent, ModelCapabilityProfile } from '../types.js';
|
|
2
|
+
export declare function toOpenAIPayload(canonical: CanonicalLLMRequest, capability: ModelCapabilityProfile, provider?: string): Record<string, unknown>;
|
|
3
|
+
export declare function parseOpenAISseEvents(raw: unknown): CanonicalStreamEvent[];
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
export function toOpenAIPayload(canonical, capability, provider = 'openai') {
|
|
2
|
+
const messages = canonical.messages.map((msg) => {
|
|
3
|
+
const base = {
|
|
4
|
+
role: msg.role,
|
|
5
|
+
content: msg.content === null ? null : typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
|
6
|
+
};
|
|
7
|
+
if (msg.name)
|
|
8
|
+
base.name = msg.name;
|
|
9
|
+
if (msg.toolCallId)
|
|
10
|
+
base.tool_call_id = msg.toolCallId;
|
|
11
|
+
if (msg.toolCalls && msg.toolCalls.length > 0) {
|
|
12
|
+
base.tool_calls = msg.toolCalls.map((call) => ({
|
|
13
|
+
id: call.id,
|
|
14
|
+
type: 'function',
|
|
15
|
+
function: {
|
|
16
|
+
name: call.name,
|
|
17
|
+
arguments: call.arguments,
|
|
18
|
+
},
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
if (provider === 'deepseek' && msg.reasoningContent !== undefined) {
|
|
22
|
+
base.reasoning_content = msg.reasoningContent;
|
|
23
|
+
}
|
|
24
|
+
return base;
|
|
25
|
+
});
|
|
26
|
+
const body = {
|
|
27
|
+
...(canonical.model ? { model: canonical.model } : {}),
|
|
28
|
+
messages,
|
|
29
|
+
};
|
|
30
|
+
// Token limits
|
|
31
|
+
if (canonical.maxTokens !== undefined) {
|
|
32
|
+
if (capability.constraints.maxTokensParamName === 'max_completion_tokens') {
|
|
33
|
+
body.max_completion_tokens = canonical.maxTokens;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
body.max_tokens = canonical.maxTokens;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Sampling hyperparameters
|
|
40
|
+
if (canonical.temperature !== undefined) {
|
|
41
|
+
body.temperature = canonical.temperature;
|
|
42
|
+
}
|
|
43
|
+
if (canonical.topP !== undefined) {
|
|
44
|
+
body.top_p = canonical.topP;
|
|
45
|
+
}
|
|
46
|
+
if (canonical.stop) {
|
|
47
|
+
body.stop = canonical.stop;
|
|
48
|
+
}
|
|
49
|
+
// Reasoning
|
|
50
|
+
if (canonical.reasoning?.effort) {
|
|
51
|
+
body.reasoning_effort = canonical.reasoning.effort;
|
|
52
|
+
}
|
|
53
|
+
// Tools
|
|
54
|
+
if (canonical.tools && canonical.tools.length > 0) {
|
|
55
|
+
body.tools = canonical.tools.map((tool) => ({
|
|
56
|
+
type: 'function',
|
|
57
|
+
function: {
|
|
58
|
+
name: tool.name,
|
|
59
|
+
...(tool.description ? { description: tool.description } : {}),
|
|
60
|
+
parameters: tool.parameters,
|
|
61
|
+
},
|
|
62
|
+
}));
|
|
63
|
+
if (canonical.toolChoice !== undefined) {
|
|
64
|
+
body.tool_choice = canonical.toolChoice;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Structured output
|
|
68
|
+
if (canonical.structuredOutput) {
|
|
69
|
+
if (canonical.structuredOutput.mode === 'json_schema') {
|
|
70
|
+
body.response_format = {
|
|
71
|
+
type: 'json_schema',
|
|
72
|
+
json_schema: {
|
|
73
|
+
name: canonical.structuredOutput.name ?? 'pulse_output',
|
|
74
|
+
strict: canonical.structuredOutput.strict ?? true,
|
|
75
|
+
schema: canonical.structuredOutput.schema,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
else if (canonical.structuredOutput.mode === 'json_object') {
|
|
80
|
+
body.response_format = { type: 'json_object' };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Streaming
|
|
84
|
+
if (canonical.stream) {
|
|
85
|
+
body.stream = true;
|
|
86
|
+
body.stream_options = { include_usage: true };
|
|
87
|
+
}
|
|
88
|
+
return body;
|
|
89
|
+
}
|
|
90
|
+
export function parseOpenAISseEvents(raw) {
|
|
91
|
+
const events = [];
|
|
92
|
+
if (!raw || typeof raw !== 'object')
|
|
93
|
+
return events;
|
|
94
|
+
const root = raw;
|
|
95
|
+
if (root.usage && typeof root.usage === 'object') {
|
|
96
|
+
const u = root.usage;
|
|
97
|
+
events.push({
|
|
98
|
+
type: 'usage',
|
|
99
|
+
usage: {
|
|
100
|
+
...(typeof u.prompt_tokens === 'number' ? { inputTokens: u.prompt_tokens } : {}),
|
|
101
|
+
...(typeof u.completion_tokens === 'number' ? { outputTokens: u.completion_tokens } : {}),
|
|
102
|
+
...(typeof u.completion_tokens_details?.reasoning_tokens === 'number'
|
|
103
|
+
? { reasoningTokens: u.completion_tokens_details.reasoning_tokens }
|
|
104
|
+
: {}),
|
|
105
|
+
...(typeof u.prompt_tokens_details?.cached_tokens === 'number'
|
|
106
|
+
? { cachedInputTokens: u.prompt_tokens_details.cached_tokens }
|
|
107
|
+
: {}),
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (Array.isArray(root.choices) && root.choices.length > 0) {
|
|
112
|
+
const choice = root.choices[0];
|
|
113
|
+
const delta = choice.delta;
|
|
114
|
+
if (typeof delta?.content === 'string' && delta.content.length > 0) {
|
|
115
|
+
events.push({ type: 'text_delta', text: delta.content });
|
|
116
|
+
}
|
|
117
|
+
if (typeof delta?.reasoning_content === 'string' && delta.reasoning_content.length > 0) {
|
|
118
|
+
events.push({ type: 'reasoning_delta', text: delta.reasoning_content });
|
|
119
|
+
}
|
|
120
|
+
if (typeof delta?.refusal === 'string' && delta.refusal.length > 0) {
|
|
121
|
+
events.push({ type: 'refusal_delta', refusal: delta.refusal });
|
|
122
|
+
}
|
|
123
|
+
if (Array.isArray(delta?.tool_calls)) {
|
|
124
|
+
for (const call of delta.tool_calls) {
|
|
125
|
+
const fn = call.function;
|
|
126
|
+
events.push({
|
|
127
|
+
type: 'tool_call_delta',
|
|
128
|
+
index: typeof call.index === 'number' ? call.index : 0,
|
|
129
|
+
...(typeof call.id === 'string' ? { id: call.id } : {}),
|
|
130
|
+
...(typeof fn?.name === 'string' ? { name: fn.name } : {}),
|
|
131
|
+
argumentsDelta: typeof fn?.arguments === 'string' ? fn.arguments : '',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (typeof choice.finish_reason === 'string') {
|
|
136
|
+
events.push({ type: 'finish', reason: choice.finish_reason });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return events;
|
|
140
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { AdaptedCanonicalRequest, CanonicalLLMRequest, ModelCapabilityProfile } from './types.js';
|
|
2
|
+
export declare class DynamicCapabilityProbe {
|
|
3
|
+
private readonly unsupportedReasoning;
|
|
4
|
+
private key;
|
|
5
|
+
isReasoningUnsupported(provider: string, model?: string): boolean;
|
|
6
|
+
markReasoningUnsupported(provider: string, model?: string): void;
|
|
7
|
+
reasoningParameterRejected(cause: unknown): boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare const globalProbe: DynamicCapabilityProbe;
|
|
10
|
+
export declare function adaptCanonicalRequest(original: CanonicalLLMRequest, capability: ModelCapabilityProfile, probe?: DynamicCapabilityProbe, toolNameAliases?: ReadonlyMap<string, string>): AdaptedCanonicalRequest;
|
|
@@ -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 {};
|
|
@@ -232,23 +232,81 @@ function parseDsmlJson(value) {
|
|
|
232
232
|
return undefined;
|
|
233
233
|
}
|
|
234
234
|
}
|
|
235
|
-
/** DeepSeek sometimes writes
|
|
235
|
+
/** DeepSeek sometimes writes tool calls as JSON or markdown fences instead of tool_calls. A bare object still needs an explicit tool name. Argument-shaped JSON, a single tool object after prose, and any trailing prose stay text. */
|
|
236
236
|
function recoverDeepSeekJsonToolCalls(text, toolNameAliases) {
|
|
237
|
-
const
|
|
237
|
+
const trimmed = text.trim();
|
|
238
|
+
return recoverStrictJsonToolCalls(trimmed, toolNameAliases) ?? recoverMarkdownToolFences(trimmed, toolNameAliases);
|
|
239
|
+
}
|
|
240
|
+
function recoverStrictJsonToolCalls(text, toolNameAliases) {
|
|
241
|
+
const leading = takeLeadingJsonValues(text);
|
|
238
242
|
if (leading === undefined || leading.values.length === 0 || leading.rest.trim().length > 0)
|
|
239
243
|
return undefined;
|
|
240
244
|
const objects = leading.values.filter((value) => !isEmptyJsonValue(value));
|
|
241
245
|
if (objects.length === 0)
|
|
242
246
|
return undefined;
|
|
247
|
+
if (objects.length === 1 && Array.isArray(objects[0]) && objects[0].every((item) => argumentObject(item) !== undefined))
|
|
248
|
+
return toolCallsFrom(objects[0], toolNameAliases);
|
|
243
249
|
if (objects.length === 1 && objects[0] && typeof objects[0] === 'object' && !Array.isArray(objects[0])) {
|
|
244
250
|
const batch = objects[0].tool_calls ?? objects[0].toolCalls;
|
|
245
|
-
if (Array.isArray(batch))
|
|
246
|
-
|
|
247
|
-
|
|
251
|
+
if (Array.isArray(batch))
|
|
252
|
+
return toolCallsFrom(batch, toolNameAliases);
|
|
253
|
+
}
|
|
254
|
+
return toolCallsFrom(objects, toolNameAliases);
|
|
255
|
+
}
|
|
256
|
+
/** A message that is only `tool.name` headings followed by JSON fences is a tool batch, not a finished answer. */
|
|
257
|
+
function recoverMarkdownToolFences(text, toolNameAliases) {
|
|
258
|
+
const lines = text.split(/\r?\n/);
|
|
259
|
+
const calls = [];
|
|
260
|
+
let index = 0;
|
|
261
|
+
const skipBlank = () => { while (index < lines.length && lines[index].trim() === '')
|
|
262
|
+
index++; };
|
|
263
|
+
skipBlank();
|
|
264
|
+
if (index >= lines.length)
|
|
265
|
+
return undefined;
|
|
266
|
+
while (index < lines.length) {
|
|
267
|
+
skipBlank();
|
|
268
|
+
if (index >= lines.length)
|
|
269
|
+
break;
|
|
270
|
+
const providerToolName = lines[index].trim();
|
|
271
|
+
if (!/^[A-Za-z][\w-]*(?:\.[\w-]+)+$/.test(providerToolName))
|
|
272
|
+
return undefined;
|
|
273
|
+
index++;
|
|
274
|
+
if (lines[index]?.trim() !== '```json' && lines[index]?.trim() !== '```')
|
|
275
|
+
return undefined;
|
|
276
|
+
index++;
|
|
277
|
+
const body = [];
|
|
278
|
+
while (index < lines.length && lines[index].trim() !== '```')
|
|
279
|
+
body.push(lines[index++]);
|
|
280
|
+
if (index >= lines.length)
|
|
281
|
+
return undefined;
|
|
282
|
+
index++;
|
|
283
|
+
let parsed;
|
|
284
|
+
try {
|
|
285
|
+
parsed = JSON.parse(body.join('\n'));
|
|
248
286
|
}
|
|
287
|
+
catch {
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
291
|
+
return undefined;
|
|
292
|
+
const input = parsed;
|
|
293
|
+
calls.push({ providerToolName, name: toolNameAliases?.get(providerToolName) ?? providerToolName, input, providerToolArguments: JSON.stringify(input) });
|
|
249
294
|
}
|
|
250
|
-
|
|
251
|
-
|
|
295
|
+
return calls.length > 0 ? calls : undefined;
|
|
296
|
+
}
|
|
297
|
+
function toolCallsFrom(values, toolNameAliases) {
|
|
298
|
+
const calls = values.map((value) => jsonToolCall(value, toolNameAliases));
|
|
299
|
+
return calls.every((call) => call !== undefined) && calls.length > 0 ? calls : undefined;
|
|
300
|
+
}
|
|
301
|
+
function argumentObject(value) {
|
|
302
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
303
|
+
return undefined;
|
|
304
|
+
const record = value;
|
|
305
|
+
const named = typeof record.tool === 'string' && record.tool.length > 0 ? record.tool : typeof record.name === 'string' && record.name.length > 0 ? record.name : undefined;
|
|
306
|
+
const raw = record.arguments ?? record.input ?? record.parameters ?? record.args;
|
|
307
|
+
if (named === undefined || !raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
308
|
+
return undefined;
|
|
309
|
+
return raw;
|
|
252
310
|
}
|
|
253
311
|
function isEmptyJsonValue(value) {
|
|
254
312
|
if (Array.isArray(value))
|
|
@@ -263,7 +321,7 @@ function jsonToolCall(value, toolNameAliases) {
|
|
|
263
321
|
const providerToolName = named;
|
|
264
322
|
if (providerToolName === undefined)
|
|
265
323
|
return undefined;
|
|
266
|
-
const raw = record.arguments ?? record.input ?? record.parameters;
|
|
324
|
+
const raw = record.arguments ?? record.input ?? record.parameters ?? record.args;
|
|
267
325
|
const input = raw && typeof raw === 'object' && !Array.isArray(raw) ? { ...raw } : { ...record };
|
|
268
326
|
if (raw === undefined) {
|
|
269
327
|
delete input.tool;
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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 (
|
|
79
|
-
this.
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
}
|
|
@@ -47,6 +47,53 @@ class SlotPool {
|
|
|
47
47
|
this.slots.set(key, slot);
|
|
48
48
|
} ; return slot; }
|
|
49
49
|
}
|
|
50
|
+
function jsonCandidates(text) {
|
|
51
|
+
const found = [];
|
|
52
|
+
const push = (value) => {
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(value);
|
|
55
|
+
found.push(parsed);
|
|
56
|
+
if (typeof parsed === 'string' && parsed !== value)
|
|
57
|
+
push(parsed);
|
|
58
|
+
}
|
|
59
|
+
catch { /* keep scanning */ }
|
|
60
|
+
};
|
|
61
|
+
const trimmed = text.trim();
|
|
62
|
+
if (trimmed)
|
|
63
|
+
push(trimmed);
|
|
64
|
+
for (let start = 0; start < text.length; start++) {
|
|
65
|
+
if (text[start] !== '{')
|
|
66
|
+
continue;
|
|
67
|
+
let depth = 0;
|
|
68
|
+
let inString = false;
|
|
69
|
+
let escaped = false;
|
|
70
|
+
for (let end = start; end < text.length; end++) {
|
|
71
|
+
const char = text[end];
|
|
72
|
+
if (inString) {
|
|
73
|
+
if (escaped)
|
|
74
|
+
escaped = false;
|
|
75
|
+
else if (char === '\\')
|
|
76
|
+
escaped = true;
|
|
77
|
+
else if (char === '"')
|
|
78
|
+
inString = false;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (char === '"')
|
|
82
|
+
inString = true;
|
|
83
|
+
else if (char === '{')
|
|
84
|
+
depth++;
|
|
85
|
+
else if (char === '}') {
|
|
86
|
+
depth--;
|
|
87
|
+
if (depth === 0) {
|
|
88
|
+
push(text.slice(start, end + 1));
|
|
89
|
+
start = end;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return found;
|
|
96
|
+
}
|
|
50
97
|
function toJson(value, seen = new Set()) {
|
|
51
98
|
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
|
52
99
|
return value;
|
|
@@ -172,13 +219,15 @@ export function createModelEffectExecutor(config) {
|
|
|
172
219
|
throw new OutputValidationError('adapter', 'MODEL_REFUSAL', output.refusal ?? 'Provider refused the request.');
|
|
173
220
|
}
|
|
174
221
|
if (input.outputSchema !== undefined) {
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
222
|
+
const candidates = output.structured === undefined ? jsonCandidates(output.text) : [output.structured, ...jsonCandidates(output.text)];
|
|
223
|
+
const matched = candidates.find((candidate) => validateJsonSchema(candidate, input.outputSchema));
|
|
224
|
+
if (matched === undefined) {
|
|
225
|
+
lastSchemaViolation = toJson(candidates[0] ?? output.text);
|
|
178
226
|
failedForSchema = true;
|
|
179
227
|
recordFeedback('schema_rejected', 0);
|
|
180
228
|
throw new OutputValidationError('structured', 'OUTPUT_SCHEMA_VIOLATION', 'Provider output did not match the declared schema');
|
|
181
229
|
}
|
|
230
|
+
output.structured = matched;
|
|
182
231
|
}
|
|
183
232
|
failedForSchema = false;
|
|
184
233
|
recordFeedback('succeeded', 1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hunterzhu/pulse-adapters",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"license": "PolyForm-Noncommercial-1.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@anthropic-ai/sandbox-runtime": "0.0.77",
|
|
21
|
-
"@hunterzhu/pulse-runtime": "0.4.
|
|
22
|
-
"@hunterzhu/pulse-tool-sdk": "0.4.
|
|
21
|
+
"@hunterzhu/pulse-runtime": "0.4.4",
|
|
22
|
+
"@hunterzhu/pulse-tool-sdk": "0.4.4",
|
|
23
23
|
"exceljs": "4.4.0",
|
|
24
24
|
"pdfjs-dist": "4.10.38"
|
|
25
25
|
}
|