pi-hypercharm-provider 1.1.4 → 1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hypercharm-provider",
3
- "version": "1.1.4",
3
+ "version": "1.2.0",
4
4
  "description": "HyperCharm provider extension for pi - Access DeepSeek, GLM, Kimi, Qwen, MiniMax, Gemma, and GPT-OSS models through the Charm Hyper API",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -21,6 +21,12 @@
21
21
  "gemma",
22
22
  "gpt-oss"
23
23
  ],
24
+ "devDependencies": {
25
+ "@earendil-works/pi-ai": "0.84.1",
26
+ "@earendil-works/pi-coding-agent": "0.84.1",
27
+ "@types/node": "^24.10.1",
28
+ "typescript": "6.0.3"
29
+ },
24
30
  "author": "",
25
31
  "license": "MIT",
26
32
  "pi": {
@@ -31,7 +37,8 @@
31
37
  "scripts": {
32
38
  "clean": "echo 'nothing to clean'",
33
39
  "build": "echo 'nothing to build'",
34
- "check": "echo 'nothing to check'",
40
+ "check": "tsc --noEmit && node tests/status.smoke.ts",
41
+ "smoke": "node tests/status.smoke.ts",
35
42
  "update-models": "node scripts/update-models.js"
36
43
  }
37
44
  }
