pi-inferhub 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +71 -13
- package/package.json +22 -4
- package/src/catalog.ts +175 -32
- package/src/config.ts +75 -0
- package/src/index.ts +23 -8
- package/src/inferhub.ts +2 -0
- package/AGENTS.md +0 -146
- 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/pi-inferhub-0.2.0.tgz +0 -0
- package/test/auth.test.ts +0 -43
- package/test/catalog.test.ts +0 -123
- 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,34 @@ 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.
|
|
101
|
+
- **Surface** — each combo rides the API its membership implies:
|
|
102
|
+
**Anthropic messages** for Claude-only combos, **[OI] responses** for
|
|
103
|
+
GPT-only combos, and **chat completions** for everything else — including
|
|
104
|
+
combos that mix both native families. Family is decided from the model name,
|
|
105
|
+
not the serving prefix.
|
|
73
106
|
- **Cost** — per-direction minimum of resolvable members' *official* rates
|
|
74
|
-
(see below).
|
|
75
|
-
|
|
107
|
+
(see below). Cache reads bill at **0.1x the input rate** (owner-confirmed);
|
|
108
|
+
cache writes are left at `0` because no write rate is published.
|
|
76
109
|
|
|
77
|
-
|
|
78
|
-
|
|
110
|
+
Member resolution is conservative but forgiving: a member resolves directly
|
|
111
|
+
from `/v1/models` when its prefixed row carries data; otherwise it joins the
|
|
112
|
+
prefixed servings of the same model name (exact suffix first, then a
|
|
113
|
+
normalized last-segment match, which covers multi-segment prefixes such as
|
|
114
|
+
`cp/cline-pass/<model>`). Combos whose members resolve to nothing at all get
|
|
115
|
+
safe defaults: text-only, reasoning-capable, `0` cost, and the fallback limits.
|
|
79
116
|
|
|
80
117
|
## Refresh semantics
|
|
81
118
|
|
|
@@ -98,6 +135,27 @@ member's *official* list rates (`official_in` / `official_out`) per direction.
|
|
|
98
135
|
InferHub's marketplace bills below list, so the real `usage.cost` reported per
|
|
99
136
|
request comes in **at or under** the displayed estimate.
|
|
100
137
|
|
|
138
|
+
Cached input is priced at **0.1x the input rate**, the rate InferHub charges
|
|
139
|
+
(confirmed by its owner). On the Anthropic surface this is not theoretical:
|
|
140
|
+
sending the same prefix twice cut a request's real cost by roughly 16x in
|
|
141
|
+
testing, because the second call billed its 1218-token prefix as a cache read
|
|
142
|
+
instead of fresh input.
|
|
143
|
+
|
|
144
|
+
## Surfaces
|
|
145
|
+
|
|
146
|
+
InferHub exposes three compatible APIs. The extension picks per model:
|
|
147
|
+
|
|
148
|
+
| membership | API | endpoint |
|
|
149
|
+
|---|---|---|
|
|
150
|
+
| Claude models only | Anthropic messages | `api.inferhub.dev/v1/messages` |
|
|
151
|
+
| GPT models only | [OI] responses | `api.inferhub.dev/v1/responses` |
|
|
152
|
+
| both families, or neither | chat completions | `api.inferhub.dev/v1/chat/completions` |
|
|
153
|
+
|
|
154
|
+
This buys native behaviour where it is unambiguous: structured thinking
|
|
155
|
+
control, `cache_control` breakpoints, and the responses surface's typed tool
|
|
156
|
+
calls. Mixed combos deliberately fall back to chat completions, which InferHub
|
|
157
|
+
translates for every model.
|
|
158
|
+
|
|
101
159
|
## Design rationale
|
|
102
160
|
|
|
103
161
|
The full design — why combos only, why `/v1/models` supplies member metadata
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-inferhub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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
|
@@ -1,14 +1,55 @@
|
|
|
1
|
-
import type { Model, ThinkingLevel, ThinkingLevelMap } from "@earendil-works/pi-ai";
|
|
1
|
+
import type { Api, Model, ThinkingLevel, ThinkingLevelMap } from "@earendil-works/pi-ai";
|
|
2
2
|
import { Type, type Static } from "typebox";
|
|
3
3
|
import { Compile } from "typebox/compile";
|
|
4
4
|
import { fetchJson, type FetchJsonOptions } from "./http.js";
|
|
5
|
-
import { INFERENCE_BASE_URL, MGMT_BASE_URL, PROVIDER_NAME } from "./inferhub.js";
|
|
5
|
+
import { ANTHROPIC_BASE_URL, INFERENCE_BASE_URL, MGMT_BASE_URL, PROVIDER_NAME } from "./inferhub.js";
|
|
6
6
|
|
|
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
10
|
const CONTEXT_WINDOW_FALLBACK = 262_144;
|
|
11
11
|
const MAX_TOKENS_FALLBACK = 131_072;
|
|
12
|
+
/**
|
|
13
|
+
* Owner-confirmed 2026-10-18 (and matching the marketplace cache rule): InferHub
|
|
14
|
+
* bills cached input at 0.1x the input rate. Cache WRITES have no published
|
|
15
|
+
* rate, so they stay 0 rather than being guessed.
|
|
16
|
+
*/
|
|
17
|
+
const CACHE_READ_MULTIPLIER = 0.1;
|
|
18
|
+
|
|
19
|
+
type ChatModel = Model<Api>;
|
|
20
|
+
|
|
21
|
+
/** Which native surface a model name belongs to. */
|
|
22
|
+
export type NativeFamily = "anthropic" | "openai" | "other";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Family of the model NAME (last path segment), never the serving prefix:
|
|
26
|
+
* live catalog 2026-10-18 shows `kiro/` serving both claude-* and gpt-* rows.
|
|
27
|
+
* `gpt-oss` is an open-weight model, not an [OI] native one, so it stays other.
|
|
28
|
+
*/
|
|
29
|
+
export function nativeFamily(id: string): NativeFamily {
|
|
30
|
+
const name = (id.split("/").pop() ?? id).toLowerCase();
|
|
31
|
+
if (name.includes("claude")) return "anthropic";
|
|
32
|
+
if (/^(gpt(?!-oss)|o\d|codex)/.test(name)) return "openai";
|
|
33
|
+
return "other";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface Surface {
|
|
37
|
+
api: Api;
|
|
38
|
+
baseUrl: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One native family present → its own surface. Both native families present
|
|
43
|
+
* (or none) → chat completions, which InferHub translates for every model.
|
|
44
|
+
*/
|
|
45
|
+
export function selectSurface(names: readonly string[]): Surface {
|
|
46
|
+
const families = new Set(names.map(nativeFamily));
|
|
47
|
+
const anthropic = families.has("anthropic");
|
|
48
|
+
const openai = families.has("openai");
|
|
49
|
+
if (anthropic && !openai) return { api: "anthropic-messages", baseUrl: ANTHROPIC_BASE_URL };
|
|
50
|
+
if (openai && !anthropic) return { api: "openai-responses", baseUrl: INFERENCE_BASE_URL };
|
|
51
|
+
return { api: "openai-completions", baseUrl: INFERENCE_BASE_URL };
|
|
52
|
+
}
|
|
12
53
|
|
|
13
54
|
export const MemberSchema = Type.Union([
|
|
14
55
|
Type.Object({ kind: Type.Literal("model"), model: Type.String() }),
|
|
@@ -31,6 +72,8 @@ export const CatalogModelSchema = Type.Object({
|
|
|
31
72
|
id: Type.String(),
|
|
32
73
|
owned_by: Type.Optional(Type.String()),
|
|
33
74
|
modality: Type.Optional(Type.String()),
|
|
75
|
+
output_modality: Type.Optional(Type.String()),
|
|
76
|
+
upstream_label: Type.Optional(Type.String()),
|
|
34
77
|
input_token_limit: Type.Optional(Type.Number()),
|
|
35
78
|
max_output_tokens: Type.Optional(Type.Number()),
|
|
36
79
|
reasoning_levels: Type.Optional(Type.Array(Type.String())),
|
|
@@ -60,24 +103,32 @@ const ModelsEnvelope = Type.Object({ data: Type.Array(CatalogModelSchema) });
|
|
|
60
103
|
const CombosValidator = Compile(CombosEnvelope);
|
|
61
104
|
const ModelsValidator = Compile(ModelsEnvelope);
|
|
62
105
|
|
|
106
|
+
export interface FetchCatalogOptions extends FetchJsonOptions {
|
|
107
|
+
/** Also publish direct-selectable upstream models from /v1/models. */
|
|
108
|
+
publishUpstream?: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
63
111
|
/** GET /combos + GET /v1/models (independent), then map combos to pi models. */
|
|
64
112
|
export async function fetchComboCatalog(
|
|
65
113
|
token: string,
|
|
66
114
|
signal?: AbortSignal,
|
|
67
|
-
opts?:
|
|
68
|
-
): Promise<
|
|
115
|
+
opts?: FetchCatalogOptions,
|
|
116
|
+
): Promise<ChatModel[]> {
|
|
69
117
|
const [combos, models] = await Promise.all([
|
|
70
118
|
fetchJson<Combo[]>(`${MGMT_BASE_URL}/combos`, token, CombosValidator, signal, opts),
|
|
71
119
|
fetchJson<Static<typeof ModelsEnvelope>>(`${INFERENCE_BASE_URL}/models`, token, ModelsValidator, signal, opts),
|
|
72
120
|
]);
|
|
73
|
-
|
|
121
|
+
const comboModels = combosToModels(combos, models.data);
|
|
122
|
+
// Combos are the product: they stay first. The upstream catalog only widens
|
|
123
|
+
// the list when the operator opts in.
|
|
124
|
+
return opts?.publishUpstream ? [...comboModels, ...catalogToModels(models.data)] : comboModels;
|
|
74
125
|
}
|
|
75
126
|
|
|
76
127
|
/** Pure combo → Model mapping; output order follows `combos` order. */
|
|
77
128
|
export function combosToModels(
|
|
78
129
|
combos: Combo[],
|
|
79
130
|
catalogModels: CatalogModel[],
|
|
80
|
-
):
|
|
131
|
+
): ChatModel[] {
|
|
81
132
|
const catalogById = new Map(catalogModels.map((model) => [model.id, model]));
|
|
82
133
|
return combos.map((combo) => comboToModel(combo, catalogById, catalogModels));
|
|
83
134
|
}
|
|
@@ -86,10 +137,16 @@ function comboToModel(
|
|
|
86
137
|
combo: Combo,
|
|
87
138
|
catalogById: Map<string, CatalogModel>,
|
|
88
139
|
catalogModels: CatalogModel[],
|
|
89
|
-
):
|
|
140
|
+
): ChatModel {
|
|
90
141
|
// Alias rows (`owned_by: "alias"`, no data) and alias-kind members resolve
|
|
91
142
|
// through prefixed sibling servings of the same model name; min-of over the
|
|
92
143
|
// siblings bounds the combo exactly like directly-joined members.
|
|
144
|
+
//
|
|
145
|
+
// Live shape 2026-10-18: combos reference multi-segment prefixes
|
|
146
|
+
// (cp/cline-pass/<model>, cp/zai/<model>) that have no exact serving, while
|
|
147
|
+
// the same model is served under another prefix (zai/<model>,
|
|
148
|
+
// cmc/moonshotai/<model>). Exact suffix matches win; the normalized
|
|
149
|
+
// last-segment name is the recovery path, never a wider net.
|
|
93
150
|
const resolvable: CatalogModel[] = [];
|
|
94
151
|
for (const member of combo.members) {
|
|
95
152
|
const name = member.kind === "model" ? member.model : member.alias;
|
|
@@ -98,58 +155,144 @@ function comboToModel(
|
|
|
98
155
|
resolvable.push(entry);
|
|
99
156
|
continue;
|
|
100
157
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
158
|
+
const exact = catalogModels.filter(
|
|
159
|
+
(sibling) => sibling.owned_by !== "alias" && sibling.id.endsWith(`/${name}`),
|
|
160
|
+
);
|
|
161
|
+
if (exact.length > 0) {
|
|
162
|
+
resolvable.push(...exact);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const normalized = normalizeModelName(name);
|
|
166
|
+
if (normalized.length > 0) {
|
|
167
|
+
resolvable.push(
|
|
168
|
+
...catalogModels.filter(
|
|
169
|
+
(sibling) => sibling.owned_by !== "alias" && normalizeModelName(sibling.id) === normalized,
|
|
170
|
+
),
|
|
171
|
+
);
|
|
104
172
|
}
|
|
105
173
|
}
|
|
106
174
|
|
|
107
|
-
//
|
|
108
|
-
//
|
|
175
|
+
// Optimistic by design: a false negative hides thinking levels (the worse
|
|
176
|
+
// error); a false positive shows an inert knob. Applies to every combo,
|
|
177
|
+
// including level-less resolved members (live-verified 2026-10-18 on
|
|
178
|
+
// combo/deepseek-v4-pro: no advertised levels, honors reasoning_effort).
|
|
179
|
+
const name = combo.name.trim().length > 0 ? combo.name : combo.slug;
|
|
180
|
+
const surface = selectSurface(combo.members.map((member) => (member.kind === "model" ? member.model : member.alias)));
|
|
181
|
+
return buildModel(`combo/${combo.slug}`, name, resolvable, surface);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Direct-selectable upstream models from `/v1/models`, in catalog order.
|
|
186
|
+
* Skipped: alias rows (information-free duplicates of a prefixed serving),
|
|
187
|
+
* image-output rows (not chat models), and blank ids (live shape `cc/`).
|
|
188
|
+
*/
|
|
189
|
+
export function catalogToModels(catalogModels: CatalogModel[]): ChatModel[] {
|
|
190
|
+
return catalogModels
|
|
191
|
+
.filter(
|
|
192
|
+
(row) =>
|
|
193
|
+
row.owned_by !== "alias" &&
|
|
194
|
+
// AGENTS.md product rule: never surface image/video generation or
|
|
195
|
+
// `free/` tier models, even when the upstream catalog is published.
|
|
196
|
+
row.output_modality !== "image" &&
|
|
197
|
+
row.output_modality !== "video" &&
|
|
198
|
+
!row.id.startsWith("free/") &&
|
|
199
|
+
// Live shape `cc/`: prefix with an empty model segment — not selectable.
|
|
200
|
+
(row.id.split("/").pop() ?? "").trim().length > 0,
|
|
201
|
+
)
|
|
202
|
+
.map((row) => {
|
|
203
|
+
const label = row.upstream_label?.trim();
|
|
204
|
+
return buildModel(row.id, label && label.length > 0 ? label : row.id, [row], selectSurface([row.id]));
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Shared pi-model construction for combo and direct-catalog entries. */
|
|
209
|
+
function buildModel(id: string, name: string, sources: CatalogModel[], surface: Surface): ChatModel {
|
|
109
210
|
const advertised = new Set<string>();
|
|
110
|
-
for (const model of
|
|
211
|
+
for (const model of sources) {
|
|
111
212
|
for (const level of model.reasoning_levels ?? []) advertised.add(level);
|
|
112
213
|
}
|
|
113
|
-
const reasoning = resolvable.length === 0 || advertised.size > 0;
|
|
114
214
|
|
|
115
|
-
const image =
|
|
215
|
+
const image = sources.length > 0 && sources.every((model) => (model.modality ?? "").includes("image"));
|
|
116
216
|
|
|
117
|
-
const priced =
|
|
217
|
+
const priced = sources.flatMap((model) => (model.pricing ? [model.pricing] : []));
|
|
218
|
+
const inputRate = minOf(priced.map((pricing) => pricing.official_in)) ?? 0;
|
|
118
219
|
|
|
119
|
-
const model:
|
|
120
|
-
id
|
|
121
|
-
name
|
|
122
|
-
api:
|
|
220
|
+
const model: ChatModel = {
|
|
221
|
+
id,
|
|
222
|
+
name,
|
|
223
|
+
api: surface.api,
|
|
123
224
|
provider: PROVIDER_NAME,
|
|
124
|
-
baseUrl:
|
|
225
|
+
baseUrl: surface.baseUrl,
|
|
125
226
|
input: image ? ["text", "image"] : ["text"],
|
|
126
227
|
cost: {
|
|
127
|
-
input:
|
|
228
|
+
input: inputRate,
|
|
128
229
|
output: minOf(priced.map((pricing) => pricing.official_out)) ?? 0,
|
|
129
|
-
cacheRead:
|
|
230
|
+
cacheRead: roundRate(inputRate * CACHE_READ_MULTIPLIER),
|
|
130
231
|
cacheWrite: 0,
|
|
131
232
|
},
|
|
132
|
-
reasoning,
|
|
133
|
-
contextWindow:
|
|
134
|
-
maxTokens:
|
|
233
|
+
reasoning: true,
|
|
234
|
+
contextWindow: minPositiveOf(sources.map((source) => source.input_token_limit)) ?? CONTEXT_WINDOW_FALLBACK,
|
|
235
|
+
maxTokens: minPositiveOf(sources.map((source) => source.max_output_tokens)) ?? MAX_TOKENS_FALLBACK,
|
|
135
236
|
};
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
237
|
+
|
|
238
|
+
if (surface.api === "openai-completions") {
|
|
239
|
+
// InferHub's [OI]-compatible surface ignores max_completion_tokens (verified
|
|
240
|
+
// live 2026-10-18: a 20-token cap sent that way produced 107 tokens; the
|
|
241
|
+
// same cap as max_tokens stopped at 20). pi-ai auto-detects per-provider
|
|
242
|
+
// and inferhub is not on its list, so pin the field explicitly.
|
|
243
|
+
model.compat = { maxTokensField: "max_tokens" };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// thinkingLevelMap: built only from an advertised union; a level-less union
|
|
247
|
+
// omits the level keys so pi defaults keep thinking levels selectable —
|
|
248
|
+
// marking them all `null` would hide the knob entirely.
|
|
249
|
+
if (surface.api === "anthropic-messages") {
|
|
250
|
+
// These Claude models reject thinking:{type:"disabled"} (400, live
|
|
251
|
+
// 2026-10-18) and require adaptive thinking. `off: null` makes pi-ai omit
|
|
252
|
+
// the field instead; the map is therefore always present, even when no
|
|
253
|
+
// ladder is advertised.
|
|
254
|
+
model.compat = { forceAdaptiveThinking: true };
|
|
255
|
+
// With no advertised ladder only `off` is pinned: the level keys stay
|
|
256
|
+
// absent so pi keeps every level selectable (optimistic by design).
|
|
257
|
+
model.thinkingLevelMap = advertised.size > 0 ? buildThinkingLevelMap(advertised, null) : { off: null };
|
|
258
|
+
} else if (surface.api === "openai-responses") {
|
|
259
|
+
// [OI] reasoning-off is effort "none"; the value "off" is accepted but
|
|
260
|
+
// leaves the effort unset (live-verified 2026-10-18).
|
|
261
|
+
if (advertised.size > 0) model.thinkingLevelMap = buildThinkingLevelMap(advertised, "none");
|
|
262
|
+
else model.thinkingLevelMap = { off: "none" };
|
|
263
|
+
} else if (advertised.size > 0) {
|
|
264
|
+
model.thinkingLevelMap = buildThinkingLevelMap(advertised, "off");
|
|
265
|
+
}
|
|
266
|
+
|
|
140
267
|
return model;
|
|
141
268
|
}
|
|
142
269
|
|
|
270
|
+
/** Cache rates are derived; trim float noise (3 * 0.1 = 0.30000000000000004). */
|
|
271
|
+
function roundRate(value: number): number {
|
|
272
|
+
return Math.round(value * 1e6) / 1e6;
|
|
273
|
+
}
|
|
274
|
+
|
|
143
275
|
/** Each pi level maps to itself when the combo advertises it, else `null`. */
|
|
144
|
-
function buildThinkingLevelMap(advertised: ReadonlySet<string
|
|
145
|
-
const map: ThinkingLevelMap = { off
|
|
276
|
+
function buildThinkingLevelMap(advertised: ReadonlySet<string>, off: "off" | "none" | null): ThinkingLevelMap {
|
|
277
|
+
const map: ThinkingLevelMap = { off };
|
|
146
278
|
for (const level of PI_THINKING_LEVELS) {
|
|
147
279
|
map[level] = advertised.has(level) ? level : null;
|
|
148
280
|
}
|
|
149
281
|
return map;
|
|
150
282
|
}
|
|
151
283
|
|
|
284
|
+
/** Last path segment, lowercased, alphanumerics only: "cp/cline-pass/Kimi-K3" → "kimik3". */
|
|
285
|
+
function normalizeModelName(id: string): string {
|
|
286
|
+
return (id.split("/").pop() ?? id).toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
287
|
+
}
|
|
288
|
+
|
|
152
289
|
function minOf(values: Array<number | undefined>): number | undefined {
|
|
153
290
|
const present = values.filter((value): value is number => value !== undefined);
|
|
154
291
|
return present.length > 0 ? Math.min(...present) : undefined;
|
|
155
292
|
}
|
|
293
|
+
|
|
294
|
+
/** Limits: 0 (or non-finite) is missing data, never a real ceiling. Prices keep 0. */
|
|
295
|
+
function minPositiveOf(values: Array<number | undefined>): number | undefined {
|
|
296
|
+
const present = values.filter((value): value is number => value !== undefined && Number.isFinite(value) && value > 0);
|
|
297
|
+
return present.length > 0 ? Math.min(...present) : undefined;
|
|
298
|
+
}
|
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
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { createProvider, type Provider } from "@earendil-works/pi-ai";
|
|
1
|
+
import { createProvider, type Api, type Provider } from "@earendil-works/pi-ai";
|
|
2
2
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
// Pi's extension loader doesn't virtualize lazy API implementation subpaths yet
|
|
4
|
-
import { openAICompletionsApi } from "@earendil-works/pi-ai/compat";
|
|
4
|
+
import { anthropicMessagesApi, openAICompletionsApi, openAIResponsesApi } 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,8 +18,14 @@ 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
|
-
|
|
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<Api> {
|
|
27
|
+
const loadConfig = options.loadConfig ?? (() => loadInferhubConfig());
|
|
28
|
+
return createProvider<Api>({
|
|
22
29
|
id: PROVIDER_NAME,
|
|
23
30
|
name: PROVIDER_DISPLAY_NAME,
|
|
24
31
|
baseUrl: INFERENCE_BASE_URL,
|
|
@@ -36,15 +43,23 @@ 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 });
|
|
47
|
+
},
|
|
48
|
+
api: {
|
|
49
|
+
// Combos carry the surface their membership selects: anthropic-messages
|
|
50
|
+
// for Claude-only, openai-responses for GPT-only, chat completions for
|
|
51
|
+
// everything else (including mixed membership). createProvider
|
|
52
|
+
// dispatches on model.api.
|
|
53
|
+
"openai-completions": openAICompletionsApi(),
|
|
54
|
+
"openai-responses": openAIResponsesApi(),
|
|
55
|
+
"anthropic-messages": anthropicMessagesApi(),
|
|
40
56
|
},
|
|
41
|
-
api: openAICompletionsApi(),
|
|
42
57
|
});
|
|
43
58
|
}
|
|
44
59
|
|
|
45
60
|
/** Register the InferHub provider and its companion command. */
|
|
46
|
-
export function registerInferhubProvider(pi: InferhubRegistrationPi): void {
|
|
47
|
-
pi.registerProvider(inferhubProvider());
|
|
61
|
+
export function registerInferhubProvider(pi: InferhubRegistrationPi, options?: InferhubRegistrationOptions): void {
|
|
62
|
+
pi.registerProvider(inferhubProvider(options));
|
|
48
63
|
|
|
49
64
|
// pi's registerCommand takes a single command name; `/login inferhub` is
|
|
50
65
|
// provided by pi's auth system through the apiKey login wired above. This
|
package/src/inferhub.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export const PROVIDER_NAME = "inferhub";
|
|
2
2
|
export const PROVIDER_DISPLAY_NAME = "InferHub";
|
|
3
3
|
export const INFERENCE_BASE_URL = "https://api.inferhub.dev/v1";
|
|
4
|
+
/** Anthropic surface lives on the bare host: pi-ai appends /v1/messages itself. */
|
|
5
|
+
export const ANTHROPIC_BASE_URL = "https://api.inferhub.dev";
|
|
4
6
|
export const MGMT_BASE_URL = "https://inferhub.dev/api";
|