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 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). `cacheRead`/`cacheWrite` are shown as `0`: the catalog carries
103
- no per-combo cache pricing, and none is fabricated.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-inferhub",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Pi extension for the InferHub inference provider",
5
5
  "license": "MIT",
6
6
  "keywords": [
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<Model<"openai-completions">[]> {
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
- ): Model<"openai-completions">[] {
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
- ): Model<"openai-completions"> {
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
- return buildModel(`combo/${combo.slug}`, name, resolvable);
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[]): Model<"openai-completions">[] {
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: Model<"openai-completions"> = {
220
+ const model: ChatModel = {
182
221
  id,
183
222
  name,
184
- api: "openai-completions",
223
+ api: surface.api,
185
224
  provider: PROVIDER_NAME,
186
- baseUrl: INFERENCE_BASE_URL,
225
+ baseUrl: surface.baseUrl,
187
226
  input: image ? ["text", "image"] : ["text"],
188
227
  cost: {
189
- input: minOf(priced.map((pricing) => pricing.official_in)) ?? 0,
228
+ input: inputRate,
190
229
  output: minOf(priced.map((pricing) => pricing.official_out)) ?? 0,
191
- cacheRead: 0,
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: { maxTokensField: "max_tokens" },
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 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);
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
- /** 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, "");
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>): ThinkingLevelMap {
217
- const map: ThinkingLevelMap = { off: "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<"openai-completions"> {
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: openAICompletionsApi(),
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";