package/patch.json CHANGED
@@ -1,155 +1 @@
1
- {
2
- "deepseek-v4-flash": {
3
- "maxTokens": 384000,
4
- "thinkingLevelMap": {
5
- "minimal": null,
6
- "low": null,
7
- "medium": null,
8
- "high": "high",
9
- "max": "max"
10
- },
11
- "compat": {
12
- "thinkingFormat": "deepseek",
13
- "maxTokensField": "max_tokens",
14
- "supportsDeveloperRole": true,
15
- "supportsStore": false,
16
- "supportsReasoningEffort": true,
17
- "requiresReasoningContentOnAssistantMessages": true
18
- }
19
- },
20
- "deepseek-v4-pro": {
21
- "maxTokens": 384000,
22
- "thinkingLevelMap": {
23
- "minimal": null,
24
- "low": null,
25
- "medium": null,
26
- "high": "high",
27
- "max": "max"
28
- },
29
- "compat": {
30
- "thinkingFormat": "deepseek",
31
- "maxTokensField": "max_tokens",
32
- "supportsDeveloperRole": true,
33
- "supportsStore": false,
34
- "supportsReasoningEffort": true,
35
- "requiresReasoningContentOnAssistantMessages": true
36
- }
37
- },
38
- "gemma-4-26b-a4b-it": {
39
- "compat": {
40
- "thinkingFormat": "openai",
41
- "maxTokensField": "max_tokens",
42
- "supportsDeveloperRole": true,
43
- "supportsStore": false,
44
- "supportsReasoningEffort": true
45
- }
46
- },
47
- "glm-5": {
48
- "compat": {
49
- "thinkingFormat": "openai",
50
- "maxTokensField": "max_tokens",
51
- "supportsDeveloperRole": true,
52
- "supportsStore": false,
53
- "supportsReasoningEffort": true
54
- }
55
- },
56
- "glm-5.1": {
57
- "compat": {
58
- "thinkingFormat": "openai",
59
- "maxTokensField": "max_tokens",
60
- "supportsDeveloperRole": true,
61
- "supportsStore": false,
62
- "supportsReasoningEffort": true
63
- }
64
- },
65
- "gpt-oss-120b": {
66
- "compat": {
67
- "thinkingFormat": "openai",
68
- "maxTokensField": "max_tokens",
69
- "supportsDeveloperRole": true,
70
- "supportsStore": false,
71
- "supportsReasoningEffort": true
72
- }
73
- },
74
- "kimi-k2.5": {
75
- "compat": {
76
- "thinkingFormat": "openai",
77
- "maxTokensField": "max_tokens",
78
- "supportsDeveloperRole": true,
79
- "supportsStore": false,
80
- "supportsReasoningEffort": true
81
- }
82
- },
83
- "kimi-k2.6": {
84
- "compat": {
85
- "thinkingFormat": "openai",
86
- "maxTokensField": "max_tokens",
87
- "supportsDeveloperRole": true,
88
- "supportsStore": false,
89
- "supportsReasoningEffort": true
90
- }
91
- },
92
- "minimax-m2.7": {
93
- "compat": {
94
- "thinkingFormat": "openai",
95
- "maxTokensField": "max_tokens",
96
- "supportsDeveloperRole": true,
97
- "supportsStore": false,
98
- "supportsReasoningEffort": true
99
- }
100
- },
101
- "qwen3.6-flash": {
102
- "compat": {
103
- "thinkingFormat": "openai",
104
- "maxTokensField": "max_tokens",
105
- "supportsDeveloperRole": true,
106
- "supportsStore": false,
107
- "supportsReasoningEffort": true
108
- }
109
- },
110
- "qwen3.6-max": {
111
- "compat": {
112
- "thinkingFormat": "openai",
113
- "maxTokensField": "max_tokens",
114
- "supportsDeveloperRole": true,
115
- "supportsStore": false,
116
- "supportsReasoningEffort": true
117
- }
118
- },
119
- "qwen3.6-plus": {
120
- "compat": {
121
- "thinkingFormat": "openai",
122
- "maxTokensField": "max_tokens",
123
- "supportsDeveloperRole": true,
124
- "supportsStore": false,
125
- "supportsReasoningEffort": true
126
- }
127
- },
128
- "qwen3.7-max": {
129
- "compat": {
130
- "thinkingFormat": "openai",
131
- "maxTokensField": "max_tokens",
132
- "supportsDeveloperRole": true,
133
- "supportsStore": false,
134
- "supportsReasoningEffort": true
135
- }
136
- },
137
- "qwen3-coder-480b-a35b-instruct-int4-mixed-ar": {
138
- "compat": {
139
- "thinkingFormat": "openai",
140
- "maxTokensField": "max_tokens",
141
- "supportsDeveloperRole": true,
142
- "supportsStore": false,
143
- "supportsReasoningEffort": true
144
- }
145
- },
146
- "qwen3-next-80b-a3b-instruct": {
147
- "compat": {
148
- "thinkingFormat": "openai",
149
- "maxTokensField": "max_tokens",
150
- "supportsDeveloperRole": true,
151
- "supportsStore": false,
152
- "supportsReasoningEffort": true
153
- }
154
- }
155
- }
1
+ {}
@@ -0,0 +1,15 @@
1
+ allowBuilds:
2
+ '@google/genai': set this to true or false
3
+ protobufjs: set this to true or false
4
+ onlyBuiltDependencies:
5
+ - '@google/genai'
6
+ - protobufjs
7
+
8
+ minimumReleaseAgeExclude:
9
+ - '@earendil-works/pi-agent-core@0.84.0 || 0.84.1'
10
+ - '@earendil-works/pi-ai@0.84.0 || 0.84.1'
11
+ - '@earendil-works/pi-client@0.84.0 || 0.84.1'
12
+ - '@earendil-works/pi-coding-agent@0.84.0 || 0.84.1'
13
+ - '@earendil-works/pi-protocol@0.84.0 || 0.84.1'
14
+ - '@earendil-works/pi-telemetry@0.84.0 || 0.84.1'
15
+ - '@earendil-works/pi-tui@0.84.0 || 0.84.1'
@@ -1,31 +1,133 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * Update HyperCharm models from API
4
+ * Update HyperCharm models from Charm's official typed provider catalog
5
5
  *
6
- * Fetches models from https://hyper.charm.land/v1/models and updates:
7
- * - models.json: Model definitions with curated reasoning/vision flags + API pricing
8
- * - README.md: Model table with patch.json overrides applied
6
+ * Fetches models from https://hyper.charm.land/v1/provider and updates:
7
+ * - models.json: canonical API-owned metadata used by @charmland/pi-hyper-provider
8
+ * - README.md: model table with patch.json overrides applied
9
9
  *
