@ryukin-dev/pi-featherless-kali 1.1.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 +15 -0
- package/README.md +136 -0
- package/bin/kaliai.js +146 -0
- package/extensions/featherless.ts +16 -0
- package/package.json +71 -0
- package/skills/kali-admin/SKILL.md +30 -0
- package/skills/websearch/SKILL.md +43 -0
- package/skills/websearch/extract.js +65 -0
- package/skills/websearch/package.json +16 -0
- package/skills/websearch/search.js +110 -0
- package/src/handlers/compaction.ts +66 -0
- package/src/handlers/concurrency.ts +70 -0
- package/src/handlers/context.test.ts +260 -0
- package/src/handlers/context.ts +211 -0
- package/src/handlers/provider.ts +14 -0
- package/src/handlers/shared.ts +10 -0
- package/src/handlers/update-check.ts +202 -0
- package/src/models/fetch.ts +31 -0
- package/src/models.ts +262 -0
- package/src/test-api.ts +157 -0
- package/src/tokenize.ts +198 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Context } from "@earendil-works/pi-ai";
|
|
2
|
+
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { convertToLlm } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { PROVIDER, getApiKey } from "./shared";
|
|
6
|
+
|
|
7
|
+
const SUMMARY_PROMPT = `Summarize the conversation so far. Focus on:
|
|
8
|
+
- Files read/modified and key findings
|
|
9
|
+
- Errors encountered and resolutions
|
|
10
|
+
- Decisions made and reasoning
|
|
11
|
+
- Current task state and next steps
|
|
12
|
+
|
|
13
|
+
Be concise but preserve essential context for continuing the work.`;
|
|
14
|
+
|
|
15
|
+
export function registerCompaction(pi: ExtensionAPI) {
|
|
16
|
+
pi.on("session_before_compact", async (event, ctx) => {
|
|
17
|
+
const model = ctx.model;
|
|
18
|
+
if (!model || model.provider !== PROVIDER) return;
|
|
19
|
+
|
|
20
|
+
const apiKey = await getApiKey(ctx);
|
|
21
|
+
if (!apiKey) return;
|
|
22
|
+
|
|
23
|
+
const { preparation, signal } = event;
|
|
24
|
+
const {
|
|
25
|
+
messagesToSummarize,
|
|
26
|
+
turnPrefixMessages,
|
|
27
|
+
tokensBefore,
|
|
28
|
+
firstKeptEntryId,
|
|
29
|
+
previousSummary,
|
|
30
|
+
} = preparation;
|
|
31
|
+
|
|
32
|
+
const llmMessages = convertToLlm([...messagesToSummarize, ...turnPrefixMessages]);
|
|
33
|
+
|
|
34
|
+
const messages: Context["messages"] = [
|
|
35
|
+
...llmMessages,
|
|
36
|
+
{
|
|
37
|
+
role: "user" as const,
|
|
38
|
+
content: [{ type: "text" as const, text: SUMMARY_PROMPT }],
|
|
39
|
+
timestamp: Date.now(),
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const result = await completeSimple(
|
|
45
|
+
model,
|
|
46
|
+
{ messages },
|
|
47
|
+
{ apiKey, maxTokens: 2048, signal },
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const summaryText = result.content
|
|
51
|
+
.filter((c: any) => c.type === "text")
|
|
52
|
+
.map((c: any) => c.text)
|
|
53
|
+
.join("\n");
|
|
54
|
+
|
|
55
|
+
if (!summaryText.trim()) return;
|
|
56
|
+
|
|
57
|
+
const summary = previousSummary
|
|
58
|
+
? `## Previous Context\n${previousSummary}\n\n## Recent Activity\n${summaryText}`
|
|
59
|
+
: summaryText;
|
|
60
|
+
|
|
61
|
+
return { compaction: { summary, firstKeptEntryId, tokensBefore } };
|
|
62
|
+
} catch {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { getConcurrencyCost, getModelClass } from "../models";
|
|
3
|
+
import { PROVIDER } from "./shared";
|
|
4
|
+
|
|
5
|
+
interface ConcurrencyState {
|
|
6
|
+
activeRequests: Map<string, number>;
|
|
7
|
+
totalCost: number;
|
|
8
|
+
limit: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const state: ConcurrencyState = {
|
|
12
|
+
activeRequests: new Map(),
|
|
13
|
+
totalCost: 0,
|
|
14
|
+
limit: 4,
|
|
15
|
+
};
|
|
16
|
+
let requestIdCounter = 0;
|
|
17
|
+
|
|
18
|
+
function parse429Limit(errorText: string): number | null {
|
|
19
|
+
const match = errorText.match(/plan limit:\s*(\d+)/i);
|
|
20
|
+
return match ? parseInt(match[1], 10) : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function handleApiError(error: any): void {
|
|
24
|
+
const message = error?.message || String(error);
|
|
25
|
+
if (message.includes("429") || message.includes("Concurrency limit")) {
|
|
26
|
+
const limit = parse429Limit(message);
|
|
27
|
+
if (limit !== null) state.limit = limit;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function release(modelId: string): boolean {
|
|
32
|
+
const modelClass = getModelClass(modelId);
|
|
33
|
+
if (modelClass && state.totalCost > 0) {
|
|
34
|
+
const cost = getConcurrencyCost(modelClass);
|
|
35
|
+
for (const [id, c] of Array.from(state.activeRequests)) {
|
|
36
|
+
if (c === cost) {
|
|
37
|
+
state.activeRequests.delete(id);
|
|
38
|
+
state.totalCost -= c;
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function registerConcurrencyTracking(pi: ExtensionAPI) {
|
|
47
|
+
pi.on("session_start", async () => {
|
|
48
|
+
state.activeRequests.clear();
|
|
49
|
+
state.totalCost = 0;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
pi.on("before_provider_request", async (event, ctx) => {
|
|
53
|
+
const model = ctx.model;
|
|
54
|
+
if (model?.provider !== PROVIDER) return;
|
|
55
|
+
|
|
56
|
+
const modelClass = getModelClass(model.id);
|
|
57
|
+
if (!modelClass) return;
|
|
58
|
+
|
|
59
|
+
const cost = getConcurrencyCost(modelClass);
|
|
60
|
+
const requestId = `req_${++requestIdCounter}`;
|
|
61
|
+
state.activeRequests.set(requestId, cost);
|
|
62
|
+
state.totalCost += cost;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
66
|
+
const model = ctx.model;
|
|
67
|
+
if (model?.provider !== PROVIDER) return;
|
|
68
|
+
release(model.id);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
4
|
+
import { getModelClass } from "../models";
|
|
5
|
+
|
|
6
|
+
function parseToolCallsFromText(
|
|
7
|
+
text: string,
|
|
8
|
+
): Array<{ id: string; name: string; arguments: Record<string, any> }> | null {
|
|
9
|
+
const regex = /<function>\s*(\{.*?\})\s*<\/function>/gs;
|
|
10
|
+
const matches = [...text.matchAll(regex)];
|
|
11
|
+
const results = [];
|
|
12
|
+
|
|
13
|
+
for (const match of matches) {
|
|
14
|
+
try {
|
|
15
|
+
const data = JSON.parse(match[1]);
|
|
16
|
+
if (data.name && data.arguments !== undefined) {
|
|
17
|
+
results.push({
|
|
18
|
+
id: `call_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
|
|
19
|
+
name: data.name,
|
|
20
|
+
arguments: typeof data.arguments === 'string'
|
|
21
|
+
? JSON.parse(data.arguments)
|
|
22
|
+
: data.arguments,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return results.length > 0 ? results : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("parseToolCallsFromText", () => {
|
|
33
|
+
it("should parse a single tool call from RWKV format", () => {
|
|
34
|
+
const text = `<function>{"name": "ls", "arguments": {"path": "/tmp"}}</function>`;
|
|
35
|
+
const result = parseToolCallsFromText(text);
|
|
36
|
+
|
|
37
|
+
expect(result).not.toBeNull();
|
|
38
|
+
expect(result).toHaveLength(1);
|
|
39
|
+
expect(result![0].name).toBe("ls");
|
|
40
|
+
expect(result![0].arguments).toEqual({ path: "/tmp" });
|
|
41
|
+
expect(result![0].id).toMatch(/^call_\d+_/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("should parse multiple tool calls in sequence", () => {
|
|
45
|
+
const text = `
|
|
46
|
+
<function>{"name": "ls", "arguments": {"path": "/tmp"}}</function>
|
|
47
|
+
<function>{"name": "read", "arguments": {"path": "/etc/hosts"}}</function>
|
|
48
|
+
`;
|
|
49
|
+
const result = parseToolCallsFromText(text);
|
|
50
|
+
|
|
51
|
+
expect(result).not.toBeNull();
|
|
52
|
+
expect(result).toHaveLength(2);
|
|
53
|
+
expect(result![0].name).toBe("ls");
|
|
54
|
+
expect(result![1].name).toBe("read");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should handle tool calls with complex arguments", () => {
|
|
58
|
+
const text = `<function>{"name": "grep", "arguments": {"pattern": "TODO", "files": ["*.ts", "*.js"]}}</function>`;
|
|
59
|
+
const result = parseToolCallsFromText(text);
|
|
60
|
+
|
|
61
|
+
expect(result).not.toBeNull();
|
|
62
|
+
expect(result![0].name).toBe("grep");
|
|
63
|
+
expect(result![0].arguments).toEqual({ pattern: "TODO", files: ["*.ts", "*.js"] });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("should handle arguments as nested JSON strings", () => {
|
|
67
|
+
const argsJson = JSON.stringify({ path: "/tmp", recursive: true });
|
|
68
|
+
const text = `<function>{"name": "bash", "arguments": "${argsJson.replace(/"/g, '\\"')}"}</function>`;
|
|
69
|
+
const result = parseToolCallsFromText(text);
|
|
70
|
+
|
|
71
|
+
expect(result).not.toBeNull();
|
|
72
|
+
expect(result![0].name).toBe("bash");
|
|
73
|
+
expect(result![0].arguments).toEqual({ path: "/tmp", recursive: true });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("should return null for text without tool calls", () => {
|
|
77
|
+
const text = "Hello, this is just a regular message without tool calls.";
|
|
78
|
+
const result = parseToolCallsFromText(text);
|
|
79
|
+
|
|
80
|
+
expect(result).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("should skip invalid JSON and continue parsing", () => {
|
|
84
|
+
const text = `
|
|
85
|
+
<function>{"name": "ls", "arguments": {"path": "/tmp"}}</function>
|
|
86
|
+
<function>{"invalid": "json"}</function>
|
|
87
|
+
<function>{"name": "read", "arguments": {"path": "/etc"}}</function>
|
|
88
|
+
`;
|
|
89
|
+
const result = parseToolCallsFromText(text);
|
|
90
|
+
|
|
91
|
+
expect(result).not.toBeNull();
|
|
92
|
+
expect(result).toHaveLength(2);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("should handle whitespace variations", () => {
|
|
96
|
+
const text = `
|
|
97
|
+
<function> {"name": "ls", "arguments": {}} </function>
|
|
98
|
+
<function>
|
|
99
|
+
{"name": "read", "arguments": {"path": "/etc"}}
|
|
100
|
+
</function>
|
|
101
|
+
`;
|
|
102
|
+
const result = parseToolCallsFromText(text);
|
|
103
|
+
|
|
104
|
+
expect(result).not.toBeNull();
|
|
105
|
+
expect(result).toHaveLength(2);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("should handle empty arguments", () => {
|
|
109
|
+
const text = `<function>{"name": "noop", "arguments": {}}</function>`;
|
|
110
|
+
const result = parseToolCallsFromText(text);
|
|
111
|
+
|
|
112
|
+
expect(result).not.toBeNull();
|
|
113
|
+
expect(result![0].name).toBe("noop");
|
|
114
|
+
expect(result![0].arguments).toEqual({});
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("message_end handler logic simulation", () => {
|
|
119
|
+
it("should convert text blocks with tool calls to toolCall blocks", () => {
|
|
120
|
+
const event = {
|
|
121
|
+
message: {
|
|
122
|
+
role: "assistant",
|
|
123
|
+
content: [
|
|
124
|
+
{ type: "text", text: "<function>{\"name\": \"ls\", \"arguments\": {\"path\": \"/tmp\"}}</function>" },
|
|
125
|
+
],
|
|
126
|
+
stopReason: "stop",
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const MODELS_NEED_TOOL_CALL_PARSING = new Set(["qrwkv-72b-32k", "qrwkv-32b-32k"]);
|
|
131
|
+
const modelClass = "qrwkv-72b-32k";
|
|
132
|
+
|
|
133
|
+
if (!MODELS_NEED_TOOL_CALL_PARSING.has(modelClass)) {
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const hasToolCalls = event.message.content?.some(
|
|
137
|
+
(block: any) => block.type === "toolCall"
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const newContent: any[] = [];
|
|
141
|
+
let foundToolCalls = false;
|
|
142
|
+
|
|
143
|
+
for (const block of event.message.content ?? []) {
|
|
144
|
+
if (block.type === "text" && block.text) {
|
|
145
|
+
const parsed = parseToolCallsFromText(block.text);
|
|
146
|
+
if (parsed && parsed.length > 0) {
|
|
147
|
+
for (const tc of parsed) {
|
|
148
|
+
newContent.push({
|
|
149
|
+
type: "toolCall",
|
|
150
|
+
id: tc.id,
|
|
151
|
+
name: tc.name,
|
|
152
|
+
arguments: tc.arguments,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
foundToolCalls = true;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
newContent.push(block);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (foundToolCalls) {
|
|
163
|
+
event.message.content = newContent;
|
|
164
|
+
event.message.stopReason = "toolUse";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
expect(event.message.content).toHaveLength(1);
|
|
168
|
+
const toolBlock = event.message.content[0] as any;
|
|
169
|
+
expect(toolBlock.type).toBe("toolCall");
|
|
170
|
+
expect(toolBlock.name).toBe("ls");
|
|
171
|
+
expect(toolBlock.arguments).toEqual({ path: "/tmp" });
|
|
172
|
+
expect(event.message.stopReason).toBe("toolUse");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("should preserve text blocks that don't contain tool calls", () => {
|
|
176
|
+
const event = {
|
|
177
|
+
message: {
|
|
178
|
+
role: "assistant",
|
|
179
|
+
content: [
|
|
180
|
+
{ type: "text", text: "Hello, I can help you!" },
|
|
181
|
+
{ type: "text", text: "<function>{\"name\": \"ls\", \"arguments\": {\"path\": \"/\"}}</function>" },
|
|
182
|
+
{ type: "text", text: "Let me run that command." },
|
|
183
|
+
],
|
|
184
|
+
stopReason: "stop",
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const MODELS_NEED_TOOL_CALL_PARSING = new Set(["qrwkv-72b-32k"]);
|
|
189
|
+
const modelClass = "qrwkv-72b-32k";
|
|
190
|
+
|
|
191
|
+
const hasToolCalls = event.message.content?.some(
|
|
192
|
+
(block: any) => block.type === "toolCall"
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const newContent: any[] = [];
|
|
196
|
+
let foundToolCalls = false;
|
|
197
|
+
|
|
198
|
+
for (const block of event.message.content ?? []) {
|
|
199
|
+
if (block.type === "text" && block.text) {
|
|
200
|
+
const parsed = parseToolCallsFromText(block.text);
|
|
201
|
+
if (parsed && parsed.length > 0) {
|
|
202
|
+
for (const tc of parsed) {
|
|
203
|
+
newContent.push({ type: "toolCall", id: tc.id, name: tc.name, arguments: tc.arguments });
|
|
204
|
+
}
|
|
205
|
+
foundToolCalls = true;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
newContent.push(block);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (foundToolCalls) {
|
|
213
|
+
event.message.content = newContent;
|
|
214
|
+
event.message.stopReason = "toolUse";
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
expect(event.message.content).toHaveLength(3);
|
|
218
|
+
expect(event.message.content[0].type).toBe("text");
|
|
219
|
+
expect(event.message.content[0].text).toBe("Hello, I can help you!");
|
|
220
|
+
expect(event.message.content[1].type).toBe("toolCall");
|
|
221
|
+
expect(event.message.content[2].type).toBe("text");
|
|
222
|
+
expect(event.message.content[2].text).toBe("Let me run that command.");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("should not modify messages that already have toolCall blocks", () => {
|
|
226
|
+
const event = {
|
|
227
|
+
message: {
|
|
228
|
+
role: "assistant",
|
|
229
|
+
content: [
|
|
230
|
+
{ type: "toolCall", id: "existing_1", name: "ls", arguments: {} },
|
|
231
|
+
{ type: "text", text: "<function>{\"name\": \"read\", \"arguments\": {}}</function>" },
|
|
232
|
+
],
|
|
233
|
+
stopReason: "toolUse",
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const hasToolCalls = event.message.content?.some(
|
|
238
|
+
(block: any) => block.type === "toolCall"
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
if (hasToolCalls) {
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
expect(event.message.content).toHaveLength(2);
|
|
245
|
+
expect(event.message.content[0].type).toBe("toolCall");
|
|
246
|
+
expect(event.message.content[1].type).toBe("text"); // Not converted
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe("getModelClass integration", () => {
|
|
251
|
+
it("should return correct model class for RWKV models", () => {
|
|
252
|
+
expect(getModelClass("featherless-ai/QRWKV-72B")).toBe("qrwkv-72b-32k");
|
|
253
|
+
expect(getModelClass("recursal/RWKV6Qwen2.5-32B-QwQ-Preview")).toBe("qrwkv-32b-32k");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("should return undefined for non-RWKV models", () => {
|
|
257
|
+
expect(getModelClass("unknown/model")).toBeUndefined();
|
|
258
|
+
expect(getModelClass("anthropic/claude-sonnet-4-5")).toBeUndefined();
|
|
259
|
+
});
|
|
260
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildSessionContext,
|
|
3
|
+
type ExtensionAPI,
|
|
4
|
+
type ExtensionContext,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { getRealContextLimit, getModelClass } from "../models";
|
|
7
|
+
import { tokenizeBatch, extractText, clearTokenCache } from "../tokenize";
|
|
8
|
+
import { PROVIDER, getApiKey } from "./shared";
|
|
9
|
+
import { handleApiError } from "./concurrency";
|
|
10
|
+
|
|
11
|
+
const MODELS_NEED_TOOL_CALL_PARSING = new Set([
|
|
12
|
+
"qrwkv-72b-32k",
|
|
13
|
+
"qrwkv-32b-32k",
|
|
14
|
+
"qwen3-32b",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
function parseToolCallsFromText(
|
|
18
|
+
text: string,
|
|
19
|
+
): Array<{ id: string; name: string; arguments: Record<string, any> }> | null {
|
|
20
|
+
const results = [];
|
|
21
|
+
|
|
22
|
+
const regex1 = /<function>\s*(\{.*?\})\s*<\/function>/gs;
|
|
23
|
+
for (const match of [...text.matchAll(regex1)]) {
|
|
24
|
+
try {
|
|
25
|
+
const data = JSON.parse(match[1]);
|
|
26
|
+
if (data.name && data.arguments !== undefined) {
|
|
27
|
+
results.push({
|
|
28
|
+
id: `call_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
|
|
29
|
+
name: data.name,
|
|
30
|
+
arguments: typeof data.arguments === 'string'
|
|
31
|
+
? JSON.parse(data.arguments)
|
|
32
|
+
: data.arguments,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const regex2 = /<tool_call>\s*(\{.*?\})\s*<\/tool_call>/gs;
|
|
40
|
+
for (const match of [...text.matchAll(regex2)]) {
|
|
41
|
+
try {
|
|
42
|
+
const data = JSON.parse(match[1]);
|
|
43
|
+
if (data.name && data.arguments !== undefined) {
|
|
44
|
+
results.push({
|
|
45
|
+
id: `call_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
|
|
46
|
+
name: data.name,
|
|
47
|
+
arguments: typeof data.arguments === 'string'
|
|
48
|
+
? JSON.parse(data.arguments)
|
|
49
|
+
: data.arguments,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return results.length > 0 ? results : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const CHAR_CHECK_THRESHOLD = 10000;
|
|
60
|
+
const COMPACTION_THRESHOLD_FACTOR = 0.7;
|
|
61
|
+
|
|
62
|
+
const tracker = new Map<
|
|
63
|
+
string,
|
|
64
|
+
{ charsSinceLastCheck: number; lastTokenCount: number }
|
|
65
|
+
>();
|
|
66
|
+
|
|
67
|
+
async function countTokens(
|
|
68
|
+
modelId: string,
|
|
69
|
+
messages: any[],
|
|
70
|
+
apiKey: string | undefined,
|
|
71
|
+
): Promise<number> {
|
|
72
|
+
const baseModelName = modelId.split("/").pop() || modelId;
|
|
73
|
+
const texts = messages.map(extractText);
|
|
74
|
+
try {
|
|
75
|
+
const counts = await tokenizeBatch(baseModelName, texts, apiKey);
|
|
76
|
+
return counts.reduce((sum, count) => sum + count, 0);
|
|
77
|
+
} catch {
|
|
78
|
+
return texts.reduce((sum, text) => sum + Math.ceil(text.length / 4), 0);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function syncAndCheckCompaction(
|
|
83
|
+
pi: ExtensionAPI,
|
|
84
|
+
ctx: ExtensionContext,
|
|
85
|
+
options?: { charsAdded?: number; messagesOverride?: any[] },
|
|
86
|
+
) {
|
|
87
|
+
const model = ctx.model;
|
|
88
|
+
if (model?.provider !== PROVIDER) return;
|
|
89
|
+
|
|
90
|
+
const realContextWindow = getRealContextLimit(model.id);
|
|
91
|
+
if (!realContextWindow) return;
|
|
92
|
+
|
|
93
|
+
const sessionFile = ctx.sessionManager.getSessionFile()!;
|
|
94
|
+
let entry = tracker.get(sessionFile) || {
|
|
95
|
+
charsSinceLastCheck: 0,
|
|
96
|
+
lastTokenCount: 0,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
if (options?.charsAdded) {
|
|
100
|
+
entry.charsSinceLastCheck += options.charsAdded;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const needsRecount =
|
|
104
|
+
!entry.lastTokenCount ||
|
|
105
|
+
entry.charsSinceLastCheck >= CHAR_CHECK_THRESHOLD ||
|
|
106
|
+
options?.messagesOverride !== undefined;
|
|
107
|
+
|
|
108
|
+
if (needsRecount) {
|
|
109
|
+
const apiKey = await getApiKey(ctx);
|
|
110
|
+
const msgs =
|
|
111
|
+
options?.messagesOverride ??
|
|
112
|
+
buildSessionContext(
|
|
113
|
+
ctx.sessionManager.getEntries(),
|
|
114
|
+
ctx.sessionManager.getLeafId(),
|
|
115
|
+
).messages;
|
|
116
|
+
|
|
117
|
+
if (msgs.length > 0) {
|
|
118
|
+
try {
|
|
119
|
+
const count = await countTokens(model.id, msgs, apiKey);
|
|
120
|
+
entry = {
|
|
121
|
+
charsSinceLastCheck: 0,
|
|
122
|
+
lastTokenCount: count,
|
|
123
|
+
};
|
|
124
|
+
} catch (err) {
|
|
125
|
+
handleApiError(err);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
tracker.set(sessionFile, entry);
|
|
131
|
+
|
|
132
|
+
if (
|
|
133
|
+
entry.lastTokenCount >
|
|
134
|
+
realContextWindow * COMPACTION_THRESHOLD_FACTOR
|
|
135
|
+
) {
|
|
136
|
+
ctx.compact({
|
|
137
|
+
keepRecentTokens: Math.floor(realContextWindow * 0.4),
|
|
138
|
+
onComplete: () => {
|
|
139
|
+
pi.sendUserMessage("Continue", { deliverAs: "followUp" });
|
|
140
|
+
},
|
|
141
|
+
} as any);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function registerContextTracking(pi: ExtensionAPI) {
|
|
146
|
+
pi.on("session_start", async () => {
|
|
147
|
+
clearTokenCache();
|
|
148
|
+
tracker.clear();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
pi.on("before_provider_request", async (event, ctx) => {
|
|
152
|
+
const messagesOverride = (event.payload as any)?.messages;
|
|
153
|
+
await syncAndCheckCompaction(pi, ctx, { messagesOverride });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
157
|
+
let charsAdded = 0;
|
|
158
|
+
for (const block of event.content ?? []) {
|
|
159
|
+
if (block.type === "text" && block.text) {
|
|
160
|
+
charsAdded += block.text.length;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
await syncAndCheckCompaction(pi, ctx, { charsAdded });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
167
|
+
await syncAndCheckCompaction(pi, ctx);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
pi.on("message_end", async (event, ctx) => {
|
|
171
|
+
const model = ctx.model;
|
|
172
|
+
if (!model || model.provider !== PROVIDER) return;
|
|
173
|
+
|
|
174
|
+
const modelClass = getModelClass(model.id);
|
|
175
|
+
if (!modelClass || !MODELS_NEED_TOOL_CALL_PARSING.has(modelClass)) return;
|
|
176
|
+
|
|
177
|
+
if (event.message?.role !== "assistant") return;
|
|
178
|
+
|
|
179
|
+
const hasToolCalls = event.message.content?.some(
|
|
180
|
+
(block: any) => block.type === "toolCall"
|
|
181
|
+
);
|
|
182
|
+
if (hasToolCalls) return;
|
|
183
|
+
|
|
184
|
+
const newContent: any[] = [];
|
|
185
|
+
let foundToolCalls = false;
|
|
186
|
+
|
|
187
|
+
for (const block of event.message.content ?? []) {
|
|
188
|
+
if (block.type === "text" && block.text) {
|
|
189
|
+
const parsed = parseToolCallsFromText(block.text);
|
|
190
|
+
if (parsed && parsed.length > 0) {
|
|
191
|
+
for (const tc of parsed) {
|
|
192
|
+
newContent.push({
|
|
193
|
+
type: "toolCall",
|
|
194
|
+
id: tc.id,
|
|
195
|
+
name: tc.name,
|
|
196
|
+
arguments: tc.arguments,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
foundToolCalls = true;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
newContent.push(block);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (foundToolCalls) {
|
|
207
|
+
event.message.content = newContent;
|
|
208
|
+
event.message.stopReason = "toolUse" as any;
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { MODELS, getModelConfig } from "../models";
|
|
3
|
+
import { BASE_URL, PROVIDER } from "./shared";
|
|
4
|
+
|
|
5
|
+
export function registerProvider(pi: ExtensionAPI) {
|
|
6
|
+
pi.registerProvider(PROVIDER, {
|
|
7
|
+
name: "Featherless AI",
|
|
8
|
+
baseUrl: BASE_URL,
|
|
9
|
+
apiKey: "$FEATHERLESS_API_KEY",
|
|
10
|
+
api: "openai-completions",
|
|
11
|
+
authHeader: true,
|
|
12
|
+
models: MODELS.map(getModelConfig),
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const BASE_URL = "https://api.featherless.ai/v1";
|
|
2
|
+
export const PROVIDER = "featherless-ai";
|
|
3
|
+
|
|
4
|
+
export async function getApiKey(ctx: any): Promise<string | undefined> {
|
|
5
|
+
if (ctx.modelRegistry) {
|
|
6
|
+
const key = await ctx.modelRegistry.getApiKeyForProvider(PROVIDER);
|
|
7
|
+
if (key) return key;
|
|
8
|
+
}
|
|
9
|
+
return process.env.FEATHERLESS_API_KEY;
|
|
10
|
+
}
|