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 +15 -0
- package/README.md +32 -0
- package/index.ts +5 -5
- package/package.json +1 -1
- package/src/{models.ts → api/models.ts} +56 -89
- package/src/api/stability.ts +184 -0
- package/src/auth/login.ts +132 -0
- package/src/auth/validate.ts +33 -0
- package/src/cache.ts +1 -1
- package/src/formatters/stability.ts +48 -0
- package/src/providers/compat.ts +50 -0
- package/src/providers/pricing.ts +12 -0
- package/src/providers/protocols.ts +53 -0
- package/src/auth.ts +0 -179
- package/src/stability.ts +0 -204
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
|
-
|
|
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
|
@@ -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 "
|
|
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:
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
}
|