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 CHANGED
@@ -1,13 +1,14 @@
1
1
  # Agent Instructions
2
2
 
3
- - This package is **pi-openmodel-provider** for OpenModel.ai, **NOT** OpenRouter.
4
- - OpenModel is a multi-model AI gateway, similar to OpenRouter but a different service.
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.
8
- - If the `/v1/models` endpoint fails (no API key), protocols are inferred from the provider.
9
- - See `.agents/skills/pi-openmodel-info/SKILL.md` for full documentation.
10
- - Follow [CONTRIBUTING.md](CONTRIBUTING.md) before changing code.
11
- - Use [RELEASE.md](RELEASE.md) for release process.
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
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026
3
+ Copyright (c) 2026 Ivan Gabriel Yarupaitan Rivera
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
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
- formatHealthStatus,
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 = readFileSync(authPath, "utf-8")
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.16",
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-models.ts && tsx tests/test-auth.ts && tsx tests/test-pricing.ts && tsx tests/test-stability.ts && tsx tests/test-edge-cases.ts"
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 "./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,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: "anthropic-messages" | "openai-responses" | "google-generative-ai"
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
- function pricePerMillion(costPerToken: number | undefined): number {
63
- if (costPerToken === undefined || costPerToken === null) return 0
64
- return Math.round(costPerToken * 1_000_000 * 1000) / 1000
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(`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
+ )
159
105
  }
160
106
 
161
107
  const body = (await response.json()) as WebApiResponse
162
108
  if (!body.success) {
163
- 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
+ )
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
- /** Fetch protocol info from legacy models endpoint */
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(`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
+ )
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
- /** Fetch models from OpenModel API (public, no auth required) */
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 (web.supports.supports_image_generation && !web.supports.supports_vision && !web.supports.supports_reasoning) {
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
- // Fallback: infer protocol from provider
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
- const inputPrice = pricePerMillion(web.prices.input_cost_per_token as number)
239
- const outputPrice = pricePerMillion(web.prices.output_cost_per_token as number)
240
- const cacheRead = pricePerMillion(web.prices.cache_read_input_token_cost as number)
241
- const cacheWrite = pricePerMillion(web.prices.cache_creation_input_token_cost as number)
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, api, reasoning)
220
+ const compat = compatForProvider(web.provider_key, reasoning)
245
221
 
246
- const base = {
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
- } as unknown as OpenModelProviderModel
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
+ }