10
- * The HyperCharm API provides: id, display_name, supports_reasoning,
11
- * supports_reasoning_effort, supports_attachments, context_window,
12
- * max_output_tokens, and cost.usd pricing.
13
- *
14
- * Note: supports_reasoning is unreliable for some models (reports true for
15
- * Llama 3.3 70B which doesn't support extended thinking). models.json
16
- * curates reasoning flags based on known model capabilities; patch.json
17
- * adds compat flags and corrections.
10
+ * The endpoint provides canonical names, $/M pricing, context/output limits,
11
+ * can_reason, optional reasoning levels, and attachment support. models.json is
12
+ * pure API data. patch.json is reserved for verified endpoint regressions and
13
+ * currently contains no overrides.
18
14
  *
19
15
  * Merge order for README: models.json → apply patch.json → merge custom-models.json
16
+ *
17
+ * API key: the stored `hypercharm` credential in ~/.pi/agent/auth.json wins, then
18
+ * the HYPERCHARM_API_KEY environment variable. The script refuses to run without one.
20
19
  */
21
20
 
22
21
  import fs from 'fs';
22
+ import os from 'os';
23
+ import { execSync } from 'child_process';
23
24
  import path from 'path';
24
25
  import { fileURLToPath } from 'url';
25
26
 
26
27
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
27
28
 
28
- const MODELS_API_URL = 'https://hyper.charm.land/v1/models';
29
+ // pi's agent directory: PI_CODING_AGENT_DIR (with ~ expansion) or ~/.pi/agent.
30
+ function piAgentDir() {
31
+ const envDir = process.env.PI_CODING_AGENT_DIR;
32
+ if (envDir) {
33
+ return envDir.startsWith('~/') || envDir === '~'
34
+ ? path.join(os.homedir(), envDir.slice(1))
35
+ : envDir;
36
+ }
37
+ return path.join(os.homedir(), '.pi', 'agent');
38
+ }
39
+
40
+ const AUTH_JSON_PATH = path.join(piAgentDir(), 'auth.json');
41
+
42
+ /**
43
+ * Resolve a configured value using pi's semantics (resolve-config-value.ts in
44
+ * pi-mono): "!command" runs via the shell (10s timeout) and uses trimmed
45
+ * stdout; "$VAR" / "${VAR}" interpolate environment variables ("$$" escapes a
46
+ * literal "$", "$!" a literal "!"); anything else is a literal. Returns
47
+ * undefined when a referenced env var is unset or a command fails.
48
+ */
49
+ function resolveConfigValue(config, env) {
50
+ if (typeof config !== 'string' || config.length === 0) return undefined;
51
+ if (config.startsWith('!')) {
52
+ try {
53
+ const out = execSync(config.slice(1), {
54
+ encoding: 'utf8',
55
+ timeout: 10000,
56
+ stdio: ['ignore', 'pipe', 'ignore'],
57
+ });
58
+ return out.trim() || undefined;
59
+ } catch {
60
+ return undefined;
61
+ }
62
+ }
63
+ const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
64
+ let resolved = '';
65
+ let index = 0;
66
+ while (index < config.length) {
67
+ const dollar = config.indexOf('$', index);
68
+ if (dollar < 0) {
69
+ resolved += config.slice(index);
70
+ break;
71
+ }
72
+ resolved += config.slice(index, dollar);
73
+ const next = config[dollar + 1];
74
+ let name;
75
+ if (next === '$' || next === '!') {
76
+ resolved += next;
77
+ index = dollar + 2;
78
+ continue;
79
+ } else if (next === '{') {
80
+ const end = config.indexOf('}', dollar + 2);
81
+ if (end < 0) {
82
+ resolved += '$';
83
+ index = dollar + 1;
84
+ continue;
85
+ }
86
+ const inner = config.slice(dollar + 2, end);
87
+ if (!ENV_NAME_RE.test(inner)) {
88
+ resolved += config.slice(dollar, end + 1);
89
+ index = end + 1;
90
+ continue;
91
+ }
92
+ name = inner;
93
+ index = end + 1;
94
+ } else {
95
+ const match = config.slice(dollar + 1).match(/^[A-Za-z_][A-Za-z0-9_]*/);
96
+ if (!match) {
97
+ resolved += '$';
98
+ index = dollar + 1;
99
+ continue;
100
+ }
101
+ name = match[0];
102
+ index = dollar + 1 + name.length;
103
+ }
104
+ const value = (env && env[name]) || process.env[name] || undefined;
105
+ if (value === undefined) return undefined;
106
+ resolved += value;
107
+ }
108
+ return resolved;
109
+ }
110
+
111
+ /**
112
+ * The API key, resolved the way pi itself resolves it for this provider: the
113
+ * stored `hypercharm` credential in ~/.pi/agent/auth.json wins, then
114
+ * the HYPERCHARM_API_KEY environment variable.
115
+ */
116
+ function resolveApiKey() {
117
+ try {
118
+ const auth = JSON.parse(fs.readFileSync(AUTH_JSON_PATH, 'utf8'));
119
+ const credential = auth?.hypercharm;
120
+ if (credential && credential.type === 'api_key' && typeof credential.key === 'string') {
121
+ const key = resolveConfigValue(credential.key, credential.env);
122
+ if (key) return key;
123
+ }
124
+ } catch {
125
+ // Missing or unparseable auth.json: fall through to the env var.
126
+ }
127
+ return process.env.HYPERCHARM_API_KEY || undefined;
128
+ }
129
+
130
+ const MODELS_API_URL = 'https://hyper.charm.land/v1/provider';
29
131
  const MODELS_JSON_PATH = path.join(__dirname, '..', 'models.json');
30
132
  const PATCH_JSON_PATH = path.join(__dirname, '..', 'patch.json');
31
133
  const CUSTOM_MODELS_JSON_PATH = path.join(__dirname, '..', 'custom-models.json');
@@ -76,6 +178,9 @@ function applyPatch(model, patch) {
76
178
  if (!result.reasoning && result.compat?.thinkingFormat) {
77
179
  delete result.compat.thinkingFormat;
78
180
  }
181
+ if (!result.reasoning && result.thinkingLevelMap) {
182
+ delete result.thinkingLevelMap;
183
+ }
79
184
  if (result.compat && Object.keys(result.compat).length === 0) {
80
185
  delete result.compat;
81
186
  }
@@ -102,91 +207,68 @@ function buildModels(baseModels, customModels, patchData) {
102
207
 
103
208
  // ─── Model transformation ─────────────────────────────────────────────────────
104
209
 
105
- // Known non-reasoning models (API incorrectly reports supports_reasoning: true)
106
- const NON_REASONING_IDS = new Set([
107
- 'llama-3.3-70b-instruct',
108
- 'llama-4-maverick-17b-128e-instruct-fp8',
109
- ]);
110
-
111
- // Known vision models
112
- const VISION_IDS = new Set([
113
- 'kimi-k2.5',
114
- 'kimi-k2.6',
115
- 'glm-5.1',
116
- 'gemma-4-26b-a4b-it',
117
- 'qwen3.6-flash',
118
- 'qwen3.6-max',
119
- 'qwen3.6-plus',
120
- 'qwen3.7-max',
121
- ]);
122
-
123
- function transformModel(apiModel, existingModelsMap) {
124
- const modelId = apiModel.id;
125
-
126
- // Preserve existing curated data
127
- if (existingModelsMap[modelId]) {
128
- const existing = { ...existingModelsMap[modelId] };
129
-
130
- // Update fields from API that may change
131
- const cost = apiModel.cost?.usd || {};
132
- const inputCost = convertPricing(cost['1m_in']);
133
- const outputCost = convertPricing(cost['1m_out']);
134
- const cacheReadCost = convertPricing(cost['1m_in_cache']);
135
- const cacheWriteCost = convertPricing(cost['1m_out_cache']);
136
-
137
- if (inputCost > 0) existing.cost.input = inputCost;
138
- if (outputCost > 0) existing.cost.output = outputCost;
139
- if (cacheReadCost > 0) existing.cost.cacheRead = cacheReadCost;
140
- if (cacheWriteCost > 0) existing.cost.cacheWrite = cacheWriteCost;
141
- if (apiModel.context_window) existing.contextWindow = apiModel.context_window;
142
- // Don't override maxTokens from API for DeepSeek — it reports 8000 but the
143
- // actual max is 384K (set in models.json / patch.json)
144
- if (apiModel.max_output_tokens && !/^deepseek-v/.test(modelId)) {
145
- existing.maxTokens = apiModel.max_output_tokens;
146
- }
147
-
148
- return existing;
210
+ const PI_THINKING_LEVELS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
211
+
212
+ // Charm's official extension treats a reasoning-capable model with no levels as
213
+ // a boolean on/off model: Pi's max level selects the single on state.
214
+ const ON_OFF_THINKING_LEVEL_MAP = {
215
+ off: 'off',
216
+ minimal: null,
217
+ low: null,
218
+ medium: null,
219
+ high: null,
220
+ xhigh: null,
221
+ max: 'max',
222
+ };
223
+
224
+ function buildThinkingLevelMap(levels) {
225
+ if (levels.length === 0) return undefined;
226
+ const available = new Set(levels);
227
+ const result = {
228
+ // The provider enum uses "none" for the off state on newer deployments;
229
+ // the official extension looked only for the older "off" spelling.
230
+ off: available.has('off') ? 'off' : available.has('none') ? 'none' : null,
231
+ };
232
+ for (const level of PI_THINKING_LEVELS) {
233
+ result[level] = available.has(level) ? level : null;
149
234
  }
235
+ return result;
236
+ }
150
237
 
151
- // New model — build from API data + curated defaults
152
- const cost = apiModel.cost?.usd || {};
153
- const isReasoning = apiModel.supports_reasoning === true && !NON_REASONING_IDS.has(modelId);
154
- const isVision = VISION_IDS.has(modelId);
155
- const isDeepSeek = /^deepseek-v/.test(modelId);
156
-
157
- const model = {
158
- id: modelId,
159
- name: apiModel.display_name || modelId,
160
- reasoning: isReasoning,
161
- input: isVision ? ['text', 'image'] : ['text'],
238
+ function transformModel(apiModel) {
239
+ const reasoningLevels = Array.isArray(apiModel.reasoning_levels)
240
+ ? apiModel.reasoning_levels.filter(level => typeof level === 'string')
241
+ : [];
242
+ const supportsReasoningEffort = reasoningLevels.length > 0;
243
+ const thinkingLevelMap = supportsReasoningEffort
244
+ ? buildThinkingLevelMap(reasoningLevels)
245
+ : apiModel.can_reason === true
246
+ ? ON_OFF_THINKING_LEVEL_MAP
247
+ : undefined;
248
+
249
+ return {
250
+ id: apiModel.id,
251
+ name: apiModel.name,
252
+ reasoning: apiModel.can_reason === true,
253
+ ...(thinkingLevelMap ? { thinkingLevelMap } : {}),
254
+ input: apiModel.supports_attachments === true ? ['text', 'image'] : ['text'],
162
255
  cost: {
163
- input: convertPricing(cost['1m_in']),
164
- output: convertPricing(cost['1m_out']),
165
- cacheRead: convertPricing(cost['1m_in_cache']),
166
- cacheWrite: convertPricing(cost['1m_out_cache']),
256
+ input: typeof apiModel.cost_per_1m_in === 'number' ? apiModel.cost_per_1m_in : 0,
257
+ output: typeof apiModel.cost_per_1m_out === 'number' ? apiModel.cost_per_1m_out : 0,
258
+ cacheRead: typeof apiModel.cost_per_1m_in_cached === 'number' ? apiModel.cost_per_1m_in_cached : 0,
259
+ // Hyper exposes discounted cached-output pricing, not cache-write pricing;
260
+ // pi's cost.cacheWrite field remains unused. This matches Charm's official extension.
261
+ cacheWrite: 0,
167
262
  },
168
263
  contextWindow: apiModel.context_window || 0,
169
- maxTokens: apiModel.max_output_tokens || 0,
170
- };
171
-
172
- // DeepSeek models: override maxTokens (API reports 8000, actual is 384K)
173
- // and add thinkingLevelMap + deepseek compat
174
- if (isDeepSeek && isReasoning) {
175
- model.maxTokens = 384000;
176
- model.thinkingLevelMap = {
177
- minimal: null, low: null, medium: null, high: 'high', max: 'max',
178
- };
179
- model.compat = {
264
+ maxTokens: apiModel.default_max_tokens || apiModel.context_window || 0,
265
+ compat: {
266
+ supportsStore: false,
267
+ supportsReasoningEffort,
180
268
  thinkingFormat: 'deepseek',
181
269
  maxTokensField: 'max_tokens',
182
- supportsDeveloperRole: true,
183
- supportsStore: false,
184
- supportsReasoningEffort: true,
185
- requiresReasoningContentOnAssistantMessages: true,
186
- };
187
- }
188
-
189
- return model;
270
+ },
271
+ };
190
272
  }
191
273
 
192
274
  // ─── README generation ────────────────────────────────────────────────────────
@@ -299,9 +381,9 @@ function updateDeprecatedModels(modelsJsonPath, newModels) {
299
381
  }
300
382
 
301
383
  async function main() {
302
- const apiKey = process.env.HYPERCHARM_API_KEY;
384
+ const apiKey = resolveApiKey();
303
385
  if (!apiKey) {
304
- console.error('Error: HYPERCHARM_API_KEY environment variable is required');
386
+ console.error('Error: No API key found: no `hypercharm` credential resolved from ' + AUTH_JSON_PATH + ' and HYPERCHARM_API_KEY is not set');
305
387
  console.error('Usage: HYPERCHARM_API_KEY=your-key node scripts/update-models.js');
306
388
  process.exit(1);
307
389
  }
@@ -317,7 +399,9 @@ async function main() {
317
399
  }
318
400
 
319
401
  const apiResponse = await response.json();
320
- const apiModels = apiResponse.data || apiResponse;
402
+ const apiModels = Array.isArray(apiResponse)
403
+ ? apiResponse
404
+ : (apiResponse.models || apiResponse.data || []);
321
405
 
322
406
  if (!Array.isArray(apiModels)) {
323
407
  throw new Error('API response does not contain an array of models');
@@ -338,16 +422,12 @@ async function main() {
338
422
  }
339
423
 
340
424
  // Transform models from API, preserving existing curated data
341
- let apiTransformed = apiModels.map(m => transformModel(m, existingModelsMap));
425
+ let apiTransformed = apiModels.map(m => transformModel(m));
342
426
  apiTransformed.sort((a, b) => a.name.localeCompare(b.name));
343
427
 
344
- // Log new models (not in patch.json)
428
+ // Load patch overrides for README rendering. Canonical metadata already
429
+ // comes from /v1/provider, so new models do not require a patch entry.
345
430
  const patch = loadJson(PATCH_JSON_PATH);
346
- for (const m of apiTransformed) {
347
- if (!patch[m.id]) {
348
- console.log(` 🆕 New model: ${m.id} (${m.name}) — add to patch.json for compat overrides`);
349
- }
350
- }
351
431
 
352
432
  // Update models.json — curated API data
353
433
  // Move delisted models to deprecated-models.json BEFORE models.json is overwritten