cachegate 1.0.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/.dockerignore +11 -0
- package/.env.example +112 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +33 -0
- package/.github/ISSUE_TEMPLATE/config.yml +8 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +29 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +25 -0
- package/.github/workflows/test.yml +63 -0
- package/CODE_OF_CONDUCT.md +66 -0
- package/CONTRIBUTING.md +94 -0
- package/Dockerfile +16 -0
- package/LICENSE +21 -0
- package/OPEN_SOURCE_ROADMAP.md +554 -0
- package/README.md +428 -0
- package/ROADMAP.md +281 -0
- package/SECURITY.md +39 -0
- package/cache.js +51 -0
- package/embeddings.js +32 -0
- package/failover.js +76 -0
- package/metrics.js +556 -0
- package/package.json +26 -0
- package/providers/anthropic.js +122 -0
- package/providers/openai.js +114 -0
- package/public/dashboard.html +1119 -0
- package/redisClient.js +45 -0
- package/router.js +218 -0
- package/semanticCache.js +154 -0
- package/server.js +668 -0
- package/streaming.js +77 -0
- package/sync-oss-release.sh +160 -0
- package/test/auth-config.test.js +27 -0
- package/test/cache.test.js +33 -0
- package/test/embeddings.test.js +24 -0
- package/test/failover.test.js +99 -0
- package/test/metrics-postgres.test.js +183 -0
- package/test/metrics.test.js +282 -0
- package/test/router.test.js +195 -0
- package/test/semanticCache.test.js +167 -0
- package/test/server.test.js +357 -0
- package/test/streaming.test.js +248 -0
package/streaming.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// model-router/streaming.js
|
|
2
|
+
//
|
|
3
|
+
// OpenAI-compatible SSE chunk framing, shared by both providers so
|
|
4
|
+
// server.js has exactly one wire format to write regardless of which
|
|
5
|
+
// provider actually answered - the provider adapters (chatStream()) do
|
|
6
|
+
// their own event-format translation and hand server.js plain text
|
|
7
|
+
// deltas plus a final usage/cost summary; this file turns that into the
|
|
8
|
+
// bytes that go on the wire.
|
|
9
|
+
//
|
|
10
|
+
// Scope for this increment: PLAIN TEXT CONTENT ONLY. Tool-call
|
|
11
|
+
// streaming (accumulating partial JSON arguments across chunks, one or
|
|
12
|
+
// more calls in flight at once) is a genuinely harder, separate
|
|
13
|
+
// problem - server.js rejects stream:true + tools with a clear error
|
|
14
|
+
// rather than attempt a half-working version of it.
|
|
15
|
+
//
|
|
16
|
+
// The final chunk carries extra fields (cost_usd, provider, cached,
|
|
17
|
+
// cache_type) beyond real OpenAI's wire format - the same deviation the
|
|
18
|
+
// non-streaming JSON response already makes. This proxy is
|
|
19
|
+
// OpenAI-COMPATIBLE in request/response SHAPE, not a byte-for-byte
|
|
20
|
+
// clone of OpenAI's actual API; MemoCode's own callers need the cost
|
|
21
|
+
// data, and no spec-compliant client chokes on unknown extra JSON
|
|
22
|
+
// fields it doesn't look for.
|
|
23
|
+
|
|
24
|
+
const crypto = require('crypto');
|
|
25
|
+
|
|
26
|
+
function chunkFrame(payload) {
|
|
27
|
+
return `data: ${JSON.stringify(payload)}\n\n`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function doneFrame() {
|
|
31
|
+
return 'data: [DONE]\n\n';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function genId() {
|
|
35
|
+
return 'chatcmpl-' + crypto.randomBytes(12).toString('hex');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function baseChunk(id, model, choice) {
|
|
39
|
+
return {
|
|
40
|
+
id,
|
|
41
|
+
object: 'chat.completion.chunk',
|
|
42
|
+
created: Math.floor(Date.now() / 1000),
|
|
43
|
+
model,
|
|
44
|
+
choices: [choice]
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function roleChunk({ id, model }) {
|
|
49
|
+
return chunkFrame(baseChunk(id, model, { index: 0, delta: { role: 'assistant' }, finish_reason: null }));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function deltaChunk({ id, model, content }) {
|
|
53
|
+
return chunkFrame(baseChunk(id, model, { index: 0, delta: { content }, finish_reason: null }));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function finalChunk({ id, model, usage, cost_usd, provider, cached, cache_type }) {
|
|
57
|
+
const frame = baseChunk(id, model, { index: 0, delta: {}, finish_reason: 'stop' });
|
|
58
|
+
frame.usage = usage;
|
|
59
|
+
frame.cost_usd = cost_usd;
|
|
60
|
+
frame.provider = provider;
|
|
61
|
+
frame.cached = !!cached;
|
|
62
|
+
if (cache_type) frame.cache_type = cache_type;
|
|
63
|
+
return chunkFrame(frame);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function errorFrame(message) {
|
|
67
|
+
return chunkFrame({ error: { message } });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function startSse(res) {
|
|
71
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
72
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
73
|
+
res.setHeader('Connection', 'keep-alive');
|
|
74
|
+
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { chunkFrame, doneFrame, genId, roleChunk, deltaChunk, finalChunk, errorFrame, startSse };
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# sync-oss-release.sh - mirrors THIS directory's git-tracked files into
|
|
4
|
+
# a checkout of the public cachegate repo, as ONE NEW COMMIT there.
|
|
5
|
+
#
|
|
6
|
+
# See OPEN_SOURCE_ROADMAP.md step 11 for why this direction (this
|
|
7
|
+
# monorepo directory is the source of truth, not the public repo) and
|
|
8
|
+
# step 1 for why the ONE-TIME initial extraction (step 16) uses fresh,
|
|
9
|
+
# curated history. This script is different from that: it's what runs
|
|
10
|
+
# on every sync AFTER the initial extraction, and it does NOT rewrite
|
|
11
|
+
# history - it adds a single normal commit on top of whatever the
|
|
12
|
+
# target repo already has, exactly like any other change to that repo.
|
|
13
|
+
# Rewriting history on every sync would break clones, forks, and
|
|
14
|
+
# in-flight PRs on the public side; that's not what this does.
|
|
15
|
+
#
|
|
16
|
+
# This is a MIRROR, not a merge: after syncing, the target's tracked
|
|
17
|
+
# files exactly match this directory's. A file that exists only in the
|
|
18
|
+
# target (added directly on GitHub, not here) gets REMOVED on sync.
|
|
19
|
+
# That's deliberate - if a file should persist in the public repo, add
|
|
20
|
+
# it here, in the monorepo, since this directory is the source of truth
|
|
21
|
+
# (see step 11). This script will refuse to run against a target that
|
|
22
|
+
# isn't a git repository, specifically so an accidental wipe of some
|
|
23
|
+
# unrelated directory can't happen by pointing this at the wrong path.
|
|
24
|
+
#
|
|
25
|
+
# Usage:
|
|
26
|
+
# ./sync-oss-release.sh <path-to-public-repo-checkout> [--version X.Y.Z]
|
|
27
|
+
#
|
|
28
|
+
# What it does, in order:
|
|
29
|
+
# 1. Refuses to run if the secrets scan (same patterns as the manual
|
|
30
|
+
# step-3 audit: API key shapes, email addresses) finds anything in
|
|
31
|
+
# this directory's tracked files - loud failure, nothing touched,
|
|
32
|
+
# rather than a quiet publish of a leak.
|
|
33
|
+
# 2. If --version is given, bumps THIS directory's own package.json
|
|
34
|
+
# to that version first, so the synced copy carries it too. Omits
|
|
35
|
+
# this by default - the script doesn't invent a version-bump
|
|
36
|
+
# policy on its own (see step 6's deferred semver plan); a
|
|
37
|
+
# no-flag run is a plain resync at whatever version is already
|
|
38
|
+
# set (e.g. reapplying a cherry-picked external PR - see
|
|
39
|
+
# CONTRIBUTING.md's note on that flow).
|
|
40
|
+
# 3. Mirrors every git-tracked file from this directory into the
|
|
41
|
+
# target checkout (removes everything else from the target's
|
|
42
|
+
# working tree first, except its own .git/) - a file removed here
|
|
43
|
+
# also disappears there, never a manual, error-prone diff to keep
|
|
44
|
+
# in sync by hand.
|
|
45
|
+
# 4. Commits in the TARGET repo (one new commit, normal history).
|
|
46
|
+
# Does NOT push - pushing is a deliberate, separate, human/CI step.
|
|
47
|
+
|
|
48
|
+
set -euo pipefail
|
|
49
|
+
|
|
50
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
51
|
+
cd "$SCRIPT_DIR"
|
|
52
|
+
|
|
53
|
+
TARGET=""
|
|
54
|
+
NEW_VERSION=""
|
|
55
|
+
|
|
56
|
+
while [[ $# -gt 0 ]]; do
|
|
57
|
+
case "$1" in
|
|
58
|
+
--version)
|
|
59
|
+
NEW_VERSION="$2"
|
|
60
|
+
shift 2
|
|
61
|
+
;;
|
|
62
|
+
*)
|
|
63
|
+
if [[ -z "$TARGET" ]]; then
|
|
64
|
+
TARGET="$1"
|
|
65
|
+
shift
|
|
66
|
+
else
|
|
67
|
+
echo "Unexpected extra argument: $1" >&2
|
|
68
|
+
exit 1
|
|
69
|
+
fi
|
|
70
|
+
;;
|
|
71
|
+
esac
|
|
72
|
+
done
|
|
73
|
+
|
|
74
|
+
if [[ -z "$TARGET" ]]; then
|
|
75
|
+
echo "Usage: $0 <path-to-public-repo-checkout> [--version X.Y.Z]" >&2
|
|
76
|
+
exit 1
|
|
77
|
+
fi
|
|
78
|
+
|
|
79
|
+
if [[ ! -d "$TARGET/.git" ]]; then
|
|
80
|
+
echo "❌ Refusing to run: $TARGET is not a git repository (no .git/ found)." >&2
|
|
81
|
+
echo " This is deliberate - pointing this at the wrong path would wipe it." >&2
|
|
82
|
+
exit 1
|
|
83
|
+
fi
|
|
84
|
+
|
|
85
|
+
TARGET="$(cd "$TARGET" && pwd)"
|
|
86
|
+
|
|
87
|
+
echo "🔍 Step 1/4: scanning tracked files for secrets before touching anything..."
|
|
88
|
+
# Same shape of check as step 3's manual audit: API key patterns and
|
|
89
|
+
# email addresses, restricted to git-tracked files only (never
|
|
90
|
+
# node_modules, .env, data/ - those aren't tracked, so git ls-files
|
|
91
|
+
# already excludes them).
|
|
92
|
+
SECRET_HIT=0
|
|
93
|
+
while IFS= read -r -d '' file; do
|
|
94
|
+
if grep -qE "sk-[a-zA-Z0-9_-]{20,}|AIza[0-9A-Za-z_-]{20,}|xai-[a-zA-Z0-9_-]{20,}" "$file" 2>/dev/null; then
|
|
95
|
+
echo " ❌ Possible API key in $file" >&2
|
|
96
|
+
SECRET_HIT=1
|
|
97
|
+
fi
|
|
98
|
+
if grep -qE "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-z]{2,}" "$file" 2>/dev/null; then
|
|
99
|
+
# .env.example intentionally has no real emails; this still flags
|
|
100
|
+
# anything matching the shape so a human confirms it's a placeholder.
|
|
101
|
+
match=$(grep -oE "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-z]{2,}" "$file" | grep -v -E "example\.com|your-|@anthropic\.com" || true)
|
|
102
|
+
if [[ -n "$match" ]]; then
|
|
103
|
+
echo " ⚠️ Email-shaped string in $file: $match (confirm this is a placeholder, not real)" >&2
|
|
104
|
+
SECRET_HIT=1
|
|
105
|
+
fi
|
|
106
|
+
fi
|
|
107
|
+
done < <(git ls-files -z)
|
|
108
|
+
|
|
109
|
+
if [[ "$SECRET_HIT" -eq 1 ]]; then
|
|
110
|
+
echo "❌ Aborting sync - resolve the findings above first. Nothing was copied." >&2
|
|
111
|
+
exit 1
|
|
112
|
+
fi
|
|
113
|
+
echo " ✅ Clean."
|
|
114
|
+
|
|
115
|
+
if [[ -n "$NEW_VERSION" ]]; then
|
|
116
|
+
echo "🔢 Step 2/4: bumping package.json to $NEW_VERSION..."
|
|
117
|
+
node -e "
|
|
118
|
+
const fs = require('fs');
|
|
119
|
+
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
|
120
|
+
pkg.version = process.argv[1];
|
|
121
|
+
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
|
|
122
|
+
" "$NEW_VERSION"
|
|
123
|
+
echo " ✅ package.json now at $NEW_VERSION."
|
|
124
|
+
else
|
|
125
|
+
echo "🔢 Step 2/4: no --version given, leaving package.json's version as-is."
|
|
126
|
+
fi
|
|
127
|
+
|
|
128
|
+
echo "📦 Step 3/4: mirroring tracked files into $TARGET..."
|
|
129
|
+
# Wipe the target's working tree except .git/, then copy this
|
|
130
|
+
# directory's tracked files in - guarantees the target ends up an
|
|
131
|
+
# EXACT mirror, not an accumulation of whatever used to be there.
|
|
132
|
+
find "$TARGET" -mindepth 1 -maxdepth 1 -not -name ".git" -exec rm -rf {} +
|
|
133
|
+
|
|
134
|
+
while IFS= read -r -d '' file; do
|
|
135
|
+
dest="$TARGET/$file"
|
|
136
|
+
mkdir -p "$(dirname "$dest")"
|
|
137
|
+
cp "$file" "$dest"
|
|
138
|
+
done < <(git ls-files -z)
|
|
139
|
+
|
|
140
|
+
echo " ✅ Copied $(git ls-files | wc -l | tr -d ' ') tracked files."
|
|
141
|
+
|
|
142
|
+
echo "💾 Step 4/4: committing in the target repo (not pushing)..."
|
|
143
|
+
SOURCE_SHA="$(git rev-parse --short HEAD)"
|
|
144
|
+
(
|
|
145
|
+
cd "$TARGET"
|
|
146
|
+
git add -A
|
|
147
|
+
if git diff --cached --quiet; then
|
|
148
|
+
echo " ℹ️ Nothing changed - target already matches this directory. No commit made."
|
|
149
|
+
else
|
|
150
|
+
git commit -m "Sync from internal monorepo @ ${SOURCE_SHA}
|
|
151
|
+
|
|
152
|
+
Mirrors 210_apps/001_model_router/ as of that commit. This commit was
|
|
153
|
+
generated by sync-oss-release.sh, not written by hand - see
|
|
154
|
+
OPEN_SOURCE_ROADMAP.md step 11 in the source repo for why this
|
|
155
|
+
direction (monorepo -> public repo, not the reverse)."
|
|
156
|
+
echo " ✅ Committed. Review with 'git show' in $TARGET, then push when ready - this script never pushes."
|
|
157
|
+
fi
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
echo "✅ Sync complete."
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
|
|
4
|
+
// server.js reads its env vars once at require time, so each case here
|
|
5
|
+
// resets env + require cache before requiring fresh.
|
|
6
|
+
function freshServer(env) {
|
|
7
|
+
delete process.env.MODEL_ROUTER_INTERNAL_KEY;
|
|
8
|
+
delete process.env.ALLOW_INSECURE_LOCAL_DEV;
|
|
9
|
+
Object.assign(process.env, env);
|
|
10
|
+
delete require.cache[require.resolve('../server')];
|
|
11
|
+
return require('../server');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
test('isAuthConfigured() is false with neither key nor opt-in set', () => {
|
|
15
|
+
const { isAuthConfigured } = freshServer({});
|
|
16
|
+
assert.equal(isAuthConfigured(), false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('isAuthConfigured() is true once MODEL_ROUTER_INTERNAL_KEY is set', () => {
|
|
20
|
+
const { isAuthConfigured } = freshServer({ MODEL_ROUTER_INTERNAL_KEY: 'some-key' });
|
|
21
|
+
assert.equal(isAuthConfigured(), true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('isAuthConfigured() is true with the explicit insecure opt-in, even with no key', () => {
|
|
25
|
+
const { isAuthConfigured } = freshServer({ ALLOW_INSECURE_LOCAL_DEV: 'true' });
|
|
26
|
+
assert.equal(isAuthConfigured(), true);
|
|
27
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const cache = require('../cache');
|
|
4
|
+
|
|
5
|
+
test('buildCacheKey is deterministic for identical payloads', () => {
|
|
6
|
+
const payload = { model: 'gpt-4o-mini', messages: [{ role: 'user', content: 'hi' }] };
|
|
7
|
+
assert.equal(cache.buildCacheKey(payload), cache.buildCacheKey({ ...payload }));
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test('buildCacheKey differs when messages differ', () => {
|
|
11
|
+
const a = { model: 'gpt-4o-mini', messages: [{ role: 'user', content: 'hi' }] };
|
|
12
|
+
const b = { model: 'gpt-4o-mini', messages: [{ role: 'user', content: 'bye' }] };
|
|
13
|
+
assert.notEqual(cache.buildCacheKey(a), cache.buildCacheKey(b));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('buildCacheKey differs when model differs, same messages', () => {
|
|
17
|
+
const messages = [{ role: 'user', content: 'hi' }];
|
|
18
|
+
const a = { model: 'gpt-4o-mini', messages };
|
|
19
|
+
const b = { model: 'claude-haiku-4-5-20251001', messages };
|
|
20
|
+
assert.notEqual(cache.buildCacheKey(a), cache.buildCacheKey(b));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('buildCacheKey treats an unset temperature the same as 0.0 (documented default)', () => {
|
|
24
|
+
const a = { model: 'gpt-4o-mini', messages: [{ role: 'user', content: 'hi' }] };
|
|
25
|
+
const b = { model: 'gpt-4o-mini', messages: [{ role: 'user', content: 'hi' }], temperature: 0.0 };
|
|
26
|
+
assert.equal(cache.buildCacheKey(a), cache.buildCacheKey(b));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('isConnected() is false with no REDIS_URL configured', () => {
|
|
30
|
+
// This test suite never sets REDIS_URL, matching the documented
|
|
31
|
+
// graceful-degradation path (cache disabled, not crashed).
|
|
32
|
+
assert.equal(cache.isConnected(), false);
|
|
33
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
|
|
4
|
+
function freshEmbeddings(env) {
|
|
5
|
+
delete process.env.OPENAI_API_KEY;
|
|
6
|
+
Object.assign(process.env, env);
|
|
7
|
+
delete require.cache[require.resolve('../embeddings')];
|
|
8
|
+
return require('../embeddings');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
test('isEnabled() is false with no OPENAI_API_KEY', () => {
|
|
12
|
+
const embeddings = freshEmbeddings({});
|
|
13
|
+
assert.equal(embeddings.isEnabled(), false);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('isEnabled() is true once OPENAI_API_KEY is set', () => {
|
|
17
|
+
const embeddings = freshEmbeddings({ OPENAI_API_KEY: 'sk-test-fake' });
|
|
18
|
+
assert.equal(embeddings.isEnabled(), true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('embed() rejects clearly when disabled, without attempting a network call', async () => {
|
|
22
|
+
const embeddings = freshEmbeddings({});
|
|
23
|
+
await assert.rejects(() => embeddings.embed('hello'), /OPENAI_API_KEY not configured/);
|
|
24
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const { isRetryableError, dispatchWithFailover } = require('../failover');
|
|
4
|
+
|
|
5
|
+
function httpError(message, status) {
|
|
6
|
+
return Object.assign(new Error(message), { status });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
test('isRetryableError treats a bad request (400) as not retryable', () => {
|
|
10
|
+
assert.equal(isRetryableError(httpError('bad request', 400)), false);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('isRetryableError treats an unknown model (404) as not retryable', () => {
|
|
14
|
+
assert.equal(isRetryableError(httpError('not found', 404)), false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('isRetryableError treats rate limits, server errors, and auth failures as retryable', () => {
|
|
18
|
+
assert.equal(isRetryableError(httpError('rate limited', 429)), true);
|
|
19
|
+
assert.equal(isRetryableError(httpError('server error', 500)), true);
|
|
20
|
+
assert.equal(isRetryableError(httpError('bad gateway', 502)), true);
|
|
21
|
+
assert.equal(isRetryableError(httpError('unauthorized', 401)), true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('isRetryableError treats a network failure with no status at all as retryable', () => {
|
|
25
|
+
assert.equal(isRetryableError(new Error('ECONNRESET')), true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('dispatchWithFailover resolves on the first candidate when it succeeds, attempts=1', async () => {
|
|
29
|
+
const candidates = [{ provider: 'openai', model: 'gpt-4o-mini' }, { provider: 'anthropic', model: 'claude-haiku-4-5-20251001' }];
|
|
30
|
+
const dispatch = async (c) => ({ ok: true, provider: c.provider });
|
|
31
|
+
|
|
32
|
+
const outcome = await dispatchWithFailover(candidates, dispatch);
|
|
33
|
+
assert.equal(outcome.attempts, 1);
|
|
34
|
+
assert.equal(outcome.candidate.provider, 'openai');
|
|
35
|
+
assert.deepEqual(outcome.result, { ok: true, provider: 'openai' });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('dispatchWithFailover moves to the next candidate on a retryable failure', async () => {
|
|
39
|
+
const candidates = [{ provider: 'openai', model: 'gpt-4o-mini' }, { provider: 'anthropic', model: 'claude-haiku-4-5-20251001' }];
|
|
40
|
+
const failedAttempts = [];
|
|
41
|
+
const dispatch = async (c) => {
|
|
42
|
+
if (c.provider === 'openai') throw httpError('rate limited', 429);
|
|
43
|
+
return { ok: true, provider: c.provider };
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const outcome = await dispatchWithFailover(candidates, dispatch, (candidate, err) => {
|
|
47
|
+
failedAttempts.push({ candidate, message: err.message });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
assert.equal(outcome.attempts, 2);
|
|
51
|
+
assert.equal(outcome.candidate.provider, 'anthropic');
|
|
52
|
+
assert.equal(failedAttempts.length, 1);
|
|
53
|
+
assert.equal(failedAttempts[0].candidate.provider, 'openai');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('dispatchWithFailover does not retry a non-retryable (400) error, even with candidates left', async () => {
|
|
57
|
+
const candidates = [{ provider: 'openai', model: 'gpt-4o-mini' }, { provider: 'anthropic', model: 'claude-haiku-4-5-20251001' }];
|
|
58
|
+
let anthropicCalled = false;
|
|
59
|
+
const dispatch = async (c) => {
|
|
60
|
+
if (c.provider === 'openai') throw httpError('bad request', 400);
|
|
61
|
+
anthropicCalled = true;
|
|
62
|
+
return { ok: true };
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
await assert.rejects(
|
|
66
|
+
() => dispatchWithFailover(candidates, dispatch),
|
|
67
|
+
/bad request/
|
|
68
|
+
);
|
|
69
|
+
assert.equal(anthropicCalled, false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('dispatchWithFailover rethrows the last error when every candidate fails', async () => {
|
|
73
|
+
const candidates = [{ provider: 'openai', model: 'gpt-4o-mini' }, { provider: 'anthropic', model: 'claude-haiku-4-5-20251001' }];
|
|
74
|
+
const dispatch = async (c) => {
|
|
75
|
+
throw httpError(`${c.provider} down`, 503);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
await assert.rejects(
|
|
79
|
+
() => dispatchWithFailover(candidates, dispatch),
|
|
80
|
+
/anthropic down/ // the LAST attempt's error, not the first
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('dispatchWithFailover calls onAttemptFailed for every failed candidate, including the last', async () => {
|
|
85
|
+
const candidates = [{ provider: 'openai', model: 'gpt-4o-mini' }, { provider: 'anthropic', model: 'claude-haiku-4-5-20251001' }];
|
|
86
|
+
const seen = [];
|
|
87
|
+
const dispatch = async (c) => {
|
|
88
|
+
throw httpError(`${c.provider} down`, 503);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
await assert.rejects(() => dispatchWithFailover(candidates, dispatch, (candidate, err, isLastCandidate) => {
|
|
92
|
+
seen.push({ provider: candidate.provider, isLastCandidate });
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
assert.deepEqual(seen, [
|
|
96
|
+
{ provider: 'openai', isLastCandidate: false },
|
|
97
|
+
{ provider: 'anthropic', isLastCandidate: true }
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Postgres-backed persistence is OPT-IN (see metrics.js's own comment) -
|
|
2
|
+
// these tests exercise that path specifically, against a real local
|
|
3
|
+
// Postgres. Needs a real local server to actually run against; the
|
|
4
|
+
// `before()` hook below probes connectivity ONCE with a clear warning
|
|
5
|
+
// (`⚠️ Skipping metrics-postgres.test.js...`) rather than letting 20
|
|
6
|
+
// individual tests each fail with their own raw ECONNREFUSED - every
|
|
7
|
+
// test still reports as passing (each returns early via `pgAvailable`)
|
|
8
|
+
// so a machine without Postgres available doesn't see a red suite, it
|
|
9
|
+
// sees a skipped one. **Corrected 2026-08-29**: this comment previously
|
|
10
|
+
// claimed the opposite ("fails loudly... rather than silently
|
|
11
|
+
// skipping") - checked directly by actually stopping Postgres and
|
|
12
|
+
// running the suite, and that was never what the code below does. A CI
|
|
13
|
+
// environment without a real Postgres service configured will report
|
|
14
|
+
// green here while silently not exercising this file at all - the
|
|
15
|
+
// workflow's own Postgres service container is what makes these tests
|
|
16
|
+
// actually run, not just avoid failing.
|
|
17
|
+
//
|
|
18
|
+
// MEMOCODE_ROUTER_DATABASE_URL (not DATABASE_URL) is used here on
|
|
19
|
+
// purpose - keeps this suite from ever accidentally pointing at a real
|
|
20
|
+
// production database if that variable happened to be set in whatever
|
|
21
|
+
// environment runs the tests.
|
|
22
|
+
const { test, before, after, beforeEach } = require('node:test');
|
|
23
|
+
const assert = require('node:assert/strict');
|
|
24
|
+
|
|
25
|
+
const TEST_DATABASE_URL =
|
|
26
|
+
process.env.MEMOCODE_ROUTER_TEST_DATABASE_URL || 'postgres://postgres:dryrun@localhost:5432/router_metrics_dryrun';
|
|
27
|
+
|
|
28
|
+
function freshPgMetrics() {
|
|
29
|
+
process.env.MEMOCODE_ROUTER_DATABASE_URL = TEST_DATABASE_URL;
|
|
30
|
+
delete process.env.DATABASE_URL; // make sure only the test var is what's triggering Postgres mode
|
|
31
|
+
delete require.cache[require.resolve('../metrics')];
|
|
32
|
+
return require('../metrics');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function flush() {
|
|
36
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let pgAvailable = true;
|
|
40
|
+
let metrics;
|
|
41
|
+
|
|
42
|
+
before(async () => {
|
|
43
|
+
metrics = freshPgMetrics();
|
|
44
|
+
try {
|
|
45
|
+
// Prove connectivity up front with a clear failure message, rather
|
|
46
|
+
// than 20 individual tests each failing with their own ECONNREFUSED.
|
|
47
|
+
await metrics.readRecent(1);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
pgAvailable = false;
|
|
50
|
+
console.warn(`⚠️ Skipping metrics-postgres.test.js - no local Postgres reachable at ${TEST_DATABASE_URL} (${err.message})`);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
beforeEach(async () => {
|
|
55
|
+
if (!pgAvailable) return;
|
|
56
|
+
// Reset between tests so each one starts from a known-empty table,
|
|
57
|
+
// same isolation guarantee freshMetrics() gives the file-backed suite
|
|
58
|
+
// via a fresh temp directory per test.
|
|
59
|
+
const { Pool } = require('pg');
|
|
60
|
+
const pool = new Pool({ connectionString: TEST_DATABASE_URL });
|
|
61
|
+
await pool.query('DROP TABLE IF EXISTS router_metrics');
|
|
62
|
+
await pool.end();
|
|
63
|
+
metrics = freshPgMetrics();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
after(async () => {
|
|
67
|
+
if (metrics) await metrics.closePostgresPoolForTests();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('usingPostgres() is true once MEMOCODE_ROUTER_DATABASE_URL is set', () => {
|
|
71
|
+
if (!pgAvailable) return;
|
|
72
|
+
assert.equal(metrics.usingPostgres(), true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('record() + readRecent(): a real INSERT round-trips back out with the same fields', async () => {
|
|
76
|
+
if (!pgAvailable) return;
|
|
77
|
+
metrics.record({ provider: 'openai', model: 'gpt-4o-mini', latency_ms: 120, cost_usd: 0.001, cache_hit: false });
|
|
78
|
+
await flush();
|
|
79
|
+
|
|
80
|
+
const rows = await metrics.readRecent();
|
|
81
|
+
assert.equal(rows.length, 1);
|
|
82
|
+
assert.equal(rows[0].provider, 'openai');
|
|
83
|
+
assert.equal(rows[0].model, 'gpt-4o-mini');
|
|
84
|
+
assert.equal(rows[0].latency_ms, 120);
|
|
85
|
+
assert.equal(rows[0].cost_usd, 0.001);
|
|
86
|
+
assert.ok(rows[0].timestamp, 'expected a real timestamp column value back');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('record() persists error + error_type together, exactly like the file backend', async () => {
|
|
90
|
+
if (!pgAvailable) return;
|
|
91
|
+
metrics.record({
|
|
92
|
+
provider: 'anthropic',
|
|
93
|
+
error: '401 {"type":"error","error":{"type":"authentication_error"}}',
|
|
94
|
+
error_type: 'authentication_error'
|
|
95
|
+
});
|
|
96
|
+
await flush();
|
|
97
|
+
|
|
98
|
+
const rows = await metrics.readRecent();
|
|
99
|
+
assert.equal(rows[0].error_type, 'authentication_error');
|
|
100
|
+
assert.ok(rows[0].error.includes('authentication_error'));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('providerStats(): error rate, avg latency, and lastErrorType all compute correctly from real rows', async () => {
|
|
104
|
+
if (!pgAvailable) return;
|
|
105
|
+
metrics.record({ provider: 'openai', latency_ms: 100 });
|
|
106
|
+
metrics.record({ provider: 'openai', latency_ms: 200 });
|
|
107
|
+
metrics.record({ provider: 'openai', error: '429 {"error":{"type":"rate_limit_error"}}', error_type: 'rate_limit_error' });
|
|
108
|
+
await flush();
|
|
109
|
+
|
|
110
|
+
const stats = await metrics.providerStats();
|
|
111
|
+
assert.equal(stats.openai.sampleSize, 3);
|
|
112
|
+
assert.equal(stats.openai.errorRate, 1 / 3);
|
|
113
|
+
assert.equal(stats.openai.avgLatencyMs, 150);
|
|
114
|
+
assert.equal(stats.openai.lastErrorType, 'rate_limit_error');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('rangeSummary(): cost, cache-hit breakdown, and by_provider all agree with what was actually inserted', async () => {
|
|
118
|
+
if (!pgAvailable) return;
|
|
119
|
+
metrics.record({ provider: 'openai', cost_usd: 0.001, cache_hit: false });
|
|
120
|
+
metrics.record({ provider: 'openai', cost_usd: 0, cache_hit: true, cache_type: 'exact' });
|
|
121
|
+
metrics.record({ provider: 'anthropic', error: '500 boom', error_type: 'unknown' });
|
|
122
|
+
await flush();
|
|
123
|
+
|
|
124
|
+
const summary = await metrics.rangeSummary(14);
|
|
125
|
+
assert.equal(summary.sample_size, 3);
|
|
126
|
+
assert.equal(summary.total_cost_usd, 0.001);
|
|
127
|
+
assert.equal(summary.cache_hit_rate.exact, 1 / 3);
|
|
128
|
+
assert.equal(summary.error_rate, 1 / 3);
|
|
129
|
+
assert.equal(summary.by_provider.openai.requests, 2);
|
|
130
|
+
assert.equal(summary.by_provider.anthropic.requests, 1);
|
|
131
|
+
assert.equal(summary.by_provider.anthropic.errorRate, 1);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('rangeSummary(): a row older than the requested window is excluded', async () => {
|
|
135
|
+
if (!pgAvailable) return;
|
|
136
|
+
const { Pool } = require('pg');
|
|
137
|
+
const pool = new Pool({ connectionString: TEST_DATABASE_URL });
|
|
138
|
+
await pool.query(
|
|
139
|
+
`CREATE TABLE IF NOT EXISTS router_metrics (id BIGSERIAL PRIMARY KEY, ts TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
140
|
+
provider TEXT, model TEXT, requested_model TEXT, cache_hit BOOLEAN, cache_type TEXT,
|
|
141
|
+
latency_ms INTEGER, cost_usd DOUBLE PRECISION, error TEXT, error_type TEXT)`
|
|
142
|
+
);
|
|
143
|
+
await pool.query(`INSERT INTO router_metrics (ts, provider, cost_usd) VALUES (now() - interval '30 days', 'openai', 0.05)`);
|
|
144
|
+
await pool.end();
|
|
145
|
+
|
|
146
|
+
metrics.record({ provider: 'openai', cost_usd: 0.001 }); // today, should count
|
|
147
|
+
await flush();
|
|
148
|
+
|
|
149
|
+
const summary = await metrics.rangeSummary(14); // 30-day-old row is outside this window
|
|
150
|
+
assert.equal(summary.sample_size, 1);
|
|
151
|
+
assert.equal(summary.total_cost_usd, 0.001);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('pruneOlderThan(): deletes only rows past the cutoff, returns their ids, leaves fresh rows alone', async () => {
|
|
155
|
+
if (!pgAvailable) return;
|
|
156
|
+
const { Pool } = require('pg');
|
|
157
|
+
const pool = new Pool({ connectionString: TEST_DATABASE_URL });
|
|
158
|
+
await pool.query(
|
|
159
|
+
`CREATE TABLE IF NOT EXISTS router_metrics (id BIGSERIAL PRIMARY KEY, ts TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
160
|
+
provider TEXT, model TEXT, requested_model TEXT, cache_hit BOOLEAN, cache_type TEXT,
|
|
161
|
+
latency_ms INTEGER, cost_usd DOUBLE PRECISION, error TEXT, error_type TEXT)`
|
|
162
|
+
);
|
|
163
|
+
await pool.query(`INSERT INTO router_metrics (ts, provider) VALUES (now() - interval '100 days', 'openai')`);
|
|
164
|
+
await pool.end();
|
|
165
|
+
|
|
166
|
+
metrics.record({ provider: 'anthropic' }); // fresh, should survive
|
|
167
|
+
await flush();
|
|
168
|
+
|
|
169
|
+
const deleted = await metrics.pruneOlderThan(30);
|
|
170
|
+
assert.equal(deleted.length, 1);
|
|
171
|
+
|
|
172
|
+
const remaining = await metrics.readRecent();
|
|
173
|
+
assert.equal(remaining.length, 1);
|
|
174
|
+
assert.equal(remaining[0].provider, 'anthropic');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('classifyErrorType() is the exact same function regardless of storage backend (not duplicated/reimplemented)', () => {
|
|
178
|
+
if (!pgAvailable) return;
|
|
179
|
+
assert.equal(
|
|
180
|
+
metrics.classifyErrorType('401 {"error":{"type":"authentication_error"}}'),
|
|
181
|
+
'authentication_error'
|
|
182
|
+
);
|
|
183
|
+
});
|