opencode-pi 1.0.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.
Files changed (2) hide show
  1. package/opencode-pi.ts +242 -0
  2. package/package.json +19 -0
package/opencode-pi.ts ADDED
@@ -0,0 +1,242 @@
1
+ import { createAssistantMessageEventStream, type Api, type AssistantMessage, type AssistantMessageEventStream, type Context, type ImageContent, type Message, type Model, type SimpleStreamOptions, type StopReason, type TextContent, type ThinkingContent, type ToolCall, type UserMessage, type ToolResultMessage } from "@earendil-works/pi-ai";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+
4
+ function sanitize(s: string): string {
5
+ return s.replace(/[\uD800-\uDFFF]/g, "\uFFFD");
6
+ }
7
+
8
+ function convertMessages(context: Context): any[] {
9
+ const msgs: any[] = [];
10
+ if (context.systemPrompt) msgs.push({ role: "system", content: sanitize(context.systemPrompt) });
11
+
12
+ for (const msg of context.messages) {
13
+ if (msg.role === "user") {
14
+ const userMsg = msg as UserMessage;
15
+ if (typeof userMsg.content === "string") {
16
+ if (userMsg.content.trim()) msgs.push({ role: "user", content: sanitize(userMsg.content) });
17
+ } else {
18
+ const parts = userMsg.content.map(c =>
19
+ c.type === "text" ? { type: "text", text: sanitize(c.text) }
20
+ : { type: "image_url", image_url: { url: `data:${c.mimeType};base64,${c.data}` } }
21
+ );
22
+ if (parts.length) msgs.push({ role: "user", content: parts });
23
+ }
24
+ } else if (msg.role === "assistant") {
25
+ const asst = msg as AssistantMessage;
26
+ const textParts: string[] = [];
27
+ const toolCalls: { id: string; type: "function"; function: { name: string; arguments: string } }[] = [];
28
+ let reasoning = "";
29
+ for (const block of asst.content) {
30
+ if (block.type === "text" && block.text.trim()) textParts.push(sanitize(block.text));
31
+ else if (block.type === "toolCall") toolCalls.push({ id: block.id, type: "function", function: { name: block.name, arguments: JSON.stringify(block.arguments) } });
32
+ else if (block.type === "thinking" && (block as ThinkingContent).thinking.trim()) reasoning += sanitize((block as ThinkingContent).thinking);
33
+ }
34
+ if (!textParts.length && !toolCalls.length && !reasoning) continue;
35
+ const m: any = { role: "assistant", content: textParts.length ? textParts.join("\n") : "" };
36
+ if (reasoning) m.reasoning_content = reasoning;
37
+ if (toolCalls.length) m.tool_calls = toolCalls;
38
+ msgs.push(m);
39
+ } else if (msg.role === "toolResult") {
40
+ const tr = msg as ToolResultMessage;
41
+ const textParts = tr.content.filter(c => c.type === "text").map(c => sanitize((c as TextContent).text));
42
+ msgs.push({ role: "tool", tool_call_id: tr.toolCallId, content: textParts.length ? textParts.join("\n") : "" });
43
+ }
44
+ }
45
+ return msgs;
46
+ }
47
+
48
+ function convertTools(tools: any[] | undefined): any[] | undefined {
49
+ if (!tools?.length) return undefined;
50
+ return tools.map(t => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.parameters } }));
51
+ }
52
+
53
+ function mapStopReason(finishReason: string | null): StopReason {
54
+ switch (finishReason) {
55
+ case "stop": return "stop";
56
+ case "length": case "max_tokens": return "length";
57
+ case "tool_calls": return "toolUse";
58
+ default: return "stop";
59
+ }
60
+ }
61
+
62
+ function streamOpenCodeFree(model: Model<Api>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
63
+ const stream = createAssistantMessageEventStream();
64
+ (async () => {
65
+ const output: AssistantMessage = {
66
+ role: "assistant", content: [], api: model.api, provider: model.provider, model: model.id,
67
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
68
+ stopReason: "stop", timestamp: Date.now(),
69
+ };
70
+
71
+ try {
72
+ const tools = convertTools(context.tools);
73
+ const body: any = {
74
+ model: model.id,
75
+ messages: convertMessages(context),
76
+ stream: true,
77
+ stream_options: { include_usage: true },
78
+ max_tokens: options?.maxTokens ?? model.maxTokens,
79
+ };
80
+ if (tools?.length) body.tools = tools;
81
+
82
+ const res = await fetch(model.baseUrl + "/chat/completions", {
83
+ method: "POST",
84
+ headers: { "Content-Type": "application/json" },
85
+ body: JSON.stringify(body),
86
+ signal: options?.signal,
87
+ });
88
+
89
+ if (!res.ok) {
90
+ const text = await res.text();
91
+ throw new Error(`Zen API error ${res.status}: ${text}`);
92
+ }
93
+
94
+ const reader = res.body?.getReader();
95
+ if (!reader) throw new Error("No response body");
96
+ const decoder = new TextDecoder();
97
+ let buf = "";
98
+
99
+ stream.push({ type: "start", partial: output });
100
+
101
+ let thinkingIndex = -1;
102
+ let textIndex = -1;
103
+ const toolCallMap = new Map<number, { id: string; name: string; args: string }>();
104
+
105
+ while (true) {
106
+ const { done, value } = await reader.read();
107
+ if (done) break;
108
+ buf += decoder.decode(value, { stream: true });
109
+ const lines = buf.split("\n");
110
+ buf = lines.pop() ?? "";
111
+
112
+ for (const line of lines) {
113
+ if (!line.startsWith("data: ")) continue;
114
+ const payload = line.slice(6).trim();
115
+ if (payload === "[DONE]") continue;
116
+
117
+ let chunk: any;
118
+ try { chunk = JSON.parse(payload); } catch { continue; }
119
+
120
+ const delta = chunk.choices?.[0]?.delta;
121
+ const finishReason = chunk.choices?.[0]?.finish_reason;
122
+ if (!delta) continue;
123
+
124
+ if (delta.reasoning_content) {
125
+ if (thinkingIndex === -1) {
126
+ thinkingIndex = output.content.length;
127
+ output.content.push({ type: "thinking", thinking: "" });
128
+ stream.push({ type: "thinking_start", contentIndex: thinkingIndex, partial: output });
129
+ }
130
+ const block = output.content[thinkingIndex] as ThinkingContent;
131
+ block.thinking += delta.reasoning_content;
132
+ stream.push({ type: "thinking_delta", contentIndex: thinkingIndex, delta: delta.reasoning_content, partial: output });
133
+ }
134
+
135
+ if (delta.content) {
136
+ if (textIndex === -1) {
137
+ textIndex = output.content.length;
138
+ output.content.push({ type: "text", text: "" });
139
+ stream.push({ type: "text_start", contentIndex: textIndex, partial: output });
140
+ }
141
+ const block = output.content[textIndex] as TextContent;
142
+ block.text += delta.content;
143
+ stream.push({ type: "text_delta", contentIndex: textIndex, delta: delta.content, partial: output });
144
+ }
145
+
146
+ if (delta.tool_calls) {
147
+ for (const tc of delta.tool_calls) {
148
+ const idx = tc.index ?? 0;
149
+ if (tc.id) {
150
+ if (!toolCallMap.has(idx)) toolCallMap.set(idx, { id: tc.id, name: "", args: "" });
151
+ toolCallMap.get(idx)!.id = tc.id;
152
+ }
153
+ if (tc.function?.name) {
154
+ if (!toolCallMap.has(idx)) toolCallMap.set(idx, { id: "", name: "", args: "" });
155
+ toolCallMap.get(idx)!.name += tc.function.name;
156
+ }
157
+ if (tc.function?.arguments) {
158
+ if (!toolCallMap.has(idx)) toolCallMap.set(idx, { id: "", name: "", args: "" });
159
+ const existing = toolCallMap.get(idx)!;
160
+ if (!existing.id) existing.id = tc.id ?? `call_${idx}`;
161
+ if (!existing.name) existing.name = tc.function?.name ?? "";
162
+ existing.args += tc.function.arguments;
163
+ }
164
+ }
165
+
166
+ for (const [, tc] of toolCallMap) {
167
+ const existingIdx = output.content.findIndex(c => c.type === "toolCall" && (c as ToolCall).id === tc.id);
168
+ if (existingIdx === -1) {
169
+ const tcBlock: ToolCall & { partialArgs: string } = { type: "toolCall", id: tc.id, name: tc.name, arguments: {}, partialArgs: "" };
170
+ output.content.push(tcBlock);
171
+ stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output });
172
+ }
173
+ }
174
+
175
+ for (const [, tc] of toolCallMap) {
176
+ const existingIdx = output.content.findIndex(c => c.type === "toolCall" && (c as ToolCall).id === tc.id);
177
+ if (existingIdx === -1) continue;
178
+ const block = output.content[existingIdx] as ToolCall & { partialArgs: string };
179
+ const prevLen = block.partialArgs.length;
180
+ block.partialArgs = tc.args;
181
+ const newDelta = block.partialArgs.slice(prevLen);
182
+ if (newDelta) {
183
+ try { block.arguments = JSON.parse(block.partialArgs); } catch {}
184
+ stream.push({ type: "toolcall_delta", contentIndex: existingIdx, delta: newDelta, partial: output });
185
+ }
186
+ }
187
+ }
188
+
189
+ if (finishReason) {
190
+ output.stopReason = mapStopReason(finishReason);
191
+ }
192
+
193
+ if (chunk.usage) {
194
+ output.usage.input = chunk.usage.prompt_tokens ?? output.usage.input;
195
+ output.usage.output = chunk.usage.completion_tokens ?? output.usage.output;
196
+ output.usage.totalTokens = chunk.usage.total_tokens ?? (output.usage.input + output.usage.output);
197
+ }
198
+ }
199
+ }
200
+
201
+ if (thinkingIndex >= 0) stream.push({ type: "thinking_end", contentIndex: thinkingIndex, content: (output.content[thinkingIndex] as ThinkingContent).thinking, partial: output });
202
+ if (textIndex >= 0) stream.push({ type: "text_end", contentIndex: textIndex, content: (output.content[textIndex] as TextContent).text, partial: output });
203
+
204
+ for (let i = 0; i < output.content.length; i++) {
205
+ const block = output.content[i];
206
+ if (block.type === "toolCall" && (block as any).partialArgs !== undefined) {
207
+ delete (block as any).partialArgs;
208
+ stream.push({ type: "toolcall_end", contentIndex: i, toolCall: block as ToolCall, partial: output });
209
+ }
210
+ }
211
+
212
+ output.usage.totalTokens = output.usage.totalTokens || (output.usage.input + output.usage.output);
213
+ output.usage.cost.total = output.usage.cost.input + output.usage.cost.output + output.usage.cost.cacheRead + output.usage.cost.cacheWrite;
214
+
215
+ if (options?.signal?.aborted) throw new Error("Aborted");
216
+ stream.push({ type: "done", reason: output.stopReason as "stop" | "length" | "toolUse", message: output });
217
+ stream.end();
218
+ } catch (error) {
219
+ output.stopReason = options?.signal?.aborted ? "aborted" : "error";
220
+ output.errorMessage = error instanceof Error ? error.message : String(error);
221
+ stream.push({ type: "error", reason: output.stopReason, error: output } as any);
222
+ stream.end();
223
+ }
224
+ })();
225
+ return stream;
226
+ }
227
+
228
+ export default function (pi: ExtensionAPI) {
229
+ pi.registerProvider("opencode-pi", {
230
+ baseUrl: "https://opencode.ai/zen/v1",
231
+ apiKey: "sk-noop",
232
+ api: "opencode-pi-free",
233
+ models: [
234
+ { id: "big-pickle", name: "Big Pickle (free)", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128000, maxTokens: 4096 },
235
+ { id: "deepseek-v4-flash-free", name: "DeepSeek V4 Flash (free)", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128000, maxTokens: 4096 },
236
+ { id: "minimax-m2.5-free", name: "MiniMax M2.5 (free)", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128000, maxTokens: 4096 },
237
+ { id: "nemotron-3-super-free", name: "Nemotron 3 Super (free)", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128000, maxTokens: 4096 },
238
+ { id: "ring-2.6-1t-free", name: "Ring 2.6 1T (free)", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128000, maxTokens: 4096 },
239
+ ],
240
+ streamSimple: streamOpenCodeFree,
241
+ });
242
+ }
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "opencode-pi",
3
+ "version": "1.0.0",
4
+ "description": "OpenCode Zen free models provider for Pi agent",
5
+ "main": "opencode-pi.ts",
6
+ "type": "module",
7
+ "keywords": ["pi-package", "opencode", "zen", "free", "deepseek", "provider"],
8
+ "pi": {
9
+ "extensions": ["./opencode-pi.ts"]
10
+ },
11
+ "peerDependencies": {
12
+ "@mariozechner/pi-ai": "*",
13
+ "@mariozechner/pi-agent-core": "*",
14
+ "@mariozechner/pi-coding-agent": "*"
15
+ },
16
+ "license": "MIT",
17
+ "repository": "https://github.com/Denveous/opencode-pi",
18
+ "author": "denveous"
19
+ }