@pwguler/pi-pengepul-provider 0.1.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.
- package/LICENSE +21 -0
- package/README.md +96 -0
- package/package.json +49 -0
- package/src/api-key.ts +101 -0
- package/src/config.ts +40 -0
- package/src/dialect.ts +76 -0
- package/src/index.ts +146 -0
- package/src/models.ts +596 -0
- package/src/runtime.ts +211 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pwguler
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# @pwguler/pi-pengepul-provider
|
|
2
|
+
|
|
3
|
+
A custom provider for [pi](https://github.com/earendil-works/pi) that connects to
|
|
4
|
+
[pengepul](https://github.com/pwguler/pengepul), a local relay that pools your
|
|
5
|
+
Claude / Codex subscription accounts and serves them over native wire protocols.
|
|
6
|
+
|
|
7
|
+
pengepul pools several subscription accounts per provider and spreads requests
|
|
8
|
+
across them, so pi runs on your subscription instead of a per-token API key.
|
|
9
|
+
This extension registers pengepul as a provider so `/model` shows the models
|
|
10
|
+
your relay serves.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
From npm:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
pi install npm:@pwguler/pi-pengepul-provider
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or straight from GitHub (no npm account needed):
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
pi install git:github.com/pwguler/pi-pengepul-provider
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Pin a release so updates don't move under you:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
pi install git:github.com/pwguler/pi-pengepul-provider@v0.1.0
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
To update a git-installed package later:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
pi install git:github.com/pwguler/pi-pengepul-provider@v0.2.0
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Start or reload pi, then select a model with `/model`. Pengepul models are
|
|
39
|
+
prefixed `pengepul/<id>`. To try it without installing, use
|
|
40
|
+
`pi -e git:github.com/pwguler/pi-pengepul-provider`.
|
|
41
|
+
|
|
42
|
+
## What it does
|
|
43
|
+
|
|
44
|
+
- Registers the `pengepul` provider against your relay's base URL
|
|
45
|
+
(`http://127.0.0.1:8317` by default).
|
|
46
|
+
- Discovers models from `GET /v1/models`, maps each to the right wire:
|
|
47
|
+
- `claude-*` / `anthropic/*` and `owned_by: anthropic` → Anthropic Messages
|
|
48
|
+
(`POST /v1/messages`),
|
|
49
|
+
- `gpt-*` / `o<N>` / `codex-*` and `<provider>/<model>` → OpenAI Chat
|
|
50
|
+
Completions (`POST /v1/chat/completions`).
|
|
51
|
+
- Takes context window, max output, pricing, and image input from what the
|
|
52
|
+
relay advertises (pengepul >= 0.6.0 sends `context_window`,
|
|
53
|
+
`max_output_tokens`, `input_modalities`, `pricing`). Fields the relay omits
|
|
54
|
+
fall back to pi's builtin catalog for the same id, then to family
|
|
55
|
+
heuristics.
|
|
56
|
+
- Caches the last successful catalog at `<agent-dir>/pengepul-models.json`, so
|
|
57
|
+
startup does not wait on the network and a briefly absent relay is covered.
|
|
58
|
+
- Reuses pi's built-in stream functions for both wires — no custom transport.
|
|
59
|
+
- Registers no commands: the catalog refreshes on every startup.
|
|
60
|
+
|
|
61
|
+
## Configuration
|
|
62
|
+
|
|
63
|
+
| Setting | Env var | Default |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| Relay base URL | `PENGEPUL_BASE_URL` | `http://127.0.0.1:8317` |
|
|
66
|
+
| API key | `PENGEPUL_API_KEY` | read from `~/.pengepul/config.yaml` |
|
|
67
|
+
| Config path | `PENGEPUL_CONFIG` | `~/.pengepul/config.yaml` |
|
|
68
|
+
| Model cache path | `PENGEPUL_MODELS_CACHE` | `<agent-dir>/pengepul-models.json` |
|
|
69
|
+
| Discovery timeout | `PENGEPUL_MODELS_TIMEOUT_MS` | `10000` |
|
|
70
|
+
|
|
71
|
+
The API key is read from `~/.pengepul/config.yaml` (`api-keys[0]`, the
|
|
72
|
+
`sk-local-...` key pengepul generates on first run) unless `PENGEPUL_API_KEY`
|
|
73
|
+
is set.
|
|
74
|
+
|
|
75
|
+
## Notes
|
|
76
|
+
|
|
77
|
+
- pengepul >= 0.6.0 advertises per-model context windows, output caps,
|
|
78
|
+
modalities, and pricing on `/v1/models`, and that is what the provider
|
|
79
|
+
registers; older relays (or ids the metadata has not reached) fall back to
|
|
80
|
+
pi's builtin catalog. Your subscription, not a per-token meter, is what
|
|
81
|
+
pengepul bills against — displayed costs are upstream list prices.
|
|
82
|
+
- The relay must be running and reachable for discovery to succeed. Without a
|
|
83
|
+
cached catalog on a first start, pengepul models stay unavailable until a
|
|
84
|
+
start with the relay up.
|
|
85
|
+
|
|
86
|
+
## Development
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
bun test
|
|
90
|
+
npx tsc --noEmit
|
|
91
|
+
bun scripts/e2e-live.ts # live e2e against a running relay; sends one tiny completion
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pwguler/pi-pengepul-provider",
|
|
3
|
+
"version": "0.1.0",
|
|
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.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "pwguler",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/pwguler/pi-pengepul-provider.git"
|
|
11
|
+
},
|
|
12
|
+
"main": "./src/index.ts",
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"keywords": [
|
|
19
|
+
"pi",
|
|
20
|
+
"pi-extension",
|
|
21
|
+
"pi-package",
|
|
22
|
+
"pi-pengepul-provider",
|
|
23
|
+
"pengepul",
|
|
24
|
+
"provider"
|
|
25
|
+
],
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@earendil-works/pi-ai": "*",
|
|
28
|
+
"@earendil-works/pi-agent-core": "*",
|
|
29
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
30
|
+
"@earendil-works/pi-tui": "*",
|
|
31
|
+
"typebox": "*"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"typecheck": "tsc --noEmit",
|
|
35
|
+
"test": "bun test"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"pi": {
|
|
41
|
+
"extensions": [
|
|
42
|
+
"./src/index.ts"
|
|
43
|
+
]
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/bun": "^1",
|
|
47
|
+
"typescript": "^5.9.3"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/api-key.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve pengepul's local API key.
|
|
3
|
+
*
|
|
4
|
+
* pengepul authenticates every request with a static key from its config:
|
|
5
|
+
* `~/.pengepul/config.yaml`, under `api-keys:` (the first is generated on first
|
|
6
|
+
* run and is `sk-local-...`). Clients send it as `Authorization: Bearer <key>`
|
|
7
|
+
* or `x-api-key: <key>`.
|
|
8
|
+
*
|
|
9
|
+
* Precedence: an explicit env override wins, then the config file. The config
|
|
10
|
+
* read is injected so this module stays io-free and testable.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const DEFAULT_CONFIG_PATH = "~/.pengepul/config.yaml"
|
|
14
|
+
|
|
15
|
+
export type ApiKeySource = "env" | "config" | "none"
|
|
16
|
+
|
|
17
|
+
export interface ApiKeyResolution {
|
|
18
|
+
/** The resolved key, or undefined when none could be found. */
|
|
19
|
+
key?: string
|
|
20
|
+
source: ApiKeySource
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const API_KEY_ENV = "PENGEPUL_API_KEY"
|
|
24
|
+
export const CONFIG_PATH_ENV = "PENGEPUL_CONFIG"
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the key from an env map and a config-file reader.
|
|
28
|
+
*
|
|
29
|
+
* @param env the environment (or a test substitution for it).
|
|
30
|
+
* @param readConfig reads a config file's text by path, or undefined when the
|
|
31
|
+
* path is unwritable/absent. Injected to keep this pure.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveApiKey(
|
|
34
|
+
env: Record<string, string | undefined>,
|
|
35
|
+
readConfig: (path: string) => string | undefined,
|
|
36
|
+
): ApiKeyResolution {
|
|
37
|
+
const envKey = env[API_KEY_ENV]
|
|
38
|
+
if (envKey && envKey.trim() !== "") return { key: envKey, source: "env" }
|
|
39
|
+
|
|
40
|
+
const configPath = env[CONFIG_PATH_ENV] ?? DEFAULT_CONFIG_PATH
|
|
41
|
+
const configText = readConfig(configPath)
|
|
42
|
+
if (configText === undefined) return { source: "none" }
|
|
43
|
+
|
|
44
|
+
const keys = extractApiKeys(configText)
|
|
45
|
+
const first = keys[0]
|
|
46
|
+
if (first) return { key: first, source: "config" }
|
|
47
|
+
|
|
48
|
+
return { source: "none" }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Extract `api-keys:` entries from pengepul's YAML config, without a YAML
|
|
53
|
+
* dependency. Handles both the inline-flow form and the block-sequence form:
|
|
54
|
+
*
|
|
55
|
+
* api-keys: [sk-local-a, sk-local-b]
|
|
56
|
+
* api-keys:
|
|
57
|
+
* - sk-local-a
|
|
58
|
+
* - sk-local-b
|
|
59
|
+
*/
|
|
60
|
+
export function extractApiKeys(configText: string): string[] {
|
|
61
|
+
const lines = configText.split(/\r?\n/)
|
|
62
|
+
const keys: string[] = []
|
|
63
|
+
|
|
64
|
+
for (let i = 0; i < lines.length; i++) {
|
|
65
|
+
const line = lines[i]
|
|
66
|
+
if (line === undefined) continue
|
|
67
|
+
|
|
68
|
+
const match = /^\s*api-keys:\s*(.*)$/.exec(line)
|
|
69
|
+
if (!match) continue
|
|
70
|
+
|
|
71
|
+
const rest = (match[1] ?? "").trim()
|
|
72
|
+
if (rest.startsWith("[")) {
|
|
73
|
+
// Inline flow sequence: [a, b, c]
|
|
74
|
+
for (const token of rest.slice(1, -1).split(",")) {
|
|
75
|
+
const key = token.trim().replace(/^["']|["']$/g, "")
|
|
76
|
+
if (key) keys.push(key)
|
|
77
|
+
}
|
|
78
|
+
} else if (rest === "" || rest === "|" || rest === ">") {
|
|
79
|
+
// Block sequence follows. Sequence entries may sit at any indent —
|
|
80
|
+
// pengepul itself writes them flush with the key (`- sk-local-…`) —
|
|
81
|
+
// so scan forward through blanks, comments, and `- ` entries and
|
|
82
|
+
// stop at the first line that starts another key.
|
|
83
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
84
|
+
const item = lines[j]
|
|
85
|
+
if (item === undefined) continue
|
|
86
|
+
const token = item.trim()
|
|
87
|
+
if (token === "" || token.startsWith("#")) continue
|
|
88
|
+
if (!token.startsWith("-")) break
|
|
89
|
+
const value = token.slice(1).trim().replace(/^["']|["']$/g, "")
|
|
90
|
+
if (value) keys.push(value)
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
// Single inline scalar: api-keys: sk-local-a
|
|
94
|
+
const value = rest.replace(/^["']|["']$/g, "")
|
|
95
|
+
if (value) keys.push(value)
|
|
96
|
+
}
|
|
97
|
+
break
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return keys
|
|
101
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve pengepul connection and cache settings from the environment.
|
|
3
|
+
*
|
|
4
|
+
* Kept small and pure: takes an env map and the host's agent dir, returns the
|
|
5
|
+
* connection defaults a test can pin down.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { join } from "node:path"
|
|
9
|
+
|
|
10
|
+
import { DEFAULT_RELAY_BASE } from "./models.ts"
|
|
11
|
+
|
|
12
|
+
export const RELAY_BASE_ENV = "PENGEPUL_BASE_URL"
|
|
13
|
+
export const MODELS_CACHE_ENV = "PENGEPUL_MODELS_CACHE"
|
|
14
|
+
export const MODELS_TIMEOUT_MS_ENV = "PENGEPUL_MODELS_TIMEOUT_MS"
|
|
15
|
+
|
|
16
|
+
export interface PengepulSettings {
|
|
17
|
+
/** The relay base URL (may or may not end in /v1). */
|
|
18
|
+
relayBase: string
|
|
19
|
+
/** Where the model catalog is cached; defaults to `<agent-dir>/pengepul-models.json`. */
|
|
20
|
+
modelsCachePath: string
|
|
21
|
+
/** Discovery timeout in milliseconds. */
|
|
22
|
+
modelsTimeoutMs: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function resolveSettings(
|
|
26
|
+
env: Record<string, string | undefined>,
|
|
27
|
+
agentDir: string,
|
|
28
|
+
): PengepulSettings {
|
|
29
|
+
const relayBase = env[RELAY_BASE_ENV] ?? DEFAULT_RELAY_BASE
|
|
30
|
+
const modelsCachePath =
|
|
31
|
+
env[MODELS_CACHE_ENV] ?? join(agentDir, "pengepul-models.json")
|
|
32
|
+
const rawTimeout = env[MODELS_TIMEOUT_MS_ENV]
|
|
33
|
+
const parsedTimeout = rawTimeout ? Number(rawTimeout) : NaN
|
|
34
|
+
const modelsTimeoutMs =
|
|
35
|
+
Number.isFinite(parsedTimeout) && parsedTimeout > 0
|
|
36
|
+
? parsedTimeout
|
|
37
|
+
: 10_000
|
|
38
|
+
|
|
39
|
+
return { relayBase, modelsCachePath, modelsTimeoutMs }
|
|
40
|
+
}
|
package/src/dialect.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure pengepul dialect mapping.
|
|
3
|
+
*
|
|
4
|
+
* pengepul is a local relay that speaks *both* native wires and routes each
|
|
5
|
+
* request by model id. This module maps a model id (and the owned_by value
|
|
6
|
+
* pengepul advertises on `/v1/models`) to the pi-ai API dialect pi should use,
|
|
7
|
+
* and to the base URL pi's built-in stream functions should point at.
|
|
8
|
+
*
|
|
9
|
+
* No pi imports, no io: this is unit-testable with a table of inputs.
|
|
10
|
+
*
|
|
11
|
+
* pengepul routing (from its `resolve_id` / heuristic):
|
|
12
|
+
* - `anthropic/...` and bare `claude-*` / `anthropic` -> Anthropic Messages
|
|
13
|
+
* (`POST /v1/messages`). pi's Anthropic SDK appends `/v1/messages`, so the
|
|
14
|
+
* base URL is the relay root (no trailing `/v1`).
|
|
15
|
+
* - `codex/...`, bare `gpt-*` / `o<N>` / `codex-*`, and any other
|
|
16
|
+
* `<provider>/<model>` prefix -> OpenAI Chat Completions
|
|
17
|
+
* (`POST /v1/chat/completions`). pi's OpenAI SDK appends `/chat/completions`,
|
|
18
|
+
* so the base URL must end in `/v1`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type PengepulDialect = "anthropic-messages" | "openai-completions"
|
|
22
|
+
|
|
23
|
+
/** Which wire pengepul serves a given model id on. */
|
|
24
|
+
export function dialectForModelId(id: string): PengepulDialect {
|
|
25
|
+
const slash = id.indexOf("/")
|
|
26
|
+
if (slash !== -1) {
|
|
27
|
+
const prefix = id.slice(0, slash).toLowerCase()
|
|
28
|
+
return prefix === "anthropic" ? "anthropic-messages" : "openai-completions"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const lower = id.toLowerCase()
|
|
32
|
+
if (lower.startsWith("claude-") || lower.startsWith("anthropic")) {
|
|
33
|
+
return "anthropic-messages"
|
|
34
|
+
}
|
|
35
|
+
if (lower.startsWith("gpt-") || lower.startsWith("codex-") || isOpenAIModelPattern(lower)) {
|
|
36
|
+
return "openai-completions"
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Unknown bare id: prefer Messages only when the upstream explicitly says so;
|
|
40
|
+
// otherwise assume Chat Completions (the relay's generic-namespace default).
|
|
41
|
+
return "openai-completions"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isOpenAIModelPattern(lower: string): boolean {
|
|
45
|
+
// o1, o3-mini, o4, ... — the OpenAI reasoning-family names pengepul routes to codex.
|
|
46
|
+
const first = lower[0]
|
|
47
|
+
const second = lower[1]
|
|
48
|
+
if (first === undefined || second === undefined) return false
|
|
49
|
+
if (lower.length >= 2 && first === "o" && /^[1-9]/.test(second)) return true
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The base URL pi's built-in stream should use for a dialect.
|
|
55
|
+
*
|
|
56
|
+
* Accepts a relay base that may or may not end in `/v1` (pengepul's own config
|
|
57
|
+
* notes "the base url may end in /v1"); we normalize so the Anthropic Messages
|
|
58
|
+
* SDK gets the root and the OpenAI Chat Completions SDK gets `/v1`, and neither
|
|
59
|
+
* produces a doubled `/v1/v1`.
|
|
60
|
+
*/
|
|
61
|
+
export function baseUrlForDialect(baseUrl: string, dialect: PengepulDialect): string {
|
|
62
|
+
const root = normalizeRootBaseUrl(baseUrl)
|
|
63
|
+
return dialect === "anthropic-messages" ? root : `${root}/v1`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Strip a trailing `/v1` (and any trailing slashes) so we can re-add it per dialect. */
|
|
67
|
+
export function normalizeRootBaseUrl(baseUrl: string): string {
|
|
68
|
+
let out = baseUrl.replace(/\/+$/g, "")
|
|
69
|
+
if (out.endsWith("/v1")) out = out.slice(0, -3)
|
|
70
|
+
return out.replace(/\/+$/g, "")
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The discovery endpoint for a relay base. */
|
|
74
|
+
export function modelsUrl(baseUrl: string): string {
|
|
75
|
+
return `${normalizeRootBaseUrl(baseUrl)}/v1/models`
|
|
76
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pwguler/pi-pengepul-provider entry point - the real edge adapter.
|
|
3
|
+
*
|
|
4
|
+
* Registers pengepul as a pi custom provider. pengepul is a local relay
|
|
5
|
+
* (`http://127.0.0.1:8317`) that pools your Claude/Codex subscriptions and
|
|
6
|
+
* speaks both native wires. The pure core lives in `./dialect.ts`, `./models.ts`
|
|
7
|
+
* and `./runtime.ts`; this file adapts them to the pi ExtensionAPI seam.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
getAgentDir,
|
|
12
|
+
type ExtensionAPI,
|
|
13
|
+
type ProviderConfig,
|
|
14
|
+
} from "@earendil-works/pi-coding-agent"
|
|
15
|
+
import { getBuiltinModel, getBuiltinModels, getBuiltinProviders } from "@earendil-works/pi-ai/providers/all"
|
|
16
|
+
import { readFileSync } from "node:fs"
|
|
17
|
+
|
|
18
|
+
import { resolveApiKey } from "./api-key.ts"
|
|
19
|
+
import { resolveSettings } from "./config.ts"
|
|
20
|
+
import { modelsUrl } from "./dialect.ts"
|
|
21
|
+
import {
|
|
22
|
+
loadCachedPengepulModels,
|
|
23
|
+
loadPengepulModels,
|
|
24
|
+
toProviderModelConfigs,
|
|
25
|
+
type PengepulModel,
|
|
26
|
+
} from "./models.ts"
|
|
27
|
+
import { createPengepulRuntime } from "./runtime.ts"
|
|
28
|
+
|
|
29
|
+
function expandHome(path: string): string {
|
|
30
|
+
if (path === "~") return process.env.HOME ?? path
|
|
31
|
+
if (path.startsWith("~/")) return `${process.env.HOME ?? ""}${path.slice(1)}`
|
|
32
|
+
return path
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readConfigText(path: string): string | undefined {
|
|
36
|
+
try {
|
|
37
|
+
return readFileSync(expandHome(path), "utf-8")
|
|
38
|
+
} catch {
|
|
39
|
+
return undefined
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The metadata fields the lookup extracts from a pi catalog entry. */
|
|
44
|
+
function metaFromModel(model: NonNullable<ReturnType<typeof getBuiltinModel>>) {
|
|
45
|
+
return {
|
|
46
|
+
reasoning: model.reasoning,
|
|
47
|
+
contextWindow: model.contextWindow,
|
|
48
|
+
maxTokens: model.maxTokens,
|
|
49
|
+
input: model.input,
|
|
50
|
+
cost: model.cost,
|
|
51
|
+
...(model.thinkingLevelMap ? { thinkingLevelMap: model.thinkingLevelMap } : {}),
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Multi-catalog lookup over pi's builtin models. A commandcode id can live
|
|
57
|
+
* in several catalogs: verbatim under an aggregator (`openrouter`, `baseten`,
|
|
58
|
+
* `together`, ...), bare under a vendor catalog (`deepseek`, `google`,
|
|
59
|
+
* `xai`, ...), or bare lowercased. Try those shapes in that order and take
|
|
60
|
+
* the first hit; reasoning metadata and the thinkingLevelMap flow from it.
|
|
61
|
+
*/
|
|
62
|
+
function createBuiltinLookup(): (id: string, dialect: string) => ReturnType<typeof metaFromModel> | undefined {
|
|
63
|
+
type Entry = { provider: string; id: string };
|
|
64
|
+
const exact = new Map<string, Entry>()
|
|
65
|
+
const lower = new Map<string, Entry>()
|
|
66
|
+
for (const provider of getBuiltinProviders()) {
|
|
67
|
+
for (const model of getBuiltinModels(provider) ?? []) {
|
|
68
|
+
if (!exact.has(model.id)) exact.set(model.id, { provider, id: model.id })
|
|
69
|
+
if (!lower.has(model.id.toLowerCase())) lower.set(model.id.toLowerCase(), { provider, id: model.id })
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const segments = new Map<string, Entry>()
|
|
73
|
+
for (const [key, entry] of exact) {
|
|
74
|
+
const slash = key.lastIndexOf("/")
|
|
75
|
+
const segment = slash === -1 ? key : key.slice(slash + 1)
|
|
76
|
+
if (!segments.has(segment)) segments.set(segment, entry)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (id, dialect) => {
|
|
80
|
+
const candidates: Array<Entry | undefined> = [
|
|
81
|
+
exact.get(id),
|
|
82
|
+
exact.get(bareOf(id)),
|
|
83
|
+
segments.get(bareOf(id)),
|
|
84
|
+
lower.get(id.toLowerCase()),
|
|
85
|
+
lower.get(bareOf(id).toLowerCase()),
|
|
86
|
+
]
|
|
87
|
+
for (const candidate of candidates) {
|
|
88
|
+
if (candidate === undefined) continue
|
|
89
|
+
const model = getBuiltinModel(candidate.provider as never, candidate.id as never)
|
|
90
|
+
if (model) return metaFromModel(model)
|
|
91
|
+
}
|
|
92
|
+
return undefined
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function bareOf(id: string): string {
|
|
97
|
+
const slash = id.lastIndexOf("/")
|
|
98
|
+
return slash === -1 ? id : id.slice(slash + 1)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function createProviderConfigFactory(relayBase: string, apiKey: string | undefined) {
|
|
102
|
+
return (models: readonly PengepulModel[]): ProviderConfig => ({
|
|
103
|
+
name: "Pengepul",
|
|
104
|
+
baseUrl: relayBase,
|
|
105
|
+
apiKey: apiKey ?? "$PENGEPUL_API_KEY",
|
|
106
|
+
api: "anthropic-messages",
|
|
107
|
+
models: toProviderModelConfigs(models, relayBase),
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Model discovery and provider registration are async: the relay's catalog is
|
|
113
|
+
* fetched live (and cached), so the runtime handles the cache-first, then
|
|
114
|
+
* live-refresh dance. The config factory pins the base URL and key once.
|
|
115
|
+
*/
|
|
116
|
+
export default async function (pi: ExtensionAPI) {
|
|
117
|
+
const settings = resolveSettings(process.env, getAgentDir())
|
|
118
|
+
const apiKey = resolveApiKey(process.env, readConfigText).key
|
|
119
|
+
|
|
120
|
+
// The relay advertises only ids; context/pricing/modality numbers come from
|
|
121
|
+
// pi's builtin catalogs, searched across providers until one knows the id
|
|
122
|
+
// (aggregator, vendor, and last-segment shapes). The lookup is injected so
|
|
123
|
+
// the catalog logic stays free of pi-ai imports.
|
|
124
|
+
const lookupBuiltin = createBuiltinLookup()
|
|
125
|
+
|
|
126
|
+
const runtime = createPengepulRuntime(pi, {
|
|
127
|
+
loadModels: (signal) =>
|
|
128
|
+
loadPengepulModels({
|
|
129
|
+
url: modelsUrl(settings.relayBase),
|
|
130
|
+
apiKey,
|
|
131
|
+
cachePath: settings.modelsCachePath,
|
|
132
|
+
relayBase: settings.relayBase,
|
|
133
|
+
timeoutMs: settings.modelsTimeoutMs,
|
|
134
|
+
lookupBuiltin,
|
|
135
|
+
signal,
|
|
136
|
+
}),
|
|
137
|
+
loadCachedModels: () => loadCachedPengepulModels(settings.modelsCachePath),
|
|
138
|
+
createProviderConfig: createProviderConfigFactory(settings.relayBase, apiKey),
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
pi.on("session_shutdown", () => {
|
|
142
|
+
runtime.dispose()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
await runtime.initialize()
|
|
146
|
+
}
|
package/src/models.ts
ADDED
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pengepul model discovery.
|
|
3
|
+
*
|
|
4
|
+
* Fetches the relay's model catalog (`GET /v1/models`) and maps it into the
|
|
5
|
+
* pi-ai `ProviderModelConfig` shape, mirroring the commandcode provider's
|
|
6
|
+
* cached-catalog design: a fresh fetch wins, a valid cache covers a briefly
|
|
7
|
+
* absent relay, and an empty result leaves pengepul models unavailable until
|
|
8
|
+
* the next successful startup refresh.
|
|
9
|
+
*
|
|
10
|
+
* The relay advertises id/owned_by and, since pengepul 0.6.0, optional
|
|
11
|
+
* per-model metadata: `context_window`, `max_output_tokens`,
|
|
12
|
+
* `input_modalities`, and `pricing`. That is the first-party truth for what
|
|
13
|
+
* this relay actually serves, so it wins. The rollout is partial (some ids
|
|
14
|
+
* still come back with ids only), so two fallbacks remain: pi's builtin
|
|
15
|
+
* catalog - pengepul forwards the same ids upstream, so pi's numbers are the
|
|
16
|
+
* next best source - and then family heuristics. The catalog lookup is
|
|
17
|
+
* injected, so this module imports nothing from pi-ai and tests pin it.
|
|
18
|
+
*
|
|
19
|
+
* The network/cache are injected so the catalog logic stays testable.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
|
|
23
|
+
import { randomUUID } from "node:crypto"
|
|
24
|
+
import { dirname } from "node:path"
|
|
25
|
+
|
|
26
|
+
import { baseUrlForDialect, dialectForModelId } from "./dialect.ts"
|
|
27
|
+
import type { PengepulDialect } from "./dialect.ts"
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_RELAY_BASE = "http://127.0.0.1:8317"
|
|
30
|
+
export const DEFAULT_MODELS_TIMEOUT_MS = 10_000
|
|
31
|
+
|
|
32
|
+
const DEFAULT_CONTEXT_WINDOW = 200_000
|
|
33
|
+
const DEFAULT_MAX_TOKENS = 64_000
|
|
34
|
+
const ZERO_COST: ModelCostRates = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
|
|
35
|
+
/** v2 cached pre-multi-catalog lookups (commandcode ids missed reasoning); reject it. */
|
|
36
|
+
const MODEL_CACHE_VERSION = 3
|
|
37
|
+
|
|
38
|
+
export type ModelInput = ("text" | "image")[]
|
|
39
|
+
|
|
40
|
+
export interface ModelCostRates {
|
|
41
|
+
input: number
|
|
42
|
+
output: number
|
|
43
|
+
cacheRead: number
|
|
44
|
+
cacheWrite: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The metadata pi's builtin catalog carries for a model the relay advertises. */
|
|
48
|
+
export interface BuiltinModelMeta {
|
|
49
|
+
reasoning: boolean
|
|
50
|
+
contextWindow: number
|
|
51
|
+
maxTokens: number
|
|
52
|
+
input: ModelInput
|
|
53
|
+
cost: ModelCostRates
|
|
54
|
+
/** Level-to-wire mapping for reasoning params; undefined = pi's default. */
|
|
55
|
+
thinkingLevelMap?: Record<string, string | null>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolves a model's metadata from pi's builtin catalogs. The core passes the
|
|
60
|
+
* relay id and its dialect; the edge decides which catalogs to search
|
|
61
|
+
* (commandcode ids carry a routing prefix, so one id may exist verbatim under
|
|
62
|
+
* `openrouter`/`baseten`/... and bare under a vendor catalog like `deepseek`).
|
|
63
|
+
* Undefined when no pi catalog knows the id.
|
|
64
|
+
*/
|
|
65
|
+
export type BuiltinModelLookup = (id: string, dialect: PengepulDialect) => BuiltinModelMeta | undefined
|
|
66
|
+
|
|
67
|
+
/** A pengepul model ready to become a pi `ProviderModelConfig`. */
|
|
68
|
+
export interface PengepulModel {
|
|
69
|
+
id: string
|
|
70
|
+
name: string
|
|
71
|
+
dialect: PengepulDialect
|
|
72
|
+
reasoning: boolean
|
|
73
|
+
input: ModelInput
|
|
74
|
+
cost: ModelCostRates
|
|
75
|
+
contextWindow: number
|
|
76
|
+
maxTokens: number
|
|
77
|
+
/** Level-to-wire mapping inherited from the catalog; undefined = pi's default. */
|
|
78
|
+
thinkingLevelMap?: Record<string, string | null>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface PengepulModelSource {
|
|
82
|
+
models: readonly PengepulModel[]
|
|
83
|
+
/** "live" = fetched from the relay; "cache" = read from disk; "empty" = none. */
|
|
84
|
+
source: "live" | "cache" | "empty"
|
|
85
|
+
warning?: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** `anthropic/claude-opus-5` -> `claude-opus-5` (the id upstream actually serves). */
|
|
89
|
+
export function bareId(id: string): string {
|
|
90
|
+
const slash = id.indexOf("/")
|
|
91
|
+
return slash === -1 ? id : id.slice(slash + 1)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Fallback for models pi's catalog does not know. Family-shaped but
|
|
96
|
+
* conservative; the builtin lookup wins whenever it has the id. The final
|
|
97
|
+
* branch treats unknown ids as non-reasoning, so an unrecognized id never
|
|
98
|
+
* gets reasoning params the upstream may reject.
|
|
99
|
+
*/
|
|
100
|
+
function heuristicMeta(id: string): BuiltinModelMeta {
|
|
101
|
+
const lower = bareId(id).toLowerCase()
|
|
102
|
+
if (lower.startsWith("claude-")) {
|
|
103
|
+
return { reasoning: true, contextWindow: 200_000, maxTokens: 64_000, input: ["text"], cost: ZERO_COST }
|
|
104
|
+
}
|
|
105
|
+
if (lower.startsWith("gpt-") || lower.startsWith("codex-") || /^o[1-9]/.test(lower)) {
|
|
106
|
+
return { reasoning: true, contextWindow: 272_000, maxTokens: 64_000, input: ["text"], cost: ZERO_COST }
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
reasoning: false,
|
|
110
|
+
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
|
111
|
+
maxTokens: DEFAULT_MAX_TOKENS,
|
|
112
|
+
input: ["text"],
|
|
113
|
+
cost: ZERO_COST,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Metadata pengepul itself advertises for a model (pengepul >= 0.6.0).
|
|
119
|
+
* Every field is optional: the rollout is partial and older relays send none.
|
|
120
|
+
* Returns undefined when the entry carries no usable metadata at all.
|
|
121
|
+
*/
|
|
122
|
+
export function metaFromRelayEntry(
|
|
123
|
+
entry: Record<string, unknown>,
|
|
124
|
+
): Partial<BuiltinModelMeta> | undefined {
|
|
125
|
+
const contextWindow = optionalPositiveNumber(entry["context_window"])
|
|
126
|
+
const maxTokens = optionalPositiveNumber(entry["max_output_tokens"])
|
|
127
|
+
const input = optionalInputModalities(entry["input_modalities"])
|
|
128
|
+
const cost = optionalPricing(entry["pricing"])
|
|
129
|
+
|
|
130
|
+
if (
|
|
131
|
+
contextWindow === undefined &&
|
|
132
|
+
maxTokens === undefined &&
|
|
133
|
+
input === undefined &&
|
|
134
|
+
cost === undefined
|
|
135
|
+
) {
|
|
136
|
+
return undefined
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
...(contextWindow !== undefined ? { contextWindow } : {}),
|
|
140
|
+
...(maxTokens !== undefined ? { maxTokens } : {}),
|
|
141
|
+
...(input !== undefined ? { input } : {}),
|
|
142
|
+
...(cost !== undefined ? { cost } : {}),
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function optionalPositiveNumber(value: unknown): number | undefined {
|
|
147
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function optionalInputModalities(value: unknown): ModelInput | undefined {
|
|
151
|
+
if (!Array.isArray(value)) return undefined
|
|
152
|
+
const input = value.filter(
|
|
153
|
+
(entry): entry is "text" | "image" => entry === "text" || entry === "image",
|
|
154
|
+
)
|
|
155
|
+
return input.length > 0 ? input : undefined
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function optionalPricing(value: unknown): ModelCostRates | undefined {
|
|
159
|
+
if (!isRecord(value)) return undefined
|
|
160
|
+
const input = optionalRate(value["input_per_million"])
|
|
161
|
+
const output = optionalRate(value["output_per_million"])
|
|
162
|
+
const cacheRead = optionalRate(value["cache_read_per_million"])
|
|
163
|
+
const cacheWrite = optionalRate(value["cache_write_per_million"])
|
|
164
|
+
if (input === undefined && output === undefined) return undefined
|
|
165
|
+
return {
|
|
166
|
+
input: input ?? 0,
|
|
167
|
+
output: output ?? 0,
|
|
168
|
+
cacheRead: cacheRead ?? 0,
|
|
169
|
+
cacheWrite: cacheWrite ?? 0,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function optionalRate(value: unknown): number | undefined {
|
|
174
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Resolve a model's metadata, most trustworthy source first:
|
|
179
|
+
* 1. what pengepul advertises (first-party for this relay),
|
|
180
|
+
* 2. pi's builtin catalogs for the id (the same id upstream),
|
|
181
|
+
* 3. family heuristics.
|
|
182
|
+
* Sources merge per field, so a relay that sends only `context_window` still
|
|
183
|
+
* picks up pricing and modalities from the catalog.
|
|
184
|
+
*/
|
|
185
|
+
function metaFor(
|
|
186
|
+
entry: Record<string, unknown>,
|
|
187
|
+
id: string,
|
|
188
|
+
dialect: PengepulDialect,
|
|
189
|
+
lookup: BuiltinModelLookup | undefined,
|
|
190
|
+
): BuiltinModelMeta {
|
|
191
|
+
const base = lookup?.(id, dialect) ?? heuristicMeta(id)
|
|
192
|
+
const relay = metaFromRelayEntry(entry)
|
|
193
|
+
return relay ? { ...base, ...relay } : base
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function toPengepulModel(
|
|
197
|
+
entry: Record<string, unknown>,
|
|
198
|
+
lookup: BuiltinModelLookup | undefined,
|
|
199
|
+
): PengepulModel {
|
|
200
|
+
const id = stringField(entry, "id")
|
|
201
|
+
const dialect = dialectForModelId(id)
|
|
202
|
+
const meta = metaFor(entry, id, dialect, lookup)
|
|
203
|
+
const ownedBy = entry["owned_by"]
|
|
204
|
+
|
|
205
|
+
// A model the relay tags `anthropic` is Claude-family, hence reasoning-
|
|
206
|
+
// capable, even when pi's catalog does not know its exact id yet.
|
|
207
|
+
const reasoning = meta.reasoning || ownedBy === "anthropic"
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
id,
|
|
211
|
+
name: displayName(id),
|
|
212
|
+
dialect,
|
|
213
|
+
reasoning,
|
|
214
|
+
input: [...meta.input],
|
|
215
|
+
cost: { ...meta.cost },
|
|
216
|
+
contextWindow: meta.contextWindow,
|
|
217
|
+
maxTokens: meta.maxTokens,
|
|
218
|
+
...(meta.thinkingLevelMap ? { thinkingLevelMap: { ...meta.thinkingLevelMap } } : {}),
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
223
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function stringField(record: Record<string, unknown>, key: string): string {
|
|
227
|
+
const value = record[key]
|
|
228
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
229
|
+
throw new Error(`Expected ${key} to be a non-empty string`)
|
|
230
|
+
}
|
|
231
|
+
return value
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Parse the raw `/v1/models` body into models. Throws on a malformed body. */
|
|
235
|
+
export function modelsFromApiResponse(
|
|
236
|
+
value: unknown,
|
|
237
|
+
lookupBuiltin?: BuiltinModelLookup,
|
|
238
|
+
): readonly PengepulModel[] {
|
|
239
|
+
if (!isRecord(value)) throw new Error("Expected models response to be an object")
|
|
240
|
+
if (value["object"] !== "list") throw new Error("Expected models response object to be 'list'")
|
|
241
|
+
|
|
242
|
+
const data = value["data"]
|
|
243
|
+
if (!Array.isArray(data)) throw new Error("Expected models response data to be an array")
|
|
244
|
+
if (data.length === 0) throw new Error("pengepul returned an empty model catalog")
|
|
245
|
+
|
|
246
|
+
return data.map((entry) => {
|
|
247
|
+
if (!isRecord(entry)) throw new Error("Expected model entry to be an object")
|
|
248
|
+
return toPengepulModel(entry, lookupBuiltin)
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Map models to pi `ProviderModelConfig` entries. Pure. */
|
|
253
|
+
export function toProviderModelConfigs(
|
|
254
|
+
models: readonly PengepulModel[],
|
|
255
|
+
relayBase: string,
|
|
256
|
+
): Array<{
|
|
257
|
+
id: string
|
|
258
|
+
name: string
|
|
259
|
+
api: PengepulDialect
|
|
260
|
+
baseUrl: string
|
|
261
|
+
reasoning: boolean
|
|
262
|
+
input: ("text" | "image")[]
|
|
263
|
+
cost: {
|
|
264
|
+
input: number
|
|
265
|
+
output: number
|
|
266
|
+
cacheRead: number
|
|
267
|
+
cacheWrite: number
|
|
268
|
+
}
|
|
269
|
+
contextWindow: number
|
|
270
|
+
maxTokens: number
|
|
271
|
+
thinkingLevelMap?: Record<string, string | null>
|
|
272
|
+
compat?: { forceAdaptiveThinking?: boolean }
|
|
273
|
+
}> {
|
|
274
|
+
return models.map((model) => {
|
|
275
|
+
const adaptive = model.dialect === "anthropic-messages" && model.reasoning
|
|
276
|
+
return {
|
|
277
|
+
id: model.id,
|
|
278
|
+
name: model.name,
|
|
279
|
+
api: model.dialect,
|
|
280
|
+
baseUrl: baseUrlForDialect(relayBase, model.dialect),
|
|
281
|
+
reasoning: model.reasoning,
|
|
282
|
+
input: model.input,
|
|
283
|
+
cost: model.cost,
|
|
284
|
+
contextWindow: model.contextWindow,
|
|
285
|
+
maxTokens: model.maxTokens,
|
|
286
|
+
// Inherited level mapping (e.g. deepseek {high:"high"}) flows through;
|
|
287
|
+
// adaptive Claude models additionally mark "off" unsupported so the
|
|
288
|
+
// stream omits thinking:{type:"disabled"} (upstream rejects it).
|
|
289
|
+
...(model.thinkingLevelMap || adaptive
|
|
290
|
+
? { thinkingLevelMap: { ...(model.thinkingLevelMap ?? {}), ...(adaptive ? { off: null } : {}) } }
|
|
291
|
+
: {}),
|
|
292
|
+
// Reasoning-capable Claude models run on the adaptive-thinking wire:
|
|
293
|
+
// pi's streamSimple always passes thinkingEnabled:false when no level is
|
|
294
|
+
// selected, and the stream would send thinking:{type:"disabled"}, which
|
|
295
|
+
// the upstream rejects (400: "thinking.type.disabled is not supported
|
|
296
|
+
// for this model"). thinkingLevelMap.off = null marks "off" as
|
|
297
|
+
// unsupported so pi omits the thinking param entirely (server default
|
|
298
|
+
// = adaptive), and forceAdaptiveThinking routes an explicit level to
|
|
299
|
+
// {type:"adaptive"} + effort instead of budget_tokens.
|
|
300
|
+
...(adaptive ? { compat: { forceAdaptiveThinking: true as const } } : {}),
|
|
301
|
+
}
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Picker label: the bare model part of a relay id, suffixed. `anthropic/claude-opus-5` -> `claude-opus-5 (pengepul)`. */
|
|
306
|
+
function displayName(id: string): string {
|
|
307
|
+
const slash = id.indexOf("/")
|
|
308
|
+
const bare = slash === -1 ? id : id.slice(slash + 1)
|
|
309
|
+
return `${bare} (pengepul)`
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
interface FetchModelsOptions {
|
|
313
|
+
url?: string
|
|
314
|
+
apiKey?: string
|
|
315
|
+
fetchImpl?: typeof fetch
|
|
316
|
+
signal?: AbortSignal
|
|
317
|
+
timeoutMs?: number
|
|
318
|
+
lookupBuiltin?: BuiltinModelLookup
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
interface LoadModelsOptions extends FetchModelsOptions {
|
|
322
|
+
cachePath: string
|
|
323
|
+
relayBase: string
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function errorMessage(error: unknown): string {
|
|
327
|
+
return error instanceof Error ? error.message : String(error)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function abortError(reason: unknown): Error {
|
|
331
|
+
if (reason instanceof Error) return reason
|
|
332
|
+
return new DOMException("The operation was aborted", "AbortError")
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function configuredTimeoutMs(timeoutMs: number | undefined): number {
|
|
336
|
+
return timeoutMs !== undefined && Number.isFinite(timeoutMs) && timeoutMs > 0
|
|
337
|
+
? timeoutMs
|
|
338
|
+
: DEFAULT_MODELS_TIMEOUT_MS
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function getModelsTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
342
|
+
const raw = env["PENGEPUL_MODELS_TIMEOUT_MS"]
|
|
343
|
+
if (!raw) return DEFAULT_MODELS_TIMEOUT_MS
|
|
344
|
+
const parsed = Number(raw)
|
|
345
|
+
return configuredTimeoutMs(parsed)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
class ModelDiscoveryTimeoutError extends Error {
|
|
349
|
+
constructor(timeoutMs: number) {
|
|
350
|
+
super(`pengepul model discovery timed out after ${timeoutMs}ms`)
|
|
351
|
+
this.name = "ModelDiscoveryTimeoutError"
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function runWithTimeout<T>(
|
|
356
|
+
operation: (signal: AbortSignal) => Promise<T>,
|
|
357
|
+
timeoutMs: number,
|
|
358
|
+
externalSignal: AbortSignal | undefined,
|
|
359
|
+
): Promise<T> {
|
|
360
|
+
const controller = new AbortController()
|
|
361
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
362
|
+
let settled = false
|
|
363
|
+
let onExternalAbort: (() => void) | undefined
|
|
364
|
+
|
|
365
|
+
return new Promise<T>((resolve, reject) => {
|
|
366
|
+
const cleanup = () => {
|
|
367
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
368
|
+
if (onExternalAbort && externalSignal) {
|
|
369
|
+
externalSignal.removeEventListener("abort", onExternalAbort)
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const resolveOnce = (value: T) => {
|
|
374
|
+
if (settled) return
|
|
375
|
+
settled = true
|
|
376
|
+
cleanup()
|
|
377
|
+
resolve(value)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const rejectOnce = (error: unknown) => {
|
|
381
|
+
if (settled) return
|
|
382
|
+
settled = true
|
|
383
|
+
cleanup()
|
|
384
|
+
reject(error)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const abort = (reason: unknown) => {
|
|
388
|
+
const error = abortError(reason)
|
|
389
|
+
controller.abort(error)
|
|
390
|
+
rejectOnce(error)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (externalSignal?.aborted) {
|
|
394
|
+
abort(externalSignal.reason)
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
onExternalAbort = () => abort(externalSignal?.reason)
|
|
399
|
+
externalSignal?.addEventListener("abort", onExternalAbort, { once: true })
|
|
400
|
+
timer = setTimeout(() => abort(new ModelDiscoveryTimeoutError(timeoutMs)), timeoutMs)
|
|
401
|
+
|
|
402
|
+
Promise.resolve()
|
|
403
|
+
.then(() => operation(controller.signal))
|
|
404
|
+
.then(resolveOnce, rejectOnce)
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export async function fetchPengepulModels(
|
|
409
|
+
options: FetchModelsOptions = {},
|
|
410
|
+
): Promise<readonly PengepulModel[]> {
|
|
411
|
+
const url = options.url ?? `${DEFAULT_RELAY_BASE}/v1/models`
|
|
412
|
+
const fetchImpl = options.fetchImpl ?? fetch
|
|
413
|
+
const apiKey = options.apiKey
|
|
414
|
+
|
|
415
|
+
const headers: Record<string, string> = {
|
|
416
|
+
accept: "application/json",
|
|
417
|
+
}
|
|
418
|
+
if (apiKey) headers["x-api-key"] = apiKey
|
|
419
|
+
|
|
420
|
+
const body: unknown = await runWithTimeout(
|
|
421
|
+
async (signal) => {
|
|
422
|
+
const response = await fetchImpl(url, { headers, signal })
|
|
423
|
+
|
|
424
|
+
if (response.status === 401 || response.status === 403) {
|
|
425
|
+
throw new Error(
|
|
426
|
+
`pengepul rejected the API key (${
|
|
427
|
+
response.status
|
|
428
|
+
}). Set PENGEPUL_API_KEY or check ~/.pengepul/config.yaml.`,
|
|
429
|
+
)
|
|
430
|
+
}
|
|
431
|
+
if (!response.ok) {
|
|
432
|
+
throw new Error(
|
|
433
|
+
`Failed to fetch pengepul models: ${response.status} ${response.statusText}`,
|
|
434
|
+
)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return await response.json()
|
|
438
|
+
},
|
|
439
|
+
configuredTimeoutMs(options.timeoutMs),
|
|
440
|
+
options.signal,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
return modelsFromApiResponse(body, options.lookupBuiltin)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function numberField(record: Record<string, unknown>, key: string): number {
|
|
447
|
+
const value = record[key]
|
|
448
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
449
|
+
throw new Error(`Expected ${key} to be a finite number`)
|
|
450
|
+
}
|
|
451
|
+
return value
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function inputField(record: Record<string, unknown>, key: string): ModelInput {
|
|
455
|
+
const value = record[key]
|
|
456
|
+
if (!Array.isArray(value)) throw new Error(`Expected ${key} to be an array`)
|
|
457
|
+
return value.map((entry) => {
|
|
458
|
+
if (entry !== "text" && entry !== "image") {
|
|
459
|
+
throw new Error(`Expected ${key} entries to be "text" or "image"`)
|
|
460
|
+
}
|
|
461
|
+
return entry
|
|
462
|
+
})
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function costField(record: Record<string, unknown>, key: string): ModelCostRates {
|
|
466
|
+
const value = record[key]
|
|
467
|
+
if (!isRecord(value)) throw new Error(`Expected ${key} to be an object`)
|
|
468
|
+
return {
|
|
469
|
+
input: numberField(value, "input"),
|
|
470
|
+
output: numberField(value, "output"),
|
|
471
|
+
cacheRead: numberField(value, "cacheRead"),
|
|
472
|
+
cacheWrite: numberField(value, "cacheWrite"),
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function levelMapField(
|
|
477
|
+
record: Record<string, unknown>,
|
|
478
|
+
key: string,
|
|
479
|
+
): Record<string, string | null> {
|
|
480
|
+
const value = record[key]
|
|
481
|
+
if (!isRecord(value)) throw new Error(`Expected ${key} to be an object`)
|
|
482
|
+
const map: Record<string, string | null> = {}
|
|
483
|
+
for (const [level, mapped] of Object.entries(value)) {
|
|
484
|
+
if (mapped !== null && typeof mapped !== "string") {
|
|
485
|
+
throw new Error(`Expected ${key} values to be strings or null`)
|
|
486
|
+
}
|
|
487
|
+
map[level] = mapped
|
|
488
|
+
}
|
|
489
|
+
return map
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export function modelsFromCache(value: unknown): readonly PengepulModel[] {
|
|
493
|
+
if (!isRecord(value)) throw new Error("Expected model cache to be an object")
|
|
494
|
+
if (value["version"] !== MODEL_CACHE_VERSION) {
|
|
495
|
+
throw new Error(`Expected model cache version ${MODEL_CACHE_VERSION}`)
|
|
496
|
+
}
|
|
497
|
+
if (!Array.isArray(value["models"])) throw new Error("Expected cached models to be an array")
|
|
498
|
+
|
|
499
|
+
const parsed: PengepulModel[] = value["models"].map((entry) => {
|
|
500
|
+
if (!isRecord(entry)) throw new Error("Expected cached model entry to be an object")
|
|
501
|
+
return {
|
|
502
|
+
id: stringField(entry, "id"),
|
|
503
|
+
name: stringField(entry, "name"),
|
|
504
|
+
dialect: stringField(entry, "dialect") as PengepulDialect,
|
|
505
|
+
reasoning: entry["reasoning"] === true,
|
|
506
|
+
input: inputField(entry, "input"),
|
|
507
|
+
cost: costField(entry, "cost"),
|
|
508
|
+
contextWindow: numberField(entry, "contextWindow"),
|
|
509
|
+
maxTokens: numberField(entry, "maxTokens"),
|
|
510
|
+
...(entry["thinkingLevelMap"] !== undefined
|
|
511
|
+
? { thinkingLevelMap: levelMapField(entry, "thinkingLevelMap") }
|
|
512
|
+
: {}),
|
|
513
|
+
}
|
|
514
|
+
})
|
|
515
|
+
if (parsed.length === 0) throw new Error("pengepul cache holds no valid models")
|
|
516
|
+
return parsed
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function readCache(cachePath: string): Promise<readonly PengepulModel[]> {
|
|
520
|
+
const contents = await readFile(cachePath, "utf-8")
|
|
521
|
+
return modelsFromCache(JSON.parse(contents))
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Reads the cached catalog without touching the network; empty when missing/invalid. */
|
|
525
|
+
export async function loadCachedPengepulModels(
|
|
526
|
+
cachePath: string,
|
|
527
|
+
): Promise<readonly PengepulModel[]> {
|
|
528
|
+
try {
|
|
529
|
+
return await readCache(cachePath)
|
|
530
|
+
} catch {
|
|
531
|
+
return []
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async function writeCache(cachePath: string, models: readonly PengepulModel[]): Promise<void> {
|
|
536
|
+
await mkdir(dirname(cachePath), { recursive: true })
|
|
537
|
+
// Unique per write: the runtime can issue two overlapping writes in one
|
|
538
|
+
// process (cache-first + background refresh), and a shared pid-keyed name
|
|
539
|
+
// would let the first rename remove the second's source mid-flight.
|
|
540
|
+
const temporaryPath = `${cachePath}.${process.pid}.${randomUUID()}.tmp`
|
|
541
|
+
|
|
542
|
+
try {
|
|
543
|
+
await writeFile(
|
|
544
|
+
temporaryPath,
|
|
545
|
+
`${JSON.stringify({ version: MODEL_CACHE_VERSION, models }, null, 2)}\n`,
|
|
546
|
+
{ encoding: "utf-8", mode: 0o600 },
|
|
547
|
+
)
|
|
548
|
+
await rename(temporaryPath, cachePath)
|
|
549
|
+
} finally {
|
|
550
|
+
try {
|
|
551
|
+
await rm(temporaryPath, { force: true })
|
|
552
|
+
} catch {
|
|
553
|
+
// Best-effort cleanup must not hide the original cache write error.
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export async function loadPengepulModels(
|
|
559
|
+
options: LoadModelsOptions,
|
|
560
|
+
): Promise<PengepulModelSource> {
|
|
561
|
+
const cachePath = options.cachePath
|
|
562
|
+
|
|
563
|
+
try {
|
|
564
|
+
const models = await fetchPengepulModels(options)
|
|
565
|
+
|
|
566
|
+
try {
|
|
567
|
+
await writeCache(cachePath, models)
|
|
568
|
+
return { models, source: "live" }
|
|
569
|
+
} catch (error) {
|
|
570
|
+
return {
|
|
571
|
+
models,
|
|
572
|
+
source: "live",
|
|
573
|
+
warning: `Loaded the live pengepul model catalog but could not update ${cachePath}: ${errorMessage(error)}`,
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
} catch (liveError) {
|
|
577
|
+
if (options.signal?.aborted) throw abortError(options.signal.reason ?? liveError)
|
|
578
|
+
|
|
579
|
+
try {
|
|
580
|
+
const models = await readCache(cachePath)
|
|
581
|
+
return {
|
|
582
|
+
models,
|
|
583
|
+
source: "cache",
|
|
584
|
+
warning: `Could not refresh the pengepul model catalog (${errorMessage(liveError)}). Using the cached catalog from ${cachePath}.`,
|
|
585
|
+
}
|
|
586
|
+
} catch (cacheError) {
|
|
587
|
+
return {
|
|
588
|
+
models: [],
|
|
589
|
+
source: "empty",
|
|
590
|
+
warning: `Could not refresh the pengepul model catalog (${errorMessage(liveError)}), and no valid cached catalog is available at ${cachePath} (${errorMessage(cacheError)}). pengepul models will remain unavailable until the next startup refresh succeeds.`,
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
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
|
+
}
|