@pixelbyte-software/pixcode 1.33.3 → 1.33.5

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.
@@ -1,92 +1,100 @@
1
- // OpenCode Response Handler — `opencode run --format json` parser.
2
- //
3
- // The JSON format streams one event per line (NDJSON). Event shapes follow
4
- // the OpenAPI contract exposed by `opencode serve`. We treat the stream
5
- // permissively: lines that don't parse as JSON are passed through as plain
6
- // text deltas (covers OpenCode's pre-stream banner output and any debug
7
- // noise the CLI emits to stdout).
8
- import { sessionsService } from './modules/providers/services/sessions.service.js';
9
-
10
- class OpencodeResponseHandler {
11
- constructor(ws, options = {}) {
12
- this.ws = ws;
13
- this.buffer = '';
14
- this.onContentFragment = options.onContentFragment || null;
15
- this.onInit = options.onInit || null;
16
- this.onToolUse = options.onToolUse || null;
17
- this.onToolResult = options.onToolResult || null;
18
- }
19
-
20
- processData(data) {
21
- this.buffer += data;
22
-
23
- const lines = this.buffer.split('\n');
24
- this.buffer = lines.pop() || '';
25
-
26
- for (const line of lines) {
27
- const trimmed = line.trim();
28
- if (!trimmed) continue;
29
- try {
30
- const event = JSON.parse(trimmed);
31
- this.handleEvent(event);
32
- } catch {
33
- // Non-JSON line — surface as plain text delta so the user sees CLI
34
- // banners / status messages instead of swallowing them silently.
35
- if (this.onContentFragment) this.onContentFragment(trimmed + '\n');
36
- }
37
- }
38
- }
39
-
40
- handleEvent(event) {
41
- const sid = typeof this.ws.getSessionId === 'function' ? this.ws.getSessionId() : null;
42
-
43
- if (event.type === 'init' || event.type === 'session.start') {
44
- if (this.onInit) this.onInit(event);
45
- return;
46
- }
47
-
48
- // OpenCode emits both `message` events and `part` events that compose
49
- // a single assistant turn. We handle whichever shape lands.
50
- if (event.type === 'message' && event.role === 'assistant') {
51
- const content = event.content || event.text || '';
52
- if (this.onContentFragment && content) this.onContentFragment(content);
53
- } else if (event.type === 'part' && event.part_type === 'text') {
54
- const content = event.text || event.content || '';
55
- if (this.onContentFragment && content) this.onContentFragment(content);
56
- } else if (event.type === 'tool_use' || event.type === 'tool-use' || event.type === 'tool.start') {
57
- if (this.onToolUse) this.onToolUse({
58
- tool_id: event.tool_id || event.id,
59
- tool_name: event.tool_name || event.name,
60
- parameters: event.parameters || event.input || {},
61
- });
62
- } else if (event.type === 'tool_result' || event.type === 'tool-result' || event.type === 'tool.end') {
63
- if (this.onToolResult) this.onToolResult({
64
- tool_id: event.tool_id || event.id,
65
- output: event.output ?? event.result ?? '',
66
- status: event.status || (event.isError ? 'error' : 'ok'),
67
- });
68
- }
69
-
70
- const normalized = sessionsService.normalizeMessage('opencode', event, sid);
71
- for (const msg of normalized) {
72
- this.ws.send(msg);
73
- }
74
- }
75
-
76
- forceFlush() {
77
- if (this.buffer.trim()) {
78
- try {
79
- const event = JSON.parse(this.buffer);
80
- this.handleEvent(event);
81
- } catch {
82
- if (this.onContentFragment) this.onContentFragment(this.buffer);
83
- }
84
- }
85
- }
86
-
87
- destroy() {
88
- this.buffer = '';
89
- }
90
- }
91
-
92
- export default OpencodeResponseHandler;
1
+ // OpenCode Response Handler — `opencode run --format json` parser.
2
+ //
3
+ // The JSON format streams one event per line (NDJSON). Event shapes follow
4
+ // the OpenAPI contract exposed by `opencode serve`. We treat the stream
5
+ // permissively: lines that don't parse as JSON are passed through as plain
6
+ // text deltas (covers OpenCode's pre-stream banner output and any debug
7
+ // noise the CLI emits to stdout).
8
+ import { sessionsService } from './modules/providers/services/sessions.service.js';
9
+
10
+ class OpencodeResponseHandler {
11
+ constructor(ws, options = {}) {
12
+ this.ws = ws;
13
+ this.buffer = '';
14
+ this.onContentFragment = options.onContentFragment || null;
15
+ this.onInit = options.onInit || null;
16
+ this.onToolUse = options.onToolUse || null;
17
+ this.onToolResult = options.onToolResult || null;
18
+ }
19
+
20
+ processData(data) {
21
+ this.buffer += data;
22
+
23
+ const lines = this.buffer.split('\n');
24
+ this.buffer = lines.pop() || '';
25
+
26
+ for (const line of lines) {
27
+ const trimmed = line.trim();
28
+ if (!trimmed) continue;
29
+ try {
30
+ const event = JSON.parse(trimmed);
31
+ this.handleEvent(event);
32
+ } catch {
33
+ // Non-JSON line — surface as plain text delta so the user sees CLI
34
+ // banners / status messages instead of swallowing them silently.
35
+ if (this.onContentFragment) this.onContentFragment(trimmed + '\n');
36
+ }
37
+ }
38
+ }
39
+
40
+ handleEvent(event) {
41
+ const sid = typeof this.ws.getSessionId === 'function' ? this.ws.getSessionId() : null;
42
+
43
+ if (event.type === 'init' || event.type === 'session.start') {
44
+ if (this.onInit) this.onInit(event);
45
+ return;
46
+ }
47
+
48
+ // OpenCode emits assistant text as either a top-level `text` event
49
+ // (current `--format json` shape: `{ type: "text", part: { text } }`)
50
+ // or as `message`/`part` legacy shapes from earlier builds. Cover all
51
+ // three so we don't drop tokens on a CLI version we haven't matched.
52
+ if (event.type === 'text' && event.part && typeof event.part.text === 'string') {
53
+ if (this.onContentFragment && event.part.text) this.onContentFragment(event.part.text);
54
+ } else if (event.type === 'message' && event.role === 'assistant') {
55
+ const content = event.content || event.text || '';
56
+ if (this.onContentFragment && content) this.onContentFragment(content);
57
+ } else if (event.type === 'part' && event.part_type === 'text') {
58
+ const content = event.text || event.content || '';
59
+ if (this.onContentFragment && content) this.onContentFragment(content);
60
+ } else if (event.type === 'tool_use' || event.type === 'tool-use' || event.type === 'tool.start') {
61
+ // Tool-use shape on `--format json`: `{ type:"tool_use", part:{ callID, tool, state:{ input } } }`
62
+ const part = event.part || event;
63
+ if (this.onToolUse) this.onToolUse({
64
+ tool_id: part.callID || part.id || event.tool_id,
65
+ tool_name: part.tool || part.name || event.tool_name,
66
+ parameters: part.state?.input || part.input || event.parameters || {},
67
+ });
68
+ } else if (event.type === 'tool_result' || event.type === 'tool-result' || event.type === 'tool.end') {
69
+ const part = event.part || event;
70
+ const state = part.state || {};
71
+ if (this.onToolResult) this.onToolResult({
72
+ tool_id: part.callID || part.id || event.tool_id,
73
+ output: state.output ?? part.output ?? event.output ?? event.result ?? '',
74
+ status: state.status || event.status || (event.isError ? 'error' : 'ok'),
75
+ });
76
+ }
77
+
78
+ const normalized = sessionsService.normalizeMessage('opencode', event, sid);
79
+ for (const msg of normalized) {
80
+ this.ws.send(msg);
81
+ }
82
+ }
83
+
84
+ forceFlush() {
85
+ if (this.buffer.trim()) {
86
+ try {
87
+ const event = JSON.parse(this.buffer);
88
+ this.handleEvent(event);
89
+ } catch {
90
+ if (this.onContentFragment) this.onContentFragment(this.buffer);
91
+ }
92
+ }
93
+ }
94
+
95
+ destroy() {
96
+ this.buffer = '';
97
+ }
98
+ }
99
+
100
+ export default OpencodeResponseHandler;
@@ -71,7 +71,9 @@ function normalizeList(list) {
71
71
  seen.add(value);
72
72
  const label = typeof item.label === 'string' && item.label.trim() ? item.label.trim() : value;
73
73
  const source = item.source === 'api' ? 'api' : 'static';
74
- out.push({ value, label, source });
74
+ const entry = { value, label, source };
75
+ if (typeof item.free === 'boolean') entry.free = item.free;
76
+ out.push(entry);
75
77
  }
