pi-hypercharm-provider 1.0.3

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,4 @@
1
+ github: monotykamary
2
+ ko_fi: monotykamary
3
+ buy_me_a_coffee: monotykamary
4
+ polar: monotykamary
@@ -0,0 +1 @@
1
+ 019eb090-74e4-7e86-998f-031e8113fb88
package/AGENTS.md ADDED
@@ -0,0 +1,56 @@
1
+ # AGENTS.md
2
+
3
+ ## DO NOT EDIT — Auto-generated Files
4
+
5
+ The following files are **idempotent** and regenerated by `scripts/update-models.js`. Never edit them directly — your changes will be overwritten on the next model sync.
6
+
7
+ | File | Why it's auto-generated |
8
+ |------|------------------------|
9
+ | `models.json` | Built from the provider API. `update-models.js` fetches models, preserves curated data for known IDs, and writes this file. |
10
+ | `README.md` (model table) | The table under `## Available Models` is replaced in-place by `update-models.js` after merging base models → patch → custom models. |
11
+
12
+ ## Correct Files to Edit
13
+
14
+ When a model needs overrides, new properties, or corrections, edit the appropriate source file below. These are the **source of truth** that the update script reads but never writes.
15
+
16
+ | File | Purpose |
17
+ |------|---------|
18
+ | `patch.json` | Per-model overrides keyed by model ID. Add reasoning flags, compat settings, pricing corrections, thinking level maps, etc. Applied on top of `models.json` at runtime and for README generation. |
19
+ | `custom-models.json` | Models that don't exist in the provider API (hidden models, router endpoints, cross-provider aliases). Merged after patch. Format: array of full model objects (same schema as `models.json` entries). |
20
+ | `index.ts` | Provider extension code. |
21
+ | `scripts/update-models.js` | The sync script itself (edit only if changing how models are fetched/transformed). |
22
+
23
+ ## Data Flow
24
+
25
+ ```
26
+ Provider API ──fetch──► models.json ──apply──► patch.json ──merge──► custom-models.json
27
+ │ │ │
28
+ └────────────────────────────┴──────────────────────┘
29
+
30
+ README model table
31
+ ```
32
+
33
+ 1. `models.json` — base data from the provider API (auto-generated, DO NOT EDIT)
34
+ 2. `patch.json` — overrides applied on top (EDIT THIS for corrections/enrichments)
35
+ 3. `custom-models.json` — additional models not in the API (EDIT THIS for new models)
36
+ 4. README table — rendered from the merged result of all three (auto-generated, DO NOT EDIT)
37
+
38
+ ## Common Tasks
39
+
40
+ ### Add a compat setting or override pricing for an existing model
41
+ → Edit `patch.json`. Add an entry keyed by the model's `id`.
42
+
43
+ ### Add a model not available in the provider API
44
+ → Edit `custom-models.json`. Add a full model object to the array.
45
+
46
+ ### Update models from the provider API
47
+ → Run `node scripts/update-models.js` (may require an API key env var).
48
+
49
+ ### Regenerate the README model table
50
+ → Run `node scripts/update-models.js` — it updates both `models.json` and the README table.
51
+
52
+ ## TL;DR
53
+
54
+ - **Never edit `models.json`** — edit `patch.json` instead.
55
+ - **Never edit the README model table** — run the update script instead.
56
+ - `patch.json` and `custom-models.json` are the source files you should modify.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,208 @@
1
+ <div align="center">
2
+
3
+ # ✨ pi-hypercharm-provider
4
+
5
+ **17+ models through [Charm Hyper](https://hyper.charm.land/)**
6
+
7
+ _Hyperoptimized coding models — DeepSeek, GLM, Kimi, Qwen, MiniMax, Gemma, GPT-OSS, and Llama for [pi](https://github.com/earendil-works/pi-coding-agent)._
8
+
9
+ [![pi extension](https://img.shields.io/badge/pi-extension-blueviolet)](https://github.com/earendil-works/pi-coding-agent)
10
+ [![license](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
11
+
12
+ </div>
13
+
14
+ ---
15
+
16
+ ## Features
17
+
18
+ - **20+ AI Models** including DeepSeek V4 Flash/Pro, GLM 5/5.1, Kimi K2.5/K2.6, Qwen3.6/3.7, MiniMax M2.7, Gemma 4, GPT-OSS, and Llama
19
+ - **DeepSeek Native Thinking** — Uses the `deepseek` thinking format with `reasoning_effort` and 384K max output for DeepSeek V4 models
20
+ - **OpenAI-compatible API** via Charm Hyper's `/v1/chat/completions` endpoint
21
+ - **Cost Tracking** with per-model pricing from the API
22
+ - **Reasoning Models** with `reasoning_effort` parameter support on select models
23
+ - **Vision Support** for image-capable models (Kimi K2.5/K2.6, GLM 5.1, Qwen3.6/3.7, Gemma 4)
24
+
25
+ ## Available Models
26
+
27
+ | Model | Type | Context | Max Tokens | Input Cost | Output Cost |
28
+ |-------|------|---------|------------|------------|-------------|
29
+ | DeepSeek V4 Flash | Text | 1.0M | 384K | $0.14 | $0.28 |
30
+ | DeepSeek V4 Pro | Text | 1.0M | 384K | $0.43 | $0.87 |
31
+ | Gemma 4 26B A4B | Text + Image | 256K | 26K | $0.13 | $0.42 |
32
+ | GLM 5.1 | Text + Image | 202K | 202K | $1.30 | $4.30 |
33
+ | GLM-5 | Text | 203K | 20K | $0.81 | $2.62 |
34
+ | GLM-5.2 | Text | 1.0M | 131K | $1.40 | $4.40 |
35
+ | GPT-OSS 120B | Text | 131K | 13K | $0.17 | $0.56 |
36
+ | Kimi K2.5 | Text + Image | 262K | 26K | $0.49 | $2.63 |
37
+ | Kimi K2.6 | Text + Image | 262K | 262K | $0.95 | $4.00 |
38
+ | Kimi K2.7 Code | Text | 256K | 33K | $0.95 | $4.00 |
39
+ | Llama 3.3 70B Instruct | Text | 128K | 13K | $0.55 | $1.07 |
40
+ | Llama 4 Maverick 17B 128E FP8 | Text | 430K | 43K | $0.27 | $0.89 |
41
+ | MiniMax M2.7 | Text | 205K | 131K | $0.30 | $1.20 |
42
+ | Qwen3 Coder 480B A35B INT4 | Text | 106K | 11K | $0.56 | $2.54 |
43
+ | Qwen3 Next 80B A3B | Text | 262K | 26K | $0.13 | $1.28 |
44
+ | Qwen3.6-Flash | Text + Image | 1.0M | 64K | $1.00 | $4.00 |
45
+ | Qwen3.6-Max | Text + Image | 256K | 64K | $2.00 | $12.00 |
46
+ | Qwen3.6-Plus | Text + Image | 1.0M | 64K | $2.00 | $6.00 |
47
+ | Qwen3.7-Max | Text + Image | 1.0M | 64K | $2.50 | $7.50 |
48
+ | Qwen3.7-Plus | Text | 1.0M | 64K | $1.20 | $4.80 |
49
+ *Costs are per million tokens. Prices subject to change — check [hyper.charm.land](https://hyper.charm.land) for current pricing.*
50
+
51
+ ## Installation
52
+
53
+ ### Option 1: Using `pi install` (Recommended)
54
+
55
+ Install directly from GitHub:
56
+
57
+ ```bash
58
+ pi install https://github.com/monotykamary/pi-hypercharm-provider
59
+ ```
60
+
61
+ Then set your API key and run pi:
62
+ ```bash
63
+ # Recommended: add to auth.json
64
+ # See Authentication section below
65
+
66
+ # Or set as environment variable
67
+ export HYPERCHARM_API_KEY=your-api-key-here
68
+
69
+ pi
70
+ ```
71
+
72
+ Get your API key from [hyper.charm.land](https://hyper.charm.land).
73
+
74
+ ### Option 2: Manual Clone
75
+
76
+ 1. Clone this repository:
77
+ ```bash
78
+ git clone https://github.com/monotykamary/pi-hypercharm-provider.git
79
+ cd pi-hypercharm-provider
80
+ ```
81
+
82
+ 2. Set your HyperCharm API key:
83
+ ```bash
84
+ # Recommended: add to auth.json
85
+ # See Authentication section below
86
+
87
+ # Or set as environment variable
88
+ export HYPERCHARM_API_KEY=your-api-key-here
89
+ ```
90
+
91
+ 3. Run pi with the extension:
92
+ ```bash
93
+ pi -e /path/to/pi-hypercharm-provider
94
+ ```
95
+
96
+ ## Usage
97
+
98
+ After loading the extension, select a model with:
99
+
100
+ ```
101
+ /model hypercharm deepseek-v4-pro
102
+ ```
103
+
104
+ Or start pi directly with a HyperCharm model:
105
+
106
+ ```bash
107
+ pi --provider hypercharm --model deepseek-v4-pro
108
+ ```
109
+
110
+ ### Reasoning Effort
111
+
112
+ For reasoning models that support `reasoning_effort`, control thinking depth:
113
+
114
+ ```bash
115
+ pi --provider hypercharm --model deepseek-v4-pro --reasoning-effort max
116
+ ```
117
+
118
+ Values: `none`, `low`, `medium`, `high`, `max`
119
+
120
+ ### Thinking Mode
121
+
122
+ DeepSeek V4 models use the `deepseek` thinking format — the same native format as the [pi-deepseek-provider](https://github.com/monotykamary/pi-deepseek-provider). This sends `thinking: {type: "enabled/disabled"}` plus `reasoning_effort` mapped via `thinkingLevelMap` (`high` → `"high"`, `max` → `"max"`). Replayed assistant messages include empty `reasoning_content` as required by DeepSeek's API.
123
+
124
+ ## Authentication
125
+
126
+ The HyperCharm API key can be configured in multiple ways (resolved in this order):
127
+
128
+ 1. **`auth.json`** (recommended) — Add to `~/.pi/agent/auth.json`:
129
+ ```json
130
+ { "hypercharm": { "type": "api_key", "key": "your-api-key" } }
131
+ ```
132
+ The `key` field supports literal values, env var names, and shell commands (prefix with `!`). See [pi's auth file docs](https://github.com/badlogic/pi-mono) for details.
133
+ 2. **Runtime override** — Use the `--api-key` CLI flag
134
+ 3. **Environment variable** — Set `HYPERCHARM_API_KEY`
135
+
136
+ Get your API key from [hyper.charm.land](https://hyper.charm.land).
137
+
138
+ ## Environment Variables
139
+
140
+ | Variable | Required | Description |
141
+ |----------|----------|-------------|
142
+ | `HYPERCHARM_API_KEY` | No | Your Charm Hyper API key (fallback if not in auth.json) |
143
+
144
+ ## Configuration
145
+
146
+ Add to your pi configuration for automatic loading:
147
+
148
+ ```json
149
+ {
150
+ "extensions": [
151
+ "/path/to/pi-hypercharm-provider"
152
+ ]
153
+ }
154
+ ```
155
+
156
+ ### Compat Settings
157
+
158
+ Charm Hyper uses an OpenAI-compatible API. Compatibility settings are managed via `patch.json`:
159
+
160
+ **DeepSeek models** use the `deepseek` thinking format (identical to the native DeepSeek API):
161
+
162
+ - **`thinkingFormat: "deepseek"`** — Sends `thinking: {type: "enabled/disabled"}` to toggle thinking mode
163
+ - **`supportsReasoningEffort: true`** — Supports `reasoning_effort: "high" | "max"` via `thinkingLevelMap`
164
+ - **`requiresReasoningContentOnAssistantMessages: true`** — Replayed assistant messages include empty `reasoning_content` when reasoning is enabled
165
+ - **`maxTokensField: "max_tokens"`** — Uses `max_tokens` (not `max_completion_tokens`)
166
+
167
+ **Other reasoning models** (GLM, Kimi, Qwen, MiniMax, Gemma, GPT-OSS) use the `openai` thinking format:
168
+
169
+ - **`thinkingFormat: "openai"`** — Returns `reasoning_content` in OpenAI format
170
+ - **`supportsReasoningEffort: true`** — Accepts `reasoning_effort` parameter on supported models
171
+ - **`maxTokensField: "max_tokens"`** — Uses `max_tokens` (not `max_completion_tokens`)
172
+ - **`supportsDeveloperRole: true`** — Developer role messages are accepted
173
+ - **`supportsStore: false`** — The `store` parameter is not supported
174
+
175
+ > **Note:** The HyperCharm `/v1/models` endpoint reports `supports_reasoning: true` for all models,
176
+ > including those that don't natively support extended thinking (e.g., Llama 3.3 70B). `models.json`
177
+ > curates reasoning flags based on known model capabilities; `patch.json` adds compat overrides.
178
+
179
+ ### Patch Overrides
180
+
181
+ The `patch.json` file contains overrides applied on top of `models.json` data:
182
+
183
+ - Correcting API-derived values (e.g., DeepSeek `maxTokens` from 8000 to 384000)
184
+ - Adding compat settings that the API doesn't provide
185
+ - Setting `thinkingFormat: "deepseek"` + `thinkingLevelMap` for DeepSeek V4 models
186
+ - Overriding pricing when official rates change
187
+
188
+ ## Updating Models
189
+
190
+ Run the update script to fetch the latest models from the HyperCharm API:
191
+
192
+ ```bash
193
+ export HYPERCHARM_API_KEY=your-api-key
194
+ node scripts/update-models.js
195
+ ```
196
+
197
+ This will:
198
+ 1. Fetch models from `https://hyper.charm.land/v1/models`
199
+ 2. Preserve curated data (reasoning, vision, DeepSeek compat) from existing `models.json`
200
+ 3. Apply overrides from `patch.json`
201
+ 4. Remove custom models now available upstream from `custom-models.json`
202
+ 5. Update `models.json` and the README model table
203
+
204
+ A GitHub Actions workflow runs this daily and creates a PR if models have changed.
205
+
206
+ ## License
207
+
208
+ MIT
@@ -0,0 +1 @@
1
+ []
package/index.ts ADDED
@@ -0,0 +1,371 @@
1
+ /**
2
+ * HyperCharm Provider Extension
3
+ *
4
+ * Registers HyperCharm (hyper.charm.land) as a custom provider using the
5
+ * openai-completions API. Base URL: https://hyper.charm.land/v1
6
+ *
7
+ * HyperCharm provides hyperoptimized coding models via an OpenAI-compatible API.
8
+ * The /v1/models endpoint returns structured metadata including reasoning flags,
9
+ * pricing, context windows, and max output tokens.
10
+ *
11
+ * Note: The API's `supports_reasoning` flag is unreliable for some models (e.g.,
12
+ * it reports true for Llama 3.3 70B which doesn't support extended thinking).
13
+ * The models.json embeds curated reasoning flags; patch.json corrects compat.
14
+ *
15
+ * Model resolution strategy: Stale-While-Revalidate
16
+ * 1. Serve stale immediately: disk cache → embedded models.json (zero-latency)
17
+ * 2. Revalidate in background: live API /v1/models → merge with embedded → cache → hot-swap
18
+ * 3. patch.json + custom-models.json applied on top of whichever source won
19
+ *
20
+ * Merge order: [live|cache|embedded] → apply patch.json → merge custom-models.json
21
+ *
22
+ * Usage:
23
+ * # Option 1: Store in auth.json (recommended)
24
+ * # Add to ~/.pi/agent/auth.json:
25
+ * # "hypercharm": { "type": "api_key", "key": "your-api-key" }
26
+ *
27
+ * # Option 2: Set as environment variable
28
+ * export HYPERCHARM_API_KEY=your-api-key
29
+ *
30
+ * # Run pi with the extension
31
+ * pi -e /path/to/pi-hypercharm-provider
32
+ *
33
+ * Then use /model to select from available models.
34
+ *
35
+ * @see https://hyper.charm.land
36
+ */
37
+
38
+ import { getAgentDir, type ExtensionAPI, type ModelRegistry } from "@earendil-works/pi-coding-agent";
39
+ import modelsData from "./models.json" with { type: "json" };
40
+ import customModelsData from "./custom-models.json" with { type: "json" };
41
+ import patchData from "./patch.json" with { type: "json" };
42
+ import fs from "fs";
43
+ import path from "path";
44
+
45
+ // ─── Types ────────────────────────────────────────────────────────────────────
46
+
47
+ interface JsonModel {
48
+ id: string;
49
+ name: string;
50
+ reasoning: boolean;
51
+ input: string[];
52
+ cost: {
53
+ input: number;
54
+ output: number;
55
+ cacheRead: number;
56
+ cacheWrite: number;
57
+ };
58
+ contextWindow: number;
59
+ maxTokens: number;
60
+ thinkingLevelMap?: Record<string, string | null>;
61
+ compat?: {
62
+ supportsDeveloperRole?: boolean;
63
+ supportsStore?: boolean;
64
+ maxTokensField?: "max_completion_tokens" | "max_tokens";
65
+ thinkingFormat?: "openai" | "zai" | "qwen" | "qwen-chat-template" | "deepseek";
66
+ supportsReasoningEffort?: boolean;
67
+ requiresReasoningContentOnAssistantMessages?: boolean;
68
+ };
69
+ }
70
+
71
+ interface PatchEntry {
72
+ name?: string;
73
+ reasoning?: boolean;
74
+ input?: string[];
75
+ cost?: {
76
+ input?: number;
77
+ output?: number;
78
+ cacheRead?: number;
79
+ cacheWrite?: number;
80
+ };
81
+ contextWindow?: number;
82
+ maxTokens?: number;
83
+ thinkingLevelMap?: Record<string, string | null>;
84
+ compat?: Record<string, unknown>;
85
+ }
86
+
87
+ type PatchData = Record<string, PatchEntry>;
88
+
89
+ // ─── Patch Application ────────────────────────────────────────────────────────
90
+
91
+ function applyPatch(model: JsonModel, patch: PatchEntry): JsonModel {
92
+ const result = { ...model };
93
+
94
+ if (patch.name !== undefined) result.name = patch.name;
95
+ if (patch.reasoning !== undefined) result.reasoning = patch.reasoning;
96
+ if (patch.input !== undefined) result.input = patch.input;
97
+ if (patch.contextWindow !== undefined) result.contextWindow = patch.contextWindow;
98
+ if (patch.maxTokens !== undefined) result.maxTokens = patch.maxTokens;
99
+ if (patch.thinkingLevelMap !== undefined) result.thinkingLevelMap = { ...patch.thinkingLevelMap };
100
+
101
+ if (patch.cost) {
102
+ result.cost = {
103
+ input: patch.cost.input ?? result.cost.input,
104
+ output: patch.cost.output ?? result.cost.output,
105
+ cacheRead: patch.cost.cacheRead ?? result.cost.cacheRead,
106
+ cacheWrite: patch.cost.cacheWrite ?? result.cost.cacheWrite,
107
+ };
108
+ }
109
+ if (patch.compat) {
110
+ result.compat = { ...(result.compat || {}), ...patch.compat };
111
+ }
112
+
113
+ if (!result.reasoning && result.compat?.thinkingFormat) {
114
+ delete result.compat.thinkingFormat;
115
+ }
116
+ if (result.compat && Object.keys(result.compat).length === 0) {
117
+ delete result.compat;
118
+ }
119
+
120
+ return result;
121
+ }
122
+
123
+ /** Full pipeline: base models → patch → custom → result */
124
+ function buildModels(base: JsonModel[], custom: JsonModel[], patch: PatchData): JsonModel[] {
125
+ const modelMap = new Map<string, JsonModel>();
126
+
127
+ for (const model of base) {
128
+ modelMap.set(model.id, model);
129
+ }
130
+
131
+ for (const [id, patchEntry] of Object.entries(patch)) {
132
+ const existing = modelMap.get(id);
133
+ if (existing) {
134
+ modelMap.set(id, applyPatch(existing, patchEntry));
135
+ }
136
+ }
137
+
138
+ for (const model of custom) {
139
+ const existing = modelMap.get(model.id);
140
+ const patchEntry = patch[model.id];
141
+ if (existing && patchEntry) {
142
+ modelMap.set(model.id, applyPatch(model, patchEntry));
143
+ } else if (existing) {
144
+ modelMap.set(model.id, model);
145
+ } else if (patchEntry) {
146
+ modelMap.set(model.id, applyPatch(model, patchEntry));
147
+ } else {
148
+ modelMap.set(model.id, model);
149
+ }
150
+ }
151
+
152
+ const result = Array.from(modelMap.values());
153
+
154
+ // Ensure DeepSeek reasoning models have required compat settings.
155
+ // Live-fetched models from the SWR pipeline may not have these set.
156
+ for (const model of result) {
157
+ if (!model.reasoning) continue;
158
+ if (isDeepSeekModel(model.id)) {
159
+ if (!model.compat) {
160
+ model.compat = {
161
+ thinkingFormat: "deepseek",
162
+ maxTokensField: "max_tokens",
163
+ supportsDeveloperRole: true,
164
+ supportsStore: false,
165
+ supportsReasoningEffort: true,
166
+ requiresReasoningContentOnAssistantMessages: true,
167
+ };
168
+ } else {
169
+ if (model.compat.thinkingFormat === undefined) {
170
+ model.compat.thinkingFormat = "deepseek";
171
+ }
172
+ if (model.compat.supportsReasoningEffort === undefined) {
173
+ model.compat.supportsReasoningEffort = true;
174
+ }
175
+ if ((model.compat as any).requiresReasoningContentOnAssistantMessages === undefined) {
176
+ (model.compat as any).requiresReasoningContentOnAssistantMessages = true;
177
+ }
178
+ }
179
+ if (!model.thinkingLevelMap) {
180
+ model.thinkingLevelMap = {
181
+ minimal: null, low: null, medium: null, high: "high", xhigh: "max",
182
+ };
183
+ }
184
+ }
185
+ }
186
+
187
+ return result;
188
+ }
189
+
190
+ function isDeepSeekModel(id: string): boolean {
191
+ return /^deepseek-v/.test(id);
192
+ }
193
+
194
+ // ─── Stale-While-Revalidate Model Sync ────────────────────────────────────────
195
+
196
+ const PROVIDER_ID = "hypercharm";
197
+ const BASE_URL = "https://hyper.charm.land/v1";
198
+ const MODELS_URL = `${BASE_URL}/models`;
199
+ const CACHE_DIR = path.join(getAgentDir(), "cache");
200
+ const CACHE_PATH = path.join(CACHE_DIR, `${PROVIDER_ID}-models.json`);
201
+ const LIVE_FETCH_TIMEOUT_MS = 8000;
202
+
203
+ /** Transform a model from the HyperCharm /v1/models API to JsonModel format. */
204
+ function transformApiModel(apiModel: any): JsonModel | null {
205
+ if (!apiModel.id) return null;
206
+
207
+ const cost = apiModel.cost?.usd || {};
208
+ const toPerM = (v: any) => {
209
+ const n = typeof v === "string" ? parseFloat(v) : (v || 0);
210
+ // API returns $/M directly; round to 6 decimals to preserve sub-cent cache prices.
211
+ return Math.round(n * 1e6) / 1e6;
212
+ };
213
+
214
+ return {
215
+ id: apiModel.id,
216
+ name: apiModel.display_name || apiModel.id,
217
+ reasoning: false, // API supports_reasoning is unreliable; patch.json corrects
218
+ input: ["text"],
219
+ cost: {
220
+ input: toPerM(cost["1m_in"]),
221
+ output: toPerM(cost["1m_out"]),
222
+ cacheRead: toPerM(cost["1m_in_cache"]),
223
+ cacheWrite: toPerM(cost["1m_out_cache"]),
224
+ },
225
+ contextWindow: apiModel.context_window || 0,
226
+ maxTokens: apiModel.max_output_tokens || 0,
227
+ };
228
+ }
229
+
230
+ async function fetchLiveModels(apiKey: string, signal?: AbortSignal): Promise<JsonModel[] | null> {
231
+ try {
232
+ const response = await fetch(MODELS_URL, {
233
+ headers: { Authorization: `Bearer ${apiKey}` },
234
+ signal: signal ? AbortSignal.any([AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS), signal]) : AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS),
235
+ });
236
+ if (!response.ok) return null;
237
+ const data = await response.json();
238
+ const apiModels = Array.isArray(data) ? data : (data.data || []);
239
+ if (!Array.isArray(apiModels) || apiModels.length === 0) return null;
240
+ return apiModels.map(transformApiModel).filter((m): m is JsonModel => m !== null);
241
+ } catch {
242
+ return null;
243
+ }
244
+ }
245
+
246
+ function loadCachedModels(): JsonModel[] | null {
247
+ try {
248
+ const data = JSON.parse(fs.readFileSync(CACHE_PATH, "utf8"));
249
+ return Array.isArray(data) ? data : null;
250
+ } catch {
251
+ return null;
252
+ }
253
+ }
254
+
255
+ function cacheModels(models: JsonModel[]): void {
256
+ try {
257
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
258
+ fs.writeFileSync(CACHE_PATH, JSON.stringify(models, null, 2) + "\n");
259
+ } catch {
260
+ // Cache write failure is non-fatal
261
+ }
262
+ }
263
+
264
+ function mergeWithEmbedded(liveModels: JsonModel[], embeddedModels: JsonModel[]): JsonModel[] {
265
+ const embeddedMap = new Map(embeddedModels.map(m => [m.id, m]));
266
+ const seen = new Set<string>();
267
+ const result: JsonModel[] = [];
268
+ for (const liveModel of liveModels) {
269
+ const embedded = embeddedMap.get(liveModel.id);
270
+ seen.add(liveModel.id);
271
+ if (embedded) {
272
+ // Self-heal: live API pricing is authoritative field-by-field. Prefer the
273
+ // live cost when the API reports it (non-zero); fall back to embedded when
274
+ // the API is silent (0) so curated cacheRead/cacheWrite isn't clobbered and
275
+ // providers whose /models endpoint exposes no pricing keep their curated
276
+ // cost. Curation (reasoning/input/compat/name) still wins via ...embedded.
277
+ result.push({
278
+ ...liveModel,
279
+ ...embedded,
280
+ cost: {
281
+ input: liveModel.cost.input || embedded.cost.input,
282
+ output: liveModel.cost.output || embedded.cost.output,
283
+ cacheRead: liveModel.cost.cacheRead || embedded.cost.cacheRead,
284
+ cacheWrite: liveModel.cost.cacheWrite || embedded.cost.cacheWrite,
285
+ },
286
+ contextWindow: liveModel.contextWindow || embedded.contextWindow,
287
+ });
288
+ } else {
289
+ result.push(liveModel);
290
+ }
291
+ }
292
+ // Append any embedded models that the live API didn't return
293
+ for (const em of embeddedModels) {
294
+ if (!seen.has(em.id)) {
295
+ result.push(em);
296
+ }
297
+ }
298
+ return result;
299
+ }
300
+
301
+ function loadStaleModels(embeddedModels: JsonModel[]): JsonModel[] {
302
+ const cached = loadCachedModels();
303
+ if (!cached || cached.length === 0) return embeddedModels;
304
+
305
+ // Merge embedded models that are missing from cache (newly added models)
306
+ const cachedMap = new Map(cached.map(m => [m.id, m]));
307
+ for (const em of embeddedModels) {
308
+ if (!cachedMap.has(em.id)) {
309
+ cached.push(em);
310
+ }
311
+ }
312
+ return cached;
313
+ }
314
+
315
+ async function revalidateModels(apiKey: string | undefined, embeddedModels: JsonModel[], signal?: AbortSignal): Promise<JsonModel[] | null> {
316
+ if (!apiKey) return null;
317
+ const liveModels = await fetchLiveModels(apiKey, signal);
318
+ if (!liveModels || liveModels.length === 0) return null;
319
+ const merged = mergeWithEmbedded(liveModels, embeddedModels);
320
+ cacheModels(merged);
321
+ return merged;
322
+ }
323
+
324
+ // ─── API Key Resolution (via ModelRegistry) ────────────────────────────────────
325
+
326
+ let cachedApiKey: string | undefined;
327
+ let revalidateAbort: AbortController | null = null;
328
+
329
+ async function resolveApiKey(modelRegistry: ModelRegistry): Promise<void> {
330
+ cachedApiKey = await modelRegistry.getApiKeyForProvider("hypercharm") ?? undefined;
331
+ }
332
+
333
+ // ─── Extension Entry Point ────────────────────────────────────────────────────
334
+
335
+ export default function (pi: ExtensionAPI) {
336
+ const embeddedModels = modelsData as JsonModel[];
337
+ const customModels = customModelsData as JsonModel[];
338
+ const patches = patchData as PatchData;
339
+
340
+ const staleBase = loadStaleModels(embeddedModels);
341
+ const staleModels = buildModels(staleBase, customModels, patches);
342
+
343
+ pi.registerProvider("hypercharm", {
344
+ baseUrl: BASE_URL,
345
+ apiKey: "$HYPERCHARM_API_KEY",
346
+ api: "openai-completions",
347
+ models: staleModels,
348
+ });
349
+
350
+ pi.on("session_start", async (_event, ctx) => {
351
+ revalidateAbort?.abort();
352
+ revalidateAbort = new AbortController();
353
+ const signal = revalidateAbort.signal;
354
+ resolveApiKey(ctx.modelRegistry).then(() => {
355
+ revalidateModels(cachedApiKey, embeddedModels, signal).then((freshBase) => {
356
+ if (freshBase && !signal.aborted) {
357
+ pi.registerProvider("hypercharm", {
358
+ baseUrl: BASE_URL,
359
+ apiKey: "$HYPERCHARM_API_KEY",
360
+ api: "openai-completions",
361
+ models: buildModels(freshBase, customModels, patches),
362
+ });
363
+ }
364
+ });
365
+ });
366
+ });
367
+
368
+ pi.on("session_shutdown", () => {
369
+ revalidateAbort?.abort();
370
+ });
371
+ }