pi-free 2.0.2 → 2.0.5

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.
@@ -18,7 +18,7 @@ import type { OAuthCredentials } from "@mariozechner/pi-ai";
18
18
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
19
19
  import { getClineShowPaid } from "../../config.ts";
20
20
  import { BASE_URL_CLINE, PROVIDER_CLINE } from "../../constants.ts";
21
- import { registerWithGlobalToggle } from "../../lib/registry.ts";
21
+ import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
22
22
  import { createToggleState } from "../../lib/toggle-state.ts";
23
23
  import { logWarning } from "../../lib/util.ts";
24
24
  import { enhanceWithCI } from "../../provider-helper.ts";
@@ -132,25 +132,30 @@ function extractTaskBody(content: unknown): string {
132
132
  if (!Array.isArray(content)) return "";
133
133
  for (const p of content as any[]) {
134
134
  if (p?.type !== "text" || typeof p?.text !== "string") continue;
135
- const m = p.text.match(/<task>\s*([\s\S]*?)\s*<\/task>/);
135
+ const m = p.text.match(/<task>([\s\S]*?)<\/task>/);
136
136
  if (m?.[1]) return m[1].trim();
137
137
  }
138
138
  return "";
139
139
  }
140
140
 
141
- function shapeMessagesForCline(messages: any[]): any[] {
142
- let lastWrappedIdx = -1;
143
- let baseTranscript = "";
141
+ function findLastClineWrappedMessage(messages: any[]): {
142
+ index: number;
143
+ transcript: string;
144
+ } {
144
145
  for (let i = messages.length - 1; i >= 0; i--) {
145
146
  if (messages[i]?.role !== "user") continue;
146
147
  if (!isClineWrapped(messages[i]?.content)) continue;
147
- lastWrappedIdx = i;
148
- baseTranscript = extractTaskBody(messages[i].content);
149
- break;
148
+ return { index: i, transcript: extractTaskBody(messages[i].content) };
150
149
  }
150
+ return { index: -1, transcript: "" };
151
+ }
151
152
 
153
+ function buildTranscriptParts(
154
+ messages: any[],
155
+ startIdx: number,
156
+ baseTranscript: string,
157
+ ): string[] {
152
158
  const parts: string[] = baseTranscript ? [baseTranscript] : [];
153
- const startIdx = lastWrappedIdx >= 0 ? lastWrappedIdx + 1 : 0;
154
159
 
155
160
  for (let i = startIdx; i < messages.length; i++) {
156
161
  const msg = messages[i];
@@ -167,9 +172,10 @@ function shapeMessagesForCline(messages: any[]): any[] {
167
172
  }
168
173
  }
169
174
 
170
- const transcript = parts.join("\n\n").trim() || "(no conversation yet)";
171
- const envDetails = buildEnvironmentDetails();
175
+ return parts;
176
+ }
172
177
 
178
+ function buildCollapsedMessage(messages: any[], transcript: string): any[] {
173
179
  const collapsed: any[] = [];
174
180
  const systemMsg = messages.find((m: any) => m?.role === "system");
175
181
  if (systemMsg) {
@@ -182,13 +188,24 @@ function shapeMessagesForCline(messages: any[]): any[] {
182
188
  content: [
183
189
  { type: "text", text: `<task>\n${transcript}\n</task>` },
184
190
  { type: "text", text: TASK_PROGRESS_BLOCK },
185
- { type: "text", text: envDetails },
191
+ { type: "text", text: buildEnvironmentDetails() },
186
192
  ],
187
193
  });
188
194
 
189
195
  return collapsed;
190
196
  }
191
197
 
198
+ function shapeMessagesForCline(messages: any[]): any[] {
199
+ const { index: lastWrappedIdx, transcript: baseTranscript } =
200
+ findLastClineWrappedMessage(messages);
201
+
202
+ const startIdx = lastWrappedIdx >= 0 ? lastWrappedIdx + 1 : 0;
203
+ const parts = buildTranscriptParts(messages, startIdx, baseTranscript);
204
+ const transcript = parts.join("\n\n").trim() || "(no conversation yet)";
205
+
206
+ return buildCollapsedMessage(messages, transcript);
207
+ }
208
+
192
209
  // =============================================================================
193
210
  // Extension entry point
194
211
  // =============================================================================
@@ -198,7 +215,9 @@ export default async function (pi: ExtensionAPI) {
198
215
  logWarning("cline", "Failed to fetch models at startup", err);
199
216
  return [];
200
217
  });
