pi-freeflow 1.2.0 → 1.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/LICENSE +21 -0
- package/README.md +7 -11
- package/extensions/index.ts +4 -2137
- package/package.json +30 -5
- package/src/catalog.ts +204 -0
- package/src/commands.ts +448 -0
- package/src/config.ts +145 -0
- package/src/deploy.ts +126 -0
- package/src/index.ts +244 -0
- package/src/logger.ts +327 -0
- package/src/models.ts +343 -0
- package/src/proxy.ts +473 -0
- package/src/rate-limiter.ts +148 -0
- package/src/relay-state.ts +218 -0
- package/src/relay.ts +193 -0
- package/src/stream-pipe.ts +154 -0
- package/src/types.ts +164 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration and path resolution for pi-freeflow
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import type { Upstream } from "./types.ts";
|
|
10
|
+
|
|
11
|
+
// ── Upstream endpoints ──────────────────────────────────────────────
|
|
12
|
+
export const UPSTREAM_OPENCODE = "https://opencode.ai/zen";
|
|
13
|
+
export const KILO_CHAT_URL = "https://api.kilo.ai/api/gateway/chat/completions";
|
|
14
|
+
export const OPENCODE_API_URL = `${UPSTREAM_OPENCODE}/v1`;
|
|
15
|
+
|
|
16
|
+
// ── Network & Server defaults ───────────────────────────────────────
|
|
17
|
+
export const DEFAULT_PORT = 18080;
|
|
18
|
+
export const HOST = "127.0.0.1";
|
|
19
|
+
export const DEFAULT_HOST = "127.0.0.1";
|
|
20
|
+
|
|
21
|
+
export function resolvePort(): number {
|
|
22
|
+
const envPort = process.env.FREEFLOW_PORT;
|
|
23
|
+
if (envPort) {
|
|
24
|
+
const parsed = Number(envPort);
|
|
25
|
+
if (Number.isFinite(parsed) && parsed > 0 && parsed <= 65535) {
|
|
26
|
+
return parsed;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return DEFAULT_PORT;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const PORT = resolvePort();
|
|
33
|
+
|
|
34
|
+
// ── OpenCode client headers ─────────────────────────────────────────
|
|
35
|
+
export const OPENCODE_USER_AGENT = "opencode/latest/1.14.50/cli";
|
|
36
|
+
export const OPENCODE_CLIENT = "cli";
|
|
37
|
+
export const OPENCODE_PROJECT = "default";
|
|
38
|
+
export const OPENCODE_SESSION = randomUUID();
|
|
39
|
+
|
|
40
|
+
export function opencodeHeaders(): Record<string, string> {
|
|
41
|
+
return {
|
|
42
|
+
"User-Agent": OPENCODE_USER_AGENT,
|
|
43
|
+
"x-opencode-client": OPENCODE_CLIENT,
|
|
44
|
+
"x-opencode-project": OPENCODE_PROJECT,
|
|
45
|
+
"x-opencode-session": OPENCODE_SESSION,
|
|
46
|
+
"x-opencode-request": randomUUID(),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Relay and Deployment constants ──────────────────────────────────
|
|
51
|
+
export const DEFAULT_RELAY_URL = "";
|
|
52
|
+
export const VERCEL_API = "https://api.vercel.com";
|
|
53
|
+
export const RELAY_MAX_TOKENS = 131_072;
|
|
54
|
+
|
|
55
|
+
// ── Catalog & Logging constants ─────────────────────────────────────
|
|
56
|
+
export const CATALOG_CACHE_TTL_MS = 86_400_000; // 24 hours — delegate to host fetchDynamicModels
|
|
57
|
+
export const LOG_MAX_BYTES = 5 * 1024 * 1024; // 5MB
|
|
58
|
+
export const LOG_MAX_FILES = 3;
|
|
59
|
+
|
|
60
|
+
// ── Rate Limit Maxima ───────────────────────────────────────────────
|
|
61
|
+
export const RATE_LIMIT_MAX: Record<Upstream, number> = {
|
|
62
|
+
opencode: 200, // public free quota: requests per UTC day per IP
|
|
63
|
+
kilo: 200, // documented gateway quota: requests per 1-hour window per IP
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// ── Whitelists & Security ───────────────────────────────────────────
|
|
67
|
+
export const ALLOWED_PATH_PATTERN = /^\/v1\/[a-zA-Z0-9/_.,\-?&=]*$/;
|
|
68
|
+
export const PATH_TRAVERSAL_PATTERN = /\.\./;
|
|
69
|
+
export const ALLOWED_METHODS = new Set(["GET", "POST", "OPTIONS", "HEAD"]);
|
|
70
|
+
|
|
71
|
+
export const STRIP_HEADERS = new Set([
|
|
72
|
+
"authorization",
|
|
73
|
+
"host",
|
|
74
|
+
"content-length",
|
|
75
|
+
"x-forwarded-for",
|
|
76
|
+
"x-forwarded-host",
|
|
77
|
+
"x-forwarded-proto",
|
|
78
|
+
"x-real-ip",
|
|
79
|
+
"x-client-ip",
|
|
80
|
+
"x-originate-ip",
|
|
81
|
+
"cookie",
|
|
82
|
+
"set-cookie",
|
|
83
|
+
"proxy-connection",
|
|
84
|
+
"proxy-authorization",
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
// ── File Path Resolvers ─────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
export function resolveRelayStatePath(): string {
|
|
90
|
+
try {
|
|
91
|
+
return path.join(homedir(), ".pi", "agent", "pi-freeflow-relay-state.json");
|
|
92
|
+
} catch {
|
|
93
|
+
return path.join(
|
|
94
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
95
|
+
"..",
|
|
96
|
+
".relay-state.json",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function resolveLogFilePath(): string {
|
|
102
|
+
try {
|
|
103
|
+
return path.join(homedir(), ".pi", "agent", "pi-freeflow.log");
|
|
104
|
+
} catch {
|
|
105
|
+
return path.join(
|
|
106
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
107
|
+
"..",
|
|
108
|
+
"pi-freeflow.log",
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function resolveCatalogCachePath(): string {
|
|
114
|
+
try {
|
|
115
|
+
return path.join(
|
|
116
|
+
homedir(),
|
|
117
|
+
".pi",
|
|
118
|
+
"agent",
|
|
119
|
+
"pi-freeflow-catalog-cache.json",
|
|
120
|
+
);
|
|
121
|
+
} catch {
|
|
122
|
+
return path.join(
|
|
123
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
124
|
+
"..",
|
|
125
|
+
".catalog-cache.json",
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function resolveDebugStatePath(): string {
|
|
131
|
+
try {
|
|
132
|
+
return path.join(homedir(), ".pi", "agent", "pi-freeflow-debug.json");
|
|
133
|
+
} catch {
|
|
134
|
+
return path.join(
|
|
135
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
136
|
+
"..",
|
|
137
|
+
".debug-state.json",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export const RELAY_STATE_FILE = resolveRelayStatePath();
|
|
143
|
+
export const LOG_FILE = resolveLogFilePath();
|
|
144
|
+
export const CATALOG_CACHE_FILE = resolveCatalogCachePath();
|
|
145
|
+
export const DEBUG_STATE_FILE = resolveDebugStatePath();
|
package/src/deploy.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automated Vercel Edge Relay deployer for pi-freeflow
|
|
3
|
+
*
|
|
4
|
+
* Deploys a private 3-file Vercel edge proxy with strict target domain whitelisting.
|
|
5
|
+
* The provided API token is held in-memory only and never persisted to disk or logs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { VERCEL_API } from "./config.ts";
|
|
9
|
+
import { log, logError } from "./logger.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Hardened Edge Worker code deployed to Vercel.
|
|
13
|
+
* Strictly whitelists OpenCode Zen and KiloCode Gateway endpoints to prevent open proxy abuse.
|
|
14
|
+
*/
|
|
15
|
+
export const VERCEL_RELAY_WORKER = `// Only the 2 upstreams pi-freeflow talks to. Anything else = open proxy abuse.
|
|
16
|
+
const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
|
|
17
|
+
export const config = { runtime: "edge" };
|
|
18
|
+
export default async function handler(req) {
|
|
19
|
+
const target = req.headers.get("x-relay-target");
|
|
20
|
+
const relayPath = req.headers.get("x-relay-path") || "/";
|
|
21
|
+
if (!target) return new Response(JSON.stringify({ error: "Missing x-relay-target header" }), { status: 400, headers: { "content-type": "application/json" } });
|
|
22
|
+
const cleanTarget = target.replace(/\\/$/, "");
|
|
23
|
+
if (!ALLOWED_TARGETS.includes(cleanTarget)) return new Response(JSON.stringify({ error: "Forbidden target" }), { status: 403, headers: { "content-type": "application/json" } });
|
|
24
|
+
if (!relayPath.startsWith("/")) return new Response(JSON.stringify({ error: "Bad path" }), { status: 400, headers: { "content-type": "application/json" } });
|
|
25
|
+
const targetUrl = cleanTarget + relayPath;
|
|
26
|
+
const headers = new Headers(req.headers);
|
|
27
|
+
headers.delete("x-relay-target"); headers.delete("x-relay-path"); headers.delete("host");
|
|
28
|
+
const response = await fetch(targetUrl, { method: req.method, headers, body: req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined, duplex: "half" });
|
|
29
|
+
return new Response(response.body, { status: response.status, headers: response.headers });
|
|
30
|
+
}`;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Deploy a fresh Vercel Edge Relay project in-memory.
|
|
34
|
+
*
|
|
35
|
+
* @param token Vercel personal access token (used in-memory only)
|
|
36
|
+
* @param name Unique project/deployment name (e.g. pi-freeflow-relay-abc123)
|
|
37
|
+
* @param onProgress Optional callback for user-facing progress updates
|
|
38
|
+
* @returns Deployed HTTPS relay URL
|
|
39
|
+
*/
|
|
40
|
+
export async function deployVercelRelay(
|
|
41
|
+
token: string,
|
|
42
|
+
name: string,
|
|
43
|
+
onProgress?: (msg: string) => void,
|
|
44
|
+
): Promise<string> {
|
|
45
|
+
const auth = {
|
|
46
|
+
Authorization: `Bearer ${token}`,
|
|
47
|
+
"Content-Type": "application/json",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// 1. Create deployment (3 inline files, no git repository required)
|
|
51
|
+
onProgress?.("Uploading relay files to Vercel…");
|
|
52
|
+
log("info", `Starting Vercel deployment: ${name}`);
|
|
53
|
+
|
|
54
|
+
const dep = await fetch(`${VERCEL_API}/v13/deployments`, {
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: auth,
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
name,
|
|
59
|
+
files: [
|
|
60
|
+
{ file: "api/relay.js", data: VERCEL_RELAY_WORKER },
|
|
61
|
+
{
|
|
62
|
+
file: "package.json",
|
|
63
|
+
data: JSON.stringify({ name, version: "1.0.0" }),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
file: "vercel.json",
|
|
67
|
+
data: JSON.stringify({
|
|
68
|
+
rewrites: [{ source: "/(.*)", destination: "/api/relay" }],
|
|
69
|
+
}),
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
projectSettings: { framework: null },
|
|
73
|
+
target: "production",
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (!dep.ok) {
|
|
78
|
+
const e = (await dep
|
|
79
|
+
.json()
|
|
80
|
+
.catch(() => ({}))) as { error?: { message?: string } };
|
|
81
|
+
const errMsg = e?.error?.message || `Vercel deploy failed (HTTP ${dep.status})`;
|
|
82
|
+
logError(`Vercel deployment failed to create: ${errMsg}`);
|
|
83
|
+
throw new Error(errMsg);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const depJson = (await dep.json()) as { id?: string; uid?: string; projectId?: string };
|
|
87
|
+
const depId = depJson.id || depJson.uid;
|
|
88
|
+
const projectId = depJson.projectId || name;
|
|
89
|
+
|
|
90
|
+
// 2. Make the deployment public (disable SSO protection if enabled on team)
|
|
91
|
+
try {
|
|
92
|
+
await fetch(`${VERCEL_API}/v9/projects/${projectId}`, {
|
|
93
|
+
method: "PATCH",
|
|
94
|
+
headers: auth,
|
|
95
|
+
body: JSON.stringify({ ssoProtection: null }),
|
|
96
|
+
});
|
|
97
|
+
} catch {}
|
|
98
|
+
|
|
99
|
+
// 3. Poll until READY state (3s interval, 120s maximum timeout)
|
|
100
|
+
onProgress?.("Waiting for Edge deployment to go live…");
|
|
101
|
+
const deadline = Date.now() + 120_000;
|
|
102
|
+
|
|
103
|
+
while (Date.now() < deadline) {
|
|
104
|
+
const s = await fetch(`${VERCEL_API}/v13/deployments/${depId}`, {
|
|
105
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
106
|
+
});
|
|
107
|
+
if (s.ok) {
|
|
108
|
+
const j = (await s.json()) as { readyState?: string; url?: string };
|
|
109
|
+
if (j.readyState === "READY" && j.url) {
|
|
110
|
+
const deployedUrl = `https://${j.url}`;
|
|
111
|
+
log("info", `Vercel relay successfully deployed: ${deployedUrl}`);
|
|
112
|
+
return deployedUrl;
|
|
113
|
+
}
|
|
114
|
+
if (j.readyState === "ERROR" || j.readyState === "CANCELED") {
|
|
115
|
+
const err = `Deployment failed with state: ${j.readyState}`;
|
|
116
|
+
logError(err);
|
|
117
|
+
throw new Error(err);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
await new Promise<void>((r) => setTimeout(r, 3000));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const timeoutErr = "Deployment timed out (120s)";
|
|
124
|
+
logError(timeoutErr);
|
|
125
|
+
throw new Error(timeoutErr);
|
|
126
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-freeflow — Modular, high-resiliency LLM extension for Pi & Oh My Pi (OMP)
|
|
3
|
+
*
|
|
4
|
+
* Provides access to 23 free models (9 OpenCode Zen + 14 KiloCode Gateway) with:
|
|
5
|
+
* - Single-port daemon reuse on 18080 across concurrent subagents
|
|
6
|
+
* - Multi-cloud rolling egress relays (Vercel Edge, Cloudflare, Deno)
|
|
7
|
+
* - 0ms instant startup with verified static catalog and background live health checks
|
|
8
|
+
* - Per-model thinking/reasoning translation and streaming SSE pass-through
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type * as http from "node:http";
|
|
12
|
+
import {
|
|
13
|
+
getAliveCatalog,
|
|
14
|
+
readCatalogCache,
|
|
15
|
+
refreshCatalog,
|
|
16
|
+
setAliveCatalog,
|
|
17
|
+
} from "./catalog.ts";
|
|
18
|
+
import { createCommandSpec, updateStatusBar } from "./commands.ts";
|
|
19
|
+
import { DEFAULT_HOST, HOST, PORT } from "./config.ts";
|
|
20
|
+
import { log, logInfo, logWarn } from "./logger.ts";
|
|
21
|
+
import { ALL_MODELS, KILO_MODEL_IDS } from "./models.ts";
|
|
22
|
+
import { isProxyAlive, startProxy } from "./proxy.ts";
|
|
23
|
+
import { resetRateLimits } from "./rate-limiter.ts";
|
|
24
|
+
import {
|
|
25
|
+
getActiveRelayState,
|
|
26
|
+
resolveRelayState,
|
|
27
|
+
setActiveRelayState,
|
|
28
|
+
setStatusUi,
|
|
29
|
+
} from "./relay-state.ts";
|
|
30
|
+
import type {
|
|
31
|
+
ExtensionAPI,
|
|
32
|
+
ExtensionContext,
|
|
33
|
+
ProviderConfig,
|
|
34
|
+
RegisteredModel,
|
|
35
|
+
} from "./types.ts";
|
|
36
|
+
|
|
37
|
+
// Re-export all sub-modules for clean library and programmatic usage
|
|
38
|
+
export * from "./types.ts";
|
|
39
|
+
export * from "./config.ts";
|
|
40
|
+
export * from "./logger.ts";
|
|
41
|
+
export * from "./rate-limiter.ts";
|
|
42
|
+
export * from "./models.ts";
|
|
43
|
+
export * from "./catalog.ts";
|
|
44
|
+
export * from "./relay-state.ts";
|
|
45
|
+
export * from "./relay.ts";
|
|
46
|
+
export * from "./deploy.ts";
|
|
47
|
+
export * from "./stream-pipe.ts";
|
|
48
|
+
export * from "./proxy.ts";
|
|
49
|
+
export * from "./commands.ts";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Construct standard ProviderConfig for pi-ai / OMP registration.
|
|
53
|
+
*/
|
|
54
|
+
export function buildProviderConfig(
|
|
55
|
+
models: RegisteredModel[],
|
|
56
|
+
port: number = PORT,
|
|
57
|
+
): ProviderConfig {
|
|
58
|
+
return {
|
|
59
|
+
baseUrl: `http://${HOST}:${port}/v1`,
|
|
60
|
+
apiKey: "placeholder",
|
|
61
|
+
api: "openai-completions",
|
|
62
|
+
compat: { supportsDeveloperRole: false },
|
|
63
|
+
models: models.map((m) => {
|
|
64
|
+
const efforts = m.thinkingLevelMap
|
|
65
|
+
? (Object.keys(m.thinkingLevelMap) as (keyof typeof m.thinkingLevelMap)[]).filter(
|
|
66
|
+
(k) => m.thinkingLevelMap![k] !== null && k !== "off",
|
|
67
|
+
)
|
|
68
|
+
: ["minimal", "low", "medium", "high", "xhigh"];
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
id: m.id,
|
|
72
|
+
name: m.name,
|
|
73
|
+
api: m.api,
|
|
74
|
+
reasoning: m.reasoning,
|
|
75
|
+
thinking: m.reasoning
|
|
76
|
+
? {
|
|
77
|
+
mode: "effort",
|
|
78
|
+
efforts: efforts.length > 0 ? efforts : ["low", "high", "max"],
|
|
79
|
+
}
|
|
80
|
+
: undefined,
|
|
81
|
+
thinkingLevelMap: m.thinkingLevelMap,
|
|
82
|
+
input: m.input ?? ["text"],
|
|
83
|
+
contextWindow: m.contextWindow,
|
|
84
|
+
maxTokens: m.maxTokens,
|
|
85
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
86
|
+
compat: m.thinkingFormat
|
|
87
|
+
? {
|
|
88
|
+
supportsDeveloperRole: false,
|
|
89
|
+
thinkingFormat: m.thinkingFormat,
|
|
90
|
+
}
|
|
91
|
+
: m.api === "openai-responses"
|
|
92
|
+
? { sessionAffinityFormat: "openai-nosession" }
|
|
93
|
+
: m.source === "kilo"
|
|
94
|
+
? {
|
|
95
|
+
supportsDeveloperRole: false,
|
|
96
|
+
supportsReasoningEffort: false,
|
|
97
|
+
}
|
|
98
|
+
: {
|
|
99
|
+
supportsDeveloperRole: false,
|
|
100
|
+
supportsReasoningEffort: true,
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Main extension entrypoint
|
|
109
|
+
*/
|
|
110
|
+
export default async function (pi: ExtensionAPI): Promise<void> {
|
|
111
|
+
logInfo("pi-freeflow extension initializing...");
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
let server: http.Server | null = null;
|
|
115
|
+
let actualPort = PORT;
|
|
116
|
+
|
|
117
|
+
// 2. Single-Port Shared Pattern: Check if daemon is already running (e.g. parent session)
|
|
118
|
+
const alreadyRunning = await isProxyAlive(PORT);
|
|
119
|
+
if (alreadyRunning) {
|
|
120
|
+
logInfo(
|
|
121
|
+
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${PORT}`,
|
|
122
|
+
);
|
|
123
|
+
actualPort = PORT;
|
|
124
|
+
} else {
|
|
125
|
+
try {
|
|
126
|
+
const r = await startProxy();
|
|
127
|
+
server = r.server;
|
|
128
|
+
actualPort = r.port;
|
|
129
|
+
} catch (e) {
|
|
130
|
+
log(
|
|
131
|
+
"error",
|
|
132
|
+
"extension inactive — could not bind proxy port. resolve the port conflict and restart pi.",
|
|
133
|
+
{ error: String(e) },
|
|
134
|
+
);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 3. Instant 0ms Static Catalog Registration
|
|
140
|
+
// Register static models immediately on boot so Pi/OMP picker is populated with zero latency!
|
|
141
|
+
const initialCatalog: RegisteredModel[] = ALL_MODELS.map((m) => ({
|
|
142
|
+
...m,
|
|
143
|
+
source: KILO_MODEL_IDS.has(m.id) ? "kilo" : "opencode",
|
|
144
|
+
}));
|
|
145
|
+
setAliveCatalog(initialCatalog);
|
|
146
|
+
pi.registerProvider(
|
|
147
|
+
"freeflow",
|
|
148
|
+
buildProviderConfig(initialCatalog, actualPort),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// 4. Background Catalog Refresh
|
|
152
|
+
// Asynchronously probe live upstreams and update the provider if alive model list changes
|
|
153
|
+
refreshCatalog(false)
|
|
154
|
+
.then((aliveModels) => {
|
|
155
|
+
if (aliveModels.length > 0) {
|
|
156
|
+
setAliveCatalog(aliveModels);
|
|
157
|
+
pi.registerProvider(
|
|
158
|
+
"freeflow",
|
|
159
|
+
buildProviderConfig(aliveModels, actualPort),
|
|
160
|
+
);
|
|
161
|
+
logInfo(
|
|
162
|
+
`Catalog refreshed: ${aliveModels.length} models verified active`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
.catch((err) => {
|
|
167
|
+
logWarn("Background catalog refresh failed; retaining static catalog", {
|
|
168
|
+
error: String(err),
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// 5. Register slash command
|
|
173
|
+
const commandSpec = createCommandSpec(pi, (updatedModels) => {
|
|
174
|
+
pi.registerProvider(
|
|
175
|
+
"freeflow",
|
|
176
|
+
buildProviderConfig(updatedModels, actualPort),
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
pi.registerCommand("freeflow", commandSpec);
|
|
180
|
+
|
|
181
|
+
// 6. Lifecycle Listeners
|
|
182
|
+
pi.on?.("session_start", async (_event, ctx: ExtensionContext) => {
|
|
183
|
+
const freshRelayState = resolveRelayState();
|
|
184
|
+
setActiveRelayState(freshRelayState, false);
|
|
185
|
+
setStatusUi(ctx.ui);
|
|
186
|
+
|
|
187
|
+
let provider: string | undefined;
|
|
188
|
+
let modelId: string | undefined;
|
|
189
|
+
if (ctx && typeof ctx === "object" && "model" in ctx && ctx.model && typeof ctx.model === "object") {
|
|
190
|
+
const m = ctx.model;
|
|
191
|
+
if ("provider" in m && typeof m.provider === "string") {
|
|
192
|
+
provider = m.provider;
|
|
193
|
+
}
|
|
194
|
+
if ("id" in m && typeof m.id === "string") {
|
|
195
|
+
modelId = m.id;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const isFreeFlow =
|
|
199
|
+
provider === "freeflow" ||
|
|
200
|
+
Boolean(modelId && getAliveCatalog().some((m) => m.id === modelId));
|
|
201
|
+
|
|
202
|
+
if (isFreeFlow) {
|
|
203
|
+
updateStatusBar(ctx.ui);
|
|
204
|
+
} else {
|
|
205
|
+
ctx.ui?.setStatus?.("freeflow", undefined);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
pi.on?.("model_select", async (event, ctx: ExtensionContext) => {
|
|
210
|
+
const freshRelayState = resolveRelayState();
|
|
211
|
+
setActiveRelayState(freshRelayState, false);
|
|
212
|
+
setStatusUi(ctx.ui);
|
|
213
|
+
let provider: string | undefined;
|
|
214
|
+
let modelId: string | undefined;
|
|
215
|
+
if (event && typeof event === "object" && "model" in event && event.model && typeof event.model === "object") {
|
|
216
|
+
const m = event.model;
|
|
217
|
+
if ("provider" in m && typeof m.provider === "string") {
|
|
218
|
+
provider = m.provider;
|
|
219
|
+
}
|
|
220
|
+
if ("id" in m && typeof m.id === "string") {
|
|
221
|
+
modelId = m.id;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const isFreeFlow =
|
|
225
|
+
provider === "freeflow" ||
|
|
226
|
+
Boolean(modelId && getAliveCatalog().some((m) => m.id === modelId));
|
|
227
|
+
|
|
228
|
+
if (isFreeFlow) {
|
|
229
|
+
updateStatusBar(ctx.ui);
|
|
230
|
+
} else {
|
|
231
|
+
ctx.ui?.setStatus?.("freeflow", undefined);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
pi.on?.("session_shutdown", () => {
|
|
236
|
+
if (server) {
|
|
237
|
+
logInfo("shutting down proxy daemon...");
|
|
238
|
+
server.close();
|
|
239
|
+
server = null;
|
|
240
|
+
resetRateLimits();
|
|
241
|
+
logInfo("shutdown complete");
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
}
|