structformatter 0.1.2
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/CHANGELOG.md +34 -0
- package/LICENSE +21 -0
- package/README.md +122 -0
- package/README.zh-CN.md +122 -0
- package/config.example.yaml +37 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +48 -0
- package/dist/config/load.d.ts +3 -0
- package/dist/config/load.js +62 -0
- package/dist/config/schema.d.ts +72 -0
- package/dist/config/schema.js +73 -0
- package/dist/enforce/engine.d.ts +15 -0
- package/dist/enforce/engine.js +149 -0
- package/dist/enforce/errors.d.ts +18 -0
- package/dist/enforce/errors.js +41 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +8 -0
- package/dist/json/extract.d.ts +2 -0
- package/dist/json/extract.js +27 -0
- package/dist/json/parse.d.ts +10 -0
- package/dist/json/parse.js +28 -0
- package/dist/patch/patch.d.ts +3 -0
- package/dist/patch/patch.js +73 -0
- package/dist/prompt/build.d.ts +9 -0
- package/dist/prompt/build.js +21 -0
- package/dist/prompt/sanitize_schema.d.ts +1 -0
- package/dist/prompt/sanitize_schema.js +26 -0
- package/dist/prompt/templates.d.ts +6 -0
- package/dist/prompt/templates.js +28 -0
- package/dist/providers/index.d.ts +12 -0
- package/dist/providers/index.js +42 -0
- package/dist/providers/openai_compatible.d.ts +22 -0
- package/dist/providers/openai_compatible.js +91 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +218 -0
- package/dist/stream/sse.d.ts +2 -0
- package/dist/stream/sse.js +57 -0
- package/dist/types/internal.d.ts +53 -0
- package/dist/types/internal.js +15 -0
- package/dist/types/openai.d.ts +59 -0
- package/dist/types/openai.js +2 -0
- package/dist/validate/ajv.d.ts +5 -0
- package/dist/validate/ajv.js +18 -0
- package/dist/validate/cache.d.ts +11 -0
- package/dist/validate/cache.js +62 -0
- package/dist/validate/errors.d.ts +8 -0
- package/dist/validate/errors.js +13 -0
- package/package.json +66 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.createServer = createServer;
|
|
7
|
+
const fastify_1 = __importDefault(require("fastify"));
|
|
8
|
+
const node_crypto_1 = require("node:crypto");
|
|
9
|
+
const node_stream_1 = require("node:stream");
|
|
10
|
+
const engine_1 = require("./enforce/engine");
|
|
11
|
+
const providers_1 = require("./providers");
|
|
12
|
+
const cache_1 = require("./validate/cache");
|
|
13
|
+
const sse_1 = require("./stream/sse");
|
|
14
|
+
function isRawCapableAdapter(v) {
|
|
15
|
+
return typeof v.chatCompletionsRaw === 'function';
|
|
16
|
+
}
|
|
17
|
+
function setProxyHeaders(reply, headers) {
|
|
18
|
+
const skip = new Set(['content-length', 'transfer-encoding', 'connection', 'keep-alive']);
|
|
19
|
+
headers.forEach((value, key) => {
|
|
20
|
+
if (skip.has(key.toLowerCase()))
|
|
21
|
+
return;
|
|
22
|
+
reply.header(key, value);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
function toBoolHeader(v) {
|
|
26
|
+
if (typeof v !== 'string')
|
|
27
|
+
return false;
|
|
28
|
+
const s = v.trim().toLowerCase();
|
|
29
|
+
return s !== '' && s !== '0' && s !== 'false';
|
|
30
|
+
}
|
|
31
|
+
function parseMaxAttemptsHeader(v) {
|
|
32
|
+
if (typeof v !== 'string')
|
|
33
|
+
return null;
|
|
34
|
+
const n = Number(v);
|
|
35
|
+
if (!Number.isInteger(n))
|
|
36
|
+
return null;
|
|
37
|
+
return Math.max(1, Math.min(n, 10));
|
|
38
|
+
}
|
|
39
|
+
function openaiError(type, message, details) {
|
|
40
|
+
return { error: { type, message, details } };
|
|
41
|
+
}
|
|
42
|
+
function chatCompletionResponse(args) {
|
|
43
|
+
const base = {
|
|
44
|
+
id: `chatcmpl_${(0, node_crypto_1.randomUUID)().replace(/-/g, '')}`,
|
|
45
|
+
object: 'chat.completion',
|
|
46
|
+
created: Math.floor(Date.now() / 1000),
|
|
47
|
+
model: args.model,
|
|
48
|
+
choices: [
|
|
49
|
+
{
|
|
50
|
+
index: 0,
|
|
51
|
+
message: { role: 'assistant', content: args.content },
|
|
52
|
+
finish_reason: 'stop',
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
usage: args.usage,
|
|
56
|
+
};
|
|
57
|
+
if (args.debug)
|
|
58
|
+
base.__debug = args.debug;
|
|
59
|
+
return base;
|
|
60
|
+
}
|
|
61
|
+
function createServer(config) {
|
|
62
|
+
const app = (0, fastify_1.default)({
|
|
63
|
+
logger: process.env.NODE_ENV === 'test'
|
|
64
|
+
? false
|
|
65
|
+
: {
|
|
66
|
+
level: process.env.STRUCTFORMATTER_LOG_LEVEL ??
|
|
67
|
+
process.env.STRUCTUREDFORMATTER_LOG_LEVEL ??
|
|
68
|
+
'info',
|
|
69
|
+
},
|
|
70
|
+
bodyLimit: config.server.request_body_limit_mb * 1024 * 1024,
|
|
71
|
+
genReqId: () => (0, node_crypto_1.randomUUID)(),
|
|
72
|
+
});
|
|
73
|
+
const registry = (0, providers_1.createProviderRegistry)(config);
|
|
74
|
+
const validatorCache = new cache_1.ValidatorCache({ maxSize: config.enforcement.validator_cache_size });
|
|
75
|
+
app.get('/healthz', async () => ({ ok: true }));
|
|
76
|
+
app.get('/v1/models', async () => ({
|
|
77
|
+
object: 'list',
|
|
78
|
+
data: (0, providers_1.listModelIds)(config).map((id) => ({ id, object: 'model' })),
|
|
79
|
+
}));
|
|
80
|
+
app.post('/v1/chat/completions', async (req, reply) => {
|
|
81
|
+
const body = req.body;
|
|
82
|
+
if (!body || typeof body !== 'object') {
|
|
83
|
+
return reply.code(400).send(openaiError('invalid_request', 'request body must be a JSON object'));
|
|
84
|
+
}
|
|
85
|
+
const request = body;
|
|
86
|
+
if (typeof request.model !== 'string' || !request.model) {
|
|
87
|
+
return reply.code(400).send(openaiError('invalid_request', 'missing model'));
|
|
88
|
+
}
|
|
89
|
+
if (!Array.isArray(request.messages)) {
|
|
90
|
+
return reply.code(400).send(openaiError('invalid_request', 'messages must be an array'));
|
|
91
|
+
}
|
|
92
|
+
const requestId = String(req.id);
|
|
93
|
+
const debug = toBoolHeader(req.headers['x-sf-debug']);
|
|
94
|
+
const maxAttemptsOverride = parseMaxAttemptsHeader(req.headers['x-sf-max-attempts']);
|
|
95
|
+
let resolved;
|
|
96
|
+
try {
|
|
97
|
+
resolved = (0, providers_1.resolveModel)(request.model, config);
|
|
98
|
+
}
|
|
99
|
+
catch (e) {
|
|
100
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
101
|
+
return reply.code(400).send(openaiError('invalid_request', msg));
|
|
102
|
+
}
|
|
103
|
+
let adapter;
|
|
104
|
+
try {
|
|
105
|
+
adapter = (0, providers_1.getAdapter)(registry, resolved.provider);
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
109
|
+
return reply.code(400).send(openaiError('invalid_request', msg));
|
|
110
|
+
}
|
|
111
|
+
const rf = request.response_format;
|
|
112
|
+
if (rf && rf.type === 'json_schema') {
|
|
113
|
+
if (request.stream) {
|
|
114
|
+
return reply
|
|
115
|
+
.code(400)
|
|
116
|
+
.send(openaiError('invalid_request', 'streaming not supported for schema-enforced requests'));
|
|
117
|
+
}
|
|
118
|
+
const schema = rf.json_schema?.schema;
|
|
119
|
+
if (!schema || typeof schema !== 'object') {
|
|
120
|
+
return reply
|
|
121
|
+
.code(400)
|
|
122
|
+
.send(openaiError('invalid_request', 'response_format.json_schema.schema must be an object'));
|
|
123
|
+
}
|
|
124
|
+
const schemaBytes = Buffer.byteLength(JSON.stringify(schema), 'utf8');
|
|
125
|
+
if (schemaBytes > config.enforcement.schema_max_bytes) {
|
|
126
|
+
return reply.code(400).send(openaiError('invalid_request', 'schema too large'));
|
|
127
|
+
}
|
|
128
|
+
const policy = {
|
|
129
|
+
maxAttempts: maxAttemptsOverride ?? config.enforcement.max_attempts,
|
|
130
|
+
timeoutMsPerAttempt: config.enforcement.timeout_ms_per_attempt,
|
|
131
|
+
enableJsonRepair: config.enforcement.enable_jsonrepair,
|
|
132
|
+
enableDeterministicFix: config.enforcement.enable_deterministic_fix,
|
|
133
|
+
enableTypeCoercion: config.enforcement.enable_type_coercion,
|
|
134
|
+
};
|
|
135
|
+
const result = await (0, engine_1.enforceJsonSchema)({
|
|
136
|
+
requestId,
|
|
137
|
+
adapter,
|
|
138
|
+
provider: resolved.provider,
|
|
139
|
+
baseUrl: config.providers[resolved.provider]?.base_url ?? '',
|
|
140
|
+
upstreamModel: resolved.upstreamModel,
|
|
141
|
+
originalRequest: request,
|
|
142
|
+
schema: schema,
|
|
143
|
+
policy,
|
|
144
|
+
validatorCache,
|
|
145
|
+
debug,
|
|
146
|
+
});
|
|
147
|
+
if (!result.ok) {
|
|
148
|
+
const status = result.error.type === 'upstream_error' ? 502 : 422;
|
|
149
|
+
const payload = { error: result.error };
|
|
150
|
+
if (debug) {
|
|
151
|
+
payload.__debug = { request_id: requestId, attempts: result.attempts, traces: result.attemptTraces };
|
|
152
|
+
}
|
|
153
|
+
return reply.code(status).send(payload);
|
|
154
|
+
}
|
|
155
|
+
return reply.code(200).send(chatCompletionResponse({
|
|
156
|
+
model: request.model,
|
|
157
|
+
content: result.jsonText,
|
|
158
|
+
usage: result.usage,
|
|
159
|
+
debug: debug ? { request_id: requestId, attempts: result.attempts, traces: result.attemptTraces } : undefined,
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
const upstreamReq = { ...request, model: resolved.upstreamModel };
|
|
163
|
+
const ctx = {
|
|
164
|
+
requestId,
|
|
165
|
+
timeoutMs: config.enforcement.timeout_ms_per_attempt,
|
|
166
|
+
provider: resolved.provider,
|
|
167
|
+
baseUrl: config.providers[resolved.provider]?.base_url ?? '',
|
|
168
|
+
upstreamModel: resolved.upstreamModel,
|
|
169
|
+
};
|
|
170
|
+
if (request.stream) {
|
|
171
|
+
if (!isRawCapableAdapter(adapter)) {
|
|
172
|
+
return reply
|
|
173
|
+
.code(400)
|
|
174
|
+
.send(openaiError('invalid_request', 'streaming not supported for this provider'));
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
const raw = await adapter.chatCompletionsRaw(upstreamReq, ctx);
|
|
178
|
+
setProxyHeaders(reply, raw.response.headers);
|
|
179
|
+
const contentType = raw.response.headers.get('content-type') ?? '';
|
|
180
|
+
const isEventStream = contentType.toLowerCase().includes('text/event-stream');
|
|
181
|
+
const body = raw.response.body;
|
|
182
|
+
if (!body) {
|
|
183
|
+
raw.cleanup();
|
|
184
|
+
return reply.code(200).send();
|
|
185
|
+
}
|
|
186
|
+
const nodeStream = node_stream_1.Readable.fromWeb(body);
|
|
187
|
+
nodeStream.on('end', raw.cleanup);
|
|
188
|
+
nodeStream.on('error', raw.cleanup);
|
|
189
|
+
reply.raw.on('close', () => {
|
|
190
|
+
raw.abort();
|
|
191
|
+
raw.cleanup();
|
|
192
|
+
});
|
|
193
|
+
reply.raw.on('error', () => {
|
|
194
|
+
raw.abort();
|
|
195
|
+
raw.cleanup();
|
|
196
|
+
});
|
|
197
|
+
const outStream = isEventStream
|
|
198
|
+
? nodeStream.pipe((0, sse_1.createSseModelRewriteTransform)(request.model))
|
|
199
|
+
: nodeStream;
|
|
200
|
+
return reply.code(raw.response.status).send(outStream);
|
|
201
|
+
}
|
|
202
|
+
catch (e) {
|
|
203
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
204
|
+
return reply.code(502).send(openaiError('upstream_error', msg));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
const upstreamResp = await adapter.chatCompletions(upstreamReq, ctx);
|
|
209
|
+
upstreamResp.model = request.model;
|
|
210
|
+
return reply.code(200).send(upstreamResp);
|
|
211
|
+
}
|
|
212
|
+
catch (e) {
|
|
213
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
214
|
+
return reply.code(502).send(openaiError('upstream_error', msg));
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
return app;
|
|
218
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createSseModelRewriteTransform = createSseModelRewriteTransform;
|
|
4
|
+
const node_stream_1 = require("node:stream");
|
|
5
|
+
function rewriteEvent(eventText, requestedModel) {
|
|
6
|
+
const newline = eventText.includes('\r\n') ? '\r\n' : '\n';
|
|
7
|
+
const lines = eventText.split(/\r?\n/);
|
|
8
|
+
const out = lines.map((line) => {
|
|
9
|
+
if (!line.startsWith('data:'))
|
|
10
|
+
return line;
|
|
11
|
+
const data = line.slice('data:'.length).trimStart();
|
|
12
|
+
if (!data)
|
|
13
|
+
return line;
|
|
14
|
+
if (data === '[DONE]')
|
|
15
|
+
return 'data: [DONE]';
|
|
16
|
+
try {
|
|
17
|
+
const obj = JSON.parse(data);
|
|
18
|
+
if (obj && typeof obj === 'object') {
|
|
19
|
+
const rec = obj;
|
|
20
|
+
if (typeof rec.model === 'string')
|
|
21
|
+
rec.model = requestedModel;
|
|
22
|
+
}
|
|
23
|
+
return `data: ${JSON.stringify(obj)}`;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return line;
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
return out.join(newline);
|
|
30
|
+
}
|
|
31
|
+
function createSseModelRewriteTransform(requestedModel) {
|
|
32
|
+
let buffer = '';
|
|
33
|
+
return new node_stream_1.Transform({
|
|
34
|
+
transform(chunk, _enc, cb) {
|
|
35
|
+
buffer += chunk.toString('utf8');
|
|
36
|
+
while (true) {
|
|
37
|
+
const lf = buffer.indexOf('\n\n');
|
|
38
|
+
const crlf = buffer.indexOf('\r\n\r\n');
|
|
39
|
+
const hasLf = lf !== -1;
|
|
40
|
+
const hasCrlf = crlf !== -1;
|
|
41
|
+
if (!hasLf && !hasCrlf)
|
|
42
|
+
break;
|
|
43
|
+
const idx = hasLf && hasCrlf ? Math.min(lf, crlf) : hasLf ? lf : crlf;
|
|
44
|
+
const sep = idx === crlf ? '\r\n\r\n' : '\n\n';
|
|
45
|
+
const eventText = buffer.slice(0, idx);
|
|
46
|
+
buffer = buffer.slice(idx + sep.length);
|
|
47
|
+
this.push(rewriteEvent(eventText, requestedModel) + sep);
|
|
48
|
+
}
|
|
49
|
+
cb();
|
|
50
|
+
},
|
|
51
|
+
flush(cb) {
|
|
52
|
+
if (buffer)
|
|
53
|
+
this.push(buffer);
|
|
54
|
+
cb();
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { OpenAIChatCompletionsRequest, OpenAIChatCompletionsResponse } from './openai';
|
|
2
|
+
export interface ProviderCapabilities {
|
|
3
|
+
json_object?: boolean;
|
|
4
|
+
tools?: boolean;
|
|
5
|
+
strict_tools?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface RequestContext {
|
|
8
|
+
requestId: string;
|
|
9
|
+
timeoutMs: number;
|
|
10
|
+
provider: string;
|
|
11
|
+
baseUrl: string;
|
|
12
|
+
upstreamModel: string;
|
|
13
|
+
}
|
|
14
|
+
export interface ProviderAdapter {
|
|
15
|
+
name: string;
|
|
16
|
+
supports: ProviderCapabilities;
|
|
17
|
+
chatCompletions(req: OpenAIChatCompletionsRequest, ctx: RequestContext): Promise<OpenAIChatCompletionsResponse>;
|
|
18
|
+
}
|
|
19
|
+
export interface EnforcementPolicy {
|
|
20
|
+
maxAttempts: number;
|
|
21
|
+
timeoutMsPerAttempt: number;
|
|
22
|
+
enableJsonRepair: boolean;
|
|
23
|
+
enableDeterministicFix: boolean;
|
|
24
|
+
enableTypeCoercion: boolean;
|
|
25
|
+
}
|
|
26
|
+
export interface StructuredResult {
|
|
27
|
+
ok: true;
|
|
28
|
+
json: unknown;
|
|
29
|
+
jsonText: string;
|
|
30
|
+
usage: {
|
|
31
|
+
prompt_tokens: number;
|
|
32
|
+
completion_tokens: number;
|
|
33
|
+
total_tokens: number;
|
|
34
|
+
};
|
|
35
|
+
attempts: number;
|
|
36
|
+
attemptTraces?: unknown[];
|
|
37
|
+
}
|
|
38
|
+
export interface StructuredError {
|
|
39
|
+
ok: false;
|
|
40
|
+
error: {
|
|
41
|
+
type: string;
|
|
42
|
+
message: string;
|
|
43
|
+
details?: Record<string, unknown>;
|
|
44
|
+
};
|
|
45
|
+
attempts: number;
|
|
46
|
+
attemptTraces?: unknown[];
|
|
47
|
+
}
|
|
48
|
+
export declare class StructFormatterError extends Error {
|
|
49
|
+
readonly type: string;
|
|
50
|
+
readonly statusCode: number;
|
|
51
|
+
readonly details?: Record<string, unknown>;
|
|
52
|
+
constructor(type: string, message: string, statusCode: number, details?: Record<string, unknown>);
|
|
53
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.StructFormatterError = void 0;
|
|
4
|
+
class StructFormatterError extends Error {
|
|
5
|
+
type;
|
|
6
|
+
statusCode;
|
|
7
|
+
details;
|
|
8
|
+
constructor(type, message, statusCode, details) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.type = type;
|
|
11
|
+
this.statusCode = statusCode;
|
|
12
|
+
this.details = details;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
exports.StructFormatterError = StructFormatterError;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export type OpenAIRole = 'system' | 'user' | 'assistant' | 'tool';
|
|
2
|
+
export interface OpenAIChatMessage {
|
|
3
|
+
role: OpenAIRole;
|
|
4
|
+
content?: string | null;
|
|
5
|
+
name?: string;
|
|
6
|
+
tool_call_id?: string;
|
|
7
|
+
tool_calls?: unknown;
|
|
8
|
+
}
|
|
9
|
+
export interface OpenAIResponseFormatJsonSchema {
|
|
10
|
+
type: 'json_schema';
|
|
11
|
+
json_schema: {
|
|
12
|
+
name: string;
|
|
13
|
+
strict?: boolean;
|
|
14
|
+
schema: Record<string, unknown>;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export interface OpenAIResponseFormatJsonObject {
|
|
18
|
+
type: 'json_object';
|
|
19
|
+
}
|
|
20
|
+
export type OpenAIResponseFormat = OpenAIResponseFormatJsonSchema | OpenAIResponseFormatJsonObject | {
|
|
21
|
+
type: string;
|
|
22
|
+
[k: string]: unknown;
|
|
23
|
+
};
|
|
24
|
+
export interface OpenAIChatCompletionsRequest {
|
|
25
|
+
model: string;
|
|
26
|
+
messages: OpenAIChatMessage[];
|
|
27
|
+
temperature?: number;
|
|
28
|
+
top_p?: number;
|
|
29
|
+
max_tokens?: number;
|
|
30
|
+
stream?: boolean;
|
|
31
|
+
response_format?: OpenAIResponseFormat;
|
|
32
|
+
tools?: unknown;
|
|
33
|
+
tool_choice?: unknown;
|
|
34
|
+
[k: string]: unknown;
|
|
35
|
+
}
|
|
36
|
+
export interface OpenAIUsage {
|
|
37
|
+
prompt_tokens?: number;
|
|
38
|
+
completion_tokens?: number;
|
|
39
|
+
total_tokens?: number;
|
|
40
|
+
[k: string]: unknown;
|
|
41
|
+
}
|
|
42
|
+
export interface OpenAIChatCompletionsChoice {
|
|
43
|
+
index: number;
|
|
44
|
+
message: {
|
|
45
|
+
role: 'assistant';
|
|
46
|
+
content: string | null;
|
|
47
|
+
tool_calls?: unknown;
|
|
48
|
+
};
|
|
49
|
+
finish_reason: string | null;
|
|
50
|
+
}
|
|
51
|
+
export interface OpenAIChatCompletionsResponse {
|
|
52
|
+
id: string;
|
|
53
|
+
object: 'chat.completion';
|
|
54
|
+
created: number;
|
|
55
|
+
model: string;
|
|
56
|
+
choices: OpenAIChatCompletionsChoice[];
|
|
57
|
+
usage?: OpenAIUsage;
|
|
58
|
+
[k: string]: unknown;
|
|
59
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.createAjv = createAjv;
|
|
7
|
+
const _2019_1 = __importDefault(require("ajv/dist/2019"));
|
|
8
|
+
const _2020_1 = __importDefault(require("ajv/dist/2020"));
|
|
9
|
+
function createAjv(draft = '2020-12') {
|
|
10
|
+
const AjvCtor = draft === '2019-09' ? _2019_1.default : _2020_1.default;
|
|
11
|
+
return new AjvCtor({
|
|
12
|
+
allErrors: true,
|
|
13
|
+
strict: false,
|
|
14
|
+
allowUnionTypes: true,
|
|
15
|
+
validateSchema: true,
|
|
16
|
+
messages: true,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ValidateFunction } from 'ajv';
|
|
2
|
+
import { type AjvDraft } from './ajv';
|
|
3
|
+
export declare class ValidatorCache {
|
|
4
|
+
private readonly cache;
|
|
5
|
+
private readonly ajv;
|
|
6
|
+
constructor(opts: {
|
|
7
|
+
maxSize: number;
|
|
8
|
+
draft?: AjvDraft;
|
|
9
|
+
});
|
|
10
|
+
get(schema: unknown): ValidateFunction;
|
|
11
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ValidatorCache = void 0;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const ajv_1 = require("./ajv");
|
|
6
|
+
function stableStringify(value) {
|
|
7
|
+
if (value === null || typeof value !== 'object')
|
|
8
|
+
return JSON.stringify(value);
|
|
9
|
+
if (Array.isArray(value))
|
|
10
|
+
return `[${value.map(stableStringify).join(',')}]`;
|
|
11
|
+
const obj = value;
|
|
12
|
+
const keys = Object.keys(obj).sort();
|
|
13
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(',')}}`;
|
|
14
|
+
}
|
|
15
|
+
function schemaHash(schema) {
|
|
16
|
+
const s = stableStringify(schema);
|
|
17
|
+
return (0, node_crypto_1.createHash)('sha256').update(s).digest('hex');
|
|
18
|
+
}
|
|
19
|
+
class LruCache {
|
|
20
|
+
maxSize;
|
|
21
|
+
map = new Map();
|
|
22
|
+
constructor(maxSize) {
|
|
23
|
+
this.maxSize = maxSize;
|
|
24
|
+
}
|
|
25
|
+
get(key) {
|
|
26
|
+
const v = this.map.get(key);
|
|
27
|
+
if (v === undefined)
|
|
28
|
+
return undefined;
|
|
29
|
+
this.map.delete(key);
|
|
30
|
+
this.map.set(key, v);
|
|
31
|
+
return v;
|
|
32
|
+
}
|
|
33
|
+
set(key, value) {
|
|
34
|
+
if (this.map.has(key))
|
|
35
|
+
this.map.delete(key);
|
|
36
|
+
this.map.set(key, value);
|
|
37
|
+
while (this.map.size > this.maxSize) {
|
|
38
|
+
const oldest = this.map.keys().next().value;
|
|
39
|
+
if (!oldest)
|
|
40
|
+
break;
|
|
41
|
+
this.map.delete(oldest);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
class ValidatorCache {
|
|
46
|
+
cache;
|
|
47
|
+
ajv;
|
|
48
|
+
constructor(opts) {
|
|
49
|
+
this.cache = new LruCache(opts.maxSize);
|
|
50
|
+
this.ajv = (0, ajv_1.createAjv)(opts.draft ?? '2020-12');
|
|
51
|
+
}
|
|
52
|
+
get(schema) {
|
|
53
|
+
const key = schemaHash(schema);
|
|
54
|
+
const existing = this.cache.get(key);
|
|
55
|
+
if (existing)
|
|
56
|
+
return existing;
|
|
57
|
+
const validate = this.ajv.compile(schema);
|
|
58
|
+
this.cache.set(key, validate);
|
|
59
|
+
return validate;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
exports.ValidatorCache = ValidatorCache;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ErrorObject } from 'ajv';
|
|
2
|
+
export interface ValidationErrorSummary {
|
|
3
|
+
path: string;
|
|
4
|
+
message: string;
|
|
5
|
+
keyword: string;
|
|
6
|
+
params?: unknown;
|
|
7
|
+
}
|
|
8
|
+
export declare function summarizeAjvErrors(errors: ErrorObject[] | null | undefined): ValidationErrorSummary[];
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.summarizeAjvErrors = summarizeAjvErrors;
|
|
4
|
+
function summarizeAjvErrors(errors) {
|
|
5
|
+
if (!errors?.length)
|
|
6
|
+
return [];
|
|
7
|
+
return errors.slice(0, 50).map((e) => ({
|
|
8
|
+
path: e.instancePath || '/',
|
|
9
|
+
message: e.message ?? 'validation error',
|
|
10
|
+
keyword: e.keyword,
|
|
11
|
+
params: e.params,
|
|
12
|
+
}));
|
|
13
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "structformatter",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "OpenAI-compatible proxy that enforces JSON Schema structured outputs for upstream LLM APIs that do not support native constrained decoding.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/FrankQDWang/StructFormatter.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/FrankQDWang/StructFormatter#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/FrankQDWang/StructFormatter/issues"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"openai",
|
|
16
|
+
"proxy",
|
|
17
|
+
"structured-outputs",
|
|
18
|
+
"json-schema",
|
|
19
|
+
"ajv",
|
|
20
|
+
"fastify"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=20"
|
|
24
|
+
},
|
|
25
|
+
"bin": {
|
|
26
|
+
"structformatter": "dist/cli.js"
|
|
27
|
+
},
|
|
28
|
+
"main": "dist/index.js",
|
|
29
|
+
"types": "dist/index.d.ts",
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"config.example.yaml",
|
|
33
|
+
"CHANGELOG.md",
|
|
34
|
+
"README.md",
|
|
35
|
+
"README.zh-CN.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"dev": "tsx watch src/cli.ts",
|
|
40
|
+
"build": "tsc -p tsconfig.build.json",
|
|
41
|
+
"start": "node dist/cli.js",
|
|
42
|
+
"lint": "eslint .",
|
|
43
|
+
"format": "prettier -w .",
|
|
44
|
+
"prepack": "npm run build",
|
|
45
|
+
"test": "vitest run"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"ajv": "^8.17.1",
|
|
49
|
+
"fastify": "^5.6.2",
|
|
50
|
+
"jsonrepair": "^3.13.1",
|
|
51
|
+
"undici": "^7.16.0",
|
|
52
|
+
"yaml": "^2.8.2",
|
|
53
|
+
"zod": "^4.2.1"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@types/node": "^25.0.3",
|
|
57
|
+
"@typescript-eslint/eslint-plugin": "^8.50.1",
|
|
58
|
+
"@typescript-eslint/parser": "^8.50.1",
|
|
59
|
+
"eslint": "^9.39.2",
|
|
60
|
+
"eslint-config-prettier": "^10.1.8",
|
|
61
|
+
"prettier": "^3.7.4",
|
|
62
|
+
"tsx": "^4.21.0",
|
|
63
|
+
"typescript": "^5.9.3",
|
|
64
|
+
"vitest": "^4.0.16"
|
|
65
|
+
}
|
|
66
|
+
}
|