opencode-zen-freex 0.2.0 → 0.2.2
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/MAINTAINING.md +171 -0
- package/README.md +139 -125
- package/capture.mjs +203 -0
- package/core-tools.mjs +0 -5
- package/index.mjs +3 -3
- package/models.mjs +211 -148
- package/package.json +7 -4
- package/provider.mjs +503 -461
- package/stream.mjs +398 -398
- package/tool-response.mjs +881 -881
- package/tools-smoke.mjs +169 -0
package/capture.mjs
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Recaptures fingerprint.json from a real OpenCode.
|
|
2
|
+
//
|
|
3
|
+
// The free tier reads the wording of the tools it knows by name out of the
|
|
4
|
+
// request body and refuses the request when it differs from its own copy. That
|
|
5
|
+
// copy moves with OpenCode, so the one held here goes stale — and a stale copy
|
|
6
|
+
// looks exactly like a working one until somebody is refused. This is how a new
|
|
7
|
+
// one is taken, so that refreshing it is a command and not an archaeology
|
|
8
|
+
// exercise.
|
|
9
|
+
//
|
|
10
|
+
// bun run capture # writes fingerprint.json
|
|
11
|
+
// bun run capture -- --dry # reports what changed, writes nothing
|
|
12
|
+
//
|
|
13
|
+
// Needs opencode on PATH (https://opencode.ai) and bun. Nothing goes to Zen: the
|
|
14
|
+
// OpenCode under test is pointed at a server running here, and the request it
|
|
15
|
+
// makes is the thing being read.
|
|
16
|
+
|
|
17
|
+
import { spawn } from "node:child_process";
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
import { mkdtempSync } from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { PROFILES } from "./core-tools.mjs";
|
|
23
|
+
|
|
24
|
+
const PORT = Number(process.env.CAPTURE_PORT ?? 8791);
|
|
25
|
+
const DRY = process.argv.includes("--dry");
|
|
26
|
+
const HERE = import.meta.dir;
|
|
27
|
+
const FINGERPRINT = join(HERE, "fingerprint.json");
|
|
28
|
+
|
|
29
|
+
// The names Zen reads. Everything else a host declares is ignored by it.
|
|
30
|
+
const READ = ["bash", "glob", "grep", "read", "write", "edit"];
|
|
31
|
+
|
|
32
|
+
// The capture host's own scratch directory and OS banner belong to the machine
|
|
33
|
+
// the capture ran on, not to the tool. These are put back to something neutral
|
|
34
|
+
// so the capture is not tied to one machine.
|
|
35
|
+
const NEUTRALISE = [
|
|
36
|
+
[/Be aware: OS: [^\n]*/g, "Be aware: OS: linux, Shell: bash"],
|
|
37
|
+
[/Use `[^`]*` for temporary work outside the workspace\./g, "Use `/tmp` for temporary work outside the workspace."],
|
|
38
|
+
[/Use `[A-Za-z]:\\[^`]*`/g, "Use `/tmp`"],
|
|
39
|
+
];
|
|
40
|
+
// Zen compares roughly the first 700 characters and ignores the rest; 1200
|
|
41
|
+
// leaves margin, and the tail is where a note to the model goes.
|
|
42
|
+
const KEEP = 1200;
|
|
43
|
+
|
|
44
|
+
const capturedPath = join(tmpdir(), `zen-capture-${process.pid}.json`);
|
|
45
|
+
const server = Bun.serve({
|
|
46
|
+
port: PORT,
|
|
47
|
+
hostname: "127.0.0.1",
|
|
48
|
+
async fetch(request) {
|
|
49
|
+
const url = new URL(request.url);
|
|
50
|
+
if (url.pathname.endsWith("/models"))
|
|
51
|
+
return Response.json({ object: "list", data: [{ id: "big-pickle", object: "model", owned_by: "opencode" }] });
|
|
52
|
+
// OpenCode makes more than one request (a short one for the title, then the
|
|
53
|
+
// real one). The one worth reading is the longest, since it is the one that
|
|
54
|
+
// carries the full toolset.
|
|
55
|
+
const body = await request.text();
|
|
56
|
+
let previous = { agent: null, body: "" };
|
|
57
|
+
try {
|
|
58
|
+
previous = JSON.parse(readFileSync(capturedPath, "utf8"));
|
|
59
|
+
} catch {}
|
|
60
|
+
if (body.length > previous.body.length)
|
|
61
|
+
await Bun.write(capturedPath, JSON.stringify({ agent: request.headers.get("user-agent"), body }));
|
|
62
|
+
return new Response(
|
|
63
|
+
[
|
|
64
|
+
'data: {"id":"c1","object":"chat.completion.chunk","created":1,"model":"big-pickle","choices":[{"index":0,"delta":{"role":"assistant","content":"OK"},"finish_reason":null}]}',
|
|
65
|
+
'data: {"id":"c1","object":"chat.completion.chunk","created":1,"model":"big-pickle","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
|
|
66
|
+
"data: [DONE]",
|
|
67
|
+
"",
|
|
68
|
+
].join("\n\n"),
|
|
69
|
+
{ headers: { "content-type": "text/event-stream" } },
|
|
70
|
+
);
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const home = mkdtempSync(join(tmpdir(), "zen-capture-home-"));
|
|
75
|
+
// Where OpenCode looks for its config is XDG_CONFIG_HOME/opencode, and the
|
|
76
|
+
// three XDG directories are pointed at `home` below.
|
|
77
|
+
const config = join(home, "config", "opencode", "opencode.json");
|
|
78
|
+
await Bun.write(
|
|
79
|
+
config,
|
|
80
|
+
JSON.stringify({ provider: { opencode: { options: { baseURL: `http://127.0.0.1:${PORT}/v1` } } } }),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
console.log(`listening on 127.0.0.1:${PORT}, running opencode against it`);
|
|
84
|
+
let ran = true;
|
|
85
|
+
const code = await new Promise((done) => {
|
|
86
|
+
const child = spawn("opencode", ["run", "-m", "opencode/big-pickle", "Say OK."], {
|
|
87
|
+
env: {
|
|
88
|
+
...process.env,
|
|
89
|
+
XDG_DATA_HOME: join(home, "data"),
|
|
90
|
+
XDG_CONFIG_HOME: join(home, "config"),
|
|
91
|
+
XDG_CACHE_HOME: join(home, "cache"),
|
|
92
|
+
},
|
|
93
|
+
stdio: ["ignore", "ignore", "inherit"],
|
|
94
|
+
});
|
|
95
|
+
child.on("error", () => {
|
|
96
|
+
console.error("could not run opencode; install it from https://opencode.ai and put it on PATH");
|
|
97
|
+
ran = false;
|
|
98
|
+
done(1);
|
|
99
|
+
});
|
|
100
|
+
child.on("exit", (value) => done(value ?? 0));
|
|
101
|
+
});
|
|
102
|
+
server.stop(true);
|
|
103
|
+
if (!ran) process.exit(1);
|
|
104
|
+
|
|
105
|
+
let request;
|
|
106
|
+
try {
|
|
107
|
+
request = JSON.parse(readFileSync(capturedPath, "utf8"));
|
|
108
|
+
} catch {
|
|
109
|
+
console.error("no request was captured; opencode did not reach the local server");
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const body = JSON.parse(request.body);
|
|
114
|
+
const tools = {};
|
|
115
|
+
for (const raw of body.tools ?? []) {
|
|
116
|
+
const tool = raw.function ?? raw;
|
|
117
|
+
if (!tool?.name || !READ.includes(tool.name) || !tool.description) continue;
|
|
118
|
+
tools[tool.name] = {
|
|
119
|
+
name: tool.name,
|
|
120
|
+
description: NEUTRALISE.reduce((text, [find, to]) => text.replace(find, to), tool.description).slice(0, KEEP),
|
|
121
|
+
parameters: tool.parameters,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const missing = READ.filter((name) => !tools[name]);
|
|
126
|
+
if (missing.length) {
|
|
127
|
+
console.error(
|
|
128
|
+
`the captured request has no ${missing.join(", ")}; this OpenCode no longer sends them, so it cannot be captured this way`,
|
|
129
|
+
);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
for (const [name, tool] of Object.entries(tools))
|
|
133
|
+
if (tool.description.length < 100) {
|
|
134
|
+
console.error(`${name}'s description looks truncated (${tool.description.length} chars); not writing`);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Which generation this client is: the names it sends say so.
|
|
139
|
+
const generation = body.tools.some((t) => (t.function ?? t).name === "shell") ? "2.x" : "1.x";
|
|
140
|
+
const required = generation === "2.x" ? ["shell", "read"] : ["bash", "glob", "grep", "read"];
|
|
141
|
+
const absent = required.filter((name) => !tools[name]);
|
|
142
|
+
if (absent.length) {
|
|
143
|
+
console.error(`a ${generation} request must carry ${absent.join(", ")}, and the capture does not`);
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Which protocol was captured, since each of 1.x's protocols bundles its own
|
|
148
|
+
// provider-utils and a capture must not overwrite the ones it did not observe.
|
|
149
|
+
const protocol = body.input !== undefined && body.messages === undefined ? "responses" : "chat";
|
|
150
|
+
|
|
151
|
+
console.log(`\nagent: ${request.agent}`);
|
|
152
|
+
console.log(`tools: ${Object.keys(tools).join(", ")} (${generation}, ${protocol})`);
|
|
153
|
+
if (code !== 0) console.log(`(opencode exited ${code}; its request was still captured)`);
|
|
154
|
+
|
|
155
|
+
const agent = parseAgent(request.agent);
|
|
156
|
+
if (agent.providerUtils) agent.providerUtils = { ...PROFILES[generation]?.agent?.providerUtils, [protocol]: agent.providerUtils[protocol] };
|
|
157
|
+
|
|
158
|
+
const before = PROFILES[generation];
|
|
159
|
+
const agentChanged = JSON.stringify(before?.agent) !== JSON.stringify(agent);
|
|
160
|
+
let changed = 0;
|
|
161
|
+
for (const name of Object.keys(tools)) {
|
|
162
|
+
if (JSON.stringify(before?.tools?.[name]) !== JSON.stringify(tools[name])) changed++;
|
|
163
|
+
console.log(` ${name.padEnd(6)} ${JSON.stringify(before?.tools?.[name]) === JSON.stringify(tools[name]) ? "unchanged" : "CHANGED"} ${tools[name].description.length} chars`);
|
|
164
|
+
}
|
|
165
|
+
if (agent.providerUtils)
|
|
166
|
+
console.log(` agent ${agentChanged ? "CHANGED" : "unchanged"} ${protocol} provider-utils kept separate from the others`);
|
|
167
|
+
|
|
168
|
+
if (DRY) {
|
|
169
|
+
const what = [changed ? `${changed} tool(s)` : null, agentChanged ? "the agent" : null].filter(Boolean);
|
|
170
|
+
console.log(`\n${what.length ? `${what.join(" and ")} would change.` : "Nothing would change."} Nothing written (--dry).`);
|
|
171
|
+
process.exit(what.length ? 1 : 0);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!changed && !agentChanged) {
|
|
175
|
+
console.log("\nnothing changed.");
|
|
176
|
+
process.exit(0);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const next = JSON.parse(readFileSync(FINGERPRINT, "utf8"));
|
|
180
|
+
next.profiles[generation] = { agent, required, tools };
|
|
181
|
+
await Bun.write(FINGERPRINT, JSON.stringify(next, null, 2) + "\n");
|
|
182
|
+
console.log(`\nwrote ${FINGERPRINT} for ${generation}. Now run \`bun run check:gate\` and commit if it passes.`);
|
|
183
|
+
|
|
184
|
+
// The User-Agent is stored the way agentFor() reads it back, which is not
|
|
185
|
+
// necessarily the same split the raw header used.
|
|
186
|
+
function parseAgent(agent) {
|
|
187
|
+
const twoPart = /^opencode\/(\d[^ ]*) ai-sdk\/provider-utils\/(\S+) (\S+)$/.exec(agent ?? "");
|
|
188
|
+
if (twoPart) {
|
|
189
|
+
const [, opencode, providerUtils, runtime] = twoPart;
|
|
190
|
+
return {
|
|
191
|
+
opencode,
|
|
192
|
+
providerUtils: { chat: providerUtils, responses: providerUtils, anthropic: providerUtils },
|
|
193
|
+
runtime,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
const threePart = /^opencode\/([a-z]+)\/(\d[^/]*)\/([a-z-]+)$/.exec(agent ?? "");
|
|
197
|
+
if (threePart) {
|
|
198
|
+
const [, channel, opencode, client] = threePart;
|
|
199
|
+
return { channel, opencode, client };
|
|
200
|
+
}
|
|
201
|
+
console.error(`\ncould not read \`${agent}\` as an OpenCode User-Agent; write PROFILES[${generation}].agent by hand`);
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
package/core-tools.mjs
CHANGED
|
@@ -23,9 +23,6 @@ const captured = JSON.parse(readFileSync(new URL("./fingerprint.json", import.me
|
|
|
23
23
|
|
|
24
24
|
export const PROFILES = captured.profiles;
|
|
25
25
|
|
|
26
|
-
/** The names Zen reads the wording of, for a generation. */
|
|
27
|
-
const readNames = (generation) => Object.keys(PROFILES[generation].tools);
|
|
28
|
-
|
|
29
26
|
/**
|
|
30
27
|
* The generation a request belongs to, told by the tools it carries. Tool names
|
|
31
28
|
* move far less often than descriptions do, so this survives a rewording that a
|
|
@@ -49,5 +46,3 @@ export function agentFor(generation, protocol = "chat") {
|
|
|
49
46
|
? `opencode/${agent.opencode} ai-sdk/provider-utils/${agent.providerUtils[protocol]} ${agent.runtime}`
|
|
50
47
|
: `opencode/${agent.channel}/${agent.opencode}/${agent.client}`;
|
|
51
48
|
}
|
|
52
|
-
|
|
53
|
-
export { readNames };
|
package/index.mjs
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import plugin from "./provider.mjs"
|
|
2
|
-
|
|
3
|
-
export const ZenFreePlugin = plugin.server
|
|
1
|
+
import plugin from "./provider.mjs"
|
|
2
|
+
|
|
3
|
+
export const ZenFreePlugin = plugin.server
|
package/models.mjs
CHANGED
|
@@ -1,148 +1,211 @@
|
|
|
1
|
-
export const PROVIDER = "opencode-zen-free";
|
|
2
|
-
export const SDK = {
|
|
3
|
-
chat: "@ai-sdk/openai-compatible",
|
|
4
|
-
responses: "@ai-sdk/openai",
|
|
5
|
-
anthropic: "@ai-sdk/anthropic",
|
|
6
|
-
};
|
|
7
|
-
|
|
8
|
-
export function parseProtocolDocs(text) {
|
|
9
|
-
const protocols = {};
|
|
10
|
-
const pattern =
|
|
11
|
-
/\|[^|]+\|\s*`?([^|`\s]+)`?\s*\|\s*`[^`]+\/v1\/(chat\/completions|responses|messages|systemone)`/g;
|
|
12
|
-
for (const match of text.matchAll(pattern)) {
|
|
13
|
-
protocols[match[1]] = {
|
|
14
|
-
"chat/completions": "chat",
|
|
15
|
-
responses: "responses",
|
|
16
|
-
messages: "anthropic",
|
|
17
|
-
systemone: "systemone",
|
|
18
|
-
}[match[2]];
|
|
19
|
-
}
|
|
20
|
-
return protocols;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function sdkProtocol(npm) {
|
|
24
|
-
if (npm?.includes("anthropic")) return "anthropic";
|
|
25
|
-
if (npm === SDK.responses) return "responses";
|
|
26
|
-
if (npm?.includes("openai-compatible")) return "chat";
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function isRetired(model) {
|
|
30
|
-
return (
|
|
31
|
-
model.deprecated === true ||
|
|
32
|
-
["deprecated", "retired", "disabled"].includes(model.status ?? model.lifecycle) ||
|
|
33
|
-
model.deprecated_at != null ||
|
|
34
|
-
model.retirement_date != null
|
|
35
|
-
);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function isFree(id, model = {}) {
|
|
39
|
-
return (
|
|
40
|
-
!isRetired(model) && (/free/i.test(id) || (model.cost?.input === 0 && model.cost?.output === 0))
|
|
41
|
-
);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function buildModels(ids, provider, protocols, baseURL) {
|
|
45
|
-
const result = {};
|
|
46
|
-
for (const id of ids) {
|
|
47
|
-
if (typeof id !== "string" || !id || id === "__proto__") continue;
|
|
48
|
-
const raw = provider.models?.[id];
|
|
49
|
-
const model = raw ?? {};
|
|
50
|
-
const protocol = protocols[id] ?? (raw && sdkProtocol(model.provider?.npm ?? provider.npm));
|
|
51
|
-
if (!isFree(id, model) || !Object.hasOwn(SDK, protocol)) continue;
|
|
52
|
-
const input = model.modalities?.input ?? ["text"];
|
|
53
|
-
const output = model.modalities?.output ?? ["text"];
|
|
54
|
-
const modalities = (values) =>
|
|
55
|
-
Object.fromEntries(
|
|
56
|
-
["text", "image", "audio", "video", "pdf"].map((name) => [name, values.includes(name)]),
|
|
57
|
-
);
|
|
58
|
-
const efforts = (model.reasoning_options ?? [])
|
|
59
|
-
.filter((option) => option.type === "effort")
|
|
60
|
-
.flatMap((option) => option.values ?? []);
|
|
61
|
-
result[id] = {
|
|
62
|
-
id,
|
|
63
|
-
providerID: PROVIDER,
|
|
64
|
-
name: model.name ?? id,
|
|
65
|
-
api: { id, url: baseURL, npm: SDK[protocol] },
|
|
66
|
-
limit: { context: 32768, output: 4096, ...model.limit },
|
|
67
|
-
capabilities: {
|
|
68
|
-
temperature: model.temperature ?? true,
|
|
69
|
-
reasoning: model.reasoning ?? false,
|
|
70
|
-
attachment: model.attachment ?? input.some((name) => name !== "text"),
|
|
71
|
-
toolcall: model.tool_call ?? true,
|
|
72
|
-
input: modalities(input),
|
|
73
|
-
output: modalities(output),
|
|
74
|
-
interleaved: model.interleaved ?? false,
|
|
75
|
-
},
|
|
76
|
-
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
|
77
|
-
variants: Object.fromEntries(efforts.map((effort) => [effort, { reasoningEffort: effort }])),
|
|
78
|
-
options: {},
|
|
79
|
-
headers: {},
|
|
80
|
-
status: model.status ?? "active",
|
|
81
|
-
release_date: model.release_date ?? "",
|
|
82
|
-
free: true,
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
return result;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
],
|
|
118
|
-
},
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
{
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
},
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
1
|
+
export const PROVIDER = "opencode-zen-free";
|
|
2
|
+
export const SDK = {
|
|
3
|
+
chat: "@ai-sdk/openai-compatible",
|
|
4
|
+
responses: "@ai-sdk/openai",
|
|
5
|
+
anthropic: "@ai-sdk/anthropic",
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function parseProtocolDocs(text) {
|
|
9
|
+
const protocols = {};
|
|
10
|
+
const pattern =
|
|
11
|
+
/\|[^|]+\|\s*`?([^|`\s]+)`?\s*\|\s*`[^`]+\/v1\/(chat\/completions|responses|messages|systemone)`/g;
|
|
12
|
+
for (const match of text.matchAll(pattern)) {
|
|
13
|
+
protocols[match[1]] = {
|
|
14
|
+
"chat/completions": "chat",
|
|
15
|
+
responses: "responses",
|
|
16
|
+
messages: "anthropic",
|
|
17
|
+
systemone: "systemone",
|
|
18
|
+
}[match[2]];
|
|
19
|
+
}
|
|
20
|
+
return protocols;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sdkProtocol(npm) {
|
|
24
|
+
if (npm?.includes("anthropic")) return "anthropic";
|
|
25
|
+
if (npm === SDK.responses) return "responses";
|
|
26
|
+
if (npm?.includes("openai-compatible")) return "chat";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isRetired(model) {
|
|
30
|
+
return (
|
|
31
|
+
model.deprecated === true ||
|
|
32
|
+
["deprecated", "retired", "disabled"].includes(model.status ?? model.lifecycle) ||
|
|
33
|
+
model.deprecated_at != null ||
|
|
34
|
+
model.retirement_date != null
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isFree(id, model = {}) {
|
|
39
|
+
return (
|
|
40
|
+
!isRetired(model) && (/free/i.test(id) || (model.cost?.input === 0 && model.cost?.output === 0))
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function buildModels(ids, provider, protocols, baseURL) {
|
|
45
|
+
const result = {};
|
|
46
|
+
for (const id of ids) {
|
|
47
|
+
if (typeof id !== "string" || !id || id === "__proto__") continue;
|
|
48
|
+
const raw = provider.models?.[id];
|
|
49
|
+
const model = raw ?? {};
|
|
50
|
+
const protocol = protocols[id] ?? (raw && sdkProtocol(model.provider?.npm ?? provider.npm));
|
|
51
|
+
if (!isFree(id, model) || !Object.hasOwn(SDK, protocol)) continue;
|
|
52
|
+
const input = model.modalities?.input ?? ["text"];
|
|
53
|
+
const output = model.modalities?.output ?? ["text"];
|
|
54
|
+
const modalities = (values) =>
|
|
55
|
+
Object.fromEntries(
|
|
56
|
+
["text", "image", "audio", "video", "pdf"].map((name) => [name, values.includes(name)]),
|
|
57
|
+
);
|
|
58
|
+
const efforts = (model.reasoning_options ?? [])
|
|
59
|
+
.filter((option) => option.type === "effort")
|
|
60
|
+
.flatMap((option) => option.values ?? []);
|
|
61
|
+
result[id] = {
|
|
62
|
+
id,
|
|
63
|
+
providerID: PROVIDER,
|
|
64
|
+
name: model.name ?? id,
|
|
65
|
+
api: { id, url: baseURL, npm: SDK[protocol] },
|
|
66
|
+
limit: { context: 32768, output: 4096, ...model.limit },
|
|
67
|
+
capabilities: {
|
|
68
|
+
temperature: model.temperature ?? true,
|
|
69
|
+
reasoning: model.reasoning ?? false,
|
|
70
|
+
attachment: model.attachment ?? input.some((name) => name !== "text"),
|
|
71
|
+
toolcall: model.tool_call ?? true,
|
|
72
|
+
input: modalities(input),
|
|
73
|
+
output: modalities(output),
|
|
74
|
+
interleaved: model.interleaved ?? false,
|
|
75
|
+
},
|
|
76
|
+
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
|
77
|
+
variants: Object.fromEntries(efforts.map((effort) => [effort, { reasoningEffort: effort }])),
|
|
78
|
+
options: {},
|
|
79
|
+
headers: {},
|
|
80
|
+
status: model.status ?? "active",
|
|
81
|
+
release_date: model.release_date ?? "",
|
|
82
|
+
free: true,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// What each free model is asked at, from OpenCode's console docs
|
|
89
|
+
// (services/www/src/docs/content/console/models.mdx) on 2026-10-15: used for
|
|
90
|
+
// a listed model when the docs can't be read, so the catalog or the docs
|
|
91
|
+
// being out of reach never leaves a listed free model out. Jev is a
|
|
92
|
+
// SystemOne decision model, not one to chat with.
|
|
93
|
+
export const bootstrapProtocols = {
|
|
94
|
+
"big-pickle": "chat",
|
|
95
|
+
"space-bunny-free": "chat",
|
|
96
|
+
"cat-jam-free": "chat",
|
|
97
|
+
"longcat-2.5-preview-free": "chat",
|
|
98
|
+
"step-5-preview-free": "chat",
|
|
99
|
+
"mimo-v2.6-flash-free": "chat",
|
|
100
|
+
"nemotron-3.5-lightning-free": "chat",
|
|
101
|
+
"muse-spark-1.3-contributor-free": "responses",
|
|
102
|
+
"jev-1.13-free": "systemone",
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Offline bootstrap metadata from the OpenCode catalog, 2026-10-06; the
|
|
106
|
+
// free models listed since added on 2026-10-15.
|
|
107
|
+
export const bootstrapProvider = {
|
|
108
|
+
npm: SDK.chat,
|
|
109
|
+
models: {
|
|
110
|
+
"cat-jam-free": {
|
|
111
|
+
name: "Cat Jam Free",
|
|
112
|
+
cost: { input: 0, output: 0 },
|
|
113
|
+
limit: { context: 262144, output: 131072 },
|
|
114
|
+
reasoning: true,
|
|
115
|
+
tool_call: true,
|
|
116
|
+
interleaved: { field: "reasoning_content" },
|
|
117
|
+
reasoning_options: [{ type: "effort", values: ["low", "medium", "high", "xhigh", "max"] }],
|
|
118
|
+
},
|
|
119
|
+
"step-5-preview-free": {
|
|
120
|
+
name: "Step 5 Preview Free",
|
|
121
|
+
cost: { input: 0, output: 0 },
|
|
122
|
+
limit: { context: 1000000, input: 1000000, output: 65536 },
|
|
123
|
+
reasoning: true,
|
|
124
|
+
tool_call: true,
|
|
125
|
+
interleaved: { field: "reasoning_content" },
|
|
126
|
+
modalities: { input: ["text", "image", "video"], output: ["text"] },
|
|
127
|
+
reasoning_options: [{ type: "effort", values: ["low", "medium", "high"] }],
|
|
128
|
+
},
|
|
129
|
+
"longcat-2.5-preview-free": {
|
|
130
|
+
name: "LongCat 2.5 Preview Free",
|
|
131
|
+
cost: { input: 0, output: 0 },
|
|
132
|
+
limit: { context: 1000000, output: 131072 },
|
|
133
|
+
reasoning: true,
|
|
134
|
+
tool_call: true,
|
|
135
|
+
interleaved: { field: "reasoning_content" },
|
|
136
|
+
modalities: { input: ["text", "image"], output: ["text"] },
|
|
137
|
+
},
|
|
138
|
+
"mimo-v2.6-flash-free": {
|
|
139
|
+
name: "MiMo-V2.6-Flash Free",
|
|
140
|
+
cost: { input: 0, output: 0 },
|
|
141
|
+
limit: { context: 200000, output: 32000 },
|
|
142
|
+
reasoning: true,
|
|
143
|
+
tool_call: true,
|
|
144
|
+
interleaved: { field: "reasoning_content" },
|
|
145
|
+
modalities: { input: ["text", "image", "audio", "video"], output: ["text"] },
|
|
146
|
+
},
|
|
147
|
+
"nemotron-3.5-lightning-free": {
|
|
148
|
+
name: "Nemotron 3.5 Lightning Free",
|
|
149
|
+
cost: { input: 0, output: 0 },
|
|
150
|
+
limit: { context: 262144, output: 262144 },
|
|
151
|
+
reasoning: true,
|
|
152
|
+
tool_call: true,
|
|
153
|
+
interleaved: { field: "reasoning_content" },
|
|
154
|
+
},
|
|
155
|
+
"big-pickle": {
|
|
156
|
+
name: "Big Pickle",
|
|
157
|
+
cost: { input: 0, output: 0 },
|
|
158
|
+
limit: { context: 200000, input: 160000, output: 32000 },
|
|
159
|
+
reasoning: true,
|
|
160
|
+
tool_call: true,
|
|
161
|
+
interleaved: { field: "reasoning_content" },
|
|
162
|
+
},
|
|
163
|
+
"space-bunny-free": {
|
|
164
|
+
name: "Space Bunny Free",
|
|
165
|
+
cost: { input: 0, output: 0 },
|
|
166
|
+
limit: { context: 1048576, input: 524288, output: 524288 },
|
|
167
|
+
reasoning: true,
|
|
168
|
+
tool_call: true,
|
|
169
|
+
},
|
|
170
|
+
"muse-spark-1.3-contributor-free": {
|
|
171
|
+
name: "Muse Spark 1.3 Free",
|
|
172
|
+
cost: { input: 0, output: 0 },
|
|
173
|
+
provider: { npm: SDK.responses },
|
|
174
|
+
limit: { context: 1048576, output: 131072 },
|
|
175
|
+
reasoning: true,
|
|
176
|
+
tool_call: true,
|
|
177
|
+
modalities: { input: ["text", "image", "video", "pdf", "audio"], output: ["text"] },
|
|
178
|
+
reasoning_options: [
|
|
179
|
+
{ type: "effort", values: ["minimal", "low", "medium", "high", "xhigh"] },
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export function configModels(models) {
|
|
186
|
+
return Object.fromEntries(
|
|
187
|
+
Object.entries(models).map(([id, model]) => [
|
|
188
|
+
id,
|
|
189
|
+
{
|
|
190
|
+
id,
|
|
191
|
+
name: model.name,
|
|
192
|
+
provider: { npm: model.api.npm, api: model.api.url },
|
|
193
|
+
limit: model.limit,
|
|
194
|
+
reasoning: model.capabilities.reasoning,
|
|
195
|
+
tool_call: model.capabilities.toolcall,
|
|
196
|
+
attachment: model.capabilities.attachment,
|
|
197
|
+
modalities: {
|
|
198
|
+
input: Object.keys(model.capabilities.input).filter(
|
|
199
|
+
(key) => model.capabilities.input[key],
|
|
200
|
+
),
|
|
201
|
+
output: Object.keys(model.capabilities.output).filter(
|
|
202
|
+
(key) => model.capabilities.output[key],
|
|
203
|
+
),
|
|
204
|
+
},
|
|
205
|
+
cost: { input: 0, output: 0 },
|
|
206
|
+
variants: model.variants,
|
|
207
|
+
free: true,
|
|
208
|
+
},
|
|
209
|
+
]),
|
|
210
|
+
);
|
|
211
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-zen-freex",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "OpenCode Zen's free models, for hosts that are not OpenCode: the public credential, automatic discovery and native streaming protocols",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
|
@@ -12,14 +12,17 @@
|
|
|
12
12
|
"models.mjs",
|
|
13
13
|
"stream.mjs",
|
|
14
14
|
"tool-response.mjs",
|
|
15
|
+
"tools-smoke.mjs",
|
|
16
|
+
"capture.mjs",
|
|
15
17
|
"README.md",
|
|
18
|
+
"MAINTAINING.md",
|
|
16
19
|
"LICENSE"
|
|
17
20
|
],
|
|
18
21
|
"keywords": ["opencode", "opencode-plugin", "zen", "free", "magpie"],
|
|
19
22
|
"license": "MIT",
|
|
20
|
-
"repository": { "type": "git", "url": "git+https://github.com/tkhs101/
|
|
21
|
-
"bugs": { "url": "https://github.com/tkhs101/
|
|
22
|
-
"homepage": "https://github.com/tkhs101/
|
|
23
|
+
"repository": { "type": "git", "url": "git+https://github.com/tkhs101/OpenCode-Zen-FreeX.git" },
|
|
24
|
+
"bugs": { "url": "https://github.com/tkhs101/OpenCode-Zen-FreeX/issues" },
|
|
25
|
+
"homepage": "https://github.com/tkhs101/OpenCode-Zen-FreeX#readme",
|
|
23
26
|
"scripts": {
|
|
24
27
|
"test": "bun test",
|
|
25
28
|
"check:gate": "bun tools-smoke.mjs",
|