pi-openmodel-provider 0.2.16 → 0.2.18
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.md +10 -9
- package/CHANGELOG.md +32 -0
- package/LICENSE +1 -1
- package/README.md +34 -0
- package/index.ts +6 -10
- package/package.json +3 -2
- package/src/{models.ts → api/models.ts} +72 -100
- package/src/api/stability.ts +163 -0
- package/src/auth/login.ts +132 -0
- package/src/auth/validate.ts +33 -0
- package/src/cache.ts +21 -6
- package/src/formatters/stability.ts +36 -0
- package/src/health.ts +30 -0
- package/src/providers/compat.ts +47 -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 -204
package/AGENTS.md
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# Agent Instructions
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
3
|
+
This package is **pi-openmodel-provider** for [OpenModel.ai](https://www.openmodel.ai) — **NOT** OpenRouter.
|
|
4
|
+
|
|
5
|
+
## Key facts for LLMs
|
|
6
|
+
|
|
7
|
+
- Models are fetched dynamically from OpenModel's API at startup. No hardcoded model list.
|
|
8
|
+
- Cached at `~/.pi/agent/cache/openmodel-models.json` with 5-min TTL.
|
|
9
|
+
- Compat flags per provider (openai, anthropic, deepseek, qwen, zai) for optimal protocol compatibility.
|
|
10
|
+
- If `/v1/models` fails (no API key), protocols are inferred from the provider.
|
|
11
|
+
- Full docs at `.agents/skills/pi-openmodel-info/SKILL.md`.
|
|
12
|
+
- General project docs: [README.md](README.md), [CONTRIBUTING.md](CONTRIBUTING.md), [RELEASE.md](RELEASE.md).
|
|
12
13
|
|
|
13
14
|
**Maintained by** [Ivan Gabriel Yarupaitan Rivera](https://www.vanchi.pro/)
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,38 @@ 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.18] - 2026-06-26
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `src/health.ts` — shared `HealthStatus` type and `determineHealth()` function, extracted from `src/api/stability.ts` and `src/formatters/stability.ts` to eliminate code duplication
|
|
12
|
+
- `tests/test-cache.ts` — 12 tests covering cache read (valid, expired, corrupted, missing) and write (success, directory creation, error suppression)
|
|
13
|
+
- `CacheFs` interface in `src/cache.ts` for dependency injection (matching `fetchImpl` pattern)
|
|
14
|
+
- `test:cache` npm script
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- `src/api/stability.ts` — removed local `determineHealthFallback()` copy, imports from `src/health.ts` instead
|
|
18
|
+
- `src/formatters/stability.ts` — removed local `determineHealth()` copy, imports from `src/health.ts` instead
|
|
19
|
+
- `src/api/models.ts` — eliminated unsafe `as unknown as OpenModelProviderModel` cast and 4 `as number` price casts via type annotation and `getNumberPrice()` helper; removed `as const` on model base object
|
|
20
|
+
- `src/providers/compat.ts` — removed unused `api` parameter from `compatForProvider()`
|
|
21
|
+
- `index.ts` — replaced blocking `readFileSync` with `await readFile` from `fs/promises`
|
|
22
|
+
- `LICENSE` — added copyright holder name
|
|
23
|
+
- `tsconfig.json` — enabled `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noFallthroughCasesInSwitch`
|
|
24
|
+
- `AGENTS.md` — reduced to LLM-focused bullet points with references to README and SKILL.md
|
|
25
|
+
- `README.md` — added `health.ts` to architecture tree, added `test:cache` to development section
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
- **Major refactor (SRP):** Reorganized `src/` into single-responsibility modules
|
|
29
|
+
- `api/` — network fetching only (models, stability)
|
|
30
|
+
- `providers/` — pure business logic (compat, protocols, pricing)
|
|
31
|
+
- `auth/` — login orchestration + input validation separated
|
|
32
|
+
- `formatters/` — pure display formatting (stability health/confidence)
|
|
33
|
+
- Each file now has exactly one responsibility (was 1-4 before)
|
|
34
|
+
- `index.ts` — replaced dynamic `import("node:fs")` with static top-level import
|
|
35
|
+
|
|
36
|
+
### Documentation
|
|
37
|
+
- `README.md` — added Codebase Architecture section with module descriptions
|
|
38
|
+
- `CONTRIBUTING.md` — added Codebase Architecture section with contributor guidelines
|
|
39
|
+
|
|
8
40
|
## [0.2.16] - 2026-06-23
|
|
9
41
|
|
|
10
42
|
### Added
|
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -93,6 +93,7 @@ Model pricing is fetched live from OpenModel's public API (`/web/v1/models`). Ea
|
|
|
93
93
|
- **Friendly error messages** with emojis and actionable guidance
|
|
94
94
|
- **No hardcoding** — new models, pricing, and capabilities appear automatically
|
|
95
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
|
|
96
97
|
|
|
97
98
|
## Error handling
|
|
98
99
|
|
|
@@ -160,8 +161,41 @@ npm run test:auth
|
|
|
160
161
|
npm run test:pricing
|
|
161
162
|
npm run test:stability
|
|
162
163
|
npm run test:edge
|
|
164
|
+
npm run test:cache
|
|
163
165
|
```
|
|
164
166
|
|
|
167
|
+
### Codebase Architecture
|
|
168
|
+
|
|
169
|
+
The source code is organized by responsibility following the Single Responsibility Principle:
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
src/
|
|
173
|
+
├── api/ # Network fetching (models, stability)
|
|
174
|
+
│ ├── models.ts # fetchOpenModelModels() — model discovery orchestration
|
|
175
|
+
│ └── stability.ts # fetchModelStabilitySummary/Detail()
|
|
176
|
+
├── providers/ # Provider-specific business logic
|
|
177
|
+
│ ├── compat.ts # compatForProvider() — per-provider compatibility flags
|
|
178
|
+
│ ├── protocols.ts # determineApi() + thinkingLevelMapForApi()
|
|
179
|
+
│ └── pricing.ts # pricePerMillion() — cost-per-token conversion
|
|
180
|
+
├── auth/ # Authentication flow
|
|
181
|
+
│ ├── login.ts # login() + refreshToken() + getApiKey()
|
|
182
|
+
│ └── validate.ts # sanitizeApiKey() + isValidApiKey()
|
|
183
|
+
├── formatters/ # Pure display formatting
|
|
184
|
+
│ └── stability.ts # formatHealthStatus() + formatConfidence()
|
|
185
|
+
├── health.ts # Shared health status determination
|
|
186
|
+
├── cache.ts # Local model cache (read/write)
|
|
187
|
+
├── errors.ts # API error parsing + friendly messages
|
|
188
|
+
└── stub.d.ts # Type stubs for pi peer dependency
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
**Key principles:**
|
|
192
|
+
- Each file has exactly one responsibility
|
|
193
|
+
- `api/` modules only handle HTTP — no business logic
|
|
194
|
+
- `providers/` modules are pure functions — no side effects
|
|
195
|
+
- `formatters/` modules are pure — no network calls
|
|
196
|
+
- `auth/` separates input validation from login orchestration
|
|
197
|
+
- Tests mirror the source structure and mock network boundaries
|
|
198
|
+
|
|
165
199
|
## Contributing
|
|
166
200
|
|
|
167
201
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, PR expectations, and commit message rules.
|
package/index.ts
CHANGED
|
@@ -5,15 +5,15 @@
|
|
|
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"
|
|
15
|
-
import { friendlyMessage } from "./src/errors.ts"
|
|
13
|
+
} from "./src/api/stability.ts"
|
|
14
|
+
import { formatHealthStatus } from "./src/formatters/stability.ts"
|
|
16
15
|
import { readModelCache, writeModelCache } from "./src/cache.ts"
|
|
16
|
+
import { readFile } from "node:fs/promises"
|
|
17
17
|
import { homedir } from "node:os"
|
|
18
18
|
|
|
19
19
|
export default async function (pi: ExtensionAPI) {
|
|
@@ -77,16 +77,12 @@ export default async function (pi: ExtensionAPI) {
|
|
|
77
77
|
description: "Show OpenModel provider status",
|
|
78
78
|
handler: async (_args: string, ctx: any) => {
|
|
79
79
|
const count = models.length
|
|
80
|
-
const status = count > 0
|
|
81
|
-
? `✅ ${count} models loaded`
|
|
82
|
-
: modelError ?? "❌ No models loaded"
|
|
83
80
|
|
|
84
81
|
// Detect if user has configured an API key in auth.json
|
|
85
82
|
let hasApiKey = false
|
|
86
83
|
try {
|
|
87
|
-
const { readFileSync } = await import("node:fs")
|
|
88
84
|
const authPath = `${homedir()}/.pi/agent/auth.json`
|
|
89
|
-
const content =
|
|
85
|
+
const content = await readFile(authPath, "utf-8")
|
|
90
86
|
const data = JSON.parse(content)
|
|
91
87
|
hasApiKey = !!(data.openmodel?.access || data.openmodel?.refresh)
|
|
92
88
|
} catch {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-openmodel-provider",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.18",
|
|
4
4
|
"description": "pi custom provider for OpenModel.ai - Multi-model AI gateway",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
"test:pricing": "tsx tests/test-pricing.ts",
|
|
41
41
|
"test:stability": "tsx tests/test-stability.ts",
|
|
42
42
|
"test:edge": "tsx tests/test-edge-cases.ts",
|
|
43
|
-
"test": "tsx tests/test-
|
|
43
|
+
"test:cache": "tsx tests/test-cache.ts",
|
|
44
|
+
"test": "tsx tests/test-models.ts && tsx tests/test-auth.ts && tsx tests/test-pricing.ts && tsx tests/test-stability.ts && tsx tests/test-edge-cases.ts && tsx tests/test-cache.ts"
|
|
44
45
|
},
|
|
45
46
|
"pi": {
|
|
46
47
|
"extensions": [
|
|
@@ -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,10 +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
|
|
23
35
|
compat?: Record<string, unknown>
|
|
24
36
|
}
|
|
25
37
|
|
|
38
|
+
// ──────────────────────────────────────────────
|
|
39
|
+
// Internal API response types
|
|
40
|
+
// ──────────────────────────────────────────────
|
|
41
|
+
|
|
26
42
|
interface WebApiModel {
|
|
27
43
|
key: string
|
|
28
44
|
provider_key: string
|
|
@@ -59,82 +75,10 @@ interface LegacyApiResponse {
|
|
|
59
75
|
object: string
|
|
60
76
|
}
|
|
61
77
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
78
|
+
// ──────────────────────────────────────────────
|
|
79
|
+
// Fetch: Web API (public, pageable)
|
|
80
|
+
// ──────────────────────────────────────────────
|
|
66
81
|
|
|
67
|
-
function determineApi(protocols: string[], provider: string): "anthropic-messages" | "openai-responses" | "google-generative-ai" | null {
|
|
68
|
-
if (protocols.includes("messages")) return "anthropic-messages"
|
|
69
|
-
if (protocols.includes("responses")) return "openai-responses"
|
|
70
|
-
if (protocols.includes("gemini")) return "google-generative-ai"
|
|
71
|
-
return null
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Determine compat flags based on provider and API.
|
|
76
|
-
* These tell pi about provider-specific quirks and capabilities.
|
|
77
|
-
*/
|
|
78
|
-
function compatForProvider(
|
|
79
|
-
providerKey: string,
|
|
80
|
-
api: "anthropic-messages" | "openai-responses" | "google-generative-ai",
|
|
81
|
-
reasoning: boolean,
|
|
82
|
-
): Record<string, unknown> | undefined {
|
|
83
|
-
switch (providerKey) {
|
|
84
|
-
case "openai":
|
|
85
|
-
return { supportsReasoningEffort: true }
|
|
86
|
-
case "deepseek":
|
|
87
|
-
if (reasoning) {
|
|
88
|
-
return { thinkingFormat: "deepseek" }
|
|
89
|
-
}
|
|
90
|
-
return undefined
|
|
91
|
-
case "anthropic":
|
|
92
|
-
return {
|
|
93
|
-
sendSessionAffinityHeaders: true,
|
|
94
|
-
supportsCacheControlOnTools: true,
|
|
95
|
-
supportsEagerToolInputStreaming: true,
|
|
96
|
-
}
|
|
97
|
-
case "google":
|
|
98
|
-
case "gemini":
|
|
99
|
-
return undefined
|
|
100
|
-
case "qwen":
|
|
101
|
-
if (reasoning) {
|
|
102
|
-
return { thinkingFormat: "qwen-chat-template" }
|
|
103
|
-
}
|
|
104
|
-
return undefined
|
|
105
|
-
case "zai":
|
|
106
|
-
if (reasoning) {
|
|
107
|
-
return { thinkingFormat: "zai" }
|
|
108
|
-
}
|
|
109
|
-
return undefined
|
|
110
|
-
default:
|
|
111
|
-
return undefined
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function thinkingLevelMapForApi(api: "anthropic-messages" | "openai-responses" | "google-generative-ai"): Partial<Record<"off" | "minimal" | "low" | "medium" | "high" | "xhigh", string | null>> {
|
|
116
|
-
if (api === "anthropic-messages") {
|
|
117
|
-
return {
|
|
118
|
-
minimal: "low",
|
|
119
|
-
low: "medium",
|
|
120
|
-
medium: "high",
|
|
121
|
-
high: "high",
|
|
122
|
-
xhigh: "max",
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
if (api === "openai-responses") {
|
|
126
|
-
return {
|
|
127
|
-
minimal: "low",
|
|
128
|
-
low: "low",
|
|
129
|
-
medium: "medium",
|
|
130
|
-
high: "high",
|
|
131
|
-
xhigh: "high",
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
return {}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** Fetch all models from the web API (public, no auth required) */
|
|
138
82
|
async function fetchWebModels(options?: {
|
|
139
83
|
url?: string
|
|
140
84
|
fetchImpl?: typeof fetch
|
|
@@ -155,12 +99,16 @@ async function fetchWebModels(options?: {
|
|
|
155
99
|
let body: any
|
|
156
100
|
try { body = await response.json() } catch {}
|
|
157
101
|
const err = parseWebError(body)
|
|
158
|
-
throw new Error(
|
|
102
|
+
throw new Error(
|
|
103
|
+
`Failed to fetch models: ${response.status} ${err.code} — ${friendlyMessage(err.code, err.message)}`,
|
|
104
|
+
)
|
|
159
105
|
}
|
|
160
106
|
|
|
161
107
|
const body = (await response.json()) as WebApiResponse
|
|
162
108
|
if (!body.success) {
|
|
163
|
-
throw new Error(
|
|
109
|
+
throw new Error(
|
|
110
|
+
`Failed to fetch models — ${friendlyMessage("INTERNAL_ERROR", "Unknown error")}`,
|
|
111
|
+
)
|
|
164
112
|
}
|
|
165
113
|
|
|
166
114
|
totalPages = body.meta.pagination.totalPages
|
|
@@ -173,7 +121,10 @@ async function fetchWebModels(options?: {
|
|
|
173
121
|
return modelMap
|
|
174
122
|
}
|
|
175
123
|
|
|
176
|
-
|
|
124
|
+
// ──────────────────────────────────────────────
|
|
125
|
+
// Fetch: Legacy API (requires API key)
|
|
126
|
+
// ──────────────────────────────────────────────
|
|
127
|
+
|
|
177
128
|
async function fetchLegacyModels(options?: {
|
|
178
129
|
url?: string
|
|
179
130
|
fetchImpl?: typeof fetch
|
|
@@ -189,7 +140,9 @@ async function fetchLegacyModels(options?: {
|
|
|
189
140
|
let body: any
|
|
190
141
|
try { body = await response.json() } catch {}
|
|
191
142
|
const err = parseProxyError(body)
|
|
192
|
-
throw new Error(
|
|
143
|
+
throw new Error(
|
|
144
|
+
`Failed to fetch models: ${response.status} — ${friendlyMessage(err.code, err.message)}`,
|
|
145
|
+
)
|
|
193
146
|
}
|
|
194
147
|
|
|
195
148
|
const body = (await response.json()) as LegacyApiResponse
|
|
@@ -204,7 +157,26 @@ async function fetchLegacyModels(options?: {
|
|
|
204
157
|
return modelMap
|
|
205
158
|
}
|
|
206
159
|
|
|
207
|
-
|
|
160
|
+
// ──────────────────────────────────────────────
|
|
161
|
+
// Orchestration
|
|
162
|
+
// ──────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
/** Safely extract a numeric price from the prices record */
|
|
165
|
+
function getNumberPrice(
|
|
166
|
+
prices: Record<string, number | Record<string, number>>,
|
|
167
|
+
key: string,
|
|
168
|
+
): number | undefined {
|
|
169
|
+
const val = prices[key]
|
|
170
|
+
return typeof val === "number" ? val : undefined
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Fetch all models from OpenModel API (public, no auth required for web endpoint).
|
|
175
|
+
*
|
|
176
|
+
* Combines pricing/capabilities from the web API with protocol info from
|
|
177
|
+
* the legacy endpoint. If the legacy endpoint fails (e.g., no API key),
|
|
178
|
+
* protocols are inferred from the provider name.
|
|
179
|
+
*/
|
|
208
180
|
export async function fetchOpenModelModels(options?: {
|
|
209
181
|
webUrl?: string
|
|
210
182
|
legacyUrl?: string
|
|
@@ -220,34 +192,38 @@ export async function fetchOpenModelModels(options?: {
|
|
|
220
192
|
const models: OpenModelProviderModel[] = []
|
|
221
193
|
|
|
222
194
|
for (const [id, web] of webModels) {
|
|
223
|
-
// Skip image-only models
|
|
224
|
-
if (
|
|
195
|
+
// Skip image-only models (e.g., DALL-E)
|
|
196
|
+
if (
|
|
197
|
+
web.supports.supports_image_generation &&
|
|
198
|
+
!web.supports.supports_vision &&
|
|
199
|
+
!web.supports.supports_reasoning
|
|
200
|
+
) {
|
|
225
201
|
continue
|
|
226
202
|
}
|
|
227
203
|
|
|
204
|
+
// Determine API protocol
|
|
228
205
|
const legacy = legacyModels.get(id)
|
|
229
206
|
const protocols = legacy?.supported_protocols ?? []
|
|
230
207
|
let api = determineApi(protocols, web.provider_key)
|
|
231
208
|
if (!api) {
|
|
232
|
-
|
|
233
|
-
if (["openai"].includes(web.provider_key)) api = "openai-responses"
|
|
234
|
-
else if (["gemini"].includes(web.provider_key)) api = "google-generative-ai"
|
|
235
|
-
else api = "anthropic-messages"
|
|
209
|
+
api = inferApiFromProvider(web.provider_key)
|
|
236
210
|
}
|
|
237
211
|
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
const
|
|
241
|
-
const
|
|
212
|
+
// Parse pricing (safely — some price fields may be Record<string, number>)
|
|
213
|
+
const inputPrice = pricePerMillion(getNumberPrice(web.prices, "input_cost_per_token"))
|
|
214
|
+
const outputPrice = pricePerMillion(getNumberPrice(web.prices, "output_cost_per_token"))
|
|
215
|
+
const cacheRead = pricePerMillion(getNumberPrice(web.prices, "cache_read_input_token_cost"))
|
|
216
|
+
const cacheWrite = pricePerMillion(getNumberPrice(web.prices, "cache_creation_input_token_cost"))
|
|
242
217
|
|
|
218
|
+
// Build model config
|
|
243
219
|
const reasoning = web.supports.supports_reasoning ?? false
|
|
244
|
-
const compat = compatForProvider(web.provider_key,
|
|
220
|
+
const compat = compatForProvider(web.provider_key, reasoning)
|
|
245
221
|
|
|
246
|
-
const
|
|
222
|
+
const model: OpenModelProviderModel = {
|
|
247
223
|
id,
|
|
248
224
|
name: id,
|
|
249
225
|
reasoning,
|
|
250
|
-
input: web.supports.supports_vision ? ["text", "image"] as const : ["text"] as const,
|
|
226
|
+
input: web.supports.supports_vision ? (["text", "image"] as const) : (["text"] as const),
|
|
251
227
|
cost: {
|
|
252
228
|
input: inputPrice * (web.price_multiplier ?? 1),
|
|
253
229
|
output: outputPrice * (web.price_multiplier ?? 1),
|
|
@@ -257,13 +233,9 @@ export async function fetchOpenModelModels(options?: {
|
|
|
257
233
|
contextWindow: web.max.max_input_tokens ?? 128_000,
|
|
258
234
|
maxTokens: web.max.max_output_tokens ?? web.max.max_tokens ?? 16_384,
|
|
259
235
|
api,
|
|
260
|
-
} as const
|
|
261
|
-
|
|
262
|
-
const model = {
|
|
263
|
-
...base,
|
|
264
236
|
...(reasoning ? { thinkingLevelMap: thinkingLevelMapForApi(api) } : {}),
|
|
265
237
|
...(compat ? { compat } : {}),
|
|
266
|
-
}
|
|
238
|
+
}
|
|
267
239
|
|
|
268
240
|
models.push(model)
|
|
269
241
|
}
|
|
@@ -0,0 +1,163 @@
|
|
|
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
|
+
import { determineHealth } from "../health.ts"
|
|
16
|
+
import type { HealthStatus } from "../health.ts"
|
|
17
|
+
|
|
18
|
+
export const STABILITY_SUMMARY_URL =
|
|
19
|
+
"https://api.openmodel.ai/web/v1/model-stability/summary"
|
|
20
|
+
|
|
21
|
+
/** Confidence level based on sample size */
|
|
22
|
+
export type ConfidenceLevel = "high" | "medium" | "low"
|
|
23
|
+
|
|
24
|
+
/** Stability summary for a single model */
|
|
25
|
+
export interface ModelStability {
|
|
26
|
+
model_name: string
|
|
27
|
+
success_rate: number
|
|
28
|
+
avg_latency_ms: number
|
|
29
|
+
avg_tps: number
|
|
30
|
+
confidence: ConfidenceLevel
|
|
31
|
+
health_status: HealthStatus
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Stability summary for a single model with time series */
|
|
35
|
+
export interface ModelStabilityDetail {
|
|
36
|
+
model_name: string
|
|
37
|
+
confidence: ConfidenceLevel
|
|
38
|
+
summary: {
|
|
39
|
+
success_rate: number
|
|
40
|
+
avg_latency_ms: number
|
|
41
|
+
avg_ttft_ms: number
|
|
42
|
+
avg_tps: number
|
|
43
|
+
}
|
|
44
|
+
series: Array<{
|
|
45
|
+
ts: number
|
|
46
|
+
success_rate: number
|
|
47
|
+
avg_latency_ms: number
|
|
48
|
+
avg_ttft_ms: number
|
|
49
|
+
avg_tps: number
|
|
50
|
+
confidence: ConfidenceLevel
|
|
51
|
+
}>
|
|
52
|
+
updated_at: number
|
|
53
|
+
health_status: HealthStatus
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Fetch stability summary for all models */
|
|
57
|
+
export async function fetchModelStabilitySummary(options?: {
|
|
58
|
+
url?: string
|
|
59
|
+
fetchImpl?: typeof fetch
|
|
60
|
+
hours?: number
|
|
61
|
+
signal?: AbortSignal
|
|
62
|
+
}): Promise<ModelStability[]> {
|
|
63
|
+
const url = options?.url ?? STABILITY_SUMMARY_URL
|
|
64
|
+
const fetchImpl = options?.fetchImpl ?? fetch
|
|
65
|
+
const hours = options?.hours ?? 24
|
|
66
|
+
|
|
67
|
+
const params = new URLSearchParams({ hours: String(hours) })
|
|
68
|
+
const response = await fetchImpl(`${url}?${params}`, {
|
|
69
|
+
headers: { accept: "application/json" },
|
|
70
|
+
signal: options?.signal ?? null,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
let errBody: any
|
|
75
|
+
try { errBody = await response.json() } catch {}
|
|
76
|
+
const err = parseWebError(errBody)
|
|
77
|
+
throw new Error(`stability — ${friendlyMessage(err.code, err.message)}`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const body = (await response.json()) as {
|
|
81
|
+
success: boolean
|
|
82
|
+
data: Array<{
|
|
83
|
+
model_name: string
|
|
84
|
+
success_rate: number
|
|
85
|
+
avg_latency_ms: number
|
|
86
|
+
avg_tps: number
|
|
87
|
+
confidence: ConfidenceLevel
|
|
88
|
+
}>
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!body.success) {
|
|
92
|
+
throw new Error(`stability — ${friendlyMessage("INTERNAL_ERROR", "Summary request failed")}`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return body.data.map((item) => ({
|
|
96
|
+
...item,
|
|
97
|
+
health_status: determineHealth(item.success_rate, item.confidence),
|
|
98
|
+
}))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Fetch stability detail for a specific model */
|
|
102
|
+
export async function fetchModelStabilityDetail(
|
|
103
|
+
modelKey: string,
|
|
104
|
+
options?: {
|
|
105
|
+
fetchImpl?: typeof fetch
|
|
106
|
+
hours?: number
|
|
107
|
+
signal?: AbortSignal
|
|
108
|
+
},
|
|
109
|
+
): Promise<ModelStabilityDetail> {
|
|
110
|
+
const fetchImpl = options?.fetchImpl ?? fetch
|
|
111
|
+
const hours = options?.hours ?? 24
|
|
112
|
+
|
|
113
|
+
const params = new URLSearchParams({ hours: String(hours) })
|
|
114
|
+
const response = await fetchImpl(
|
|
115
|
+
`https://api.openmodel.ai/web/v1/model-stability/${encodeURIComponent(modelKey)}?${params}`,
|
|
116
|
+
{
|
|
117
|
+
headers: { accept: "application/json" },
|
|
118
|
+
signal: options?.signal ?? null,
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if (!response.ok) {
|
|
123
|
+
let errBody: any
|
|
124
|
+
try { errBody = await response.json() } catch {}
|
|
125
|
+
const err = parseWebError(errBody)
|
|
126
|
+
throw new Error(`stability — ${friendlyMessage(err.code, err.message)}`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const body = (await response.json()) as {
|
|
130
|
+
success: boolean
|
|
131
|
+
data: {
|
|
132
|
+
model_name: string
|
|
133
|
+
confidence: ConfidenceLevel
|
|
134
|
+
summary: {
|
|
135
|
+
success_rate: number
|
|
136
|
+
avg_latency_ms: number
|
|
137
|
+
avg_ttft_ms: number
|
|
138
|
+
avg_tps: number
|
|
139
|
+
}
|
|
140
|
+
series: Array<{
|
|
141
|
+
ts: number
|
|
142
|
+
success_rate: number
|
|
143
|
+
avg_latency_ms: number
|
|
144
|
+
avg_ttft_ms: number
|
|
145
|
+
avg_tps: number
|
|
146
|
+
confidence: ConfidenceLevel
|
|
147
|
+
}>
|
|
148
|
+
updated_at: number
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!body.success) {
|
|
153
|
+
throw new Error(`stability — ${friendlyMessage("NOT_FOUND", `Model "${modelKey}" not found`)}`)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
...body.data,
|
|
158
|
+
health_status: determineHealth(
|
|
159
|
+
body.data.summary.success_rate,
|
|
160
|
+
body.data.confidence,
|
|
161
|
+
),
|
|
162
|
+
}
|
|
163
|
+
}
|