pi-openmodel-provider 0.2.17 → 0.2.19

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.
@@ -32,9 +32,31 @@ Models are cached locally at `~/.pi/agent/cache/openmodel-models.json` with a **
32
32
 
33
33
  ## Thinking levels
34
34
 
35
- Reasoning models support thinking levels:
36
- - **Messages protocol:** minimal → low, low → medium, medium → high
37
- - **Responses protocol:** `reasoning_effort` levels (low, medium, high)
35
+ Reasoning models support thinking levels mapped per protocol:
36
+
37
+ **Messages protocol (`anthropic-messages`):**
38
+
39
+ | Level | Mapped value |
40
+ |---|---|
41
+ | `off` | `null` |
42
+ | `minimal` | `low` |
43
+ | `low` | `medium` |
44
+ | `medium` | `high` |
45
+ | `high` | `high` |
46
+ | `xhigh` | `max` |
47
+
48
+ **Responses protocol (`openai-responses`):** uses `reasoning_effort`
49
+
50
+ | Level | Mapped value |
51
+ |---|---|
52
+ | `off` | `null` |
53
+ | `minimal` | `low` |
54
+ | `low` | `low` |
55
+ | `medium` | `medium` |
56
+ | `high` | `high` |
57
+ | `xhigh` | `high` |
58
+
59
+ **Gemini protocol (`google-generative-ai`):** no thinking level mapping (returns empty).
38
60
 
39
61
  ## Compat flags
40
62
 
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,7 +5,39 @@ 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
8
+ ## [0.2.19] - 2026-06-27
9
+
10
+ ### Added
11
+ - `src/auth/login.ts` — `hasApiKey()` function to check for configured credentials (was inline in `index.ts`)
12
+ - `src/formatters/status.ts` — new module with pure `formatProviderStatus()` for the `/openmodel` command display
13
+ - `formatStabilityDetail()` and `formatStabilitySummaryLine()` in `src/formatters/stability.ts` — extract stability formatting from `index.ts`
14
+ - `CommandContext` interface in `index.ts` — types the command handler context instead of using `any`
15
+
16
+ ### Changed
17
+ - `index.ts` — refactored to pure orchestration: `/openmodel` and `/openmodel-stability` handlers delegate I/O, formatting, and display logic to extracted functions
18
+ - `.agents/skills/pi-openmodel-info/SKILL.md` — completed thinking levels section with full mappings (`minimal` through `xhigh`) for both protocols
19
+
20
+ ### Removed
21
+ - `import { readFile } from "node:fs/promises"` and `import { homedir } from "node:os"` from `index.ts` (moved into `hasApiKey()`)
22
+
23
+ ## [0.2.18] - 2026-06-26
24
+
25
+ ### Added
26
+ - `src/health.ts` — shared `HealthStatus` type and `determineHealth()` function, extracted from `src/api/stability.ts` and `src/formatters/stability.ts` to eliminate code duplication
27
+ - `tests/test-cache.ts` — 12 tests covering cache read (valid, expired, corrupted, missing) and write (success, directory creation, error suppression)
28
+ - `CacheFs` interface in `src/cache.ts` for dependency injection (matching `fetchImpl` pattern)
29
+ - `test:cache` npm script
30
+
31
+ ### Changed
32
+ - `src/api/stability.ts` — removed local `determineHealthFallback()` copy, imports from `src/health.ts` instead
33
+ - `src/formatters/stability.ts` — removed local `determineHealth()` copy, imports from `src/health.ts` instead
34
+ - `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
35
+ - `src/providers/compat.ts` — removed unused `api` parameter from `compatForProvider()`
36
+ - `index.ts` — replaced blocking `readFileSync` with `await readFile` from `fs/promises`
37
+ - `LICENSE` — added copyright holder name
38
+ - `tsconfig.json` — enabled `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noFallthroughCasesInSwitch`
39
+ - `AGENTS.md` — reduced to LLM-focused bullet points with references to README and SKILL.md
40
+ - `README.md` — added `health.ts` to architecture tree, added `test:cache` to development section
9
41
 
10
42
  ### Changed
11
43
  - **Major refactor (SRP):** Reorganized `src/` into single-responsibility modules
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
@@ -161,6 +161,7 @@ npm run test:auth
161
161
  npm run test:pricing
162
162
  npm run test:stability
163
163
  npm run test:edge
164
+ npm run test:cache
164
165
  ```
