@hunterzhu/pulse-adapters 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/providers/anthropic.d.ts +16 -0
- package/dist/providers/anthropic.js +89 -0
- package/dist/providers/factory.d.ts +2 -0
- package/dist/providers/factory.js +6 -0
- package/dist/providers/mock.d.ts +10 -0
- package/dist/providers/mock.js +12 -0
- package/dist/providers/normalize.d.ts +22 -0
- package/dist/providers/normalize.js +236 -0
- package/dist/providers/openai-compat.d.ts +22 -0
- package/dist/providers/openai-compat.js +87 -0
- package/dist/providers/runtime-executor.d.ts +9 -0
- package/dist/providers/runtime-executor.js +225 -0
- package/dist/providers/types.d.ts +28 -0
- package/dist/providers/types.js +1 -0
- package/dist/tools/filesystem.d.ts +27 -0
- package/dist/tools/filesystem.js +183 -0
- package/dist/tools/registry.d.ts +5 -0
- package/dist/tools/registry.js +141 -0
- package/dist/tools/shell.d.ts +15 -0
- package/dist/tools/shell.js +73 -0
- package/dist/workers/http.d.ts +60 -0
- package/dist/workers/http.js +402 -0
- package/package.json +22 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export * from './providers/types.js';
|
|
2
|
+
export * from './providers/normalize.js';
|
|
3
|
+
export * from './providers/mock.js';
|
|
4
|
+
export * from './providers/openai-compat.js';
|
|
5
|
+
export * from './providers/anthropic.js';
|
|
6
|
+
export * from './providers/factory.js';
|
|
7
|
+
export * from './providers/runtime-executor.js';
|
|
8
|
+
export * from './tools/filesystem.js';
|
|
9
|
+
export * from './tools/shell.js';
|
|
10
|
+
export * from './tools/registry.js';
|
|
11
|
+
export * from './workers/http.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export * from './providers/types.js';
|
|
2
|
+
export * from './providers/normalize.js';
|
|
3
|
+
export * from './providers/mock.js';
|
|
4
|
+
export * from './providers/openai-compat.js';
|
|
5
|
+
export * from './providers/anthropic.js';
|
|
6
|
+
export * from './providers/factory.js';
|
|
7
|
+
export * from './providers/runtime-executor.js';
|
|
8
|
+
export * from './tools/filesystem.js';
|
|
9
|
+
export * from './tools/shell.js';
|
|
10
|
+
export * from './tools/registry.js';
|
|
11
|
+
export * from './workers/http.js';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { JsonValue, LLMRequestProjection } from '@hunterzhu/pulse-runtime';
|
|
2
|
+
import type { ProviderAdapter, ProviderPresetConfig } from './types.js';
|
|
3
|
+
export declare class AnthropicAdapter implements ProviderAdapter {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
private readonly config;
|
|
6
|
+
readonly name = "Anthropic Messages";
|
|
7
|
+
constructor(id: string, config: ProviderPresetConfig);
|
|
8
|
+
executeAttempt(params: {
|
|
9
|
+
request: LLMRequestProjection;
|
|
10
|
+
signal: AbortSignal;
|
|
11
|
+
onObservation?: (chunk: string) => void;
|
|
12
|
+
outputSchema?: JsonValue;
|
|
13
|
+
model?: string;
|
|
14
|
+
maxOutputTokens?: number;
|
|
15
|
+
}): Promise<import("@hunterzhu/pulse-runtime").LLMResult>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { consumeProviderSse, normalizeAnthropicResponse, parseProviderJson, providerHttpError, providerNetworkError, providerResponseError } from './normalize.js';
|
|
2
|
+
export class AnthropicAdapter {
|
|
3
|
+
id;
|
|
4
|
+
config;
|
|
5
|
+
name = 'Anthropic Messages';
|
|
6
|
+
constructor(id, config) {
|
|
7
|
+
this.id = id;
|
|
8
|
+
this.config = config;
|
|
9
|
+
}
|
|
10
|
+
async executeAttempt(params) {
|
|
11
|
+
const system = params.request.blocks.filter((block) => block.kind === 'system' || block.kind === 'policy' || block.kind === 'tools').map((block) => typeof block.content === 'string' ? block.content : JSON.stringify(block.content)).join('\n');
|
|
12
|
+
const messages = [{ role: 'user', content: params.request.blocks.filter((block) => !['system', 'policy', 'tools'].includes(block.kind)).map((block) => ({ type: 'text', text: typeof block.content === 'string' ? block.content : JSON.stringify(block.content) })) }];
|
|
13
|
+
const streaming = params.onObservation !== undefined;
|
|
14
|
+
const tools = toolDefinitions(params.request);
|
|
15
|
+
const body = { ...(params.model ?? this.config.defaultModel ? { model: params.model ?? this.config.defaultModel } : {}), max_tokens: params.maxOutputTokens ?? this.config.maxOutputTokens ?? 4096, ...(system ? { system } : {}), messages, ...(tools.length ? { tools, ...(this.config.toolChoice === undefined ? {} : { tool_choice: anthropicToolChoice(this.config.toolChoice) }) } : {}), ...(params.outputSchema === undefined ? {} : { output_format: { type: 'json_schema', schema: params.outputSchema } }), ...(streaming ? { stream: true } : {}) };
|
|
16
|
+
try {
|
|
17
|
+
const response = await fetch(`${(this.config.baseURL ?? 'https://api.anthropic.com').replace(/\/$/, '')}/v1/messages`, { method: 'POST', signal: params.signal, headers: { 'content-type': 'application/json', ...(this.config.apiKey ? { 'x-api-key': this.config.apiKey } : {}), 'anthropic-version': '2023-06-01' }, body: JSON.stringify(body) });
|
|
18
|
+
if (!response.ok)
|
|
19
|
+
throw providerHttpError(response.status);
|
|
20
|
+
if (!streaming || !response.headers.get('content-type')?.includes('text/event-stream'))
|
|
21
|
+
return normalizeAnthropicResponse(await parseProviderJson(response));
|
|
22
|
+
const events = await consumeProviderSse(response);
|
|
23
|
+
const blocks = [];
|
|
24
|
+
let stopReason;
|
|
25
|
+
let usage = {};
|
|
26
|
+
for (const event of events) {
|
|
27
|
+
if (!event.data || typeof event.data !== 'object')
|
|
28
|
+
continue;
|
|
29
|
+
const data = event.data;
|
|
30
|
+
if (event.event === 'message_start' && data.message?.usage && typeof data.message.usage === 'object')
|
|
31
|
+
usage = { ...usage, ...data.message.usage };
|
|
32
|
+
if (event.event === 'content_block_start' && data.content_block && typeof data.content_block === 'object')
|
|
33
|
+
blocks[Number(data.index ?? blocks.length)] = { ...data.content_block };
|
|
34
|
+
if (event.event === 'content_block_delta' && data.delta && typeof data.delta === 'object') {
|
|
35
|
+
const index = Number(data.index ?? 0);
|
|
36
|
+
const block = blocks[index] ?? {};
|
|
37
|
+
if (data.delta.type === 'text_delta' && typeof data.delta.text === 'string') {
|
|
38
|
+
block.type = 'text';
|
|
39
|
+
block.text = `${typeof block.text === 'string' ? block.text : ''}${data.delta.text}`;
|
|
40
|
+
params.onObservation?.(data.delta.text);
|
|
41
|
+
}
|
|
42
|
+
if (data.delta.type === 'input_json_delta' && typeof data.delta.partial_json === 'string')
|
|
43
|
+
block.inputJson = `${typeof block.inputJson === 'string' ? block.inputJson : ''}${data.delta.partial_json}`;
|
|
44
|
+
blocks[index] = block;
|
|
45
|
+
}
|
|
46
|
+
if (event.event === 'message_delta') {
|
|
47
|
+
if (typeof data.delta?.stop_reason === 'string')
|
|
48
|
+
stopReason = data.delta.stop_reason;
|
|
49
|
+
if (data.usage && typeof data.usage === 'object')
|
|
50
|
+
usage = { ...usage, ...data.usage };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const content = blocks.filter(Boolean).map((block) => {
|
|
54
|
+
if (block.type === 'tool_use' && typeof block.inputJson === 'string') {
|
|
55
|
+
try {
|
|
56
|
+
return { ...block, input: JSON.parse(block.inputJson) };
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw providerResponseError('INVALID_TOOL_ARGUMENTS');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return block;
|
|
63
|
+
});
|
|
64
|
+
return normalizeAnthropicResponse({ content, stop_reason: stopReason, ...(Object.keys(usage).length ? { usage } : {}) });
|
|
65
|
+
}
|
|
66
|
+
catch (cause) {
|
|
67
|
+
if (params.signal.aborted)
|
|
68
|
+
throw Object.assign(new Error('Provider request was cancelled.'), { code: 'PROVIDER_REQUEST_CANCELLED', retryable: false, cause });
|
|
69
|
+
if (cause instanceof Error && 'code' in cause && typeof cause.code === 'string' && 'retryable' in cause && typeof cause.retryable === 'boolean')
|
|
70
|
+
throw cause;
|
|
71
|
+
throw providerNetworkError(cause);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function toolDefinitions(request) {
|
|
76
|
+
const block = request.blocks.find((candidate) => candidate.kind === 'tools');
|
|
77
|
+
const content = block?.content;
|
|
78
|
+
const values = Array.isArray(content) ? content : content && typeof content === 'object' && !Array.isArray(content) && Array.isArray(content.tools) ? content.tools : [];
|
|
79
|
+
return values.filter((value) => typeof value === 'object' && value !== null && !Array.isArray(value) && typeof value.name === 'string').map((value) => ({ name: value.name, ...(typeof value.description === 'string' ? { description: value.description } : {}), input_schema: (value.inputSchema ?? value.parameters ?? {}) }));
|
|
80
|
+
}
|
|
81
|
+
function anthropicToolChoice(choice) {
|
|
82
|
+
if (choice === 'auto')
|
|
83
|
+
return { type: 'auto' };
|
|
84
|
+
if (choice === 'required')
|
|
85
|
+
return { type: 'any' };
|
|
86
|
+
if (choice === 'none')
|
|
87
|
+
return undefined;
|
|
88
|
+
return { type: 'tool', name: choice.function.name };
|
|
89
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { AnthropicAdapter } from './anthropic.js';
|
|
2
|
+
import { OpenAICompatibleAdapter } from './openai-compat.js';
|
|
3
|
+
import { MockAdapter } from './mock.js';
|
|
4
|
+
export function createProviderAdapter(config) { if (config.provider === 'anthropic')
|
|
5
|
+
return new AnthropicAdapter(config.provider, config); if (config.provider === 'mock')
|
|
6
|
+
return new MockAdapter(); return new OpenAICompatibleAdapter(config.provider, config); }
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { LLMResult } from '@hunterzhu/pulse-runtime';
|
|
2
|
+
import type { ProviderAdapter } from './types.js';
|
|
3
|
+
export declare class MockAdapter implements ProviderAdapter {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly name = "Mock Provider";
|
|
6
|
+
private readonly queue;
|
|
7
|
+
constructor(id?: string);
|
|
8
|
+
enqueue(result: LLMResult | Error): void;
|
|
9
|
+
executeAttempt(): Promise<LLMResult>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export class MockAdapter {
|
|
2
|
+
id;
|
|
3
|
+
name = 'Mock Provider';
|
|
4
|
+
queue = [];
|
|
5
|
+
constructor(id = 'mock') {
|
|
6
|
+
this.id = id;
|
|
7
|
+
}
|
|
8
|
+
enqueue(result) { this.queue.push(result); }
|
|
9
|
+
async executeAttempt() { const next = this.queue.shift(); if (!next)
|
|
10
|
+
return { text: '', toolCalls: [], finishReason: 'stop' }; if (next instanceof Error)
|
|
11
|
+
throw next; return structuredClone(next); }
|
|
12
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { LLMResult } from '@hunterzhu/pulse-runtime';
|
|
2
|
+
export interface ProviderSseEvent {
|
|
3
|
+
event?: string;
|
|
4
|
+
data: any;
|
|
5
|
+
}
|
|
6
|
+
export declare function providerHttpError(status: number): Error & {
|
|
7
|
+
code: string;
|
|
8
|
+
retryable: boolean;
|
|
9
|
+
};
|
|
10
|
+
export declare function providerNetworkError(cause: unknown): Error & {
|
|
11
|
+
code: string;
|
|
12
|
+
retryable: boolean;
|
|
13
|
+
};
|
|
14
|
+
export declare function providerResponseError(detail: string): Error & {
|
|
15
|
+
code: string;
|
|
16
|
+
retryable: boolean;
|
|
17
|
+
};
|
|
18
|
+
export declare function parseProviderJson(response: Response): Promise<unknown>;
|
|
19
|
+
/** Read provider SSE frames without treating incomplete tool arguments as executable input. */
|
|
20
|
+
export declare function consumeProviderSse(response: Response): Promise<ProviderSseEvent[]>;
|
|
21
|
+
export declare function normalizeOpenAIResponse(response: any): LLMResult;
|
|
22
|
+
export declare function normalizeAnthropicResponse(response: any): LLMResult;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
export function providerHttpError(status) {
|
|
2
|
+
const retryable = status === 408 || status === 425 || status === 429 || status >= 500;
|
|
3
|
+
return Object.assign(new Error(`PROVIDER_HTTP_${status}`), { code: `PROVIDER_HTTP_${status}`, retryable });
|
|
4
|
+
}
|
|
5
|
+
export function providerNetworkError(cause) {
|
|
6
|
+
return Object.assign(new Error('PROVIDER_NETWORK_ERROR'), { code: 'PROVIDER_NETWORK_ERROR', retryable: true, cause });
|
|
7
|
+
}
|
|
8
|
+
export function providerResponseError(detail) {
|
|
9
|
+
return Object.assign(new Error(`PROVIDER_RESPONSE_INVALID: ${detail}`), { code: 'PROVIDER_RESPONSE_INVALID', retryable: true });
|
|
10
|
+
}
|
|
11
|
+
export async function parseProviderJson(response) {
|
|
12
|
+
try {
|
|
13
|
+
return await response.json();
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
throw providerResponseError('PROVIDER_RESPONSE_INVALID_JSON');
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Read provider SSE frames without treating incomplete tool arguments as executable input. */
|
|
20
|
+
export async function consumeProviderSse(response) {
|
|
21
|
+
if (!response.body)
|
|
22
|
+
throw providerResponseError('PROVIDER_STREAM_BODY_MISSING');
|
|
23
|
+
const reader = response.body.getReader();
|
|
24
|
+
const decoder = new TextDecoder();
|
|
25
|
+
const events = [];
|
|
26
|
+
let buffer = '';
|
|
27
|
+
let eventName;
|
|
28
|
+
let dataLines = [];
|
|
29
|
+
const flush = () => {
|
|
30
|
+
if (dataLines.length === 0) {
|
|
31
|
+
eventName = undefined;
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const raw = dataLines.join('\n');
|
|
35
|
+
dataLines = [];
|
|
36
|
+
const data = raw === '[DONE]' ? raw : (() => { try {
|
|
37
|
+
return JSON.parse(raw);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
throw providerResponseError('PROVIDER_STREAM_INVALID_JSON');
|
|
41
|
+
} })();
|
|
42
|
+
events.push({ ...(eventName === undefined ? {} : { event: eventName }), data });
|
|
43
|
+
eventName = undefined;
|
|
44
|
+
};
|
|
45
|
+
const consumeLines = (text) => {
|
|
46
|
+
buffer += text;
|
|
47
|
+
let newline = buffer.indexOf('\n');
|
|
48
|
+
while (newline >= 0) {
|
|
49
|
+
let line = buffer.slice(0, newline);
|
|
50
|
+
buffer = buffer.slice(newline + 1);
|
|
51
|
+
if (line.endsWith('\r'))
|
|
52
|
+
line = line.slice(0, -1);
|
|
53
|
+
if (line.length === 0)
|
|
54
|
+
flush();
|
|
55
|
+
else if (line.startsWith(':')) { /* SSE comment */ }
|
|
56
|
+
else if (line.startsWith('event:'))
|
|
57
|
+
eventName = line.slice('event:'.length).trim();
|
|
58
|
+
else if (line.startsWith('data:'))
|
|
59
|
+
dataLines.push(line.slice('data:'.length).trimStart());
|
|
60
|
+
newline = buffer.indexOf('\n');
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
while (true) {
|
|
64
|
+
const chunk = await reader.read();
|
|
65
|
+
if (chunk.done)
|
|
66
|
+
break;
|
|
67
|
+
consumeLines(decoder.decode(chunk.value, { stream: true }));
|
|
68
|
+
}
|
|
69
|
+
consumeLines(decoder.decode());
|
|
70
|
+
if (buffer.length > 0 || dataLines.length > 0)
|
|
71
|
+
flush();
|
|
72
|
+
return events;
|
|
73
|
+
}
|
|
74
|
+
export function normalizeOpenAIResponse(response) {
|
|
75
|
+
const root = providerRecord(response, 'OpenAI response');
|
|
76
|
+
if (!Array.isArray(root.choices) || root.choices.length === 0)
|
|
77
|
+
throw providerResponseError('OpenAI response must contain at least one choice');
|
|
78
|
+
const choice = providerRecord(root.choices[0], 'OpenAI choice');
|
|
79
|
+
const message = providerRecord(choice.message, 'OpenAI message');
|
|
80
|
+
const toolCalls = message.tool_calls === undefined ? [] : normalizeOpenAIToolCalls(message.tool_calls);
|
|
81
|
+
const refusal = message.refusal === undefined ? undefined : requiredProviderString(message.refusal, 'OpenAI refusal');
|
|
82
|
+
const text = providerText(message.content, 'OpenAI message content');
|
|
83
|
+
const finishReason = normalizeOpenAIFinishReason(choice.finish_reason, refusal, toolCalls.length > 0);
|
|
84
|
+
const rawUsage = root.usage === undefined ? undefined : providerRecord(root.usage, 'OpenAI usage');
|
|
85
|
+
const promptDetails = rawUsage?.prompt_tokens_details === undefined ? undefined : providerRecord(rawUsage.prompt_tokens_details, 'OpenAI prompt token details');
|
|
86
|
+
const usage = normalizeUsage(rawUsage === undefined ? undefined : { ...rawUsage, cached_tokens: rawUsage.cached_tokens ?? promptDetails?.cached_tokens ?? rawUsage.cache_read_input_tokens }, { input: 'prompt_tokens', output: 'completion_tokens', cached: 'cached_tokens' }, 'OpenAI');
|
|
87
|
+
return { text, ...(parseStructured(text) === undefined ? {} : { structured: parseStructured(text) }), toolCalls, ...(refusal === undefined ? {} : { refusal }), finishReason, ...(usage === undefined ? {} : { usage }) };
|
|
88
|
+
}
|
|
89
|
+
export function normalizeAnthropicResponse(response) {
|
|
90
|
+
const root = providerRecord(response, 'Anthropic response');
|
|
91
|
+
if (!Array.isArray(root.content))
|
|
92
|
+
throw providerResponseError('Anthropic response content must be an array');
|
|
93
|
+
const blocks = root.content;
|
|
94
|
+
const text = blocks.filter((block) => providerRecord(block, 'Anthropic content block').type === 'text').map((block) => requiredProviderString(providerRecord(block, 'Anthropic text block').text, 'Anthropic text block text')).join('');
|
|
95
|
+
const toolBlocks = blocks.filter((block) => providerRecord(block, 'Anthropic content block').type === 'tool_use');
|
|
96
|
+
const toolCalls = toolBlocks.map((block, index) => {
|
|
97
|
+
const value = providerRecord(block, 'Anthropic tool block');
|
|
98
|
+
return { toolCallId: `pulse-tool-${index + 1}`, name: requiredProviderString(value.name, 'Anthropic tool name'), input: parseJson(value.input) };
|
|
99
|
+
});
|
|
100
|
+
const refusalBlock = blocks.find((block) => block.type === 'refusal' && typeof block.text === 'string');
|
|
101
|
+
const refusal = refusalBlock?.text;
|
|
102
|
+
const finishReason = normalizeAnthropicFinishReason(root.stop_reason, refusal, toolCalls.length > 0);
|
|
103
|
+
const usage = normalizeUsage(root.usage, { input: 'input_tokens', output: 'output_tokens', cached: 'cache_read_input_tokens' }, 'Anthropic');
|
|
104
|
+
return { text, ...(parseStructured(text) === undefined ? {} : { structured: parseStructured(text) }), toolCalls, ...(refusal === undefined ? {} : { refusal }), finishReason, ...(usage === undefined ? {} : { usage }) };
|
|
105
|
+
}
|
|
106
|
+
function providerRecord(value, label) {
|
|
107
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
108
|
+
throw providerResponseError(`${label} must be an object`);
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
function requiredProviderString(value, label) {
|
|
112
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
113
|
+
throw providerResponseError(`${label} must be a non-empty string`);
|
|
114
|
+
return value;
|
|
115
|
+
}
|
|
116
|
+
function providerText(value, label) {
|
|
117
|
+
if (value === undefined || value === null)
|
|
118
|
+
return '';
|
|
119
|
+
if (typeof value === 'string')
|
|
120
|
+
return value;
|
|
121
|
+
if (Array.isArray(value) && value.every((part) => {
|
|
122
|
+
if (!part || typeof part !== 'object' || Array.isArray(part))
|
|
123
|
+
return false;
|
|
124
|
+
const item = part;
|
|
125
|
+
return item.type === 'text' && typeof item.text === 'string';
|
|
126
|
+
}))
|
|
127
|
+
return value.map((part) => part.text).join('');
|
|
128
|
+
throw providerResponseError(`${label} must be a string, null, or text-part array`);
|
|
129
|
+
}
|
|
130
|
+
function providerMetric(value, label) {
|
|
131
|
+
if (value === undefined)
|
|
132
|
+
return undefined;
|
|
133
|
+
if (!Number.isInteger(value) || !Number.isFinite(value) || value < 0)
|
|
134
|
+
throw providerResponseError(`${label} must be a non-negative integer`);
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
function normalizeUsage(raw, fields, provider) {
|
|
138
|
+
if (raw === undefined)
|
|
139
|
+
return undefined;
|
|
140
|
+
const value = providerRecord(raw, `${provider} usage`);
|
|
141
|
+
const inputTokens = providerMetric(value[fields.input], `${provider} input tokens`);
|
|
142
|
+
const outputTokens = providerMetric(value[fields.output], `${provider} output tokens`);
|
|
143
|
+
const cachedInputTokens = providerMetric(value[fields.cached], `${provider} cached input tokens`);
|
|
144
|
+
if (inputTokens !== undefined && cachedInputTokens !== undefined && cachedInputTokens > inputTokens)
|
|
145
|
+
throw providerResponseError(`${provider} cached input tokens exceed input tokens`);
|
|
146
|
+
const cost = value.cost === undefined ? undefined : normalizeCost(value.cost, provider);
|
|
147
|
+
return { ...(inputTokens === undefined ? {} : { inputTokens }), ...(outputTokens === undefined ? {} : { outputTokens }), ...(cachedInputTokens === undefined ? {} : { cachedInputTokens }), ...(inputTokens !== undefined && cachedInputTokens !== undefined ? { uncachedInputTokens: inputTokens - cachedInputTokens } : {}), ...(cost === undefined ? {} : { cost }) };
|
|
148
|
+
}
|
|
149
|
+
function normalizeCost(raw, provider) {
|
|
150
|
+
const value = providerRecord(raw, `${provider} cost`);
|
|
151
|
+
if (typeof value.amount !== 'number' || !Number.isFinite(value.amount) || value.amount < 0)
|
|
152
|
+
throw providerResponseError(`${provider} cost amount must be a non-negative number`);
|
|
153
|
+
if (typeof value.currency !== 'string' || value.currency.length === 0)
|
|
154
|
+
throw providerResponseError(`${provider} cost currency must be a non-empty string`);
|
|
155
|
+
if (value.pricing_version !== undefined && typeof value.pricing_version !== 'string')
|
|
156
|
+
throw providerResponseError(`${provider} pricing version must be a string`);
|
|
157
|
+
return { amount: value.amount, currency: value.currency, source: 'reported', ...(value.pricing_version === undefined ? {} : { pricingVersion: value.pricing_version }) };
|
|
158
|
+
}
|
|
159
|
+
function normalizeOpenAIToolCalls(raw) {
|
|
160
|
+
if (!Array.isArray(raw))
|
|
161
|
+
throw providerResponseError('OpenAI tool_calls must be an array');
|
|
162
|
+
return raw.map((call, index) => {
|
|
163
|
+
const value = providerRecord(call, 'OpenAI tool call');
|
|
164
|
+
const fn = providerRecord(value.function, 'OpenAI tool function');
|
|
165
|
+
return { toolCallId: `pulse-tool-${index + 1}`, name: requiredProviderString(fn.name, 'OpenAI tool name'), input: parseJson(fn.arguments) };
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
function normalizeOpenAIFinishReason(raw, refusal, hasTools) {
|
|
169
|
+
if (refusal !== undefined || raw === 'refusal')
|
|
170
|
+
return 'refusal';
|
|
171
|
+
if (raw === undefined)
|
|
172
|
+
return hasTools ? 'tool_calls' : 'stop';
|
|
173
|
+
if (raw === 'tool_calls') {
|
|
174
|
+
if (!hasTools)
|
|
175
|
+
throw providerResponseError('tool_calls finish reason requires tool calls');
|
|
176
|
+
return 'tool_calls';
|
|
177
|
+
}
|
|
178
|
+
if (raw === 'stop') {
|
|
179
|
+
if (hasTools)
|
|
180
|
+
throw providerResponseError('stop finish reason cannot contain tool calls');
|
|
181
|
+
return 'stop';
|
|
182
|
+
}
|
|
183
|
+
if (raw === 'length') {
|
|
184
|
+
if (hasTools)
|
|
185
|
+
throw providerResponseError('length finish reason cannot contain tool calls');
|
|
186
|
+
return 'length';
|
|
187
|
+
}
|
|
188
|
+
if (raw === 'error' || raw === 'content_filter') {
|
|
189
|
+
if (hasTools)
|
|
190
|
+
throw providerResponseError('error finish reason cannot contain tool calls');
|
|
191
|
+
return 'error';
|
|
192
|
+
}
|
|
193
|
+
throw providerResponseError(`unsupported OpenAI finish reason: ${String(raw)}`);
|
|
194
|
+
}
|
|
195
|
+
function normalizeAnthropicFinishReason(raw, refusal, hasTools) {
|
|
196
|
+
if (refusal !== undefined || raw === 'refusal')
|
|
197
|
+
return 'refusal';
|
|
198
|
+
if (raw === undefined)
|
|
199
|
+
return hasTools ? 'tool_calls' : 'stop';
|
|
200
|
+
if (raw === 'tool_use') {
|
|
201
|
+
if (!hasTools)
|
|
202
|
+
throw providerResponseError('tool_use stop reason requires tool calls');
|
|
203
|
+
return 'tool_calls';
|
|
204
|
+
}
|
|
205
|
+
if (raw === 'max_tokens') {
|
|
206
|
+
if (hasTools)
|
|
207
|
+
throw providerResponseError('max_tokens stop reason cannot contain tool calls');
|
|
208
|
+
return 'length';
|
|
209
|
+
}
|
|
210
|
+
if (raw === 'end_turn' || raw === 'stop_sequence') {
|
|
211
|
+
if (hasTools)
|
|
212
|
+
throw providerResponseError('text stop reason cannot contain tool calls');
|
|
213
|
+
return 'stop';
|
|
214
|
+
}
|
|
215
|
+
throw providerResponseError(`unsupported Anthropic stop reason: ${String(raw)}`);
|
|
216
|
+
}
|
|
217
|
+
function parseJson(value) {
|
|
218
|
+
if (value === undefined || value === null || value === '')
|
|
219
|
+
return {};
|
|
220
|
+
if (typeof value !== 'string')
|
|
221
|
+
return value;
|
|
222
|
+
try {
|
|
223
|
+
return JSON.parse(value);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
throw providerResponseError('INVALID_TOOL_ARGUMENTS');
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function parseStructured(value) { if (!value.trim())
|
|
230
|
+
return undefined; try {
|
|
231
|
+
const parsed = JSON.parse(value);
|
|
232
|
+
return parsed !== null && typeof parsed === 'object' ? parsed : undefined;
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return undefined;
|
|
236
|
+
} }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { JsonValue, LLMRequestProjection } from '@hunterzhu/pulse-runtime';
|
|
2
|
+
import type { ProviderAdapter, ProviderPresetConfig } from './types.js';
|
|
3
|
+
export declare class OpenAICompatibleAdapter implements ProviderAdapter {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
private readonly config;
|
|
6
|
+
readonly name = "OpenAI Compatible";
|
|
7
|
+
private readonly baseURL;
|
|
8
|
+
constructor(id: string, config: ProviderPresetConfig);
|
|
9
|
+
executeAttempt(params: {
|
|
10
|
+
request: LLMRequestProjection;
|
|
11
|
+
signal: AbortSignal;
|
|
12
|
+
onObservation?: (chunk: string) => void;
|
|
13
|
+
model?: string;
|
|
14
|
+
outputSchema?: JsonValue;
|
|
15
|
+
maxOutputTokens?: number;
|
|
16
|
+
}): Promise<import("@hunterzhu/pulse-runtime").LLMResult>;
|
|
17
|
+
}
|
|
18
|
+
export declare function toOpenAIMessages(request: LLMRequestProjection): Array<{
|
|
19
|
+
role: 'system' | 'user' | 'assistant';
|
|
20
|
+
content: string;
|
|
21
|
+
name?: string;
|
|
22
|
+
}>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { consumeProviderSse, normalizeOpenAIResponse, parseProviderJson, providerHttpError, providerNetworkError } from './normalize.js';
|
|
2
|
+
export class OpenAICompatibleAdapter {
|
|
3
|
+
id;
|
|
4
|
+
config;
|
|
5
|
+
name = 'OpenAI Compatible';
|
|
6
|
+
baseURL;
|
|
7
|
+
constructor(id, config) {
|
|
8
|
+
this.id = id;
|
|
9
|
+
this.config = config;
|
|
10
|
+
this.baseURL = config.baseURL ?? 'https://api.openai.com/v1';
|
|
11
|
+
}
|
|
12
|
+
async executeAttempt(params) {
|
|
13
|
+
const streaming = params.onObservation !== undefined;
|
|
14
|
+
const tools = toolDefinitions(params.request);
|
|
15
|
+
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 }), messages: toMessages(params.request), ...(tools.length ? { tools, ...(this.config.toolChoice === undefined ? {} : { tool_choice: this.config.toolChoice }) } : {}), ...(params.outputSchema === undefined ? {} : { response_format: { type: 'json_schema', json_schema: { name: 'pulse_output', strict: true, schema: params.outputSchema } } }), ...(streaming ? { stream: true, stream_options: { include_usage: true } } : {}) };
|
|
16
|
+
try {
|
|
17
|
+
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) });
|
|
18
|
+
if (!response.ok)
|
|
19
|
+
throw providerHttpError(response.status);
|
|
20
|
+
if (!streaming || !response.headers.get('content-type')?.includes('text/event-stream'))
|
|
21
|
+
return normalizeOpenAIResponse(await parseProviderJson(response));
|
|
22
|
+
const events = await consumeProviderSse(response);
|
|
23
|
+
const content = [];
|
|
24
|
+
const refusals = [];
|
|
25
|
+
const toolCalls = new Map();
|
|
26
|
+
let finishReason;
|
|
27
|
+
let usage;
|
|
28
|
+
for (const event of events) {
|
|
29
|
+
if (event.data === '[DONE]' || !event.data || typeof event.data !== 'object')
|
|
30
|
+
continue;
|
|
31
|
+
const choice = event.data.choices?.[0];
|
|
32
|
+
const delta = choice?.delta;
|
|
33
|
+
if (typeof delta?.content === 'string') {
|
|
34
|
+
content.push(delta.content);
|
|
35
|
+
params.onObservation?.(delta.content);
|
|
36
|
+
}
|
|
37
|
+
if (typeof delta?.refusal === 'string') {
|
|
38
|
+
refusals.push(delta.refusal);
|
|
39
|
+
params.onObservation?.(delta.refusal);
|
|
40
|
+
}
|
|
41
|
+
if (typeof choice?.finish_reason === 'string')
|
|
42
|
+
finishReason = choice.finish_reason;
|
|
43
|
+
if (Array.isArray(delta?.tool_calls))
|
|
44
|
+
for (const call of delta.tool_calls) {
|
|
45
|
+
const index = Number(call.index ?? 0);
|
|
46
|
+
const current = toolCalls.get(index) ?? { name: '', arguments: '' };
|
|
47
|
+
if (typeof call.id === 'string')
|
|
48
|
+
current.id = call.id;
|
|
49
|
+
if (typeof call.function?.name === 'string')
|
|
50
|
+
current.name += call.function.name;
|
|
51
|
+
if (typeof call.function?.arguments === 'string')
|
|
52
|
+
current.arguments += call.function.arguments;
|
|
53
|
+
toolCalls.set(index, current);
|
|
54
|
+
}
|
|
55
|
+
if (event.data.usage !== undefined)
|
|
56
|
+
usage = event.data.usage;
|
|
57
|
+
}
|
|
58
|
+
return normalizeOpenAIResponse({ choices: [{ message: { content: content.join('') || null, ...(refusals.length ? { refusal: refusals.join('') } : {}), ...(toolCalls.size ? { tool_calls: [...toolCalls.entries()].sort(([left], [right]) => left - right).map(([, call]) => ({ id: call.id, function: { name: call.name, arguments: call.arguments } })) } : {}) }, finish_reason: finishReason ?? 'stop' }], ...(usage === undefined ? {} : { usage }) });
|
|
59
|
+
}
|
|
60
|
+
catch (cause) {
|
|
61
|
+
if (params.signal.aborted)
|
|
62
|
+
throw Object.assign(new Error('Provider request was cancelled.'), { code: 'PROVIDER_REQUEST_CANCELLED', retryable: false, cause });
|
|
63
|
+
if (cause instanceof Error && 'code' in cause && typeof cause.code === 'string' && 'retryable' in cause && typeof cause.retryable === 'boolean')
|
|
64
|
+
throw cause;
|
|
65
|
+
throw providerNetworkError(cause);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function toOpenAIMessages(request) {
|
|
70
|
+
return request.blocks.map((block) => {
|
|
71
|
+
const content = typeof block.content === 'string' ? block.content : JSON.stringify(block.content);
|
|
72
|
+
if (block.kind === 'system' || block.kind === 'policy' || block.kind === 'tools')
|
|
73
|
+
return { role: 'system', content };
|
|
74
|
+
if (block.kind === 'history')
|
|
75
|
+
return { role: 'assistant', content };
|
|
76
|
+
return { role: 'user', name: block.kind, content };
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
function toMessages(request) {
|
|
80
|
+
return toOpenAIMessages(request);
|
|
81
|
+
}
|
|
82
|
+
function toolDefinitions(request) {
|
|
83
|
+
const block = request.blocks.find((candidate) => candidate.kind === 'tools');
|
|
84
|
+
const content = block?.content;
|
|
85
|
+
const values = Array.isArray(content) ? content : content && typeof content === 'object' && !Array.isArray(content) && Array.isArray(content.tools) ? content.tools : [];
|
|
86
|
+
return values.filter((value) => typeof value === 'object' && value !== null && !Array.isArray(value) && typeof value.name === 'string').map((value) => ({ type: 'function', function: { name: value.name, ...(typeof value.description === 'string' ? { description: value.description } : {}), parameters: (value.inputSchema ?? value.parameters ?? {}) } }));
|
|
87
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { EffectExecutor, ModelRouteRequirements, ModelRouter } from '@hunterzhu/pulse-runtime';
|
|
2
|
+
import type { ProviderAdapter } from './types.js';
|
|
3
|
+
export declare function createModelEffectExecutor(config: {
|
|
4
|
+
router: ModelRouter;
|
|
5
|
+
providers: ReadonlyMap<string, ProviderAdapter>;
|
|
6
|
+
requirements?: ModelRouteRequirements;
|
|
7
|
+
maxConcurrentByProvider?: Readonly<Record<string, number>>;
|
|
8
|
+
maxConcurrentByModel?: Readonly<Record<string, number>>;
|
|
9
|
+
}): EffectExecutor;
|