pi-openmodel-provider 0.2.7 → 0.2.9

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.
@@ -39,3 +39,7 @@ The `/openmodel-stability` command shows real-time data from OpenModel's public
39
39
  - 🟡 Degraded (≥95%)
40
40
  - 🔴 Unstable (<95%)
41
41
  - ⚪ No Data (<10 requests)
42
+
43
+ ---
44
+
45
+ **Maintained by** [Ivan Gabriel Yarupaitan Rivera](https://www.vanchi.pro/)
package/AGENTS.md CHANGED
@@ -5,3 +5,5 @@
5
5
  - See `.agents/skills/pi-openmodel-info/SKILL.md` for full documentation.
6
6
  - Follow [CONTRIBUTING.md](CONTRIBUTING.md) before changing code.
7
7
  - Use [RELEASE.md](RELEASE.md) for release process.
8
+
9
+ **Maintained by** [Ivan Gabriel Yarupaitan Rivera](https://www.vanchi.pro/)
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.9] - 2026-06-20
9
+
10
+ ### Added
11
+ - `src/errors.ts` with `parseWebError`, `parseProxyError`, `friendlyMessage` helpers
12
+ - Friendly error messages for 401, 402, 404, 429, 5xx and more
13
+ - `thinkingLevelMap` for reasoning models (Messages + Responses protocols)
14
+ - Error handling in model discovery and stability endpoints
15
+ - Comprehensive error handling section in README
16
+
17
+ ### Changed
18
+ - Models now fetched from two public endpoints (no auth required)
19
+ - Pricing, context window, reasoning, and vision capabilities from real API
20
+ - Removed all hardcoded pricing tables (PROVIDER_DEFAULTS, PRICING_OVERRIDES)
21
+ - Simplified index.ts: removed readFileSync and auth.json reading
22
+ - Stability errors now show specific API error codes
23
+ - Updated all tests to use mock fetch with real API response format
24
+
25
+ ### Removed
26
+ - Hardcoded PROVIDER_DEFAULTS in src/models.ts
27
+ - Hardcoded PRICING_OVERRIDES, CONTEXT_OVERRIDES, MAX_TOKENS_OVERRIDES
28
+ - Hardcoded REASONING_OVERRIDES in src/models.ts
29
+ - readFileSync import and getApiKeyFromAuth function from index.ts
30
+
8
31
  ## [0.2.6] - 2026-06-20
9
32
 
10
33
  ### Fixed
@@ -86,6 +109,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
86
109
  - Import path extensions (.ts → .js)
87
110
  - Process import in models.ts
88
111
 
112
+ [0.2.9]: https://github.com/IvanGabrielYarupaitanRivera/pi-openmodel-provider/releases/tag/v0.2.9
89
113
  [0.2.6]: https://github.com/IvanGabrielYarupaitanRivera/pi-openmodel-provider/releases/tag/v0.2.6
90
114
  [0.2.5]: https://github.com/IvanGabrielYarupaitanRivera/pi-openmodel-provider/releases/tag/v0.2.5
91
115
  [0.2.4]: https://github.com/IvanGabrielYarupaitanRivera/pi-openmodel-provider/releases/tag/v0.2.4
package/README.md CHANGED
@@ -2,16 +2,22 @@
2
2
 
3
3
  A [pi](https://github.com/earendil-works/pi-mono) custom provider that connects pi to [OpenModel.ai](https://www.openmodel.ai) — a unified AI API gateway.
4
4
 
5
+ [![npm version](https://badge.fury.io/js/pi-openmodel-provider.svg)](https://www.npmjs.com/package/pi-openmodel-provider)
6
+
5
7
  > **Disclaimer:** This is an unofficial, community-maintained package. I am not affiliated with, endorsed by, or connected to OpenModel in any way. This provider simply forwards requests to the public OpenModel API using your own API key.
6
8
 
7
9
  > **Note:** This package only provides a model _provider_. It does **not** include an API key. You must bring your own OpenModel API key.
8
10
 
11
+ [▶️ Watch the video tutorial](https://youtu.be/aUaXznGVuzg) — See the full installation and usage walkthrough.
12
+
9
13
  ## Install + Quick start
10
14
 
11
15
  ```sh
12
16
  pi install npm:pi-openmodel-provider
13
17
  ```
14
18
 
19
+ ![pi-openmodel-provider thumbnail](https://raw.githubusercontent.com/IvanGabrielYarupaitanRivera/pi-openmodel-provider/master/media/thumbnail.jpeg)
20
+
15
21
  | Step | What to do |
16
22
  |------|------------|
17
23
  | 1️⃣ | `/reload` (so OpenModel appears in /login) |
@@ -51,18 +57,21 @@ Models are fetched live from OpenModel's API at startup, so new models show up w
51
57
 
52
58
  ## Model discovery
53
59
 
54
- On startup, the provider fetches:
60
+ On startup, the provider fetches models from two public endpoints (no authentication required):
55
61
 
56
- ```txt
57
- https://api.openmodel.ai/v1/models
58
- ```
62
+ - **Model list & protocols:** `https://api.openmodel.ai/v1/models`
63
+ - **Pricing & capabilities:** `https://api.openmodel.ai/web/v1/models`
64
+
65
+ Pricing, context window, reasoning support, and vision capabilities are all provided by the API — no hardcoded data.
59
66
 
60
67
  ## Pricing
61
68
 
62
- OpenModel does not yet expose model pricing through its Provider API. The provider ships a static cost table (`PROVIDER_DEFAULTS` and `PRICING_OVERRIDES` in `src/models.ts`) for known models so that pi can display per-model pricing.
69
+ Model pricing is fetched live from OpenModel's public API (`/web/v1/models`). Each model returns its real per-token rates in microdollars, converted to dollars per million tokens for display.
63
70
 
64
- - Models present in the provider defaults show their estimated per-million-token rates.
65
- - Models **not** in the table fall back to zero cost.
71
+ - Input and output tokens are billed at separate rates
72
+ - Cache reads and writes are billed at reduced rates
73
+ - A `price_multiplier` may apply (e.g., 0.95 = 5% discount)
74
+ - Free models have zero cost
66
75
 
67
76
  ## Features
68
77
 
@@ -70,7 +79,23 @@ OpenModel does not yet expose model pricing through its Provider API. The provid
70
79
  - **3 protocols**: Messages (Anthropic), Responses (OpenAI), Gemini (Google)
71
80
  - **Model stability metrics** via `/openmodel-stability`
72
81
  - **1M context window** for DeepSeek V4 models
73
- - **No hardcoding** new models appear automatically
82
+ - **Thinking levels** for reasoning models (DeepSeek, Claude, GPT, Gemini, etc.)
83
+ - **Friendly error messages** with emojis and actionable guidance
84
+ - **No hardcoding** — new models, pricing, and capabilities appear automatically
85
+
86
+ ## Error handling
87
+
88
+ Errors from OpenModel's API are shown with friendly messages:
89
+
90
+ | HTTP Status | What you'll see |
91
+ |-------------|-----------------|
92
+ | 401 | 🔑 Invalid API key. Check your credentials or run /login again. |
93
+ | 402 | 💳 Insufficient balance. Top up at console.openmodel.ai |
94
+ | 429 | ⏳ Rate limited. Try again later. |
95
+ | 404 | 🔍 Resource not found. Check the model name. |
96
+ | 5xx | 🔧 OpenModel API error. Try again later. |
97
+
98
+ For stability endpoints, errors include context about what went wrong.
74
99
 
75
100
  ## Commands
76
101
 
@@ -115,8 +140,14 @@ npm install
115
140
  # Type check
116
141
  npm run typecheck
117
142
 
118
- # Test model fetching
143
+ # Run all tests
144
+ npm test
145
+
146
+ # Run specific tests
119
147
  npm run test:models
148
+ npm run test:auth
149
+ npm run test:pricing
150
+ npm run test:stability
120
151
  ```
121
152
 
122
153
  ## Contributing
@@ -129,4 +160,8 @@ See [RELEASE.md](RELEASE.md) for prerelease, npm smoke-test, stable publish, git
129
160
 
130
161
  ## License
131
162
 
132
- MIT
163
+ MIT
164
+
165
+ ---
166
+
167
+ **Maintained by** [Ivan Gabriel Yarupaitan Rivera](https://www.vanchi.pro/)
package/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * OpenModel provider for pi.
3
3
  *
4
- * Models are fetched from OpenModel's API at startup.
4
+ * Models are fetched from OpenModel's public API at startup.
5
5
  */
6
6
 
7
7
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
@@ -12,28 +12,19 @@ import {
12
12
  fetchModelStabilityDetail,
13
13
  formatHealthStatus,
14
14
  } from "./src/stability.ts"
15
- import { readFileSync } from "node:fs"
16
-
17
- function getApiKeyFromAuth(): string | null {
18
- try {
19
- const authPath = "C:/Users/Admin/.pi/agent/auth.json"
20
- const content = readFileSync(authPath, "utf-8")
21
- const data = JSON.parse(content)
22
- return data.openmodel?.access || data.openmodel?.refresh || null
23
- } catch {
24
- return null
25
- }
26
- }
15
+ import { friendlyMessage } from "./src/errors.ts"
27
16
 
28
17
  export default async function (pi: ExtensionAPI) {
29
18
  let models: Awaited<ReturnType<typeof fetchOpenModelModels>> = []
30
- const apiKey = getApiKeyFromAuth()
19
+ let modelError: string | null = null
31
20
 
32
- if (apiKey) {
33
- try {
34
- models = await fetchOpenModelModels({ apiKey })
35
- } catch {
36
- // Models will load after API key is configured
21
+ try {
22
+ models = await fetchOpenModelModels()
23
+ } catch (error) {
24
+ if (error instanceof TypeError && error.message.includes("fetch")) {
25
+ modelError = "🌐 Network error: check your internet connection"
26
+ } else {
27
+ modelError = `⚠️ ${error instanceof Error ? error.message : "Could not load models"}`
37
28
  }
38
29
  }
39
30
 
@@ -48,39 +39,71 @@ export default async function (pi: ExtensionAPI) {
48
39
  refreshToken,
49
40
  getApiKey,
50
41
  },
51
- models: models.map((model) => ({
52
- id: model.id,
53
- name: model.name,
54
- api: model.api,
55
- reasoning: model.reasoning,
56
- input: model.input,
57
- cost: model.cost,
58
- contextWindow: model.contextWindow,
59
- maxTokens: model.maxTokens,
60
- })),
42
+ models: models.map((model) => {
43
+ const config: Record<string, unknown> = {
44
+ id: model.id,
45
+ name: model.name,
46
+ api: model.api,
47
+ reasoning: model.reasoning,
48
+ input: model.input,
49
+ cost: model.cost,
50
+ contextWindow: model.contextWindow,
51
+ maxTokens: model.maxTokens,
52
+ }
53
+ if (model.thinkingLevelMap) {
54
+ config.thinkingLevelMap = model.thinkingLevelMap
55
+ }
56
+ return config
57
+ }),
61
58
  })
62
59
 
63
60
  // /openmodel - Show provider status
64
61
  pi.registerCommand("openmodel", {
65
62
  description: "Show OpenModel provider status",
66
63
  handler: async (_args: string, ctx: any) => {
67
- const key = getApiKeyFromAuth()
68
- const status = key ? "✅ Configured" : "❌ Not configured"
69
64
  const count = models.length
65
+ const status = count > 0
66
+ ? `✅ ${count} models loaded`
67
+ : modelError ?? "❌ No models loaded"
68
+
69
+ // Detect if user has configured an API key in auth.json
70
+ let hasApiKey = false
71
+ try {
72
+ const { readFileSync } = await import("node:fs")
73
+ const authPath = `${require("node:os").homedir()}/.pi/agent/auth.json`
74
+ const content = readFileSync(authPath, "utf-8")
75
+ const data = JSON.parse(content)
76
+ hasApiKey = !!(data.openmodel?.access || data.openmodel?.refresh)
77
+ } catch {
78
+ // Auth file not found
79
+ }
70
80
 
71
81
  const lines = [
72
- "╔════════════════════════════════╗",
73
- "║ OpenModel.ai ║",
74
- "╠════════════════════════════════╣",
75
- `║ Status: ${status.padEnd(20)}║`,
76
- `║ Models: ${String(count).padStart(3)} available ║`,
77
- "╠════════════════════════════════╣",
78
- "║ Commands: ║",
79
- "║ /model openmodel/... ║",
80
- "║ /openmodel-stability ║",
81
- "╚════════════════════════════════╝",
82
+ "╔══════════════════════════════════╗",
83
+ "║ OpenModel.ai ║",
84
+ "╠══════════════════════════════════╣",
85
+ `║ Models: ${String(count).padStart(3)} loaded ║`,
86
+ hasApiKey ? "║ API Key: Configured ║" : "║ API Key: ❌ Not configured ║",
87
+ "╠══════════════════════════════════╣",
88
+ "║ Commands: ║",
89
+ "║ /model openmodel/... ║",
90
+ "║ /openmodel-stability ║",
91
+ "╚══════════════════════════════════╝",
82
92
  ]
83
- ctx.ui.notify(lines.join("\n"), "info")
93
+
94
+ const hints: string[] = []
95
+ if (!hasApiKey) {
96
+ hints.push("ℹ️ Run /login → OpenModel → paste your API key")
97
+ }
98
+ if (count === 0 && hasApiKey) {
99
+ hints.push("ℹ️ Run /reload after setting your API key")
100
+ }
101
+ if (count === 0 && modelError) {
102
+ hints.push(`ℹ️ ${modelError}`)
103
+ }
104
+ hints.push("ℹ️ Press Ctrl+L to select a model")
105
+
106
+ ctx.ui.notify([...lines, ...hints].join("\n"), "info")
84
107
  },
85
108
  })
86
109
 
@@ -106,7 +129,7 @@ export default async function (pi: ExtensionAPI) {
106
129
  } else {
107
130
  const summary = await fetchModelStabilitySummary()
108
131
  if (summary.length === 0) {
109
- ctx.ui.notify("No stability data available.", "warning")
132
+ ctx.ui.notify("📊 No stability data available for any model yet.", "warning")
110
133
  return
111
134
  }
112
135
  const lines = ["📊 OpenModel Stability (24h)", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"]
@@ -120,8 +143,9 @@ export default async function (pi: ExtensionAPI) {
120
143
  }
121
144
  ctx.ui.notify(lines.join("\n"), "info")
122
145
  }
123
- } catch {
124
- ctx.ui.notify("Failed to fetch stability data.", "error")
146
+ } catch (error) {
147
+ const msg = error instanceof Error ? error.message : "Unknown error"
148
+ ctx.ui.notify(`❌ ${msg}`, "error")
125
149
  }
126
150
  },
127
151
  })
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-openmodel-provider",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "pi custom provider for OpenModel.ai - Multi-model AI gateway",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -22,6 +22,7 @@
22
22
  "src/stub.d.ts",
23
23
  "AGENTS.md",
24
24
  ".agents/",
25
+ "media/",
25
26
  "README.md",
26
27
  "CHANGELOG.md",
27
28
  "LICENSE"
@@ -34,7 +35,12 @@
34
35
  },
35
36
  "scripts": {
36
37
  "typecheck": "tsc --noEmit",
37
- "test:models": "tsx src/models.ts"
38
+ "test:models": "tsx tests/test-models.ts",
39
+ "test:auth": "tsx tests/test-auth.ts",
40
+ "test:pricing": "tsx tests/test-pricing.ts",
41
+ "test:stability": "tsx tests/test-stability.ts",
42
+ "test:edge": "tsx tests/test-edge-cases.ts",
43
+ "test": "tsx tests/test-models.ts && tsx tests/test-auth.ts && tsx tests/test-pricing.ts && tsx tests/test-stability.ts && tsx tests/test-edge-cases.ts"
38
44
  },
39
45
  "pi": {
40
46
  "extensions": [
@@ -42,7 +48,9 @@
42
48
  ],
43
49
  "skills": [
44
50
  "./.agents/skills"
45
- ]
51
+ ],
52
+ "image": "https://raw.githubusercontent.com/IvanGabrielYarupaitanRivera/pi-openmodel-provider/master/media/thumbnail.jpeg",
53
+ "video": "https://youtu.be/aUaXznGVuzg"
46
54
  },
47
55
  "peerDependencies": {
48
56
  "@earendil-works/pi-coding-agent": "^0.75.5"
package/src/errors.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Shared error handling for OpenModel API responses.
3
+ *
4
+ * OpenModel Web API returns errors in this format:
5
+ * { success: false, error: { code: string, msg: string, detail?: string } }
6
+ *
7
+ * Proxy endpoints return errors in provider-specific formats (Anthropic, OpenAI, Gemini).
8
+ */
9
+
10
+ /** Parse an OpenModel Web API error response body */
11
+ 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
+ }
18
+ if (err.detail) {
19
+ result.detail = String(err.detail)
20
+ }
21
+ return result
22
+ }
23
+ return { code: "UNKNOWN", message: "An unknown error occurred" }
24
+ }
25
+
26
+ /** Parse an OpenModel proxy API error body (any format) */
27
+ export function parseProxyError(body: unknown): { code: string; message: string } {
28
+ const b = body as any
29
+
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
+ }
34
+
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
+ }
39
+
40
+ // Gemini format: { error: { code, message, status } }
41
+ if (b?.error?.status) {
42
+ return { code: b.error.status, message: b.error.message }
43
+ }
44
+
45
+ return { code: "UNKNOWN", message: "An unknown error occurred" }
46
+ }
47
+
48
+ /** Return a user-friendly message for known error codes */
49
+ export function friendlyMessage(code: string, fallback: string): string {
50
+ const map: Record<string, string> = {
51
+ UNAUTHORIZED: "🔑 Invalid API key. Check your credentials or run /login again.",
52
+ INVALID_TOKEN: "🔑 Invalid or expired API key. Run /login again.",
53
+ TOKEN_EXPIRED: "🔑 API key expired. Run /login again.",
54
+ FORBIDDEN: "🚫 Permission denied. Check your account permissions.",
55
+ NOT_FOUND: "🔍 Resource not found. Check the model name or endpoint.",
56
+ RESOURCE_NOT_FOUND: "🔍 Model not found. Check the name and try again.",
57
+ TOO_MANY_REQUESTS: "⏳ Rate limited. Try again later.",
58
+ INSUFFICIENT_BALANCE: "💳 Insufficient balance. Top up at console.openmodel.ai",
59
+ PAYLOAD_TOO_LARGE: "📦 Request too large. Reduce the input size.",
60
+ VALIDATION_FAILED: "⚠️ Invalid request. Check your parameters.",
61
+ INTERNAL_ERROR: "🔧 OpenModel API error. Try again later.",
62
+ SERVICE_UNAVAIL: "🔧 Service temporarily unavailable. Try again later.",
63
+ BAD_REQUEST: "⚠️ Invalid request. Check the parameters.",
64
+ CONFIG_NOT_READY: "⏳ System not ready. Try again later.",
65
+ }
66
+ return map[code] ?? fallback
67
+ }
package/src/models.ts CHANGED
@@ -1,309 +1,223 @@
1
1
  /**
2
2
  * OpenModel.ai model fetching and parsing.
3
3
  *
4
- * Fetches available models from OpenModel's API endpoint
5
- * and maps them to pi provider model definitions.
6
- *
7
- * Rather than hardcoding per-model metadata, we infer capabilities
8
- * from the provider (owned_by) and model name patterns. This way
9
- * new models added by OpenModel are automatically supported.
4
+ * Fetches available models from OpenModel's public API (no auth required).
5
+ * Pricing, context window, and capabilities are all provided by the API.
10
6
  */
11
7
 
12
- export const DEFAULT_MODELS_URL = "https://api.openmodel.ai/v1/models";
13
- export const DEFAULT_API_BASE = "https://api.openmodel.ai";
8
+ import { parseWebError, parseProxyError, friendlyMessage } from "./errors.ts"
14
9
 
15
- /** Supported protocols from OpenModel API */
16
- type SupportedProtocol = "messages" | "responses" | "gemini" | "images";
10
+ const DEFAULT_WEB_MODELS_URL = "https://api.openmodel.ai/web/v1/models"
11
+ export const DEFAULT_LEGACY_MODELS_URL = "https://api.openmodel.ai/v1/models"
17
12
 
18
- /** Raw model from OpenModel API response */
19
- interface OpenModelApiModel {
20
- id: string;
21
- object: string;
22
- created: number;
23
- owned_by: string;
24
- supported_protocols: SupportedProtocol[];
25
- supported_apis?: SupportedProtocol[]; // alt name from docs
13
+ export interface OpenModelProviderModel {
14
+ id: string
15
+ name: string
16
+ reasoning: boolean
17
+ thinkingLevelMap?: Partial<Record<"off" | "minimal" | "low" | "medium" | "high" | "xhigh", string | null>>
18
+ input: readonly ("text" | "image")[]
19
+ cost: { input: number; output: number; cacheRead: number; cacheWrite: number }
20
+ contextWindow: number
21
+ maxTokens: number
22
+ api: "anthropic-messages" | "openai-responses" | "google-generative-ai"
26
23
  }
27
24
 
28
- /** OpenModel API response shape */
29
- interface OpenModelModelsResponse {
30
- data: OpenModelApiModel[];
31
- object: string;
25
+ interface WebApiModel {
26
+ key: string
27
+ provider_key: string
28
+ provider_name: string
29
+ prices: Record<string, number | Record<string, number>>
30
+ max: {
31
+ max_input_tokens?: number
32
+ max_output_tokens?: number
33
+ max_tokens?: number
34
+ }
35
+ supports: {
36
+ supports_reasoning?: boolean
37
+ supports_vision?: boolean
38
+ supports_image_generation?: boolean
39
+ }
40
+ price_multiplier: number
32
41
  }
33
42
 
34
- /** Mapped provider model for pi */
35
- export interface OpenModelProviderModel {
36
- id: string;
37
- name: string;
38
- reasoning: boolean;
39
- input: readonly ("text" | "image")[];
40
- cost: {
41
- input: number;
42
- output: number;
43
- cacheRead: number;
44
- cacheWrite: number;
45
- };
46
- contextWindow: number;
47
- maxTokens: number;
48
- api: "anthropic-messages" | "openai-responses" | "google-generative-ai";
43
+ interface WebApiResponse {
44
+ success: boolean
45
+ data: WebApiModel[]
46
+ meta: { pagination: { page: number; pageSize: number; total: number; totalPages: number } }
49
47
  }
50
48
 
51
- // ---------------------------------------------------------------------------
52
- // Provider-level defaults based on owned_by
53
- // ---------------------------------------------------------------------------
54
-
55
- interface ProviderDefaults {
56
- contextWindow: number;
57
- maxTokens: number;
58
- reasoning: boolean;
59
- supportsImages: boolean;
60
- costPerMInput: number; // $ per million input tokens
61
- costPerMOutput: number; // $ per million output tokens
49
+ interface LegacyApiModel {
50
+ id: string
51
+ object: string
52
+ owned_by: string
53
+ supported_protocols: string[]
62
54
  }
63
55
 
64
- const PROVIDER_DEFAULTS: Record<string, ProviderDefaults> = {
65
- anthropic: {
66
- contextWindow: 200_000,
67
- maxTokens: 8_192,
68
- reasoning: true,
69
- supportsImages: true,
70
- costPerMInput: 3,
71
- costPerMOutput: 15,
72
- },
73
- deepseek: {
74
- contextWindow: 1_000_000,
75
- maxTokens: 65_536,
76
- reasoning: true,
77
- supportsImages: false,
78
- costPerMInput: 0.14,
79
- costPerMOutput: 0.28,
80
- },
81
- openai: {
82
- contextWindow: 128_000,
83
- maxTokens: 16_384,
84
- reasoning: true,
85
- supportsImages: true,
86
- costPerMInput: 2.5,
87
- costPerMOutput: 10,
88
- },
89
- gemini: {
90
- contextWindow: 1_000_000,
91
- maxTokens: 8_192,
92
- reasoning: true,
93
- supportsImages: true,
94
- costPerMInput: 0.3,
95
- costPerMOutput: 1.2,
96
- },
97
- moonshot: {
98
- contextWindow: 128_000,
99
- maxTokens: 65_536,
100
- reasoning: true,
101
- supportsImages: true,
102
- costPerMInput: 0.6,
103
- costPerMOutput: 3,
104
- },
105
- zai: {
106
- contextWindow: 128_000,
107
- maxTokens: 16_384,
108
- reasoning: true,
109
- supportsImages: false,
110
- costPerMInput: 1,
111
- costPerMOutput: 3.2,
112
- },
113
- dashscope: {
114
- contextWindow: 131_072,
115
- maxTokens: 16_384,
116
- reasoning: true,
117
- supportsImages: true,
118
- costPerMInput: 0.5,
119
- costPerMOutput: 3,
120
- },
121
- minimax: {
122
- contextWindow: 128_000,
123
- maxTokens: 16_384,
124
- reasoning: true,
125
- supportsImages: false,
126
- costPerMInput: 0.27,
127
- costPerMOutput: 0.95,
128
- },
129
- mimo: {
130
- contextWindow: 128_000,
131
- maxTokens: 16_384,
132
- reasoning: true,
133
- supportsImages: false,
134
- costPerMInput: 0,
135
- costPerMOutput: 0,
136
- },
137
- };
138
-
139
- const DEFAULT_FALLBACK: ProviderDefaults = {
140
- contextWindow: 128_000,
141
- maxTokens: 16_384,
142
- reasoning: true,
143
- supportsImages: false,
144
- costPerMInput: 0,
145
- costPerMOutput: 0,
146
- };
147
-
148
- function getDefaults(ownedBy: string): ProviderDefaults {
149
- return PROVIDER_DEFAULTS[ownedBy.toLowerCase()] ?? DEFAULT_FALLBACK;
56
+ interface LegacyApiResponse {
57
+ data: LegacyApiModel[]
58
+ object: string
150
59
  }
151
60
 
152
- // ---------------------------------------------------------------------------
153
- // Model-specific overrides for well-known exceptions
154
- // ---------------------------------------------------------------------------
155
-
156
- /** Fine-tune contextWindow for specific model IDs that differ from their provider default */
157
- const CONTEXT_OVERRIDES: Record<string, number> = {
158
- // Some older/smaller models have less context
159
- };
160
-
161
- /** Fine-tune maxTokens for specific model IDs */
162
- const MAX_TOKENS_OVERRIDES: Record<string, number> = {
163
- // e.g., "some-small-model": 4096,
164
- };
165
-
166
- /** Fine-tune reasoning for specific model IDs */
167
- const REASONING_OVERRIDES: Record<string, boolean> = {
168
- "gpt-5.4-mini": false,
169
- "gemini-3.1-flash-lite-preview": false,
170
- "gemini-3-flash-preview": false,
171
- };
172
-
173
- /** Known pricing exceptions (model-specific overrides to provider defaults) */
174
- const PRICING_OVERRIDES: Record<string, { input: number; output: number }> = {
175
- "claude-opus-4-7": { input: 15, output: 75 },
176
- "claude-opus-4-6": { input: 15, output: 75 },
177
- "claude-opus-4-8": { input: 15, output: 75 },
178
- "claude-sonnet-4-5": { input: 3, output: 15 },
179
- "claude-sonnet-4-6": { input: 3, output: 15 },
180
- "claude-haiku-4-5-20251001": { input: 0.25, output: 1.25 },
181
- "deepseek-v4-pro": { input: 0.435, output: 0.87 },
182
- "deepseek-v4-flash": { input: 0.14, output: 0.28 },
183
- "gpt-5.5-pro": { input: 10, output: 40 },
184
- "gpt-5.5": { input: 5, output: 20 },
185
- "gpt-5.4-pro": { input: 5, output: 20 },
186
- "gpt-5.4": { input: 2.5, output: 10 },
187
- "gpt-5.4-mini": { input: 0.4, output: 1.6 },
188
- "gpt-5.3-codex": { input: 2, output: 8 },
189
- "gpt-5.2-pro": { input: 5, output: 20 },
190
- "gpt-5.2": { input: 2, output: 8 },
191
- "gemini-3.5-flash": { input: 0.3, output: 1.2 },
192
- "gemini-3.1-pro-preview": { input: 1.5, output: 6.0 },
193
- "gemini-3-flash-preview": { input: 0.15, output: 0.6 },
194
- "kimi-k2.6": { input: 0.95, output: 4 },
195
- "kimi-k2.5": { input: 0.6, output: 3 },
196
- "kimi-k2.7-code": { input: 0.95, output: 4 },
197
- "glm-5.2": { input: 1.4, output: 5.6 },
198
- "glm-5.1": { input: 1.4, output: 4.4 },
199
- "glm-5": { input: 1, output: 3.2 },
200
- "glm-4.7": { input: 0.5, output: 2 },
201
- "qwen3.7-max": { input: 2, output: 6 },
202
- "qwen3.6-max-preview": { input: 1.3, output: 7.8 },
203
- "qwen3.6-plus": { input: 0.5, output: 3 },
204
- "qwen3.6-flash": { input: 0.2, output: 1 },
205
- "qwen3.5-plus": { input: 0.5, output: 3 },
206
- "qwen3-max": { input: 2.5, output: 6 },
207
- "MiniMax-M3": { input: 0.5, output: 2 },
208
- "MiniMax-M2.7": { input: 0.3, output: 1.2 },
209
- "MiniMax-M2.5": { input: 0.27, output: 0.95 },
210
- };
211
-
212
- // ---------------------------------------------------------------------------
213
- // Mapping
214
- // ---------------------------------------------------------------------------
215
-
216
- /** Map OpenModel protocol to pi API type */
217
- function protocolToApi(
218
- protocols: SupportedProtocol[],
219
- ): "anthropic-messages" | "openai-responses" | "google-generative-ai" | null {
220
- if (protocols.includes("messages")) return "anthropic-messages";
221
- if (protocols.includes("responses")) return "openai-responses";
222
- if (protocols.includes("gemini")) return "google-generative-ai";
223
- return null; // images-only, skip
61
+ function pricePerMillion(costPerToken: number | undefined): number {
62
+ if (costPerToken === undefined || costPerToken === null) return 0
63
+ return Math.round(costPerToken * 1_000_000 * 1000) / 1000
224
64
  }
225
65
 
226
- /** Parse raw API model into pi provider model */
227
- function parseApiModel(raw: OpenModelApiModel): OpenModelProviderModel | null {
228
- // Accept both supported_protocols (API) and supported_apis (doc) field names
229
- const protocols = raw.supported_protocols ?? raw.supported_apis ?? [];
230
- const api = protocolToApi(protocols);
231
- if (!api) return null; // skip image-only models
232
-
233
- const defaults = getDefaults(raw.owned_by);
234
- const pricing = PRICING_OVERRIDES[raw.id] ?? {
235
- input: defaults.costPerMInput,
236
- output: defaults.costPerMOutput,
237
- };
238
-
239
- return {
240
- id: raw.id,
241
- name: raw.id,
242
- reasoning: REASONING_OVERRIDES[raw.id] ?? defaults.reasoning,
243
- input: defaults.supportsImages
244
- ? (["text", "image"] as const)
245
- : (["text"] as const),
246
- cost: {
247
- input: pricing.input,
248
- output: pricing.output,
249
- cacheRead: pricing.input * 0.1,
250
- cacheWrite: pricing.input * 0.25,
251
- },
252
- contextWindow: CONTEXT_OVERRIDES[raw.id] ?? defaults.contextWindow,
253
- maxTokens: MAX_TOKENS_OVERRIDES[raw.id] ?? defaults.maxTokens,
254
- api,
255
- };
66
+ function determineApi(protocols: string[], provider: string): "anthropic-messages" | "openai-responses" | "google-generative-ai" | null {
67
+ if (protocols.includes("messages")) return "anthropic-messages"
68
+ if (protocols.includes("responses")) return "openai-responses"
69
+ if (protocols.includes("gemini")) return "google-generative-ai"
70
+ return null
256
71
  }
257
72
 
258
- /** Fetch models from OpenModel API */
259
- export async function fetchOpenModelModels(options?: {
260
- url?: string;
261
- fetchImpl?: typeof fetch;
262
- apiKey?: string;
263
- }): Promise<readonly OpenModelProviderModel[]> {
264
- const url = options?.url ?? DEFAULT_MODELS_URL;
265
- const fetchImpl = options?.fetchImpl ?? fetch;
73
+ function thinkingLevelMapForApi(api: "anthropic-messages" | "openai-responses" | "google-generative-ai"): Partial<Record<"off" | "minimal" | "low" | "medium" | "high" | "xhigh", string | null>> {
74
+ if (api === "anthropic-messages") {
75
+ return {
76
+ minimal: "low",
77
+ low: "medium",
78
+ medium: "high",
79
+ high: "high",
80
+ xhigh: "max",
81
+ }
82
+ }
83
+ if (api === "openai-responses") {
84
+ return {
85
+ minimal: "low",
86
+ low: "low",
87
+ medium: "medium",
88
+ high: "high",
89
+ xhigh: "high",
90
+ }
91
+ }
92
+ return {}
93
+ }
266
94
 
267
- const headers: Record<string, string> = { accept: "application/json" };
268
- if (options?.apiKey) {
269
- headers["authorization"] = `Bearer ${options.apiKey}`;
95
+ /** Fetch all models from the web API (public, no auth required) */
96
+ async function fetchWebModels(options?: {
97
+ url?: string
98
+ fetchImpl?: typeof fetch
99
+ }): Promise<Map<string, WebApiModel>> {
100
+ const baseUrl = options?.url ?? DEFAULT_WEB_MODELS_URL
101
+ const fetchImpl = options?.fetchImpl ?? fetch
102
+
103
+ const modelMap = new Map<string, WebApiModel>()
104
+ let page = 1
105
+ let totalPages = 1
106
+
107
+ while (page <= totalPages) {
108
+ const response = await fetchImpl(`${baseUrl}?page=${page}`, {
109
+ headers: { accept: "application/json" },
110
+ })
111
+
112
+ if (!response.ok) {
113
+ let body: any
114
+ try { body = await response.json() } catch {}
115
+ const err = parseWebError(body)
116
+ throw new Error(`Failed to fetch models: ${response.status} ${err.code} — ${friendlyMessage(err.code, err.message)}`)
117
+ }
118
+
119
+ const body = (await response.json()) as WebApiResponse
120
+ if (!body.success) {
121
+ throw new Error(`Failed to fetch models — ${friendlyMessage("INTERNAL_ERROR", "Unknown error")}`)
122
+ }
123
+
124
+ totalPages = body.meta.pagination.totalPages
125
+ for (const model of body.data) {
126
+ modelMap.set(model.key, model)
127
+ }
128
+ page++
270
129
  }
271
130
 
272
- const response = await fetchImpl(url, { headers });
131
+ return modelMap
132
+ }
133
+
134
+ /** Fetch protocol info from legacy models endpoint */
135
+ async function fetchLegacyModels(options?: {
136
+ url?: string
137
+ fetchImpl?: typeof fetch
138
+ }): Promise<Map<string, LegacyApiModel>> {
139
+ const url = options?.url ?? DEFAULT_LEGACY_MODELS_URL
140
+ const fetchImpl = options?.fetchImpl ?? fetch
141
+
142
+ const response = await fetchImpl(url, {
143
+ headers: { accept: "application/json" },
144
+ })
273
145
 
274
146
  if (!response.ok) {
275
- throw new Error(
276
- `Failed to fetch OpenModel models: ${response.status} ${response.statusText}`,
277
- );
147
+ let body: any
148
+ try { body = await response.json() } catch {}
149
+ const err = parseProxyError(body)
150
+ throw new Error(`Failed to fetch models: ${response.status} — ${friendlyMessage(err.code, err.message)}`)
278
151
  }
279
152
 
280
- const body = (await response.json()) as OpenModelModelsResponse;
281
- const models: OpenModelProviderModel[] = [];
153
+ const body = (await response.json()) as LegacyApiResponse
154
+ const modelMap = new Map<string, LegacyApiModel>()
282
155
 
283
- for (const raw of body.data) {
284
- if (raw.object !== "model") continue;
285
- const parsed = parseApiModel(raw);
286
- if (parsed) models.push(parsed);
156
+ for (const model of body.data) {
157
+ if (model.object === "model") {
158
+ modelMap.set(model.id, model)
159
+ }
287
160
  }
288
161
 
289
- return models;
162
+ return modelMap
290
163
  }
291
164
 
292
- // Allow direct execution: `tsx src/models.ts`
293
- // if (import.meta.url === `file://${process.argv[1]}`) {
294
- // const { env } = await import('node:process');
295
- // const key = env.OPENMODEL_API_KEY
296
- // const models = await fetchOpenModelModels({
297
- // apiKey: key ?? undefined,
298
- // })
299
- // for (const m of models) {
300
- // console.log(
301
- // `${m.id.padEnd(30)} ` +
302
- // `${m.api.padEnd(22)} ` +
303
- // `${m.input.join("+").padEnd(8)} ` +
304
- // `ctx=${String(m.contextWindow).padStart(7)} ` +
305
- // `max=${String(m.maxTokens).padStart(5)} ` +
306
- // `\$${m.cost.input.toFixed(3)}/\$${m.cost.output.toFixed(3)}`,
307
- // )
308
- // }
309
- // }
165
+ /** Fetch models from OpenModel API (public, no auth required) */
166
+ export async function fetchOpenModelModels(options?: {
167
+ webUrl?: string
168
+ legacyUrl?: string
169
+ fetchImpl?: typeof fetch
170
+ }): Promise<readonly OpenModelProviderModel[]> {
171
+ const fetchImpl = options?.fetchImpl ?? fetch
172
+
173
+ const [webModels, legacyModels] = await Promise.all([
174
+ fetchWebModels({ fetchImpl }),
175
+ fetchLegacyModels({ fetchImpl }),
176
+ ])
177
+
178
+ const models: OpenModelProviderModel[] = []
179
+
180
+ for (const [id, web] of webModels) {
181
+ // Skip image-only models
182
+ if (web.supports.supports_image_generation && !web.supports.supports_vision && !web.supports.supports_reasoning) {
183
+ continue
184
+ }
185
+
186
+ const legacy = legacyModels.get(id)
187
+ const protocols = legacy?.supported_protocols ?? []
188
+ const api = determineApi(protocols, web.provider_key)
189
+ if (!api) continue
190
+
191
+ const inputPrice = pricePerMillion(web.prices.input_cost_per_token as number)
192
+ const outputPrice = pricePerMillion(web.prices.output_cost_per_token as number)
193
+ const cacheRead = pricePerMillion(web.prices.cache_read_input_token_cost as number)
194
+ const cacheWrite = pricePerMillion(web.prices.cache_creation_input_token_cost as number)
195
+
196
+ const reasoning = web.supports.supports_reasoning ?? false
197
+
198
+ const base = {
199
+ id,
200
+ name: id,
201
+ reasoning,
202
+ input: web.supports.supports_vision ? ["text", "image"] as const : ["text"] as const,
203
+ cost: {
204
+ input: inputPrice * (web.price_multiplier ?? 1),
205
+ output: outputPrice * (web.price_multiplier ?? 1),
206
+ cacheRead,
207
+ cacheWrite,
208
+ },
209
+ contextWindow: web.max.max_input_tokens ?? 128_000,
210
+ maxTokens: web.max.max_output_tokens ?? web.max.max_tokens ?? 16_384,
211
+ api,
212
+ } as const
213
+
214
+ const model = {
215
+ ...base,
216
+ ...(reasoning ? { thinkingLevelMap: thinkingLevelMapForApi(api) } : {}),
217
+ } as unknown as OpenModelProviderModel
218
+
219
+ models.push(model)
220
+ }
221
+
222
+ return models
223
+ }
package/src/stability.ts CHANGED
@@ -9,6 +9,8 @@
9
9
  * GET https://api.openmodel.ai/web/v1/model-stability/:modelKey
10
10
  */
11
11
 
12
+ import { parseWebError, friendlyMessage } from "./errors.ts"
13
+
12
14
  export const STABILITY_SUMMARY_URL =
13
15
  "https://api.openmodel.ai/web/v1/model-stability/summary";
14
16
 
@@ -82,6 +84,13 @@ export async function fetchModelStabilitySummary(options?: {
82
84
  headers: { accept: "application/json" },
83
85
  });
84
86
 
87
+ if (!response.ok) {
88
+ let errBody: any
89
+ try { errBody = await response.json() } catch {}
90
+ const err = parseWebError(errBody)
91
+ throw new Error(`stability — ${friendlyMessage(err.code, err.message)}`)
92
+ }
93
+
85
94
  const body = (await response.json()) as {
86
95
  success: boolean;
87
96
  data: Array<{
@@ -93,7 +102,9 @@ export async function fetchModelStabilitySummary(options?: {
93
102
  }>;
94
103
  };
95
104
 
96
- if (!body.success) throw new Error("Model stability summary request failed");
105
+ if (!body.success) {
106
+ throw new Error(`stability — ${friendlyMessage("INTERNAL_ERROR", "Summary request failed")}`)
107
+ }
97
108
 
98
109
  return body.data.map((item) => ({
99
110
  ...item,
@@ -118,6 +129,13 @@ export async function fetchModelStabilityDetail(
118
129
  { headers: { accept: "application/json" } },
119
130
  );
120
131
 
132
+ if (!response.ok) {
133
+ let errBody: any
134
+ try { errBody = await response.json() } catch {}
135
+ const err = parseWebError(errBody)
136
+ throw new Error(`stability — ${friendlyMessage(err.code, err.message)}`)
137
+ }
138
+
121
139
  const body = (await response.json()) as {
122
140
  success: boolean;
123
141
  data: {
@@ -141,8 +159,9 @@ export async function fetchModelStabilityDetail(
141
159
  };
142
160
  };
143
161
 
144
- if (!body.success)
145
- throw new Error(`Model stability detail request failed for ${modelKey}`);
162
+ if (!body.success) {
163
+ throw new Error(`stability ${friendlyMessage("NOT_FOUND", `Model "${modelKey}" not found`)}`)
164
+ }
146
165
 
147
166
  return {
148
167
  ...body.data,