pi-freeflow 1.2.1 → 1.3.1
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/README.md +12 -14
- package/package.json +7 -2
- package/src/catalog.ts +13 -64
- package/src/config.ts +1 -1
- package/src/index.ts +2 -1
- package/src/models.ts +8 -8
- package/src/proxy.ts +15 -9
- package/src/relay-state.ts +12 -2
- package/src/relay.ts +2 -7
- package/src/normalizer.ts +0 -173
package/README.md
CHANGED
|
@@ -1,22 +1,20 @@
|
|
|
1
1
|
# pi-freeflow
|
|
2
2
|
|
|
3
|
-
> 🌊 **FreeFlow
|
|
3
|
+
> 🌊 **FreeFlow** — Thin provider for [Oh My Pi (OMP)](https://omp.sh) & [Pi](https://pi.dev): **model list + relay proxy + log**. The rest (thinking, normalization, provider composition) is handled by the host `pi-ai` system — `pi-freeflow` just provides 23 free models via a multi-cloud rolling relay.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
## ✨ Features
|
|
8
|
-
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
- 📝 **Persistent Background Logging**: Automatic diagnostic logs recorded to `~/.pi/agent/pi-freeflow.log` with 5MB auto-rotation.
|
|
19
|
-
- 🤖 **Sub-Agent Ready**: Seamlessly shared across concurrent parallel sub-agent tasks.
|
|
7
|
+
## ✨ Features — Minimal by Design
|
|
8
|
+
|
|
9
|
+
`pi-freeflow` is **thin**: **model list + relay proxy + log**. Host `pi-ai`/OMP owns thinking, normalization, and provider composition.
|
|
10
|
+
|
|
11
|
+
- 📋 **23 Free Models** — `9 OpenCode Zen + 14 Kilo` via host `fetchDynamicModels` (24h cache, disk-only, no subagent live fetch)
|
|
12
|
+
- 🌐 **Dumb Relay Proxy** — `http://127.0.0.1:18080` dumb pipe: `x-relay-target/path` → 7-pool (CF Workers + Vercel Edge) round-robin seamless `429` roll → direct fallback; host already normalized `reasoning_effort`/`thinking`
|
|
13
|
+
- 📝 **Persistent Log** — `~/.pi/agent/pi-freeflow.log` 5MB rotate, `pi-freeflow-debug.json` toggle, `GET /v1/models` pathname-guarded (no `?query` paid leak)
|
|
14
|
+
- 🤖 **Sub-agent Ready** — shared daemon `18080` (or `18081` fallback) reused across parallel subagents; round-robin primary + `willUseRelay` bypass prevents local `127.0.0.1` 429
|
|
15
|
+
- ⚡ **SSE Streaming** — zero-buffer chunk pipe, `thinking_delta` pass-through (host parses)
|
|
16
|
+
|
|
17
|
+
> **Philosophy:** *model list + relay proxy + log — the rest is Pi.* No `src/normalizer.ts` duplication; `pi-ai` owns `compat.thinkingFormat` (11 variants), `clampThinkingBudgetToAnswerRoom`, `transformMessages`.
|
|
20
18
|
|
|
21
19
|
---
|
|
22
20
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-freeflow",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.
|
|
5
|
-
"description": "
|
|
4
|
+
"version": "1.3.1",
|
|
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",
|
|
8
8
|
"keywords": [
|
|
@@ -23,6 +23,11 @@
|
|
|
23
23
|
"url": "https://github.com/trefeon/pi-freeflow.git"
|
|
24
24
|
},
|
|
25
25
|
"homepage": "https://github.com/trefeon/pi-freeflow#readme",
|
|
26
|
+
"omp": {
|
|
27
|
+
"extensions": [
|
|
28
|
+
"./extensions"
|
|
29
|
+
]
|
|
30
|
+
},
|
|
26
31
|
"pi": {
|
|
27
32
|
"extensions": [
|
|
28
33
|
"./extensions"
|
package/src/catalog.ts
CHANGED
|
@@ -183,73 +183,22 @@ export function writeCatalogCache(data: CatalogCacheData): void {
|
|
|
183
183
|
* Falls back gracefully to cached or static models if network requests fail.
|
|
184
184
|
*/
|
|
185
185
|
export async function refreshCatalog(force = false): Promise<RegisteredModel[]> {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
186
|
+
// Thin provider: no live fetch — subagents must not hit upstream directly
|
|
187
|
+
// (proxy-only). Host Pi/OMP owns dynamic discovery via fetchDynamicModels (24h).
|
|
188
|
+
// We only serve disk cache if fresh, otherwise static 23-model aliveCatalog.
|
|
189
|
+
const disk = readCatalogCache();
|
|
190
|
+
if (disk && Array.isArray(disk.models) && disk.models.length > 0) {
|
|
191
|
+
const age = Date.now() - (disk.timestamp ?? 0);
|
|
192
|
+
if (!force && age < CATALOG_CACHE_TTL_MS) {
|
|
189
193
|
aliveCatalog = disk.models;
|
|
190
194
|
return aliveCatalog;
|
|
191
195
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
try {
|
|
197
|
-
const r = await fetch(`${OPENCODE_API_URL}/models`, {
|
|
198
|
-
headers: opencodeHeaders(),
|
|
199
|
-
signal: AbortSignal.timeout(10_000),
|
|
200
|
-
});
|
|
201
|
-
if (r.ok) {
|
|
202
|
-
const d = await r.json();
|
|
203
|
-
const items: RawModelItem[] = Array.isArray(d?.data) ? d.data : [];
|
|
204
|
-
const aliveIds = new Set(items.map((m) => m.id));
|
|
205
|
-
opencodeList = KNOWN_MODELS.filter((m) => aliveIds.has(m.id)).map((m) => ({
|
|
206
|
-
...m,
|
|
207
|
-
source: "opencode" as const,
|
|
208
|
-
}));
|
|
209
|
-
}
|
|
210
|
-
} catch (err) {
|
|
211
|
-
logDebug("OpenCode dynamic model fetch failed, using defaults", { error: String(err) });
|
|
212
|
-
}
|
|
213
|
-
if (!opencodeList.length) {
|
|
214
|
-
opencodeList = KNOWN_MODELS.map((m) => ({ ...m, source: "opencode" as const }));
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// 2. Fetch KiloCode Gateway models
|
|
218
|
-
let kiloList: RegisteredModel[] = [];
|
|
219
|
-
try {
|
|
220
|
-
const kiloModelsUrl = KILO_CHAT_URL.replace("/chat/completions", "/models");
|
|
221
|
-
const r = await fetch(kiloModelsUrl, {
|
|
222
|
-
headers: { Authorization: "Bearer kilo-free" },
|
|
223
|
-
signal: AbortSignal.timeout(10_000),
|
|
224
|
-
});
|
|
225
|
-
if (r.ok) {
|
|
226
|
-
const d = await r.json();
|
|
227
|
-
const items: RawModelItem[] = Array.isArray(d?.data) ? d.data : [];
|
|
228
|
-
const aliveIds = new Set(items.map((m) => m.id));
|
|
229
|
-
kiloList = KILO_MODELS.filter((m) => aliveIds.has(m.id)).map((m) => ({
|
|
230
|
-
...m,
|
|
231
|
-
source: "kilo" as const,
|
|
232
|
-
}));
|
|
196
|
+
// Stale cache still better than empty — return it without network
|
|
197
|
+
if (disk.models.length === 23) {
|
|
198
|
+
aliveCatalog = disk.models;
|
|
199
|
+
return aliveCatalog;
|
|
233
200
|
}
|
|
234
|
-
} catch (err) {
|
|
235
|
-
logDebug("KiloCode dynamic model fetch failed, using defaults", { error: String(err) });
|
|
236
201
|
}
|
|
237
|
-
if
|
|
238
|
-
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
const all = [...opencodeList, ...kiloList];
|
|
242
|
-
aliveCatalog = all;
|
|
243
|
-
|
|
244
|
-
// Write rich models to cache atomically
|
|
245
|
-
const data: CatalogCacheData = {
|
|
246
|
-
timestamp: Date.now(),
|
|
247
|
-
opencode: opencodeList.map((m) => m.id),
|
|
248
|
-
kilo: kiloList.map((m) => m.id),
|
|
249
|
-
models: all,
|
|
250
|
-
};
|
|
251
|
-
writeCatalogCache(data);
|
|
252
|
-
|
|
253
|
-
log("info", `Catalog refreshed: ${opencodeList.length} OpenCode + ${kiloList.length} KiloCode free models available`);
|
|
254
|
-
return all;
|
|
202
|
+
// No valid cache — return in-memory static 23 (host will refresh if needed)
|
|
203
|
+
return aliveCatalog;
|
|
255
204
|
}
|
package/src/config.ts
CHANGED
|
@@ -53,7 +53,7 @@ export const VERCEL_API = "https://api.vercel.com";
|
|
|
53
53
|
export const RELAY_MAX_TOKENS = 131_072;
|
|
54
54
|
|
|
55
55
|
// ── Catalog & Logging constants ─────────────────────────────────────
|
|
56
|
-
export const CATALOG_CACHE_TTL_MS =
|
|
56
|
+
export const CATALOG_CACHE_TTL_MS = 86_400_000; // 24 hours — delegate to host fetchDynamicModels
|
|
57
57
|
export const LOG_MAX_BYTES = 5 * 1024 * 1024; // 5MB
|
|
58
58
|
export const LOG_MAX_FILES = 3;
|
|
59
59
|
|
package/src/index.ts
CHANGED
|
@@ -44,7 +44,6 @@ export * from "./catalog.ts";
|
|
|
44
44
|
export * from "./relay-state.ts";
|
|
45
45
|
export * from "./relay.ts";
|
|
46
46
|
export * from "./deploy.ts";
|
|
47
|
-
export * from "./normalizer.ts";
|
|
48
47
|
export * from "./stream-pipe.ts";
|
|
49
48
|
export * from "./proxy.ts";
|
|
50
49
|
export * from "./commands.ts";
|
|
@@ -208,6 +207,8 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
208
207
|
});
|
|
209
208
|
|
|
210
209
|
pi.on?.("model_select", async (event, ctx: ExtensionContext) => {
|
|
210
|
+
const freshRelayState = resolveRelayState();
|
|
211
|
+
setActiveRelayState(freshRelayState, false);
|
|
211
212
|
setStatusUi(ctx.ui);
|
|
212
213
|
let provider: string | undefined;
|
|
213
214
|
let modelId: string | undefined;
|
package/src/models.ts
CHANGED
|
@@ -21,7 +21,7 @@ export const OPENCODE_MODELS: ModelDef[] = [
|
|
|
21
21
|
maxTokens: 384_000,
|
|
22
22
|
input: ["text"],
|
|
23
23
|
thinkingLevelMap: {
|
|
24
|
-
off:
|
|
24
|
+
off: null,
|
|
25
25
|
minimal: "low",
|
|
26
26
|
low: "low",
|
|
27
27
|
medium: "high",
|
|
@@ -38,7 +38,7 @@ export const OPENCODE_MODELS: ModelDef[] = [
|
|
|
38
38
|
maxTokens: 131_072,
|
|
39
39
|
input: ["text", "image"],
|
|
40
40
|
thinkingLevelMap: {
|
|
41
|
-
off:
|
|
41
|
+
off: null,
|
|
42
42
|
minimal: "low",
|
|
43
43
|
low: "low",
|
|
44
44
|
medium: "high",
|
|
@@ -73,7 +73,7 @@ export const OPENCODE_MODELS: ModelDef[] = [
|
|
|
73
73
|
maxTokens: 131_072,
|
|
74
74
|
input: ["text", "image"],
|
|
75
75
|
thinkingLevelMap: {
|
|
76
|
-
off:
|
|
76
|
+
off: null,
|
|
77
77
|
minimal: "low",
|
|
78
78
|
low: "low",
|
|
79
79
|
medium: "medium",
|
|
@@ -90,7 +90,7 @@ export const OPENCODE_MODELS: ModelDef[] = [
|
|
|
90
90
|
maxTokens: 128_000,
|
|
91
91
|
input: ["text"],
|
|
92
92
|
thinkingLevelMap: {
|
|
93
|
-
off:
|
|
93
|
+
off: null,
|
|
94
94
|
minimal: "low",
|
|
95
95
|
low: "low",
|
|
96
96
|
medium: "high",
|
|
@@ -107,7 +107,7 @@ export const OPENCODE_MODELS: ModelDef[] = [
|
|
|
107
107
|
maxTokens: 128_000,
|
|
108
108
|
input: ["text"],
|
|
109
109
|
thinkingLevelMap: {
|
|
110
|
-
off:
|
|
110
|
+
off: null,
|
|
111
111
|
minimal: "low",
|
|
112
112
|
low: "low",
|
|
113
113
|
medium: "high",
|
|
@@ -124,7 +124,7 @@ export const OPENCODE_MODELS: ModelDef[] = [
|
|
|
124
124
|
maxTokens: 262_144,
|
|
125
125
|
input: ["text"],
|
|
126
126
|
thinkingLevelMap: {
|
|
127
|
-
off:
|
|
127
|
+
off: null,
|
|
128
128
|
minimal: "low",
|
|
129
129
|
low: "low",
|
|
130
130
|
medium: "high",
|
|
@@ -141,7 +141,7 @@ export const OPENCODE_MODELS: ModelDef[] = [
|
|
|
141
141
|
maxTokens: 32_000,
|
|
142
142
|
input: ["text"],
|
|
143
143
|
thinkingLevelMap: {
|
|
144
|
-
off:
|
|
144
|
+
off: null,
|
|
145
145
|
minimal: "high",
|
|
146
146
|
low: "high",
|
|
147
147
|
medium: "high",
|
|
@@ -158,7 +158,7 @@ export const OPENCODE_MODELS: ModelDef[] = [
|
|
|
158
158
|
maxTokens: 131_072,
|
|
159
159
|
input: ["text"],
|
|
160
160
|
thinkingLevelMap: {
|
|
161
|
-
off:
|
|
161
|
+
off: null,
|
|
162
162
|
minimal: "low",
|
|
163
163
|
low: "low",
|
|
164
164
|
medium: "high",
|
package/src/proxy.ts
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
} from "./config.ts";
|
|
25
25
|
import { isDebugEnabled, log } from "./logger.ts";
|
|
26
26
|
import { KILO_MODEL_IDS } from "./models.ts";
|
|
27
|
-
|
|
27
|
+
// normalize removed — host pi-ai already normalizes thinking/reasoning before proxy
|
|
28
28
|
import { checkRateLimit } from "./rate-limiter.ts";
|
|
29
29
|
import { relayFetch } from "./relay.ts";
|
|
30
30
|
import { getActiveRelayState } from "./relay-state.ts";
|
|
@@ -143,10 +143,12 @@ export function startProxy(
|
|
|
143
143
|
|
|
144
144
|
// Serve ONLY our registered free models. Never forward /v1/models to upstream
|
|
145
145
|
// to prevent paid/proprietary upstream models from leaking into the model picker.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
146
|
+
// Use pathname check so /v1/models?query variants are also guarded (no leak).
|
|
147
|
+
let reqPathname: string | null = null;
|
|
148
|
+
try {
|
|
149
|
+
reqPathname = new URL(req.url ?? "/", `http://${HOST}`).pathname;
|
|
150
|
+
} catch {}
|
|
151
|
+
if (req.method === "GET" && (reqPathname === "/v1/models" || reqPathname === "/v1/models/")) {
|
|
150
152
|
const alive = getAliveCatalog();
|
|
151
153
|
const body = JSON.stringify({
|
|
152
154
|
object: "list",
|
|
@@ -207,7 +209,14 @@ export function startProxy(
|
|
|
207
209
|
const upstream: Upstream = isKilo ? "kilo" : "opencode";
|
|
208
210
|
const isStream = parsedBody?.stream === true;
|
|
209
211
|
|
|
210
|
-
|
|
212
|
+
// Seamless sub-agent rate-limit: when relay pool is active, bypass
|
|
213
|
+
// local per-IP quota (127.0.0.1 shared by all subagents) — upstream
|
|
214
|
+
// quota is per-egress-IP and relayFetch already rolls on 429 across
|
|
215
|
+
// 7 candidates until a response succeeds. Without this, parallel
|
|
216
|
+
// subagents sharing the daemon would hit local 429 before relay failover.
|
|
217
|
+
const relayPreview = getActiveRelayState();
|
|
218
|
+
const willUseRelay = relayPreview.enabled && Boolean(relayPreview.url || relayPreview.relays.length > 0);
|
|
219
|
+
if (!willUseRelay && !checkRateLimit(clientIP, upstream)) {
|
|
211
220
|
res.writeHead(429, { "content-type": "application/json" });
|
|
212
221
|
res.end(JSON.stringify({ error: "rate limit exceeded" }));
|
|
213
222
|
return;
|
|
@@ -216,7 +225,6 @@ export function startProxy(
|
|
|
216
225
|
try {
|
|
217
226
|
if (isKilo && parsedBody) {
|
|
218
227
|
const kiloBodyObj = structuredClone(parsedBody);
|
|
219
|
-
normalizeRequestBody(kiloBodyObj, true, isKilo, reqId);
|
|
220
228
|
const response = await relayFetch(
|
|
221
229
|
KILO_CHAT_URL,
|
|
222
230
|
{
|
|
@@ -271,7 +279,6 @@ export function startProxy(
|
|
|
271
279
|
try {
|
|
272
280
|
if (parsedBody) {
|
|
273
281
|
const relayBodyObj = structuredClone(parsedBody);
|
|
274
|
-
normalizeRequestBody(relayBodyObj, true, isKilo, reqId);
|
|
275
282
|
const relayBody = Buffer.from(JSON.stringify(relayBodyObj));
|
|
276
283
|
const response = await relayFetch(
|
|
277
284
|
fullUrl,
|
|
@@ -327,7 +334,6 @@ export function startProxy(
|
|
|
327
334
|
let directBody = Buffer.concat(bodyChunks);
|
|
328
335
|
if (parsedBody) {
|
|
329
336
|
const directBodyObj = structuredClone(parsedBody);
|
|
330
|
-
normalizeRequestBody(directBodyObj, false, isKilo, reqId);
|
|
331
337
|
directBody = Buffer.from(JSON.stringify(directBodyObj));
|
|
332
338
|
}
|
|
333
339
|
|
package/src/relay-state.ts
CHANGED
|
@@ -89,8 +89,9 @@ export function resolveRelayState(): RelayState {
|
|
|
89
89
|
|
|
90
90
|
// In-memory global relay state
|
|
91
91
|
let activeRelayState: RelayState = resolveRelayState();
|
|
92
|
+
// Monotonic counter to distribute primary relay across concurrent subagents
|
|
93
|
+
let roundRobinCounter = 0;
|
|
92
94
|
let activeStatusUi: ExtensionUIContext | null = null;
|
|
93
|
-
|
|
94
95
|
/**
|
|
95
96
|
* Mtime of the on-disk state file at the moment we last read or wrote it.
|
|
96
97
|
* Lets worker processes pick up relay-pool changes persisted by another
|
|
@@ -113,6 +114,11 @@ lastKnownStateMtimeMs = currentDiskStateMtimeMs();
|
|
|
113
114
|
* Get active in-memory relay state
|
|
114
115
|
*/
|
|
115
116
|
export function getActiveRelayState(): RelayState {
|
|
117
|
+
const currentMtime = currentDiskStateMtimeMs();
|
|
118
|
+
if (currentMtime > 0 && currentMtime > lastKnownStateMtimeMs) {
|
|
119
|
+
activeRelayState = resolveRelayState();
|
|
120
|
+
lastKnownStateMtimeMs = currentMtime;
|
|
121
|
+
}
|
|
116
122
|
return activeRelayState;
|
|
117
123
|
}
|
|
118
124
|
|
|
@@ -174,9 +180,13 @@ export function getOrderedRelayUrls(): string[] {
|
|
|
174
180
|
if (activeIdx < 0) {
|
|
175
181
|
activeIdx = 0;
|
|
176
182
|
}
|
|
183
|
+
// Rotate starting point per-request to avoid thundering herd when many
|
|
184
|
+
// subagents hit the shared 127.0.0.1 daemon at once — each request
|
|
185
|
+
// tries a different primary relay, but still rolls seamlessly on 429.
|
|
186
|
+
const startIdx = (activeIdx + (roundRobinCounter++ % activeRelayState.relays.length)) % activeRelayState.relays.length;
|
|
177
187
|
const ordered: string[] = [];
|
|
178
188
|
for (let i = 0; i < activeRelayState.relays.length; i++) {
|
|
179
|
-
const r = activeRelayState.relays[(
|
|
189
|
+
const r = activeRelayState.relays[(startIdx + i) % activeRelayState.relays.length];
|
|
180
190
|
if (r?.url?.trim()) {
|
|
181
191
|
ordered.push(r.url.trim());
|
|
182
192
|
}
|
package/src/relay.ts
CHANGED
|
@@ -24,16 +24,11 @@ import {
|
|
|
24
24
|
export function isRetriableStatus(status: number): boolean {
|
|
25
25
|
return (
|
|
26
26
|
status === 429 ||
|
|
27
|
+
status === 408 ||
|
|
28
|
+
status === 500 ||
|
|
27
29
|
status === 502 ||
|
|
28
30
|
status === 503 ||
|
|
29
31
|
status === 504 ||
|
|
30
|
-
status === 408 ||
|
|
31
|
-
status === 404 ||
|
|
32
|
-
status === 410 ||
|
|
33
|
-
status === 402 ||
|
|
34
|
-
status === 403 ||
|
|
35
|
-
status === 500 ||
|
|
36
|
-
status === 400 ||
|
|
37
32
|
(status >= 520 && status <= 530)
|
|
38
33
|
);
|
|
39
34
|
}
|
package/src/normalizer.ts
DELETED
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Request payload normalization for pi-freeflow
|
|
3
|
-
*
|
|
4
|
-
* Normalizes tool choice, translates Anthropic thinking to OpenAI reasoning_effort,
|
|
5
|
-
* applies per-model thinkingLevelMap translation, and enforces token clamping.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { RELAY_MAX_TOKENS } from "./config.ts";
|
|
9
|
-
import { isDebugEnabled, log } from "./logger.ts";
|
|
10
|
-
import { MODEL_MAP } from "./models.ts";
|
|
11
|
-
import type { ModelDef } from "./types.ts";
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Normalizes an OpenAI-compatible / Anthropic request payload before forwarding to upstream.
|
|
15
|
-
*
|
|
16
|
-
* 1. Strips empty tools and normalizes tool_choice (none -> stripped, other non-auto -> auto for OpenCode).
|
|
17
|
-
* 2. Translates Anthropic thinking ({ type: "enabled", budget_tokens }) to OpenAI reasoning_effort.
|
|
18
|
-
* 3. Applies per-model thinkingLevelMap and reasoning effort normalization.
|
|
19
|
-
* 4. Clamps token limits (min 16 for OpenCode, maxTokens from modelDef, RELAY_MAX_TOKENS for relays).
|
|
20
|
-
*/
|
|
21
|
-
export function normalizeRequestBody(
|
|
22
|
-
body: Record<string, unknown>,
|
|
23
|
-
isRelay = false,
|
|
24
|
-
isKilo = false,
|
|
25
|
-
reqId?: string,
|
|
26
|
-
): Record<string, unknown> {
|
|
27
|
-
const DBG = isDebugEnabled();
|
|
28
|
-
const modelId = typeof body.model === "string" ? body.model : "";
|
|
29
|
-
const modelDef = MODEL_MAP.get(modelId);
|
|
30
|
-
const isResponsesApi = modelId === "muse-spark-1.2-contributor-free";
|
|
31
|
-
|
|
32
|
-
if (DBG) {
|
|
33
|
-
log(
|
|
34
|
-
"debug",
|
|
35
|
-
`normalize: incoming model=${modelId} kilo=${isKilo} relay=${isRelay}`,
|
|
36
|
-
{
|
|
37
|
-
reasoning_effort: body.reasoning_effort,
|
|
38
|
-
reasoning: body.reasoning,
|
|
39
|
-
thinking: (body as Record<string, unknown>).thinking,
|
|
40
|
-
tool_choice: body.tool_choice,
|
|
41
|
-
toolsLen: Array.isArray(body.tools) ? body.tools.length : undefined,
|
|
42
|
-
},
|
|
43
|
-
reqId,
|
|
44
|
-
);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// 1. Tool choice & empty tools normalization (pi-ai compat: opencode only supports auto)
|
|
48
|
-
if (Array.isArray(body.tools) && body.tools.length === 0) {
|
|
49
|
-
delete body.tools;
|
|
50
|
-
delete body.tool_choice;
|
|
51
|
-
}
|
|
52
|
-
if (body.tool_choice === "none") {
|
|
53
|
-
delete body.tool_choice;
|
|
54
|
-
delete body.tools;
|
|
55
|
-
} else if (!isKilo && body.tool_choice && body.tool_choice !== "auto") {
|
|
56
|
-
body.tool_choice = "auto";
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// 1b. Anthropic thinking -> OpenAI reasoning_effort auto-translate
|
|
60
|
-
// Pi sends anthropic `thinking: {type:"enabled",budget_tokens}` when provider is anthropic.
|
|
61
|
-
// Our proxy is always openai-completions/responses upstream, so translate.
|
|
62
|
-
// Ref: pi-ai api/anthropic-messages.js (thinking.type adaptive/enabled/disabled) -> api/openai-completions.js (reasoning_effort)
|
|
63
|
-
const thinkingRaw = (body as Record<string, unknown>).thinking;
|
|
64
|
-
if (thinkingRaw && typeof thinkingRaw === "object") {
|
|
65
|
-
const th = thinkingRaw as Record<string, unknown>;
|
|
66
|
-
if (th.type === "disabled") {
|
|
67
|
-
delete (body as Record<string, unknown>).thinking;
|
|
68
|
-
// Mark as off so downstream reasoning mapping can clear effort
|
|
69
|
-
if (!body.reasoning_effort && !body.reasoning) {
|
|
70
|
-
body.reasoning_effort = "off";
|
|
71
|
-
}
|
|
72
|
-
} else if (th.type === "enabled" || th.type === "adaptive") {
|
|
73
|
-
delete (body as Record<string, unknown>).thinking;
|
|
74
|
-
// Preserve budget as hint if no explicit effort set
|
|
75
|
-
if (!body.reasoning_effort && typeof th.budget_tokens === "number") {
|
|
76
|
-
const budget = th.budget_tokens as number;
|
|
77
|
-
if (budget >= 8000) body.reasoning_effort = "xhigh";
|
|
78
|
-
else if (budget >= 4000) body.reasoning_effort = "high";
|
|
79
|
-
else if (budget >= 2000) body.reasoning_effort = "medium";
|
|
80
|
-
else body.reasoning_effort = "low";
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// 2. Reasoning normalization — per-model thinkingLevelMap aware
|
|
86
|
-
// Ref: pi-ai api/openai-completions.js (compat.thinkingFormat branches) + api/openai-responses-shared.js
|
|
87
|
-
const mapEffort = (rawEffort: string): string | null | undefined => {
|
|
88
|
-
const key = rawEffort.toLowerCase() as keyof NonNullable<
|
|
89
|
-
ModelDef["thinkingLevelMap"]
|
|
90
|
-
>;
|
|
91
|
-
let mapped: string | null | undefined;
|
|
92
|
-
if (modelDef?.thinkingLevelMap && key in modelDef.thinkingLevelMap) {
|
|
93
|
-
mapped = modelDef.thinkingLevelMap[key] as string | null;
|
|
94
|
-
} else if (rawEffort === "xhigh" || rawEffort === "max") {
|
|
95
|
-
mapped = isResponsesApi
|
|
96
|
-
? "xhigh"
|
|
97
|
-
: modelId === "x-preview-f-free"
|
|
98
|
-
? "max"
|
|
99
|
-
: "xhigh";
|
|
100
|
-
} else if (rawEffort === "high" || rawEffort === "medium") {
|
|
101
|
-
mapped = "high";
|
|
102
|
-
} else if (rawEffort === "minimal") {
|
|
103
|
-
mapped = "minimal";
|
|
104
|
-
} else if (rawEffort === "none" || rawEffort === "off") {
|
|
105
|
-
mapped = null;
|
|
106
|
-
} else {
|
|
107
|
-
mapped = "low";
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if (isResponsesApi && mapped === "max") {
|
|
111
|
-
mapped = "xhigh";
|
|
112
|
-
}
|
|
113
|
-
return mapped;
|
|
114
|
-
};
|
|
115
|
-
if (typeof body.reasoning_effort === "string") {
|
|
116
|
-
const mapped = mapEffort(body.reasoning_effort);
|
|
117
|
-
if (mapped === null || mapped === undefined) {
|
|
118
|
-
delete body.reasoning_effort;
|
|
119
|
-
} else {
|
|
120
|
-
body.reasoning_effort = mapped;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
if (body.reasoning && typeof body.reasoning === "object") {
|
|
124
|
-
const r = body.reasoning as Record<string, unknown>;
|
|
125
|
-
if (r.effort === "none" || r.effort === "off") {
|
|
126
|
-
delete r.effort;
|
|
127
|
-
} else if (typeof r.effort === "string") {
|
|
128
|
-
const mapped = mapEffort(r.effort);
|
|
129
|
-
if (mapped === null || mapped === undefined) {
|
|
130
|
-
delete r.effort;
|
|
131
|
-
} else {
|
|
132
|
-
r.effort = mapped;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
if (isResponsesApi && r.effort === "max") r.effort = "xhigh";
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// 3. Max & Min tokens clamping (model-specific clamp + Vercel relay clamp)
|
|
139
|
-
const modelMax = modelDef?.maxTokens ?? RELAY_MAX_TOKENS;
|
|
140
|
-
const clampTokens = (val: number): number => {
|
|
141
|
-
let clamped = val;
|
|
142
|
-
if (!isKilo && clamped < 16) clamped = 16;
|
|
143
|
-
if (clamped > modelMax) clamped = modelMax;
|
|
144
|
-
if (isRelay && clamped > RELAY_MAX_TOKENS) clamped = RELAY_MAX_TOKENS;
|
|
145
|
-
return clamped;
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
if (typeof body.max_tokens === "number") {
|
|
149
|
-
body.max_tokens = clampTokens(body.max_tokens);
|
|
150
|
-
}
|
|
151
|
-
if (typeof body.maxTokens === "number") {
|
|
152
|
-
body.maxTokens = clampTokens(body.maxTokens);
|
|
153
|
-
}
|
|
154
|
-
if (typeof body.max_output_tokens === "number") {
|
|
155
|
-
body.max_output_tokens = clampTokens(body.max_output_tokens);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
if (DBG) {
|
|
159
|
-
log(
|
|
160
|
-
"debug",
|
|
161
|
-
`normalize: outgoing model=${modelId}`,
|
|
162
|
-
{
|
|
163
|
-
reasoning_effort: body.reasoning_effort,
|
|
164
|
-
reasoning: body.reasoning,
|
|
165
|
-
max_tokens: body.max_tokens,
|
|
166
|
-
max_output_tokens: body.max_output_tokens,
|
|
167
|
-
},
|
|
168
|
-
reqId,
|
|
169
|
-
);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
return body;
|
|
173
|
-
}
|