opencode-zen-freex 0.0.0-stage → 0.2.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/LICENSE ADDED
@@ -0,0 +1,27 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xiaozhou26
4
+ Copyright (c) 2026 opencode-zen-free contributors
5
+
6
+ This is a maintained fork of the OpenCode Zen Free plugin from
7
+ magpie-community/plugins, which is MIT licensed and carries the notice above.
8
+ The fork is maintained separately because the free tier's request fingerprint
9
+ moves faster than that repository follows it.
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,125 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # OpenCode Zen Free
2
+
3
+ OpenCode Zen's free models for a host that is not OpenCode. Provider id:
4
+ `opencode-zen-free`.
5
+
6
+ Zen's free tier answers 403 FreeTierError ("can only be used from within
7
+ OpenCode") to a request that isn't OpenCode's, and it reads the request body
8
+ as well as its headers: the tools it knows by name have to be described the
9
+ way OpenCode describes them. This plugin asks the endpoint that serves the
10
+ free tier, and presents the tools the way OpenCode presents them while
11
+ keeping the host's own tools, so its calls still run.
12
+
13
+ This is a maintained fork of the plugin from `magpie-community/plugins`,
14
+ which is MIT licensed. It is forked because that fingerprint moves faster
15
+ than that repository follows it: its last release is still built for an
16
+ endpoint that no longer serves the free tier. See [MAINTAINING.md](MAINTAINING.md)
17
+ for how to keep it working, and `bun run check:gate` for whether it is
18
+ working now.
19
+
20
+ ## Install
21
+
22
+ ```sh
23
+ magpie plugin add opencode-zen-freex
24
+ magpie plugin login opencode-zen-free
25
+ ```
26
+
27
+ ## Sign in
28
+
29
+ Enter `public` in the key field. No personal API key is needed.
30
+ The credential is kept in OpenCode's `auth.json` or magpie's
31
+ `plugin-auth.json`.
32
+
33
+ ## Models
34
+
35
+ The plugin discovers Zen's current free models, including their context
36
+ windows and reasoning levels. Use `opencode-zen-free/<model>`, for example
37
+ `opencode-zen-free/big-pickle`.
38
+
39
+ Retired models and SystemOne models are excluded. The list is asked
40
+ again each time the host lists models (magpie does this on start and every
41
+ hour): a model Zen no longer gives free leaves it, and a new free one joins
42
+ it. When Zen's list can't be read, the last list is kept. A request for a
43
+ model that isn't free now gets a 404 naming the free ones; one made free
44
+ since the last list is served after the list is asked again (at most once a
45
+ minute). Availability and rate limits are set by Zen; the plugin has no
46
+ remaining-quota data.
47
+
48
+ ## When the free tier stops answering
49
+
50
+ A stale fingerprint does not look different from a working one until
51
+ somebody is refused. Ask Zen which generations the plugin still speaks:
52
+
53
+ ```sh
54
+ bun run check:gate
55
+ ```
56
+
57
+ Each profile is reported as `answered`, `refused` (403), `superseded`
58
+ (426 — Zen wants a newer OpenCode than the profile claims) or
59
+ `unreachable`. Run it before a release and whenever a 403 is reported.
60
+
61
+ If every profile is refused, capture the wording again from a current
62
+ OpenCode: point a release at a local server and read the tools out of the
63
+ request it makes. A refusal from the plugin says which wording went out
64
+ and where to look, rather than leaving it to be guessed at.
65
+
66
+ Note that the gate is not steady: it moved more than once in a day while
67
+ this was being worked out, and repeated refusals from one address can
68
+ themselves be answered for a while. Read a verdict here as "at this
69
+ moment", and run it again before believing a change.
70
+
71
+ ## Requests
72
+
73
+ Requests go to `https://opencode.ai/inference/v1` using each model's
74
+ native Chat Completions, Responses or Anthropic Messages API. Streaming and
75
+ ordinary JSON replies are supported.
76
+
77
+ The agent's tools are preserved. Zen's models often call OpenCode's own
78
+ tool names; such a call is given the agent's tool whose name differs only
79
+ in case (`bash` to `Bash`), or else only in case and `_`/`-` (`todowrite`
80
+ to `todo_write`, `webfetch` to `web_fetch`), when exactly one does. A
81
+ call with no such tool (or with no name) is left out of the reply, and
82
+ the reply's text says which name was left out; a turn whose every call
83
+ was left out ends as an ordinary answer. Tools run in the agent.
84
+
85
+ Zen's free tier reads the body as well as the headers, and answers a
86
+ request only when the whole of it belongs to one generation of OpenCode:
87
+ the User-Agent, the headers and the tools have to agree. Two generations
88
+ are current, and the tools a request carries say which: `shell` and `read`
89
+ are 2.x, `bash`, `glob`, `grep` and `read` are 1.x. A host that is OpenCode
90
+ itself says which it is in its own User-Agent, and that is passed on rather
91
+ than pinned, so it is never behind. Where nothing says, the tools decide
92
+ and the User-Agent follows them.
93
+
94
+ Within a generation, the tools Zen knows by name — `bash`, `glob`, `grep`,
95
+ `read`, `write`, `edit` — have to be there and described the way OpenCode
96
+ describes them. The first four are added when the host lacks them; the
97
+ other two are only read when the host has them. A tool is never
98
+ discarded: the host's own tool is kept whole, and only the wording sent
99
+ upstream is replaced, so the call the model makes still fits the tool that
100
+ runs it. A tool of any other name is not looked at.
101
+
102
+ A tool that was *added* has nothing behind it: the agent has no such tool
103
+ and a call to it is dropped. Describing it as OpenCode describes it is what
104
+ Zen asks for, and it also makes the model want to call it, so a note saying
105
+ so is placed after the opening Zen reads and before the closing it does
106
+ not. That note is phrased about the one tool on purpose: a blanket "these
107
+ tools are not available" was measured to stop the model using the host's
108
+ own tools as well.
109
+
110
+ The headers sent are exactly those of one generation and no more, and that
111
+ set is a deliberate subset of everything Zen has been seen to accept.
112
+ `x-opencode-request` was measured refused on 2026-10-14 and has never been
113
+ measured to be needed, so it is not sent. `x-session-affinity` and
114
+ `x-session-id` are in OpenCode's own header set upstream and have been
115
+ measured refused, so they are not sent either. A test pins the set by name,
116
+ because adding a header is the easiest way to break this plugin.
117
+
118
+ Now and then Zen closes a streamed Chat Completions reply before its
119
+ `[DONE]`. When the reply has said something and no tool call of it is
120
+ unfinished, it ends there as an ordinary answer, and its text says it was
121
+ cut short; asking again goes on. A reply cut short with a tool call
122
+ unfinished, with nothing said, or collapsed for a client that doesn't
123
+ stream fails with an error naming the reply's id and how much of it came.
124
+ An Anthropic Messages or Responses reply cut short fails too: magpie
125
+ tells it from a whole one and, where the model can, goes on with it.
package/core-tools.mjs ADDED
@@ -0,0 +1,53 @@
1
+ // Which generation of OpenCode's request Zen's free tier will serve.
2
+ //
3
+ // Zen accepts a request only when the whole of it belongs to one generation:
4
+ // the User-Agent, the header set and the tools have to agree. Two generations
5
+ // are known to be current, and which one applies is the host's to decide:
6
+ //
7
+ // 1.x bash, glob, grep, read - User-Agent opencode/<v> ai-sdk/provider-utils/<v> runtime/bun/<v>
8
+ // 2.x shell, read - User-Agent opencode/<channel>/<version>/<client>
9
+ //
10
+ // Within a generation, the tools named in `read` are compared against Zen's own
11
+ // copy of OpenCode's, and the ones in `required` have to be there. Their data
12
+ // lives in fingerprint.json, which is a capture of a real OpenCode rather than
13
+ // anything written by hand; `bun run capture` takes a new one, and
14
+ // `bun run check:gate` says whether Zen still accepts it.
15
+ //
16
+ // The descriptions there are the opening of OpenCode's, since that is the part
17
+ // Zen reads, with the capture host's scratch directory and OS banner replaced
18
+ // because those belong to the machine the capture ran on.
19
+
20
+ import { readFileSync } from "node:fs";
21
+
22
+ const captured = JSON.parse(readFileSync(new URL("./fingerprint.json", import.meta.url), "utf8"));
23
+
24
+ export const PROFILES = captured.profiles;
25
+
26
+ /** The names Zen reads the wording of, for a generation. */
27
+ const readNames = (generation) => Object.keys(PROFILES[generation].tools);
28
+
29
+ /**
30
+ * The generation a request belongs to, told by the tools it carries. Tool names
31
+ * move far less often than descriptions do, so this survives a rewording that a
32
+ * pinned User-Agent would not.
33
+ */
34
+ export function generationOf(tools) {
35
+ const names = new Set((tools ?? []).map((tool) => tool?.function?.name ?? tool?.name));
36
+ if (names.has("shell")) return "2.x";
37
+ if (names.has("bash")) return "1.x";
38
+ return undefined;
39
+ }
40
+
41
+ /**
42
+ * The User-Agent that generation of OpenCode sends, for a protocol. Each
43
+ * protocol's ai-sdk package bundles its own provider-utils, so 1.x differs by
44
+ * protocol and 2.x does not.
45
+ */
46
+ export function agentFor(generation, protocol = "chat") {
47
+ const agent = PROFILES[generation].agent;
48
+ return agent.providerUtils
49
+ ? `opencode/${agent.opencode} ai-sdk/provider-utils/${agent.providerUtils[protocol]} ${agent.runtime}`
50
+ : `opencode/${agent.channel}/${agent.opencode}/${agent.client}`;
51
+ }
52
+
53
+ export { readNames };
@@ -0,0 +1,241 @@
1
+ {
2
+ "_note": "Captured from a real OpenCode by capture.mjs. Refresh with: bun run capture",
3
+ "profiles": {
4
+ "1.x": {
5
+ "agent": {
6
+ "opencode": "1.19.0",
7
+ "providerUtils": {
8
+ "chat": "4.0.23",
9
+ "responses": "4.0.40",
10
+ "anthropic": "4.0.46"
11
+ },
12
+ "runtime": "runtime/bun/1.3.14"
13
+ },
14
+ "required": [
15
+ "bash",
16
+ "glob",
17
+ "grep",
18
+ "read"
19
+ ],
20
+ "tools": {
21
+ "bash": {
22
+ "name": "bash",
23
+ "description": "Executes a given Windows PowerShell (5.1) command with optional timeout, ensuring proper handling and security measures.\n\nBe aware: OS: linux, Shell: bash\n\nAll commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID changing directories inside the command - use `workdir` instead.\n\nUse `/tmp` for temporary work outside the workspace. This directory has already been created, already exists, and is pre-approved for external directory access.\n\nIMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead.\n\n# Windows PowerShell (5.1) shell notes\n- Use `cmd1; if ($?) { cmd2 }` to chain dependent commands.\n- Use double quotes for interpolated strings (`\"Hello $name\"`), single quotes for verbatim strings.\n- Prefer full cmdlet names like `Get-ChildItem`, `Set-Content`, `Remove-Item`, and `New-Item` over aliases.\n- Use `$(...)` for subexpressions. Use `@(...)` for array expressions.\n- To call a native executable whose path contains spaces, use the call opera",
24
+ "parameters": {
25
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
26
+ "type": "object",
27
+ "properties": {
28
+ "command": {
29
+ "type": "string",
30
+ "description": "The command to execute"
31
+ },
32
+ "timeout": {
33
+ "minimum": -9007199254740991,
34
+ "exclusiveMinimum": 0,
35
+ "type": "integer",
36
+ "maximum": 9007199254740991,
37
+ "description": "Optional timeout in milliseconds"
38
+ },
39
+ "workdir": {
40
+ "type": "string",
41
+ "description": "The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands."
42
+ }
43
+ },
44
+ "required": [
45
+ "command"
46
+ ]
47
+ }
48
+ },
49
+ "glob": {
50
+ "name": "glob",
51
+ "description": "- Fast file pattern matching tool that works with any codebase size\n- Supports glob patterns like \"**/*.js\" or \"src/**/*.ts\"\n- Returns matching file paths\n- Use this tool when you need to find files by name patterns\n- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead\n- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful.\n",
52
+ "parameters": {
53
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
54
+ "type": "object",
55
+ "properties": {
56
+ "pattern": {
57
+ "type": "string",
58
+ "description": "The glob pattern to match files against"
59
+ },
60
+ "path": {
61
+ "type": "string",
62
+ "description": "The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter \"undefined\" or \"null\" - simply omit it for the default behavior. Must be a valid directory path if provided."
63
+ }
64
+ },
65
+ "required": [
66
+ "pattern"
67
+ ]
68
+ }
69
+ },
70
+ "grep": {
71
+ "name": "grep",
72
+ "description": "- Fast content search tool that works with any codebase size\n- Searches file contents using regular expressions\n- Supports full regex syntax (eg. \"log.*Error\", \"function\\s+\\w+\", etc.)\n- Filter files by pattern with the include parameter (eg. \"*.js\", \"*.{ts,tsx}\")\n- Returns file paths and line numbers with matching lines\n- Use this tool when you need to find files containing specific patterns\n- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`.\n- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead\n",
73
+ "parameters": {
74
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
75
+ "type": "object",
76
+ "properties": {
77
+ "pattern": {
78
+ "type": "string",
79
+ "description": "The regex pattern to search for in file contents"
80
+ },
81
+ "path": {
82
+ "type": "string",
83
+ "description": "The directory to search in. Defaults to the current working directory."
84
+ },
85
+ "include": {
86
+ "type": "string",
87
+ "description": "File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")"
88
+ }
89
+ },
90
+ "required": [
91
+ "pattern"
92
+ ]
93
+ }
94
+ },
95
+ "read": {
96
+ "name": "read",
97
+ "description": "Read a file or directory from the local filesystem. If the path does not exist, an error is returned.\n\nUsage:\n- The filePath parameter should be an absolute path.\n- By default, this tool returns up to 2000 lines from the start of the file.\n- The offset parameter is the line number to start from (1-indexed).\n- To read later sections, call this tool again with a larger offset.\n- Use the grep tool to find specific content in large files or files with long lines.\n- If you are unsure of the correct file path, use the glob tool to look up filenames by glob pattern.\n- Contents are returned with each line prefixed by its line number as `<line>: <content>`. For example, if a file has contents \"foo\\n\", you will receive \"1: foo\\n\". For directories, entries are returned one per line (without line numbers) with a trailing `/` for subdirectories.\n- Any line longer than 2000 characters is truncated.\n- Call this tool in parallel when you know there are multiple files you want to read.\n- Avoid tiny repeated slices (30 line chunks). If you need more context, read a larger window.\n- This tool can read image files and PDFs and return them as file attachments.\n",
98
+ "parameters": {
99
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
100
+ "type": "object",
101
+ "properties": {
102
+ "filePath": {
103
+ "type": "string",
104
+ "description": "The absolute path to the file or directory to read"
105
+ },
106
+ "offset": {
107
+ "minimum": 0,
108
+ "type": "integer",
109
+ "maximum": 9007199254740991,
110
+ "description": "The line number to start reading from (1-indexed)"
111
+ },
112
+ "limit": {
113
+ "minimum": 0,
114
+ "type": "integer",
115
+ "maximum": 9007199254740991,
116
+ "description": "The maximum number of lines to read (defaults to 2000)"
117
+ }
118
+ },
119
+ "required": [
120
+ "filePath"
121
+ ]
122
+ }
123
+ },
124
+ "edit": {
125
+ "name": "edit",
126
+ "description": "Performs exact string replacements in files. \n\nUsage:\n- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. \n- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: line number + colon + space (e.g., `1: `). Everything after that space is the actual file content to match. Never include any part of the line number prefix in the oldString or newString.\n- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.\n- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.\n- The edit will FAIL if `oldString` is not found in the file with an error \"oldString not found in content\".\n- The edit will FAIL if `oldString` is found multiple times in the file with an error \"Found multiple matches for oldString. Provide more surrounding lines in oldString to identify the correct match.\" Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every ",
127
+ "parameters": {
128
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
129
+ "type": "object",
130
+ "properties": {
131
+ "filePath": {
132
+ "type": "string",
133
+ "description": "The absolute path to the file to modify"
134
+ },
135
+ "oldString": {
136
+ "type": "string",
137
+ "description": "The text to replace"
138
+ },
139
+ "newString": {
140
+ "type": "string",
141
+ "description": "The text to replace it with (must be different from oldString)"
142
+ },
143
+ "replaceAll": {
144
+ "type": "boolean",
145
+ "description": "Replace all occurrences of oldString (default false)"
146
+ }
147
+ },
148
+ "required": [
149
+ "filePath",
150
+ "oldString",
151
+ "newString"
152
+ ]
153
+ }
154
+ },
155
+ "write": {
156
+ "name": "write",
157
+ "description": "Writes a file to the local filesystem.\n\nUsage:\n- This tool will overwrite the existing file if there is one at the provided path.\n- If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail if you did not read the file first.\n- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.\n- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.\n- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.\n",
158
+ "parameters": {
159
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
160
+ "type": "object",
161
+ "properties": {
162
+ "content": {
163
+ "type": "string",
164
+ "description": "The content to write to the file"
165
+ },
166
+ "filePath": {
167
+ "type": "string",
168
+ "description": "The absolute path to the file to write (must be absolute, not relative)"
169
+ }
170
+ },
171
+ "required": [
172
+ "content",
173
+ "filePath"
174
+ ]
175
+ }
176
+ }
177
+ }
178
+ },
179
+ "2.x": {
180
+ "agent": {
181
+ "channel": "latest",
182
+ "opencode": "2.0.29",
183
+ "client": "cli"
184
+ },
185
+ "required": [
186
+ "shell",
187
+ "read"
188
+ ],
189
+ "tools": {
190
+ "shell": {
191
+ "name": "shell",
192
+ "description": "Execute a shell command and return its output. Commands run on Windows using PowerShell (5.1). Quote file paths containing spaces or special characters. Prefer dedicated tools over shell commands when possible. When output is large, the full result is saved to a file and a truncated preview is returned. Rely on automatic truncation unless filtering the output is more useful. Commands accept an optional timeout, background commands have no timeout by default. Background commands return immediately, and you will be notified when they complete.",
193
+ "parameters": {
194
+ "type": "object",
195
+ "properties": {
196
+ "command": {
197
+ "type": "string",
198
+ "description": "Shell command string to execute"
199
+ },
200
+ "workdir": {
201
+ "type": "string",
202
+ "description": "Working directory to execute the command in."
203
+ },
204
+ "timeout": {
205
+ "type": "number",
206
+ "description": "Timeout in milliseconds. Set to 0 to disable the timeout."
207
+ }
208
+ },
209
+ "required": [
210
+ "command"
211
+ ]
212
+ }
213
+ },
214
+ "read": {
215
+ "name": "read",
216
+ "description": "Read the contents of a file or directory. Supports text files, images, and PDFs. Images and PDFs are presented directly to the model. Each text line is prefixed by its 1-based line number as <line>: <content>. The prefix is for reference and is not part of the file content. Directory entries are returned one per line. Use offset and limit to read large files or directories in sections. Prefer one larger read over many small slices, and use grep to find specific content in large files.",
217
+ "parameters": {
218
+ "type": "object",
219
+ "properties": {
220
+ "path": {
221
+ "type": "string",
222
+ "description": "File or directory to read"
223
+ },
224
+ "offset": {
225
+ "type": "number",
226
+ "description": "The line or directory entry to start reading from (1-based)"
227
+ },
228
+ "limit": {
229
+ "type": "number",
230
+ "description": "The maximum number of lines or directory entries to read"
231
+ }
232
+ },
233
+ "required": [
234
+ "path"
235
+ ]
236
+ }
237
+ }
238
+ }
239
+ }
240
+ }
241
+ }
package/index.mjs ADDED
@@ -0,0 +1,3 @@
1
+ import plugin from "./provider.mjs"
2
+
3
+ export const ZenFreePlugin = plugin.server
package/models.mjs ADDED
@@ -0,0 +1,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
+ // Offline bootstrap metadata from the OpenCode catalog, 2026-10-06.
89
+ export const bootstrapProvider = {
90
+ npm: SDK.chat,
91
+ models: {
92
+ "big-pickle": {
93
+ name: "Big Pickle",
94
+ cost: { input: 0, output: 0 },
95
+ limit: { context: 200000, input: 160000, output: 32000 },
96
+ reasoning: true,
97
+ tool_call: true,
98
+ interleaved: { field: "reasoning_content" },
99
+ },
100
+ "space-bunny-free": {
101
+ name: "Space Bunny Free",
102
+ cost: { input: 0, output: 0 },
103
+ limit: { context: 1048576, input: 524288, output: 524288 },
104
+ reasoning: true,
105
+ tool_call: true,
106
+ },
107
+ "muse-spark-1.3-contributor-free": {
108
+ name: "Muse Spark 1.3 Free",
109
+ cost: { input: 0, output: 0 },
110
+ provider: { npm: SDK.responses },
111
+ limit: { context: 1048576, output: 131072 },
112
+ reasoning: true,
113
+ tool_call: true,
114
+ modalities: { input: ["text", "image", "video", "pdf", "audio"], output: ["text"] },
115
+ reasoning_options: [
116
+ { type: "effort", values: ["minimal", "low", "medium", "high", "xhigh"] },
117
+ ],
118
+ },
119
+ },
120
+ };
121
+
122
+ export function configModels(models) {
123
+ return Object.fromEntries(
124
+ Object.entries(models).map(([id, model]) => [
125
+ id,
126
+ {
127
+ id,
128
+ name: model.name,
129
+ provider: { npm: model.api.npm, api: model.api.url },
130
+ limit: model.limit,
131
+ reasoning: model.capabilities.reasoning,
132
+ tool_call: model.capabilities.toolcall,
133
+ attachment: model.capabilities.attachment,
134
+ modalities: {
135
+ input: Object.keys(model.capabilities.input).filter(
136
+ (key) => model.capabilities.input[key],
137
+ ),
138
+ output: Object.keys(model.capabilities.output).filter(
139
+ (key) => model.capabilities.output[key],
140
+ ),
141
+ },
142
+ cost: { input: 0, output: 0 },
143
+ variants: model.variants,
144
+ free: true,
145
+ },
146
+ ]),
147
+ );
148
+ }
package/package.json CHANGED
@@ -1,6 +1,29 @@
1
1
  {
2
2
  "name": "opencode-zen-freex",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "OpenCode Zen's free models, for hosts that are not OpenCode: the public credential, automatic discovery and native streaming protocols",
5
+ "type": "module",
6
+ "main": "./index.mjs",
7
+ "files": [
8
+ "index.mjs",
9
+ "provider.mjs",
10
+ "core-tools.mjs",
11
+ "fingerprint.json",
12
+ "models.mjs",
13
+ "stream.mjs",
14
+ "tool-response.mjs",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "keywords": ["opencode", "opencode-plugin", "zen", "free", "magpie"],
19
+ "license": "MIT",
20
+ "repository": { "type": "git", "url": "git+https://github.com/tkhs101/opencode-zen-freex.git" },
21
+ "bugs": { "url": "https://github.com/tkhs101/opencode-zen-freex/issues" },
22
+ "homepage": "https://github.com/tkhs101/opencode-zen-freex#readme",
23
+ "scripts": {
24
+ "test": "bun test",
25
+ "check:gate": "bun tools-smoke.mjs",
26
+ "capture": "bun capture.mjs"
27
+ },
28
+ "magpie": { "icon": "https://opencode.ai/favicon-v3.ico", "maxConcurrency": 2 }
6
29
  }