pi-openmodel-provider 0.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/CHANGELOG.md ADDED
@@ -0,0 +1,54 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.2.0] - 2026-06-20
9
+
10
+ ### Added
11
+ - Async factory pattern (same as pi-commandcode-provider)
12
+ - API key auto-detection from pi's auth.json
13
+ - Debug logs for model loading process
14
+ - Windows path support for auth.json
15
+
16
+ ### Changed
17
+ - Rewrote extension to match Command Code provider architecture
18
+ - Removed all event hooks (session_start, before_agent_start)
19
+ - Simplified to pure async factory function
20
+ - Improved auth.json reading with error handling
21
+ - Updated auth.json path for Windows compatibility
22
+
23
+ ### Fixed
24
+ - All TypeScript errors (no `any`, proper types, `@types/node`)
25
+ - `node:fs` import instead of bare `fs`
26
+ - `allowImportingTsExtensions` in tsconfig
27
+ - `.ts` extension imports working correctly
28
+ - auth.json path resolution
29
+
30
+ ## [0.1.0] - 2026-06-20
31
+
32
+ ### Added
33
+ - Initial release
34
+ - Dynamic model discovery from OpenModel API (42 models)
35
+ - OAuth login via `/login openmodel`
36
+ - Model stability metrics via `/openmodel-stability`
37
+ - Health status indicators on model names
38
+ - Support for 3 protocols: Messages (Anthropic), Responses (OpenAI), Gemini (Google)
39
+ - Provider-based inference for context window, maxTokens, and pricing
40
+ - Commands: `/openmodel`, `/openmodel-stability`
41
+
42
+ ### Changed
43
+ - Models fetched dynamically instead of hardcoded list
44
+ - Context windows inferred from provider (DeepSeek = 1M, Anthropic = 200K, etc.)
45
+ - Pricing as provider defaults with per-model overrides
46
+
47
+ ### Fixed
48
+ - TypeScript type errors
49
+ - `onSelect` optional callback handling
50
+ - Import path extensions (.ts → .js)
51
+ - Process import in models.ts
52
+
53
+ [0.2.0]: https://github.com/IvanGabrielYarupaitanRivera/pi-openmodel-provider/releases/tag/v0.2.0
54
+ [0.1.0]: https://github.com/IvanGabrielYarupaitanRivera/pi-openmodel-provider/releases/tag/v0.1.0
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,131 @@
1
+ # pi-openmodel-provider
2
+
3
+ A [pi](https://github.com/earendil-works/pi-mono) custom provider that connects pi to [OpenModel.ai](https://www.openmodel.ai) — a unified AI API gateway.
4
+
5
+ > **Disclaimer:** This is an unofficial, community-maintained package. I am not affiliated with, endorsed by, or connected to OpenModel in any way. This provider simply forwards requests to the public OpenModel API using your own API key.
6
+
7
+ > **Note:** This package only provides a model _provider_. It does **not** include an API key. You must bring your own OpenModel API key.
8
+
9
+ ## Install + Quick start
10
+
11
+ ```sh
12
+ pi install git:github.com/IvanGabrielYarupaitanRivera/pi-openmodel-provider
13
+ ```
14
+
15
+ | Step | What to do |
16
+ |------|------------|
17
+ | 1️⃣ | `/login` → "Use a subscription" → **OpenModel** → "Paste API key manually" → paste your key |
18
+ | 2️⃣ | `/reload` (so models appear) |
19
+ | 3️⃣ | `Ctrl + L` or `/model openmodel/deepseek-v4-flash` to select your model |
20
+
21
+ Done! You can now use OpenModel in pi.
22
+
23
+ ## Usage
24
+
25
+ After setup, select any OpenModel model:
26
+
27
+ ```txt
28
+ /model openmodel/deepseek-v4-flash
29
+ ```
30
+
31
+ Press **Ctrl + L** to open the model selector and browse available models.
32
+
33
+ ## Models
34
+
35
+ Models are fetched live from OpenModel's API at startup, so new models show up without a package release.
36
+
37
+ ### Supported Providers
38
+
39
+ | Provider | Models |
40
+ |----------|--------|
41
+ | OpenAI | GPT-5.x family |
42
+ | Anthropic | Claude Opus/Sonnet/Haiku |
43
+ | Google Gemini | Gemini Flash/Pro |
44
+ | DeepSeek | DeepSeek V4 (1M context) |
45
+ | Alibaba Qwen | Qwen3.x family |
46
+ | Xiaomi (MiMo) | Mimo v2.x |
47
+ | Moonshot (Kimi) | Kimi K2.x |
48
+ | MiniMax | MiniMax M2.x/M3 |
49
+ | ZAI (GLM) | GLM-4.x/5.x |
50
+
51
+ ## Model discovery
52
+
53
+ On startup, the provider fetches:
54
+
55
+ ```txt
56
+ https://api.openmodel.ai/v1/models
57
+ ```
58
+
59
+ ## Pricing
60
+
61
+ OpenModel does not yet expose model pricing through its Provider API. The provider ships a static cost table (`PROVIDER_DEFAULTS` and `PRICING_OVERRIDES` in `src/models.ts`) for known models so that pi can display per-model pricing.
62
+
63
+ - Models present in the provider defaults show their estimated per-million-token rates.
64
+ - Models **not** in the table fall back to zero cost.
65
+
66
+ ## Features
67
+
68
+ - **41 models** from 9+ providers (dynamically fetched)
69
+ - **3 protocols**: Messages (Anthropic), Responses (OpenAI), Gemini (Google)
70
+ - **Model stability metrics** via `/openmodel-stability`
71
+ - **1M context window** for DeepSeek V4 models
72
+ - **No hardcoding** — new models appear automatically
73
+
74
+ ## Commands
75
+
76
+ ```txt
77
+ /openmodel Show provider status
78
+ /openmodel-stability Show health metrics for all models
79
+ /openmodel-stability <model> Show detailed metrics for a specific model
80
+ ```
81
+
82
+ ## Stability explained
83
+
84
+ The `/openmodel-stability` command shows how healthy each model is:
85
+
86
+ | Symbol | Meaning | Condition |
87
+ |--------|---------|-----------|
88
+ | ✅ Operational | Healthy | ≥99.9% success + enough data |
89
+ | 🟢 Healthy | Good | ≥99% success |
90
+ | 🟡 Degraded | Some issues | ≥95% success |
91
+ | 🔴 Unstable | Problems | <95% success |
92
+ | ⚪ No Data | Not enough info | <10 requests (low confidence) |
93
+
94
+ ```
95
+ ✅ deepseek-v4-flash 100.0% 8541ms 136.4 t/s
96
+ ↑ ↑ ↑ ↑ ↑
97
+ | | | | └── Tokens per second
98
+ | | | └── Average latency (ms)
99
+ | | └── Success rate
100
+ | └── Model name
101
+ └── Health status
102
+ ```
103
+
104
+ ## Development
105
+
106
+ ```sh
107
+ # Clone the repo
108
+ git clone https://github.com/IvanGabrielYarupaitanRivera/pi-openmodel-provider
109
+ cd pi-openmodel-provider
110
+
111
+ # Install dependencies
112
+ npm install
113
+
114
+ # Type check
115
+ npm run typecheck
116
+
117
+ # Test model fetching
118
+ npm run test:models
119
+ ```
120
+
121
+ ## Contributing
122
+
123
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, PR expectations, and commit message rules.
124
+
125
+ ## Release
126
+
127
+ See [RELEASE.md](RELEASE.md) for prerelease, npm smoke-test, stable publish, git tag, and GitHub follow-up checklist.
128
+
129
+ ## License
130
+
131
+ MIT
package/index.ts ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * OpenModel provider for pi.
3
+ *
4
+ * Models are fetched from OpenModel's API at startup.
5
+ */
6
+
7
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
8
+ import { fetchOpenModelModels } from "./src/models.ts"
9
+ import { login, refreshToken, getApiKey } from "./src/auth.ts"
10
+ import {
11
+ fetchModelStabilitySummary,
12
+ fetchModelStabilityDetail,
13
+ formatHealthStatus,
14
+ } from "./src/stability.ts"
15
+ import { readFileSync } from "node:fs"
16
+
17
+ function getApiKeyFromAuth(): string | null {
18
+ try {
19
+ const authPath = "C:/Users/Admin/.pi/agent/auth.json"
20
+ const content = readFileSync(authPath, "utf-8")
21
+ const data = JSON.parse(content)
22
+ return data.openmodel?.access || data.openmodel?.refresh || null
23
+ } catch {
24
+ return null
25
+ }
26
+ }
27
+
28
+ export default async function (pi: ExtensionAPI) {
29
+ let models: Awaited<ReturnType<typeof fetchOpenModelModels>> = []
30
+ const apiKey = getApiKeyFromAuth()
31
+
32
+ if (apiKey) {
33
+ try {
34
+ models = await fetchOpenModelModels({ apiKey })
35
+ } catch {
36
+ // Models will load after API key is configured
37
+ }
38
+ }
39
+
40
+ pi.registerProvider("openmodel", {
41
+ name: "OpenModel",
42
+ baseUrl: "https://api.openmodel.ai",
43
+ apiKey: "$OPENMODEL_API_KEY",
44
+ api: "anthropic-messages",
45
+ oauth: {
46
+ name: "OpenModel",
47
+ login,
48
+ refreshToken,
49
+ getApiKey,
50
+ },
51
+ models: models.map((model) => ({
52
+ id: model.id,
53
+ name: model.name,
54
+ api: model.api,
55
+ reasoning: model.reasoning,
56
+ input: model.input,
57
+ cost: model.cost,
58
+ contextWindow: model.contextWindow,
59
+ maxTokens: model.maxTokens,
60
+ })),
61
+ })
62
+
63
+ // /openmodel - Show provider status
64
+ pi.registerCommand("openmodel", {
65
+ description: "Show OpenModel provider status",
66
+ handler: async (_args: string, ctx: any) => {
67
+ const key = getApiKeyFromAuth()
68
+ const status = key ? "✅ Configured" : "❌ Not configured"
69
+ const count = models.length
70
+
71
+ const lines = [
72
+ "╔════════════════════════════════╗",
73
+ "║ OpenModel.ai ║",
74
+ "╠════════════════════════════════╣",
75
+ `║ Status: ${status.padEnd(20)}║`,
76
+ `║ Models: ${String(count).padStart(3)} available ║`,
77
+ "╠════════════════════════════════╣",
78
+ "║ Commands: ║",
79
+ "║ /model openmodel/... ║",
80
+ "║ /openmodel-stability ║",
81
+ "╚════════════════════════════════╝",
82
+ ]
83
+ ctx.ui.notify(lines.join("\n"), "info")
84
+ },
85
+ })
86
+
87
+ // /openmodel-stability - Show model health metrics
88
+ pi.registerCommand("openmodel-stability", {
89
+ description: "Show model stability metrics (24h)",
90
+ handler: async (args: string | undefined, ctx: any) => {
91
+ try {
92
+ if (args?.trim()) {
93
+ const name = args.trim()
94
+ const detail = await fetchModelStabilityDetail(name)
95
+ const lines = [
96
+ `📊 ${detail.model_name}`,
97
+ `━━━━━━━━━━━━━━━━━━━━━━`,
98
+ `Health: ${formatHealthStatus(detail.health_status)}`,
99
+ `Success: ${detail.summary.success_rate.toFixed(2)}%`,
100
+ `Latency: ${detail.summary.avg_latency_ms.toFixed(0)}ms`,
101
+ `TTFT: ${detail.summary.avg_ttft_ms.toFixed(0)}ms`,
102
+ `Throughput: ${detail.summary.avg_tps.toFixed(1)} t/s`,
103
+ `Confidence: ${detail.confidence}`,
104
+ ]
105
+ ctx.ui.notify(lines.join("\n"), "info")
106
+ } else {
107
+ const summary = await fetchModelStabilitySummary()
108
+ if (summary.length === 0) {
109
+ ctx.ui.notify("No stability data available.", "warning")
110
+ return
111
+ }
112
+ const lines = ["📊 OpenModel Stability (24h)", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"]
113
+ const sorted = [...summary].sort((a, b) => {
114
+ const order = { operational: 0, healthy: 1, degraded: 2, unstable: 3, no_data: 4 }
115
+ return (order[a.health_status] ?? 5) - (order[b.health_status] ?? 5)
116
+ })
117
+ for (const s of sorted) {
118
+ const emoji = formatHealthStatus(s.health_status).split(" ")[0]
119
+ lines.push(`${emoji} ${s.model_name.padEnd(28)} ${s.success_rate.toFixed(1).padStart(5)}% ${s.avg_latency_ms.toFixed(0).padStart(5)}ms ${s.avg_tps.toFixed(1).padStart(6)} t/s`)
120
+ }
121
+ ctx.ui.notify(lines.join("\n"), "info")
122
+ }
123
+ } catch {
124
+ ctx.ui.notify("Failed to fetch stability data.", "error")
125
+ }
126
+ },
127
+ })
128
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "pi-openmodel-provider",
3
+ "version": "0.2.0",
4
+ "description": "pi custom provider for OpenModel.ai - Multi-model AI gateway",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "openmodel",
10
+ "provider",
11
+ "ai-gateway"
12
+ ],
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/IvanGabrielYarupaitanRivera/pi-openmodel-provider.git"
17
+ },
18
+ "homepage": "https://github.com/IvanGabrielYarupaitanRivera/pi-openmodel-provider#readme",
19
+ "files": [
20
+ "index.ts",
21
+ "src/",
22
+ "src/stub.d.ts",
23
+ "README.md",
24
+ "CHANGELOG.md",
25
+ "LICENSE"
26
+ ],
27
+ "devDependencies": {
28
+ "@types/node": "^25.6.0",
29
+ "prettier": "^3.5.0",
30
+ "tsx": "4.22.4",
31
+ "typescript": "6.0.3"
32
+ },
33
+ "scripts": {
34
+ "typecheck": "tsc --noEmit",
35
+ "test:models": "tsx src/models.ts"
36
+ },
37
+ "pi": {
38
+ "extensions": [
39
+ "./index.ts"
40
+ ]
41
+ },
42
+ "peerDependencies": {
43
+ "@earendil-works/pi-coding-agent": "^0.75.5"
44
+ },
45
+ "peerDependenciesMeta": {
46
+ "@earendil-works/pi-coding-agent": {
47
+ "optional": true
48
+ }
49
+ }
50
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,179 @@
1
+ /**
2
+ * OpenModel authentication for pi's /login flow.
3
+ *
4
+ * Provides OAuth integration so users can authenticate via:
5
+ * /login openmodel
6
+ *
7
+ * Flow:
8
+ * 1. Opens the OpenModel Console in the browser
9
+ * 2. Prompts the user to paste their API key
10
+ * 3. Validates the key format (must start with "om-")
11
+ * 4. Stores credentials in pi's auth.json
12
+ *
13
+ * Since OpenModel API keys don't expire, "refresh" is a no-op.
14
+ */
15
+
16
+ export interface OAuthLoginCallbacks {
17
+ onAuth(params: { url: string }): void;
18
+ onPrompt(params: { message: string }): Promise<string>;
19
+ onSelect?(params: {
20
+ message: string;
21
+ options: { id: string; label: string }[];
22
+ }): Promise<string | undefined>;
23
+ }
24
+
25
+ export interface OAuthCredentials {
26
+ refresh: string;
27
+ access: string;
28
+ expires: number;
29
+ }
30
+
31
+ const CONSOLE_URL = "https://console.openmodel.ai";
32
+ const FIVE_YEARS_MS = 5 * 365 * 24 * 60 * 60 * 1000; // API keys don't expire
33
+
34
+ /**
35
+ * Sanitize API key input, removing terminal paste wrappers and control chars.
36
+ */
37
+ export function sanitizeApiKey(input: string): string {
38
+ const esc = String.fromCharCode(27);
39
+ return Array.from(
40
+ input
41
+ .replaceAll(`${esc}[200~`, "")
42
+ .replaceAll(`${esc}[201~`, "")
43
+ .replaceAll("[200~", "")
44
+ .replaceAll("[201~", ""),
45
+ )
46
+ .filter((char) => {
47
+ const code = char.charCodeAt(0);
48
+ return code > 31 && code !== 127;
49
+ })
50
+ .join("")
51
+ .trim();
52
+ }
53
+
54
+ /**
55
+ * Validate that an API key looks like a valid OpenModel key.
56
+ */
57
+ export function isValidApiKey(key: string): boolean {
58
+ return /^om-[A-Za-z0-9_-]+$/.test(key);
59
+ }
60
+
61
+ function credentialsFromApiKey(apiKey: string): OAuthCredentials {
62
+ return {
63
+ refresh: apiKey,
64
+ access: apiKey,
65
+ expires: Date.now() + FIVE_YEARS_MS,
66
+ };
67
+ }
68
+
69
+ /**
70
+ * /login openmodel handler.
71
+ *
72
+ * Offers two options:
73
+ * 1. Browser: opens the OpenModel Console so the user can create/copy a key
74
+ * 2. Manual: prompts the user to paste their API key
75
+ */
76
+ export async function login(
77
+ callbacks: OAuthLoginCallbacks,
78
+ ): Promise<OAuthCredentials> {
79
+ // Offer login method choice (onSelect is optional)
80
+ let method: string | undefined;
81
+ if (callbacks.onSelect) {
82
+ method = await callbacks.onSelect({
83
+ message: "How would you like to authenticate with OpenModel?",
84
+ options: [
85
+ { id: "browser", label: "🌐 Open console in browser" },
86
+ { id: "paste", label: "📋 Paste API key manually" },
87
+ ],
88
+ });
89
+ }
90
+
91
+ if (!method) {
92
+ throw new Error("Login cancelled");
93
+ }
94
+
95
+ if (method === "browser") {
96
+ // Open the OpenModel Console in the browser
97
+ callbacks.onAuth({ url: CONSOLE_URL });
98
+
99
+ // Then prompt for the API key
100
+ const apiKey = sanitizeApiKey(
101
+ await callbacks.onPrompt({
102
+ message: `1. Open ${CONSOLE_URL}\n2. In the sidebar, click on API Keys\n3. Click Create API Key, give it a name, and copy the generated key\n4. Paste the key here (starts with "om-"):`,
103
+ }),
104
+ );
105
+
106
+ if (!apiKey) {
107
+ throw new Error("No OpenModel API key provided");
108
+ }
109
+
110
+ if (!isValidApiKey(apiKey)) {
111
+ let retry: string | undefined;
112
+ if (callbacks.onSelect) {
113
+ retry = await callbacks.onSelect({
114
+ message: `Invalid API key format. Key should start with "om-". Try again?`,
115
+ options: [
116
+ { id: "retry", label: "🔄 Try again" },
117
+ { id: "cancel", label: "❌ Cancel" },
118
+ ],
119
+ });
120
+ }
121
+
122
+ if (retry !== "retry") {
123
+ throw new Error("Login cancelled - invalid API key");
124
+ }
125
+
126
+ // Recursive retry
127
+ return login(callbacks);
128
+ }
129
+
130
+ return credentialsFromApiKey(apiKey);
131
+ }
132
+
133
+ // Manual paste
134
+ const apiKey = sanitizeApiKey(
135
+ await callbacks.onPrompt({
136
+ message: 'Paste your OpenModel API key (starts with "om-"):',
137
+ }),
138
+ );
139
+
140
+ if (!apiKey) {
141
+ throw new Error("No OpenModel API key provided");
142
+ }
143
+
144
+ if (!isValidApiKey(apiKey)) {
145
+ const retry = callbacks.onSelect
146
+ ? await callbacks.onSelect({
147
+ message: `Invalid API key format. Key should start with "om-". Try again?`,
148
+ options: [
149
+ { id: "retry", label: "🔄 Try again" },
150
+ { id: "cancel", label: "❌ Cancel" },
151
+ ],
152
+ })
153
+ : undefined;
154
+
155
+ if (retry !== "retry") {
156
+ throw new Error("Login cancelled - invalid API key");
157
+ }
158
+
159
+ return login(callbacks);
160
+ }
161
+
162
+ return credentialsFromApiKey(apiKey);
163
+ }
164
+
165
+ /**
166
+ * OpenModel API keys don't expire, so "refresh" is a no-op.
167
+ */
168
+ export async function refreshToken(
169
+ credentials: OAuthCredentials,
170
+ ): Promise<OAuthCredentials> {
171
+ return credentialsFromApiKey(credentials.refresh);
172
+ }
173
+
174
+ /**
175
+ * Extract the API key from stored credentials.
176
+ */
177
+ export function getApiKey(credentials: OAuthCredentials): string {
178
+ return credentials.access;
179
+ }
package/src/models.ts ADDED
@@ -0,0 +1,309 @@
1
+ /**
2
+ * OpenModel.ai model fetching and parsing.
3
+ *
4
+ * Fetches available models from OpenModel's API endpoint
5
+ * and maps them to pi provider model definitions.
6
+ *
7
+ * Rather than hardcoding per-model metadata, we infer capabilities
8
+ * from the provider (owned_by) and model name patterns. This way
9
+ * new models added by OpenModel are automatically supported.
10
+ */
11
+
12
+ export const DEFAULT_MODELS_URL = "https://api.openmodel.ai/v1/models";
13
+ export const DEFAULT_API_BASE = "https://api.openmodel.ai";
14
+
15
+ /** Supported protocols from OpenModel API */
16
+ type SupportedProtocol = "messages" | "responses" | "gemini" | "images";
17
+
18
+ /** Raw model from OpenModel API response */
19
+ interface OpenModelApiModel {
20
+ id: string;
21
+ object: string;
22
+ created: number;
23
+ owned_by: string;
24
+ supported_protocols: SupportedProtocol[];
25
+ supported_apis?: SupportedProtocol[]; // alt name from docs
26
+ }
27
+
28
+ /** OpenModel API response shape */
29
+ interface OpenModelModelsResponse {
30
+ data: OpenModelApiModel[];
31
+ object: string;
32
+ }
33
+
34
+ /** Mapped provider model for pi */
35
+ export interface OpenModelProviderModel {
36
+ id: string;
37
+ name: string;
38
+ reasoning: boolean;
39
+ input: readonly ("text" | "image")[];
40
+ cost: {
41
+ input: number;
42
+ output: number;
43
+ cacheRead: number;
44
+ cacheWrite: number;
45
+ };
46
+ contextWindow: number;
47
+ maxTokens: number;
48
+ api: "anthropic-messages" | "openai-responses" | "google-generative-ai";
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Provider-level defaults based on owned_by
53
+ // ---------------------------------------------------------------------------
54
+
55
+ interface ProviderDefaults {
56
+ contextWindow: number;
57
+ maxTokens: number;
58
+ reasoning: boolean;
59
+ supportsImages: boolean;
60
+ costPerMInput: number; // $ per million input tokens
61
+ costPerMOutput: number; // $ per million output tokens
62
+ }
63
+
64
+ const PROVIDER_DEFAULTS: Record<string, ProviderDefaults> = {
65
+ anthropic: {
66
+ contextWindow: 200_000,
67
+ maxTokens: 8_192,
68
+ reasoning: true,
69
+ supportsImages: true,
70
+ costPerMInput: 3,
71
+ costPerMOutput: 15,
72
+ },
73
+ deepseek: {
74
+ contextWindow: 1_000_000,
75
+ maxTokens: 65_536,
76
+ reasoning: true,
77
+ supportsImages: false,
78
+ costPerMInput: 0.14,
79
+ costPerMOutput: 0.28,
80
+ },
81
+ openai: {
82
+ contextWindow: 128_000,
83
+ maxTokens: 16_384,
84
+ reasoning: true,
85
+ supportsImages: true,
86
+ costPerMInput: 2.5,
87
+ costPerMOutput: 10,
88
+ },
89
+ gemini: {
90
+ contextWindow: 1_000_000,
91
+ maxTokens: 8_192,
92
+ reasoning: true,
93
+ supportsImages: true,
94
+ costPerMInput: 0.3,
95
+ costPerMOutput: 1.2,
96
+ },
97
+ moonshot: {
98
+ contextWindow: 128_000,
99
+ maxTokens: 65_536,
100
+ reasoning: true,
101
+ supportsImages: true,
102
+ costPerMInput: 0.6,
103
+ costPerMOutput: 3,
104
+ },
105
+ zai: {
106
+ contextWindow: 128_000,
107
+ maxTokens: 16_384,
108
+ reasoning: true,
109
+ supportsImages: false,
110
+ costPerMInput: 1,
111
+ costPerMOutput: 3.2,
112
+ },
113
+ dashscope: {
114
+ contextWindow: 131_072,
115
+ maxTokens: 16_384,
116
+ reasoning: true,
117
+ supportsImages: true,
118
+ costPerMInput: 0.5,
119
+ costPerMOutput: 3,
120
+ },
121
+ minimax: {
122
+ contextWindow: 128_000,
123
+ maxTokens: 16_384,
124
+ reasoning: true,
125
+ supportsImages: false,
126
+ costPerMInput: 0.27,
127
+ costPerMOutput: 0.95,
128
+ },
129
+ mimo: {
130
+ contextWindow: 128_000,
131
+ maxTokens: 16_384,
132
+ reasoning: true,
133
+ supportsImages: false,
134
+ costPerMInput: 0,
135
+ costPerMOutput: 0,
136
+ },
137
+ };
138
+
139
+ const DEFAULT_FALLBACK: ProviderDefaults = {
140
+ contextWindow: 128_000,
141
+ maxTokens: 16_384,
142
+ reasoning: true,
143
+ supportsImages: false,
144
+ costPerMInput: 0,
145
+ costPerMOutput: 0,
146
+ };
147
+
148
+ function getDefaults(ownedBy: string): ProviderDefaults {
149
+ return PROVIDER_DEFAULTS[ownedBy.toLowerCase()] ?? DEFAULT_FALLBACK;
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Model-specific overrides for well-known exceptions
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /** Fine-tune contextWindow for specific model IDs that differ from their provider default */
157
+ const CONTEXT_OVERRIDES: Record<string, number> = {
158
+ // Some older/smaller models have less context
159
+ };
160
+
161
+ /** Fine-tune maxTokens for specific model IDs */
162
+ const MAX_TOKENS_OVERRIDES: Record<string, number> = {
163
+ // e.g., "some-small-model": 4096,
164
+ };
165
+
166
+ /** Fine-tune reasoning for specific model IDs */
167
+ const REASONING_OVERRIDES: Record<string, boolean> = {
168
+ "gpt-5.4-mini": false,
169
+ "gemini-3.1-flash-lite-preview": false,
170
+ "gemini-3-flash-preview": false,
171
+ };
172
+
173
+ /** Known pricing exceptions (model-specific overrides to provider defaults) */
174
+ const PRICING_OVERRIDES: Record<string, { input: number; output: number }> = {
175
+ "claude-opus-4-7": { input: 15, output: 75 },
176
+ "claude-opus-4-6": { input: 15, output: 75 },
177
+ "claude-opus-4-8": { input: 15, output: 75 },
178
+ "claude-sonnet-4-5": { input: 3, output: 15 },
179
+ "claude-sonnet-4-6": { input: 3, output: 15 },
180
+ "claude-haiku-4-5-20251001": { input: 0.25, output: 1.25 },
181
+ "deepseek-v4-pro": { input: 0.435, output: 0.87 },
182
+ "deepseek-v4-flash": { input: 0.14, output: 0.28 },
183
+ "gpt-5.5-pro": { input: 10, output: 40 },
184
+ "gpt-5.5": { input: 5, output: 20 },
185
+ "gpt-5.4-pro": { input: 5, output: 20 },
186
+ "gpt-5.4": { input: 2.5, output: 10 },
187
+ "gpt-5.4-mini": { input: 0.4, output: 1.6 },
188
+ "gpt-5.3-codex": { input: 2, output: 8 },
189
+ "gpt-5.2-pro": { input: 5, output: 20 },
190
+ "gpt-5.2": { input: 2, output: 8 },
191
+ "gemini-3.5-flash": { input: 0.3, output: 1.2 },
192
+ "gemini-3.1-pro-preview": { input: 1.5, output: 6.0 },
193
+ "gemini-3-flash-preview": { input: 0.15, output: 0.6 },
194
+ "kimi-k2.6": { input: 0.95, output: 4 },
195
+ "kimi-k2.5": { input: 0.6, output: 3 },
196
+ "kimi-k2.7-code": { input: 0.95, output: 4 },
197
+ "glm-5.2": { input: 1.4, output: 5.6 },
198
+ "glm-5.1": { input: 1.4, output: 4.4 },
199
+ "glm-5": { input: 1, output: 3.2 },
200
+ "glm-4.7": { input: 0.5, output: 2 },
201
+ "qwen3.7-max": { input: 2, output: 6 },
202
+ "qwen3.6-max-preview": { input: 1.3, output: 7.8 },
203
+ "qwen3.6-plus": { input: 0.5, output: 3 },
204
+ "qwen3.6-flash": { input: 0.2, output: 1 },
205
+ "qwen3.5-plus": { input: 0.5, output: 3 },
206
+ "qwen3-max": { input: 2.5, output: 6 },
207
+ "MiniMax-M3": { input: 0.5, output: 2 },
208
+ "MiniMax-M2.7": { input: 0.3, output: 1.2 },
209
+ "MiniMax-M2.5": { input: 0.27, output: 0.95 },
210
+ };
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Mapping
214
+ // ---------------------------------------------------------------------------
215
+
216
+ /** Map OpenModel protocol to pi API type */
217
+ function protocolToApi(
218
+ protocols: SupportedProtocol[],
219
+ ): "anthropic-messages" | "openai-responses" | "google-generative-ai" | null {
220
+ if (protocols.includes("messages")) return "anthropic-messages";
221
+ if (protocols.includes("responses")) return "openai-responses";
222
+ if (protocols.includes("gemini")) return "google-generative-ai";
223
+ return null; // images-only, skip
224
+ }
225
+
226
+ /** Parse raw API model into pi provider model */
227
+ function parseApiModel(raw: OpenModelApiModel): OpenModelProviderModel | null {
228
+ // Accept both supported_protocols (API) and supported_apis (doc) field names
229
+ const protocols = raw.supported_protocols ?? raw.supported_apis ?? [];
230
+ const api = protocolToApi(protocols);
231
+ if (!api) return null; // skip image-only models
232
+
233
+ const defaults = getDefaults(raw.owned_by);
234
+ const pricing = PRICING_OVERRIDES[raw.id] ?? {
235
+ input: defaults.costPerMInput,
236
+ output: defaults.costPerMOutput,
237
+ };
238
+
239
+ return {
240
+ id: raw.id,
241
+ name: raw.id,
242
+ reasoning: REASONING_OVERRIDES[raw.id] ?? defaults.reasoning,
243
+ input: defaults.supportsImages
244
+ ? (["text", "image"] as const)
245
+ : (["text"] as const),
246
+ cost: {
247
+ input: pricing.input,
248
+ output: pricing.output,
249
+ cacheRead: pricing.input * 0.1,
250
+ cacheWrite: pricing.input * 0.25,
251
+ },
252
+ contextWindow: CONTEXT_OVERRIDES[raw.id] ?? defaults.contextWindow,
253
+ maxTokens: MAX_TOKENS_OVERRIDES[raw.id] ?? defaults.maxTokens,
254
+ api,
255
+ };
256
+ }
257
+
258
+ /** Fetch models from OpenModel API */
259
+ export async function fetchOpenModelModels(options?: {
260
+ url?: string;
261
+ fetchImpl?: typeof fetch;
262
+ apiKey?: string;
263
+ }): Promise<readonly OpenModelProviderModel[]> {
264
+ const url = options?.url ?? DEFAULT_MODELS_URL;
265
+ const fetchImpl = options?.fetchImpl ?? fetch;
266
+
267
+ const headers: Record<string, string> = { accept: "application/json" };
268
+ if (options?.apiKey) {
269
+ headers["authorization"] = `Bearer ${options.apiKey}`;
270
+ }
271
+
272
+ const response = await fetchImpl(url, { headers });
273
+
274
+ if (!response.ok) {
275
+ throw new Error(
276
+ `Failed to fetch OpenModel models: ${response.status} ${response.statusText}`,
277
+ );
278
+ }
279
+
280
+ const body = (await response.json()) as OpenModelModelsResponse;
281
+ const models: OpenModelProviderModel[] = [];
282
+
283
+ for (const raw of body.data) {
284
+ if (raw.object !== "model") continue;
285
+ const parsed = parseApiModel(raw);
286
+ if (parsed) models.push(parsed);
287
+ }
288
+
289
+ return models;
290
+ }
291
+
292
+ // Allow direct execution: `tsx src/models.ts`
293
+ // if (import.meta.url === `file://${process.argv[1]}`) {
294
+ // const { env } = await import('node:process');
295
+ // const key = env.OPENMODEL_API_KEY
296
+ // const models = await fetchOpenModelModels({
297
+ // apiKey: key ?? undefined,
298
+ // })
299
+ // for (const m of models) {
300
+ // console.log(
301
+ // `${m.id.padEnd(30)} ` +
302
+ // `${m.api.padEnd(22)} ` +
303
+ // `${m.input.join("+").padEnd(8)} ` +
304
+ // `ctx=${String(m.contextWindow).padStart(7)} ` +
305
+ // `max=${String(m.maxTokens).padStart(5)} ` +
306
+ // `\$${m.cost.input.toFixed(3)}/\$${m.cost.output.toFixed(3)}`,
307
+ // )
308
+ // }
309
+ // }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * OpenModel.ai Model Stability API.
3
+ *
4
+ * Fetches real-time stability metrics (success rate, latency, throughput)
5
+ * for all models. Publicly accessible without authentication.
6
+ *
7
+ * Reference:
8
+ * GET https://api.openmodel.ai/web/v1/model-stability/summary
9
+ * GET https://api.openmodel.ai/web/v1/model-stability/:modelKey
10
+ */
11
+
12
+ export const STABILITY_SUMMARY_URL =
13
+ "https://api.openmodel.ai/web/v1/model-stability/summary";
14
+
15
+ /** Health status derived from success rate */
16
+ export type HealthStatus =
17
+ | "operational"
18
+ | "healthy"
19
+ | "degraded"
20
+ | "unstable"
21
+ | "no_data";
22
+
23
+ /** Confidence level based on sample size */
24
+ export type ConfidenceLevel = "high" | "medium" | "low";
25
+
26
+ /** Stability summary for a single model */
27
+ export interface ModelStability {
28
+ model_name: string;
29
+ success_rate: number;
30
+ avg_latency_ms: number;
31
+ avg_tps: number;
32
+ confidence: ConfidenceLevel;
33
+ health_status: HealthStatus;
34
+ }
35
+
36
+ /** Stability summary for a single model with time series */
37
+ export interface ModelStabilityDetail {
38
+ model_name: string;
39
+ confidence: ConfidenceLevel;
40
+ summary: {
41
+ success_rate: number;
42
+ avg_latency_ms: number;
43
+ avg_ttft_ms: number;
44
+ avg_tps: number;
45
+ };
46
+ series: Array<{
47
+ ts: number;
48
+ success_rate: number;
49
+ avg_latency_ms: number;
50
+ avg_ttft_ms: number;
51
+ avg_tps: number;
52
+ confidence: ConfidenceLevel;
53
+ }>;
54
+ updated_at: number;
55
+ health_status: HealthStatus;
56
+ }
57
+
58
+ /** Determine health status from success rate */
59
+ function determineHealth(
60
+ successRate: number,
61
+ confidence: ConfidenceLevel,
62
+ ): HealthStatus {
63
+ if (confidence === "low") return "no_data";
64
+ if (successRate >= 99.9) return "operational";
65
+ if (successRate >= 99) return "healthy";
66
+ if (successRate >= 95) return "degraded";
67
+ return "unstable";
68
+ }
69
+
70
+ /** Fetch stability summary for all models */
71
+ export async function fetchModelStabilitySummary(options?: {
72
+ url?: string;
73
+ fetchImpl?: typeof fetch;
74
+ hours?: number;
75
+ }): Promise<ModelStability[]> {
76
+ const url = options?.url ?? STABILITY_SUMMARY_URL;
77
+ const fetchImpl = options?.fetchImpl ?? fetch;
78
+ const hours = options?.hours ?? 24;
79
+
80
+ const params = new URLSearchParams({ hours: String(hours) });
81
+ const response = await fetchImpl(`${url}?${params}`, {
82
+ headers: { accept: "application/json" },
83
+ });
84
+
85
+ const body = (await response.json()) as {
86
+ success: boolean;
87
+ data: Array<{
88
+ model_name: string;
89
+ success_rate: number;
90
+ avg_latency_ms: number;
91
+ avg_tps: number;
92
+ confidence: ConfidenceLevel;
93
+ }>;
94
+ };
95
+
96
+ if (!body.success) throw new Error("Model stability summary request failed");
97
+
98
+ return body.data.map((item) => ({
99
+ ...item,
100
+ health_status: determineHealth(item.success_rate, item.confidence),
101
+ }));
102
+ }
103
+
104
+ /** Fetch stability detail for a specific model */
105
+ export async function fetchModelStabilityDetail(
106
+ modelKey: string,
107
+ options?: {
108
+ fetchImpl?: typeof fetch;
109
+ hours?: number;
110
+ },
111
+ ): Promise<ModelStabilityDetail> {
112
+ const fetchImpl = options?.fetchImpl ?? fetch;
113
+ const hours = options?.hours ?? 24;
114
+
115
+ const params = new URLSearchParams({ hours: String(hours) });
116
+ const response = await fetchImpl(
117
+ `https://api.openmodel.ai/web/v1/model-stability/${encodeURIComponent(modelKey)}?${params}`,
118
+ { headers: { accept: "application/json" } },
119
+ );
120
+
121
+ const body = (await response.json()) as {
122
+ success: boolean;
123
+ data: {
124
+ model_name: string;
125
+ confidence: ConfidenceLevel;
126
+ summary: {
127
+ success_rate: number;
128
+ avg_latency_ms: number;
129
+ avg_ttft_ms: number;
130
+ avg_tps: number;
131
+ };
132
+ series: Array<{
133
+ ts: number;
134
+ success_rate: number;
135
+ avg_latency_ms: number;
136
+ avg_ttft_ms: number;
137
+ avg_tps: number;
138
+ confidence: ConfidenceLevel;
139
+ }>;
140
+ updated_at: number;
141
+ };
142
+ };
143
+
144
+ if (!body.success)
145
+ throw new Error(`Model stability detail request failed for ${modelKey}`);
146
+
147
+ return {
148
+ ...body.data,
149
+ health_status: determineHealth(
150
+ body.data.summary.success_rate,
151
+ body.data.confidence,
152
+ ),
153
+ };
154
+ }
155
+
156
+ /** Format health status with emoji */
157
+ export function formatHealthStatus(status: HealthStatus): string {
158
+ switch (status) {
159
+ case "operational":
160
+ return "✅ Operational";
161
+ case "healthy":
162
+ return "🟢 Healthy";
163
+ case "degraded":
164
+ return "🟡 Degraded";
165
+ case "unstable":
166
+ return "🔴 Unstable";
167
+ case "no_data":
168
+ return "⚪ No Data";
169
+ }
170
+ }
171
+
172
+ /** Format confidence level */
173
+ export function formatConfidence(level: ConfidenceLevel): string {
174
+ switch (level) {
175
+ case "high":
176
+ return "🟢 High";
177
+ case "medium":
178
+ return "🟡 Medium";
179
+ case "low":
180
+ return "⚪ Low";
181
+ }
182
+ }
package/src/stub.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Stub type declarations for peer dependencies that may not be installed.
3
+ * These are provided by pi-coding-agent when the extension runs.
4
+ */
5
+
6
+ declare module "@earendil-works/pi-coding-agent" {
7
+ export interface ExtensionAPI {
8
+ registerProvider(name: string, config: any): void;
9
+ registerCommand(name: string, options: any): void;
10
+ }
11
+
12
+ export interface ProviderModelConfig {
13
+ id: string;
14
+ name: string;
15
+ reasoning: boolean;
16
+ input: readonly ["text" | "image"];
17
+ cost: {
18
+ input: number;
19
+ output: number;
20
+ cacheRead: number;
21
+ cacheWrite: number;
22
+ };
23
+ contextWindow: number;
24
+ maxTokens: number;
25
+ api?: string;
26
+ headers?: Record<string, string>;
27
+ }
28
+ }