76
78
  return out;
77
79
  }
@@ -123,6 +125,101 @@ async function discoverOpenAiCompat(apiKey, baseUrl, fallbackBase) {
123
125
  }));
124
126
  }
125
127
 
128
+ /**
129
+ * OpenCode is multi-provider — its "model" picker isn't a single API list,
130
+ * it's the union of every provider it can route to (Anthropic, OpenAI,
131
+ * Google, xAI, OpenRouter, OpenCode Zen, Ollama, etc.). The canonical
132
+ * catalog lives at https://models.dev/api.json (no auth, ~1.8 MB JSON, 115
133
+ * providers as of 2026-04). We pull that, filter to providers the user
134
+ * has authenticated with (read `~/.local/share/opencode/auth.json`) plus
135
+ * always include the OpenCode Zen tier (works without explicit auth on
136
+ * the free models), drop deprecated entries, and tag free models.
137
+ */
138
+ async function discoverOpencode() {
139
+ const url = process.env.OPENCODE_MODELS_URL || 'https://models.dev/api.json';
140
+ const response = await fetch(url, {
141
+ // OpenCode itself caches this for hours; we cache for 6h via the
142
+ // outer wrapper so a single 7s fetch on cold start is acceptable.
143
+ signal: AbortSignal.timeout(15000),
144
+ });
145
+ if (!response.ok) throw new Error(`models.dev/api.json returned ${response.status}`);
146
+ const data = await response.json();
147
+ if (!data || typeof data !== 'object') throw new Error('models.dev returned a non-object payload');
148
+
149
+ // Read OpenCode's auth.json to know which providers the user can
150
+ // actually call. Missing file → only show always-free Zen.
151
+ const authedProviders = new Set(['opencode']);
152
+ try {
153
+ const authPath = path.join(os.homedir(), '.local', 'share', 'opencode', 'auth.json');
154
+ const raw = await fs.readFile(authPath, 'utf8');
155
+ const auth = JSON.parse(raw);
156
+ if (auth && typeof auth === 'object') {
157
+ for (const k of Object.keys(auth)) authedProviders.add(k);
158
+ }
159
+ } catch { /* no auth.json → only Zen free models surface */ }
160
+
161
+ // Common env-var providers OpenCode picks up automatically. If the user
162
+ // exported one in their shell, surface those models too even without
163
+ // auth.json. Mirrors the env list in opencode-auth.provider.ts.
164
+ const envProviderHints = {
165
+ anthropic: ['ANTHROPIC_API_KEY'],
166
+ openai: ['OPENAI_API_KEY'],
167
+ google: ['GOOGLE_GENERATIVE_AI_API_KEY', 'GEMINI_API_KEY'],
168
+ 'google-vertex': ['GOOGLE_APPLICATION_CREDENTIALS'],
169
+ xai: ['XAI_API_KEY'],
170
+ groq: ['GROQ_API_KEY'],
171
+ cerebras: ['CEREBRAS_API_KEY'],
172
+ openrouter: ['OPENROUTER_API_KEY'],
173
+ };
174
+ for (const [providerId, envVars] of Object.entries(envProviderHints)) {
175
+ if (envVars.some((v) => process.env[v]?.trim())) authedProviders.add(providerId);
176
+ }
177
+
178
+ const out = [];
179
+ for (const [providerId, providerCfg] of Object.entries(data)) {
180
+ if (!authedProviders.has(providerId)) continue;
181
+ if (!providerCfg || typeof providerCfg !== 'object') continue;
182
+ const models = providerCfg.models;
183
+ if (!models || typeof models !== 'object') continue;
184
+
185
+ const providerName = typeof providerCfg.name === 'string' && providerCfg.name.trim()
186
+ ? providerCfg.name
187
+ : providerId;
188
+
189
+ for (const [modelId, modelCfg] of Object.entries(models)) {
190
+ if (!modelCfg || typeof modelCfg !== 'object') continue;
191
+ // Skip deprecated entries from the default list — users can
192
+ // still hand-type them if they really need to.
193
+ if (modelCfg.status === 'deprecated') continue;
194
+ const cost = modelCfg.cost && typeof modelCfg.cost === 'object' ? modelCfg.cost : null;
195
+ const free = !cost || (Number(cost.input) === 0 && Number(cost.output) === 0);
196
+ const ctx = modelCfg.limit?.context;
197
+ const ctxLabel = typeof ctx === 'number' && ctx > 0
198
+ ? ` · ${ctx >= 1_000_000 ? `${(ctx / 1_000_000).toFixed(1)}M` : `${Math.round(ctx / 1000)}K`}`
199
+ : '';
200
+ const freeLabel = free ? ' · Free' : '';
201
+ const modelName = typeof modelCfg.name === 'string' && modelCfg.name.trim()
202
+ ? modelCfg.name
203
+ : modelId;
204
+
205
+ out.push({
206
+ value: `${providerId}/${modelId}`,
207
+ label: `${providerName} · ${modelName}${ctxLabel}${freeLabel}`,
208
+ source: 'api',
209
+ free,
210
+ });
211
+ }
212
+ }
213
+
214
+ // Sort: free first (handy when the user is unauthed), then by label.
215
+ out.sort((a, b) => {
216
+ if (a.free !== b.free) return a.free ? -1 : 1;
217
+ return a.label.localeCompare(b.label);
218
+ });
219
+
220
+ return out;
221
+ }
222
+
126
223
  async function discoverGoogle(apiKey) {
127
224
  // Google Generative Language API — public models list, API key as query.
128
225
  const endpoint = `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}`;
@@ -165,6 +262,22 @@ export async function getProviderModels(provider, opts = {}) {
165
262
  };
166
263
  }
