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 +54 -0
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/index.ts +128 -0
- package/package.json +50 -0
- package/src/auth.ts +179 -0
- package/src/models.ts +309 -0
- package/src/stability.ts +182 -0
- package/src/stub.d.ts +28 -0
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
|
+
// }
|
package/src/stability.ts
ADDED
|
@@ -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
|
+
}
|