165
166
 
166
167
  ### Codebase Architecture
@@ -181,6 +182,7 @@ src/
181
182
  │ └── validate.ts # sanitizeApiKey() + isValidApiKey()
182
183
  ├── formatters/ # Pure display formatting
183
184
  │ └── stability.ts # formatHealthStatus() + formatConfidence()
185
+ ├── health.ts # Shared health status determination
184
186
  ├── cache.ts # Local model cache (read/write)
185
187
  ├── errors.ts # API error parsing + friendly messages
186
188
  └── stub.d.ts # Type stubs for pi peer dependency
package/index.ts CHANGED
@@ -6,16 +6,25 @@
6
6
 
7
7
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
8
8
  import { fetchOpenModelModels } from "./src/api/models.ts"
9
- import { login, refreshToken, getApiKey } from "./src/auth/login.ts"
9
+ import { login, refreshToken, getApiKey, hasApiKey } from "./src/auth/login.ts"
10
10
  import {
11
11
  fetchModelStabilitySummary,
12
12
  fetchModelStabilityDetail,
13
13
  } from "./src/api/stability.ts"
14
- import { formatHealthStatus } from "./src/formatters/stability.ts"
15
- import { friendlyMessage } from "./src/errors.ts"
14
+ import {
15
+ formatStabilityDetail,
16
+ formatStabilitySummaryLine,
17
+ } from "./src/formatters/stability.ts"
18
+ import { formatProviderStatus } from "./src/formatters/status.ts"
16
19
  import { readModelCache, writeModelCache } from "./src/cache.ts"
17
- import { readFileSync } from "node:fs"
18
- import { homedir } from "node:os"
20
+
21
+ /** Minimal command context type for pi extension command handlers. */
22
+ interface CommandContext {
23
+ signal?: AbortSignal
24
+ ui: {
25
+ notify(message: string, type: string): void
26
+ }
27
+ }
19
28
 
