pi-openmodel-provider 0.2.16 → 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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,21 @@ 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
+
8
23
  ## [0.2.16] - 2026-06-23
9
24
 
10
25
  ### Added
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
 
@@ -162,6 +163,37 @@ npm run test:stability
162
163
  npm run test:edge
163
164
  ```
164
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
+
165
197
  ## Contributing
166
198
 
167
199
  See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, PR expectations, and commit message rules.
package/index.ts CHANGED
@@ -5,15 +5,16 @@
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
16
  import { readModelCache, writeModelCache } from "./src/cache.ts"
17
+ import { readFileSync } from "node:fs"
17
18
  import { homedir } from "node:os"
18
19
 
19
20
  export default async function (pi: ExtensionAPI) {
@@ -84,7 +85,6 @@ export default async function (pi: ExtensionAPI) {
84
85
  // Detect if user has configured an API key in auth.json
85
86
  let hasApiKey = false
86
87
  try {
87
- const { readFileSync } = await import("node:fs")
88
88
  const authPath = `${homedir()}/.pi/agent/auth.json`
89
89
  const content = readFileSync(authPath, "utf-8")
90
90
  const data = JSON.parse(content)
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.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,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
- }
66
-
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
- }
78
+ // ──────────────────────────────────────────────
79
+ // Fetch: Web API (public, pageable)
80
+ // ──────────────────────────────────────────────
136
81
 
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,17 @@ 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
+ /**
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
+ */
208
171
  export async function fetchOpenModelModels(options?: {
209
172
  webUrl?: string
210
173
  legacyUrl?: string
@@ -220,26 +183,30 @@ export async function fetchOpenModelModels(options?: {
220
183
  const models: OpenModelProviderModel[] = []
221
184
 
222
185
  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) {
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
+ ) {
225
192
  continue
226
193
  }
227
194
 
195
+ // Determine API protocol
228
196
  const legacy = legacyModels.get(id)
229
197
  const protocols = legacy?.supported_protocols ?? []
230
198
  let api = determineApi(protocols, web.provider_key)
231
199
  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"
200
+ api = inferApiFromProvider(web.provider_key)
236
201
  }
237
202
 
203
+ // Parse pricing
238
204
  const inputPrice = pricePerMillion(web.prices.input_cost_per_token as number)
239
205
  const outputPrice = pricePerMillion(web.prices.output_cost_per_token as number)
240
206
  const cacheRead = pricePerMillion(web.prices.cache_read_input_token_cost as number)
241
207
  const cacheWrite = pricePerMillion(web.prices.cache_creation_input_token_cost as number)
242
208
 
209
+ // Build model config
243
210
  const reasoning = web.supports.supports_reasoning ?? false
244
211
  const compat = compatForProvider(web.provider_key, api, reasoning)
245
212
 
@@ -247,7 +214,7 @@ export async function fetchOpenModelModels(options?: {
247
214
  id,
248
215
  name: id,
249
216
  reasoning,
250
- 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),
251
218
  cost: {
252
219
  input: inputPrice * (web.price_multiplier ?? 1),
253
220
  output: outputPrice * (web.price_multiplier ?? 1),
@@ -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
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * OpenModel authentication for pi's /login flow.
3
+ *
4
+ * Flow:
5
+ * 1. Opens the OpenModel Console in the browser
6
+ * 2. Prompts the user to paste their API key
7
+ * 3. Validates the key format (must start with "om-")
8
+ * 4. Stores credentials in pi's auth.json
9
+ *
10
+ * Since OpenModel API keys don't expire, "refresh" is a no-op.
11
+ */
12
+
13
+ import { sanitizeApiKey, isValidApiKey } from "./validate.ts"
14
+
15
+ export interface OAuthLoginCallbacks {
16
+ onAuth(params: { url: string }): void;
17
+ onPrompt(params: { message: string }): Promise<string>;
18
+ onSelect?(params: {
19
+ message: string;
20
+ options: { id: string; label: string }[];
21
+ }): Promise<string | undefined>;
22
+ }
23
+
24
+ export interface OAuthCredentials {
25
+ refresh: string;
26
+ access: string;
27
+ expires: number;
28
+ }
29
+
30
+ export const CONSOLE_URL = "https://console.openmodel.ai"
31
+ const FIVE_YEARS_MS = 5 * 365 * 24 * 60 * 60 * 1000 // API keys don't expire
32
+
33
+ function credentialsFromApiKey(apiKey: string): OAuthCredentials {
34
+ return {
35
+ refresh: apiKey,
36
+ access: apiKey,
37
+ expires: Date.now() + FIVE_YEARS_MS,
38
+ }
39
+ }
40
+
41
+ async function promptForKey(
42
+ callbacks: OAuthLoginCallbacks,
43
+ message: string,
44
+ ): Promise<string> {
45
+ return sanitizeApiKey(await callbacks.onPrompt({ message }))
46
+ }
47
+
48
+ async function handleKey(
49
+ apiKey: string,
50
+ callbacks: OAuthLoginCallbacks,
51
+ ): Promise<OAuthCredentials> {
52
+ if (!apiKey) {
53
+ throw new Error("No OpenModel API key provided")
54
+ }
55
+
56
+ if (!isValidApiKey(apiKey)) {
57
+ // Offer retry when onSelect is available
58
+ if (callbacks.onSelect) {
59
+ const retry = await callbacks.onSelect({
60
+ message: `Invalid API key format. Key should start with "om-". Try again?`,
61
+ options: [
62
+ { id: "retry", label: "🔄 Try again" },
63
+ { id: "cancel", label: "❌ Cancel" },
64
+ ],
65
+ })
66
+ if (retry === "retry") {
67
+ return login(callbacks)
68
+ }
69
+ }
70
+ throw new Error("Login cancelled - invalid API key")
71
+ }
72
+
73
+ return credentialsFromApiKey(apiKey)
74
+ }
75
+
76
+ /**
77
+ * /login openmodel handler.
78
+ *
79
+ * Offers two options:
80
+ * 1. Browser: opens the OpenModel Console so the user can create/copy a key
81
+ * 2. Manual: prompts the user to paste their API key
82
+ */
83
+ export async function login(
84
+ callbacks: OAuthLoginCallbacks,
85
+ ): Promise<OAuthCredentials> {
86
+ // Determine login method (onSelect is optional)
87
+ let method: string | undefined
88
+ if (callbacks.onSelect) {
89
+ method = await callbacks.onSelect({
90
+ message: "How would you like to authenticate with OpenModel?",
91
+ options: [
92
+ { id: "browser", label: "🌐 Open console in browser" },
93
+ { id: "paste", label: "📋 Paste API key manually" },
94
+ ],
95
+ })
96
+ }
97
+
98
+ if (!method) {
99
+ throw new Error("Login cancelled")
100
+ }
101
+
102
+ if (method === "browser") {
103
+ callbacks.onAuth({ url: CONSOLE_URL })
104
+
105
+ const apiKey = await promptForKey(
106
+ callbacks,
107
+ `1. Open ${CONSOLE_URL}\n2. In the sidebar, click on API Keys\n3. Click Create API Key, give it a name, and copy the generated key\n4. Paste the key here (starts with "om-"):`,
108
+ )
109
+
110
+ return handleKey(apiKey, callbacks)
111
+ }
112
+
113
+ // Manual paste
114
+ const apiKey = await promptForKey(callbacks, 'Paste your OpenModel API key (starts with "om-"):')
115
+ return handleKey(apiKey, callbacks)
116
+ }
117
+
118
+ /**
119
+ * OpenModel API keys don't expire, so "refresh" is a no-op.
120
+ */
121
+ export async function refreshToken(
122
+ credentials: OAuthCredentials,
123
+ ): Promise<OAuthCredentials> {
124
+ return credentialsFromApiKey(credentials.refresh)
125
+ }
126
+
127
+ /**
128
+ * Extract the API key from stored credentials.
129
+ */
130
+ export function getApiKey(credentials: OAuthCredentials): string {
131
+ return credentials.access
132
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * API key validation and sanitization.
3
+ *
4
+ * Sanitizes user-pasted input (handling terminal paste wrappers, control chars)
5
+ * and validates that the key matches OpenModel's "om-..." format.
6
+ */
7
+
8
+ /**
9
+ * Sanitize API key input, removing terminal paste wrappers and control chars.
10
+ */
11
+ export function sanitizeApiKey(input: string): string {
12
+ const esc = String.fromCharCode(27)
13
+ return Array.from(
14
+ input
15
+ .replaceAll(`${esc}[200~`, "")
16
+ .replaceAll(`${esc}[201~`, "")
17
+ .replaceAll("[200~", "")
18
+ .replaceAll("[201~", ""),
19
+ )
20
+ .filter((char) => {
21
+ const code = char.charCodeAt(0)
22
+ return code > 31 && code !== 127
23
+ })
24
+ .join("")
25
+ .trim()
26
+ }
27
+
28
+ /**
29
+ * Validate that an API key looks like a valid OpenModel key.
30
+ */
31
+ export function isValidApiKey(key: string): boolean {
32
+ return /^om-[A-Za-z0-9_-]+$/.test(key)
33
+ }
package/src/cache.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  import { readFile, writeFile, mkdir } from "node:fs/promises"
9
9
  import { join } from "node:path"
10
10
  import { homedir } from "node:os"
11
- import type { OpenModelProviderModel } from "./models.ts"
11
+ import type { OpenModelProviderModel } from "./api/models.ts"
12
12
 
13
13
  export const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
14
14
 
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Formatters for model stability presentation.
3
+ *
4
+ * Pure functions — no side effects, no network calls.
5
+ * Transforms health/confidence data into display-ready strings.
6
+ */
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
+ }
21
+
22
+ /** Format health status with emoji */
23
+ export function formatHealthStatus(status: HealthStatus): string {
24
+ switch (status) {
25
+ case "operational":
26
+ return "✅ Operational"
27
+ case "healthy":
28
+ return "🟢 Healthy"
29
+ case "degraded":
30
+ return "🟡 Degraded"
31
+ case "unstable":
32
+ return "🔴 Unstable"
33
+ case "no_data":
34
+ return "⚪ No Data"
35
+ }
36
+ }
37
+
38
+ /** Format confidence level */
39
+ export function formatConfidence(level: ConfidenceLevel): string {
40
+ switch (level) {
41
+ case "high":
42
+ return "🟢 High"
43
+ case "medium":
44
+ return "🟡 Medium"
45
+ case "low":
46
+ return "⚪ Low"
47
+ }
48
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Provider-specific compatibility flags for pi.
3
+ *
4
+ * These flags tell pi about each provider's quirks and capabilities,
5
+ * enabling optimal protocol compatibility (thinking formats, session
6
+ * affinity, cache control, etc.).
7
+ */
8
+
9
+ import type { ApiProtocol } from "./protocols.ts"
10
+
11
+ /**
12
+ * Determine compat flags based on provider and API.
13
+ * Returns undefined when no special flags are needed.
14
+ */
15
+ export function compatForProvider(
16
+ providerKey: string,
17
+ api: ApiProtocol,
18
+ reasoning: boolean,
19
+ ): Record<string, unknown> | undefined {
20
+ switch (providerKey) {
21
+ case "openai":
22
+ return { supportsReasoningEffort: true }
23
+
24
+ case "deepseek":
25
+ if (reasoning) return { thinkingFormat: "deepseek" }
26
+ return undefined
27
+
28
+ case "anthropic":
29
+ return {
30
+ sendSessionAffinityHeaders: true,
31
+ supportsCacheControlOnTools: true,
32
+ supportsEagerToolInputStreaming: true,
33
+ }
34
+
35
+ case "google":
36
+ case "gemini":
37
+ return undefined
38
+
39
+ case "qwen":
40
+ if (reasoning) return { thinkingFormat: "qwen-chat-template" }
41
+ return undefined
42
+
43
+ case "zai":
44
+ if (reasoning) return { thinkingFormat: "zai" }
45
+ return undefined
46
+
47
+ default:
48
+ return undefined
49
+ }
50
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Pricing utilities for converting cost-per-token to $/M tokens.
3
+ *
4
+ * OpenModel API returns prices in cost-per-token (microdollars).
5
+ * We convert to dollars per million tokens for pi's display.
6
+ */
7
+
8
+ /** Convert cost-per-token to dollars per million tokens */
9
+ export function pricePerMillion(costPerToken: number | undefined): number {
10
+ if (costPerToken === undefined || costPerToken === null) return 0
11
+ return Math.round(costPerToken * 1_000_000 * 1000) / 1000
12
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Protocol detection and thinking level mapping per provider.
3
+ *
4
+ * Determines the correct pi protocol (anthropic-messages, openai-responses,
5
+ * google-generative-ai) based on provider protocol lists and fallback inference.
6
+ */
7
+
8
+ export type ApiProtocol = "anthropic-messages" | "openai-responses" | "google-generative-ai"
9
+
10
+ /** Infer protocol from a list of supported protocol strings */
11
+ export function determineApi(
12
+ protocols: string[],
13
+ _provider: string,
14
+ ): ApiProtocol | null {
15
+ if (protocols.includes("messages")) return "anthropic-messages"
16
+ if (protocols.includes("responses")) return "openai-responses"
17
+ if (protocols.includes("gemini")) return "google-generative-ai"
18
+ return null
19
+ }
20
+
21
+ /** Fallback: infer API protocol from provider name when legacy endpoint fails */
22
+ export function inferApiFromProvider(providerKey: string): ApiProtocol {
23
+ if (["openai"].includes(providerKey)) return "openai-responses"
24
+ if (["gemini"].includes(providerKey)) return "google-generative-ai"
25
+ return "anthropic-messages"
26
+ }
27
+
28
+ export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"
29
+
30
+ /** Build a thinking-level map appropriate for the protocol */
31
+ export function thinkingLevelMapForApi(
32
+ api: ApiProtocol,
33
+ ): Partial<Record<ThinkingLevel, string | null>> {
34
+ if (api === "anthropic-messages") {
35
+ return {
36
+ minimal: "low",
37
+ low: "medium",
38
+ medium: "high",
39
+ high: "high",
40
+ xhigh: "max",
41
+ }
42
+ }
43
+ if (api === "openai-responses") {
44
+ return {
45
+ minimal: "low",
46
+ low: "low",
47
+ medium: "medium",
48
+ high: "high",
49
+ xhigh: "high",
50
+ }
51
+ }
52
+ return {}
53
+ }
package/src/auth.ts DELETED
@@ -1,179 +0,0 @@
1
- /**
2
- * OpenModel authentication for pi's /login flow.
3
- *
4
- * Provides OAuth integration so users can authenticate via:
5
- * /login openmodel
6
- *
7
- * Flow:
8
- * 1. Opens the OpenModel Console in the browser
9
- * 2. Prompts the user to paste their API key
10
- * 3. Validates the key format (must start with "om-")
11
- * 4. Stores credentials in pi's auth.json
12
- *
13
- * Since OpenModel API keys don't expire, "refresh" is a no-op.
14
- */
15
-
16
- export interface OAuthLoginCallbacks {
17
- onAuth(params: { url: string }): void;
18
- onPrompt(params: { message: string }): Promise<string>;
19
- onSelect?(params: {
20
- message: string;
21
- options: { id: string; label: string }[];
22
- }): Promise<string | undefined>;
23
- }
24
-
25
- export interface OAuthCredentials {
26
- refresh: string;
27
- access: string;
28
- expires: number;
29
- }
30
-
31
- const CONSOLE_URL = "https://console.openmodel.ai";
32
- const FIVE_YEARS_MS = 5 * 365 * 24 * 60 * 60 * 1000; // API keys don't expire
33
-
34
- /**
35
- * Sanitize API key input, removing terminal paste wrappers and control chars.
36
- */
37
- export function sanitizeApiKey(input: string): string {
38
- const esc = String.fromCharCode(27);
39
- return Array.from(
40
- input
41
- .replaceAll(`${esc}[200~`, "")
42
- .replaceAll(`${esc}[201~`, "")
43
- .replaceAll("[200~", "")
44
- .replaceAll("[201~", ""),
45
- )
46
- .filter((char) => {
47
- const code = char.charCodeAt(0);
48
- return code > 31 && code !== 127;
49
- })
50
- .join("")
51
- .trim();
52
- }
53
-
54
- /**
55
- * Validate that an API key looks like a valid OpenModel key.
56
- */
57
- export function isValidApiKey(key: string): boolean {
58
- return /^om-[A-Za-z0-9_-]+$/.test(key);
59
- }
60
-
61
- function credentialsFromApiKey(apiKey: string): OAuthCredentials {
62
- return {
63
- refresh: apiKey,
64
- access: apiKey,
65
- expires: Date.now() + FIVE_YEARS_MS,
66
- };
67
- }
68
-
69
- /**
70
- * /login openmodel handler.
71
- *
72
- * Offers two options:
73
- * 1. Browser: opens the OpenModel Console so the user can create/copy a key
74
- * 2. Manual: prompts the user to paste their API key
75
- */
76
- export async function login(
77
- callbacks: OAuthLoginCallbacks,
78
- ): Promise<OAuthCredentials> {
79
- // Offer login method choice (onSelect is optional)
80
- let method: string | undefined;
81
- if (callbacks.onSelect) {
82
- method = await callbacks.onSelect({
83
- message: "How would you like to authenticate with OpenModel?",
84
- options: [
85
- { id: "browser", label: "🌐 Open console in browser" },
86
- { id: "paste", label: "📋 Paste API key manually" },
87
- ],
88
- });
89
- }
90
-
91
- if (!method) {
92
- throw new Error("Login cancelled");
93
- }
94
-
95
- if (method === "browser") {
96
- // Open the OpenModel Console in the browser
97
- callbacks.onAuth({ url: CONSOLE_URL });
98
-
99
- // Then prompt for the API key
100
- const apiKey = sanitizeApiKey(
101
- await callbacks.onPrompt({
102
- message: `1. Open ${CONSOLE_URL}\n2. In the sidebar, click on API Keys\n3. Click Create API Key, give it a name, and copy the generated key\n4. Paste the key here (starts with "om-"):`,
103
- }),
104
- );
105
-
106
- if (!apiKey) {
107
- throw new Error("No OpenModel API key provided");
108
- }
109
-
110
- if (!isValidApiKey(apiKey)) {
111
- let retry: string | undefined;
112
- if (callbacks.onSelect) {
113
- retry = await callbacks.onSelect({
114
- message: `Invalid API key format. Key should start with "om-". Try again?`,
115
- options: [
116
- { id: "retry", label: "🔄 Try again" },
117
- { id: "cancel", label: "❌ Cancel" },
118
- ],
119
- });
120
- }
121
-
122
- if (retry !== "retry") {
123
- throw new Error("Login cancelled - invalid API key");
124
- }
125
-
126
- // Recursive retry
127
- return login(callbacks);
128
- }
129
-
130
- return credentialsFromApiKey(apiKey);
131
- }
132
-
133
- // Manual paste
134
- const apiKey = sanitizeApiKey(
135
- await callbacks.onPrompt({
136
- message: 'Paste your OpenModel API key (starts with "om-"):',
137
- }),
138
- );
139
-
140
- if (!apiKey) {
141
- throw new Error("No OpenModel API key provided");
142
- }
143
-
144
- if (!isValidApiKey(apiKey)) {
145
- const retry = callbacks.onSelect
146
- ? await callbacks.onSelect({
147
- message: `Invalid API key format. Key should start with "om-". Try again?`,
148
- options: [
149
- { id: "retry", label: "🔄 Try again" },
150
- { id: "cancel", label: "❌ Cancel" },
151
- ],
152
- })
153
- : undefined;
154
-
155
- if (retry !== "retry") {
156
- throw new Error("Login cancelled - invalid API key");
157
- }
158
-
159
- return login(callbacks);
160
- }
161
-
162
- return credentialsFromApiKey(apiKey);
163
- }
164
-
165
- /**
166
- * OpenModel API keys don't expire, so "refresh" is a no-op.
167
- */
168
- export async function refreshToken(
169
- credentials: OAuthCredentials,
170
- ): Promise<OAuthCredentials> {
171
- return credentialsFromApiKey(credentials.refresh);
172
- }
173
-
174
- /**
175
- * Extract the API key from stored credentials.
176
- */
177
- export function getApiKey(credentials: OAuthCredentials): string {
178
- return credentials.access;
179
- }
package/src/stability.ts DELETED
@@ -1,204 +0,0 @@
1
- /**
2
- * OpenModel.ai Model Stability API.
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
-
12
- import { parseWebError, friendlyMessage } from "./errors.ts"
13
-
14
- export const STABILITY_SUMMARY_URL =
15
- "https://api.openmodel.ai/web/v1/model-stability/summary";
16
-
17
- /** Health status derived from success rate */
18
- export type HealthStatus =
19
- | "operational"
20
- | "healthy"
21
- | "degraded"
22
- | "unstable"
23
- | "no_data";
24
-
25
- /** Confidence level based on sample size */
26
- export type ConfidenceLevel = "high" | "medium" | "low";
27
-
28
- /** Stability summary for a single model */
29
- export interface ModelStability {
30
- model_name: string;
31
- success_rate: number;
32
- avg_latency_ms: number;
33
- avg_tps: number;
34
- confidence: ConfidenceLevel;
35
- health_status: HealthStatus;
36
- }
37
-
38
- /** Stability summary for a single model with time series */
39
- export interface ModelStabilityDetail {
40
- model_name: string;
41
- confidence: ConfidenceLevel;
42
- summary: {
43
- success_rate: number;
44
- avg_latency_ms: number;
45
- avg_ttft_ms: number;
46
- avg_tps: number;
47
- };
48
- series: Array<{
49
- ts: number;
50
- success_rate: number;
51
- avg_latency_ms: number;
52
- avg_ttft_ms: number;
53
- avg_tps: number;
54
- confidence: ConfidenceLevel;
55
- }>;
56
- updated_at: number;
57
- health_status: HealthStatus;
58
- }
59
-
60
- /** Determine health status from success rate */
61
- function determineHealth(
62
- successRate: number,
63
- confidence: ConfidenceLevel,
64
- ): HealthStatus {
65
- if (confidence === "low") return "no_data";
66
- if (successRate >= 99.9) return "operational";
67
- if (successRate >= 99) return "healthy";
68
- if (successRate >= 95) return "degraded";
69
- return "unstable";
70
- }
71
-
72
- /** Fetch stability summary for all models */
73
- export async function fetchModelStabilitySummary(options?: {
74
- url?: string;
75
- fetchImpl?: typeof fetch;
76
- hours?: number;
77
- signal?: AbortSignal;
78
- }): Promise<ModelStability[]> {
79
- const url = options?.url ?? STABILITY_SUMMARY_URL;
80
- const fetchImpl = options?.fetchImpl ?? fetch;
81
- const hours = options?.hours ?? 24;
82
-
83
- const params = new URLSearchParams({ hours: String(hours) });
84
- const response = await fetchImpl(`${url}?${params}`, {
85
- headers: { accept: "application/json" },
86
- signal: options?.signal ?? null,
87
- });
88
-
89
- if (!response.ok) {
90
- let errBody: any
91
- try { errBody = await response.json() } catch {}
92
- const err = parseWebError(errBody)
93
- throw new Error(`stability — ${friendlyMessage(err.code, err.message)}`)
94
- }
95
-
96
- const body = (await response.json()) as {
97
- success: boolean;
98
- data: Array<{
99
- model_name: string;
100
- success_rate: number;
101
- avg_latency_ms: number;
102
- avg_tps: number;
103
- confidence: ConfidenceLevel;
104
- }>;
105
- };
106
-
107
- if (!body.success) {
108
- throw new Error(`stability — ${friendlyMessage("INTERNAL_ERROR", "Summary request failed")}`)
109
- }
110
-
111
- return body.data.map((item) => ({
112
- ...item,
113
- health_status: determineHealth(item.success_rate, item.confidence),
114
- }));
115
- }
116
-
117
- /** Fetch stability detail for a specific model */
118
- export async function fetchModelStabilityDetail(
119
- modelKey: string,
120
- options?: {
121
- fetchImpl?: typeof fetch;
122
- hours?: number;
123
- signal?: AbortSignal;
124
- },
125
- ): Promise<ModelStabilityDetail> {
126
- const fetchImpl = options?.fetchImpl ?? fetch;
127
- const hours = options?.hours ?? 24;
128
-
129
- const params = new URLSearchParams({ hours: String(hours) });
130
- const response = await fetchImpl(
131
- `https://api.openmodel.ai/web/v1/model-stability/${encodeURIComponent(modelKey)}?${params}`,
132
- { headers: { accept: "application/json" }, signal: options?.signal ?? null },
133
- );
134
-
135
- if (!response.ok) {
136
- let errBody: any
137
- try { errBody = await response.json() } catch {}
138
- const err = parseWebError(errBody)
139
- throw new Error(`stability — ${friendlyMessage(err.code, err.message)}`)
140
- }
141
-
142
- const body = (await response.json()) as {
143
- success: boolean;
144
- data: {
145
- model_name: string;
146
- confidence: ConfidenceLevel;
147
- summary: {
148
- success_rate: number;
149
- avg_latency_ms: number;
150
- avg_ttft_ms: number;
151
- avg_tps: number;
152
- };
153
- series: Array<{
154
- ts: number;
155
- success_rate: number;
156
- avg_latency_ms: number;
157
- avg_ttft_ms: number;
158
- avg_tps: number;
159
- confidence: ConfidenceLevel;
160
- }>;
161
- updated_at: number;
162
- };
163
- };
164
-
165
- if (!body.success) {
166
- throw new Error(`stability — ${friendlyMessage("NOT_FOUND", `Model "${modelKey}" not found`)}`)
167
- }
168
-
169
- return {
170
- ...body.data,
171
- health_status: determineHealth(
172
- body.data.summary.success_rate,
173
- body.data.confidence,
174
- ),
175
- };
176
- }
177
-
178
- /** Format health status with emoji */
179
- export function formatHealthStatus(status: HealthStatus): string {
180
- switch (status) {
181
- case "operational":
182
- return "✅ Operational";
183
- case "healthy":
184
- return "🟢 Healthy";
185
- case "degraded":
186
- return "🟡 Degraded";
187
- case "unstable":
188
- return "🔴 Unstable";
189
- case "no_data":
190
- return "⚪ No Data";
191
- }
192
- }
193
-
194
- /** Format confidence level */
195
- export function formatConfidence(level: ConfidenceLevel): string {
196
- switch (level) {
197
- case "high":
198
- return "🟢 High";
199
- case "medium":
200
- return "🟡 Medium";
201
- case "low":
202
- return "⚪ Low";
203
- }
204
- }