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/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
+ });