pi-openmodel-provider 0.2.15 → 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.
@@ -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 ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Local cache for fetched OpenModel models.
3
+ *
4
+ * Avoids hitting the OpenModel API on every startup or /reload.
5
+ * Cache is stored at ~/.pi/agent/cache/openmodel-models.json with a 5-minute TTL.
6
+ */
7
+
8
+ import { readFile, writeFile, mkdir } from "node:fs/promises"
9
+ import { join } from "node:path"
10
+ import { homedir } from "node:os"
11
+ import type { OpenModelProviderModel } from "./api/models.ts"
12
+
13
+ export const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
14
+
15
+ const CACHE_DIR = join(homedir(), ".pi", "agent", "cache")
16
+ const CACHE_FILE = join(CACHE_DIR, "openmodel-models.json")
17
+
18
+ interface ModelCache {
19
+ /** Unix timestamp (ms) when the cache was written */
20
+ timestamp: number
21
+ /** Cached model list */
22
+ models: readonly OpenModelProviderModel[]
23
+ }
24
+
25
+ /**
26
+ * Read models from cache.
27
+ * Returns null if cache is missing, expired, or corrupted.
28
+ */
29
+ export async function readModelCache(): Promise<readonly OpenModelProviderModel[] | null> {
30
+ try {
31
+ const raw = await readFile(CACHE_FILE, "utf-8")
32
+ const cache: ModelCache = JSON.parse(raw)
33
+
34
+ if (typeof cache.timestamp !== "number" || !Array.isArray(cache.models)) {
35
+ return null
36
+ }
37
+
38
+ const age = Date.now() - cache.timestamp
39
+ if (age >= CACHE_TTL_MS) {
40
+ return null // expired
41
+ }
42
+
43
+ return cache.models
44
+ } catch {
45
+ return null // no cache or invalid JSON
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Write models to the local cache.
51
+ * Failures are silently ignored — cache is optional.
52
+ */
53
+ export async function writeModelCache(models: readonly OpenModelProviderModel[]): Promise<void> {
54
+ try {
55
+ await mkdir(CACHE_DIR, { recursive: true })
56
+ const cache: ModelCache = { timestamp: Date.now(), models }
57
+ await writeFile(CACHE_FILE, JSON.stringify(cache, null, 2), "utf-8")
58
+ } catch {
59
+ // Cache writes are best-effort
60
+ }
61
+ }
@@ -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
- }