167
264
 
265
+ // OpenCode is the odd one out: its catalog is models.dev, not a per-key
266
+ // API endpoint. Skip the credential plumbing and dispatch straight.
267
+ let liveModels = [];
268
+ let error;
269
+ if (provider === 'opencode') {
270
+ try {
271
+ liveModels = await discoverOpencode();
272
+ } catch (err) {
273
+ error = err?.message || String(err);
274
+ }
275
+ const merged = mergeCatalogs(normalizeList(liveModels), staticCatalog);
276
+ const entry = { models: merged, error };
277
+ await saveCacheEntry(provider, entry).catch(() => { /* non-fatal */ });
278
+ return { models: merged, fetchedAt: new Date().toISOString(), error, fromCache: false };
279
+ }
280
+
168
281
  // Pick up credentials from Pixcode's UI store first, then fall back to
169
282
  // the native env vars so a user who already exported ANTHROPIC_API_KEY
170
283
  // (or authenticated Claude Code via OAuth — the SDK writes the key into
@@ -185,8 +298,6 @@ export async function getProviderModels(provider, opts = {}) {
185
298
  const apiKey = creds?.apiKey || envKey;
186
299
  const baseUrl = creds?.baseUrl || envBase || undefined;
187
300
 
188
- let liveModels = [];
189
- let error;
190
301
  if (!apiKey) {
191
302
  // Be explicit so the UI can surface a useful hint rather than just
192
303
  // showing the static baseline with no reason given.
@@ -95,25 +95,48 @@ export const QWEN_MODELS = {
95
95
  };
96
96
 
97
97
  /**
98
- * OpenCode Models
98
+ * OpenCode Models — STATIC FALLBACK ONLY.
99
99
  *
100
- * OpenCode is multi-provider, so the picker is really "which upstream
101
- * model does opencode talk to?". The defaults here point at OpenCode Zen
102
- * (their curated routing tier) plus a few direct picks the docs call
103
- * out as well-supported. Advanced users override via opencode.json's
104
- * `provider` / `model` block, which bypasses this list.
100
+ * OpenCode is multi-provider and the live model catalog rotates often
101
+ * (Zen free models come and go; new Anthropic/OpenAI/Google models ship
102
+ * every few weeks). The runtime fetches the live catalog from
103
+ * `https://models.dev/api.json` via server/services/provider-models.js
104
+ * and merges it on top of this list — these entries only show when the
105
+ * fetch fails (offline install, firewalled host).
106
+ *
107
+ * Curated current free Zen tier + canonical paid picks. Update when the
108
+ * fallback feels stale, but the live fetch is the source of truth.
105
109
  */
106
110
  export const OPENCODE_MODELS = {
107
111
  OPTIONS: [
108
- { value: "opencode/zen-best", label: "OpenCode Zen (Best)" },
109
- { value: "opencode/zen-fast", label: "OpenCode Zen (Fast)" },
112
+ // OpenCode Zen free tier (no charge, may rate-limit). The "limited
113
+ // time" Zen freebies rotate, so this is the safest small set.
114
+ { value: "opencode/big-pickle", label: "OpenCode Zen · Big Pickle (Free)", free: true },
115
+ { value: "opencode/minimax-m2.5-free", label: "OpenCode Zen · MiniMax M2.5 (Free)", free: true },
116
+ { value: "opencode/hy3-preview-free", label: "OpenCode Zen · Hy3 Preview (Free)", free: true },
117
+ { value: "opencode/ling-2.6-flash-free", label: "OpenCode Zen · Ling 2.6 Flash (Free)", free: true },
118
+ { value: "opencode/nemotron-3-super-free", label: "OpenCode Zen · Nemotron 3 Super (Free)", free: true },
119
+ { value: "opencode/gpt-5-nano", label: "OpenCode Zen · GPT-5 Nano (Free)", free: true },
120
+
121
+ // Anthropic — current flagship + cheap.
122
+ { value: "anthropic/claude-opus-4-7", label: "Claude Opus 4.7 (Anthropic)" },
110
123
  { value: "anthropic/claude-sonnet-4-6", label: "Claude Sonnet 4.6 (Anthropic)" },
111
- { value: "anthropic/claude-opus-4-6", label: "Claude Opus 4.6 (Anthropic)" },
124
+ { value: "anthropic/claude-haiku-4-5", label: "Claude Haiku 4.5 (Anthropic)" },
125
+
126
+ // OpenAI — current GPT-5 family.
112
127
  { value: "openai/gpt-5.4", label: "GPT-5.4 (OpenAI)" },
113
- { value: "google/gemini-3-pro-preview", label: "Gemini 3 Pro (Google)" },
128
+ { value: "openai/gpt-5.1-codex", label: "GPT-5.1 Codex (OpenAI)" },
129
+
130
+ // Google — current Gemini 3 family.
131
+ { value: "google/gemini-3.1-pro-preview", label: "Gemini 3.1 Pro (Google)" },
132
+ { value: "google/gemini-3-flash-preview", label: "Gemini 3 Flash (Google)" },
133
+
134
+ // xAI — fast & cheap coding model.
135
+ { value: "xai/grok-code-fast-1", label: "Grok Code Fast 1 (xAI)" },
114
136
  ],
115
137
 
116
- DEFAULT: "opencode/zen-best",
138
+ // Free Zen freebie that historically works for unauthed installs.
139
+ DEFAULT: "opencode/big-pickle",
117
140
  };
118
141
 
119
142
  /**