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.
@@ -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
  [![npm version](https://img.shields.io/npm/v/pi-openmodel-provider)](https://www.npmjs.com/package/pi-openmodel-provider)
6
+ [![CI](https://github.com/IvanGabrielYarupaitanRivera/pi-openmodel-provider/actions/workflows/ci.yml/badge.svg)](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
- formatHealthStatus,
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
- try {
23
- models = await fetchOpenModelModels()
24
- } catch (error) {
25
- if (error instanceof TypeError && error.message.includes("fetch")) {
26
- modelError = "🌐 Network error: check your internet connection"
27
- } else {
28
- modelError = `⚠️ ${error instanceof Error ? error.message : "Could not load models"}`
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-openmodel-provider",
3
- "version": "0.2.15",
3
+ "version": "0.2.17",
4
4
  "description": "pi custom provider for OpenModel.ai - Multi-model AI gateway",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -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 "./errors.ts"
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: "anthropic-messages" | "openai-responses" | "google-generative-ai"
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
- function pricePerMillion(costPerToken: number | undefined): number {
62
- if (costPerToken === undefined || costPerToken === null) return 0
63
- return Math.round(costPerToken * 1_000_000 * 1000) / 1000
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(`Failed to fetch models: ${response.status} ${err.code} — ${friendlyMessage(err.code, err.message)}`)
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(`Failed to fetch models — ${friendlyMessage("INTERNAL_ERROR", "Unknown 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
- /** Fetch protocol info from legacy models endpoint */
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(`Failed to fetch models: ${response.status} — ${friendlyMessage(err.code, err.message)}`)
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
- /** Fetch models from OpenModel API (public, no auth required) */
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 (web.supports.supports_image_generation && !web.supports.supports_vision && !web.supports.supports_reasoning) {
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
- // Fallback: infer protocol from provider
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
+ }