pi-openmodel-provider 0.2.16 → 0.2.18

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.
@@ -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
 
@@ -22,13 +22,27 @@ interface ModelCache {
22
22
  models: readonly OpenModelProviderModel[]
23
23
  }
24
24
 
25
+ /** Minimal fs interface matching what cache.ts actually uses */
26
+ export interface CacheFs {
27
+ readFile(path: string, encoding: string): Promise<string>
28
+ writeFile(path: string, data: string, encoding?: string): Promise<void>
29
+ mkdir(path: string, options?: { recursive?: boolean }): Promise<void>
30
+ }
31
+
32
+ const DEFAULT_FS: CacheFs = {
33
+ readFile: readFile as CacheFs["readFile"],
34
+ writeFile: writeFile as CacheFs["writeFile"],
35
+ mkdir: mkdir as CacheFs["mkdir"],
36
+ }
37
+
25
38
  /**
26
39
  * Read models from cache.
27
40
  * Returns null if cache is missing, expired, or corrupted.
28
41
  */
29
- export async function readModelCache(): Promise<readonly OpenModelProviderModel[] | null> {
42
+ export async function readModelCache(fsImpl?: CacheFs): Promise<readonly OpenModelProviderModel[] | null> {
43
+ const { readFile: rf } = fsImpl ?? DEFAULT_FS
30
44
  try {
31
- const raw = await readFile(CACHE_FILE, "utf-8")
45
+ const raw = await rf(CACHE_FILE, "utf-8")
32
46
  const cache: ModelCache = JSON.parse(raw)
33
47
 
34
48
  if (typeof cache.timestamp !== "number" || !Array.isArray(cache.models)) {
@@ -50,11 +64,12 @@ export async function readModelCache(): Promise<readonly OpenModelProviderModel[
50
64
  * Write models to the local cache.
51
65
  * Failures are silently ignored — cache is optional.
52
66
  */
53
- export async function writeModelCache(models: readonly OpenModelProviderModel[]): Promise<void> {
67
+ export async function writeModelCache(models: readonly OpenModelProviderModel[], fsImpl?: CacheFs): Promise<void> {
68
+ const { mkdir: mkd, writeFile: wf } = fsImpl ?? DEFAULT_FS
54
69
  try {
55
- await mkdir(CACHE_DIR, { recursive: true })
70
+ await mkd(CACHE_DIR, { recursive: true })
56
71
  const cache: ModelCache = { timestamp: Date.now(), models }
57
- await writeFile(CACHE_FILE, JSON.stringify(cache, null, 2), "utf-8")
72
+ await wf(CACHE_FILE, JSON.stringify(cache, null, 2), "utf-8")
58
73
  } catch {
59
74
  // Cache writes are best-effort
60
75
  }
@@ -0,0 +1,36 @@
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 } from "../health.ts"
9
+ import type { ConfidenceLevel } from "../api/stability.ts"
10
+ /** Format health status with emoji */
11
+ export function formatHealthStatus(status: HealthStatus): string {
12
+ switch (status) {
13
+ case "operational":
14
+ return "✅ Operational"
15
+ case "healthy":
16
+ return "🟢 Healthy"
17
+ case "degraded":
18
+ return "🟡 Degraded"
19
+ case "unstable":
20
+ return "🔴 Unstable"
21
+ case "no_data":
22
+ return "⚪ No Data"
23
+ }
24
+ }
25
+
26
+ /** Format confidence level */
27
+ export function formatConfidence(level: ConfidenceLevel): string {
28
+ switch (level) {
29
+ case "high":
30
+ return "🟢 High"
31
+ case "medium":
32
+ return "🟡 Medium"
33
+ case "low":
34
+ return "⚪ Low"
35
+ }
36
+ }
package/src/health.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Shared health status determination for model stability.
3
+ *
4
+ * Extracted from stability.ts and formatters/stability.ts to avoid
5
+ * code duplication — both modules need the same logic.
6
+ */
7
+
8
+ import type { ConfidenceLevel } from "./api/stability.ts"
9
+
10
+ export type HealthStatus =
11
+ | "operational"
12
+ | "healthy"
13
+ | "degraded"
14
+ | "unstable"
15
+ | "no_data"
16
+
17
+ /**
18
+ * Determine health status from success rate and confidence.
19
+ * Low confidence → no_data regardless of success rate.
20
+ */
21
+ export function determineHealth(
22
+ successRate: number,
23
+ confidence: ConfidenceLevel,
24
+ ): HealthStatus {
25
+ if (confidence === "low") return "no_data"
26
+ if (successRate >= 99.9) return "operational"
27
+ if (successRate >= 99) return "healthy"
28
+ if (successRate >= 95) return "degraded"
29
+ return "unstable"
30
+ }
@@ -0,0 +1,47 @@
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
+ /**
10
+ * Determine compat flags based on provider.
11
+ * Returns undefined when no special flags are needed.
12
+ */
13
+ export function compatForProvider(
14
+ providerKey: string,
15
+ reasoning: boolean,
16
+ ): Record<string, unknown> | undefined {
17
+ switch (providerKey) {
18
+ case "openai":
19
+ return { supportsReasoningEffort: true }
20
+
21
+ case "deepseek":
22
+ if (reasoning) return { thinkingFormat: "deepseek" }
23
+ return undefined
24
+
25
+ case "anthropic":
26
+ return {
27
+ sendSessionAffinityHeaders: true,
28
+ supportsCacheControlOnTools: true,
29
+ supportsEagerToolInputStreaming: true,
30
+ }
31
+
32
+ case "google":
33
+ case "gemini":
34
+ return undefined
35
+
36
+ case "qwen":
37
+ if (reasoning) return { thinkingFormat: "qwen-chat-template" }
38
+ return undefined
39
+
40
+ case "zai":
41
+ if (reasoning) return { thinkingFormat: "zai" }
42
+ return undefined
43
+
44
+ default:
45
+ return undefined
46
+ }
47
+ }
@@ -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
- }