opencode-zen-freex 0.0.0-stage → 0.2.1

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,105 @@
1
- # Temporary Holding Version
1
+ # OpenCode Zen Free
2
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.
3
+ 给非 OpenCode 宿主使用的 OpenCode Zen 免费模型。provider id:`opencode-zen-free`。
4
+
5
+ Zen 的免费层会拒绝一切不像 OpenCode 发出的请求,回答 403 FreeTierError("can only be used from within OpenCode")。它不只看请求头,也读请求体:凡是它认得出名字的工具,描述都必须和 OpenCode 自己写的一样。这个插件直接去问真正提供免费层的那个端点,把工具按 OpenCode 的样子呈现出去,同时保留宿主自己的工具,所以模型发出的调用仍然跑得起来。
6
+
7
+ 不需要注册账号,也没有费用。凭据就是 `public` 这一个词。
8
+
9
+ 本项目是 `magpie-community/plugins` 里那个插件的维护分支,同样是 MIT。分叉的理由是那份指纹的变动比那个仓库跟得快:它最后一个版本仍然对着一个早已不提供免费层的端点。两个插件只能装一个——它们注册的是同一个 provider id,都装上的话谁生效取决于加载顺序。维护步骤见 [MAINTAINING.md](MAINTAINING.md);想确认现在还能不能用,跑 `bun run check:gate`。
10
+
11
+ ## 目录
12
+
13
+ - [安装](#安装)
14
+ - [登录](#登录)
15
+ - [模型](#模型)
16
+ - [免费层不再应答时](#免费层不再应答时)
17
+ - [请求](#请求)
18
+ - [排查](#排查)
19
+
20
+ ## 安装
21
+
22
+ ```sh
23
+ magpie plugin add opencode-zen-freex
24
+ magpie plugin login opencode-zen-free
25
+ ```
26
+
27
+ ## 登录
28
+
29
+ 在密钥一栏填 `public`。不需要个人的 API key。
30
+
31
+ 凭据保存在 OpenCode 的 `auth.json` 或 magpie 的 `plugin-auth.json` 里。
32
+
33
+ ## 模型
34
+
35
+ 插件会去发现 Zen 当前的免费模型,包括它们的上下文窗口和思考(reasoning)等级。用 `opencode-zen-free/<模型名>`,例如 `opencode-zen-free/big-pickle`。
36
+
37
+ 已下线的模型和 SystemOne 模型会被排除。这份列表在宿主每次列举模型时重新获取(magpie 在启动时做一次,之后每小时一次):Zen 不再免费开放的模型会退出列表,新转为免费的模型会加入列表。如果读不到 Zen 的列表,就沿用上一次的结果;第一次还没读到之前,用的是打包在插件里的那份,所以这个 provider 不会空着。请求一个当前并不免费的模型,会得到一个 404 并列出当时免费的模型;若某个模型是在上次列举之后才转免费的,请求会在重新获取列表后照常服务(最多每分钟一次)。可用性和限流由 Zen 决定,插件这边拿不到剩余额度。
38
+
39
+ ## 免费层不再应答时
40
+
41
+ 过期的指纹和好用的指纹看起来一模一样,直到有人被拒绝。要问 Zen 它还认哪几代 OpenCode:
42
+
43
+ ```sh
44
+ bun run check:gate
45
+ ```
46
+
47
+ 每一代的结论是 `answered`(正常应答)、`refused`(被拒,403)、`superseded`(426——Zen 要求的 OpenCode 比这份指纹声称的更新)或 `unreachable`(根本联系不上)。只要还有一代能用,一代被拒就不算失败;只有全部应答时退出码才是 0。发版之前、以及有人报告 403 的时候,都该跑一次。`ZEN_CHECK_MODEL` 可以指定探测用的模型,`ZEN_BASE_URL` 可以指定端点,默认值不合适时就改这两个。
48
+
49
+ 如果每一代都被拒,就从当前版本的 OpenCode 重新采集措辞:把一个 release 指向本地服务器,从它发出的请求里把工具读出来。`bun run capture -- --dry` 取一份新采集并报告差异,`bun run capture` 才真正写入。插件在拒绝时会直接说出发出去的措辞是哪个版本、去哪里找,而不是让人自己猜。
50
+
51
+ 要注意这个闸门并不稳定:摸索它的过程中它一天之内挪动过不止一次,而且同一个地址反复被拒之后,接下来一段时间反而会被应答。这里的结论只能读成"此时此刻",信之前再跑一遍。
52
+
53
+ `bun test` 是这个循环的另一半。它从不联网,所以它回答不了指纹是否还有效——它只能说明这个包的行为和以前是否一致。
54
+
55
+ ## 请求
56
+
57
+ 请求发往 `https://opencode.ai/inference/v1`,按每个模型原生的 Chat Completions、Responses 或 Anthropic Messages 接口来走。流式和普通 JSON 两种回复都支持。
58
+
59
+ 宿主自己的工具会被完整保留。Zen 的模型经常去调 OpenCode 的工具名;这种调用会被对上宿主那个只差大小写的工具(`bash` 对 `Bash`),或者只差大小写和 `_`/`-` 的那个(`todowrite` 对 `todo_write`、`webfetch` 对 `web_fetch`),前提是恰好只有一个对得上。找不到对应工具(或者调用根本没有名字)的,不放进回复里,回复的正文会说明哪个名字被略去了;如果这一轮所有调用都被略去,这一轮就以一段普通回答结束。工具仍然在宿主里执行。
60
+
61
+ Zen 的免费层读请求体,也读请求头,只有当整个请求都属于 OpenCode 的同一代时才作答:User-Agent、请求头和工具三者必须一致。当前一共有两代,请求带的工具说明是哪一代:`shell` 和 `read` 属于 2.x,`bash`、`glob`、`grep` 和 `read` 属于 1.x。如果宿主本身就是 OpenCode,它自己的 User-Agent 已经说明是哪一代,这个值会被原样传出去,而不是被钉死,所以永远不会落后。没有任何一方说明时,以工具为准,User-Agent 跟着工具走。
62
+
63
+ 在同一代之内,Zen 认得名字的工具——`bash`、`glob`、`grep`、`read`、`write`、`edit`——必须在场,并且要按 OpenCode 的说法描述。前四个在宿主缺了的时候会补上;后两个只在宿主本来就有的时候才去读。工具永远不会被丢弃:宿主自己的工具原封不动地保留,只有发往上游的措辞被替换掉,所以模型发出的调用仍然适配真正执行它的那个工具。别的名字的工具一概不看。
64
+
65
+ 被*补上*的工具背后什么都没有:宿主没有这个工具,调它也会被丢弃。但 Zen 要求按 OpenCode 的样子描述它,而这么描述又会让模型想去调它,所以在 Zen 会读的正文开头之后、它不读的那段收尾之前,插一句说明。那句话是只针对这一个工具写的:实测过一句笼统的"这些工具不可用",会连宿主自己的工具也一起不用。
66
+
67
+ 发出去的请求头就是下面这些,一个不多,而这份清单是刻意从 Zen 曾经接受过的全部头里挑出来的子集:
68
+
69
+ | 请求头 | 值 |
70
+ | --- | --- |
71
+ | `content-type` | `application/json` |
72
+ | `accept` | `*/*` |
73
+ | `user-agent` | 上面说的那一代的值 |
74
+ | `x-opencode-project` | 宿主的 project id,或 `global` |
75
+ | `x-opencode-session` | 整个会话共用的一个 id |
76
+ | `x-opencode-session-id` | 同一个 id |
77
+ | `x-opencode-client` | `cli` |
78
+ | `authorization` | `Bearer public`;走 Messages 时是 `x-api-key: public` |
79
+
80
+ `anthropic-version` 和 `anthropic-beta` 也会发出去,但只在走 Messages 时,而且只在宿主自己设了它们的时候。
81
+
82
+ `x-opencode-request` 在 2026-10-14 实测被拒,而且从未测出它是必需的,所以不发。`x-session-affinity` 和 `x-session-id` 出现在 OpenCode 上游自己的请求头集合里,实测被拒,所以同样不发——不过进来的同名请求头仍然会被读,用来判断这次请求属于哪个会话。测试按名字钉住了发出的这组请求头,因为多加一个请求头是弄坏这个插件最容易的方式。
83
+
84
+ Zen 的免费层会检查 session id 是否是 OpenCode 能生成的那种:时间戳加每毫秒计数器截成十二位十六进制,后面接十四个随机的 base62 字符;session 的那一种还会把低位取反。宿主给的 id 如果本来就是这个形状,就原样传出去;否则按会话的首条消息生成一个并记住,这样同一会话的各轮请求会共用一个 session id。
85
+
86
+ 偶尔 Zen 会在发出 `[DONE]` 之前就结束一个流式的 Chat Completions 回复。如果回复已经说了内容、且其中没有未完成的工具调用,就以普通回答结束,正文里会说明它被截断了;再问一次就会接着往下走。如果是带着未完成的工具调用被截断、或者什么都没说就断了、或者被折叠给一个不支持流式的客户端,请求就会失败,错误信息里写明这个回复的 id 和收到了多少。走 Anthropic Messages 或 Responses 的回复被截断同样会失败:magpie 能把它和完整的回复区分开,并且在该模型支持的情况下继续往下走。
87
+
88
+ ## 排查
89
+
90
+ | 看到的现象 | 这是什么 | 怎么办 |
91
+ | --- | --- | --- |
92
+ | 每个请求都是 `403 FreeTierError` | `fingerprint.json` 里的措辞过期了 | 跑 `bun run check:gate`;如果每一代都被拒,跑 `bun run capture` |
93
+ | `426 Upgrade Required` | 这个包自称的 OpenCode 版本比 Zen 要的旧 | 升级插件,或者在更新的 OpenCode 里运行 |
94
+ | `404 … not available as an OpenCode Zen free model now` | Zen 不再免费开放这个模型 | 从错误信息列出的模型里换一个 |
95
+ | `Model is not an available free model for this protocol` | 请求发来时列表变了 | 让宿主刷新模型列表再试 |
96
+ | `uses the public credential` | 存的密钥不是 `public` | 用 `public` 重新登录 |
97
+ | 不带 `FreeTierError` 的 403 | 是别的 403,原样透传 | 按 Zen 自己的错误来读,别当成这个插件的 |
98
+
99
+ 这个插件给出的 403 会写明发出去的措辞来自哪个 OpenCode 版本。那正是 `check:gate` 在问的那份指纹,所以从它开始查,而不是从请求头开始查。
100
+
101
+ 模型的可用性和限流归 Zen 管,插件拿不到剩余额度可显示。同一个地址反复被拒,也可能被放进一个冷却期,表现得和被拒很像。
102
+
103
+ ## 许可
104
+
105
+ MIT。分叉自 `magpie-community/plugins`(MIT)。
package/core-tools.mjs ADDED
@@ -0,0 +1,48 @@
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
+ /**
27
+ * The generation a request belongs to, told by the tools it carries. Tool names
28
+ * move far less often than descriptions do, so this survives a rewording that a
29
+ * pinned User-Agent would not.
30
+ */
31
+ export function generationOf(tools) {
32
+ const names = new Set((tools ?? []).map((tool) => tool?.function?.name ?? tool?.name));
33
+ if (names.has("shell")) return "2.x";
34
+ if (names.has("bash")) return "1.x";
35
+ return undefined;
36
+ }
37
+
38
+ /**
39
+ * The User-Agent that generation of OpenCode sends, for a protocol. Each
40
+ * protocol's ai-sdk package bundles its own provider-utils, so 1.x differs by
41
+ * protocol and 2.x does not.
42
+ */
43
+ export function agentFor(generation, protocol = "chat") {
44
+ const agent = PROFILES[generation].agent;
45
+ return agent.providerUtils
46
+ ? `opencode/${agent.opencode} ai-sdk/provider-utils/${agent.providerUtils[protocol]} ${agent.runtime}`
47
+ : `opencode/${agent.channel}/${agent.opencode}/${agent.client}`;
48
+ }
@@ -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.1",
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
  }