pi-freeflow 1.2.0 → 1.2.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/src/models.ts ADDED
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Static model definitions and upstream routing catalogs for pi-freeflow
3
+ *
4
+ * Defines the 23 verified free models:
5
+ * - 9 OpenCode Zen models (2 Responses API + 7 Chat Completions)
6
+ * - 14 KiloCode Keyless Gateway models (11 OpenRouter format + 3 Standard format)
7
+ */
8
+
9
+ import type { ModelDef, Upstream } from "./types.ts";
10
+
11
+ /**
12
+ * OpenCode Zen free models verified against the live catalog and inference APIs.
13
+ * Endpoint: https://opencode.ai/zen/v1
14
+ */
15
+ export const OPENCODE_MODELS: ModelDef[] = [
16
+ {
17
+ id: "deepseek-v4-flash-free",
18
+ name: "DeepSeek V4 Flash (1M)",
19
+ reasoning: true,
20
+ contextWindow: 1_000_000,
21
+ maxTokens: 384_000,
22
+ input: ["text"],
23
+ thinkingLevelMap: {
24
+ off: "low",
25
+ minimal: "low",
26
+ low: "low",
27
+ medium: "high",
28
+ high: "high",
29
+ xhigh: "max",
30
+ max: "max",
31
+ },
32
+ },
33
+ {
34
+ id: "x-preview-f-free",
35
+ name: "Ox Alpha (1M)",
36
+ reasoning: true,
37
+ contextWindow: 1_048_576,
38
+ maxTokens: 131_072,
39
+ input: ["text", "image"],
40
+ thinkingLevelMap: {
41
+ off: "low",
42
+ minimal: "low",
43
+ low: "low",
44
+ medium: "high",
45
+ high: "high",
46
+ xhigh: "max",
47
+ max: "max",
48
+ },
49
+ },
50
+ {
51
+ id: "muse-spark-1.2-contributor-free",
52
+ name: "Muse Spark 1.2 (1M)",
53
+ reasoning: true,
54
+ contextWindow: 1_048_576,
55
+ maxTokens: 131_072,
56
+ api: "openai-responses",
57
+ input: ["text", "image"],
58
+ thinkingLevelMap: {
59
+ off: null,
60
+ minimal: "minimal",
61
+ low: "low",
62
+ medium: "medium",
63
+ high: "high",
64
+ xhigh: "xhigh",
65
+ max: "max",
66
+ },
67
+ },
68
+ {
69
+ id: "mimo-v2.5-free",
70
+ name: "MiMo V2.5 (1M)",
71
+ reasoning: true,
72
+ contextWindow: 1_048_576,
73
+ maxTokens: 131_072,
74
+ input: ["text", "image"],
75
+ thinkingLevelMap: {
76
+ off: "low",
77
+ minimal: "low",
78
+ low: "low",
79
+ medium: "medium",
80
+ high: "high",
81
+ xhigh: "high",
82
+ max: "high",
83
+ },
84
+ },
85
+ {
86
+ id: "hy3-free",
87
+ name: "Hy3 (262K)",
88
+ reasoning: true,
89
+ contextWindow: 262_144,
90
+ maxTokens: 128_000,
91
+ input: ["text"],
92
+ thinkingLevelMap: {
93
+ off: "low",
94
+ minimal: "low",
95
+ low: "low",
96
+ medium: "high",
97
+ high: "high",
98
+ xhigh: "max",
99
+ max: "max",
100
+ },
101
+ },
102
+ {
103
+ id: "nemotron-3-ultra-free",
104
+ name: "Nemotron 3 Ultra (1M)",
105
+ reasoning: true,
106
+ contextWindow: 1_000_000,
107
+ maxTokens: 128_000,
108
+ input: ["text"],
109
+ thinkingLevelMap: {
110
+ off: "low",
111
+ minimal: "low",
112
+ low: "low",
113
+ medium: "high",
114
+ high: "high",
115
+ xhigh: "max",
116
+ max: "max",
117
+ },
118
+ },
119
+ {
120
+ id: "nemotron-3.5-lightning-free",
121
+ name: "Nemotron 3.5 Lightning (1M)",
122
+ reasoning: true,
123
+ contextWindow: 1_000_000,
124
+ maxTokens: 262_144,
125
+ input: ["text"],
126
+ thinkingLevelMap: {
127
+ off: "low",
128
+ minimal: "low",
129
+ low: "low",
130
+ medium: "high",
131
+ high: "high",
132
+ xhigh: "max",
133
+ max: "max",
134
+ },
135
+ },
136
+ {
137
+ id: "big-pickle",
138
+ name: "Big Pickle",
139
+ reasoning: true,
140
+ contextWindow: 200_000,
141
+ maxTokens: 32_000,
142
+ input: ["text"],
143
+ thinkingLevelMap: {
144
+ off: "high",
145
+ minimal: "high",
146
+ low: "high",
147
+ medium: "high",
148
+ high: "high",
149
+ xhigh: "max",
150
+ max: "max",
151
+ },
152
+ },
153
+ {
154
+ id: "laguna-s-2.1-free",
155
+ name: "Laguna S 2.1 (1M)",
156
+ reasoning: true,
157
+ contextWindow: 1_048_576,
158
+ maxTokens: 131_072,
159
+ input: ["text"],
160
+ thinkingLevelMap: {
161
+ off: "low",
162
+ minimal: "low",
163
+ low: "low",
164
+ medium: "high",
165
+ high: "high",
166
+ xhigh: "max",
167
+ max: "max",
168
+ },
169
+ },
170
+ ];
171
+
172
+ /**
173
+ * Backward compatibility alias for OPENCODE_MODELS
174
+ */
175
+ export const KNOWN_MODELS = OPENCODE_MODELS;
176
+
177
+ /**
178
+ * KiloCode Gateway free models (keyless — https://kilo.ai/docs/gateway).
179
+ * Endpoint: https://api.kilo.ai/api/gateway/chat/completions
180
+ */
181
+ export const KILO_MODELS: ModelDef[] = [
182
+ {
183
+ id: "dots-studio/dots-3-note-preview:free",
184
+ name: "Dots3-Note Preview (512K)",
185
+ reasoning: true,
186
+ contextWindow: 512_000,
187
+ maxTokens: 512_000,
188
+ input: ["text", "image"],
189
+ thinkingFormat: "openrouter",
190
+ },
191
+ {
192
+ id: "stepfun/step-3.7-flash:free",
193
+ name: "Step 3.7 Flash",
194
+ reasoning: true,
195
+ contextWindow: 262_144,
196
+ maxTokens: 262_144,
197
+ input: ["text", "image"],
198
+ thinkingFormat: "openrouter",
199
+ },
200
+ {
201
+ id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
202
+ name: "Nemotron 3 Nano Omni",
203
+ reasoning: true,
204
+ contextWindow: 256_000,
205
+ maxTokens: 65_536,
206
+ input: ["text", "image"],
207
+ thinkingFormat: "openrouter",
208
+ },
209
+ {
210
+ id: "nvidia/nemotron-3-ultra-550b-a55b:free",
211
+ name: "Nemotron 3 Ultra 550B (1M)",
212
+ reasoning: true,
213
+ contextWindow: 1_000_000,
214
+ maxTokens: 65_536,
215
+ input: ["text"],
216
+ thinkingFormat: "openrouter",
217
+ },
218
+ {
219
+ id: "nvidia/nemotron-3.5-lightning:free",
220
+ name: "Nemotron 3.5 Lightning (Kilo)",
221
+ reasoning: true,
222
+ contextWindow: 1_000_000,
223
+ maxTokens: 65_536,
224
+ input: ["text"],
225
+ thinkingFormat: "openrouter",
226
+ },
227
+ {
228
+ id: "nvidia/nemotron-3-super-120b-a12b:free",
229
+ name: "Nemotron 3 Super 120B",
230
+ reasoning: true,
231
+ contextWindow: 262_144,
232
+ maxTokens: 262_144,
233
+ input: ["text"],
234
+ thinkingFormat: "openrouter",
235
+ },
236
+ {
237
+ id: "tencent/hy3:free",
238
+ name: "Tencent Hy3 (Kilo)",
239
+ reasoning: true,
240
+ contextWindow: 262_144,
241
+ maxTokens: 128_000,
242
+ input: ["text"],
243
+ thinkingFormat: "openrouter",
244
+ },
245
+ {
246
+ id: "cohere/north-mini-code:free",
247
+ name: "North Mini Code",
248
+ reasoning: true,
249
+ contextWindow: 256_000,
250
+ maxTokens: 64_000,
251
+ input: ["text"],
252
+ thinkingFormat: "openrouter",
253
+ },
254
+ {
255
+ id: "poolside/laguna-s-2.1:free",
256
+ name: "Laguna S 2.1 (Kilo)",
257
+ reasoning: true,
258
+ contextWindow: 262_144,
259
+ maxTokens: 32_768,
260
+ input: ["text"],
261
+ thinkingFormat: "openrouter",
262
+ },
263
+ {
264
+ id: "poolside/laguna-xs-2.1:free",
265
+ name: "Laguna XS 2.1",
266
+ reasoning: true,
267
+ contextWindow: 262_144,
268
+ maxTokens: 32_768,
269
+ input: ["text"],
270
+ thinkingFormat: "openrouter",
271
+ },
272
+ {
273
+ id: "liquid/lfm-2.5-2.6b:free",
274
+ name: "Liquid LFM 2.5",
275
+ reasoning: true,
276
+ contextWindow: 65_536,
277
+ maxTokens: 8_192,
278
+ input: ["text"],
279
+ thinkingFormat: "openrouter",
280
+ },
281
+ {
282
+ id: "kilo-auto/free",
283
+ name: "Kilo Auto",
284
+ reasoning: false,
285
+ contextWindow: 256_000,
286
+ maxTokens: 10_000,
287
+ input: ["text"],
288
+ },
289
+ {
290
+ id: "openrouter/free",
291
+ name: "OpenRouter Auto",
292
+ reasoning: false,
293
+ contextWindow: 200_000,
294
+ maxTokens: 65_536,
295
+ input: ["text"],
296
+ },
297
+ {
298
+ id: "nvidia/nemotron-3.5-content-safety:free",
299
+ name: "Nemotron Content Safety",
300
+ reasoning: false,
301
+ contextWindow: 128_000,
302
+ maxTokens: 8_192,
303
+ input: ["text"],
304
+ },
305
+ ];
306
+
307
+ /**
308
+ * Set of all KiloCode model IDs for fast lookup
309
+ */
310
+ export const KILO_MODEL_IDS = new Set<string>(KILO_MODELS.map((m) => m.id));
311
+
312
+ /**
313
+ * Combined list of all 23 static free models
314
+ */
315
+ export const ALL_MODELS: ModelDef[] = [...OPENCODE_MODELS, ...KILO_MODELS];
316
+
317
+ /**
318
+ * Map of model ID -> ModelDef
319
+ */
320
+ export const MODEL_MAP = new Map<string, ModelDef>(
321
+ ALL_MODELS.map((m) => [m.id, m]),
322
+ );
323
+
324
+ /**
325
+ * Lookup a model definition by ID
326
+ */
327
+ export function getModelDef(id: string): ModelDef | undefined {
328
+ return MODEL_MAP.get(id);
329
+ }
330
+
331
+ /**
332
+ * Check if a model ID belongs to KiloCode Gateway
333
+ */
334
+ export function isKiloModel(id: string): boolean {
335
+ return KILO_MODEL_IDS.has(id);
336
+ }
337
+
338
+ /**
339
+ * Determine the upstream provider for a given model ID
340
+ */
341
+ export function getModelUpstream(id: string): Upstream {
342
+ return isKiloModel(id) ? "kilo" : "opencode";
343
+ }
@@ -0,0 +1,173 @@
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
+ }