aki-pro-max 2.3.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.
- package/.env.example +34 -0
- package/AICOWORKER-NATIVE-TOOLS.md +60 -0
- package/CLAUDE-LIVE-TOOLS-EVIDENCE.json +94 -0
- package/FULL-TRACE-EVIDENCE.json +109 -0
- package/ISSUE-1-REMOTE.json +1 -0
- package/ISSUE-2-POSTREVIEW.json +1 -0
- package/ISSUE-2-REMOTE.json +1 -0
- package/KEY-ROTATION-EVIDENCE.json +9 -0
- package/LICENSE +21 -0
- package/PMN-9ROUTER-FINAL.md +13 -0
- package/RAPID-BASIL-RECONCILIATION.json +10 -0
- package/README.md +232 -0
- package/RELEASE-ARCHIVE.json +15 -0
- package/SECURITY-RECONCILIATION.json +30 -0
- package/SECURITY.md +16 -0
- package/TEST-ISSUES12-FINAL.txt +0 -0
- package/TEST-ISSUES12-HARNESS.txt +0 -0
- package/VERIFICATION-REPORT.md +58 -0
- package/VERIFIER-ISSUES12-FINAL.txt +0 -0
- package/VERIFY-RELEASE-ISSUES12-FINAL.txt +0 -0
- package/VERIFY-RELEASE-ISSUES12-HARNESS.txt +0 -0
- package/bin/aki-pro-max.js +98 -0
- package/docs/ADMIN-GUI-CONTRACT.md +29 -0
- package/docs/ARCHITECTURE.md +109 -0
- package/docs/CAPABILITY-MATRIX.md +44 -0
- package/docs/CORRELATION-DESIGN.md +226 -0
- package/docs/FAIL-CLOSED-ISSUE-HARNESS.md +21 -0
- package/docs/WEB-SESSION-TRANSPORT-DESIGN.md +423 -0
- package/docs/assets/control-plane.jpg +0 -0
- package/gitleaks-report-all.json +1 -0
- package/gitleaks-report-latest.json +1 -0
- package/gitleaks-report.json +1 -0
- package/package.json +33 -0
- package/scripts/eventual-tool-loop.mjs +55 -0
- package/scripts/install-local.ps1 +35 -0
- package/scripts/live-eventual-multitool.mjs +18 -0
- package/scripts/upgrade-admin-v232.mjs +33 -0
- package/scripts/verify-issue-closure.mjs +81 -0
- package/scripts/verify-release.mjs +31 -0
- package/src/admin-auth.mjs +94 -0
- package/src/admin.mjs +133 -0
- package/src/canonical.mjs +23 -0
- package/src/config.mjs +88 -0
- package/src/correlation-store.mjs +120 -0
- package/src/errors.mjs +18 -0
- package/src/index.mjs +4 -0
- package/src/openai-response.mjs +72 -0
- package/src/openai.mjs +104 -0
- package/src/postman-events.mjs +43 -0
- package/src/postman-request.mjs +49 -0
- package/src/schema.mjs +35 -0
- package/src/server.mjs +73 -0
- package/src/session-store.mjs +48 -0
- package/src/sse.mjs +13 -0
- package/src/transport.mjs +90 -0
- package/src/web-session-events.mjs +358 -0
- package/src/web-session-request.mjs +280 -0
- package/test/9router-executor.integration.test.mjs +207 -0
- package/test/admin-auth.test.mjs +47 -0
- package/test/admin.test.mjs +68 -0
- package/test/config.test.mjs +14 -0
- package/test/contract.test.mjs +14 -0
- package/test/correlation-store.test.mjs +19 -0
- package/test/correlation.integration.test.mjs +48 -0
- package/test/eventual-tool-loop.test.mjs +42 -0
- package/test/fixtures/text.json +8 -0
- package/test/fixtures/tool.json +7 -0
- package/test/fixtures/web-session-observed-done.json +12 -0
- package/test/fixtures/web-session-tool-fragments.json +14 -0
- package/test/full-ingress/alias-loader.mjs +22 -0
- package/test/full-ingress/run-full-ingress.mjs +207 -0
- package/test/full-ingress/seed-9router.mjs +36 -0
- package/test/helpers.mjs +9 -0
- package/test/issue-closure-harness.test.mjs +49 -0
- package/test/model-thinking.test.mjs +20 -0
- package/test/protocol.test.mjs +16 -0
- package/test/request.test.mjs +10 -0
- package/test/session-store.test.mjs +20 -0
- package/test/web-session-builder.test.mjs +124 -0
- package/test/web-session-events.test.mjs +241 -0
- package/test/web-session-integration.test.mjs +103 -0
- package/test/web-session-tools.test.mjs +52 -0
- package/version.json +8 -0
package/src/index.mjs
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { loadConfig } from './config.mjs';
|
|
2
|
+
import { createProviderServer } from './server.mjs';
|
|
3
|
+
const config=loadConfig();const server=createProviderServer(config);server.listen(config.port,config.host,()=>{const address=server.address();console.log(JSON.stringify({event:'listening',host:config.host,port:typeof address==='object'?address.port:config.port,upstream_verified:false}));});
|
|
4
|
+
const shutdown=signal=>{server.close(()=>process.exit(0));setTimeout(()=>process.exit(1),5000).unref();};process.on('SIGINT',()=>shutdown('SIGINT'));process.on('SIGTERM',()=>shutdown('SIGTERM'));
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { upstream } from './errors.mjs';
|
|
3
|
+
import { validateValue } from './schema.mjs';
|
|
4
|
+
import { canonicalHash } from './canonical.mjs';
|
|
5
|
+
|
|
6
|
+
export function createResponseState(request) {
|
|
7
|
+
const id = `chatcmpl_${randomUUID().replaceAll('-', '')}`;
|
|
8
|
+
const created = Math.floor(Date.now() / 1000);
|
|
9
|
+
let content = '', finishReason = null, usage = null, conversationId = null, admittedGroup = null;
|
|
10
|
+
const calls = new Map();
|
|
11
|
+
const finalizeTools = () => {
|
|
12
|
+
for (const call of calls.values()) {
|
|
13
|
+
let args;
|
|
14
|
+
try { args = JSON.parse(call.function.arguments || '{}'); }
|
|
15
|
+
catch { throw upstream(`Upstream tool ${call.function.name} returned invalid JSON arguments.`, 'upstream_invalid_tool_arguments'); }
|
|
16
|
+
const schema = request.toolSchemaMap?.get(call.function.name);
|
|
17
|
+
if (!schema) throw upstream('Upstream returned an undeclared tool.', 'upstream_unregistered_tool');
|
|
18
|
+
try { validateValue(args, schema); }
|
|
19
|
+
catch { throw upstream(`Upstream tool ${call.function.name} arguments do not match the caller schema.`, 'upstream_tool_schema_mismatch'); }
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
const orderedCalls = () => [...calls.entries()].sort(([a], [b]) => a - b).map(([, value]) => value);
|
|
23
|
+
const message = () => { const value = { role: 'assistant', content: content || null }; if (calls.size) value.tool_calls = orderedCalls().map(call => ({ id: call.id, type: 'function', function: { ...call.function } })); return value; };
|
|
24
|
+
return {
|
|
25
|
+
id, created, model: request.model,
|
|
26
|
+
apply(event) {
|
|
27
|
+
if (event.type === 'response_start') conversationId = event.conversationId;
|
|
28
|
+
else if (event.type === 'text_delta') content += event.text;
|
|
29
|
+
else if (event.type === 'tool_call_start') calls.set(event.index, { id: event.id, upstreamId: event.id, upstreamGroupId: event.groupId ?? null, type: 'function', function: { name: event.name, arguments: '' } });
|
|
30
|
+
else if (event.type === 'tool_args_delta') { const call = calls.get(event.index); if (!call) throw upstream('Tool argument delta arrived before tool start.', 'upstream_tool_fragment_error'); call.function.arguments += event.arguments; }
|
|
31
|
+
else if (event.type === 'usage') usage = event.usage;
|
|
32
|
+
else if (event.type === 'finish') { finishReason = event.reason; if (event.reason === 'tool_calls') finalizeTools(); }
|
|
33
|
+
},
|
|
34
|
+
hasTools() { return calls.size > 0; },
|
|
35
|
+
admit(store, context) {
|
|
36
|
+
if (!calls.size) return null;
|
|
37
|
+
finalizeTools();
|
|
38
|
+
if (!conversationId) throw upstream('Upstream tool calls are missing a conversation ID.', 'upstream_invalid_conversation');
|
|
39
|
+
const values = orderedCalls();
|
|
40
|
+
const groupValues = values.map(call => call.upstreamGroupId);
|
|
41
|
+
const groups = new Set(groupValues);
|
|
42
|
+
if (values.length > 1 && (groups.size !== 1 || groupValues[0] === null)) throw upstream('Upstream returned tool calls without one common group.', 'upstream_ungrouped_tool_batch');
|
|
43
|
+
const upstreamGroupId = groupValues[0] ?? null;
|
|
44
|
+
admittedGroup = store.admit({ ...context, conversationId, upstreamGroupId, calls: values.map(call => ({ upstreamToolCallId: call.upstreamId, upstreamGroupId: call.upstreamGroupId, originalName: call.function.name, argumentsJson: call.function.arguments, schemaHash: canonicalHash(request.toolSchemaMap.get(call.function.name)) })) });
|
|
45
|
+
admittedGroup.calls.forEach((stored, index) => { values[index].id = stored.publicToolCallId; });
|
|
46
|
+
return admittedGroup;
|
|
47
|
+
},
|
|
48
|
+
toolChunks() {
|
|
49
|
+
return orderedCalls().flatMap((call, index) => [
|
|
50
|
+
{ id, object: 'chat.completion.chunk', created, model: request.model, choices: [{ index: 0, delta: { tool_calls: [{ index, id: call.id, type: 'function', function: { name: call.function.name, arguments: '' } }] }, finish_reason: null }] },
|
|
51
|
+
...(call.function.arguments ? [{ id, object: 'chat.completion.chunk', created, model: request.model, choices: [{ index: 0, delta: { tool_calls: [{ index, function: { arguments: call.function.arguments } }] }, finish_reason: null }] }] : []),
|
|
52
|
+
]);
|
|
53
|
+
},
|
|
54
|
+
chunk(event) {
|
|
55
|
+
const base = { id, object: 'chat.completion.chunk', created, model: request.model, choices: [{ index: 0, delta: {}, finish_reason: null }] }; const choice = base.choices[0];
|
|
56
|
+
if (event.type === 'response_start') choice.delta = { role: 'assistant', content: '' };
|
|
57
|
+
else if (event.type === 'text_delta') choice.delta = { content: event.text };
|
|
58
|
+
else if (event.type === 'tool_call_start' || event.type === 'tool_args_delta') return null;
|
|
59
|
+
else if (event.type === 'usage') return { ...base, choices: [], usage: event.usage };
|
|
60
|
+
else if (event.type === 'finish') { choice.delta = {}; choice.finish_reason = event.reason; }
|
|
61
|
+
else return null;
|
|
62
|
+
return base;
|
|
63
|
+
},
|
|
64
|
+
result() {
|
|
65
|
+
if (!finishReason) finishReason = calls.size ? 'tool_calls' : 'stop';
|
|
66
|
+
if (calls.size) finalizeTools();
|
|
67
|
+
return { id, object: 'chat.completion', created, model: request.model, choices: [{ index: 0, message: message(), finish_reason: finishReason }], usage: usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, usage_estimated: true };
|
|
68
|
+
},
|
|
69
|
+
message,
|
|
70
|
+
admittedGroup() { return admittedGroup; },
|
|
71
|
+
};
|
|
72
|
+
}
|
package/src/openai.mjs
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { ProviderError, invalid } from './errors.mjs';
|
|
3
|
+
import { validateSchemaDefinition, validateValue } from './schema.mjs';
|
|
4
|
+
const namePattern = /^[A-Za-z0-9_-]{1,64}$/;
|
|
5
|
+
const text = (content, field) => {
|
|
6
|
+
if (typeof content === 'string') return content;
|
|
7
|
+
if (content === null || content === undefined) return '';
|
|
8
|
+
if (Array.isArray(content)) {
|
|
9
|
+
// Extract any text blocks, thinking blocks, or tool results gracefully
|
|
10
|
+
const parts = [];
|
|
11
|
+
for (const p of content) {
|
|
12
|
+
if (!p || typeof p !== 'object') continue;
|
|
13
|
+
if (typeof p.text === 'string') parts.push(p.text);
|
|
14
|
+
else if (typeof p.content === 'string') parts.push(p.content);
|
|
15
|
+
else if (typeof p.thinking === 'string') parts.push(p.thinking);
|
|
16
|
+
else if (p.type === 'toolCall' || p.type === 'tool_call') parts.push(`[Tool Call: ${p.name || p.function?.name || ''}]`);
|
|
17
|
+
}
|
|
18
|
+
return parts.join('\n');
|
|
19
|
+
}
|
|
20
|
+
return String(content);
|
|
21
|
+
};
|
|
22
|
+
function normalizeTools(input, maxTools) {
|
|
23
|
+
if (input === undefined) return { tools: [], nameMap: new Map(), schemaMap: new Map(), binding: [] };
|
|
24
|
+
if (!Array.isArray(input) || input.length > maxTools) throw invalid(`tools must be an array with at most ${maxTools} entries.`, 'invalid_tools');
|
|
25
|
+
const used = new Set(), nameMap = new Map(), schemaMap = new Map(), binding = [];
|
|
26
|
+
const tools = input.map((entry, index) => {
|
|
27
|
+
if (entry?.type !== 'function' || !entry.function || typeof entry.function !== 'object') throw invalid(`tools[${index}] must be a function tool.`, 'invalid_tool');
|
|
28
|
+
const { name, description = '', parameters = { type: 'object', properties: {} } } = entry.function;
|
|
29
|
+
if (!namePattern.test(name)) throw invalid(`tools[${index}].function.name is invalid.`, 'invalid_tool_name');
|
|
30
|
+
validateSchemaDefinition(parameters, `$.tools[${index}].function.parameters`);
|
|
31
|
+
let registered = `openai__${name}`;
|
|
32
|
+
if (registered.length > 64 || used.has(registered)) registered = `openai__${createHash('sha256').update(name).digest('hex').slice(0,40)}`;
|
|
33
|
+
if (used.has(registered)) throw invalid('Tool names collide after normalization.', 'tool_name_collision');
|
|
34
|
+
used.add(registered); nameMap.set(registered, name); schemaMap.set(name, parameters);
|
|
35
|
+
binding.push({ type: 'function', function: { name, description, parameters } });
|
|
36
|
+
return { name: registered, originalName: name, description: `${name}: ${description || 'Client-declared function tool.'}`, parameters };
|
|
37
|
+
});
|
|
38
|
+
return { tools, nameMap, schemaMap, binding };
|
|
39
|
+
}
|
|
40
|
+
function normalizeMessages(messages, schemaMap, maxToolResultBytes) {
|
|
41
|
+
if (!Array.isArray(messages) || !messages.length) throw invalid('messages must be a non-empty array.', 'invalid_messages');
|
|
42
|
+
const normalized = [], declaredCalls = new Map(); const toolResults = [];
|
|
43
|
+
let suffixStart = messages.length; while (suffixStart > 0 && messages[suffixStart - 1]?.role === 'tool') suffixStart--;
|
|
44
|
+
for (let i = 0; i < messages.length; i++) {
|
|
45
|
+
const message = messages[i];
|
|
46
|
+
if (!message || typeof message !== 'object' || !['system','developer','user','assistant','tool'].includes(message.role)) throw invalid(`messages[${i}].role is invalid.`, 'invalid_message_role');
|
|
47
|
+
if (message.role === 'tool') {
|
|
48
|
+
if (typeof message.tool_call_id !== 'string' || !message.tool_call_id) throw invalid(`messages[${i}].tool_call_id is required.`, 'invalid_tool_result');
|
|
49
|
+
const content = text(message.content, `messages[${i}].content`);
|
|
50
|
+
if (Buffer.byteLength(content) > maxToolResultBytes) throw new ProviderError(413, 'Tool result exceeds the configured limit.', 'invalid_request_error', 'tool_result_too_large');
|
|
51
|
+
if (i >= suffixStart) toolResults.push({ toolCallId: message.tool_call_id, content });
|
|
52
|
+
normalized.push({ role: 'tool', tool_call_id: message.tool_call_id, content });
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const item = { role: message.role, content: text(message.content, `messages[${i}].content`) };
|
|
56
|
+
if (message.role === 'assistant' && message.tool_calls !== undefined) {
|
|
57
|
+
if (!Array.isArray(message.tool_calls) || !message.tool_calls.length) throw invalid(`messages[${i}].tool_calls must be non-empty.`, 'invalid_tool_calls');
|
|
58
|
+
item.tool_calls = message.tool_calls.map((call, j) => {
|
|
59
|
+
if (call?.type !== 'function' || typeof call.id !== 'string' || !call.id || !call.function || !namePattern.test(call.function.name) || typeof call.function.arguments !== 'string') throw invalid(`messages[${i}].tool_calls[${j}] is invalid.`, 'invalid_tool_call');
|
|
60
|
+
let args; try { args = JSON.parse(call.function.arguments || '{}'); } catch { throw invalid(`messages[${i}].tool_calls[${j}] has invalid JSON arguments.`, 'invalid_tool_arguments'); }
|
|
61
|
+
const schema = schemaMap.get(call.function.name); if (schema) validateValue(args, schema);
|
|
62
|
+
declaredCalls.set(call.id, call.function.name);
|
|
63
|
+
return { id: call.id, type: 'function', function: { name: call.function.name, arguments: call.function.arguments } };
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
normalized.push(item);
|
|
67
|
+
}
|
|
68
|
+
if (toolResults.length) {
|
|
69
|
+
const finalBlockIds = new Set();
|
|
70
|
+
for (let index = normalized.length - toolResults.length - 1; index >= 0; index--) {
|
|
71
|
+
const message = normalized[index];
|
|
72
|
+
if (message.role === 'assistant' && message.tool_calls?.length) { for (const call of message.tool_calls) finalBlockIds.add(call.id); break; }
|
|
73
|
+
}
|
|
74
|
+
for (const result of toolResults) if (!finalBlockIds.has(result.toolCallId) && !result.toolCallId.startsWith('call_pmn_') && !result.toolCallId.startsWith('callpmn')) {
|
|
75
|
+
throw invalid(`Tool result references unknown tool call ${result.toolCallId}.`, 'unknown_tool_call');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { messages: normalized, prefix: normalized.slice(0, normalized.length - toolResults.length), toolResults };
|
|
79
|
+
}
|
|
80
|
+
function normalizeToolChoice(value, binding) {
|
|
81
|
+
if (value === undefined) return binding.length ? 'auto' : 'none';
|
|
82
|
+
if (['none','auto','required'].includes(value)) return value;
|
|
83
|
+
if (value?.type === 'function' && namePattern.test(value.function?.name) && binding.some(t => t.function.name === value.function.name)) return { type: 'function', name: value.function.name };
|
|
84
|
+
throw invalid('tool_choice is invalid or references an unknown tool.', 'invalid_tool_choice');
|
|
85
|
+
}
|
|
86
|
+
export function normalizeOpenAIRequest(body, config) {
|
|
87
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) throw invalid('Request body must be a JSON object.');
|
|
88
|
+
if (typeof body.model !== 'string') throw invalid('model is required.', 'invalid_model');
|
|
89
|
+
|
|
90
|
+
// Preserve OpenAI assistant tool_calls and role=tool messages verbatim so the
|
|
91
|
+
// correlation store can resolve each result exactly once. Content arrays are
|
|
92
|
+
// already normalized safely by text(); rewriting tool results as user turns
|
|
93
|
+
// loses tool_call_id and restarts the initial tool-selection phase.
|
|
94
|
+
const normalizedTools = normalizeTools(body.tools, config.maxTools);
|
|
95
|
+
const history = normalizeMessages(body.messages, normalizedTools.schemaMap, config.maxToolResultBytes ?? config.maxBodyBytes);
|
|
96
|
+
if (body.n !== undefined && body.n !== 1) throw invalid('Only n=1 is supported.', 'unsupported_n');
|
|
97
|
+
if (body.response_format !== undefined) throw invalid('response_format is not verified for this upstream.', 'unsupported_response_format');
|
|
98
|
+
if (body.parallel_tool_calls !== undefined && body.parallel_tool_calls !== true) throw invalid('parallel_tool_calls=false is not supported.', 'unsupported_parallel_tool_calls');
|
|
99
|
+
for (const [key,min,max] of [['temperature',0,2],['top_p',0,1]]) if (body[key] !== undefined && (typeof body[key] !== 'number' || body[key] < min || body[key] > max)) throw invalid(`${key} is out of range.`, `invalid_${key}`);
|
|
100
|
+
if (body.max_tokens !== undefined && (!Number.isSafeInteger(body.max_tokens) || body.max_tokens <= 0)) throw invalid('max_tokens must be a positive integer.', 'invalid_max_tokens');
|
|
101
|
+
const toolChoice = normalizeToolChoice(body.tool_choice, normalizedTools.binding);
|
|
102
|
+
return { model: body.model, messages: history.messages, transcriptPrefix: history.prefix, tools: normalizedTools.tools, toolsBinding: { tools: normalizedTools.binding, toolChoice }, toolNameMap: normalizedTools.nameMap, toolSchemaMap: normalizedTools.schemaMap, toolResults: history.toolResults, toolChoice, stream: Boolean(body.stream), maxTokens: body.max_tokens ?? null, temperature: body.temperature ?? null, topP: body.top_p ?? null, stop: body.stop ?? null, user: typeof body.user === 'string' ? body.user : null };
|
|
103
|
+
}
|
|
104
|
+
export function buildTranscript(messages) { return messages.map(m => m.role === 'assistant' && m.tool_calls ? { role: 'assistant', content: m.content, tool_calls: m.tool_calls } : m.role === 'tool' ? { role: 'tool', tool_call_id: m.tool_call_id, content: m.content } : { role: m.role, content: m.content }); }
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { upstream } from './errors.mjs';
|
|
2
|
+
const eventName=(event,frame)=>typeof event.eventType==='string'?event.eventType:frame.event||'message';
|
|
3
|
+
const object=value=>value&&typeof value==='object'&&!Array.isArray(value)?value:null;
|
|
4
|
+
function failureFor(event,frame){
|
|
5
|
+
const data=object(event.data);const envelope=data||event;const name=eventName(event,frame);
|
|
6
|
+
const type=typeof (event.errorType??data?.errorType)==='string'?(event.errorType??data.errorType):null;
|
|
7
|
+
if(type==='TOOL_VALIDATION_ERROR')return upstream('Postman gateway rejected the advertised tool catalog.','upstream_tool_validation_error',400);
|
|
8
|
+
if(type==='LLM_STREAM_ERROR')return upstream('Postman model stream was interrupted.','upstream_stream_error');
|
|
9
|
+
if(type)return upstream('Postman gateway reported an upstream model error.','upstream_model_error');
|
|
10
|
+
if(envelope.result==='failure'||name==='failure')return upstream('Postman gateway reported a request failure.','upstream_gateway_failure');
|
|
11
|
+
if(name==='error'||event.error||data?.error)return upstream('Postman gateway reported an upstream model error.','upstream_model_error');
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
function usageFor(data){
|
|
15
|
+
if(typeof data.usageState==='string'&&data.usageState==='BLOCKED')throw upstream('Postman AI credit access is blocked.','upstream_credit_blocked',400);
|
|
16
|
+
if(typeof data.usageState==='string'&&data.usageState!=='AVAILABLE')throw upstream('Postman gateway reported an unknown credit state.','upstream_unknown_credit_state');
|
|
17
|
+
const usage={prompt_tokens:Number.isSafeInteger(data.inputTokens)?data.inputTokens:0,completion_tokens:Number.isSafeInteger(data.outputTokens)?data.outputTokens:0};usage.total_tokens=usage.prompt_tokens+usage.completion_tokens;return [{type:'usage',usage,estimated:true}];
|
|
18
|
+
}
|
|
19
|
+
export function createRawEventAdapter({toolNameMap,maxToolArgumentBytes,maxOutputBytes}) {
|
|
20
|
+
const tools=new Map(); let outputBytes=0;
|
|
21
|
+
const addOutput=value=>{outputBytes+=Buffer.byteLength(value||'');if(outputBytes>maxOutputBytes)throw upstream('Upstream output exceeds the configured limit.','upstream_output_too_large');};
|
|
22
|
+
return {
|
|
23
|
+
adapt(frame){let event;try{event=JSON.parse(frame.data);}catch{throw upstream('Postman gateway sent malformed SSE JSON.','upstream_invalid_json');}if(!event||typeof event!=='object'||Array.isArray(event))throw upstream('Postman gateway sent an invalid SSE event.','upstream_invalid_event');
|
|
24
|
+
const failure=failureFor(event,frame);if(failure)throw failure;
|
|
25
|
+
const name=eventName(event,frame),data=event.data??{};
|
|
26
|
+
if(name==='conversation'){if(typeof data.id!=='string'||!data.id)throw upstream('Conversation event is missing an ID.','upstream_invalid_conversation');return [{type:'response_start',conversationId:data.id}];}
|
|
27
|
+
if(name==='textChunk'){if(typeof data.textContent!=='string')throw upstream('textChunk has an unverified shape.','upstream_unverified_event_shape');addOutput(data.textContent);return [{type:'text_delta',text:data.textContent}];}
|
|
28
|
+
if(name==='toolCallChunk'||name==='tool_call') return this.tool(data);
|
|
29
|
+
if(name==='usage')return usageFor(data);
|
|
30
|
+
if(name==='thinkingChunk'||name==='thinkingComplete'||name==='usageUpdate'||name==='ping') return [];
|
|
31
|
+
throw upstream(`Unsupported Postman SSE event type: ${name}.`,'upstream_unverified_event_shape');
|
|
32
|
+
},
|
|
33
|
+
tool(data){const chunks=data?.toolCalls||(data?.toolCall?[data.toolCall]:data?.id||data?.index!==undefined?[data]:null);if(!Array.isArray(chunks))throw upstream('Unsupported Postman tool event shape.','upstream_unverified_tool_shape');const out=[];
|
|
34
|
+
for(const chunk of chunks){if(!chunk||typeof chunk!=='object'||(chunk.id!==undefined&&(typeof chunk.id!=='string'||!chunk.id))||(chunk.index!==undefined&&(!Number.isInteger(chunk.index)||chunk.index<0)))throw upstream('Invalid Postman tool fragment correlation.','upstream_tool_fragment_error');
|
|
35
|
+
const byId=chunk.id?[...tools.entries()].find(([,v])=>v.id===chunk.id):null,byIndex=chunk.index!==undefined?[...tools.entries()].find(([,v])=>v.index===chunk.index):null;if(byId&&byIndex&&byId[0]!==byIndex[0])throw upstream('Conflicting Postman tool fragment correlation.','upstream_tool_fragment_error');const existing=byId||byIndex;const key=existing?.[0]||(chunk.id?`id:${chunk.id}`:chunk.index!==undefined?`index:${chunk.index}`:null);if(!key)throw upstream('Postman tool fragment has no ID or index.','upstream_tool_fragment_error');
|
|
36
|
+
const current=existing?.[1]||{id:chunk.id||null,index:chunk.index??tools.size,name:null,argsBytes:0,started:false,groupId:null};const upstreamName=chunk.function?.name??chunk.name;if(upstreamName){const original=toolNameMap.get(upstreamName);if(!original)throw upstream('Postman returned an unregistered tool name.','upstream_unregistered_tool');if(current.name&¤t.name!==original)throw upstream('Postman tool name changed mid-stream.','upstream_tool_fragment_error');current.name=original;}
|
|
37
|
+
const args=chunk.function?.arguments??chunk.arguments;let fragment='';if(args!==undefined){if(typeof args==='string')fragment=args;else if(args&&typeof args==='object'&&!current.started)fragment=JSON.stringify(args);else throw upstream('Unsupported Postman tool argument fragment.','upstream_unverified_tool_shape');current.argsBytes+=Buffer.byteLength(fragment);if(current.argsBytes>maxToolArgumentBytes)throw upstream('Tool arguments exceed the configured limit.','upstream_tool_arguments_too_large');}
|
|
38
|
+
current.groupId=chunk.toolCallGroupId??current.groupId;tools.set(key,current);if(!current.started){if(!current.id||!current.name) { if(fragment) throw upstream('Tool arguments arrived before ID/name.','upstream_tool_fragment_error'); continue; } current.started=true;out.push({type:'tool_call_start',index:current.index,id:current.id,name:current.name,groupId:current.groupId});}if(fragment)out.push({type:'tool_args_delta',index:current.index,id:current.id,arguments:fragment});
|
|
39
|
+
}return out;
|
|
40
|
+
},
|
|
41
|
+
finish(){return [{type:'finish',reason:tools.size?'tool_calls':'stop'}];}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { upstreamUrl } from './config.mjs';
|
|
2
|
+
import { invalid } from './errors.mjs';
|
|
3
|
+
import { buildTranscript } from './openai.mjs';
|
|
4
|
+
|
|
5
|
+
export function catalogMetadata(config) {
|
|
6
|
+
const metadata = { platform: config.platform ?? 'OPENAI_COMPATIBLE_ADAPTER', excludedTools: [...(config.excludedTools ?? [])] };
|
|
7
|
+
if (config.nativeToolsHash !== undefined) metadata.nativeToolsHash = config.nativeToolsHash;
|
|
8
|
+
return metadata;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function clientTools(request, metadata) {
|
|
12
|
+
const thirdParty = request.tools.length ? { 'openai-client': { tools: request.tools.map(({ originalName, ...tool }) => tool) } } : {};
|
|
13
|
+
const tools = { excludedTools: [...metadata.excludedTools], thirdParty };
|
|
14
|
+
if (metadata.nativeToolsHash !== undefined) tools.nativeToolsHash = metadata.nativeToolsHash;
|
|
15
|
+
return tools;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function envelope(request, config, input) {
|
|
19
|
+
const metadata = catalogMetadata(config);
|
|
20
|
+
return {
|
|
21
|
+
url: upstreamUrl(config),
|
|
22
|
+
init: {
|
|
23
|
+
method: 'POST', redirect: 'error',
|
|
24
|
+
headers: { 'content-type': 'application/json', accept: 'text/event-stream', 'x-access-token': config.upstreamToken, 'x-pstmn-req-service': 'agent-mode-service', 'x-app-version': config.appVersion },
|
|
25
|
+
body: JSON.stringify({ input, platform: metadata.platform, clientTools: clientTools(request, metadata), mandatoryContext: { workspaceId: config.workspaceId }, devModeOptions: { selectedModel: request.model } }),
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function buildPostmanRequest(request, config, continuation = null) {
|
|
31
|
+
if (config.transportStrategy === 'web_session') throw invalid('web_session transport is not active until its event and terminal parser is implemented.', 'web_session_transport_not_active');
|
|
32
|
+
if (request.toolResults?.length && !continuation) throw invalid('Tool-result continuation is not correlated.', config.correlationEnabled === false ? 'unsupported_tool_result_continuation' : 'unknown_tool_call');
|
|
33
|
+
if (continuation) {
|
|
34
|
+
const { group, orderedResults } = continuation;
|
|
35
|
+
const input = { chatType: 'TOOL_RESPONSE', query: '', conversationId: group.conversationId, product: 'workspace_v12', useCase: null };
|
|
36
|
+
if (group.upstreamGroupId) {
|
|
37
|
+
input.toolCallGroupId = group.upstreamGroupId;
|
|
38
|
+
input.toolResponses = group.calls.map((call, index) => ({ toolCallId: call.upstreamToolCallId, content: orderedResults[index].content, toolResponseSummary: `Result for ${call.originalName}`, toolResponseStatus: 'SUCCESS' }));
|
|
39
|
+
} else {
|
|
40
|
+
if (group.calls.length !== 1) throw invalid('Multiple ungrouped tool calls cannot be continued natively.', 'tool_result_group_mismatch');
|
|
41
|
+
input.toolCallId = group.calls[0].upstreamToolCallId;
|
|
42
|
+
input.toolResponse = orderedResults[0].content;
|
|
43
|
+
input.toolResponseSummary = `Result for ${group.calls[0].originalName}`;
|
|
44
|
+
}
|
|
45
|
+
return envelope(request, config, input);
|
|
46
|
+
}
|
|
47
|
+
const input = { chatType: 'USER_QUERY', query: JSON.stringify({ format: 'openai-full-history-v1', messages: buildTranscript(request.messages), tool_choice: request.toolChoice }), conversationId: null, product: 'workspace_v12', toolCallGroupId: null, toolResponses: [] };
|
|
48
|
+
return envelope(request, config, input);
|
|
49
|
+
}
|
package/src/schema.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { invalid } from './errors.mjs';
|
|
2
|
+
const supported = new Set(['type','properties','required','additionalProperties','patternProperties','items','enum','const','anyOf','oneOf','allOf','description','title','default','minimum','maximum','minLength','maxLength','minItems','maxItems','$schema','$id']);
|
|
3
|
+
export function validateSchemaDefinition(schema, path = '$') {
|
|
4
|
+
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) throw invalid(`Tool schema at ${path} must be an object.`, 'invalid_tool_schema');
|
|
5
|
+
for (const key of Object.keys(schema)) if (!supported.has(key)) throw invalid(`Unsupported JSON Schema keyword ${key} at ${path}.`, 'unsupported_tool_schema');
|
|
6
|
+
if (schema.type !== undefined && !['object','array','string','number','integer','boolean','null'].includes(schema.type)) throw invalid(`Unsupported schema type at ${path}.`, 'unsupported_tool_schema');
|
|
7
|
+
if (schema.properties !== undefined) { if (!schema.properties || typeof schema.properties !== 'object' || Array.isArray(schema.properties)) throw invalid(`properties at ${path} must be an object.`, 'invalid_tool_schema'); for (const [k,v] of Object.entries(schema.properties)) validateSchemaDefinition(v, `${path}.properties.${k}`); }
|
|
8
|
+
if (schema.patternProperties !== undefined) { if (!schema.patternProperties || typeof schema.patternProperties !== 'object' || Array.isArray(schema.patternProperties)) throw invalid(`patternProperties at ${path} must be an object.`, 'invalid_tool_schema'); for (const [k,v] of Object.entries(schema.patternProperties)) validateSchemaDefinition(v, `${path}.patternProperties.${k}`); }
|
|
9
|
+
if (schema.required !== undefined && (!Array.isArray(schema.required) || schema.required.some(k => typeof k !== 'string'))) throw invalid(`required at ${path} must be an array of strings.`, 'invalid_tool_schema');
|
|
10
|
+
if (schema.additionalProperties !== undefined && typeof schema.additionalProperties !== 'boolean') throw invalid(`additionalProperties at ${path} supports booleans only.`, 'unsupported_tool_schema');
|
|
11
|
+
if (schema.items !== undefined) validateSchemaDefinition(schema.items, `${path}.items`);
|
|
12
|
+
if (schema.enum !== undefined && (!Array.isArray(schema.enum) || !schema.enum.length)) throw invalid(`enum at ${path} must be a non-empty array.`, 'invalid_tool_schema');
|
|
13
|
+
for (const key of ['minimum','maximum']) if (schema[key] !== undefined && (typeof schema[key] !== 'number' || !Number.isFinite(schema[key]))) throw invalid(`${key} at ${path} must be a finite number.`, 'invalid_tool_schema');
|
|
14
|
+
for (const key of ['minLength','maxLength','minItems','maxItems']) if (schema[key] !== undefined && (!Number.isSafeInteger(schema[key]) || schema[key] < 0)) throw invalid(`${key} at ${path} must be a non-negative integer.`, 'invalid_tool_schema');
|
|
15
|
+
for (const key of ['anyOf','oneOf','allOf']) if (schema[key] !== undefined) { if (!Array.isArray(schema[key]) || !schema[key].length) throw invalid(`${key} at ${path} must be a non-empty array.`, 'invalid_tool_schema'); schema[key].forEach((v,i)=>validateSchemaDefinition(v,`${path}.${key}[${i}]`)); }
|
|
16
|
+
return schema;
|
|
17
|
+
}
|
|
18
|
+
function fail(path, message) { throw invalid(`Tool arguments ${path} ${message}.`, 'tool_arguments_schema_mismatch'); }
|
|
19
|
+
export function validateValue(value, schema, path = '$') {
|
|
20
|
+
if (schema.const !== undefined && JSON.stringify(value) !== JSON.stringify(schema.const)) fail(path, 'does not match const');
|
|
21
|
+
if (schema.enum && !schema.enum.some(v => JSON.stringify(v) === JSON.stringify(value))) fail(path, 'is not in enum');
|
|
22
|
+
if (schema.anyOf && !schema.anyOf.some(s => { try { validateValue(value,s,path); return true; } catch { return false; } })) fail(path,'does not match anyOf');
|
|
23
|
+
if (schema.oneOf && schema.oneOf.filter(s => { try { validateValue(value,s,path); return true; } catch { return false; } }).length !== 1) fail(path,'does not match exactly one oneOf branch');
|
|
24
|
+
if (schema.allOf) schema.allOf.forEach(s=>validateValue(value,s,path));
|
|
25
|
+
const type = schema.type;
|
|
26
|
+
if (type === 'object') { if (!value || typeof value !== 'object' || Array.isArray(value)) fail(path,'must be an object'); const req=schema.required||[]; for(const k of req) if(!(k in value)) fail(`${path}.${k}`,'is required'); if(schema.additionalProperties===false) for(const k of Object.keys(value)) if(!schema.properties?.[k]) fail(`${path}.${k}`,'is not allowed'); for(const [k,v] of Object.entries(value)) if(schema.properties?.[k]) validateValue(v,schema.properties[k],`${path}.${k}`); }
|
|
27
|
+
else if (type === 'array') { if(!Array.isArray(value)) fail(path,'must be an array'); if(schema.minItems!==undefined&&value.length<schema.minItems) fail(path,'has too few items'); if(schema.maxItems!==undefined&&value.length>schema.maxItems) fail(path,'has too many items'); if(schema.items) value.forEach((v,i)=>validateValue(v,schema.items,`${path}[${i}]`)); }
|
|
28
|
+
else if (type === 'string') { if(typeof value!=='string') fail(path,'must be a string'); if(schema.minLength!==undefined&&value.length<schema.minLength) fail(path,'is too short'); if(schema.maxLength!==undefined&&value.length>schema.maxLength) fail(path,'is too long'); }
|
|
29
|
+
else if (type === 'number' && (typeof value!=='number'||!Number.isFinite(value))) fail(path,'must be a finite number');
|
|
30
|
+
else if (type === 'integer' && !Number.isSafeInteger(value)) fail(path,'must be an integer');
|
|
31
|
+
else if (type === 'boolean' && typeof value!=='boolean') fail(path,'must be a boolean');
|
|
32
|
+
else if (type === 'null' && value!==null) fail(path,'must be null');
|
|
33
|
+
if(typeof value==='number'){ if(schema.minimum!==undefined&&value<schema.minimum) fail(path,'is below minimum'); if(schema.maximum!==undefined&&value>schema.maximum) fail(path,'is above maximum'); }
|
|
34
|
+
return true;
|
|
35
|
+
}
|
package/src/server.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { SessionMappingStore } from './session-store.mjs';
|
|
2
|
+
import { handleAdminRequest } from './admin.mjs';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { authenticate, assertModel, ConcurrencyGate } from './config.mjs';
|
|
6
|
+
import { ProviderError, errorBody, invalid } from './errors.mjs';
|
|
7
|
+
import { normalizeOpenAIRequest } from './openai.mjs';
|
|
8
|
+
import { canonicalEvents } from './transport.mjs';
|
|
9
|
+
import { createResponseState } from './openai-response.mjs';
|
|
10
|
+
import { encodeOpenAIFrame } from './sse.mjs';
|
|
11
|
+
import { CorrelationStore } from './correlation-store.mjs';
|
|
12
|
+
import { catalogMetadata } from './postman-request.mjs';
|
|
13
|
+
import { webSessionCatalogMetadata } from './web-session-request.mjs';
|
|
14
|
+
import { upstreamUrl, webSessionUrl } from './config.mjs';
|
|
15
|
+
async function readJson(req,limit,signal){const chunks=[];let size=0;for await(const chunk of req){signal.throwIfAborted();size+=chunk.length;if(size>limit)throw new ProviderError(413,'Request body exceeds the configured limit.','invalid_request_error','request_too_large');chunks.push(chunk);}let value;try{value=JSON.parse(Buffer.concat(chunks).toString('utf8'));}catch{throw invalid('Request body is not valid JSON.','invalid_json');}return value;}
|
|
16
|
+
const json=(res,status,value,headers={})=>{res.writeHead(status,{'content-type':'application/json; charset=utf-8',...headers});res.end(JSON.stringify(value));};
|
|
17
|
+
export function createProviderServer(config,{fetchImpl=fetch,logger=console,correlationStore=null,sessionStore=null}={}){const sessions=sessionStore||new SessionMappingStore();const gate=new ConcurrencyGate(config.maxConcurrent);const store=correlationStore||new CorrelationStore({pendingTtlMs:config.correlationPendingTtlMs,terminalTtlMs:config.correlationTerminalTtlMs,inflightTtlMs:config.correlationInflightTtlMs,maxGroups:config.correlationMaxGroups,maxCalls:config.correlationMaxCalls,maxBytes:config.correlationMaxBytes});return http.createServer(async(req,res)=>{const requestId=randomUUID();res.setHeader('request-id',requestId);const controller=new AbortController();const timer=setTimeout(()=>controller.abort(new ProviderError(504,'Request timed out.','api_error','request_timeout')),config.timeoutMs);timer.unref();const onClose=()=>{if(!res.writableEnded)controller.abort(new ProviderError(499,'Client disconnected.','api_error','client_disconnected'));};res.on('close',onClose);let leave,claim=null,dispatched=false;
|
|
18
|
+
try{const url=new URL(req.url,'http://localhost');if(await handleAdminRequest(req,res,config,url.pathname))return;if(url.pathname==='/health'&&req.method==='GET'){json(res,200,{status:'ok',upstream_verified:false,...(config.exposeHealthDetails?{models:config.models.length,active:gate.active,correlations:store.size}:{})});return;}if(url.pathname==='/v1/models'&&req.method==='GET'){authenticate(req.headers.authorization,config.apiKey,config);json(res,200,{object:'list',data:config.models.map(id=>({id,object:'model',created:0,owned_by:'postman-openai-provider'}))});return;}const principal=authenticate(req.headers.authorization,config.apiKey,config);if(url.pathname!=='/v1/chat/completions'||req.method!=='POST')throw new ProviderError(404,'Route not found.','invalid_request_error','not_found');leave=gate.enter();const body=await readJson(req,config.maxBodyBytes,controller.signal);assertModel(body.model,config);const request=normalizeOpenAIRequest(body,config);
|
|
19
|
+
const sessionId = req.headers['x-session-id'] || req.headers['session-id'] || body.user || null;
|
|
20
|
+
const isNewSessionRequest = request.messages.some(m => typeof m.content === 'string' && (m.content.trim() === '/new' || m.content.includes('[reset-session]')));
|
|
21
|
+
if (isNewSessionRequest && sessionId) {
|
|
22
|
+
sessions.reset(sessionId);
|
|
23
|
+
}
|
|
24
|
+
if (!request.toolResults.length && sessionId && !isNewSessionRequest) {
|
|
25
|
+
const boundConvoId = sessions.getConversationId(sessionId);
|
|
26
|
+
if (boundConvoId) {
|
|
27
|
+
request.establishedConversationId = boundConvoId;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const dynamicSubdomain = req.headers['x-postman-subdomain'] || req.headers['x-team-subdomain'] || config.webSessionSubdomain;
|
|
31
|
+
const dynamicWorkspaceId = req.headers['x-postman-workspace-id'] || req.headers['x-workspace-id'] || config.workspaceId;
|
|
32
|
+
const effectiveConfig = (devSubdomain => devSubdomain !== config.webSessionSubdomain || dynamicWorkspaceId !== config.workspaceId ? { ...config, webSessionSubdomain: devSubdomain, workspaceId: dynamicWorkspaceId } : config)(dynamicSubdomain);
|
|
33
|
+
const transportOrigin=effectiveConfig.transportStrategy==='web_session'?webSessionUrl(effectiveConfig).origin:upstreamUrl(effectiveConfig).origin;const effectiveCatalogMetadata=effectiveConfig.transportStrategy==='web_session'?webSessionCatalogMetadata(request,effectiveConfig):catalogMetadata(effectiveConfig);
|
|
34
|
+
const traceId = typeof req.headers['x-trace-id'] === 'string' && /^[A-Za-z0-9._-]{1,128}$/.test(req.headers['x-trace-id']) ? req.headers['x-trace-id'] : null;
|
|
35
|
+
if (traceId) logger.info?.({ event: 'request_trace', traceId, model: request.model, sessionId: sessionId || null, transport: effectiveConfig.transportStrategy, origin: transportOrigin });
|
|
36
|
+
if(request.toolResults.length){
|
|
37
|
+
if(config.correlationEnabled===false)throw invalid('Tool-result continuation is disabled.','unsupported_tool_result_continuation');
|
|
38
|
+
let group = null;
|
|
39
|
+
try {
|
|
40
|
+
group = store.resolve(principal.principalId, request.toolResults.map(result => result.toolCallId));
|
|
41
|
+
claim = store.claim({principalId:principal.principalId,results:request.toolResults,workspaceId:config.workspaceId,model:request.model,transportStrategy:config.transportStrategy,transportOrigin,transcript:request.transcriptPrefix,toolsBinding:request.toolsBinding,catalogMetadata:effectiveCatalogMetadata});
|
|
42
|
+
// Keep the complete client-declared tool registry on continuation turns.
|
|
43
|
+
// Restricting the maps to the calls from the immediately preceding
|
|
44
|
+
// group makes a valid sequential next-tool call look undeclared.
|
|
45
|
+
for (const call of group.calls) {
|
|
46
|
+
if (!request.toolNameMap.has(`openai__${call.originalName}`)) throw invalid('Tool continuation references a tool outside the current declaration.', 'continuation_tool_binding_mismatch');
|
|
47
|
+
}
|
|
48
|
+
} catch(err) {
|
|
49
|
+
// Fallback if correlation was lost during restart
|
|
50
|
+
const boundConvoId = sessions.getConversationId(sessionId);
|
|
51
|
+
if (boundConvoId) {
|
|
52
|
+
claim = {
|
|
53
|
+
conversationId: boundConvoId,
|
|
54
|
+
orderedResults: request.toolResults,
|
|
55
|
+
syntheticToolCallId: request.toolResults[0]?.toolCallId || 'call_01',
|
|
56
|
+
group: {
|
|
57
|
+
conversationId: boundConvoId,
|
|
58
|
+
calls: request.toolResults.map(tr => ({
|
|
59
|
+
upstreamToolCallId: tr.toolCallId,
|
|
60
|
+
originalName: 'tool_response'
|
|
61
|
+
}))
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
} else {
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const state=createResponseState(request);const events=[];for await(const event of canonicalEvents(request,effectiveConfig,{fetchImpl,signal:controller.signal,continuation:claim,onDispatched:()=>{dispatched=true;}})){state.apply(event);events.push(event);if(event.type==='response_start'&&event.conversationId&&sessionId){sessions.setConversationId(sessionId,event.conversationId);}if(request.stream&&!state.hasTools()&&event.type!=='tool_call_start'&&event.type!=='tool_args_delta'){if(!res.headersSent)res.writeHead(200,{'content-type':'text/event-stream; charset=utf-8','cache-control':'no-cache, no-transform','x-accel-buffering':'no'});const chunk=state.chunk(event);if(chunk)res.write(encodeOpenAIFrame(chunk));}}
|
|
70
|
+
const assistant=state.message();if(state.hasTools()){const transcript=[...request.messages,{...assistant,content:assistant.content??''}];state.admit(store,{principalId:principal.principalId,workspaceId:config.workspaceId,model:request.model,transportStrategy:config.transportStrategy,transportOrigin,transcript,toolsBinding:request.toolsBinding,catalogMetadata:effectiveCatalogMetadata});}
|
|
71
|
+
if(request.stream){if(!res.headersSent)res.writeHead(200,{'content-type':'text/event-stream; charset=utf-8','cache-control':'no-cache, no-transform','x-accel-buffering':'no'});if(state.hasTools()){res.write(encodeOpenAIFrame({id:state.id,object:'chat.completion.chunk',created:state.created,model:request.model,choices:[{index:0,delta:{role:'assistant',content:''},finish_reason:null}]}));for(const chunk of state.toolChunks())res.write(encodeOpenAIFrame(chunk));const finish=events.findLast(event=>event.type==='finish');if(finish)res.write(encodeOpenAIFrame(state.chunk(finish)));}res.end(encodeOpenAIFrame('[DONE]'));if(claim)store.complete(claim.group);}
|
|
72
|
+
else{const result=state.result();if(claim)store.complete(claim.group,result);json(res,200,result);}
|
|
73
|
+
}catch(cause){if(claim?.group?.state==='INFLIGHT'){if(cause?.preSend===true&&!dispatched)store.releasePreSend(claim.group);else store.uncertain(claim.group);}const error=controller.signal.aborted&&controller.signal.reason instanceof ProviderError?controller.signal.reason:cause instanceof ProviderError?cause:new ProviderError(500,'The provider could not complete the request.','api_error','internal_error');logger.error?.(JSON.stringify({event:'request_error',request_id:requestId,status:error.status,code:error.code,...(error.upstreamDetail?{upstream:error.upstreamDetail}:{})}));if(!res.destroyed){if(res.headersSent){res.end(encodeOpenAIFrame({error:errorBody(error,requestId).error}));}else{const headers=error.retryAfter?{'retry-after':String(error.retryAfter)}:{};json(res,error.status,errorBody(error,requestId),headers);}}}finally{clearTimeout(timer);res.off('close',onClose);leave?.();}});}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
|
|
2
|
+
export class SessionMappingStore {
|
|
3
|
+
#conversations = new Map();
|
|
4
|
+
#expiresAt = new Map();
|
|
5
|
+
|
|
6
|
+
constructor({ ttlMs = 24 * 60 * 60 * 1000, maxSessions = 1000 } = {}) {
|
|
7
|
+
this.ttlMs = ttlMs;
|
|
8
|
+
this.maxSessions = maxSessions;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
getConversationId(sessionId) {
|
|
13
|
+
if (!sessionId) return null;
|
|
14
|
+
this.sweep();
|
|
15
|
+
return this.#conversations.get(sessionId) || null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
setConversationId(sessionId, conversationId) {
|
|
20
|
+
if (!sessionId || !conversationId) return;
|
|
21
|
+
this.sweep();
|
|
22
|
+
if (this.#conversations.size >= this.maxSessions) {
|
|
23
|
+
const oldest = this.#conversations.keys().next().value;
|
|
24
|
+
if (oldest) { this.#conversations.delete(oldest); this.#expiresAt.delete(oldest); }
|
|
25
|
+
}
|
|
26
|
+
this.#conversations.set(sessionId, conversationId);
|
|
27
|
+
this.#expiresAt.set(sessionId, Date.now() + this.ttlMs);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
reset(sessionId) {
|
|
32
|
+
if (!sessionId) return;
|
|
33
|
+
this.#conversations.delete(sessionId);
|
|
34
|
+
this.#expiresAt.delete(sessionId);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
sweep(now = Date.now()) {
|
|
39
|
+
for (const [sessionId, expires] of this.#expiresAt.entries()) {
|
|
40
|
+
if (now >= expires) {
|
|
41
|
+
this.#conversations.delete(sessionId);
|
|
42
|
+
this.#expiresAt.delete(sessionId);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get size() { return this.#conversations.size; }
|
|
48
|
+
}
|
package/src/sse.mjs
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { upstream } from './errors.mjs';
|
|
2
|
+
export async function* abortableBytes(body, signal) {
|
|
3
|
+
if(!body) throw upstream('Postman gateway returned no response body.','upstream_empty_body');
|
|
4
|
+
const reader=body.getReader(); const cancel=()=>void reader.cancel(signal.reason).catch(()=>{}); signal.addEventListener('abort',cancel,{once:true});
|
|
5
|
+
try{while(true){signal.throwIfAborted();const {done,value}=await reader.read();signal.throwIfAborted();if(done)return;yield value;}}finally{signal.removeEventListener('abort',cancel);await reader.cancel().catch(()=>{});reader.releaseLock();}
|
|
6
|
+
}
|
|
7
|
+
export async function* parseSSE(source,maxEventBytes) {
|
|
8
|
+
const decoder=new TextDecoder('utf-8',{fatal:true}); let buffer='',event='',data=[],size=0;
|
|
9
|
+
const line=value=>{if(value===''){const frame=data.length?{event:event||'message',data:data.join('\n')}:null;event='';data=[];size=0;return frame;}if(value.startsWith(':'))return null;const colon=value.indexOf(':');const key=colon<0?value:value.slice(0,colon);const content=colon<0?'':value.slice(colon+1).replace(/^ /,'');if(key==='data'){data.push(content);size+=Buffer.byteLength(content);if(size>maxEventBytes)throw upstream('Upstream SSE event exceeds the configured limit.','upstream_event_too_large');}if(key==='event')event=content;return null;};
|
|
10
|
+
try{for await(const bytes of source){buffer+=typeof bytes==='string'?bytes:decoder.decode(bytes,{stream:true});if(Buffer.byteLength(buffer)+size>maxEventBytes*2)throw upstream('Upstream SSE parser buffer exceeds the configured limit.','upstream_event_too_large');let match;while((match=/\r\n|\r|\n/.exec(buffer))){if(match[0]==='\r'&&match.index===buffer.length-1)break;const frame=line(buffer.slice(0,match.index));buffer=buffer.slice(match.index+match[0].length);if(frame)yield frame;}}buffer+=decoder.decode();}catch(error){if(error?.code)throw error;throw upstream('Postman gateway sent invalid UTF-8 SSE.','upstream_invalid_utf8');}
|
|
11
|
+
if(buffer.endsWith('\r'))buffer=buffer.slice(0,-1);if(buffer){const frame=line(buffer);if(frame)yield frame;}const frame=line('');if(frame)yield frame;
|
|
12
|
+
}
|
|
13
|
+
export const encodeOpenAIFrame=value=>`data: ${typeof value==='string'?value:JSON.stringify(value)}\n\n`;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { upstream } from './errors.mjs';
|
|
2
|
+
import { abortableBytes, parseSSE } from './sse.mjs';
|
|
3
|
+
import { buildPostmanRequest } from './postman-request.mjs';
|
|
4
|
+
import { createRawEventAdapter } from './postman-events.mjs';
|
|
5
|
+
import { buildWebSessionRequest } from './web-session-request.mjs';
|
|
6
|
+
import { createWebSessionEventParser } from './web-session-events.mjs';
|
|
7
|
+
|
|
8
|
+
async function fetchUpstream(url, init, { fetchImpl, signal, onDispatched }) {
|
|
9
|
+
try {
|
|
10
|
+
const response = await fetchImpl(url, { ...init, signal });
|
|
11
|
+
onDispatched?.();
|
|
12
|
+
return response;
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if (error?.preSend === true) error.preSend = true;
|
|
15
|
+
else error.dispatchUncertain = true;
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function assertEventStream(response, label) {
|
|
21
|
+
if (!response.ok) {
|
|
22
|
+
await response.body?.cancel().catch(() => {});
|
|
23
|
+
throw upstream(`${label} returned HTTP ${response.status}.`, 'upstream_http_error', response.status, { retryAfter: response.headers.get('retry-after') });
|
|
24
|
+
}
|
|
25
|
+
if (!response.headers.get('content-type')?.toLowerCase().includes('text/event-stream')) {
|
|
26
|
+
await response.body?.cancel().catch(() => {});
|
|
27
|
+
throw upstream(`${label} did not return text/event-stream.`, 'upstream_content_type_error');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function* accessTokenEvents(request, config, options) {
|
|
32
|
+
const { url, init } = buildPostmanRequest(request, config, options.continuation);
|
|
33
|
+
const response = await fetchUpstream(url, init, options);
|
|
34
|
+
await assertEventStream(response, 'Postman gateway');
|
|
35
|
+
const adapter = createRawEventAdapter({ toolNameMap: request.toolNameMap, maxToolArgumentBytes: config.maxToolArgumentBytes, maxOutputBytes: config.maxOutputBytes });
|
|
36
|
+
let done = false;
|
|
37
|
+
for await (const frame of parseSSE(abortableBytes(response.body, options.signal), config.maxEventBytes)) {
|
|
38
|
+
options.signal.throwIfAborted();
|
|
39
|
+
if (frame.data.trim() === '[DONE]') { done = true; break; }
|
|
40
|
+
for (const event of adapter.adapt(frame)) yield event;
|
|
41
|
+
}
|
|
42
|
+
if (!done) throw upstream('Postman stream disconnected before [DONE].', 'upstream_incomplete_stream');
|
|
43
|
+
for (const event of adapter.finish()) yield event;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function* webSessionEvents(request, config, options) {
|
|
47
|
+
const { url, init } = buildWebSessionRequest(request, config, options.continuation);
|
|
48
|
+
const response = await fetchUpstream(url, init, options);
|
|
49
|
+
await assertEventStream(response, 'Postman web-session gateway');
|
|
50
|
+
const parser = createWebSessionEventParser({
|
|
51
|
+
maxEventBytes: config.maxEventBytes,
|
|
52
|
+
maxOutputBytes: config.maxOutputBytes,
|
|
53
|
+
requestedModel: request.model,
|
|
54
|
+
selectedUpstreamModel: config.selectedUpstreamModel,
|
|
55
|
+
allowedObservedModels: config.webSessionObservedModels,
|
|
56
|
+
toolNameMap: request.toolNameMap,
|
|
57
|
+
maxToolArgumentBytes: config.maxToolArgumentBytes,
|
|
58
|
+
});
|
|
59
|
+
for await (const bytes of abortableBytes(response.body, options.signal)) {
|
|
60
|
+
options.signal.throwIfAborted();
|
|
61
|
+
parser.feed(bytes);
|
|
62
|
+
}
|
|
63
|
+
const result = parser.end();
|
|
64
|
+
if (!result.successful) {
|
|
65
|
+
if (result.error) {
|
|
66
|
+
const error = upstream(result.error.message, result.error.code, result.error.status, { cause: result.error });
|
|
67
|
+
if (typeof result.error.observedModel === 'string') error.observedModel = result.error.observedModel;
|
|
68
|
+
if (typeof result.error.observedModelField === 'string') error.observedModelField = result.error.observedModelField;
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
throw upstream('Postman web-session did not prove successful completion.', 'upstream_incomplete_stream');
|
|
72
|
+
}
|
|
73
|
+
yield { type: 'response_start', conversationId: result.observations.conversationId };
|
|
74
|
+
if (result.text) yield { type: 'text_delta', text: result.text };
|
|
75
|
+
for (const call of result.toolCalls) {
|
|
76
|
+
yield { type: 'tool_call_start', index: call.index, id: call.upstreamId, name: call.originalName, groupId: call.groupId };
|
|
77
|
+
if (call.arguments) yield { type: 'tool_args_delta', index: call.index, arguments: call.arguments };
|
|
78
|
+
}
|
|
79
|
+
if (result.observations.usage) yield { type: 'usage', usage: result.observations.usage, estimated: false };
|
|
80
|
+
yield { type: 'finish', reason: result.toolCalls.length ? 'tool_calls' : 'stop' };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function* canonicalEvents(request, config, options = {}) {
|
|
84
|
+
const resolved = { fetchImpl: fetch, signal: undefined, continuation: null, onDispatched: null, ...options };
|
|
85
|
+
if (config.transportStrategy === 'web_session') {
|
|
86
|
+
yield* webSessionEvents(request, config, resolved);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
yield* accessTokenEvents(request, config, resolved);
|
|
90
|
+
}
|