pi-tensorix-provider 1.0.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,4 @@
1
+ github: monotykamary
2
+ ko_fi: monotykamary
3
+ buy_me_a_coffee: monotykamary
4
+ polar: monotykamary
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,186 @@
1
+ <div align="center">
2
+
3
+ # 🧮 pi-tensorix-provider
4
+
5
+ **Free models during preview via [Tensorix](https://tensorix.ai)**
6
+
7
+ _32 models — DeepSeek, Kimi, GLM, Qwen, MiniMax, Llama — all **free** 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
+ - **OpenAI-compatible API** - Uses Tensorix's `/v1/chat/completions` endpoint
19
+ - **Reasoning models** - Support for thinking models with `reasoning_effort` parameter
20
+ - **Vision models** - Image input support on Kimi K2.5, Kimi K2.6, GLM 5V Turbo, and Qwen3 VL
21
+ - **Tool use** - Function calling support
22
+ - **Streaming** - Real-time token streaming
23
+ - **Free tier** - All models available at no cost during preview
24
+
25
+ ## Available Models
26
+
27
+ | Model | Context | Vision | Reasoning | Input $/M | Output $/M |
28
+ |-------|---------|--------|-----------|-----------|------------|
29
+ | Chatterbox Turbo | 131K | ❌ | ❌ | **Free** | **Free** |
30
+ | Deepseek Chat V3 0324 | 131K | ❌ | ❌ | **Free** | **Free** |
31
+ | Deepseek Chat V3.1 | 131K | ❌ | ❌ | **Free** | **Free** |
32
+ | Deepseek R1 0528 | 131K | ❌ | ✅ | **Free** | **Free** |
33
+ | Deepseek V3.2 | 164K | ❌ | ✅ | **Free** | **Free** |
34
+ | Deepseek V4 Flash | 1.0M | ❌ | ✅ | **Free** | **Free** |
35
+ | Deepseek V4 Flash Backup | 131K | ❌ | ❌ | **Free** | **Free** |
36
+ | Deepseek V4 Pro | 1.0M | ❌ | ✅ | **Free** | **Free** |
37
+ | Glm 4.6 | 131K | ❌ | ❌ | **Free** | **Free** |
38
+ | Glm 4.7 | 203K | ❌ | ✅ | **Free** | **Free** |
39
+ | Glm 5 | 203K | ❌ | ✅ | **Free** | **Free** |
40
+ | Glm 5 Turbo | 203K | ❌ | ✅ | **Free** | **Free** |
41
+ | Glm 5.1 | 203K | ❌ | ✅ | **Free** | **Free** |
42
+ | Glm 5.2 | 131K | ❌ | ❌ | **Free** | **Free** |
43
+ | Glm 5v Turbo | 203K | ✅ | ✅ | **Free** | **Free** |
44
+ | Gpt Oss 120b | 131K | ❌ | ✅ | **Free** | **Free** |
45
+ | Gpt Oss 20b | 131K | ❌ | ✅ | **Free** | **Free** |
46
+ | Kimi K2.5 | 262K | ✅ | ✅ | **Free** | **Free** |
47
+ | Kimi K2.6 | 262K | ✅ | ✅ | **Free** | **Free** |
48
+ | Kimi K2.6 | 262K | ✅ | ✅ | **Free** | **Free** |
49
+ | Kimi K2.7 Code | 131K | ❌ | ❌ | **Free** | **Free** |
50
+ | Llama 3.3 70b Instruct | 131K | ❌ | ❌ | **Free** | **Free** |
51
+ | Llama 4 Maverick | 1.0M | ❌ | ❌ | **Free** | **Free** |
52
+ | Minimax M2 | 131K | ❌ | ❌ | **Free** | **Free** |
53
+ | Minimax M2.1 | 131K | ❌ | ❌ | **Free** | **Free** |
54
+ | Minimax M2.5 | 205K | ❌ | ✅ | **Free** | **Free** |
55
+ | Minimax M2.7 | 205K | ❌ | ✅ | **Free** | **Free** |
56
+ | Minimax M3 | 131K | ❌ | ❌ | **Free** | **Free** |
57
+ | Nemotron 3 Super 120b A12b | 4K | ❌ | ❌ | **Free** | **Free** |
58
+ | Qwen 2.5 72b Instruct | 131K | ❌ | ❌ | **Free** | **Free** |
59
+ | Qwen3 235b A22b 2507 | 262K | ❌ | ✅ | **Free** | **Free** |
60
+ | Qwen3 Coder 30b A3b Instruct | 262K | ❌ | ✅ | **Free** | **Free** |
61
+ | Qwen3 Vl 235b A22b Instruct | 262K | ✅ | ✅ | **Free** | **Free** |
62
+ | Qwen3.5 122b A10b | 262K | ❌ | ✅ | **Free** | **Free** |
63
+ | Qwen3.5 9b | 262K | ❌ | ✅ | **Free** | **Free** |
64
+
65
+ ## Installation
66
+
67
+ ### Option 1: Using `pi install` (Recommended)
68
+
69
+ Install directly from GitHub:
70
+
71
+ ```bash
72
+ pi install https://github.com/monotykamary/pi-tensorix-provider
73
+ ```
74
+
75
+ Then set your API key and run pi:
76
+ ```bash
77
+ # Recommended: add to auth.json
78
+ # See Authentication section below
79
+
80
+ # Or set as environment variable
81
+ export TENSORIX_API_KEY=your-api-key-here
82
+
83
+ pi
84
+ ```
85
+
86
+ Get your API key from [tensorix.ai](https://tensorix.ai).
87
+
88
+ ### Option 2: Manual Clone
89
+
90
+ 1. Clone this repository:
91
+ ```bash
92
+ git clone https://github.com/monotykamary/pi-tensorix-provider.git
93
+ cd pi-tensorix-provider
94
+ ```
95
+
96
+ 2. Set your Tensorix API key:
97
+ ```bash
98
+ # Recommended: add to auth.json
99
+ # See Authentication section below
100
+
101
+ # Or set as environment variable
102
+ export TENSORIX_API_KEY=your-api-key-here
103
+ ```
104
+
105
+ 3. Run pi with the extension:
106
+ ```bash
107
+ pi -e /path/to/pi-tensorix-provider
108
+ ```
109
+
110
+ ## Authentication
111
+
112
+ The Tensorix API key can be configured in multiple ways (resolved in this order):
113
+
114
+ 1. **`auth.json`** (recommended) — Add to `~/.pi/agent/auth.json`:
115
+ ```json
116
+ { "tensorix": { "type": "api_key", "key": "your-api-key" } }
117
+ ```
118
+ 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.
119
+ 2. **Runtime override** — Use the `--api-key` CLI flag
120
+ 3. **Environment variable** — Set `TENSORIX_API_KEY`
121
+
122
+ Get your API key from [tensorix.ai](https://tensorix.ai).
123
+
124
+ ## Environment Variables
125
+
126
+ | Variable | Required | Description |
127
+ |----------|----------|-------------|
128
+ | `TENSORIX_API_KEY` | No | Your Tensorix API key (fallback if not in auth.json) |
129
+
130
+ ## Configuration
131
+
132
+ Add to your pi configuration for automatic loading:
133
+
134
+ ```json
135
+ {
136
+ "extensions": [
137
+ "/path/to/pi-tensorix-provider"
138
+ ]
139
+ }
140
+ ```
141
+
142
+ ## Usage
143
+
144
+ Once loaded, select a model with:
145
+
146
+ ```
147
+ /model tensorix deepseek/deepseek-v4-pro
148
+ ```
149
+
150
+ Or use `/models` to browse all available Tensorix models.
151
+
152
+ ### Reasoning Effort
153
+
154
+ For reasoning models, control thinking depth:
155
+
156
+ ```
157
+ /reasoning high
158
+ ```
159
+
160
+ Values: `none`, `low`, `medium`, `high`
161
+
162
+ ## API Compatibility
163
+
164
+ Tensorix provides an OpenAI-compatible API proxy. Key notes:
165
+
166
+ | Aspect | Behavior |
167
+ |--------|----------|
168
+ | Max tokens field | Both `max_tokens` and `max_completion_tokens` accepted |
169
+ | Thinking format | `openai` (always `reasoning_content` in response) |
170
+ | Developer role | ✅ Supported on all models |
171
+ | Reasoning effort | Accepted by reasoning models |
172
+ | `store` parameter | ❌ Not supported |
173
+
174
+ > **Note:** The Tensorix `/v1/models` endpoint only returns model IDs — no pricing,
175
+ > context lengths, or reasoning flags. `patch.json` provides these based on
176
+ > known model specifications and E2E testing.
177
+
178
+ ## API Documentation
179
+
180
+ - Tensorix Docs: https://docs.tensorix.ai
181
+ - OpenAI-compatible endpoint: `https://api.tensorix.ai/v1`
182
+ - Models endpoint: `https://api.tensorix.ai/v1/models`
183
+
184
+ ## License
185
+
186
+ MIT
@@ -0,0 +1 @@
1
+ []
package/index.ts ADDED
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Tensorix Provider Extension
3
+ *
4
+ * Registers Tensorix (tensorix.ai) as a custom provider using the openai-completions API.
5
+ * Base URL: https://api.tensorix.ai/v1
6
+ *
7
+ * Tensorix proxies multiple model families (DeepSeek, GLM, Kimi, Qwen, MiniMax,
8
+ * Llama, Mistral, GPT-OSS, Nemotron) through an OpenAI-compatible API. The
9
+ * /v1/models endpoint returns minimal metadata (id only — no pricing, context
10
+ * lengths, or reasoning flags). The patch.json file provides these based on
11
+ * known model specifications and E2E testing.
12
+ *
13
+ * Key API characteristics:
14
+ * - Both `max_tokens` and `max_completion_tokens` are accepted
15
+ * - All reasoning models return `reasoning_content` in OpenAI format
16
+ * - Developer role is supported across all models
17
+ * - `reasoning_effort` parameter is accepted by all reasoning models
18
+ * - All models are currently free during preview
19
+ *
20
+ * Model resolution strategy: Stale-While-Revalidate
21
+ * 1. Serve stale immediately: disk cache → embedded models.json (zero-latency)
22
+ * 2. Revalidate in background: live API /models → merge with embedded → cache → hot-swap
23
+ * 3. patch.json + custom-models.json applied on top of whichever source won
24
+ *
25
+ * Merge order: [live|cache|embedded] → apply patch.json → merge custom-models.json
26
+ *
27
+ * Usage:
28
+ * # Option 1: Store in auth.json (recommended)
29
+ * # Add to ~/.pi/agent/auth.json:
30
+ * # "tensorix": { "type": "api_key", "key": "your-api-key" }
31
+ *
32
+ * # Option 2: Set as environment variable
33
+ * export TENSORIX_API_KEY=your-api-key
34
+ *
35
+ * # Run pi with the extension
36
+ * pi -e /path/to/pi-tensorix-provider
37
+ *
38
+ * Then use /model to select from available models like DeepSeek V4 Pro,
39
+ * GLM 5.1, Kimi K2.6, Qwen3.5, and more.
40
+ *
41
+ * @see https://docs.tensorix.ai
42
+ */
43
+
44
+ import { getAgentDir, type ExtensionAPI, type ModelRegistry } from "@earendil-works/pi-coding-agent";
45
+ import modelsData from "./models.json" with { type: "json" };
46
+ import customModelsData from "./custom-models.json" with { type: "json" };
47
+ import patchData from "./patch.json" with { type: "json" };
48
+ import fs from "fs";
49
+ import path from "path";
50
+
51
+ // ─── Types ────────────────────────────────────────────────────────────────────
52
+
53
+ interface JsonModel {
54
+ id: string;
55
+ name: string;
56
+ reasoning: boolean;
57
+ input: string[];
58
+ cost: {
59
+ input: number;
60
+ output: number;
61
+ cacheRead: number;
62
+ cacheWrite: number;
63
+ };
64
+ contextWindow: number;
65
+ maxTokens: number;
66
+ thinkingLevelMap?: Record<string, string | null>;
67
+ compat?: {
68
+ supportsDeveloperRole?: boolean;
69
+ supportsStore?: boolean;
70
+ maxTokensField?: "max_completion_tokens" | "max_tokens";
71
+ thinkingFormat?: "openai" | "zai" | "qwen" | "qwen-chat-template";
72
+ supportsReasoningEffort?: boolean;
73
+ };
74
+ }
75
+
76
+ interface PatchEntry {
77
+ name?: string;
78
+ reasoning?: boolean;
79
+ input?: string[];
80
+ cost?: {
81
+ input?: number;
82
+ output?: number;
83
+ cacheRead?: number;
84
+ cacheWrite?: number;
85
+ };
86
+ contextWindow?: number;
87
+ maxTokens?: number;
88
+ thinkingLevelMap?: Record<string, string | null>;
89
+ compat?: Record<string, unknown>;
90
+ }
91
+
92
+ type PatchData = Record<string, PatchEntry>;
93
+
94
+ // ─── Patch Application ────────────────────────────────────────────────────────
95
+
96
+ function applyPatch(model: JsonModel, patch: PatchEntry): JsonModel {
97
+ const result = { ...model };
98
+
99
+ if (patch.name !== undefined) result.name = patch.name;
100
+ if (patch.reasoning !== undefined) result.reasoning = patch.reasoning;
101
+ if (patch.input !== undefined) result.input = patch.input;
102
+ if (patch.contextWindow !== undefined) result.contextWindow = patch.contextWindow;
103
+ if (patch.maxTokens !== undefined) result.maxTokens = patch.maxTokens;
104
+ if (patch.thinkingLevelMap !== undefined) result.thinkingLevelMap = { ...patch.thinkingLevelMap };
105
+
106
+ if (patch.cost) {
107
+ result.cost = {
108
+ input: patch.cost.input ?? result.cost.input,
109
+ output: patch.cost.output ?? result.cost.output,
110
+ cacheRead: patch.cost.cacheRead ?? result.cost.cacheRead,
111
+ cacheWrite: patch.cost.cacheWrite ?? result.cost.cacheWrite,
112
+ };
113
+ }
114
+ if (patch.compat) {
115
+ result.compat = { ...(result.compat || {}), ...patch.compat };
116
+ }
117
+
118
+ if (!result.reasoning && result.compat?.thinkingFormat) {
119
+ delete result.compat.thinkingFormat;
120
+ }
121
+ if (result.compat && Object.keys(result.compat).length === 0) {
122
+ delete result.compat;
123
+ }
124
+
125
+ return result;
126
+ }
127
+
128
+ /** Full pipeline: base models → patch → custom → result */
129
+ function buildModels(base: JsonModel[], custom: JsonModel[], patch: PatchData): JsonModel[] {
130
+ const modelMap = new Map<string, JsonModel>();
131
+
132
+ for (const model of base) {
133
+ modelMap.set(model.id, model);
134
+ }
135
+
136
+ for (const [id, patchEntry] of Object.entries(patch)) {
137
+ const existing = modelMap.get(id);
138
+ if (existing) {
139
+ modelMap.set(id, applyPatch(existing, patchEntry));
140
+ }
141
+ }
142
+
143
+ for (const model of custom) {
144
+ const existing = modelMap.get(model.id);
145
+ const patchEntry = patch[model.id];
146
+ if (existing && patchEntry) {
147
+ modelMap.set(model.id, applyPatch(model, patchEntry));
148
+ } else if (existing) {
149
+ modelMap.set(model.id, model);
150
+ } else if (patchEntry) {
151
+ modelMap.set(model.id, applyPatch(model, patchEntry));
152
+ } else {
153
+ modelMap.set(model.id, model);
154
+ }
155
+ }
156
+
157
+ return Array.from(modelMap.values());
158
+ }
159
+
160
+ // ─── Stale-While-Revalidate Model Sync ────────────────────────────────────────
161
+
162
+ const PROVIDER_ID = "tensorix";
163
+ const BASE_URL = "https://api.tensorix.ai/v1";
164
+ const MODELS_URL = `${BASE_URL}/models`;
165
+ const CACHE_DIR = path.join(getAgentDir(), "cache");
166
+ const CACHE_PATH = path.join(CACHE_DIR, `${PROVIDER_ID}-models.json`);
167
+ const LIVE_FETCH_TIMEOUT_MS = 8000;
168
+
169
+ /** Transform a model from the Tensorix /v1/models API. */
170
+ function transformApiModel(apiModel: any): JsonModel | null {
171
+ // Skip non-chat models
172
+ const skipIds = new Set(["qwen/qwen3-embedding-8b", "Systran/faster-whisper-large-v3"]);
173
+ if (skipIds.has(apiModel.id)) return null;
174
+
175
+ const id = apiModel.id;
176
+ const parts = id.split("/");
177
+ const rawName = parts.length > 1 ? parts.slice(1).join("/") : id;
178
+
179
+ return {
180
+ id,
181
+ name: rawName.replace(/-/g, " ").replace(/\b\w/g, c => c.toUpperCase()),
182
+ reasoning: false,
183
+ input: ["text"],
184
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
185
+ contextWindow: 131072,
186
+ maxTokens: 131072,
187
+ };
188
+ }
189
+
190
+ async function fetchLiveModels(apiKey: string, signal?: AbortSignal): Promise<JsonModel[] | null> {
191
+ try {
192
+ const response = await fetch(MODELS_URL, {
193
+ headers: { Authorization: `Bearer ${apiKey}` },
194
+ signal: signal ? AbortSignal.any([AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS), signal]) : AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS),
195
+ });
196
+ if (!response.ok) return null;
197
+ const data = await response.json();
198
+ const apiModels = Array.isArray(data) ? data : (data.data || []);
199
+ if (!Array.isArray(apiModels) || apiModels.length === 0) return null;
200
+ return apiModels.map(transformApiModel).filter((m): m is JsonModel => m !== null);
201
+ } catch {
202
+ return null;
203
+ }
204
+ }
205
+
206
+ function loadCachedModels(): JsonModel[] | null {
207
+ try {
208
+ const data = JSON.parse(fs.readFileSync(CACHE_PATH, "utf8"));
209
+ return Array.isArray(data) ? data : null;
210
+ } catch {
211
+ return null;
212
+ }
213
+ }
214
+
215
+ function cacheModels(models: JsonModel[]): void {
216
+ try {
217
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
218
+ fs.writeFileSync(CACHE_PATH, JSON.stringify(models, null, 2) + "\n");
219
+ } catch {
220
+ // Cache write failure is non-fatal
221
+ }
222
+ }
223
+
224
+ function mergeWithEmbedded(liveModels: JsonModel[], embeddedModels: JsonModel[]): JsonModel[] {
225
+ const embeddedMap = new Map(embeddedModels.map(m => [m.id, m]));
226
+ const seen = new Set<string>();
227
+ const result: JsonModel[] = [];
228
+ for (const liveModel of liveModels) {
229
+ const embedded = embeddedMap.get(liveModel.id);
230
+ seen.add(liveModel.id);
231
+ if (embedded) {
232
+ // Self-heal: live API pricing is authoritative field-by-field. Prefer the
233
+ // live cost when the API reports it (non-zero); fall back to embedded when
234
+ // the API is silent (0) so curated cacheRead/cacheWrite isn't clobbered and
235
+ // providers whose /models endpoint exposes no pricing keep their curated
236
+ // cost. Curation (reasoning/input/compat/name) still wins via ...embedded.
237
+ result.push({
238
+ ...liveModel,
239
+ ...embedded,
240
+ cost: {
241
+ input: liveModel.cost.input || embedded.cost.input,
242
+ output: liveModel.cost.output || embedded.cost.output,
243
+ cacheRead: liveModel.cost.cacheRead || embedded.cost.cacheRead,
244
+ cacheWrite: liveModel.cost.cacheWrite || embedded.cost.cacheWrite,
245
+ },
246
+ contextWindow: liveModel.contextWindow || embedded.contextWindow,
247
+ });
248
+ } else {
249
+ result.push(liveModel);
250
+ }
251
+ }
252
+ for (const em of embeddedModels) {
253
+ if (!seen.has(em.id)) {
254
+ result.push(em);
255
+ }
256
+ }
257
+ return result;
258
+ }
259
+
260
+ function loadStaleModels(embeddedModels: JsonModel[]): JsonModel[] {
261
+ const cached = loadCachedModels();
262
+ if (!cached || cached.length === 0) return embeddedModels;
263
+
264
+ const cachedMap = new Map(cached.map(m => [m.id, m]));
265
+ for (const em of embeddedModels) {
266
+ if (!cachedMap.has(em.id)) {
267
+ cached.push(em);
268
+ }
269
+ }
270
+ return cached;
271
+ }
272
+
273
+ async function revalidateModels(apiKey: string | undefined, embeddedModels: JsonModel[], signal?: AbortSignal): Promise<JsonModel[] | null> {
274
+ if (!apiKey) return null;
275
+ const liveModels = await fetchLiveModels(apiKey, signal);
276
+ if (!liveModels || liveModels.length === 0) return null;
277
+ const merged = mergeWithEmbedded(liveModels, embeddedModels);
278
+ cacheModels(merged);
279
+ return merged;
280
+ }
281
+
282
+ // ─── API Key Resolution (via ModelRegistry) ────────────────────────────────────
283
+
284
+ let cachedApiKey: string | undefined;
285
+ let revalidateAbort: AbortController | null = null;
286
+
287
+ async function resolveApiKey(modelRegistry: ModelRegistry): Promise<void> {
288
+ cachedApiKey = await modelRegistry.getApiKeyForProvider("tensorix") ?? undefined;
289
+ }
290
+
291
+ // ─── Extension Entry Point ────────────────────────────────────────────────────
292
+
293
+ export default function (pi: ExtensionAPI) {
294
+ const embeddedModels = modelsData as JsonModel[];
295
+ const customModels = customModelsData as JsonModel[];
296
+ const patches = patchData as PatchData;
297
+
298
+ const staleBase = loadStaleModels(embeddedModels);
299
+ const staleModels = buildModels(staleBase, customModels, patches);
300
+
301
+ pi.registerProvider("tensorix", {
302
+ baseUrl: BASE_URL,
303
+ apiKey: "$TENSORIX_API_KEY",
304
+ api: "openai-completions",
305
+ models: staleModels,
306
+ });
307
+
308
+ pi.on("session_start", async (_event, ctx) => {
309
+ revalidateAbort?.abort();
310
+ revalidateAbort = new AbortController();
311
+ const signal = revalidateAbort.signal;
312
+ resolveApiKey(ctx.modelRegistry).then(() => {
313
+ revalidateModels(cachedApiKey, embeddedModels, signal).then((freshBase) => {
314
+ if (freshBase && !signal.aborted) {
315
+ pi.registerProvider("tensorix", {
316
+ baseUrl: BASE_URL,
317
+ apiKey: "$TENSORIX_API_KEY",
318
+ api: "openai-completions",
319
+ models: buildModels(freshBase, customModels, patches),
320
+ });
321
+ }
322
+ });
323
+ });
324
+ });
325
+
326
+ pi.on("session_shutdown", () => {
327
+ revalidateAbort?.abort();
328
+ });
329
+ }