pi-inferhub 0.3.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/README.md +28 -2
- package/package.json +1 -1
- package/src/catalog.ts +93 -28
- package/src/index.ts +13 -5
- package/src/inferhub.ts +2 -0
package/README.md
CHANGED
|
@@ -98,9 +98,14 @@ combo to one model:
|
|
|
98
98
|
only shows an inert one. Where members advertise `reasoning_levels`, Pi's
|
|
99
99
|
levels map onto that union; when none advertise a ladder the map is omitted
|
|
100
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.
|
|
101
106
|
- **Cost** — per-direction minimum of resolvable members' *official* rates
|
|
102
|
-
(see below).
|
|
103
|
-
|
|
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.
|
|
104
109
|
|
|
105
110
|
Member resolution is conservative but forgiving: a member resolves directly
|
|
106
111
|
from `/v1/models` when its prefixed row carries data; otherwise it joins the
|
|
@@ -130,6 +135,27 @@ member's *official* list rates (`official_in` / `official_out`) per direction.
|
|
|
130
135
|
InferHub's marketplace bills below list, so the real `usage.cost` reported per
|
|
131
136
|
request comes in **at or under** the displayed estimate.
|
|
132
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
|
+
|
|
133
159
|
## Design rationale
|
|
134
160
|
|
|
135
161
|
The full design — why combos only, why `/v1/models` supplies member metadata
|
package/package.json
CHANGED
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() }),
|
|
@@ -72,7 +113,7 @@ export async function fetchComboCatalog(
|
|
|
72
113
|
token: string,
|
|
73
114
|
signal?: AbortSignal,
|
|
74
115
|
opts?: FetchCatalogOptions,
|
|
75
|
-
): Promise<
|
|
116
|
+
): Promise<ChatModel[]> {
|
|
76
117
|
const [combos, models] = await Promise.all([
|
|
77
118
|
fetchJson<Combo[]>(`${MGMT_BASE_URL}/combos`, token, CombosValidator, signal, opts),
|
|
78
119
|
fetchJson<Static<typeof ModelsEnvelope>>(`${INFERENCE_BASE_URL}/models`, token, ModelsValidator, signal, opts),
|
|
@@ -87,7 +128,7 @@ export async function fetchComboCatalog(
|
|
|
87
128
|
export function combosToModels(
|
|
88
129
|
combos: Combo[],
|
|
89
130
|
catalogModels: CatalogModel[],
|
|
90
|
-
):
|
|
131
|
+
): ChatModel[] {
|
|
91
132
|
const catalogById = new Map(catalogModels.map((model) => [model.id, model]));
|
|
92
133
|
return combos.map((combo) => comboToModel(combo, catalogById, catalogModels));
|
|
93
134
|
}
|
|
@@ -96,7 +137,7 @@ function comboToModel(
|
|
|
96
137
|
combo: Combo,
|
|
97
138
|
catalogById: Map<string, CatalogModel>,
|
|
98
139
|
catalogModels: CatalogModel[],
|
|
99
|
-
):
|
|
140
|
+
): ChatModel {
|
|
100
141
|
// Alias rows (`owned_by: "alias"`, no data) and alias-kind members resolve
|
|
101
142
|
// through prefixed sibling servings of the same model name; min-of over the
|
|
102
143
|
// siblings bounds the combo exactly like directly-joined members.
|
|
@@ -136,7 +177,8 @@ function comboToModel(
|
|
|
136
177
|
// including level-less resolved members (live-verified 2026-10-18 on
|
|
137
178
|
// combo/deepseek-v4-pro: no advertised levels, honors reasoning_effort).
|
|
138
179
|
const name = combo.name.trim().length > 0 ? combo.name : combo.slug;
|
|
139
|
-
|
|
180
|
+
const surface = selectSurface(combo.members.map((member) => (member.kind === "model" ? member.model : member.alias)));
|
|
181
|
+
return buildModel(`combo/${combo.slug}`, name, resolvable, surface);
|
|
140
182
|
}
|
|
141
183
|
|
|
142
184
|
/**
|
|
@@ -144,7 +186,7 @@ function comboToModel(
|
|
|
144
186
|
* Skipped: alias rows (information-free duplicates of a prefixed serving),
|
|
145
187
|
* image-output rows (not chat models), and blank ids (live shape `cc/`).
|
|
146
188
|
*/
|
|
147
|
-
export function catalogToModels(catalogModels: CatalogModel[]):
|
|
189
|
+
export function catalogToModels(catalogModels: CatalogModel[]): ChatModel[] {
|
|
148
190
|
return catalogModels
|
|
149
191
|
.filter(
|
|
150
192
|
(row) =>
|
|
@@ -159,16 +201,12 @@ export function catalogToModels(catalogModels: CatalogModel[]): Model<"openai-co
|
|
|
159
201
|
)
|
|
160
202
|
.map((row) => {
|
|
161
203
|
const label = row.upstream_label?.trim();
|
|
162
|
-
return buildModel(row.id, label && label.length > 0 ? label : row.id, [row]);
|
|
204
|
+
return buildModel(row.id, label && label.length > 0 ? label : row.id, [row], selectSurface([row.id]));
|
|
163
205
|
});
|
|
164
206
|
}
|
|
165
207
|
|
|
166
208
|
/** 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"> {
|
|
209
|
+
function buildModel(id: string, name: string, sources: CatalogModel[], surface: Surface): ChatModel {
|
|
172
210
|
const advertised = new Set<string>();
|
|
173
211
|
for (const model of sources) {
|
|
174
212
|
for (const level of model.reasoning_levels ?? []) advertised.add(level);
|
|
@@ -177,50 +215,77 @@ function buildModel(
|
|
|
177
215
|
const image = sources.length > 0 && sources.every((model) => (model.modality ?? "").includes("image"));
|
|
178
216
|
|
|
179
217
|
const priced = sources.flatMap((model) => (model.pricing ? [model.pricing] : []));
|
|
218
|
+
const inputRate = minOf(priced.map((pricing) => pricing.official_in)) ?? 0;
|
|
180
219
|
|
|
181
|
-
const model:
|
|
220
|
+
const model: ChatModel = {
|
|
182
221
|
id,
|
|
183
222
|
name,
|
|
184
|
-
api:
|
|
223
|
+
api: surface.api,
|
|
185
224
|
provider: PROVIDER_NAME,
|
|
186
|
-
baseUrl:
|
|
225
|
+
baseUrl: surface.baseUrl,
|
|
187
226
|
input: image ? ["text", "image"] : ["text"],
|
|
188
227
|
cost: {
|
|
189
|
-
input:
|
|
228
|
+
input: inputRate,
|
|
190
229
|
output: minOf(priced.map((pricing) => pricing.official_out)) ?? 0,
|
|
191
|
-
cacheRead:
|
|
230
|
+
cacheRead: roundRate(inputRate * CACHE_READ_MULTIPLIER),
|
|
192
231
|
cacheWrite: 0,
|
|
193
232
|
},
|
|
194
233
|
reasoning: true,
|
|
195
234
|
contextWindow: minPositiveOf(sources.map((source) => source.input_token_limit)) ?? CONTEXT_WINDOW_FALLBACK,
|
|
196
235
|
maxTokens: minPositiveOf(sources.map((source) => source.max_output_tokens)) ?? MAX_TOKENS_FALLBACK,
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
if (surface.api === "openai-completions") {
|
|
197
239
|
// InferHub's [OI]-compatible surface ignores max_completion_tokens (verified
|
|
198
240
|
// live 2026-10-18: a 20-token cap sent that way produced 107 tokens; the
|
|
199
241
|
// same cap as max_tokens stopped at 20). pi-ai auto-detects per-provider
|
|
200
242
|
// and inferhub is not on its list, so pin the field explicitly.
|
|
201
|
-
compat
|
|
202
|
-
}
|
|
243
|
+
model.compat = { maxTokensField: "max_tokens" };
|
|
244
|
+
}
|
|
245
|
+
|
|
203
246
|
// thinkingLevelMap: built only from an advertised union; a level-less union
|
|
204
|
-
// omits the
|
|
205
|
-
// them all `null` would hide the knob entirely.
|
|
206
|
-
if (
|
|
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
|
+
|
|
207
267
|
return model;
|
|
208
268
|
}
|
|
209
269
|
|
|
210
|
-
/**
|
|
211
|
-
function
|
|
212
|
-
return
|
|
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;
|
|
213
273
|
}
|
|
214
274
|
|
|
215
275
|
/** Each pi level maps to itself when the combo advertises it, else `null`. */
|
|
216
|
-
function buildThinkingLevelMap(advertised: ReadonlySet<string
|
|
217
|
-
const map: ThinkingLevelMap = { off
|
|
276
|
+
function buildThinkingLevelMap(advertised: ReadonlySet<string>, off: "off" | "none" | null): ThinkingLevelMap {
|
|
277
|
+
const map: ThinkingLevelMap = { off };
|
|
218
278
|
for (const level of PI_THINKING_LEVELS) {
|
|
219
279
|
map[level] = advertised.has(level) ? level : null;
|
|
220
280
|
}
|
|
221
281
|
return map;
|
|
222
282
|
}
|
|
223
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
|
+
|
|
224
289
|
function minOf(values: Array<number | undefined>): number | undefined {
|
|
225
290
|
const present = values.filter((value): value is number => value !== undefined);
|
|
226
291
|
return present.length > 0 ? Math.min(...present) : undefined;
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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
7
|
import { type InferhubConfig, loadInferhubConfig } from "./config.js";
|
|
@@ -23,9 +23,9 @@ export interface InferhubRegistrationOptions {
|
|
|
23
23
|
loadConfig?: () => InferhubConfig;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
function inferhubProvider(options: InferhubRegistrationOptions = {}): Provider<
|
|
26
|
+
function inferhubProvider(options: InferhubRegistrationOptions = {}): Provider<Api> {
|
|
27
27
|
const loadConfig = options.loadConfig ?? (() => loadInferhubConfig());
|
|
28
|
-
return createProvider({
|
|
28
|
+
return createProvider<Api>({
|
|
29
29
|
id: PROVIDER_NAME,
|
|
30
30
|
name: PROVIDER_DISPLAY_NAME,
|
|
31
31
|
baseUrl: INFERENCE_BASE_URL,
|
|
@@ -45,7 +45,15 @@ function inferhubProvider(options: InferhubRegistrationOptions = {}): Provider<"
|
|
|
45
45
|
if (!token) return [];
|
|
46
46
|
return fetchComboCatalog(token, context.signal, { publishUpstream: loadConfig().publishUpstream });
|
|
47
47
|
},
|
|
48
|
-
api:
|
|
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(),
|
|
56
|
+
},
|
|
49
57
|
});
|
|
50
58
|
}
|
|
51
59
|
|
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";
|