pi-openmodel-provider 0.2.15 → 0.2.17
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/.agents/skills/pi-openmodel-info/SKILL.md +13 -0
- package/AGENTS.md +2 -0
- package/CHANGELOG.md +30 -0
- package/README.md +44 -1
- package/index.ts +29 -15
- package/package.json +1 -1
- package/src/{models.ts → api/models.ts} +59 -48
- package/src/api/stability.ts +184 -0
- package/src/auth/login.ts +132 -0
- package/src/auth/validate.ts +33 -0
- package/src/cache.ts +61 -0
- package/src/formatters/stability.ts +48 -0
- package/src/providers/compat.ts +50 -0
- package/src/providers/pricing.ts +12 -0
- package/src/providers/protocols.ts +53 -0
- package/src/auth.ts +0 -179
- package/src/stability.ts +0 -201
|
@@ -26,12 +26,25 @@ Models are fetched live from OpenModel's API at startup:
|
|
|
26
26
|
|
|
27
27
|
If the API key is not configured yet, models still load — protocols are inferred automatically from the provider name.
|
|
28
28
|
|
|
29
|
+
### Caching
|
|
30
|
+
|
|
31
|
+
Models are cached locally at `~/.pi/agent/cache/openmodel-models.json` with a **5-minute TTL**. On subsequent startups or `/reload`, the cached list is used instead of hitting the API again. The `/openmodel` command shows `(cached)` when the cache is active.
|
|
32
|
+
|
|
29
33
|
## Thinking levels
|
|
30
34
|
|
|
31
35
|
Reasoning models support thinking levels:
|
|
32
36
|
- **Messages protocol:** minimal → low, low → medium, medium → high
|
|
33
37
|
- **Responses protocol:** `reasoning_effort` levels (low, medium, high)
|
|
34
38
|
|
|
39
|
+
## Compat flags
|
|
40
|
+
|
|
41
|
+
Compat flags are automatically set per provider for optimal protocol compatibility:
|
|
42
|
+
- **OpenAI:** `supportsReasoningEffort: true`
|
|
43
|
+
- **Anthropic:** `sendSessionAffinityHeaders`, `supportsCacheControlOnTools`, `supportsEagerToolInputStreaming`
|
|
44
|
+
- **DeepSeek (reasoning):** `thinkingFormat: "deepseek"`
|
|
45
|
+
- **Qwen (reasoning):** `thinkingFormat: "qwen-chat-template"`
|
|
46
|
+
- **ZAI / GLM (reasoning):** `thinkingFormat: "zai"`
|
|
47
|
+
|
|
35
48
|
## Available commands
|
|
36
49
|
|
|
37
50
|
- `/openmodel` — Show provider status
|
package/AGENTS.md
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
- This package is **pi-openmodel-provider** for OpenModel.ai, **NOT** OpenRouter.
|
|
4
4
|
- OpenModel is a multi-model AI gateway, similar to OpenRouter but a different service.
|
|
5
5
|
- Models are fetched dynamically from OpenModel's API at startup — no hardcoded model list.
|
|
6
|
+
- Models are cached locally at `~/.pi/agent/cache/openmodel-models.json` with a 5-minute TTL to avoid hitting the API on every startup.
|
|
7
|
+
- Compat flags are set per provider (openai, anthropic, deepseek, qwen, zai) for optimal protocol compatibility.
|
|
6
8
|
- If the `/v1/models` endpoint fails (no API key), protocols are inferred from the provider.
|
|
7
9
|
- See `.agents/skills/pi-openmodel-info/SKILL.md` for full documentation.
|
|
8
10
|
- Follow [CONTRIBUTING.md](CONTRIBUTING.md) before changing code.
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,36 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.2.17] - 2026-06-23
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- **Major refactor (SRP):** Reorganized `src/` into single-responsibility modules
|
|
12
|
+
- `api/` — network fetching only (models, stability)
|
|
13
|
+
- `providers/` — pure business logic (compat, protocols, pricing)
|
|
14
|
+
- `auth/` — login orchestration + input validation separated
|
|
15
|
+
- `formatters/` — pure display formatting (stability health/confidence)
|
|
16
|
+
- Each file now has exactly one responsibility (was 1-4 before)
|
|
17
|
+
- `index.ts` — replaced dynamic `import("node:fs")` with static top-level import
|
|
18
|
+
|
|
19
|
+
### Documentation
|
|
20
|
+
- `README.md` — added Codebase Architecture section with module descriptions
|
|
21
|
+
- `CONTRIBUTING.md` — added Codebase Architecture section with contributor guidelines
|
|
22
|
+
|
|
23
|
+
## [0.2.16] - 2026-06-23
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- Local model cache at `~/.pi/agent/cache/openmodel-models.json` with 5-minute TTL
|
|
27
|
+
- `src/cache.ts` module for cache read/write operations
|
|
28
|
+
- Compat flags per provider (openai, anthropic, deepseek, qwen, zai) for optimal protocol compatibility
|
|
29
|
+
- AbortSignal support in stability fetch functions
|
|
30
|
+
- CI workflow (`.github/workflows/ci.yml`) for typecheck + tests on push and PR
|
|
31
|
+
- Typecheck and test steps before publish in `.github/workflows/publish.yml`
|
|
32
|
+
- `(cached)` indicator in `/openmodel` status output
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
- Models now load from cache first, falling back to API fetch
|
|
36
|
+
- Updated `actions/checkout` and `actions/setup-node` to v5 (Node 24 native)
|
|
37
|
+
|
|
8
38
|
## [0.2.14] - 2026-06-22
|
|
9
39
|
|
|
10
40
|
### Changed
|
package/README.md
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
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
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/pi-openmodel-provider)
|
|
6
|
+
[](https://github.com/IvanGabrielYarupaitanRivera/pi-openmodel-provider/actions/workflows/ci.yml)
|
|
6
7
|
|
|
7
8
|
> **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.
|
|
8
9
|
|
|
@@ -64,6 +65,12 @@ On startup, the provider fetches models from two endpoints:
|
|
|
64
65
|
|
|
65
66
|
Pricing, context window, reasoning support, and vision capabilities are all provided by the API — no hardcoded data.
|
|
66
67
|
|
|
68
|
+
### Caching
|
|
69
|
+
|
|
70
|
+
Models are cached locally at `~/.pi/agent/cache/openmodel-models.json` with a **5-minute TTL**. On subsequent startups or `/reload`, the cached list is used instead of hitting the API again. The `/openmodel` command shows `(cached)` when the cache is active.
|
|
71
|
+
|
|
72
|
+
To force a fresh fetch, wait 5 minutes or delete the cache file manually.
|
|
73
|
+
|
|
67
74
|
## Pricing
|
|
68
75
|
|
|
69
76
|
Model pricing is fetched live from OpenModel's public API (`/web/v1/models`). Each model returns its real per-token rates in microdollars, converted to dollars per million tokens for display.
|
|
@@ -75,13 +82,18 @@ Model pricing is fetched live from OpenModel's public API (`/web/v1/models`). Ea
|
|
|
75
82
|
|
|
76
83
|
## Features
|
|
77
84
|
|
|
78
|
-
- **41 models** from 9+ providers (dynamically fetched)
|
|
85
|
+
- **41+ models** from 9+ providers (dynamically fetched)
|
|
79
86
|
- **3 protocols**: Messages (Anthropic), Responses (OpenAI), Gemini (Google)
|
|
80
87
|
- **Model stability metrics** via `/openmodel-stability`
|
|
81
88
|
- **1M context window** for DeepSeek V4 models
|
|
82
89
|
- **Thinking levels** for reasoning models (DeepSeek, Claude, GPT, Gemini, etc.)
|
|
90
|
+
- **Compat flags** per provider for optimal protocol compatibility
|
|
91
|
+
- **Local caching** with 5-minute TTL to reduce API calls
|
|
92
|
+
- **AbortSignal support** in stability commands for cancellation
|
|
83
93
|
- **Friendly error messages** with emojis and actionable guidance
|
|
84
94
|
- **No hardcoding** — new models, pricing, and capabilities appear automatically
|
|
95
|
+
- **CI workflow** — typecheck and tests run on every push and PR
|
|
96
|
+
- **Modular architecture** — each module has a single responsibility (SRP), making the codebase easy to maintain and extend
|
|
85
97
|
|
|
86
98
|
## Error handling
|
|
87
99
|
|
|
@@ -151,6 +163,37 @@ npm run test:stability
|
|
|
151
163
|
npm run test:edge
|
|
152
164
|
```
|
|
153
165
|
|
|
166
|
+
### Codebase Architecture
|
|
167
|
+
|
|
168
|
+
The source code is organized by responsibility following the Single Responsibility Principle:
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
src/
|
|
172
|
+
├── api/ # Network fetching (models, stability)
|
|
173
|
+
│ ├── models.ts # fetchOpenModelModels() — model discovery orchestration
|
|
174
|
+
│ └── stability.ts # fetchModelStabilitySummary/Detail()
|
|
175
|
+
├── providers/ # Provider-specific business logic
|
|
176
|
+
│ ├── compat.ts # compatForProvider() — per-provider compatibility flags
|
|
177
|
+
│ ├── protocols.ts # determineApi() + thinkingLevelMapForApi()
|
|
178
|
+
│ └── pricing.ts # pricePerMillion() — cost-per-token conversion
|
|
179
|
+
├── auth/ # Authentication flow
|
|
180
|
+
│ ├── login.ts # login() + refreshToken() + getApiKey()
|
|
181
|
+
│ └── validate.ts # sanitizeApiKey() + isValidApiKey()
|
|
182
|
+
├── formatters/ # Pure display formatting
|
|
183
|
+
│ └── stability.ts # formatHealthStatus() + formatConfidence()
|
|
184
|
+
├── cache.ts # Local model cache (read/write)
|
|
185
|
+
├── errors.ts # API error parsing + friendly messages
|
|
186
|
+
└── stub.d.ts # Type stubs for pi peer dependency
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
**Key principles:**
|
|
190
|
+
- Each file has exactly one responsibility
|
|
191
|
+
- `api/` modules only handle HTTP — no business logic
|
|
192
|
+
- `providers/` modules are pure functions — no side effects
|
|
193
|
+
- `formatters/` modules are pure — no network calls
|
|
194
|
+
- `auth/` separates input validation from login orchestration
|
|
195
|
+
- Tests mirror the source structure and mock network boundaries
|
|
196
|
+
|
|
154
197
|
## Contributing
|
|
155
198
|
|
|
156
199
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, PR expectations, and commit message rules.
|
package/index.ts
CHANGED
|
@@ -5,27 +5,39 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
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"
|
|
8
|
+
import { fetchOpenModelModels } from "./src/api/models.ts"
|
|
9
|
+
import { login, refreshToken, getApiKey } from "./src/auth/login.ts"
|
|
10
10
|
import {
|
|
11
11
|
fetchModelStabilitySummary,
|
|
12
12
|
fetchModelStabilityDetail,
|
|
13
|
-
|
|
14
|
-
} from "./src/stability.ts"
|
|
13
|
+
} from "./src/api/stability.ts"
|
|
14
|
+
import { formatHealthStatus } from "./src/formatters/stability.ts"
|
|
15
15
|
import { friendlyMessage } from "./src/errors.ts"
|
|
16
|
+
import { readModelCache, writeModelCache } from "./src/cache.ts"
|
|
17
|
+
import { readFileSync } from "node:fs"
|
|
16
18
|
import { homedir } from "node:os"
|
|
17
19
|
|
|
18
20
|
export default async function (pi: ExtensionAPI) {
|
|
19
21
|
let models: Awaited<ReturnType<typeof fetchOpenModelModels>> = []
|
|
20
22
|
let modelError: string | null = null
|
|
23
|
+
let fromCache = false
|
|
21
24
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
// Try local cache first to avoid hitting the API on every startup
|
|
26
|
+
const cached = await readModelCache()
|
|
27
|
+
if (cached) {
|
|
28
|
+
models = cached
|
|
29
|
+
fromCache = true
|
|
30
|
+
} else {
|
|
31
|
+
try {
|
|
32
|
+
models = await fetchOpenModelModels()
|
|
33
|
+
// Fire-and-forget cache write (failures are silently ignored)
|
|
34
|
+
writeModelCache(models)
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (error instanceof TypeError && error.message.includes("fetch")) {
|
|
37
|
+
modelError = "🌐 Network error: check your internet connection"
|
|
38
|
+
} else {
|
|
39
|
+
modelError = `⚠️ ${error instanceof Error ? error.message : "Could not load models"}`
|
|
40
|
+
}
|
|
29
41
|
}
|
|
30
42
|
}
|
|
31
43
|
|
|
@@ -54,6 +66,9 @@ export default async function (pi: ExtensionAPI) {
|
|
|
54
66
|
if (model.thinkingLevelMap) {
|
|
55
67
|
config.thinkingLevelMap = model.thinkingLevelMap
|
|
56
68
|
}
|
|
69
|
+
if (model.compat) {
|
|
70
|
+
config.compat = model.compat
|
|
71
|
+
}
|
|
57
72
|
return config
|
|
58
73
|
}),
|
|
59
74
|
})
|
|
@@ -70,7 +85,6 @@ export default async function (pi: ExtensionAPI) {
|
|
|
70
85
|
// Detect if user has configured an API key in auth.json
|
|
71
86
|
let hasApiKey = false
|
|
72
87
|
try {
|
|
73
|
-
const { readFileSync } = await import("node:fs")
|
|
74
88
|
const authPath = `${homedir()}/.pi/agent/auth.json`
|
|
75
89
|
const content = readFileSync(authPath, "utf-8")
|
|
76
90
|
const data = JSON.parse(content)
|
|
@@ -83,7 +97,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
83
97
|
"╔══════════════════════════════════╗",
|
|
84
98
|
"║ OpenModel.ai ║",
|
|
85
99
|
"╠══════════════════════════════════╣",
|
|
86
|
-
`║ Models: ${String(count).padStart(3)} loaded
|
|
100
|
+
`║ Models: ${String(count).padStart(3)} loaded${fromCache ? " (cached)" : ""} ║`,
|
|
87
101
|
hasApiKey ? "║ API Key: ✅ Configured ║" : "║ API Key: ❌ Not configured ║",
|
|
88
102
|
"╠══════════════════════════════════╣",
|
|
89
103
|
"║ Commands: ║",
|
|
@@ -115,7 +129,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
115
129
|
try {
|
|
116
130
|
if (args?.trim()) {
|
|
117
131
|
const name = args.trim()
|
|
118
|
-
const detail = await fetchModelStabilityDetail(name)
|
|
132
|
+
const detail = await fetchModelStabilityDetail(name, { signal: ctx.signal })
|
|
119
133
|
const lines = [
|
|
120
134
|
`📊 ${detail.model_name}`,
|
|
121
135
|
`━━━━━━━━━━━━━━━━━━━━━━`,
|
|
@@ -128,7 +142,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
128
142
|
]
|
|
129
143
|
ctx.ui.notify(lines.join("\n"), "info")
|
|
130
144
|
} else {
|
|
131
|
-
const summary = await fetchModelStabilitySummary()
|
|
145
|
+
const summary = await fetchModelStabilitySummary({ signal: ctx.signal })
|
|
132
146
|
if (summary.length === 0) {
|
|
133
147
|
ctx.ui.notify("📊 No stability data available for any model yet.", "warning")
|
|
134
148
|
return
|
package/package.json
CHANGED
|
@@ -3,13 +3,25 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Fetches available models from OpenModel's public API (no auth required).
|
|
5
5
|
* Pricing, context window, and capabilities are all provided by the API.
|
|
6
|
+
*
|
|
7
|
+
* This module owns the orchestration — ping both endpoints, merge results,
|
|
8
|
+
* and return canonical model objects. Provider-specific logic (compat,
|
|
9
|
+
* protocols, pricing) is delegated to src/providers/*.
|
|
6
10
|
*/
|
|
7
11
|
|
|
8
|
-
import { parseWebError, parseProxyError, friendlyMessage } from "
|
|
12
|
+
import { parseWebError, parseProxyError, friendlyMessage } from "../errors.ts"
|
|
13
|
+
import { pricePerMillion } from "../providers/pricing.ts"
|
|
14
|
+
import { determineApi, inferApiFromProvider, thinkingLevelMapForApi } from "../providers/protocols.ts"
|
|
15
|
+
import { compatForProvider } from "../providers/compat.ts"
|
|
16
|
+
import type { ApiProtocol } from "../providers/protocols.ts"
|
|
9
17
|
|
|
10
18
|
const DEFAULT_WEB_MODELS_URL = "https://api.openmodel.ai/web/v1/models"
|
|
11
19
|
export const DEFAULT_LEGACY_MODELS_URL = "https://api.openmodel.ai/v1/models"
|
|
12
20
|
|
|
21
|
+
// ──────────────────────────────────────────────
|
|
22
|
+
// Public model interface
|
|
23
|
+
// ──────────────────────────────────────────────
|
|
24
|
+
|
|
13
25
|
export interface OpenModelProviderModel {
|
|
14
26
|
id: string
|
|
15
27
|
name: string
|
|
@@ -19,9 +31,14 @@ export interface OpenModelProviderModel {
|
|
|
19
31
|
cost: { input: number; output: number; cacheRead: number; cacheWrite: number }
|
|
20
32
|
contextWindow: number
|
|
21
33
|
maxTokens: number
|
|
22
|
-
api:
|
|
34
|
+
api: ApiProtocol
|
|
35
|
+
compat?: Record<string, unknown>
|
|
23
36
|
}
|
|
24
37
|
|
|
38
|
+
// ──────────────────────────────────────────────
|
|
39
|
+
// Internal API response types
|
|
40
|
+
// ──────────────────────────────────────────────
|
|
41
|
+
|
|
25
42
|
interface WebApiModel {
|
|
26
43
|
key: string
|
|
27
44
|
provider_key: string
|
|
@@ -58,41 +75,10 @@ interface LegacyApiResponse {
|
|
|
58
75
|
object: string
|
|
59
76
|
}
|
|
60
77
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
78
|
+
// ──────────────────────────────────────────────
|
|
79
|
+
// Fetch: Web API (public, pageable)
|
|
80
|
+
// ──────────────────────────────────────────────
|
|
65
81
|
|
|
66
|
-
function determineApi(protocols: string[], provider: string): "anthropic-messages" | "openai-responses" | "google-generative-ai" | null {
|
|
67
|
-
if (protocols.includes("messages")) return "anthropic-messages"
|
|
68
|
-
if (protocols.includes("responses")) return "openai-responses"
|
|
69
|
-
if (protocols.includes("gemini")) return "google-generative-ai"
|
|
70
|
-
return null
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function thinkingLevelMapForApi(api: "anthropic-messages" | "openai-responses" | "google-generative-ai"): Partial<Record<"off" | "minimal" | "low" | "medium" | "high" | "xhigh", string | null>> {
|
|
74
|
-
if (api === "anthropic-messages") {
|
|
75
|
-
return {
|
|
76
|
-
minimal: "low",
|
|
77
|
-
low: "medium",
|
|
78
|
-
medium: "high",
|
|
79
|
-
high: "high",
|
|
80
|
-
xhigh: "max",
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
if (api === "openai-responses") {
|
|
84
|
-
return {
|
|
85
|
-
minimal: "low",
|
|
86
|
-
low: "low",
|
|
87
|
-
medium: "medium",
|
|
88
|
-
high: "high",
|
|
89
|
-
xhigh: "high",
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
return {}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** Fetch all models from the web API (public, no auth required) */
|
|
96
82
|
async function fetchWebModels(options?: {
|
|
97
83
|
url?: string
|
|
98
84
|
fetchImpl?: typeof fetch
|
|
@@ -113,12 +99,16 @@ async function fetchWebModels(options?: {
|
|
|
113
99
|
let body: any
|
|
114
100
|
try { body = await response.json() } catch {}
|
|
115
101
|
const err = parseWebError(body)
|
|
116
|
-
throw new Error(
|
|
102
|
+
throw new Error(
|
|
103
|
+
`Failed to fetch models: ${response.status} ${err.code} — ${friendlyMessage(err.code, err.message)}`,
|
|
104
|
+
)
|
|
117
105
|
}
|
|
118
106
|
|
|
119
107
|
const body = (await response.json()) as WebApiResponse
|
|
120
108
|
if (!body.success) {
|
|
121
|
-
throw new Error(
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Failed to fetch models — ${friendlyMessage("INTERNAL_ERROR", "Unknown error")}`,
|
|
111
|
+
)
|
|
122
112
|
}
|
|
123
113
|
|
|
124
114
|
totalPages = body.meta.pagination.totalPages
|
|
@@ -131,7 +121,10 @@ async function fetchWebModels(options?: {
|
|
|
131
121
|
return modelMap
|
|
132
122
|
}
|
|
133
123
|
|
|
134
|
-
|
|
124
|
+
// ──────────────────────────────────────────────
|
|
125
|
+
// Fetch: Legacy API (requires API key)
|
|
126
|
+
// ──────────────────────────────────────────────
|
|
127
|
+
|
|
135
128
|
async function fetchLegacyModels(options?: {
|
|
136
129
|
url?: string
|
|
137
130
|
fetchImpl?: typeof fetch
|
|
@@ -147,7 +140,9 @@ async function fetchLegacyModels(options?: {
|
|
|
147
140
|
let body: any
|
|
148
141
|
try { body = await response.json() } catch {}
|
|
149
142
|
const err = parseProxyError(body)
|
|
150
|
-
throw new Error(
|
|
143
|
+
throw new Error(
|
|
144
|
+
`Failed to fetch models: ${response.status} — ${friendlyMessage(err.code, err.message)}`,
|
|
145
|
+
)
|
|
151
146
|
}
|
|
152
147
|
|
|
153
148
|
const body = (await response.json()) as LegacyApiResponse
|
|
@@ -162,7 +157,17 @@ async function fetchLegacyModels(options?: {
|
|
|
162
157
|
return modelMap
|
|
163
158
|
}
|
|
164
159
|
|
|
165
|
-
|
|
160
|
+
// ──────────────────────────────────────────────
|
|
161
|
+
// Orchestration
|
|
162
|
+
// ──────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Fetch all models from OpenModel API (public, no auth required for web endpoint).
|
|
166
|
+
*
|
|
167
|
+
* Combines pricing/capabilities from the web API with protocol info from
|
|
168
|
+
* the legacy endpoint. If the legacy endpoint fails (e.g., no API key),
|
|
169
|
+
* protocols are inferred from the provider name.
|
|
170
|
+
*/
|
|
166
171
|
export async function fetchOpenModelModels(options?: {
|
|
167
172
|
webUrl?: string
|
|
168
173
|
legacyUrl?: string
|
|
@@ -178,33 +183,38 @@ export async function fetchOpenModelModels(options?: {
|
|
|
178
183
|
const models: OpenModelProviderModel[] = []
|
|
179
184
|
|
|
180
185
|
for (const [id, web] of webModels) {
|
|
181
|
-
// Skip image-only models
|
|
182
|
-
if (
|
|
186
|
+
// Skip image-only models (e.g., DALL-E)
|
|
187
|
+
if (
|
|
188
|
+
web.supports.supports_image_generation &&
|
|
189
|
+
!web.supports.supports_vision &&
|
|
190
|
+
!web.supports.supports_reasoning
|
|
191
|
+
) {
|
|
183
192
|
continue
|
|
184
193
|
}
|
|
185
194
|
|
|
195
|
+
// Determine API protocol
|
|
186
196
|
const legacy = legacyModels.get(id)
|
|
187
197
|
const protocols = legacy?.supported_protocols ?? []
|
|
188
198
|
let api = determineApi(protocols, web.provider_key)
|
|
189
199
|
if (!api) {
|
|
190
|
-
|
|
191
|
-
if (["openai"].includes(web.provider_key)) api = "openai-responses"
|
|
192
|
-
else if (["gemini"].includes(web.provider_key)) api = "google-generative-ai"
|
|
193
|
-
else api = "anthropic-messages"
|
|
200
|
+
api = inferApiFromProvider(web.provider_key)
|
|
194
201
|
}
|
|
195
202
|
|
|
203
|
+
// Parse pricing
|
|
196
204
|
const inputPrice = pricePerMillion(web.prices.input_cost_per_token as number)
|
|
197
205
|
const outputPrice = pricePerMillion(web.prices.output_cost_per_token as number)
|
|
198
206
|
const cacheRead = pricePerMillion(web.prices.cache_read_input_token_cost as number)
|
|
199
207
|
const cacheWrite = pricePerMillion(web.prices.cache_creation_input_token_cost as number)
|
|
200
208
|
|
|
209
|
+
// Build model config
|
|
201
210
|
const reasoning = web.supports.supports_reasoning ?? false
|
|
211
|
+
const compat = compatForProvider(web.provider_key, api, reasoning)
|
|
202
212
|
|
|
203
213
|
const base = {
|
|
204
214
|
id,
|
|
205
215
|
name: id,
|
|
206
216
|
reasoning,
|
|
207
|
-
input: web.supports.supports_vision ? ["text", "image"] as const : ["text"] as const,
|
|
217
|
+
input: web.supports.supports_vision ? (["text", "image"] as const) : (["text"] as const),
|
|
208
218
|
cost: {
|
|
209
219
|
input: inputPrice * (web.price_multiplier ?? 1),
|
|
210
220
|
output: outputPrice * (web.price_multiplier ?? 1),
|
|
@@ -219,6 +229,7 @@ export async function fetchOpenModelModels(options?: {
|
|
|
219
229
|
const model = {
|
|
220
230
|
...base,
|
|
221
231
|
...(reasoning ? { thinkingLevelMap: thinkingLevelMapForApi(api) } : {}),
|
|
232
|
+
...(compat ? { compat } : {}),
|
|
222
233
|
} as unknown as OpenModelProviderModel
|
|
223
234
|
|
|
224
235
|
models.push(model)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenModel.ai Model Stability API client.
|
|
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
|
+
* This module is pure fetching — formatting is in formatters/stability.ts.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { parseWebError, friendlyMessage } from "../errors.ts"
|
|
15
|
+
|
|
16
|
+
export const STABILITY_SUMMARY_URL =
|
|
17
|
+
"https://api.openmodel.ai/web/v1/model-stability/summary"
|
|
18
|
+
|
|
19
|
+
/** Health status derived from success rate */
|
|
20
|
+
export type HealthStatus =
|
|
21
|
+
| "operational"
|
|
22
|
+
| "healthy"
|
|
23
|
+
| "degraded"
|
|
24
|
+
| "unstable"
|
|
25
|
+
| "no_data"
|
|
26
|
+
|
|
27
|
+
/** Confidence level based on sample size */
|
|
28
|
+
export type ConfidenceLevel = "high" | "medium" | "low"
|
|
29
|
+
|
|
30
|
+
/** Stability summary for a single model */
|
|
31
|
+
export interface ModelStability {
|
|
32
|
+
model_name: string
|
|
33
|
+
success_rate: number
|
|
34
|
+
avg_latency_ms: number
|
|
35
|
+
avg_tps: number
|
|
36
|
+
confidence: ConfidenceLevel
|
|
37
|
+
health_status: HealthStatus
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Stability summary for a single model with time series */
|
|
41
|
+
export interface ModelStabilityDetail {
|
|
42
|
+
model_name: string
|
|
43
|
+
confidence: ConfidenceLevel
|
|
44
|
+
summary: {
|
|
45
|
+
success_rate: number
|
|
46
|
+
avg_latency_ms: number
|
|
47
|
+
avg_ttft_ms: number
|
|
48
|
+
avg_tps: number
|
|
49
|
+
}
|
|
50
|
+
series: Array<{
|
|
51
|
+
ts: number
|
|
52
|
+
success_rate: number
|
|
53
|
+
avg_latency_ms: number
|
|
54
|
+
avg_ttft_ms: number
|
|
55
|
+
avg_tps: number
|
|
56
|
+
confidence: ConfidenceLevel
|
|
57
|
+
}>
|
|
58
|
+
updated_at: number
|
|
59
|
+
health_status: HealthStatus
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Fetch stability summary for all models */
|
|
63
|
+
export async function fetchModelStabilitySummary(options?: {
|
|
64
|
+
url?: string
|
|
65
|
+
fetchImpl?: typeof fetch
|
|
66
|
+
hours?: number
|
|
67
|
+
signal?: AbortSignal
|
|
68
|
+
}): Promise<ModelStability[]> {
|
|
69
|
+
const url = options?.url ?? STABILITY_SUMMARY_URL
|
|
70
|
+
const fetchImpl = options?.fetchImpl ?? fetch
|
|
71
|
+
const hours = options?.hours ?? 24
|
|
72
|
+
|
|
73
|
+
const params = new URLSearchParams({ hours: String(hours) })
|
|
74
|
+
const response = await fetchImpl(`${url}?${params}`, {
|
|
75
|
+
headers: { accept: "application/json" },
|
|
76
|
+
signal: options?.signal ?? null,
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
if (!response.ok) {
|
|
80
|
+
let errBody: any
|
|
81
|
+
try { errBody = await response.json() } catch {}
|
|
82
|
+
const err = parseWebError(errBody)
|
|
83
|
+
throw new Error(`stability — ${friendlyMessage(err.code, err.message)}`)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const body = (await response.json()) as {
|
|
87
|
+
success: boolean
|
|
88
|
+
data: Array<{
|
|
89
|
+
model_name: string
|
|
90
|
+
success_rate: number
|
|
91
|
+
avg_latency_ms: number
|
|
92
|
+
avg_tps: number
|
|
93
|
+
confidence: ConfidenceLevel
|
|
94
|
+
}>
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!body.success) {
|
|
98
|
+
throw new Error(`stability — ${friendlyMessage("INTERNAL_ERROR", "Summary request failed")}`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return body.data.map((item) => ({
|
|
102
|
+
...item,
|
|
103
|
+
health_status: determineHealthFallback(item.success_rate, item.confidence),
|
|
104
|
+
}))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Fetch stability detail for a specific model */
|
|
108
|
+
export async function fetchModelStabilityDetail(
|
|
109
|
+
modelKey: string,
|
|
110
|
+
options?: {
|
|
111
|
+
fetchImpl?: typeof fetch
|
|
112
|
+
hours?: number
|
|
113
|
+
signal?: AbortSignal
|
|
114
|
+
},
|
|
115
|
+
): Promise<ModelStabilityDetail> {
|
|
116
|
+
const fetchImpl = options?.fetchImpl ?? fetch
|
|
117
|
+
const hours = options?.hours ?? 24
|
|
118
|
+
|
|
119
|
+
const params = new URLSearchParams({ hours: String(hours) })
|
|
120
|
+
const response = await fetchImpl(
|
|
121
|
+
`https://api.openmodel.ai/web/v1/model-stability/${encodeURIComponent(modelKey)}?${params}`,
|
|
122
|
+
{
|
|
123
|
+
headers: { accept: "application/json" },
|
|
124
|
+
signal: options?.signal ?? null,
|
|
125
|
+
},
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if (!response.ok) {
|
|
129
|
+
let errBody: any
|
|
130
|
+
try { errBody = await response.json() } catch {}
|
|
131
|
+
const err = parseWebError(errBody)
|
|
132
|
+
throw new Error(`stability — ${friendlyMessage(err.code, err.message)}`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const body = (await response.json()) as {
|
|
136
|
+
success: boolean
|
|
137
|
+
data: {
|
|
138
|
+
model_name: string
|
|
139
|
+
confidence: ConfidenceLevel
|
|
140
|
+
summary: {
|
|
141
|
+
success_rate: number
|
|
142
|
+
avg_latency_ms: number
|
|
143
|
+
avg_ttft_ms: number
|
|
144
|
+
avg_tps: number
|
|
145
|
+
}
|
|
146
|
+
series: Array<{
|
|
147
|
+
ts: number
|
|
148
|
+
success_rate: number
|
|
149
|
+
avg_latency_ms: number
|
|
150
|
+
avg_ttft_ms: number
|
|
151
|
+
avg_tps: number
|
|
152
|
+
confidence: ConfidenceLevel
|
|
153
|
+
}>
|
|
154
|
+
updated_at: number
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!body.success) {
|
|
159
|
+
throw new Error(`stability — ${friendlyMessage("NOT_FOUND", `Model "${modelKey}" not found`)}`)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
...body.data,
|
|
164
|
+
health_status: determineHealthFallback(
|
|
165
|
+
body.data.summary.success_rate,
|
|
166
|
+
body.data.confidence,
|
|
167
|
+
),
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Inline fallback to avoid circular dependency with formatters.
|
|
173
|
+
* determineHealth() in formatters/stability.ts is the canonical version.
|
|
174
|
+
*/
|
|
175
|
+
function determineHealthFallback(
|
|
176
|
+
successRate: number,
|
|
177
|
+
confidence: ConfidenceLevel,
|
|
178
|
+
): HealthStatus {
|
|
179
|
+
if (confidence === "low") return "no_data"
|
|
180
|
+
if (successRate >= 99.9) return "operational"
|
|
181
|
+
if (successRate >= 99) return "healthy"
|
|
182
|
+
if (successRate >= 95) return "degraded"
|
|
183
|
+
return "unstable"
|
|
184
|
+
}
|