201
- let freeModels = allModels.filter((m) => m.cost.input === 0);
218
+ let freeModels = allModels.filter((m) =>
219
+ isFreeModel({ ...m, provider: PROVIDER_CLINE }, allModels),
220
+ );
202
221
  const stored = { free: freeModels, all: allModels };
203
222
  const toggleState = createToggleState({
204
223
  providerId: PROVIDER_CLINE,
@@ -246,6 +265,29 @@ export default async function (pi: ExtensionAPI) {
246
265
  },
247
266
  });
248
267
 
268
+ // ── Status bar for provider selection ─────────────────────────
269
+
270
+ pi.on("model_select", (_event, ctx) => {
271
+ if (_event.model?.provider !== PROVIDER_CLINE) {
272
+ ctx.ui.setStatus(`${PROVIDER_CLINE}-status`, undefined);
273
+ return;
274
+ }
275
+
276
+ const free = stored.free.length;
277
+ const total = stored.all.length;
278
+ const paid = total - free;
279
+ const mode = toggleState.getCurrentMode();
280
+ let status: string;
281
+ if (paid === 0) {
282
+ status = `cline: ${free} free models`;
283
+ } else if (mode === "all") {
284
+ status = `cline: ${total} models (free + paid)`;
285
+ } else {
286
+ status = `cline: ${free} free \u00b7 ${paid} paid`;
287
+ }
288
+ ctx.ui.setStatus(`${PROVIDER_CLINE}-status`, status);
289
+ });
290
+
249
291
  pi.on("before_agent_start", async (_event, ctx) => {
250
292
  if (ctx.model?.provider !== PROVIDER_CLINE) return;
251
293
  _currentTaskId = generateUlid();
@@ -263,7 +305,9 @@ export default async function (pi: ExtensionAPI) {
263
305
  const fresh = await fetchClineModels(false);
264
306
  if (fresh.length > 0) {
265
307
  allModels = fresh;
266
- freeModels = allModels.filter((m) => m.cost.input === 0);
308
+ freeModels = allModels.filter((m) =>
309
+ isFreeModel({ ...m, provider: PROVIDER_CLINE }, allModels),
310
+ );
267
311
  stored.all = allModels;
268
312
  stored.free = freeModels;
269
313
  toggleState.setModels(stored);
@@ -0,0 +1,170 @@
1
+ /**
2
+ * CrofAI Provider Extension
3
+ *
4
+ * Provides access to CrofAI API - OpenAI-compatible LLM inference service.
5
+ *
6
+ * Setup:
7
+ * 1. Get API key from https://ai.nahcrof.com
8
+ * 2. Set CROFAI_API_KEY env var or add to ~/.pi/free.json
9
+ *
10
+ * Responds to global free-only filter.
11
+ *
12
+ * Usage:
13
+ * pi install git:github.com/apmantza/pi-free
14
+ * # Set CROFAI_API_KEY env var
15
+ * # Models appear in /model selector
16
+ */
17
+
18
+ import type {
19
+ ExtensionAPI,
20
+ ProviderModelConfig,
21
+ } from "@mariozechner/pi-coding-agent";
22
+ import { getCrofaiApiKey, getCrofaiShowPaid } from "../../config.ts";
23
+ import {
24
+ BASE_URL_CROFAI,
25
+ DEFAULT_FETCH_TIMEOUT_MS,
26
+ PROVIDER_CROFAI,
27
+ } from "../../constants.ts";
28
+ import { createLogger } from "../../lib/logger.ts";
29
+ import {
30
+ getProxyModelCompat,
31
+ isLikelyReasoningModel,
32
+ } from "../../lib/provider-compat.ts";
33
+ import { isFreeModel, registerWithGlobalToggle } from "../../lib/registry.ts";
34
+ import { fetchWithRetry } from "../../lib/util.ts";
35
+ import { createReRegister, setupProvider } from "../../provider-helper.ts";
36
+
37
+ const _logger = createLogger("crofai");
38
+
39
+ // =============================================================================
40
+ // Fetch CrofAI models
41
+ // =============================================================================
42
+
43
+ interface CrofaiModel {
44
+ id: string;
45
+ object?: string;
46
+ created?: number;
47
+ owned_by?: string;
48
+ }
49
+
50
+ async function fetchCrofaiModels(
51
+ apiKey: string,
52
+ ): Promise<ProviderModelConfig[]> {
53
+ _logger.info("[crofai] Fetching models from CrofAI API...");
54
+
55
+ try {
56
+ const response = await fetchWithRetry(
57
+ `${BASE_URL_CROFAI}/models`,
58
+ {
59
+ headers: {
60
+ Authorization: `Bearer ${apiKey}`,
61
+ "Content-Type": "application/json",
62
+ },
63
+ },
64
+ 3,
65
+ 1000,
66
+ DEFAULT_FETCH_TIMEOUT_MS,
67
+ );
68
+
69
+ if (!response.ok) {
70
+ throw new Error(`CrofAI API error: ${response.status}`);
71
+ }
72
+
73
+ const data = (await response.json()) as { data?: CrofaiModel[] };
74
+ const models = data.data ?? [];
75
+
76
+ _logger.info(`[crofai] Fetched ${models.length} models`);
77
+
78
+ return models
79
+ .filter((m) => m.id) // Filter out any empty entries
80
+ .map((m) => {
81
+ const name = m.id.split("/").pop() || m.id;
82
+ return {
83
+ id: m.id,
84
+ name,
85
+ reasoning: isLikelyReasoningModel({ id: m.id, name }),
86
+ input: ["text"],
87
+ cost: {
88
+ input: 0, // CrofAI doesn't expose pricing via API
89
+ output: 0,
90
+ cacheRead: 0,
91
+ cacheWrite: 0,
92
+ },
93
+ contextWindow: 128000, // Default, varies by model
94
+ maxTokens: 4096,
95
+ compat: getProxyModelCompat({ id: m.id, name }),
96
+ } satisfies ProviderModelConfig;
97
+ });
98
+ } catch (error) {
99
+ _logger.error("[crofai] Failed to fetch models:", {
100
+ error: error instanceof Error ? error.message : String(error),
101
+ });
102
+ return [];
103
+ }
104
+ }
105
+
106
+ // =============================================================================
107
+ // Extension Entry Point
108
+ // =============================================================================
109
+
110
+ export default async function crofaiProvider(pi: ExtensionAPI) {
111
+ const apiKey = getCrofaiApiKey();
112
+
113
+ if (!apiKey) {
114
+ _logger.info(
115
+ "[crofai] Skipping - CROFAI_API_KEY not set (env var or ~/.pi/free.json)",
116
+ );
117
+ return;
118
+ }
119
+
120
+ // Fetch models
121
+ const allModels = await fetchCrofaiModels(apiKey);
122
+
123
+ if (allModels.length === 0) {
124
+ _logger.warn("[crofai] No models available");
125
+ return;
126
+ }
127
+
128
+ // Use isFreeModel with allModels for proper detection
129
+ // CrofAI doesn't expose pricing (all costs are $0), so Route B will be used:
130
+ // FREE only if "free" in name
131
+ const freeModels = allModels.filter((m) =>
132
+ isFreeModel({ ...m, provider: PROVIDER_CROFAI }, allModels),
133
+ );
134
+
135
+ const stored = { free: freeModels, all: allModels };
136
+
137
+ _logger.info(
138
+ `[crofai] Registered ${allModels.length} models (${freeModels.length} free)`,
139
+ );
140
+
141
+ // Create re-register function
142
+ const reRegister = createReRegister(pi, {
143
+ providerId: PROVIDER_CROFAI,
144
+ baseUrl: BASE_URL_CROFAI,
145
+ apiKey,
146
+ });
147
+
148
+ // Register with global toggle
149
+ registerWithGlobalToggle(PROVIDER_CROFAI, stored, reRegister, true);
150
+
151
+ // Setup provider with toggle command
152
+ setupProvider(
153
+ pi,
154
+ {
155
+ providerId: PROVIDER_CROFAI,
156
+ initialShowPaid: getCrofaiShowPaid(),
157
+ reRegister: (models, _stored) => {
158
+ if (_stored) {
159
+ stored.free = _stored.free;
160
+ stored.all = _stored.all;
161
+ }
162
+ reRegister(models);
163
+ },
164
+ },
165
+ stored,
166
+ );
167
+
168
+ // Initial registration
169
+ reRegister(freeModels);
170
+ }