pi-inferhub 0.1.1 → 0.3.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 +43 -11
- package/package.json +22 -4
- package/src/catalog.ts +122 -23
- package/src/config.ts +75 -0
- package/src/index.ts +12 -5
- package/AGENTS.md +0 -136
- package/docs/superpowers/plans/2026-10-06-inferhub-provider.md +0 -438
- package/docs/superpowers/specs/2026-10-06-inferhub-provider-design.md +0 -207
- package/test/auth.test.ts +0 -43
- package/test/catalog.test.ts +0 -74
- package/test/http.test.ts +0 -43
- package/test/index.test.ts +0 -159
- package/tsconfig.json +0 -14
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Damn Mersault
|
|
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
CHANGED
|
@@ -7,9 +7,10 @@ InferHub **combos** as selectable chat models.
|
|
|
7
7
|
InferHub pools AI subscriptions behind one OpenAI-compatible endpoint. Combos
|
|
8
8
|
are its server-side routed bundles — failover, cache-sticky, billed in USDC —
|
|
9
9
|
and they are the primary consumption surface. This extension exposes **combos
|
|
10
|
-
only**: each combo on your account appears as exactly one model
|
|
11
|
-
`combo/<slug>`. Upstream `prefix/model` entries, bare-name aliases, and
|
|
12
|
-
free-tier models
|
|
10
|
+
only by default**: each combo on your account appears as exactly one model
|
|
11
|
+
named `combo/<slug>`. Upstream `prefix/model` entries, bare-name aliases, and
|
|
12
|
+
free-tier models are not exposed unless you opt in with
|
|
13
|
+
[`publishUpstream`](#full-upstream-catalog-optional).
|
|
13
14
|
|
|
14
15
|
## Install
|
|
15
16
|
|
|
@@ -51,9 +52,30 @@ points you to `/login inferhub` if not.
|
|
|
51
52
|
## Model selection
|
|
52
53
|
|
|
53
54
|
Send `/model` and filter by provider name `inferhub` or by `combo/`. Each
|
|
54
|
-
model id is `combo/<slug>` — the routable inference name on
|
|
55
|
+
combo model id is `combo/<slug>` — the routable inference name on
|
|
55
56
|
`https://api.inferhub.dev/v1`.
|
|
56
57
|
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
Optional `~/.pi/agent/pi-inferhub.json` (or `$PI_AGENT_DIR/pi-inferhub.json`).
|
|
61
|
+
A missing file means defaults; a malformed one logs an issue and keeps the
|
|
62
|
+
defaults — it never breaks startup.
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{ "publishUpstream": true }
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Full upstream catalog (optional)
|
|
69
|
+
|
|
70
|
+
`publishUpstream` (default `false`) additionally publishes every chat model
|
|
71
|
+
from `/v1/models` as a direct-selectable `prefix/model` entry, listed after
|
|
72
|
+
the combos. This bypasses combo routing — you pick one specific serving — and
|
|
73
|
+
is intended for pinning a particular upstream. Alias rows, `free/` tiers, and
|
|
74
|
+
image/video generation models are never published, even with the option on.
|
|
75
|
+
|
|
76
|
+
The `INFERHUB_PUBLISH_UPSTREAM` environment variable overrides the file in
|
|
77
|
+
both directions (`1/true/yes/on`, `0/false/no/off`).
|
|
78
|
+
|
|
57
79
|
## Catalog derivation
|
|
58
80
|
|
|
59
81
|
On refresh the extension makes two authenticated GETs —
|
|
@@ -63,19 +85,29 @@ combo to one model:
|
|
|
63
85
|
|
|
64
86
|
- **Context/output limits** — minimum across resolvable members
|
|
65
87
|
(`input_token_limit` / `max_output_tokens`), so a combo is only as large as
|
|
66
|
-
its smallest member. When no resolvable member supplies limits,
|
|
67
|
-
|
|
88
|
+
its smallest member. When no resolvable member supplies limits, fallbacks
|
|
89
|
+
apply: **256k context / 128k max output**. A limit of `0` is treated as
|
|
90
|
+
missing data rather than a real ceiling.
|
|
91
|
+
- **Output cap enforcement** — `max_tokens` is sent rather than
|
|
92
|
+
`max_completion_tokens`, because InferHub ignores the latter (a request
|
|
93
|
+
capped at 20 tokens still produced 107).
|
|
68
94
|
- **Image input** — offered (`text` + `image`) only when *every* resolvable
|
|
69
95
|
member's modality includes image; otherwise text-only.
|
|
70
|
-
- **Reasoning** —
|
|
71
|
-
|
|
72
|
-
|
|
96
|
+
- **Reasoning** — combos are always marked reasoning-capable: a false
|
|
97
|
+
negative hides Pi's thinking knob (the worse error), while a false positive
|
|
98
|
+
only shows an inert one. Where members advertise `reasoning_levels`, Pi's
|
|
99
|
+
levels map onto that union; when none advertise a ladder the map is omitted
|
|
100
|
+
so every level stays selectable.
|
|
73
101
|
- **Cost** — per-direction minimum of resolvable members' *official* rates
|
|
74
102
|
(see below). `cacheRead`/`cacheWrite` are shown as `0`: the catalog carries
|
|
75
103
|
no per-combo cache pricing, and none is fabricated.
|
|
76
104
|
|
|
77
|
-
|
|
78
|
-
|
|
105
|
+
Member resolution is conservative but forgiving: a member resolves directly
|
|
106
|
+
from `/v1/models` when its prefixed row carries data; otherwise it joins the
|
|
107
|
+
prefixed servings of the same model name (exact suffix first, then a
|
|
108
|
+
normalized last-segment match, which covers multi-segment prefixes such as
|
|
109
|
+
`cp/cline-pass/<model>`). Combos whose members resolve to nothing at all get
|
|
110
|
+
safe defaults: text-only, reasoning-capable, `0` cost, and the fallback limits.
|
|
79
111
|
|
|
80
112
|
## Refresh semantics
|
|
81
113
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-inferhub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Pi extension for the InferHub inference provider",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -23,9 +23,15 @@
|
|
|
23
23
|
"typebox": "*"
|
|
24
24
|
},
|
|
25
25
|
"peerDependenciesMeta": {
|
|
26
|
-
"@earendil-works/pi-ai": {
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
"@earendil-works/pi-ai": {
|
|
27
|
+
"optional": false
|
|
28
|
+
},
|
|
29
|
+
"@earendil-works/pi-coding-agent": {
|
|
30
|
+
"optional": false
|
|
31
|
+
},
|
|
32
|
+
"typebox": {
|
|
33
|
+
"optional": false
|
|
34
|
+
}
|
|
29
35
|
},
|
|
30
36
|
"devDependencies": {
|
|
31
37
|
"@earendil-works/pi-ai": "^1.0.4",
|
|
@@ -34,5 +40,17 @@
|
|
|
34
40
|
"typebox": "^1.3.36",
|
|
35
41
|
"typescript": "^6.0.3",
|
|
36
42
|
"vitest": "^5.0.3"
|
|
43
|
+
},
|
|
44
|
+
"files": [
|
|
45
|
+
"src",
|
|
46
|
+
"README.md",
|
|
47
|
+
"LICENSE"
|
|
48
|
+
],
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=22"
|
|
51
|
+
},
|
|
52
|
+
"repository": {
|
|
53
|
+
"type": "git",
|
|
54
|
+
"url": "git+https://github.com/damnmersault/pi-inferhub.git"
|
|
37
55
|
}
|
|
38
56
|
}
|
package/src/catalog.ts
CHANGED
|
@@ -7,8 +7,8 @@ import { INFERENCE_BASE_URL, MGMT_BASE_URL, PROVIDER_NAME } from "./inferhub.js"
|
|
|
7
7
|
/** pi thinking levels, in ladder order; `off` is handled separately. */
|
|
8
8
|
const PI_THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const satisfies readonly ThinkingLevel[];
|
|
9
9
|
|
|
10
|
-
const CONTEXT_WINDOW_FALLBACK =
|
|
11
|
-
const MAX_TOKENS_FALLBACK =
|
|
10
|
+
const CONTEXT_WINDOW_FALLBACK = 262_144;
|
|
11
|
+
const MAX_TOKENS_FALLBACK = 131_072;
|
|
12
12
|
|
|
13
13
|
export const MemberSchema = Type.Union([
|
|
14
14
|
Type.Object({ kind: Type.Literal("model"), model: Type.String() }),
|
|
@@ -24,9 +24,15 @@ export const ComboSchema = Type.Object({
|
|
|
24
24
|
});
|
|
25
25
|
export type Combo = Static<typeof ComboSchema>;
|
|
26
26
|
|
|
27
|
+
/** Live-pinned 2026-10-15: bare (prefix-less) catalog rows are aliases served by
|
|
28
|
+
* prefixed upstream rows — they carry owned_by "alias" and NO limits/pricing/
|
|
29
|
+
* reasoning fields at all. Unknown extra fields pass validation regardless. */
|
|
27
30
|
export const CatalogModelSchema = Type.Object({
|
|
28
31
|
id: Type.String(),
|
|
32
|
+
owned_by: Type.Optional(Type.String()),
|
|
29
33
|
modality: Type.Optional(Type.String()),
|
|
34
|
+
output_modality: Type.Optional(Type.String()),
|
|
35
|
+
upstream_label: Type.Optional(Type.String()),
|
|
30
36
|
input_token_limit: Type.Optional(Type.Number()),
|
|
31
37
|
max_output_tokens: Type.Optional(Type.Number()),
|
|
32
38
|
reasoning_levels: Type.Optional(Type.Array(Type.String())),
|
|
@@ -34,6 +40,8 @@ export const CatalogModelSchema = Type.Object({
|
|
|
34
40
|
Type.Object({
|
|
35
41
|
official_in: Type.Number(),
|
|
36
42
|
official_out: Type.Number(),
|
|
43
|
+
min_ask_in: Type.Optional(Type.Number()),
|
|
44
|
+
min_ask_out: Type.Optional(Type.Number()),
|
|
37
45
|
}),
|
|
38
46
|
),
|
|
39
47
|
});
|
|
@@ -45,22 +53,34 @@ export type CatalogModel = Static<typeof CatalogModelSchema>;
|
|
|
45
53
|
* validation (no additionalProperties restrictions).
|
|
46
54
|
*/
|
|
47
55
|
const CombosEnvelope = Type.Array(ComboSchema);
|
|
56
|
+
/** Bare (prefix-less) alias row shape observed live 2026-10-15:
|
|
57
|
+
* {id, object:"model", created, owned_by:"alias", supports_cache} — no limits,
|
|
58
|
+
* pricing, modality, or reasoning fields at all. Prefixed servings of the same
|
|
59
|
+
* model carry the data; catalog.ts resolves aliases through them (sibling join). */
|
|
48
60
|
const ModelsEnvelope = Type.Object({ data: Type.Array(CatalogModelSchema) });
|
|
49
61
|
|
|
50
62
|
const CombosValidator = Compile(CombosEnvelope);
|
|
51
63
|
const ModelsValidator = Compile(ModelsEnvelope);
|
|
52
64
|
|
|
65
|
+
export interface FetchCatalogOptions extends FetchJsonOptions {
|
|
66
|
+
/** Also publish direct-selectable upstream models from /v1/models. */
|
|
67
|
+
publishUpstream?: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
53
70
|
/** GET /combos + GET /v1/models (independent), then map combos to pi models. */
|
|
54
71
|
export async function fetchComboCatalog(
|
|
55
72
|
token: string,
|
|
56
73
|
signal?: AbortSignal,
|
|
57
|
-
opts?:
|
|
74
|
+
opts?: FetchCatalogOptions,
|
|
58
75
|
): Promise<Model<"openai-completions">[]> {
|
|
59
76
|
const [combos, models] = await Promise.all([
|
|
60
77
|
fetchJson<Combo[]>(`${MGMT_BASE_URL}/combos`, token, CombosValidator, signal, opts),
|
|
61
78
|
fetchJson<Static<typeof ModelsEnvelope>>(`${INFERENCE_BASE_URL}/models`, token, ModelsValidator, signal, opts),
|
|
62
79
|
]);
|
|
63
|
-
|
|
80
|
+
const comboModels = combosToModels(combos, models.data);
|
|
81
|
+
// Combos are the product: they stay first. The upstream catalog only widens
|
|
82
|
+
// the list when the operator opts in.
|
|
83
|
+
return opts?.publishUpstream ? [...comboModels, ...catalogToModels(models.data)] : comboModels;
|
|
64
84
|
}
|
|
65
85
|
|
|
66
86
|
/** Pure combo → Model mapping; output order follows `combos` order. */
|
|
@@ -69,35 +89,98 @@ export function combosToModels(
|
|
|
69
89
|
catalogModels: CatalogModel[],
|
|
70
90
|
): Model<"openai-completions">[] {
|
|
71
91
|
const catalogById = new Map(catalogModels.map((model) => [model.id, model]));
|
|
72
|
-
return combos.map((combo) => comboToModel(combo, catalogById));
|
|
92
|
+
return combos.map((combo) => comboToModel(combo, catalogById, catalogModels));
|
|
73
93
|
}
|
|
74
94
|
|
|
75
95
|
function comboToModel(
|
|
76
96
|
combo: Combo,
|
|
77
97
|
catalogById: Map<string, CatalogModel>,
|
|
98
|
+
catalogModels: CatalogModel[],
|
|
78
99
|
): Model<"openai-completions"> {
|
|
100
|
+
// Alias rows (`owned_by: "alias"`, no data) and alias-kind members resolve
|
|
101
|
+
// through prefixed sibling servings of the same model name; min-of over the
|
|
102
|
+
// siblings bounds the combo exactly like directly-joined members.
|
|
103
|
+
//
|
|
104
|
+
// Live shape 2026-10-18: combos reference multi-segment prefixes
|
|
105
|
+
// (cp/cline-pass/<model>, cp/zai/<model>) that have no exact serving, while
|
|
106
|
+
// the same model is served under another prefix (zai/<model>,
|
|
107
|
+
// cmc/moonshotai/<model>). Exact suffix matches win; the normalized
|
|
108
|
+
// last-segment name is the recovery path, never a wider net.
|
|
79
109
|
const resolvable: CatalogModel[] = [];
|
|
80
110
|
for (const member of combo.members) {
|
|
81
|
-
|
|
82
|
-
const entry = catalogById.get(member.model);
|
|
83
|
-
if (entry !== undefined
|
|
111
|
+
const name = member.kind === "model" ? member.model : member.alias;
|
|
112
|
+
const entry = member.kind === "model" ? catalogById.get(member.model) : undefined;
|
|
113
|
+
if (entry !== undefined && entry.owned_by !== "alias") {
|
|
114
|
+
resolvable.push(entry);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const exact = catalogModels.filter(
|
|
118
|
+
(sibling) => sibling.owned_by !== "alias" && sibling.id.endsWith(`/${name}`),
|
|
119
|
+
);
|
|
120
|
+
if (exact.length > 0) {
|
|
121
|
+
resolvable.push(...exact);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const normalized = normalizeModelName(name);
|
|
125
|
+
if (normalized.length > 0) {
|
|
126
|
+
resolvable.push(
|
|
127
|
+
...catalogModels.filter(
|
|
128
|
+
(sibling) => sibling.owned_by !== "alias" && normalizeModelName(sibling.id) === normalized,
|
|
129
|
+
),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
84
132
|
}
|
|
85
133
|
|
|
86
|
-
//
|
|
87
|
-
//
|
|
134
|
+
// Optimistic by design: a false negative hides thinking levels (the worse
|
|
135
|
+
// error); a false positive shows an inert knob. Applies to every combo,
|
|
136
|
+
// including level-less resolved members (live-verified 2026-10-18 on
|
|
137
|
+
// combo/deepseek-v4-pro: no advertised levels, honors reasoning_effort).
|
|
138
|
+
const name = combo.name.trim().length > 0 ? combo.name : combo.slug;
|
|
139
|
+
return buildModel(`combo/${combo.slug}`, name, resolvable);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Direct-selectable upstream models from `/v1/models`, in catalog order.
|
|
144
|
+
* Skipped: alias rows (information-free duplicates of a prefixed serving),
|
|
145
|
+
* image-output rows (not chat models), and blank ids (live shape `cc/`).
|
|
146
|
+
*/
|
|
147
|
+
export function catalogToModels(catalogModels: CatalogModel[]): Model<"openai-completions">[] {
|
|
148
|
+
return catalogModels
|
|
149
|
+
.filter(
|
|
150
|
+
(row) =>
|
|
151
|
+
row.owned_by !== "alias" &&
|
|
152
|
+
// AGENTS.md product rule: never surface image/video generation or
|
|
153
|
+
// `free/` tier models, even when the upstream catalog is published.
|
|
154
|
+
row.output_modality !== "image" &&
|
|
155
|
+
row.output_modality !== "video" &&
|
|
156
|
+
!row.id.startsWith("free/") &&
|
|
157
|
+
// Live shape `cc/`: prefix with an empty model segment — not selectable.
|
|
158
|
+
(row.id.split("/").pop() ?? "").trim().length > 0,
|
|
159
|
+
)
|
|
160
|
+
.map((row) => {
|
|
161
|
+
const label = row.upstream_label?.trim();
|
|
162
|
+
return buildModel(row.id, label && label.length > 0 ? label : row.id, [row]);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Shared pi-model construction for combo and direct-catalog entries. */
|
|
167
|
+
function buildModel(
|
|
168
|
+
id: string,
|
|
169
|
+
name: string,
|
|
170
|
+
sources: CatalogModel[],
|
|
171
|
+
): Model<"openai-completions"> {
|
|
88
172
|
const advertised = new Set<string>();
|
|
89
|
-
for (const model of
|
|
173
|
+
for (const model of sources) {
|
|
90
174
|
for (const level of model.reasoning_levels ?? []) advertised.add(level);
|
|
91
175
|
}
|
|
92
|
-
const reasoning = resolvable.length === 0 || advertised.size > 0;
|
|
93
176
|
|
|
94
|
-
const image =
|
|
177
|
+
const image = sources.length > 0 && sources.every((model) => (model.modality ?? "").includes("image"));
|
|
95
178
|
|
|
96
|
-
const priced =
|
|
179
|
+
const priced = sources.flatMap((model) => (model.pricing ? [model.pricing] : []));
|
|
97
180
|
|
|
98
181
|
const model: Model<"openai-completions"> = {
|
|
99
|
-
id
|
|
100
|
-
name
|
|
182
|
+
id,
|
|
183
|
+
name,
|
|
101
184
|
api: "openai-completions",
|
|
102
185
|
provider: PROVIDER_NAME,
|
|
103
186
|
baseUrl: INFERENCE_BASE_URL,
|
|
@@ -108,17 +191,27 @@ function comboToModel(
|
|
|
108
191
|
cacheRead: 0,
|
|
109
192
|
cacheWrite: 0,
|
|
110
193
|
},
|
|
111
|
-
reasoning,
|
|
112
|
-
contextWindow:
|
|
113
|
-
maxTokens:
|
|
194
|
+
reasoning: true,
|
|
195
|
+
contextWindow: minPositiveOf(sources.map((source) => source.input_token_limit)) ?? CONTEXT_WINDOW_FALLBACK,
|
|
196
|
+
maxTokens: minPositiveOf(sources.map((source) => source.max_output_tokens)) ?? MAX_TOKENS_FALLBACK,
|
|
197
|
+
// InferHub's [OI]-compatible surface ignores max_completion_tokens (verified
|
|
198
|
+
// live 2026-10-18: a 20-token cap sent that way produced 107 tokens; the
|
|
199
|
+
// same cap as max_tokens stopped at 20). pi-ai auto-detects per-provider
|
|
200
|
+
// and inferhub is not on its list, so pin the field explicitly.
|
|
201
|
+
compat: { maxTokensField: "max_tokens" },
|
|
114
202
|
};
|
|
115
|
-
// thinkingLevelMap: built only from an advertised union;
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
if (
|
|
203
|
+
// thinkingLevelMap: built only from an advertised union; a level-less union
|
|
204
|
+
// omits the map so pi defaults keep thinking levels selectable — marking
|
|
205
|
+
// them all `null` would hide the knob entirely.
|
|
206
|
+
if (advertised.size > 0) model.thinkingLevelMap = buildThinkingLevelMap(advertised);
|
|
119
207
|
return model;
|
|
120
208
|
}
|
|
121
209
|
|
|
210
|
+
/** Last path segment, lowercased, alphanumerics only: "cp/cline-pass/Kimi-K3" → "kimik3". */
|
|
211
|
+
function normalizeModelName(id: string): string {
|
|
212
|
+
return (id.split("/").pop() ?? id).toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
213
|
+
}
|
|
214
|
+
|
|
122
215
|
/** Each pi level maps to itself when the combo advertises it, else `null`. */
|
|
123
216
|
function buildThinkingLevelMap(advertised: ReadonlySet<string>): ThinkingLevelMap {
|
|
124
217
|
const map: ThinkingLevelMap = { off: "off" };
|
|
@@ -132,3 +225,9 @@ function minOf(values: Array<number | undefined>): number | undefined {
|
|
|
132
225
|
const present = values.filter((value): value is number => value !== undefined);
|
|
133
226
|
return present.length > 0 ? Math.min(...present) : undefined;
|
|
134
227
|
}
|
|
228
|
+
|
|
229
|
+
/** Limits: 0 (or non-finite) is missing data, never a real ceiling. Prices keep 0. */
|
|
230
|
+
function minPositiveOf(values: Array<number | undefined>): number | undefined {
|
|
231
|
+
const present = values.filter((value): value is number => value !== undefined && Number.isFinite(value) && value > 0);
|
|
232
|
+
return present.length > 0 ? Math.min(...present) : undefined;
|
|
233
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** Canonical config file for this package, under pi's agent dir. */
|
|
6
|
+
export const CONFIG_FILE_NAME = "pi-inferhub.json";
|
|
7
|
+
|
|
8
|
+
/** Env override for `publishUpstream`; wins over the file when set. */
|
|
9
|
+
export const PUBLISH_UPSTREAM_ENV = "INFERHUB_PUBLISH_UPSTREAM";
|
|
10
|
+
|
|
11
|
+
export interface InferhubConfig {
|
|
12
|
+
/**
|
|
13
|
+
* Also publish every chat model from `/v1/models` as a direct-selectable
|
|
14
|
+
* `prefix/model` entry, alongside the `combo/<slug>` models. Combos stay
|
|
15
|
+
* first; this only widens the catalog. Default: false.
|
|
16
|
+
*/
|
|
17
|
+
publishUpstream: boolean;
|
|
18
|
+
/** Human-readable problems found while loading; never fatal. */
|
|
19
|
+
issues: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** pi's agent dir: `PI_AGENT_DIR` when set, else `~/.pi/agent`. */
|
|
23
|
+
export function defaultAgentDir(): string {
|
|
24
|
+
return process.env.PI_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseEnvBoolean(raw: string): boolean | undefined {
|
|
28
|
+
const value = raw.trim().toLowerCase();
|
|
29
|
+
if (["1", "true", "yes", "on"].includes(value)) return true;
|
|
30
|
+
if (["0", "false", "no", "off"].includes(value)) return false;
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Load `<agentDir>/pi-inferhub.json`. Missing file = defaults; malformed file =
|
|
36
|
+
* defaults + issue; unknown keys are ignored, wrong-typed values are reported
|
|
37
|
+
* and leave the default in place. Never throws for file content problems.
|
|
38
|
+
*/
|
|
39
|
+
export function loadInferhubConfig(agentDir: string = defaultAgentDir()): InferhubConfig {
|
|
40
|
+
const issues: string[] = [];
|
|
41
|
+
let raw: unknown;
|
|
42
|
+
try {
|
|
43
|
+
raw = JSON.parse(readFileSync(join(agentDir, CONFIG_FILE_NAME), "utf8"));
|
|
44
|
+
} catch (error) {
|
|
45
|
+
const code = (error as NodeJS.ErrnoException)?.code;
|
|
46
|
+
if (code === "ENOENT") return { publishUpstream: false, issues };
|
|
47
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
48
|
+
issues.push(`${CONFIG_FILE_NAME} is not valid JSON (${message}); using defaults`);
|
|
49
|
+
return { publishUpstream: false, issues };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
53
|
+
issues.push(`${CONFIG_FILE_NAME} must contain a JSON object; using defaults`);
|
|
54
|
+
return { publishUpstream: false, issues };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let publishUpstream = false;
|
|
58
|
+
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
|
59
|
+
if (key !== "publishUpstream") continue;
|
|
60
|
+
if (typeof value === "boolean") publishUpstream = value;
|
|
61
|
+
else issues.push(`publishUpstream must be a boolean (got ${typeof value}); using false`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const envRaw = process.env[PUBLISH_UPSTREAM_ENV];
|
|
65
|
+
if (envRaw !== undefined) {
|
|
66
|
+
const envValue = parseEnvBoolean(envRaw);
|
|
67
|
+
if (envValue === undefined) {
|
|
68
|
+
issues.push(`${PUBLISH_UPSTREAM_ENV} must be a boolean-like value; ignoring`);
|
|
69
|
+
} else {
|
|
70
|
+
publishUpstream = envValue;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { publishUpstream, issues };
|
|
75
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-c
|
|
|
4
4
|
import { openAICompletionsApi } from "@earendil-works/pi-ai/compat";
|
|
5
5
|
import { inferhubApiKeyAuth, loginInferhub } from "./auth.js";
|
|
6
6
|
import { fetchComboCatalog } from "./catalog.js";
|
|
7
|
+
import { type InferhubConfig, loadInferhubConfig } from "./config.js";
|
|
7
8
|
import { INFERENCE_BASE_URL, PROVIDER_DISPLAY_NAME, PROVIDER_NAME } from "./inferhub.js";
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -17,7 +18,13 @@ export interface InferhubRegistrationPi {
|
|
|
17
18
|
registerCommand(name: string, options: { description?: string; handler: (args: string, ctx: ExtensionCommandContext) => Promise<void> }): void;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
export interface InferhubRegistrationOptions {
|
|
22
|
+
/** Config loader seam; production reads `<agentDir>/pi-inferhub.json`. */
|
|
23
|
+
loadConfig?: () => InferhubConfig;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function inferhubProvider(options: InferhubRegistrationOptions = {}): Provider<"openai-completions"> {
|
|
27
|
+
const loadConfig = options.loadConfig ?? (() => loadInferhubConfig());
|
|
21
28
|
return createProvider({
|
|
22
29
|
id: PROVIDER_NAME,
|
|
23
30
|
name: PROVIDER_DISPLAY_NAME,
|
|
@@ -36,15 +43,15 @@ function inferhubProvider(): Provider<"openai-completions"> {
|
|
|
36
43
|
const credential = context.credential;
|
|
37
44
|
const token = credential?.type === "api_key" ? credential.key : undefined;
|
|
38
45
|
if (!token) return [];
|
|
39
|
-
return fetchComboCatalog(token, context.signal);
|
|
46
|
+
return fetchComboCatalog(token, context.signal, { publishUpstream: loadConfig().publishUpstream });
|
|
40
47
|
},
|
|
41
48
|
api: openAICompletionsApi(),
|
|
42
49
|
});
|
|
43
50
|
}
|
|
44
51
|
|
|
45
52
|
/** Register the InferHub provider and its companion command. */
|
|
46
|
-
export function registerInferhubProvider(pi: InferhubRegistrationPi): void {
|
|
47
|
-
pi.registerProvider(inferhubProvider());
|
|
53
|
+
export function registerInferhubProvider(pi: InferhubRegistrationPi, options?: InferhubRegistrationOptions): void {
|
|
54
|
+
pi.registerProvider(inferhubProvider(options));
|
|
48
55
|
|
|
49
56
|
// pi's registerCommand takes a single command name; `/login inferhub` is
|
|
50
57
|
// provided by pi's auth system through the apiKey login wired above. This
|
|
@@ -55,7 +62,7 @@ export function registerInferhubProvider(pi: InferhubRegistrationPi): void {
|
|
|
55
62
|
const status = ctx.modelRegistry.getProviderAuthStatus(PROVIDER_NAME);
|
|
56
63
|
if (status?.configured) {
|
|
57
64
|
const source = status.label ? ` (${status.label})` : "";
|
|
58
|
-
ctx.ui.notify(`InferHub is configured${source}. Run /
|
|
65
|
+
ctx.ui.notify(`InferHub is configured${source}. Run /model to pick a combo.`, "info");
|
|
59
66
|
return;
|
|
60
67
|
}
|
|
61
68
|
ctx.ui.notify(
|
package/AGENTS.md
DELETED
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
# pi-inferhub
|
|
2
|
-
|
|
3
|
-
pi provider extension exposing the user's InferHub (https://inferhub.dev)
|
|
4
|
-
**combos** as selectable chat models. Combos are the product: the extension
|
|
5
|
-
never surfaces `prefix/model` upstream ids, bare aliases, `free/` tier
|
|
6
|
-
models, or images/video generation. Combos are `combo/<slug>` models routed
|
|
7
|
-
through InferHub's OpenAI-compatible inference surface; all failover,
|
|
8
|
-
serialization, and selection happens server-side.
|
|
9
|
-
|
|
10
|
-
Spec (binding design authority): `docs/superpowers/specs/2026-10-06-inferhub-provider-design.md`
|
|
11
|
-
Plan: `docs/superpowers/plans/2026-10-06-inferhub-provider.md`
|
|
12
|
-
|
|
13
|
-
## Architecture
|
|
14
|
-
|
|
15
|
-
Four modules, acyclic, one-directional:
|
|
16
|
-
|
|
17
|
-
```
|
|
18
|
-
src/inferhub.ts constants only — PROVIDER_NAME="inferhub", PROVIDER_DISPLAY_NAME="InferHub",
|
|
19
|
-
INFERENCE_BASE_URL="https://api.inferhub.dev/v1", MGMT_BASE_URL="https://inferhub.dev/api"
|
|
20
|
-
src/http.ts ← transport only — fetchJson (Bearer GET + typebox validation), InferhubHttpError{status,code,message}
|
|
21
|
-
src/auth.ts ← loginInferhub (prompt → validate GET /me → store), inferhubApiKeyAuth (envApiKeyAuth)
|
|
22
|
-
src/catalog.ts ← fetchComboCatalog (Promise.all of the two GETs), pure combosToModels for tests
|
|
23
|
-
src/index.ts ← default extension factory: createProvider registration + inferhub-login signpost command
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
No custom `streamSimple`. Everything streams through pi-ai's
|
|
27
|
-
`openAICompletionsApi()` (imported from `@earendil-works/pi-ai/compat` —
|
|
28
|
-
pi's extension loader does not virtualize lazy API subpaths). pi-ai owns
|
|
29
|
-
message conversion, tool handling, usage, cancellation, retries. Adding a
|
|
30
|
-
custom stream is a design-level decision, never a drive-by fix for an
|
|
31
|
-
upstream quirk.
|
|
32
|
-
|
|
33
|
-
## Wire contract (live-pinned 2026-10-07, verify before trusting)
|
|
34
|
-
|
|
35
|
-
- Auth: `Authorization: Bearer sk-airo-…` on BOTH surfaces.
|
|
36
|
-
- `GET MGMT_BASE_URL/combos` → **bare JSON array** of combos (id/name/slug/
|
|
37
|
-
members[]; member kinds `model` with `model: "prefix/upstream_id"` and
|
|
38
|
-
`alias` with bare name, no catalog linkage).
|
|
39
|
-
- `GET INFERENCE_BASE_URL/models` → `{object:"list", data:[...]}`; items
|
|
40
|
-
carry `id, modality ("text" | "text,image"), input_token_limit,
|
|
41
|
-
max_output_tokens, reasoning_levels, pricing.official_in/official_out`.
|
|
42
|
-
Combos do NOT appear here — that is why refresh fetches /combos
|
|
43
|
-
separately. `/v1/models` requires auth (401 unauthenticated).
|
|
44
|
-
- `GET MGMT_BASE_URL/me` → any JSON object; login validation endpoint.
|
|
45
|
-
- Management API rate limit 30 req/min/account; inference 500 req/min/account.
|
|
46
|
-
- Errors: `{"error":{"code","message"}}`; suspended/banned → 403
|
|
47
|
-
`account_blocked` with the ban reason in `message`.
|
|
48
|
-
- Cost: inference responses report `usage.cost` USD (OpenRouter convention),
|
|
49
|
-
$0.00001 minimum on billed requests. All management-surface money is USDC
|
|
50
|
-
decimal strings.
|
|
51
|
-
|
|
52
|
-
## Invariants (breaking any of these is a reverted commit)
|
|
53
|
-
|
|
54
|
-
1. **Reserved-key play-safe**: combo-level metadata (maxInputPerMtok/
|
|
55
|
-
maxOutputPerMtok caps, selection mode, filters, sticky, members) never
|
|
56
|
-
rides on the pi-ai `Model` object. Mapped models carry exactly pi-ai
|
|
57
|
-
`Model<"openai-completions">` fields.
|
|
58
|
-
2. **Cost honesty**: `cost.input/output` = per-direction MIN of resolvable
|
|
59
|
-
members' `official` rates (conservative upper bound — InferHub bills
|
|
60
|
-
discounts below official). `cost.cacheRead/cacheWrite` = 0 always —
|
|
61
|
-
InferHub's /v1/models pricing carries no cache fields; never fabricate.
|
|
62
|
-
3. **Fallback constants**: `CONTEXT_WINDOW_FALLBACK = 128_000`,
|
|
63
|
-
`MAX_TOKENS_FALLBACK = 8_192` — used when no resolvable member supplies
|
|
64
|
-
the field. pi-ai's `Model` type requires both as numbers; never guess a
|
|
65
|
-
model's ceiling other than via these constants.
|
|
66
|
-
4. **Image rule**: `input: ["text","image"]` only when ≥1 resolvable member
|
|
67
|
-
AND every resolvable member's `modality` includes `image`. Understating
|
|
68
|
-
input is acceptable; overstating sends requests the upstream rejects.
|
|
69
|
-
5. **Alias-member fallback**: empty resolvable set (alias-only combos, or
|
|
70
|
-
all member joins missed) → `input:["text"]`, `reasoning:true`, zero
|
|
71
|
-
cost, fallback limits. `reasoning:true` is deliberate: a false negative
|
|
72
|
-
hides thinking levels (the worse error); a false positive shows an inert
|
|
73
|
-
knob.
|
|
74
|
-
6. **thinkingLevelMap**: built from the union of resolvable members'
|
|
75
|
-
`reasoning_levels`; each pi level (`minimal|low|medium|high|xhigh|max`)
|
|
76
|
-
maps to itself when advertised, else `null`; `off:"off"`. Set only when
|
|
77
|
-
`reasoning` is true. Alias-only/level-less combos OMIT the map entirely —
|
|
78
|
-
an all-null map would hide every selectable level.
|
|
79
|
-
7. **No key leakage**: the API key appears ONLY in the Authorization header
|
|
80
|
-
and pi's credential store (`auth.json` via pi's own store). Never log,
|
|
81
|
-
print, or persist it anywhere else. Tests inject fetch mocks; a test
|
|
82
|
-
asserting the header is fine, echoing the key in fixtures is not.
|
|
83
|
-
8. **Unconfigured = zero models, no network**: `fetchModels` returns `[]`
|
|
84
|
-
and fetches nothing when auth resolves no key. pi-ai passes BOTH
|
|
85
|
-
stored-credential and env-resolved keys as `context.credential`
|
|
86
|
-
(verified in pi-ai 1.0.4 `dist/models.js` `resolveRefreshCredential`) —
|
|
87
|
-
do not add a second key-resolution path.
|
|
88
|
-
9. **No cross-run catalog persistence**: refresh is always live. A stale
|
|
89
|
-
combo snapshot silently serving the wrong routing is worse than an
|
|
90
|
-
explicit refresh.
|
|
91
|
-
|
|
92
|
-
## Commands
|
|
93
|
-
|
|
94
|
-
```
|
|
95
|
-
npx vitest run # full suite (18 tests, <2s)
|
|
96
|
-
npx tsc --noEmit # typecheck
|
|
97
|
-
npm pack --dry-run # inspect package contents before publishing
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
pi packs the extension from `package.json` `pi.extensions:
|
|
101
|
-
["./src/index.ts"]`. Local dev: `pi -e <repo path>`. Published install:
|
|
102
|
-
`npm:pi-inferhub` in `~/.pi/agent/settings.json` packages (NOT yet
|
|
103
|
-
published as of 2026-10-07 — v0.1.0).
|
|
104
|
-
|
|
105
|
-
## Environment gotchas (learned the hard way)
|
|
106
|
-
|
|
107
|
-
- **TypeScript 6.0.3**: tsconfig MUST carry `types: ["node"]` — TS 6 dropped
|
|
108
|
-
automatic `@types/*` inclusion; without it `node:module`/`fetch`/`AbortSignal`
|
|
109
|
-
don't resolve and `tsc --noEmit` fails.
|
|
110
|
-
- **typebox 1.3.36**: compiled validators are typed `Validator<TProperties,
|
|
111
|
-
TSchema>` — pass the validator, not the schema, to `fetchJson`. Validation
|
|
112
|
-
error detail lives on `instancePath` (there is no `path` field). Extra
|
|
113
|
-
payload keys pass through by default; do NOT add
|
|
114
|
-
`additionalProperties: false` to wire schemas.
|
|
115
|
-
- **Git signing**: fresh clones inherit `gpg.format=ssh` globally but need
|
|
116
|
-
`git config user.signingkey ~/.ssh/michaelmacleod.id_ecdsa.pub` locally
|
|
117
|
-
before the first commit fails.
|
|
118
|
-
- **pi-ai `AuthEvent`** is a union; non-info variants carry no `message`.
|
|
119
|
-
Anything typing an interaction callback as `{type: string; message:
|
|
120
|
-
string}` breaks under contravariance — use `{type: string; message?:
|
|
121
|
-
string}`.
|
|
122
|
-
- **Live pinning of wire shapes is allowed and encouraged** when a shape is
|
|
123
|
-
undocumented: run a free listing GET, pin the parsed envelope in a typebox
|
|
124
|
-
validator, and leave a dated comment. Drift surfaces as
|
|
125
|
-
`code: "schema_mismatch"` with `instancePath` — fail-loud by design.
|
|
126
|
-
|
|
127
|
-
## Testing conventions
|
|
128
|
-
|
|
129
|
-
- Typebox schemas live at module scope (`Compile(Type.Object({...}))`).
|
|
130
|
-
- Tabs, ESM with `.js` import specifiers, single-responsibility modules.
|
|
131
|
-
- Tests verify real behavior through public seams (`combosToModels` pure
|
|
132
|
-
function; real `createProvider` refresh path in test/index.test.ts with
|
|
133
|
-
injected `fetchImpl`). No seams into internals.
|
|
134
|
-
- Deleting a brief-verbatim test line, or "fixing" a failing test by editing
|
|
135
|
-
the test to fit the code rather than the contract, is not allowed without
|
|
136
|
-
naming the contract being violated.
|