pi-freeflow 1.30.0 → 1.32.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/CHANGELOG.md +24 -0
- package/package.json +1 -1
- package/src/config.ts +14 -0
- package/src/relay-state.ts +18 -1
- package/src/responses.ts +95 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.32.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Relay sessions now stick to their warm relay and release it after 20 minutes idle, so follow-up turns in one session reuse the fast path instead of paying a cold start.
|
|
8
|
+
|
|
9
|
+
## 1.31.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- de0a0fe: Two new free models on OpenCode Zen, picked the same way as the rest (`/model` → `freeflow` → pick):
|
|
14
|
+
|
|
15
|
+
- **Fledge Alpha Free** (`fledge-alpha-free`, short name `fledge-alpha`) — 1M context, answers with vision.
|
|
16
|
+
- **Ling 3.1 Flash Free** (`ling-3.1-flash-free`, short name `ling-3.1-flash`) — 262K context, text answers.
|
|
17
|
+
|
|
18
|
+
### Patch Changes
|
|
19
|
+
|
|
20
|
+
- 48eec6d: Remove two free models that had stopped serving upstream, so they no longer appear in the model list:
|
|
21
|
+
|
|
22
|
+
- **Ling 3.0 Flash Fin** on OpenCode (`ling-3.0-flash-fin-free`) — upstream answers `400 Endpoint is unavailable`.
|
|
23
|
+
- **Ling 3.0 Flash Fin** on KiloCode (`inclusionai/ling-3.0-flash-fin:free`) — upstream answers `404 does not exist`.
|
|
24
|
+
|
|
25
|
+
Both are excluded permanently, so a stale on-disk catalog or a later catalog refresh cannot bring them back. The short name `ling-3.0-flash-fin` no longer resolves; `ling-3.0-flash-sante` is unaffected and still works.
|
|
26
|
+
|
|
3
27
|
## 1.30.0
|
|
4
28
|
|
|
5
29
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-freeflow",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.32.0",
|
|
5
5
|
"description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
|
|
6
6
|
"main": "extensions/index.ts",
|
|
7
7
|
"types": "src/index.ts",
|
package/src/config.ts
CHANGED
|
@@ -391,6 +391,20 @@ export function resolveUpstreamHealthPath(): string {
|
|
|
391
391
|
}
|
|
392
392
|
}
|
|
393
393
|
|
|
394
|
+
export function resolveSessionPinPath(): string {
|
|
395
|
+
try {
|
|
396
|
+
const override = dataDirOverride();
|
|
397
|
+
if (override) return path.join(override, "pi-freeflow-session-pins.json");
|
|
398
|
+
return path.join(homedir(), ".pi", "agent", "pi-freeflow-session-pins.json");
|
|
399
|
+
} catch {
|
|
400
|
+
return path.join(
|
|
401
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
402
|
+
"..",
|
|
403
|
+
".session-pins.json",
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
394
408
|
export const RELAY_STATE_FILE = resolveRelayStatePath();
|
|
395
409
|
export const LOG_FILE = resolveLogFilePath();
|
|
396
410
|
export const CATALOG_CACHE_FILE = resolveCatalogCachePath();
|
package/src/relay-state.ts
CHANGED
|
@@ -793,6 +793,23 @@ export function ewmaBadge(health: RelayHealth | undefined): string {
|
|
|
793
793
|
return ` ~${Math.round(ms)}ms avg`;
|
|
794
794
|
}
|
|
795
795
|
|
|
796
|
+
/**
|
|
797
|
+
* Session-pin eligibility: a pinned issuer hoists to first only while it is a
|
|
798
|
+
* live pool member, healthy (no active cooldown), and not parked for a
|
|
799
|
+
* disabled deployment (402 park). Idle-stale pins never reach here — the
|
|
800
|
+
* issuer map releases them on access — so this stays a pure health/presence
|
|
801
|
+
* gate and ordering otherwise behaves exactly as today.
|
|
802
|
+
*/
|
|
803
|
+
export function isSessionPinEligible(pin: string, ordered: string[]): boolean {
|
|
804
|
+
const clean = (pin ?? "").trim();
|
|
805
|
+
if (!clean) return false;
|
|
806
|
+
if (!ordered.includes(clean)) return false;
|
|
807
|
+
if (!isRelayHealthy(clean)) return false;
|
|
808
|
+
const health = getRelayHealth(clean);
|
|
809
|
+
if (health?.lastStatus === 402 && Date.now() < health.cooldownUntil) return false;
|
|
810
|
+
return true;
|
|
811
|
+
}
|
|
812
|
+
|
|
796
813
|
/**
|
|
797
814
|
* Candidate relay order for one request.
|
|
798
815
|
*
|
|
@@ -816,7 +833,7 @@ export function orderedRelayCandidates(preferredRelay?: string, spreadKey?: stri
|
|
|
816
833
|
if (preferred) {
|
|
817
834
|
const index = ordered.indexOf(preferred);
|
|
818
835
|
if (index <= 0) return ordered;
|
|
819
|
-
if (!
|
|
836
|
+
if (!isSessionPinEligible(preferred, ordered)) return ordered;
|
|
820
837
|
return [ordered[index], ...ordered.slice(0, index), ...ordered.slice(index + 1)];
|
|
821
838
|
}
|
|
822
839
|
if (activeRelayState.mode !== "spread") return ordered;
|
package/src/responses.ts
CHANGED
|
@@ -21,7 +21,10 @@
|
|
|
21
21
|
* the conversation keeps its recent reasoning.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { createHash } from "node:crypto";
|
|
24
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import { resolveSessionPinPath } from "./config.ts";
|
|
25
28
|
|
|
26
29
|
/** Upstream rejection for a reasoning blob issued to a different caller. */
|
|
27
30
|
const CALLER_MISMATCH_PATTERN =
|
|
@@ -50,9 +53,14 @@ const HASH_LENGTH = 32;
|
|
|
50
53
|
const ISSUER_TTL_MS = 12 * 60 * 60 * 1_000;
|
|
51
54
|
/** Conversations with issuer affinity tracked before the oldest is dropped. */
|
|
52
55
|
const MAX_TRACKED_ISSUERS = 1_024;
|
|
56
|
+
/** Idle window a session pin holds its warm relay: turns inside the window stay
|
|
57
|
+
* on the issuing relay (the upstream prefix cache makes turn-2 fast); past it
|
|
58
|
+
* the pin releases and ordering falls back to the standing health order.
|
|
59
|
+
* Middle of the 10-30min user range; deliberately not a config key. */
|
|
60
|
+
const SESSION_PIN_IDLE_MS = 20 * 60 * 1_000;
|
|
53
61
|
|
|
54
62
|
/** Conversation -> the relay (or direct path) that served its last success. */
|
|
55
|
-
const issuerByConversation = new Map<string, { relay: string | null; expiresAt: number }>();
|
|
63
|
+
const issuerByConversation = new Map<string, { relay: string | null; expiresAt: number; lastServedAt: number }>();
|
|
56
64
|
|
|
57
65
|
interface RejectedReasoning {
|
|
58
66
|
hashes: Set<string>;
|
|
@@ -101,6 +109,7 @@ export function responsesConversationKey(body: unknown): string | null {
|
|
|
101
109
|
* backend from any relay.
|
|
102
110
|
*/
|
|
103
111
|
export function rememberIssuerRelay(conversationKey: string, relay: string | null): void {
|
|
112
|
+
ensureSessionPinsLoaded();
|
|
104
113
|
pruneReasoningState();
|
|
105
114
|
if (
|
|
106
115
|
!issuerByConversation.has(conversationKey) &&
|
|
@@ -112,7 +121,9 @@ export function rememberIssuerRelay(conversationKey: string, relay: string | nul
|
|
|
112
121
|
issuerByConversation.set(conversationKey, {
|
|
113
122
|
relay,
|
|
114
123
|
expiresAt: Date.now() + ISSUER_TTL_MS,
|
|
124
|
+
lastServedAt: Date.now(),
|
|
115
125
|
});
|
|
126
|
+
persistSessionPins();
|
|
116
127
|
}
|
|
117
128
|
|
|
118
129
|
/**
|
|
@@ -121,9 +132,17 @@ export function rememberIssuerRelay(conversationKey: string, relay: string | nul
|
|
|
121
132
|
* record expired), in which case nothing can be replayed incompatibly.
|
|
122
133
|
*/
|
|
123
134
|
export function issuerRelayFor(conversationKey: string): string | null | undefined {
|
|
135
|
+
ensureSessionPinsLoaded();
|
|
124
136
|
const entry = issuerByConversation.get(conversationKey);
|
|
125
137
|
if (!entry) return undefined;
|
|
126
|
-
|
|
138
|
+
const now = Date.now();
|
|
139
|
+
if (entry.expiresAt <= now) {
|
|
140
|
+
issuerByConversation.delete(conversationKey);
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
if (now - entry.lastServedAt >= SESSION_PIN_IDLE_MS) {
|
|
144
|
+
// Idle past the pin window: release the warm relay so ordering falls back
|
|
145
|
+
// to the standing health order. Lazy on access — no timer in the hot path.
|
|
127
146
|
issuerByConversation.delete(conversationKey);
|
|
128
147
|
return undefined;
|
|
129
148
|
}
|
|
@@ -444,7 +463,7 @@ function pruneReasoningState(): void {
|
|
|
444
463
|
if (entry.expiresAt <= now) rejectedIdsByConversation.delete(key);
|
|
445
464
|
}
|
|
446
465
|
for (const [key, entry] of issuerByConversation) {
|
|
447
|
-
if (entry.expiresAt <= now) issuerByConversation.delete(key);
|
|
466
|
+
if (entry.expiresAt <= now || now - entry.lastServedAt >= SESSION_PIN_IDLE_MS) issuerByConversation.delete(key);
|
|
448
467
|
}
|
|
449
468
|
}
|
|
450
469
|
|
|
@@ -453,4 +472,76 @@ export function _resetReasoningStateForTest(): void {
|
|
|
453
472
|
rejectedByConversation.clear();
|
|
454
473
|
rejectedIdsByConversation.clear();
|
|
455
474
|
issuerByConversation.clear();
|
|
475
|
+
sessionPinsLoaded = false;
|
|
476
|
+
lastPinSaveAt = 0;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Session-pin disk mirror: a small JSON next to the relay state file so pins
|
|
480
|
+
* survive proxy restarts. Best-effort both ways — a corrupt file starts empty
|
|
481
|
+
* and a failed save only costs re-learning. Never widens the RelayState shape. */
|
|
482
|
+
const PIN_SAVE_THROTTLE_MS = 1_000;
|
|
483
|
+
let sessionPinsLoaded = false;
|
|
484
|
+
let lastPinSaveAt = 0;
|
|
485
|
+
|
|
486
|
+
function ensureSessionPinsLoaded(): void {
|
|
487
|
+
if (sessionPinsLoaded) return;
|
|
488
|
+
sessionPinsLoaded = true;
|
|
489
|
+
let raw: string;
|
|
490
|
+
try {
|
|
491
|
+
raw = fs.readFileSync(resolveSessionPinPath(), "utf8");
|
|
492
|
+
} catch {
|
|
493
|
+
return; // no mirror yet — start empty
|
|
494
|
+
}
|
|
495
|
+
try {
|
|
496
|
+
const parsed = JSON.parse(raw) as { pins?: Record<string, { relay?: unknown; expiresAt?: unknown; lastServedAt?: unknown }> };
|
|
497
|
+
const pins = parsed?.pins;
|
|
498
|
+
if (!pins || typeof pins !== "object") return;
|
|
499
|
+
const now = Date.now();
|
|
500
|
+
for (const [key, value] of Object.entries(pins)) {
|
|
501
|
+
if (issuerByConversation.size >= MAX_TRACKED_ISSUERS) break;
|
|
502
|
+
if (!key || typeof value !== "object" || value === null) continue;
|
|
503
|
+
const relay = (value as { relay?: unknown }).relay;
|
|
504
|
+
const expiresAt = (value as { expiresAt?: unknown }).expiresAt;
|
|
505
|
+
const lastServedAt = (value as { lastServedAt?: unknown }).lastServedAt;
|
|
506
|
+
if (relay !== null && typeof relay !== "string") continue;
|
|
507
|
+
if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt) || expiresAt <= now) continue;
|
|
508
|
+
if (typeof lastServedAt !== "number" || !Number.isFinite(lastServedAt)) continue;
|
|
509
|
+
if (now - lastServedAt >= SESSION_PIN_IDLE_MS) continue;
|
|
510
|
+
issuerByConversation.set(key, { relay, expiresAt, lastServedAt });
|
|
511
|
+
}
|
|
512
|
+
} catch {
|
|
513
|
+
// Corrupt mirror — start empty, never throw.
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function persistSessionPins(): void {
|
|
518
|
+
const now = Date.now();
|
|
519
|
+
if (now - lastPinSaveAt < PIN_SAVE_THROTTLE_MS) return;
|
|
520
|
+
for (const [key, entry] of issuerByConversation) {
|
|
521
|
+
if (entry.expiresAt <= now || now - entry.lastServedAt >= SESSION_PIN_IDLE_MS) issuerByConversation.delete(key);
|
|
522
|
+
}
|
|
523
|
+
while (issuerByConversation.size > MAX_TRACKED_ISSUERS) {
|
|
524
|
+
const oldest = issuerByConversation.keys().next();
|
|
525
|
+
if (oldest.done) break;
|
|
526
|
+
issuerByConversation.delete(oldest.value);
|
|
527
|
+
}
|
|
528
|
+
try {
|
|
529
|
+
const file = resolveSessionPinPath();
|
|
530
|
+
const dir = path.dirname(file);
|
|
531
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
532
|
+
const body = JSON.stringify({ version: 1, pins: Object.fromEntries(issuerByConversation) }, null, 2);
|
|
533
|
+
const tmp = `${file}.${randomUUID()}.tmp`;
|
|
534
|
+
fs.writeFileSync(tmp, body, "utf8");
|
|
535
|
+
try {
|
|
536
|
+
fs.renameSync(tmp, file);
|
|
537
|
+
} catch {
|
|
538
|
+
try {
|
|
539
|
+
fs.rmSync(tmp, { force: true });
|
|
540
|
+
} catch {}
|
|
541
|
+
fs.writeFileSync(file, body, "utf8");
|
|
542
|
+
}
|
|
543
|
+
lastPinSaveAt = Date.now();
|
|
544
|
+
} catch {
|
|
545
|
+
// Best-effort: losing the mirror only costs re-learning pins.
|
|
546
|
+
}
|
|
456
547
|
}
|