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 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.30.0",
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();
@@ -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 (!isRelayHealthy(preferred)) return ordered;
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
- if (entry.expiresAt <= Date.now()) {
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
  }