pi-parasail-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
@@ -0,0 +1 @@
1
+ 019dea0e-d06e-72dc-b8b1-e2c14288bed8
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) 2026
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,196 @@
1
+ <div align="center">
2
+
3
+ # 🪂 pi-parasail-provider
4
+
5
+ **30+ models via [Parasail](https://parasail.io/)**
6
+
7
+ _DeepSeek, Qwen, GLM, Kimi, Llama, Gemma, Mistral — serverless with live pricing 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
+ - **Wide Model Selection** — 30+ models from DeepSeek, Qwen, GLM, Kimi, Llama, Gemma, Mistral, MiniMax, and more
19
+ - **Reasoning Models** — DeepSeek V4, Qwen3, GLM 5, Kimi K2.5/K2.6, Trinity, MiniMax with thinking mode
20
+ - **Vision Support** — Qwen3-VL, Gemma 4, Qwen2.5-VL accept image + text input
21
+ - **Unified API** — OpenAI-compatible completions endpoint
22
+ - **Long Context** — DeepSeek V4 (1M), Llama 4 Maverick (1M), MiniMax M2.5 (1M)
23
+
24
+ ## Installation
25
+
26
+ ### Option 1: Using `pi install` (Recommended)
27
+
28
+ Install directly from GitHub:
29
+
30
+ ```bash
31
+ pi install https://github.com/monotykamary/pi-parasail-provider
32
+ ```
33
+
34
+ Then set your API key and run pi:
35
+ ```bash
36
+ # Recommended: add to auth.json
37
+ # See Authentication section below
38
+
39
+ # Or set as environment variable
40
+ export PARASAIL_API_KEY=your-api-key-here
41
+
42
+ pi
43
+ ```
44
+
45
+ Get your API key at [parasail.io](https://parasail.io/).
46
+
47
+ ### Option 2: Manual Clone
48
+
49
+ 1. Clone this repository:
50
+ ```bash
51
+ git clone https://github.com/monotykamary/pi-parasail-provider.git
52
+ cd pi-parasail-provider
53
+ ```
54
+
55
+ 2. Set your Parasail API key:
56
+ ```bash
57
+ # Recommended: add to auth.json
58
+ # See Authentication section below
59
+
60
+ # Or set as environment variable
61
+ export PARASAIL_API_KEY=your-api-key-here
62
+ ```
63
+
64
+ 3. Run pi with the extension:
65
+ ```bash
66
+ pi -e /path/to/pi-parasail-provider
67
+ ```
68
+
69
+ ## Available Models
70
+
71
+ | Model | Context | Reasoning | Input | Max Output | Input $/M | Output $/M | Cache $/M |
72
+ |-------|---------|-----------|-------|------------|-----------|------------|-----------|
73
+ | Cydonia 24B v4.1 | 131K | ❌ | Text | 16K | $0.30 | $0.50 | $0.15 |
74
+ | DeepSeek V4 Flash | 1M | ✅ | Text | 384K | $0.14 | $0.28 | $0.07 |
75
+ | DeepSeek V4 Pro | 1M | ✅ | Text | 384K | $1.74 | $3.48 | $0.10 |
76
+ | Gemma 3 27B | 131K | ❌ | Text + Image | 16K | $0.08 | $0.45 | $0.04 |
77
+ | Gemma 4 26B (A4B) | 262K | ❌ | Text + Image | 16K | $0.13 | $0.40 | $0.05 |
78
+ | Gemma 4 31B | 262K | ❌ | Text + Image | 16K | $0.15 | $0.40 | $0.06 |
79
+ | GLM 5 | 203K | ✅ | Text | 16K | $1.00 | $3.20 | $0.20 |
80
+ | GLM 5.1 | 203K | ✅ | Text | 16K | $1.40 | $4.40 | $0.26 |
81
+ | Glm 52 | 262K | ❌ | Text | 16K | $1.40 | $4.40 | $0.26 |
82
+ | Gpt Oss 120b Fast | 131K | ❌ | Text | 16K | $0.15 | $0.60 | Free |
83
+ | GPT-OSS 120B | 131K | ❌ | Text | 16K | $0.10 | $0.75 | $0.06 |
84
+ | GPT-OSS 20B | 131K | ❌ | Text | 16K | $0.04 | $0.20 | $0.02 |
85
+ | Kimi K2.6 | 262K | ✅ | Text + Image | 16K | $0.75 | $3.50 | $0.16 |
86
+ | Kimi K27 Code | 262K | ❌ | Text | 16K | $0.75 | $3.50 | $0.16 |
87
+ | Llama 3.3 70B | 131K | ❌ | Text | 16K | $0.22 | $0.50 | $0.11 |
88
+ | Llama 4 Maverick 17B-128E | 524K | ❌ | Text + Image | 16K | $0.35 | $1.00 | $0.17 |
89
+ | Mimo V25 | 1M | ❌ | Text | 16K | $0.14 | $0.28 | $0.05 |
90
+ | MiniMax M2.5 | 197K | ✅ | Text | 16K | $0.30 | $1.20 | $0.03 |
91
+ | Minimax M3 | 1M | ❌ | Text | 16K | $0.30 | $1.20 | $0.06 |
92
+ | Mistral Small 3.2 24B | 131K | ❌ | Text + Image | 16K | $0.09 | $0.30 | $0.05 |
93
+ | Qwen 3.5 35B (A3B) | 262K | ✅ | Text + Image | 33K | $0.15 | $1.00 | $0.05 |
94
+ | Qwen 3.5 397B (A17B) | 262K | ✅ | Text + Image | 33K | $0.50 | $3.60 | $0.30 |
95
+ | Qwen 3.6 35B (A3B) | 262K | ✅ | Text + Image | 33K | $0.15 | $1.00 | $0.05 |
96
+ | Qwen2.5-VL 72B | 128K | ❌ | Text + Image | 8K | $0.80 | $1.00 | $0.40 |
97
+ | Qwen3 235B (A22B) | 131K | ✅ | Text | 33K | $0.14 | $0.80 | $0.05 |
98
+ | Qwen3 Coder Next | 262K | ✅ | Text | 33K | $0.12 | $0.80 | $0.07 |
99
+ | Qwen3 Next 80B (A3B) | 262K | ✅ | Text | 33K | $0.10 | $1.10 | $0.07 |
100
+ | Qwen3-VL 235B (A22B) | 131K | ✅ | Text + Image | 33K | $0.21 | $1.90 | $0.10 |
101
+ | Qwen3-VL 8B | 262K | ✅ | Text + Image | 8K | $0.25 | $0.75 | $0.12 |
102
+ | Skyfall 36b V2 Fp8 | 33K | ❌ | Text | 16K | $0.55 | $0.80 | $0.25 |
103
+ | Trinity Large Thinking | 262K | ❌ | Text | 16K | $0.22 | $0.85 | $0.06 |
104
+
105
+ *Pricing fetched live from [Parasail's pricing API](https://www.saas.parasail.io/api/v1/prices/serverlessEndpoints). Prices are per million tokens and subject to change.*
106
+
107
+ ## Usage
108
+
109
+ After loading the extension, use the `/model` command in pi to select your preferred model:
110
+
111
+ ```
112
+ /model parasail parasail-deepseek-v4-pro
113
+ ```
114
+
115
+ Or start pi directly with a Parasail model:
116
+
117
+ ```bash
118
+ pi --provider parasail --model parasail-deepseek-v4-pro
119
+ ```
120
+
121
+ ### Thinking Mode
122
+
123
+ Reasoning models (DeepSeek V4, Qwen3, GLM, Kimi) support both thinking and non-thinking modes. In pi, reasoning models automatically use the appropriate thinking format:
124
+
125
+ - **DeepSeek V4** — `openai` thinking format (`thinking: {type: "enabled"}`)
126
+ - **Qwen3/Qwen3.5** — `qwen` thinking format
127
+ - **GLM** — `zai` thinking format
128
+
129
+ ## Authentication
130
+
131
+ The Parasail API key can be configured in multiple ways (resolved in this order):
132
+
133
+ 1. **`auth.json`** (recommended) — Add to `~/.pi/agent/auth.json`:
134
+ ```json
135
+ { "parasail": { "type": "api_key", "key": "your-api-key" } }
136
+ ```
137
+ 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.
138
+ 2. **Runtime override** — Use the `--api-key` CLI flag
139
+ 3. **Environment variable** — Set `PARASAIL_API_KEY`
140
+
141
+ Get your API key at [parasail.io](https://parasail.io/).
142
+
143
+ ## Environment Variables
144
+
145
+ | Variable | Required | Description |
146
+ |----------|----------|-------------|
147
+ | `PARASAIL_API_KEY` | No | Your Parasail API key (fallback if not in auth.json) |
148
+
149
+ ## Configuration
150
+
151
+ Add to your pi configuration for automatic loading:
152
+
153
+ ```json
154
+ {
155
+ "extensions": [
156
+ "/path/to/pi-parasail-provider"
157
+ ]
158
+ }
159
+ ```
160
+
161
+ ### Compat Settings
162
+
163
+ Parasail's API uses OpenAI-compatible settings:
164
+
165
+ - **`thinkingFormat`** — Set per-model family: `"openai"` (DeepSeek V4), `"qwen"` (Qwen3), `"zai"` (GLM)
166
+ - **`maxTokensField: "max_completion_tokens"`** — All models use `max_completion_tokens`
167
+ - **`supportsDeveloperRole: false`** — All models use `system` role, not `developer`
168
+ - **`supportsStore: false`** — All models don't support the `store` parameter
169
+
170
+ ### Patch Overrides
171
+
172
+ The `patch.json` file contains overrides that are applied on top of `models.json` data at runtime. This is useful for:
173
+ - Correcting API-derived values (e.g., marking a model as reasoning-capable)
174
+ - Adding compat settings that the API doesn't provide
175
+ - Overriding pricing/context window when specs change
176
+
177
+ ## Updating Models
178
+
179
+ Run the update script to fetch the latest models from Parasail's API:
180
+
181
+ ```bash
182
+ export PARASAIL_API_KEY=your-api-key
183
+ node scripts/update-models.js
184
+ ```
185
+
186
+ This will:
187
+ 1. Fetch models from `https://api.parasail.io/v1/models`
188
+ 2. Preserve pricing, compat, and metadata from existing `models.json`
189
+ 3. Apply overrides from `patch.json` at runtime
190
+ 4. Update `models.json` and the README model table
191
+
192
+ A GitHub Actions workflow runs this daily and creates a PR if models have changed.
193
+
194
+ ## License
195
+
196
+ MIT
@@ -0,0 +1 @@
1
+ []
package/index.ts ADDED
@@ -0,0 +1,405 @@
1
+ /**
2
+ * Parasail Provider Extension
3
+ *
4
+ * Registers Parasail (api.parasail.io) as a custom provider.
5
+ * Base URL: https://api.parasail.io/v1 (OpenAI-compatible)
6
+ *
7
+ * Parasail's API is fully OpenAI-compatible and hosts a wide range of
8
+ * open-source models including DeepSeek, Qwen, GLM, Kimi, Llama, Gemma,
9
+ * Mistral, and more.
10
+ *
11
+ * Model resolution strategy: Stale-While-Revalidate
12
+ * 1. Serve stale immediately: disk cache → embedded models.json (zero-latency)
13
+ * 2. Revalidate in background:
14
+ * a. Live API /v1/models → merge with embedded → enrich with pricing
15
+ * b. Public pricing endpoint → apply input/output/cache costs + context windows
16
+ * 3. patch.json + custom-models.json applied on top of whichever source won
17
+ *
18
+ * Merge order: [live|cache|embedded] → apply patch.json → merge custom-models.json
19
+ *
20
+ * Usage:
21
+ * # Option 1: Store in auth.json (recommended)
22
+ * # Add to ~/.pi/agent/auth.json:
23
+ * # "parasail": { "type": "api_key", "key": "your-api-key" }
24
+ *
25
+ * # Option 2: Set as environment variable
26
+ * export PARASAIL_API_KEY=your-api-key
27
+ *
28
+ * # Run pi with the extension
29
+ * pi -e /path/to/pi-parasail-provider
30
+ *
31
+ * Then use /model to select from available models
32
+ */
33
+
34
+ import { getAgentDir, type ExtensionAPI, type ModelRegistry } from "@earendil-works/pi-coding-agent";
35
+ import modelsData from "./models.json" with { type: "json" };
36
+ import customModelsData from "./custom-models.json" with { type: "json" };
37
+ import patchData from "./patch.json" with { type: "json" };
38
+ import fs from "fs";
39
+ import path from "path";
40
+
41
+ // ─── Types ────────────────────────────────────────────────────────────────────
42
+
43
+ interface JsonModel {
44
+ id: string;
45
+ name: string;
46
+ reasoning: boolean;
47
+ input: string[];
48
+ cost: {
49
+ input: number;
50
+ output: number;
51
+ cacheRead: number;
52
+ cacheWrite: number;
53
+ };
54
+ contextWindow: number;
55
+ maxTokens: number;
56
+ compat?: {
57
+ supportsDeveloperRole?: boolean;
58
+ supportsStore?: boolean;
59
+ maxTokensField?: "max_completion_tokens" | "max_tokens";
60
+ thinkingFormat?: "openai" | "zai" | "qwen" | "qwen-chat-template";
61
+ supportsReasoningEffort?: boolean;
62
+ };
63
+ }
64
+
65
+ interface PatchEntry {
66
+ name?: string;
67
+ reasoning?: boolean;
68
+ input?: string[];
69
+ cost?: {
70
+ input?: number;
71
+ output?: number;
72
+ cacheRead?: number;
73
+ cacheWrite?: number;
74
+ };
75
+ contextWindow?: number;
76
+ maxTokens?: number;
77
+ compat?: Record<string, unknown>;
78
+ }
79
+
80
+ type PatchData = Record<string, PatchEntry>;
81
+
82
+ // ─── Patch Application ────────────────────────────────────────────────────────
83
+
84
+ function applyPatch(model: JsonModel, patch: PatchEntry): JsonModel {
85
+ const result = { ...model };
86
+
87
+ if (patch.name !== undefined) result.name = patch.name;
88
+ if (patch.reasoning !== undefined) result.reasoning = patch.reasoning;
89
+ if (patch.input !== undefined) result.input = patch.input;
90
+ if (patch.contextWindow !== undefined) result.contextWindow = patch.contextWindow;
91
+ if (patch.maxTokens !== undefined) result.maxTokens = patch.maxTokens;
92
+
93
+ if (patch.cost) {
94
+ result.cost = {
95
+ input: patch.cost.input ?? result.cost.input,
96
+ output: patch.cost.output ?? result.cost.output,
97
+ cacheRead: patch.cost.cacheRead ?? result.cost.cacheRead,
98
+ cacheWrite: patch.cost.cacheWrite ?? result.cost.cacheWrite,
99
+ };
100
+ }
101
+ if (patch.compat) {
102
+ result.compat = { ...(result.compat || {}), ...patch.compat };
103
+ }
104
+
105
+ if (!result.reasoning && result.compat?.thinkingFormat) {
106
+ delete result.compat.thinkingFormat;
107
+ }
108
+ if (result.compat && Object.keys(result.compat).length === 0) {
109
+ delete result.compat;
110
+ }
111
+
112
+ return result;
113
+ }
114
+
115
+ /** Full pipeline: base models → patch → custom → result */
116
+ function buildModels(base: JsonModel[], custom: JsonModel[], patch: PatchData): JsonModel[] {
117
+ const modelMap = new Map<string, JsonModel>();
118
+
119
+ for (const model of base) {
120
+ modelMap.set(model.id, model);
121
+ }
122
+
123
+ for (const [id, patchEntry] of Object.entries(patch)) {
124
+ const existing = modelMap.get(id);
125
+ if (existing) {
126
+ modelMap.set(id, applyPatch(existing, patchEntry));
127
+ }
128
+ }
129
+
130
+ for (const model of custom) {
131
+ const existing = modelMap.get(model.id);
132
+ const patchEntry = patch[model.id];
133
+ if (existing && patchEntry) {
134
+ modelMap.set(model.id, applyPatch(model, patchEntry));
135
+ } else if (existing) {
136
+ modelMap.set(model.id, model);
137
+ } else if (patchEntry) {
138
+ modelMap.set(model.id, applyPatch(model, patchEntry));
139
+ } else {
140
+ modelMap.set(model.id, model);
141
+ }
142
+ }
143
+
144
+ return Array.from(modelMap.values());
145
+ }
146
+
147
+ // ─── Stale-While-Revalidate Model Sync ────────────────────────────────────────
148
+
149
+ const PROVIDER_ID = "parasail";
150
+ const BASE_URL = "https://api.parasail.io/v1";
151
+ const MODELS_URL = `${BASE_URL}/models`;
152
+ const PRICING_URL = "https://www.saas.parasail.io/api/v1/prices/serverlessEndpoints";
153
+ const CACHE_DIR = path.join(getAgentDir(), "cache");
154
+ const CACHE_PATH = path.join(CACHE_DIR, `${PROVIDER_ID}-models.json`);
155
+ const LIVE_FETCH_TIMEOUT_MS = 8000;
156
+
157
+ // Non-LLM model prefixes to skip (embedding, TTS, UI agent models)
158
+ const SKIP_PREFIXES = ["parasail-bge-", "parasail-resemble-", "parasail-ui-tars-"];
159
+
160
+ /** Transform a model from the Parasail /v1/models API. API returns minimal data (id only). */
161
+ function transformApiModel(apiModel: any): JsonModel | null {
162
+ const id = apiModel.id;
163
+
164
+ // Skip non-LLM models
165
+ if (SKIP_PREFIXES.some(prefix => id.startsWith(prefix))) return null;
166
+
167
+ // Prefer parasail- prefixed IDs (cleaner aliases)
168
+ // Skip original IDs that have parasail- equivalents
169
+ if (!id.startsWith("parasail-")) return null;
170
+
171
+ return {
172
+ id,
173
+ name: generateDisplayName(id),
174
+ reasoning: false,
175
+ input: ["text"],
176
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
177
+ contextWindow: 131072,
178
+ maxTokens: 16384,
179
+ };
180
+ }
181
+
182
+ function generateDisplayName(id: string): string {
183
+ // Strip parasail- prefix and prettify
184
+ const raw = id.replace(/^parasail-/, "");
185
+ return raw
186
+ .replace(/[-_]/g, " ")
187
+ .replace(/\b\w/g, c => c.toUpperCase());
188
+ }
189
+
190
+ interface PricingEntry {
191
+ externalAlias: string;
192
+ contextLength: number;
193
+ inputCost: number | null;
194
+ outputCost: number | null;
195
+ cachedCost: number | null;
196
+ tags: string[];
197
+ }
198
+
199
+ /** Fetch pricing data from the public Parasail SaaS pricing endpoint (no auth required). */
200
+ async function fetchPricingData(signal?: AbortSignal): Promise<Map<string, PricingEntry> | null> {
201
+ try {
202
+ const response = await fetch(PRICING_URL, {
203
+ signal: signal ? AbortSignal.any([AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS), signal]) : AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS),
204
+ });
205
+ if (!response.ok) return null;
206
+ const data = await response.json();
207
+ if (!Array.isArray(data)) return null;
208
+ const map = new Map<string, PricingEntry>();
209
+ for (const entry of data) {
210
+ const alias = entry.externalAlias;
211
+ if (!alias || !alias.startsWith("parasail-")) continue;
212
+ map.set(alias, {
213
+ externalAlias: alias,
214
+ contextLength: entry.contextLength || 0,
215
+ inputCost: entry.inputCost ?? 0,
216
+ outputCost: entry.outputCost ?? 0,
217
+ cachedCost: entry.cachedCost ?? 0,
218
+ tags: entry.tags || [],
219
+ });
220
+ }
221
+ return map;
222
+ } catch {
223
+ return null;
224
+ }
225
+ }
226
+
227
+ /** Enrich models with live pricing data from the public endpoint. */
228
+ function applyPricing(models: JsonModel[], pricing: Map<string, PricingEntry>): JsonModel[] {
229
+ return models.map((model) => {
230
+ const entry = pricing.get(model.id);
231
+ if (!entry) return model;
232
+ const updated = { ...model };
233
+ updated.cost = {
234
+ ...updated.cost,
235
+ input: entry.inputCost ?? updated.cost.input,
236
+ output: entry.outputCost ?? updated.cost.output,
237
+ cacheRead: entry.cachedCost ?? updated.cost.cacheRead,
238
+ };
239
+ // Update context window from pricing data (authoritative)
240
+ if (entry.contextLength) {
241
+ updated.contextWindow = entry.contextLength;
242
+ }
243
+ // Update vision from tags
244
+ if (entry.tags.includes("multimodal") && !updated.input.includes("image")) {
245
+ updated.input = [...updated.input, "image"];
246
+ }
247
+ return updated;
248
+ });
249
+ }
250
+
251
+ async function fetchLiveModels(apiKey: string, signal?: AbortSignal): Promise<JsonModel[] | null> {
252
+ try {
253
+ const response = await fetch(MODELS_URL, {
254
+ headers: { Authorization: `Bearer ${apiKey}` },
255
+ signal: signal ? AbortSignal.any([AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS), signal]) : AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS),
256
+ });
257
+ if (!response.ok) return null;
258
+ const data = await response.json();
259
+ const apiModels = Array.isArray(data) ? data : (data.data || []);
260
+ if (!Array.isArray(apiModels) || apiModels.length === 0) return null;
261
+ return apiModels.map(transformApiModel).filter((m): m is JsonModel => m !== null);
262
+ } catch {
263
+ return null;
264
+ }
265
+ }
266
+
267
+ function loadCachedModels(): JsonModel[] | null {
268
+ try {
269
+ const data = JSON.parse(fs.readFileSync(CACHE_PATH, "utf8"));
270
+ return Array.isArray(data) ? data : null;
271
+ } catch {
272
+ return null;
273
+ }
274
+ }
275
+
276
+ function cacheModels(models: JsonModel[]): void {
277
+ try {
278
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
279
+ fs.writeFileSync(CACHE_PATH, JSON.stringify(models, null, 2) + "\n");
280
+ } catch {
281
+ // Cache write failure is non-fatal
282
+ }
283
+ }
284
+
285
+ function mergeWithEmbedded(liveModels: JsonModel[], embeddedModels: JsonModel[]): JsonModel[] {
286
+ const embeddedMap = new Map(embeddedModels.map(m => [m.id, m]));
287
+ const seen = new Set<string>();
288
+ const result: JsonModel[] = [];
289
+ for (const liveModel of liveModels) {
290
+ const embedded = embeddedMap.get(liveModel.id);
291
+ seen.add(liveModel.id);
292
+ if (embedded) {
293
+ // Self-heal: live API pricing is authoritative field-by-field. Prefer the
294
+ // live cost when the API reports it (non-zero); fall back to embedded when
295
+ // the API is silent (0) so curated cacheRead/cacheWrite isn't clobbered and
296
+ // providers whose /models endpoint exposes no pricing keep their curated
297
+ // cost. Curation (reasoning/input/compat/name) still wins via ...embedded.
298
+ result.push({
299
+ ...liveModel,
300
+ ...embedded,
301
+ cost: {
302
+ input: liveModel.cost.input || embedded.cost.input,
303
+ output: liveModel.cost.output || embedded.cost.output,
304
+ cacheRead: liveModel.cost.cacheRead || embedded.cost.cacheRead,
305
+ cacheWrite: liveModel.cost.cacheWrite || embedded.cost.cacheWrite,
306
+ },
307
+ contextWindow: liveModel.contextWindow || embedded.contextWindow,
308
+ });
309
+ } else {
310
+ result.push(liveModel);
311
+ }
312
+ }
313
+ // Append any embedded models that the live API didn't return
314
+ for (const em of embeddedModels) {
315
+ if (!seen.has(em.id)) {
316
+ result.push(em);
317
+ }
318
+ }
319
+ return result;
320
+ }
321
+
322
+ function loadStaleModels(embeddedModels: JsonModel[]): JsonModel[] {
323
+ const cached = loadCachedModels();
324
+ if (!cached || cached.length === 0) return embeddedModels;
325
+
326
+ // Merge embedded models that are missing from cache (newly added models)
327
+ const cachedMap = new Map(cached.map(m => [m.id, m]));
328
+ for (const em of embeddedModels) {
329
+ if (!cachedMap.has(em.id)) {
330
+ cached.push(em);
331
+ }
332
+ }
333
+ return cached;
334
+ }
335
+
336
+ async function revalidateModels(apiKey: string | undefined, embeddedModels: JsonModel[], signal?: AbortSignal): Promise<JsonModel[] | null> {
337
+ // Fetch both models list and pricing in parallel
338
+ const [liveModels, pricing] = await Promise.all([
339
+ apiKey ? fetchLiveModels(apiKey, signal) : Promise.resolve(null),
340
+ fetchPricingData(signal),
341
+ ]);
342
+
343
+ // Use embedded as base if live fetch failed
344
+ const base = liveModels && liveModels.length > 0
345
+ ? mergeWithEmbedded(liveModels, embeddedModels)
346
+ : embeddedModels;
347
+
348
+ // Always apply live pricing if available (public endpoint, no auth needed)
349
+ const enriched = pricing ? applyPricing(base, pricing) : base;
350
+
351
+ cacheModels(enriched);
352
+ return enriched;
353
+ }
354
+
355
+ // ─── API Key Resolution (via ModelRegistry) ────────────────────────────────────
356
+
357
+ let cachedApiKey: string | undefined;
358
+ let revalidateAbort: AbortController | null = null;
359
+
360
+ async function resolveApiKey(modelRegistry: ModelRegistry): Promise<void> {
361
+ cachedApiKey = await modelRegistry.getApiKeyForProvider("parasail") ?? undefined;
362
+ }
363
+
364
+ // ─── Extension Entry Point ────────────────────────────────────────────────────
365
+
366
+ export default function (pi: ExtensionAPI) {
367
+ // SWR: Serve stale immediately (cache → embedded) — zero-latency registration
368
+ const embeddedModels = modelsData as JsonModel[];
369
+ const customModels = customModelsData as JsonModel[];
370
+ const patches = patchData as PatchData;
371
+
372
+ const staleBase = loadStaleModels(embeddedModels);
373
+ const staleModels = buildModels(staleBase, customModels, patches);
374
+
375
+ pi.registerProvider("parasail", {
376
+ baseUrl: BASE_URL,
377
+ apiKey: "$PARASAIL_API_KEY",
378
+ api: "openai-completions",
379
+ models: staleModels,
380
+ });
381
+
382
+ // Revalidate in background: fetch live models + pricing → merge → cache → hot-swap
383
+ // Pricing endpoint is public (no auth needed), models endpoint requires API key
384
+ pi.on("session_start", async (_event, ctx) => {
385
+ revalidateAbort?.abort();
386
+ revalidateAbort = new AbortController();
387
+ const signal = revalidateAbort.signal;
388
+ resolveApiKey(ctx.modelRegistry).then(() => {
389
+ revalidateModels(cachedApiKey, embeddedModels, signal).then((freshBase) => {
390
+ if (freshBase && !signal.aborted) {
391
+ pi.registerProvider("parasail", {
392
+ baseUrl: BASE_URL,
393
+ apiKey: "$PARASAIL_API_KEY",
394
+ api: "openai-completions",
395
+ models: buildModels(freshBase, customModels, patches),
396
+ });
397
+ }
398
+ });
399
+ });
400
+ });
401
+
402
+ pi.on("session_shutdown", () => {
403
+ revalidateAbort?.abort();
404
+ });
405
+ }