pi-openmodel-provider 0.2.18 → 0.2.20

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/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ 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.20] - 2026-06-28
9
+
10
+ ### Changed
11
+ - `src/errors.ts` — replaced all `as any` casts with proper `isRecord()` type guard for type-safe body parsing
12
+
13
+ ### Fixed
14
+ - Added missing `isRecord()` utility type guard to eliminate `any` usage in error parsing (adheres to project type safety policy)
15
+
16
+ ## [0.2.19] - 2026-06-27
17
+
18
+ ### Added
19
+ - `src/auth/login.ts` — `hasApiKey()` function to check for configured credentials (was inline in `index.ts`)
20
+ - `src/formatters/status.ts` — new module with pure `formatProviderStatus()` for the `/openmodel` command display
21
+ - `formatStabilityDetail()` and `formatStabilitySummaryLine()` in `src/formatters/stability.ts` — extract stability formatting from `index.ts`
22
+ - `CommandContext` interface in `index.ts` — types the command handler context instead of using `any`
23
+
24
+ ### Changed
25
+ - `index.ts` — refactored to pure orchestration: `/openmodel` and `/openmodel-stability` handlers delegate I/O, formatting, and display logic to extracted functions
26
+ - `.agents/skills/pi-openmodel-info/SKILL.md` — completed thinking levels section with full mappings (`minimal` through `xhigh`) for both protocols
27
+
28
+ ### Removed
29
+ - `import { readFile } from "node:fs/promises"` and `import { homedir } from "node:os"` from `index.ts` (moved into `hasApiKey()`)
30
+
8
31
  ## [0.2.18] - 2026-06-26
9
32
 
10
33
  ### Added
package/index.ts CHANGED
@@ -6,15 +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"
14
+ import {
15
+ formatStabilityDetail,
16
+ formatStabilitySummaryLine,
17
+ } from "./src/formatters/stability.ts"
18
+ import { formatProviderStatus } from "./src/formatters/status.ts"
15
19
  import { readModelCache, writeModelCache } from "./src/cache.ts"
16
- import { readFile } from "node:fs/promises"
17
- 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
+ }
18
28
 
