opencode-zen-freex 0.2.1 → 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.
@@ -0,0 +1,169 @@
1
+ // Asks Zen which of the client generations this package still speaks.
2
+ //
3
+ // The gate moved more than once in a day, and nothing short of asking it says
4
+ // so: a stale fingerprint does not look different from a working one until a
5
+ // user is refused. This is the check to run before a release, and whenever a
6
+ // report comes in that the free tier answers 403.
7
+ //
8
+ // bun run check:gate
9
+ //
10
+ // Each profile is asked twice, because the plugin sends two shapes and Zen
11
+ // does not answer them alike:
12
+ //
13
+ // floor the names in `required`, which is what the plugin stands in for a
14
+ // host that is not OpenCode and has none of these tools by name. This
15
+ // is the shape the plugin exists to send, so it is the one that
16
+ // decides the answer.
17
+ // full every tool the profile carries, which is what a host that is
18
+ // OpenCode itself sends. It is reported for what it says about a
19
+ // wording that has gone stale, and does not decide the exit code.
20
+ //
21
+ // Asking only the full shape hid a real gap: `read` alone is served and
22
+ // everything but `read` is refused, so a Zen that started insisting on another
23
+ // name would leave the full shape passing while the shape the plugin actually
24
+ // sends was refused.
25
+ //
26
+ // Every request here is built by the plugin itself: the header names come from
27
+ // provider.mjs's outboundHeaders and the tools from the same `required` the
28
+ // plugin stands in, so this cannot report on a shape the plugin does not send.
29
+ // A copy of the headers is what it used to ask with, and that copy had drifted -
30
+ // it was missing `accept` - with nothing failing on it.
31
+ //
32
+ // Each request is reported as:
33
+ //
34
+ // answered Zen served the request; this shape is current
35
+ // refused Zen did not recognise this generation (403)
36
+ // superseded Zen wants a newer OpenCode than the one this profile claims (426)
37
+ // unreachable the free tier could not be asked at all
38
+ //
39
+ // Exit code is 0 when at least one generation's floor is answered. A generation
40
+ // being refused is not a failure while another still works.
41
+
42
+ import { randomBytes } from "node:crypto";
43
+ import { PROFILES, agentFor } from "./core-tools.mjs";
44
+ import { outboundHeaders } from "./provider.mjs";
45
+
46
+ const BASE = process.env.ZEN_BASE_URL ?? "https://opencode.ai/inference/v1";
47
+ const MODEL = process.env.ZEN_CHECK_MODEL ?? "space-bunny-free";
48
+ const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
49
+
50
+ // A session id of the shape OpenCode makes, since that is part of what is asked.
51
+ let stamp = 0;
52
+ let counter = 0;
53
+ function sessionID() {
54
+ const now = Date.now();
55
+ if (now !== stamp) {
56
+ stamp = now;
57
+ counter = 0;
58
+ }
59
+ counter++;
60
+ const value = (~(BigInt(now) * 0x1000n + BigInt(counter))) & 0xffffffffffffn;
61
+ let tail = "";
62
+ for (const byte of randomBytes(14)) tail += ALPHABET[byte % 62];
63
+ return `ses_${value.toString(16).padStart(12, "0")}${tail}`;
64
+ }
65
+
66
+ // Zen gives the same sentence whichever part of a request failed to look like
67
+ // OpenCode, so it is worth printing: a refusal that arrives with no message at
68
+ // all reads as something other than a stale fingerprint.
69
+ function says(text) {
70
+ const match = text.match(/"message"\s*:\s*"([^"]{0,120})"/);
71
+ return match ? `: ${match[1]}` : "";
72
+ }
73
+
74
+ // The request a shape is asked with. It is its own function so index.test.mjs
75
+ // can hold it against the request the plugin was observed to send, offline.
76
+ export function requestFor(generation, profile, names) {
77
+ return {
78
+ headers: outboundHeaders({
79
+ session: sessionID(),
80
+ agent: agentFor(generation),
81
+ project: "global",
82
+ protocol: "chat",
83
+ }),
84
+ body: {
85
+ model: MODEL,
86
+ messages: [{ role: "user", content: "hi" }],
87
+ tools: names.map((name) => ({ type: "function", function: profile.tools[name] })),
88
+ stream: true,
89
+ stream_options: { include_usage: true },
90
+ max_tokens: 40,
91
+ },
92
+ };
93
+ }
94
+
95
+ async function check(generation, profile, shape, names) {
96
+ const { headers, body } = requestFor(generation, profile, names);
97
+ try {
98
+ const response = await fetch(`${BASE}/chat/completions`, {
99
+ method: "POST",
100
+ headers,
101
+ body: JSON.stringify(body),
102
+ signal: AbortSignal.timeout(30000),
103
+ });
104
+ const text = await response.text();
105
+ if (response.ok)
106
+ return { generation, shape, verdict: "answered", detail: `as ${headers.get("user-agent")}` };
107
+ if (response.status === 426)
108
+ return {
109
+ generation,
110
+ shape,
111
+ verdict: "superseded",
112
+ detail: "Zen wants a newer OpenCode than this profile claims",
113
+ };
114
+ return { generation, shape, verdict: "refused", detail: `HTTP ${response.status}${says(text)}` };
115
+ } catch (error) {
116
+ return { generation, shape, verdict: "unreachable", detail: error.message };
117
+ }
118
+ }
119
+
120
+ // Only a run asks Zen. index.test.mjs imports this module to hold its request
121
+ // against the plugin's, and an import must not reach the network.
122
+ if (import.meta.main) {
123
+ const results = [];
124
+ for (const [generation, profile] of Object.entries(PROFILES)) {
125
+ results.push(await check(generation, profile, "floor", [...profile.required]));
126
+ results.push(await check(generation, profile, "full", Object.keys(profile.tools)));
127
+ }
128
+
129
+ const width = Math.max(...results.map((result) => result.generation.length));
130
+ for (const result of results)
131
+ console.log(
132
+ `${result.shape.padEnd(6)}${result.verdict.padEnd(12)} ${result.generation.padEnd(width)} ${result.detail}`,
133
+ );
134
+
135
+ const floor = results.filter((result) => result.shape === "floor");
136
+ const served = floor.filter((result) => result.verdict === "answered");
137
+ console.log(`\n${served.length}/${floor.length} generations serve a host that is not OpenCode at ${BASE}`);
138
+
139
+ // A generation whose full shape is served while its floor is not is the case
140
+ // this gate was widened to see: the plugin would work for a host bringing its
141
+ // own OpenCode tools and be refused for the host it was written for.
142
+ for (const result of floor) {
143
+ if (result.verdict === "answered") continue;
144
+ const full = results.find(
145
+ (other) => other.generation === result.generation && other.shape === "full",
146
+ );
147
+ if (full?.verdict === "answered")
148
+ console.log(
149
+ `${result.generation} answers the full OpenCode shape but not the tools the plugin stands in: a host that has none of them by name will be refused.`,
150
+ );
151
+ }
152
+
153
+ if (!served.length) {
154
+ // Being unable to ask is not an answer. Saying the fingerprint has gone stale
155
+ // when nothing was reached sends the reader to `capture` over a dead network.
156
+ if (results.every((result) => result.verdict === "unreachable")) {
157
+ console.log(
158
+ "The free tier could not be reached at all, so this says nothing about the fingerprint. Check the network or ZEN_BASE_URL and ask again.",
159
+ );
160
+ } else {
161
+ console.log(
162
+ "None of them. Zen has moved past every generation this plugin knows; the tool wording in core-tools.mjs needs capturing again from a current OpenCode.",
163
+ );
164
+ }
165
+ process.exitCode = 1;
166
+ } else if (served.length < floor.length) {
167
+ console.log("Some are refused but at least one works, so the plugin still has a generation Zen will serve.");
168
+ }
169
+ }