@pwguler/pi-pengepul-provider 0.2.4 → 0.4.0

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,269 @@
1
+ /**
2
+ * The pengepul provider, as pi-ai sees it.
3
+ *
4
+ * Registered as a native provider, so pi resolves auth through this module and
5
+ * hands the credential back to `refreshModels`. That closes two gaps the
6
+ * config-value form had: model discovery now uses the same resolved key pi uses
7
+ * for requests (it used to read only `PENGEPUL_API_KEY` and pengepul's own
8
+ * config, so a key stored in `auth.json` produced a 401), and the relay base
9
+ * comes from the credential instead of the environment.
10
+ *
11
+ * The relay base lives on the credential as `baseUrl`, a field pi core does not
12
+ * read. Per-model base URLs are derived from it, because pi applies a base URL
13
+ * returned from `resolve()` to every model at once and pengepul's two wires need
14
+ * different paths (`/` for Messages, `/v1` for Chat Completions).
15
+ */
16
+
17
+ import type {
18
+ Api,
19
+ ApiKeyCredential,
20
+ AuthCheck,
21
+ AuthResult,
22
+ Model,
23
+ Provider,
24
+ ProviderAuthInteraction,
25
+ ProviderStreams,
26
+ RefreshModelsContext,
27
+ } from "@earendil-works/pi-ai"
28
+ import { anthropicMessagesApi, lazyStream, openAICompletionsApi } from "@earendil-works/pi-ai/compat"
29
+
30
+ import { API_KEY_ENV, extractApiKeys } from "./api-key.ts"
31
+ import { RELAY_BASE_ENV } from "./config.ts"
32
+ import {
33
+ credentialApiKey,
34
+ credentialRelayBase,
35
+ relayBaseFromConfigText,
36
+ resolveApiKey,
37
+ resolveRelayBase,
38
+ type PengepulCredential,
39
+ } from "./credential.ts"
40
+ import { modelsUrl, type PengepulDialect } from "./dialect.ts"
41
+ import {
42
+ DEFAULT_RELAY_BASE,
43
+ fetchPengepulModels,
44
+ getModelsTimeoutMs,
45
+ isPengepulModelEntry,
46
+ loadCachedPengepulModels,
47
+ PENGEPUL_PROVIDER_ID,
48
+ restampRelayBase,
49
+ toPengepulModels,
50
+ type BuiltinModelLookup,
51
+ type PengepulModelEntry,
52
+ } from "./models.ts"
53
+
54
+ /** The credential this provider writes: the relay key plus the base it applies to. */
55
+ export interface PengepulApiKeyCredential extends ApiKeyCredential {
56
+ type: "api_key"
57
+ key?: string
58
+ baseUrl?: string
59
+ }
60
+
61
+ /** Where the config-file fallback key comes from, for the status label. */
62
+ const CONFIG_FILE_LABEL = "~/.pengepul/config.yaml"
63
+
64
+ export interface PengepulProviderOptions {
65
+ /** Environment map holding the optional `PENGEPUL_*` overrides. */
66
+ env?: Record<string, string | undefined>
67
+ /** Text of pengepul's own config, when this machine happens to run the relay. */
68
+ configText?: string
69
+ /** Where the pre-0.3 catalog cache lives; read once to seed pi's store. */
70
+ legacyCachePath: string
71
+ /** Catalog fetch transport, for tests. */
72
+ fetchImpl?: typeof fetch
73
+ /** Builtin metadata lookup, injected so the catalog logic stays free of pi imports. */
74
+ lookupBuiltin?: BuiltinModelLookup
75
+ /** Warning sink; defaults to `console.warn`. */
76
+ logWarning?: (message: string) => void
77
+ /** Clock, for tests. */
78
+ now?: () => number
79
+ }
80
+
81
+ function errorMessage(error: unknown): string {
82
+ return error instanceof Error ? error.message : String(error)
83
+ }
84
+
85
+ /** The `Api` union is open-ended, so narrow it before indexing by wire. */
86
+ function isPengepulDialect(api: Api): api is PengepulDialect {
87
+ return api === "anthropic-messages" || api === "openai-completions"
88
+ }
89
+
90
+ /**
91
+ * A provider whose catalog comes from pengepul and whose auth is the relay key.
92
+ *
93
+ * Ambient sources are read from the captured environment and config text, not
94
+ * from `AuthContext.env`, because `refreshModels` receives no auth context and
95
+ * both paths must agree on what the key and base are.
96
+ */
97
+ export function createPengepulProvider(options: PengepulProviderOptions): Provider {
98
+ const env = options.env ?? process.env
99
+ const logWarning =
100
+ options.logWarning ?? ((message: string) => console.warn(`[pengepul] ${message}`))
101
+ const now = options.now ?? Date.now
102
+ const fetchImpl = options.fetchImpl ?? fetch
103
+ const timeoutMs = getModelsTimeoutMs(env as NodeJS.ProcessEnv)
104
+
105
+ const environmentRelayBase = env[RELAY_BASE_ENV]
106
+ const environmentApiKey = env[API_KEY_ENV]
107
+ const configText = options.configText
108
+ const configRelayBase = relayBaseFromConfigText(configText)
109
+ const configApiKey = configText ? extractApiKeys(configText)[0] : undefined
110
+
111
+ let models: PengepulModelEntry[] = []
112
+
113
+ /** The key from outside auth.json, with the label the status UI shows. */
114
+ function ambientKey(): { key?: string; source?: string } {
115
+ if (environmentApiKey?.trim()) return { key: environmentApiKey.trim(), source: API_KEY_ENV }
116
+ if (configApiKey) return { key: configApiKey, source: CONFIG_FILE_LABEL }
117
+ return {}
118
+ }
119
+
120
+ function resolveKey(credential: unknown): { key?: string; source?: string } {
121
+ const stored = credentialApiKey(credential as PengepulCredential | undefined)
122
+ if (stored) return { key: stored, source: "stored credential" }
123
+ return ambientKey()
124
+ }
125
+
126
+ const streams: Record<PengepulDialect, ProviderStreams> = {
127
+ "anthropic-messages": anthropicMessagesApi(),
128
+ "openai-completions": openAICompletionsApi(),
129
+ }
130
+
131
+ function streamsFor(model: Model<Api>): ProviderStreams | undefined {
132
+ return isPengepulDialect(model.api) ? streams[model.api] : undefined
133
+ }
134
+
135
+ function unsupported(model: Model<Api>) {
136
+ return lazyStream(model, async () => {
137
+ throw new Error(`pengepul serves no "${model.api}" wire, so ${model.id} cannot be streamed`)
138
+ })
139
+ }
140
+
141
+ async function restoreOrSeed(context: RefreshModelsContext): Promise<boolean> {
142
+ const relayBase = resolveRelayBase({
143
+ credential: credentialRelayBase(context.credential as PengepulCredential | undefined),
144
+ environment: environmentRelayBase,
145
+ config: configRelayBase,
146
+ })
147
+
148
+ const stored = (context.stored?.models ?? []).filter(isPengepulModelEntry)
149
+ if (stored.length > 0) {
150
+ const restored = restampRelayBase(stored, relayBase)
151
+ return context.publish({ update: () => { models = restored } })
152
+ }
153
+
154
+ // Pre-0.3 installs kept their own cache file. Read it once so an upgrade
155
+ // does not start blind; pi's store owns the catalog from here on.
156
+ const legacy = await loadCachedPengepulModels(options.legacyCachePath)
157
+ if (legacy.length === 0) return true
158
+ const seeded = toPengepulModels(legacy, relayBase)
159
+ return context.publish({ update: () => { models = seeded } })
160
+ }
161
+
162
+ async function refreshFromRelay(context: RefreshModelsContext): Promise<void> {
163
+ const relayBase = resolveRelayBase({
164
+ credential: credentialRelayBase(context.credential as PengepulCredential | undefined),
165
+ environment: environmentRelayBase,
166
+ config: configRelayBase,
167
+ })
168
+
169
+ const resolved = resolveKey(context.credential)
170
+ if (!resolved.key) {
171
+ logWarning(
172
+ `No pengepul API key is configured (${API_KEY_ENV}, ${CONFIG_FILE_LABEL}, or auth.json), so the model catalog cannot be refreshed. Run /login pengepul.`,
173
+ )
174
+ return
175
+ }
176
+
177
+ try {
178
+ const catalog = await fetchPengepulModels({
179
+ url: modelsUrl(relayBase),
180
+ apiKey: resolved.key,
181
+ fetchImpl,
182
+ timeoutMs,
183
+ signal: context.signal,
184
+ ...(options.lookupBuiltin ? { lookupBuiltin: options.lookupBuiltin } : {}),
185
+ })
186
+ if (context.signal.aborted) return
187
+
188
+ const entries = toPengepulModels(catalog, relayBase)
189
+ await context.publish({
190
+ persist: { models: entries, checkedAt: now() },
191
+ update: () => { models = entries },
192
+ })
193
+ } catch (error) {
194
+ if (context.signal.aborted) return
195
+ logWarning(
196
+ `Could not refresh the pengepul model catalog (${errorMessage(error)}). Keeping the last known catalog.`,
197
+ )
198
+ }
199
+ }
200
+
201
+ return {
202
+ id: PENGEPUL_PROVIDER_ID,
203
+ name: "Pengepul",
204
+ baseUrl: resolveRelayBase({
205
+ environment: environmentRelayBase,
206
+ config: configRelayBase,
207
+ }),
208
+ auth: {
209
+ apiKey: {
210
+ name: "Pengepul relay",
211
+ login: async (
212
+ interaction: ProviderAuthInteraction,
213
+ ): Promise<PengepulApiKeyCredential> => {
214
+ const key = (await interaction.prompt({
215
+ type: "secret",
216
+ message: "Pengepul API key",
217
+ })).trim()
218
+ const answer = (await interaction.prompt({
219
+ type: "text",
220
+ message: "Pengepul relay URL",
221
+ placeholder: DEFAULT_RELAY_BASE,
222
+ })).trim()
223
+ // Written, not defaulted at read time: pi replaces the whole
224
+ // credential on login, so the URL has to be in the stored entry or a
225
+ // key rotation silently points the relay back at loopback.
226
+ return {
227
+ type: "api_key",
228
+ ...(key === "" ? {} : { key }),
229
+ baseUrl: resolveRelayBase({ credential: answer }),
230
+ }
231
+ },
232
+ check: async (input): Promise<AuthCheck | undefined> => {
233
+ const resolved = resolveKey(input.credential)
234
+ return resolved.key
235
+ ? { type: "api_key", ...(resolved.source ? { source: resolved.source } : {}) }
236
+ : undefined
237
+ },
238
+ resolve: async (input): Promise<AuthResult | undefined> => {
239
+ const resolved = resolveKey(input.credential)
240
+ if (!resolved.key) return undefined
241
+ // No `baseUrl` here on purpose: pi would apply it to every model and
242
+ // collapse the two dialect base URLs into one.
243
+ return {
244
+ auth: { apiKey: resolved.key },
245
+ ...(resolved.source ? { source: resolved.source } : {}),
246
+ }
247
+ },
248
+ },
249
+ },
250
+ getModels: () => models,
251
+ refreshModels: async (context) => {
252
+ if (!(await restoreOrSeed(context))) return
253
+ if (!context.allowNetwork || context.signal.aborted) return
254
+ await refreshFromRelay(context)
255
+ },
256
+ stream: (model, context, streamOptions) => {
257
+ const implementation = streamsFor(model)
258
+ return implementation
259
+ ? implementation.stream(model, context, streamOptions)
260
+ : unsupported(model)
261
+ },
262
+ streamSimple: (model, context, streamOptions) => {
263
+ const implementation = streamsFor(model)
264
+ return implementation
265
+ ? implementation.streamSimple(model, context, streamOptions)
266
+ : unsupported(model)
267
+ },
268
+ }
269
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@pwguler/pi-pengepul-provider",
3
- "version": "0.2.4",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
- "description": "pi custom provider for pengepul, a local relay that pools your Claude/Codex subscriptions. Connects pi to http://127.0.0.1:8317 over the native Anthropic Messages and OpenAI Chat Completions wires.",
5
+ "description": "pi custom provider for pengepul, a relay that pools your Claude/Codex subscriptions. Key and relay base live in auth.json; both native wires (Anthropic Messages, OpenAI Chat Completions) come from one catalog.",
6
6
  "license": "MIT",
7
7
  "author": "pwguler",
8
8
  "repository": {
@@ -1,211 +0,0 @@
1
- /**
2
- * Cached-model runtime for the pengepul provider.
3
- *
4
- * Registers the provider immediately from the cache (so startup never waits on
5
- * the network), refreshes it in the background, and disposes cleanly. There
6
- * are no user-facing commands: the startup refresh is the only refresh.
7
- *
8
- * The pi seam is an interface, so the whole thing is testable with a fake host.
9
- */
10
-
11
- import type { PengepulModel, PengepulModelSource } from "./models.ts"
12
-
13
- export interface PengepulRuntimeApi {
14
- registerProvider(name: string, config: unknown): void
15
- }
16
-
17
- export interface PengepulRuntimeOptions {
18
- /** A provider config built from a model list. */
19
- createProviderConfig: (models: readonly PengepulModel[]) => unknown
20
- /** Live fetch; resolves to the catalog plus a source marker. */
21
- loadModels: (signal: AbortSignal) => Promise<PengepulModelSource>
22
- /** Cached catalog only; empty when no valid cache exists. */
23
- loadCachedModels: () => Promise<readonly PengepulModel[]>
24
- now?: () => number
25
- logWarning?: (message: string) => void
26
- }
27
-
28
- export interface PengepulRefreshResult {
29
- refreshed: boolean
30
- source: PengepulModelSource["source"]
31
- modelCount: number
32
- warning?: string
33
- }
34
-
35
- interface RuntimeStatus {
36
- source: PengepulModelSource["source"]
37
- modelCount: number
38
- providerRegistered: boolean
39
- lastSuccess?: number
40
- lastAttempt?: number
41
- warning?: string
42
- refreshing: boolean
43
- }
44
-
45
- const REDACTED = "[redacted]"
46
-
47
- function errorMessage(error: unknown): string {
48
- return error instanceof Error ? error.message : String(error)
49
- }
50
-
51
- function redactDiagnosticText(value: string): string {
52
- return value
53
- .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, `Bearer ${REDACTED}`)
54
- .replace(/\b(?:sk-local|sk-|api[-_ ]?key|token|secret|password)[_-]?[\w.~+/=-]{8,}\b/gi, REDACTED)
55
- .replace(/\b(?:api[-_ ]?key|token|secret|password)\s*[=:]\s*[^\s,;)]+/gi, (match) => {
56
- const separator = match.match(/\s*[=:]\s*/)?.[0] ?? "="
57
- return `${match.slice(0, match.indexOf(separator))}${separator}${REDACTED}`
58
- })
59
- }
60
-
61
- export class PengepulRuntime {
62
- private readonly now: () => number
63
- private readonly logWarning: (message: string) => void
64
- private status: RuntimeStatus
65
- private providerRegistered = false
66
- private refreshPromise: Promise<PengepulRefreshResult> | undefined
67
- private readonly shutdown = new AbortController()
68
-
69
- constructor(
70
- private readonly pi: PengepulRuntimeApi,
71
- private readonly options: PengepulRuntimeOptions,
72
- ) {
73
- this.now = options.now ?? Date.now
74
- this.logWarning = options.logWarning ?? ((message) => console.warn(`[pengepul] ${message}`))
75
- this.status = {
76
- source: "empty",
77
- modelCount: 0,
78
- providerRegistered: false,
79
- refreshing: false,
80
- }
81
- }
82
-
83
- /**
84
- * Registers the cached catalog immediately (so host startup does not wait on
85
- * the network) and refreshes it in the background. Without a valid cache the
86
- * live refresh is awaited so models are available right away.
87
- */
88
- async initialize(): Promise<void> {
89
- const cached = await this.options.loadCachedModels()
90
- if (cached.length === 0) {
91
- await this.refresh()
92
- return
93
- }
94
-
95
- this.pi.registerProvider("pengepul", this.options.createProviderConfig(cached))
96
- this.providerRegistered = true
97
- this.status = {
98
- ...this.status,
99
- source: "cache",
100
- modelCount: cached.length,
101
- providerRegistered: true,
102
- lastSuccess: this.now(),
103
- }
104
- void this.refresh()
105
- }
106
-
107
- /** Aborts any background refresh so a stopping host does not wait on the network. */
108
- dispose(): void {
109
- this.shutdown.abort(new Error("pengepul provider shut down"))
110
- }
111
-
112
- refresh(): Promise<PengepulRefreshResult> {
113
- if (this.refreshPromise) return this.refreshPromise
114
-
115
- const refreshPromise = this.refreshCatalog().finally(() => {
116
- if (this.refreshPromise === refreshPromise) this.refreshPromise = undefined
117
- })
118
- this.refreshPromise = refreshPromise
119
- return refreshPromise
120
- }
121
-
122
- private async refreshCatalog(): Promise<PengepulRefreshResult> {
123
- this.status = { ...this.status, lastAttempt: this.now(), refreshing: true }
124
-
125
- try {
126
- const loaded = await this.options.loadModels(this.shutdown.signal)
127
- const warning = loaded.warning ? redactDiagnosticText(loaded.warning) : undefined
128
-
129
- const shouldRegister =
130
- !this.providerRegistered ||
131
- loaded.source === "live" ||
132
- (this.status.modelCount === 0 && loaded.models.length > 0)
133
-
134
- if (shouldRegister) {
135
- this.pi.registerProvider("pengepul", this.options.createProviderConfig(loaded.models))
136
- this.providerRegistered = true
137
-
138
- if (loaded.models.length === 0) {
139
- const preservedWarning = warning ?? "pengepul model discovery returned no models"
140
- this.status = {
141
- ...this.status,
142
- source: loaded.source,
143
- modelCount: 0,
144
- providerRegistered: true,
145
- warning: preservedWarning,
146
- refreshing: false,
147
- }
148
- this.warn(preservedWarning)
149
- return { refreshed: false, source: loaded.source, modelCount: 0, warning: preservedWarning }
150
- }
151
-
152
- this.status = {
153
- ...this.status,
154
- source: loaded.source,
155
- modelCount: loaded.models.length,
156
- providerRegistered: true,
157
- lastSuccess: this.now(),
158
- warning,
159
- refreshing: false,
160
- }
161
- if (warning) this.warn(warning)
162
- return { refreshed: true, source: loaded.source, modelCount: loaded.models.length, warning }
163
- }
164
-
165
- const preservedWarning = warning ?? "pengepul model discovery returned no models"
166
- this.status = { ...this.status, warning: preservedWarning, refreshing: false }
167
- this.warn(preservedWarning)
168
- return {
169
- refreshed: false,
170
- source: this.status.source,
171
- modelCount: this.status.modelCount,
172
- warning: preservedWarning,
173
- }
174
- } catch (error) {
175
- if (this.shutdown.signal.aborted) {
176
- this.status = { ...this.status, refreshing: false }
177
- return {
178
- refreshed: false,
179
- source: this.status.source,
180
- modelCount: this.status.modelCount,
181
- }
182
- }
183
- const warning = redactDiagnosticText(
184
- `Could not refresh the pengepul model catalog: ${errorMessage(error)}`,
185
- )
186
- this.status = { ...this.status, warning, refreshing: false }
187
- this.warn(warning)
188
- return {
189
- refreshed: false,
190
- source: this.status.source,
191
- modelCount: this.status.modelCount,
192
- warning,
193
- }
194
- }
195
- }
196
-
197
- private warn(message: string): void {
198
- try {
199
- this.logWarning(redactDiagnosticText(message))
200
- } catch {
201
- // Diagnostics must never make a catalog refresh fail.
202
- }
203
- }
204
- }
205
-
206
- export function createPengepulRuntime(
207
- pi: PengepulRuntimeApi,
208
- options: PengepulRuntimeOptions,
209
- ): PengepulRuntime {
210
- return new PengepulRuntime(pi, options)
211
- }