20
29
  export default async function (pi: ExtensionAPI) {
21
30
  let models: Awaited<ReturnType<typeof fetchOpenModelModels>> = []
@@ -76,73 +85,31 @@ export default async function (pi: ExtensionAPI) {
76
85
  // /openmodel - Show provider status
77
86
  pi.registerCommand("openmodel", {
78
87
  description: "Show OpenModel provider status",
79
- handler: async (_args: string, ctx: any) => {
80
- const count = models.length
81
- const status = count > 0
82
- ? `✅ ${count} models loaded`
83
- : modelError ?? "❌ No models loaded"
84
-
85
- // Detect if user has configured an API key in auth.json
86
- let hasApiKey = false
87
- try {
88
- const authPath = `${homedir()}/.pi/agent/auth.json`
89
- const content = readFileSync(authPath, "utf-8")
90
- const data = JSON.parse(content)
91
- hasApiKey = !!(data.openmodel?.access || data.openmodel?.refresh)
92
- } catch {
93
- // Auth file not found
94
- }
95
-
96
- const lines = [
97
- "╔══════════════════════════════════╗",
98
- "║ OpenModel.ai ║",
99
- "╠══════════════════════════════════╣",
100
- `║ Models: ${String(count).padStart(3)} loaded${fromCache ? " (cached)" : ""} ║`,
101
- hasApiKey ? "║ API Key: ✅ Configured ║" : "║ API Key: ❌ Not configured ║",
102
- "╠══════════════════════════════════╣",
103
- "║ Commands: ║",
104
- "║ /model openmodel/... ║",
105
- "║ /openmodel-stability ║",
106
- "╚══════════════════════════════════╝",
107
- ]
108
-
109
- const hints: string[] = []
110
- if (!hasApiKey) {
111
- hints.push("ℹ️ Run /login → OpenModel → paste your API key")
112
- }
113
- if (count === 0 && hasApiKey) {
114
- hints.push("ℹ️ Run /reload after setting your API key")
115
- }
116
- if (count === 0 && modelError) {
117
- hints.push(`ℹ️ ${modelError}`)
118
- }
119
- hints.push("ℹ️ Press Ctrl+L to select a model")
120
-
121
- ctx.ui.notify([...lines, ...hints].join("\n"), "info")
88
+ handler: async (_args: string, ctx: CommandContext) => {
89
+ ctx.ui.notify(
90
+ formatProviderStatus({
91
+ count: models.length,
92
+ fromCache,
93
+ hasApiKey: await hasApiKey(),
94
+ modelError,
95
+ }),
96
+ "info",
97
+ )
122
98
  },
123
99
  })
124
100
 
125
101
  // /openmodel-stability - Show model health metrics
126
102
  pi.registerCommand("openmodel-stability", {
127
103
  description: "Show model stability metrics (24h)",
128
- handler: async (args: string | undefined, ctx: any) => {
104
+ handler: async (args: string | undefined, ctx: CommandContext) => {
129
105
  try {
106
+ const fetchOptions = ctx.signal ? { signal: ctx.signal } : {}
130
107
  if (args?.trim()) {
131
108
  const name = args.trim()
132
- const detail = await fetchModelStabilityDetail(name, { signal: ctx.signal })
133
- const lines = [
134
- `📊 ${detail.model_name}`,
135
- `━━━━━━━━━━━━━━━━━━━━━━`,
136
- `Health: ${formatHealthStatus(detail.health_status)}`,
137
- `Success: ${detail.summary.success_rate.toFixed(2)}%`,
138
- `Latency: ${detail.summary.avg_latency_ms.toFixed(0)}ms`,
139
- `TTFT: ${detail.summary.avg_ttft_ms.toFixed(0)}ms`,
140
- `Throughput: ${detail.summary.avg_tps.toFixed(1)} t/s`,
141
- `Confidence: ${detail.confidence}`,
142
- ]
143
- ctx.ui.notify(lines.join("\n"), "info")
109
+ const detail = await fetchModelStabilityDetail(name, fetchOptions)
110
+ ctx.ui.notify(formatStabilityDetail(detail), "info")
144
111
  } else {
145
- const summary = await fetchModelStabilitySummary({ signal: ctx.signal })
112
+ const summary = await fetchModelStabilitySummary(fetchOptions)
146
113
  if (summary.length === 0) {
147
114
  ctx.ui.notify("📊 No stability data available for any model yet.", "warning")
148
115
  return
@@ -153,8 +120,7 @@ export default async function (pi: ExtensionAPI) {
153
120
  return (order[a.health_status] ?? 5) - (order[b.health_status] ?? 5)
154
121
  })
155
122
  for (const s of sorted) {
156
- const emoji = formatHealthStatus(s.health_status).split(" ")[0]
157
- lines.push(`${emoji} ${s.model_name.padEnd(28)} ${s.success_rate.toFixed(1).padStart(5)}% ${s.avg_latency_ms.toFixed(0).padStart(5)}ms ${s.avg_tps.toFixed(1).padStart(6)} t/s`)
123
+ lines.push(formatStabilitySummaryLine(s))
158
124
  }
159
125
  ctx.ui.notify(lines.join("\n"), "info")
160
126
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-openmodel-provider",
3
- "version": "0.2.17",
3
+ "version": "0.2.19",
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": [
package/src/api/models.ts CHANGED
@@ -161,6 +161,15 @@ async function fetchLegacyModels(options?: {
161
161
  // Orchestration
162
162
  // ──────────────────────────────────────────────
163
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
+
164
173
  /**
165
174
  * Fetch all models from OpenModel API (public, no auth required for web endpoint).
166
175
  *
@@ -200,17 +209,17 @@ export async function fetchOpenModelModels(options?: {
200
209
  api = inferApiFromProvider(web.provider_key)
201
210
  }
202
211
 
203
- // Parse pricing
204
- const inputPrice = pricePerMillion(web.prices.input_cost_per_token as number)
205
- const outputPrice = pricePerMillion(web.prices.output_cost_per_token as number)
206
- const cacheRead = pricePerMillion(web.prices.cache_read_input_token_cost as number)
207
- 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"))
208
217
 
209
218
  // Build model config
210
219
  const reasoning = web.supports.supports_reasoning ?? false
211
- const compat = compatForProvider(web.provider_key, api, reasoning)
220
+ const compat = compatForProvider(web.provider_key, reasoning)
212
221
 
213
- const base = {
222
+ const model: OpenModelProviderModel = {
214
223
  id,
215
224
  name: id,
216
225
  reasoning,
@@ -224,13 +233,9 @@ export async function fetchOpenModelModels(options?: {
224
233
  contextWindow: web.max.max_input_tokens ?? 128_000,
225
234
  maxTokens: web.max.max_output_tokens ?? web.max.max_tokens ?? 16_384,
226
235
  api,
227
- } as const
228
-
229
- const model = {
230
- ...base,
231
236
  ...(reasoning ? { thinkingLevelMap: thinkingLevelMapForApi(api) } : {}),
232
237
  ...(compat ? { compat } : {}),
233
- } as unknown as OpenModelProviderModel
238
+ }
234
239
 
235
240
  models.push(model)
236
241
  }
@@ -12,18 +12,12 @@
12
12
  */
13
13
 
14
14
  import { parseWebError, friendlyMessage } from "../errors.ts"
15
+ import { determineHealth } from "../health.ts"
16
+ import type { HealthStatus } from "../health.ts"
15
17
 
16
18
  export const STABILITY_SUMMARY_URL =
17
19
  "https://api.openmodel.ai/web/v1/model-stability/summary"
18
20
 
19
- /** Health status derived from success rate */
20
- export type HealthStatus =
21
- | "operational"
22
- | "healthy"
23
- | "degraded"
24
- | "unstable"
25
- | "no_data"
26
-
27
21
  /** Confidence level based on sample size */
28
22
  export type ConfidenceLevel = "high" | "medium" | "low"
29
23
 
@@ -100,7 +94,7 @@ export async function fetchModelStabilitySummary(options?: {
100
94
 
101
95
  return body.data.map((item) => ({
102
96
  ...item,
103
- health_status: determineHealthFallback(item.success_rate, item.confidence),
97
+ health_status: determineHealth(item.success_rate, item.confidence),
104
98
  }))
105
99
  }
106
100
 
@@ -161,24 +155,9 @@ export async function fetchModelStabilityDetail(
161
155
 
162
156
  return {
163
157
  ...body.data,
164
- health_status: determineHealthFallback(
158
+ health_status: determineHealth(
165
159
  body.data.summary.success_rate,
166
160
  body.data.confidence,
167
161
  ),
168
162
  }
169
163
  }
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
- }
package/src/auth/login.ts CHANGED
@@ -10,6 +10,9 @@
10
10
  * Since OpenModel API keys don't expire, "refresh" is a no-op.
11
11
  */
12
12
 
13
+ import { readFile } from "node:fs/promises"
14
+ import { join } from "node:path"
15
+ import { homedir } from "node:os"
13
16
  import { sanitizeApiKey, isValidApiKey } from "./validate.ts"
14
17
 
15
18
  export interface OAuthLoginCallbacks {
@@ -130,3 +133,17 @@ export async function refreshToken(
130
133
  export function getApiKey(credentials: OAuthCredentials): string {
131
134
  return credentials.access
132
135
  }
136
+
137
+ /**
138
+ * Check if the user has configured an OpenModel API key in pi's auth file.
139
+ */
140
+ export async function hasApiKey(): Promise<boolean> {
141
+ const authPath = join(homedir(), ".pi", "agent", "auth.json")
142
+ try {
143
+ const content = await readFile(authPath, "utf-8")
144
+ const data = JSON.parse(content)
145
+ return !!(data.openmodel?.access || data.openmodel?.refresh)
146
+ } catch {
147
+ return false
148
+ }
149
+ }
package/src/cache.ts CHANGED
@@ -22,13 +22,27 @@ interface ModelCache {
22
22
  models: readonly OpenModelProviderModel[]
23
23
  }
24
24
 
25
+ /** Minimal fs interface matching what cache.ts actually uses */
26
+ export interface CacheFs {
27
+ readFile(path: string, encoding: string): Promise<string>
28
+ writeFile(path: string, data: string, encoding?: string): Promise<void>
29
+ mkdir(path: string, options?: { recursive?: boolean }): Promise<void>
30
+ }
31
+
32
+ const DEFAULT_FS: CacheFs = {
33
+ readFile: readFile as CacheFs["readFile"],
34
+ writeFile: writeFile as CacheFs["writeFile"],
35
+ mkdir: mkdir as CacheFs["mkdir"],
36
+ }
37
+
25
38
  /**
26
39
  * Read models from cache.
27
40
  * Returns null if cache is missing, expired, or corrupted.
28
41
  */
29
- export async function readModelCache(): Promise<readonly OpenModelProviderModel[] | null> {
42
+ export async function readModelCache(fsImpl?: CacheFs): Promise<readonly OpenModelProviderModel[] | null> {
43
+ const { readFile: rf } = fsImpl ?? DEFAULT_FS
30
44
  try {
31
- const raw = await readFile(CACHE_FILE, "utf-8")
45
+ const raw = await rf(CACHE_FILE, "utf-8")
32
46
  const cache: ModelCache = JSON.parse(raw)
33
47
 
34
48
  if (typeof cache.timestamp !== "number" || !Array.isArray(cache.models)) {
@@ -50,11 +64,12 @@ export async function readModelCache(): Promise<readonly OpenModelProviderModel[
50
64
  * Write models to the local cache.
51
65
  * Failures are silently ignored — cache is optional.
52
66
  */
53
- export async function writeModelCache(models: readonly OpenModelProviderModel[]): Promise<void> {
67
+ export async function writeModelCache(models: readonly OpenModelProviderModel[], fsImpl?: CacheFs): Promise<void> {
68
+ const { mkdir: mkd, writeFile: wf } = fsImpl ?? DEFAULT_FS
54
69
  try {
55
- await mkdir(CACHE_DIR, { recursive: true })
70
+ await mkd(CACHE_DIR, { recursive: true })
56
71
  const cache: ModelCache = { timestamp: Date.now(), models }
57
- await writeFile(CACHE_FILE, JSON.stringify(cache, null, 2), "utf-8")
72
+ await wf(CACHE_FILE, JSON.stringify(cache, null, 2), "utf-8")
58
73
  } catch {
59
74
  // Cache writes are best-effort
60
75
  }
@@ -5,19 +5,12 @@
5
5
  * Transforms health/confidence data into display-ready strings.
6
6
  */
7
7
 
8
- import type { HealthStatus, ConfidenceLevel } from "../api/stability.ts"
9
-
10
- /** Determine health status from success rate and confidence */
11
- export function determineHealth(
12
- successRate: number,
13
- confidence: ConfidenceLevel,
14
- ): HealthStatus {
15
- if (confidence === "low") return "no_data"
16
- if (successRate >= 99.9) return "operational"
17
- if (successRate >= 99) return "healthy"
18
- if (successRate >= 95) return "degraded"
19
- return "unstable"
20
- }
8
+ import type { HealthStatus } from "../health.ts"
9
+ import type {
10
+ ConfidenceLevel,
11
+ ModelStability,
12
+ ModelStabilityDetail,
13
+ } from "../api/stability.ts"
21
14
 
22
15
  /** Format health status with emoji */
23
16
  export function formatHealthStatus(status: HealthStatus): string {
@@ -46,3 +39,23 @@ export function formatConfidence(level: ConfidenceLevel): string {
46
39
  return "⚪ Low"
47
40
  }
48
41
  }
42
+
43
+ /** Format detailed stability view for a single model */
44
+ export function formatStabilityDetail(detail: ModelStabilityDetail): string {
45
+ return [
46
+ `📊 ${detail.model_name}`,
47
+ `━━━━━━━━━━━━━━━━━━━━━━`,
48
+ `Health: ${formatHealthStatus(detail.health_status)}`,
49
+ `Success: ${detail.summary.success_rate.toFixed(2)}%`,
50
+ `Latency: ${detail.summary.avg_latency_ms.toFixed(0)}ms`,
51
+ `TTFT: ${detail.summary.avg_ttft_ms.toFixed(0)}ms`,
52
+ `Throughput: ${detail.summary.avg_tps.toFixed(1)} t/s`,
53
+ `Confidence: ${formatConfidence(detail.confidence)}`,
54
+ ].join("\n")
55
+ }
56
+
57
+ /** Format a single summary line for the stability list */
58
+ export function formatStabilitySummaryLine(s: ModelStability): string {
59
+ const emoji = formatHealthStatus(s.health_status).split(" ")[0]
60
+ return `${emoji} ${s.model_name.padEnd(28)} ${s.success_rate.toFixed(1).padStart(5)}% ${s.avg_latency_ms.toFixed(0).padStart(5)}ms ${s.avg_tps.toFixed(1).padStart(6)} t/s`
61
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Formatters for the /openmodel provider status display.
3
+ *
4
+ * Pure functions — no side effects, no network calls.
5
+ */
6
+
7
+ export interface ProviderStatusOptions {
8
+ count: number
9
+ fromCache: boolean
10
+ hasApiKey: boolean
11
+ modelError: string | null
12
+ }
13
+
14
+ /**
15
+ * Format the full /openmodel status display including hints.
16
+ */
17
+ export function formatProviderStatus(options: ProviderStatusOptions): string {
18
+ const { count, fromCache, hasApiKey, modelError } = options
19
+
20
+ const lines = [
21
+ "╔══════════════════════════════════╗",
22
+ "║ OpenModel.ai ║",
23
+ "╠══════════════════════════════════╣",
24
+ `║ Models: ${String(count).padStart(3)} loaded${fromCache ? " (cached)" : ""} ║`,
25
+ hasApiKey ? "║ API Key: ✅ Configured ║" : "║ API Key: ❌ Not configured ║",
26
+ "╠══════════════════════════════════╣",
27
+ "║ Commands: ║",
28
+ "║ /model openmodel/... ║",
29
+ "║ /openmodel-stability ║",
30
+ "╚══════════════════════════════════╝",
31
+ ]
32
+
33
+ const hints: string[] = []
34
+ if (!hasApiKey) {
35
+ hints.push("ℹ️ Run /login → OpenModel → paste your API key")
36
+ }
37
+ if (count === 0 && hasApiKey) {
38
+ hints.push("ℹ️ Run /reload after setting your API key")
39
+ }
40
+ if (count === 0 && modelError) {
41
+ hints.push(`ℹ️ ${modelError}`)
42
+ }
43
+ hints.push("ℹ️ Press Ctrl+L to select a model")
44
+
45
+ return [...lines, ...hints].join("\n")
46
+ }
package/src/health.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Shared health status determination for model stability.
3
+ *
4
+ * Extracted from stability.ts and formatters/stability.ts to avoid
5
+ * code duplication — both modules need the same logic.
6
+ */
7
+
8
+ import type { ConfidenceLevel } from "./api/stability.ts"
9
+
10
+ export type HealthStatus =
11
+ | "operational"
12
+ | "healthy"
13
+ | "degraded"
14
+ | "unstable"
15
+ | "no_data"
16
+
17
+ /**
18
+ * Determine health status from success rate and confidence.
19
+ * Low confidence → no_data regardless of success rate.
20
+ */
21
+ export function determineHealth(
22
+ successRate: number,
23
+ confidence: ConfidenceLevel,
24
+ ): HealthStatus {
25
+ if (confidence === "low") return "no_data"
26
+ if (successRate >= 99.9) return "operational"
27
+ if (successRate >= 99) return "healthy"
28
+ if (successRate >= 95) return "degraded"
29
+ return "unstable"
30
+ }
@@ -6,15 +6,12 @@
6
6
  * affinity, cache control, etc.).
7
7
  */
8
8
 
9
- import type { ApiProtocol } from "./protocols.ts"
10
-
11
9
  /**
12
- * Determine compat flags based on provider and API.
10
+ * Determine compat flags based on provider.
13
11
  * Returns undefined when no special flags are needed.
14
12
  */
15
13
  export function compatForProvider(
16
14
  providerKey: string,
17
- api: ApiProtocol,
18
15
  reasoning: boolean,
19
16
  ): Record<string, unknown> | undefined {
20
17
  switch (providerKey) {