streamtrust 0.1.1 → 0.3.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/README.md +88 -12
- package/examples/librechat/README.md +91 -0
- package/examples/librechat/adapter.mjs +515 -0
- package/examples/librechat/provider.mjs +79 -0
- package/examples/librechat/setup.mjs +42 -0
- package/examples/vercel-ai/README.md +108 -0
- package/examples/vercel-ai/adapter.mjs +333 -0
- package/examples/vercel-ai/config.mjs +17 -0
- package/examples/vercel-ai/provider.mjs +79 -0
- package/examples/vercel-ai/reference-app/app/api/chat/route.js +86 -0
- package/examples/vercel-ai/reference-app/app/chat/[id]/chat.jsx +69 -0
- package/examples/vercel-ai/reference-app/app/chat/[id]/page.jsx +20 -0
- package/examples/vercel-ai/reference-app/app/layout.jsx +18 -0
- package/examples/vercel-ai/reference-app/app/page.jsx +8 -0
- package/examples/vercel-ai/reference-app/lib/store.js +37 -0
- package/examples/vercel-ai/reference-app/next.config.mjs +9 -0
- package/examples/vercel-ai/reference-app/npm-shrinkwrap.json +1247 -0
- package/examples/vercel-ai/reference-app/package.json +21 -0
- package/examples/vercel-ai/setup.mjs +27 -0
- package/package.json +17 -2
- package/src/checks.d.ts +36 -0
- package/src/checks.js +105 -0
- package/src/cli.d.ts +2 -0
- package/src/cli.js +38 -11
- package/src/index.js +9 -7
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "streamtrust-vercel-ai-reference-app",
|
|
3
|
+
"private": true,
|
|
4
|
+
"description": "Minimal Next.js + AI SDK chat used as the StreamTrust vercel-ai preset's application under test",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "next build",
|
|
11
|
+
"start": "next start"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@ai-sdk/openai-compatible": "3.0.68",
|
|
15
|
+
"@ai-sdk/react": "4.0.141",
|
|
16
|
+
"ai": "7.0.138",
|
|
17
|
+
"next": "16.4.3",
|
|
18
|
+
"react": "19.3.0",
|
|
19
|
+
"react-dom": "19.3.0"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { assertIsolatedCheckout } from './config.mjs';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
const root = resolve(
|
|
7
|
+
process.env.STREAMTRUST_VERCEL_AI_ROOT ||
|
|
8
|
+
fileURLToPath(new URL('./reference-app', import.meta.url)),
|
|
9
|
+
);
|
|
10
|
+
if (Number(process.versions.node.split('.')[0]) < 22)
|
|
11
|
+
throw new Error('The vercel-ai preset requires Node.js 22+ (AI SDK 7)');
|
|
12
|
+
if (!existsSync(resolve(root, 'package.json')))
|
|
13
|
+
throw new Error(`No package.json in ${root}`);
|
|
14
|
+
assertIsolatedCheckout(root);
|
|
15
|
+
const run = (cmd, args) =>
|
|
16
|
+
execFileSync(cmd, args, {
|
|
17
|
+
cwd: root,
|
|
18
|
+
stdio: 'inherit',
|
|
19
|
+
env: { ...process.env, NEXT_TELEMETRY_DISABLED: '1' },
|
|
20
|
+
});
|
|
21
|
+
// The bundled shrinkwrap pins the fixture; external checkouts retain their own lockfile.
|
|
22
|
+
run('npm', [
|
|
23
|
+
existsSync(resolve(root, 'npm-shrinkwrap.json')) || existsSync(resolve(root, 'package-lock.json')) ? 'ci' : 'install',
|
|
24
|
+
'--no-audit',
|
|
25
|
+
'--no-fund',
|
|
26
|
+
]);
|
|
27
|
+
run('npm', ['run', 'build']);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "streamtrust",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Adapter-driven CI verification for streaming application lifecycle guarantees",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -14,13 +14,28 @@
|
|
|
14
14
|
".": {
|
|
15
15
|
"types": "./src/index.d.ts",
|
|
16
16
|
"import": "./src/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./checks": {
|
|
19
|
+
"types": "./src/checks.d.ts",
|
|
20
|
+
"import": "./src/checks.js"
|
|
17
21
|
}
|
|
18
22
|
},
|
|
19
23
|
"types": "src/index.d.ts",
|
|
20
24
|
"files": [
|
|
21
25
|
"bin/",
|
|
22
26
|
"src/",
|
|
23
|
-
"examples/",
|
|
27
|
+
"examples/http.adapter.mjs",
|
|
28
|
+
"examples/demo-app.mjs",
|
|
29
|
+
"examples/librechat/*.mjs",
|
|
30
|
+
"examples/librechat/README.md",
|
|
31
|
+
"examples/vercel-ai/*.mjs",
|
|
32
|
+
"examples/vercel-ai/README.md",
|
|
33
|
+
"examples/vercel-ai/reference-app/package.json",
|
|
34
|
+
"examples/vercel-ai/reference-app/npm-shrinkwrap.json",
|
|
35
|
+
"examples/vercel-ai/reference-app/next.config.mjs",
|
|
36
|
+
"examples/vercel-ai/reference-app/lib/*.js",
|
|
37
|
+
"examples/vercel-ai/reference-app/app/**/*.js",
|
|
38
|
+
"examples/vercel-ai/reference-app/app/**/*.jsx",
|
|
24
39
|
"README.md",
|
|
25
40
|
"LICENSE"
|
|
26
41
|
],
|
package/src/checks.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ExternalObservation } from './index.js';
|
|
2
|
+
export type LifecycleScenario =
|
|
3
|
+
| 'persistence'
|
|
4
|
+
| 'cancellation'
|
|
5
|
+
| 'regeneration'
|
|
6
|
+
| 'error-retry';
|
|
7
|
+
export const LIFECYCLE_SCENARIOS: readonly LifecycleScenario[];
|
|
8
|
+
export interface LifecycleObservation {
|
|
9
|
+
scenario: LifecycleScenario;
|
|
10
|
+
expected: string;
|
|
11
|
+
original: string;
|
|
12
|
+
previousPrefix: string;
|
|
13
|
+
beforeReload: string;
|
|
14
|
+
reloaded: string;
|
|
15
|
+
messages: Array<{
|
|
16
|
+
id: string;
|
|
17
|
+
parentId: string;
|
|
18
|
+
text: string;
|
|
19
|
+
assistant: boolean;
|
|
20
|
+
unfinished: boolean;
|
|
21
|
+
error: boolean;
|
|
22
|
+
}>;
|
|
23
|
+
callsBeforeReload: number;
|
|
24
|
+
callsAfterReload: number;
|
|
25
|
+
cancellation?: {
|
|
26
|
+
acknowledged: boolean;
|
|
27
|
+
providerClosed: boolean;
|
|
28
|
+
providerFinished: boolean;
|
|
29
|
+
lateCompletionRejected: boolean;
|
|
30
|
+
};
|
|
31
|
+
failure?: { saved: boolean; visibleAfterReload: boolean };
|
|
32
|
+
}
|
|
33
|
+
/** Evaluate in-memory observations; returns only fixed check names and allowlisted numeric evidence. */
|
|
34
|
+
export function evaluateLifecycle(
|
|
35
|
+
observation: LifecycleObservation,
|
|
36
|
+
): ExternalObservation;
|
package/src/checks.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Raw observations remain in memory. Returned evidence is deliberately allowlisted.
|
|
2
|
+
export const LIFECYCLE_SCENARIOS = Object.freeze([
|
|
3
|
+
'persistence',
|
|
4
|
+
'cancellation',
|
|
5
|
+
'regeneration',
|
|
6
|
+
'error-retry',
|
|
7
|
+
]);
|
|
8
|
+
export function evaluateLifecycle(o) {
|
|
9
|
+
const text = (v) => typeof v === 'string' && v.length > 0;
|
|
10
|
+
const bool = (v) => typeof v === 'boolean';
|
|
11
|
+
if (
|
|
12
|
+
!o ||
|
|
13
|
+
!LIFECYCLE_SCENARIOS.includes(o.scenario) ||
|
|
14
|
+
!text(o.expected) ||
|
|
15
|
+
!text(o.original) ||
|
|
16
|
+
!text(o.previousPrefix) ||
|
|
17
|
+
typeof o.beforeReload !== 'string' ||
|
|
18
|
+
typeof o.reloaded !== 'string' ||
|
|
19
|
+
!Array.isArray(o.messages) ||
|
|
20
|
+
o.messages.some(
|
|
21
|
+
(m) =>
|
|
22
|
+
!m ||
|
|
23
|
+
!text(m.id) ||
|
|
24
|
+
typeof m.parentId !== 'string' ||
|
|
25
|
+
typeof m.text !== 'string' ||
|
|
26
|
+
!bool(m.assistant) ||
|
|
27
|
+
!bool(m.unfinished) ||
|
|
28
|
+
!bool(m.error),
|
|
29
|
+
) ||
|
|
30
|
+
!Number.isSafeInteger(o.callsBeforeReload) ||
|
|
31
|
+
o.callsBeforeReload < 1 ||
|
|
32
|
+
!Number.isSafeInteger(o.callsAfterReload) ||
|
|
33
|
+
o.callsAfterReload < 0
|
|
34
|
+
)
|
|
35
|
+
throw new Error('Invalid lifecycle observation');
|
|
36
|
+
const checks = [];
|
|
37
|
+
const check = (name, passed) => checks.push({ name, passed });
|
|
38
|
+
const matching = o.messages.filter(
|
|
39
|
+
(m) => m.assistant && m.text.trim() === o.expected.trim(),
|
|
40
|
+
);
|
|
41
|
+
check('saved.answer-present', matching.length > 0);
|
|
42
|
+
check('saved.single-matching-answer', matching.length === 1);
|
|
43
|
+
check('ui.answer-visible', o.beforeReload.includes(o.expected));
|
|
44
|
+
check(
|
|
45
|
+
'saved.lifecycle-state',
|
|
46
|
+
matching.length === 1 &&
|
|
47
|
+
(o.scenario === 'cancellation'
|
|
48
|
+
? matching[0].unfinished === true
|
|
49
|
+
: matching[0].unfinished === false && !matching[0].error),
|
|
50
|
+
);
|
|
51
|
+
check('reload.answer-preserved', o.reloaded.includes(o.expected));
|
|
52
|
+
check('reload.no-new-generation', o.callsAfterReload === o.callsBeforeReload);
|
|
53
|
+
if (o.scenario === 'cancellation') {
|
|
54
|
+
const c = o.cancellation;
|
|
55
|
+
if (
|
|
56
|
+
!c ||
|
|
57
|
+
[
|
|
58
|
+
'acknowledged',
|
|
59
|
+
'providerClosed',
|
|
60
|
+
'providerFinished',
|
|
61
|
+
'lateCompletionRejected',
|
|
62
|
+
].some((k) => !bool(c[k]))
|
|
63
|
+
)
|
|
64
|
+
throw new Error('Invalid cancellation observation');
|
|
65
|
+
check('cancel.server-acknowledged', c.acknowledged);
|
|
66
|
+
check('cancel.provider-aborted', c.providerClosed && !c.providerFinished);
|
|
67
|
+
check('cancel.late-completion-rejected', c.lateCompletionRejected);
|
|
68
|
+
}
|
|
69
|
+
if (o.scenario === 'regeneration') {
|
|
70
|
+
const original = o.messages.filter(
|
|
71
|
+
(m) => m.assistant && m.text === o.original,
|
|
72
|
+
);
|
|
73
|
+
check('retry.original-branch-preserved', original.length === 1);
|
|
74
|
+
check(
|
|
75
|
+
'retry.distinct-sibling',
|
|
76
|
+
original.length === 1 &&
|
|
77
|
+
matching.length === 1 &&
|
|
78
|
+
original[0].id !== matching[0].id &&
|
|
79
|
+
original[0].parentId === matching[0].parentId,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
if (o.scenario === 'error-retry') {
|
|
83
|
+
const f = o.failure;
|
|
84
|
+
if (!f || !bool(f.saved) || !bool(f.visibleAfterReload))
|
|
85
|
+
throw new Error('Invalid failure observation');
|
|
86
|
+
check('error.saved-before-retry', f.saved);
|
|
87
|
+
check('error.visible-after-reload', f.visibleAfterReload);
|
|
88
|
+
}
|
|
89
|
+
if (['regeneration', 'error-retry'].includes(o.scenario))
|
|
90
|
+
check(
|
|
91
|
+
'retry.previous-answer-not-active',
|
|
92
|
+
!o.reloaded.includes(o.previousPrefix),
|
|
93
|
+
);
|
|
94
|
+
return {
|
|
95
|
+
checks,
|
|
96
|
+
evidence: {
|
|
97
|
+
policy: 'summary-only-v1',
|
|
98
|
+
checkVersion: 1,
|
|
99
|
+
savedMessageCount: o.messages.length,
|
|
100
|
+
matchingAnswerCount: matching.length,
|
|
101
|
+
providerCallsBeforeReload: o.callsBeforeReload,
|
|
102
|
+
providerCallsAfterReload: o.callsAfterReload,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
package/src/cli.d.ts
CHANGED
package/src/cli.js
CHANGED
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
import { fork } from 'node:child_process';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { resolve } from 'node:path';
|
|
3
|
+
import { resolve, join, dirname } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { mkdir, copyFile, open, unlink, readFile } from 'node:fs/promises';
|
|
6
6
|
import { constants } from 'node:fs';
|
|
7
7
|
import { VERSION, failureReport } from './index.js';
|
|
8
|
-
const
|
|
8
|
+
const EXAMPLES = fileURLToPath(new URL('../examples/', import.meta.url));
|
|
9
|
+
// Explicit source inventory: never copy local builds, dependencies or configuration.
|
|
10
|
+
const PRESETS = {
|
|
11
|
+
http: [['http.adapter.mjs', 'adapter.mjs'], ['demo-app.mjs', 'demo-app.mjs']],
|
|
12
|
+
librechat: ['adapter.mjs', 'provider.mjs', 'setup.mjs', 'README.md'].map(
|
|
13
|
+
(name) => [`librechat/${name}`, name]),
|
|
14
|
+
'vercel-ai': [
|
|
15
|
+
'adapter.mjs', 'provider.mjs', 'setup.mjs', 'config.mjs', 'README.md',
|
|
16
|
+
'reference-app/package.json', 'reference-app/npm-shrinkwrap.json',
|
|
17
|
+
'reference-app/next.config.mjs', 'reference-app/lib/store.js',
|
|
18
|
+
'reference-app/app/layout.jsx', 'reference-app/app/page.jsx',
|
|
19
|
+
'reference-app/app/api/chat/route.js', 'reference-app/app/chat/[id]/chat.jsx',
|
|
20
|
+
'reference-app/app/chat/[id]/page.jsx',
|
|
21
|
+
].map((name) => [`vercel-ai/${name}`, name]),
|
|
22
|
+
};
|
|
23
|
+
export function presets() { return Object.keys(PRESETS); }
|
|
24
|
+
function presetFiles(preset) { return PRESETS[preset]; }
|
|
25
|
+
const HELP = () =>
|
|
26
|
+
`StreamTrust ${VERSION}\n\nUsage:\n streamtrust init [directory] [--preset <name>]\n streamtrust verify --adapter ./streamtrust/adapter.mjs [options]\n\nOptions:\n --output <directory> Reports (default: reports/streamtrust)\n --repetitions <1-1000> Repetitions per scenario (default: 3)\n --timeout-ms <integer> Total run deadline including setup/cleanup (default: 300000)\n --help Show help\n --version Show version\n\nPresets: ${presets().join(', ')} (default: http)\n\nExit codes: 0 all pass; 1 failed/unsupported checks; 2 setup, timeout or harness error.\nAdapters execute trusted local code. JavaScript ESM (.mjs) works without dependencies.\n`;
|
|
9
27
|
export async function main(args) {
|
|
10
28
|
let outputDir;
|
|
11
29
|
try {
|
|
@@ -14,24 +32,33 @@ export async function main(args) {
|
|
|
14
32
|
return 0;
|
|
15
33
|
}
|
|
16
34
|
if (!args.length || args.includes('--help')) {
|
|
17
|
-
console.log(HELP);
|
|
35
|
+
console.log(HELP());
|
|
18
36
|
return 0;
|
|
19
37
|
}
|
|
20
38
|
if (args[0] === 'init') {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
39
|
+
const initArgs = args.slice(1);
|
|
40
|
+
const presetIndex = initArgs.indexOf('--preset');
|
|
41
|
+
let preset = 'http';
|
|
42
|
+
if (presetIndex !== -1) {
|
|
43
|
+
preset = initArgs[presetIndex + 1];
|
|
44
|
+
initArgs.splice(presetIndex, 2);
|
|
45
|
+
if (!presets().includes(preset))
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Unknown init preset; available: ${presets().join(', ')}`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (initArgs.length > 1 || initArgs[0]?.startsWith('-'))
|
|
51
|
+
throw new Error('Invalid init arguments');
|
|
52
|
+
const dir = resolve(initArgs[0] ?? 'streamtrust');
|
|
24
53
|
await mkdir(dir, { recursive: true });
|
|
25
54
|
// Exclusive copies preserve existing adapters. No generated config overrides user files.
|
|
26
55
|
const created = [];
|
|
27
56
|
try {
|
|
28
|
-
for (const [source, target] of
|
|
29
|
-
['http.adapter.mjs', 'adapter.mjs'],
|
|
30
|
-
['demo-app.mjs', 'demo-app.mjs'],
|
|
31
|
-
]) {
|
|
57
|
+
for (const [source, target] of presetFiles(preset)) {
|
|
32
58
|
const destination = resolve(dir, target);
|
|
59
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
33
60
|
await copyFile(
|
|
34
|
-
|
|
61
|
+
join(EXAMPLES, source),
|
|
35
62
|
destination,
|
|
36
63
|
constants.COPYFILE_EXCL,
|
|
37
64
|
);
|
package/src/index.js
CHANGED
|
@@ -63,13 +63,15 @@ export async function writeReports(outputDir, report) {
|
|
|
63
63
|
const rows = report.results;
|
|
64
64
|
const screenshots = new Set();
|
|
65
65
|
await Promise.all(
|
|
66
|
-
rows.map(
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
66
|
+
(report.metadata?.evidencePolicy === 'summary-only-v1' ? [] : rows).map(
|
|
67
|
+
async (row) => {
|
|
68
|
+
const path = `${row.scenario}-${row.repetition}.png`;
|
|
69
|
+
try {
|
|
70
|
+
await access(resolve(outputDir, path));
|
|
71
|
+
screenshots.add(path);
|
|
72
|
+
} catch {}
|
|
73
|
+
},
|
|
74
|
+
),
|
|
73
75
|
);
|
|
74
76
|
const suiteCases = report.suiteErrors
|
|
75
77
|
.map(
|