19
29
  export default async function (pi: ExtensionAPI) {
20
30
  let models: Awaited<ReturnType<typeof fetchOpenModelModels>> = []
@@ -75,70 +85,31 @@ export default async function (pi: ExtensionAPI) {
75
85
  // /openmodel - Show provider status
76
86
  pi.registerCommand("openmodel", {
77
87
  description: "Show OpenModel provider status",
78
- handler: async (_args: string, ctx: any) => {
79
- const count = models.length
80
-
81
- // Detect if user has configured an API key in auth.json
82
- let hasApiKey = false
83
- try {
84
- const authPath = `${homedir()}/.pi/agent/auth.json`
85
- const content = await readFile(authPath, "utf-8")
86
- const data = JSON.parse(content)
87
- hasApiKey = !!(data.openmodel?.access || data.openmodel?.refresh)
88
- } catch {
89
- // Auth file not found
90
- }
91
-
92
- const lines = [
93
- "╔══════════════════════════════════╗",
94
- "║ OpenModel.ai ║",
95
- "╠══════════════════════════════════╣",
96
- `║ Models: ${String(count).padStart(3)} loaded${fromCache ? " (cached)" : ""} ║`,
97
- hasApiKey ? "║ API Key: ✅ Configured ║" : "║ API Key: ❌ Not configured ║",
98
- "╠══════════════════════════════════╣",
99
- "║ Commands: ║",
100
- "║ /model openmodel/... ║",
101
- "║ /openmodel-stability ║",
102
- "╚══════════════════════════════════╝",
103
- ]
104
-
105
- const hints: string[] = []
106
- if (!hasApiKey) {
107
- hints.push("ℹ️ Run /login → OpenModel → paste your API key")
108
- }
109
- if (count === 0 && hasApiKey) {
110
- hints.push("ℹ️ Run /reload after setting your API key")
111
- }
112
- if (count === 0 && modelError) {
113
- hints.push(`ℹ️ ${modelError}`)
114
- }
115
- hints.push("ℹ️ Press Ctrl+L to select a model")
116
-
117
- 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
+ )
118
98
  },
119
99
  })
120
100
 
121
101
  // /openmodel-stability - Show model health metrics
122
102
  pi.registerCommand("openmodel-stability", {
123
103
  description: "Show model stability metrics (24h)",
124
- handler: async (args: string | undefined, ctx: any) => {
104
+ handler: async (args: string | undefined, ctx: CommandContext) => {
125
105
  try {
106
+ const fetchOptions = ctx.signal ? { signal: ctx.signal } : {}
126
107
  if (args?.trim()) {
127
108
  const name = args.trim()
128
- const detail = await fetchModelStabilityDetail(name, { signal: ctx.signal })
129
- const lines = [
130
- `📊 ${detail.model_name}`,
131
- `━━━━━━━━━━━━━━━━━━━━━━`,
132
- `Health: ${formatHealthStatus(detail.health_status)}`,
133
- `Success: ${detail.summary.success_rate.toFixed(2)}%`,
134
- `Latency: ${detail.summary.avg_latency_ms.toFixed(0)}ms`,
135
- `TTFT: ${detail.summary.avg_ttft_ms.toFixed(0)}ms`,
136
- `Throughput: ${detail.summary.avg_tps.toFixed(1)} t/s`,
137
- `Confidence: ${detail.confidence}`,
138
- ]
139
- ctx.ui.notify(lines.join("\n"), "info")
109
+ const detail = await fetchModelStabilityDetail(name, fetchOptions)
110
+ ctx.ui.notify(formatStabilityDetail(detail), "info")
140
111
  } else {
141
- const summary = await fetchModelStabilitySummary({ signal: ctx.signal })
112
+ const summary = await fetchModelStabilitySummary(fetchOptions)
142
113
  if (summary.length === 0) {
143
114
  ctx.ui.notify("📊 No stability data available for any model yet.", "warning")
144
115
  return
@@ -149,8 +120,7 @@ export default async function (pi: ExtensionAPI) {
149
120
  return (order[a.health_status] ?? 5) - (order[b.health_status] ?? 5)
150
121
  })
151
122
  for (const s of sorted) {
152
- const emoji = formatHealthStatus(s.health_status).split(" ")[0]
153
- 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))
154
124
  }
155
125
  ctx.ui.notify(lines.join("\n"), "info")
156
126
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-openmodel-provider",
3
- "version": "0.2.18",
3
+ "version": "0.2.20",
4
4
  "description": "pi custom provider for OpenModel.ai - Multi-model AI gateway",
5
5
  "type": "module",
6
6
  "keywords": [
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/errors.ts CHANGED
@@ -7,39 +7,49 @@
7
7
  * Proxy endpoints return errors in provider-specific formats (Anthropic, OpenAI, Gemini).
8
8
  */
9
9
 
10
+ /** Check if a value is a non-null object (Record) */
11
+ function isRecord(value: unknown): value is Record<string, unknown> {
12
+ return typeof value === "object" && value !== null
13
+ }
14
+
10
15
  /** Parse an OpenModel Web API error response body */
11
16
  export function parseWebError(body: unknown): { code: string; message: string; detail?: string } {
12
- const err = (body as any)?.error
13
- if (err?.code && err?.msg) {
14
- const result: { code: string; message: string; detail?: string } = {
15
- code: String(err.code),
16
- message: String(err.msg),
17
+ if (isRecord(body) && isRecord(body.error)) {
18
+ const err = body.error
19
+ if (typeof err.code === "string" && typeof err.msg === "string") {
20
+ const result: { code: string; message: string; detail?: string } = {
21
+ code: err.code,
22
+ message: err.msg,
23
+ }
24
+ if (typeof err.detail === "string") {
25
+ result.detail = err.detail
26
+ }
27
+ return result
17
28
  }
18
- if (err.detail) {
19
- result.detail = String(err.detail)
20
- }
21
- return result
22
29
  }
23
30
  return { code: "UNKNOWN", message: "An unknown error occurred" }
24
31
  }
25
32
 
26
33
  /** Parse an OpenModel proxy API error body (any format) */
27
34
  export function parseProxyError(body: unknown): { code: string; message: string } {
28
- const b = body as any
35
+ if (isRecord(body) && isRecord(body.error)) {
36
+ const err = body.error
29
37
 
30
- // Anthropic format: { type: "error", error: { type, message } }
31
- if (b?.type === "error" && b?.error?.message) {
32
- return { code: b.error.type ?? "UNKNOWN", message: b.error.message }
33
- }
38
+ // Anthropic format: { type: "error", error: { type, message } }
39
+ if (body.type === "error" && typeof err.message === "string") {
40
+ return { code: typeof err.type === "string" ? err.type : "UNKNOWN", message: err.message }
41
+ }
34
42
 
35
- // OpenAI format: { error: { message, type, code } }
36
- if (b?.error?.message) {
37
- return { code: b.error.code ?? b.error.type ?? "UNKNOWN", message: b.error.message }
38
- }
43
+ // OpenAI format: { error: { message, type, code } }
44
+ if (typeof err.message === "string") {
45
+ const code = typeof err.code === "string" ? err.code : typeof err.type === "string" ? err.type : "UNKNOWN"
46
+ return { code, message: err.message }
47
+ }
39
48
 
40
- // Gemini format: { error: { code, message, status } }
41
- if (b?.error?.status) {
42
- return { code: b.error.status, message: b.error.message }
49
+ // Gemini format: { error: { code, message, status } }
50
+ if (typeof err.status === "string") {
51
+ return { code: err.status, message: typeof err.message === "string" ? err.message : "Unknown error" }
52
+ }
43
53
  }
44
54
 
45
55
  return { code: "UNKNOWN", message: "An unknown error occurred" }
@@ -6,7 +6,12 @@
6
6
  */
7
7
 
8
8
  import type { HealthStatus } from "../health.ts"
9
- import type { ConfidenceLevel } from "../api/stability.ts"
9
+ import type {
10
+ ConfidenceLevel,
11
+ ModelStability,
12
+ ModelStabilityDetail,
13
+ } from "../api/stability.ts"
14
+
10
15
  /** Format health status with emoji */
11
16
  export function formatHealthStatus(status: HealthStatus): string {
12
17
  switch (status) {
@@ -34,3 +39,23 @@ export function formatConfidence(level: ConfidenceLevel): string {
34
39
  return "⚪ Low"
35
40
  }
36
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
+ }