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.
- package/AGENTS.md +10 -9
- package/CHANGELOG.md +32 -0
- package/LICENSE +1 -1
- package/README.md +34 -0
- package/index.ts +6 -10
- package/package.json +3 -2
- package/src/{models.ts → api/models.ts} +72 -100
- package/src/api/stability.ts +163 -0
- package/src/auth/login.ts +132 -0
- package/src/auth/validate.ts +33 -0
- package/src/cache.ts +21 -6
- package/src/formatters/stability.ts +36 -0
- package/src/health.ts +30 -0
- package/src/providers/compat.ts +47 -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
|
@@ -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
|
|
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
|
|
70
|
+
await mkd(CACHE_DIR, { recursive: true })
|
|
56
71
|
const cache: ModelCache = { timestamp: Date.now(), models }
|
|
57
|
-
await
|
|
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
|
-
}
|