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/AGENTS.md
DELETED
|
@@ -1,146 +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 = 262_144`,
|
|
63
|
-
`MAX_TOKENS_FALLBACK = 131_072` — used when no resolvable member supplies
|
|
64
|
-
the field (last resort after the alias sibling join; see 5). pi-ai's
|
|
65
|
-
`Model` type requires both as numbers; never guess a model's ceiling
|
|
66
|
-
other than via these constants.
|
|
67
|
-
4. **Image rule**: `input: ["text","image"]` only when ≥1 resolvable member
|
|
68
|
-
AND every resolvable member's `modality` includes `image`. Understating
|
|
69
|
-
input is acceptable; overstating sends requests the upstream rejects.
|
|
70
|
-
5. **Alias-member fallback**: bare alias rows (`owned_by: "alias"`, no
|
|
71
|
-
data — observed live 2026-10-15) and alias-kind members first resolve
|
|
72
|
-
through prefixed sibling servings of the same model name (`<prefix>/<name>`
|
|
73
|
-
rows in the /v1/models catalog, min-of applies). Only when no sibling
|
|
74
|
-
carries data either → `input:["text"]`, `reasoning:true`, zero cost,
|
|
75
|
-
fallback limits. `reasoning:true` is deliberate: a false negative hides
|
|
76
|
-
thinking levels (the worse error); a false positive shows an inert knob.
|
|
77
|
-
6. **thinkingLevelMap**: built from the union of resolvable members'
|
|
78
|
-
`reasoning_levels`; each pi level (`minimal|low|medium|high|xhigh|max`)
|
|
79
|
-
maps to itself when advertised, else `null`; `off:"off"`. Set only when
|
|
80
|
-
`reasoning` is true. Alias-only/level-less combos OMIT the map entirely —
|
|
81
|
-
an all-null map would hide every selectable level.
|
|
82
|
-
7. **No key leakage**: the API key appears ONLY in the Authorization header
|
|
83
|
-
and pi's credential store (`auth.json` via pi's own store). Never log,
|
|
84
|
-
print, or persist it anywhere else. Tests inject fetch mocks; a test
|
|
85
|
-
asserting the header is fine, echoing the key in fixtures is not.
|
|
86
|
-
8. **Unconfigured = zero models, no network**: `fetchModels` returns `[]`
|
|
87
|
-
and fetches nothing when auth resolves no key. pi-ai passes BOTH
|
|
88
|
-
stored-credential and env-resolved keys as `context.credential`
|
|
89
|
-
(verified in pi-ai 1.0.4 `dist/models.js` `resolveRefreshCredential`) —
|
|
90
|
-
do not add a second key-resolution path.
|
|
91
|
-
9. **No cross-run catalog persistence**: refresh is always live. A stale
|
|
92
|
-
combo snapshot silently serving the wrong routing is worse than an
|
|
93
|
-
explicit refresh.
|
|
94
|
-
|
|
95
|
-
## Commands
|
|
96
|
-
|
|
97
|
-
```
|
|
98
|
-
npx vitest run # full suite (18 tests, <2s)
|
|
99
|
-
npx tsc --noEmit # typecheck
|
|
100
|
-
npm pack --dry-run # inspect package contents before publishing
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
pi packs the extension from `package.json` `pi.extensions:
|
|
104
|
-
["./src/index.ts"]`. Local dev: `pi -e <repo path>`. Published install:
|
|
105
|
-
`npm:pi-inferhub` in `~/.pi/agent/settings.json` packages (NOT yet
|
|
106
|
-
published npm:pi-inferhub (v0.1.1).
|
|
107
|
-
|
|
108
|
-
## Environment gotchas (learned the hard way)
|
|
109
|
-
|
|
110
|
-
- **Pi packages declare host modules as peerDependencies, never
|
|
111
|
-
dependencies**: `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`,
|
|
112
|
-
`typebox` (and pi-tui if used) go in `peerDependencies` with `"*"` ranges;
|
|
113
|
-
keep `dependencies` empty. Regular dependencies make pi's package installer
|
|
114
|
-
warn about duplicate host-module copies — extensions must bind to the
|
|
115
|
-
copies the pi host already loads. Mirror the same modules in
|
|
116
|
-
`devDependencies` (with real version pins) for the repo's own
|
|
117
|
-
tests/typecheck.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
- **TypeScript 6.0.3**: tsconfig MUST carry `types: ["node"]` — TS 6 dropped
|
|
121
|
-
automatic `@types/*` inclusion; without it `node:module`/`fetch`/`AbortSignal`
|
|
122
|
-
don't resolve and `tsc --noEmit` fails.
|
|
123
|
-
- **typebox 1.3.36**: compiled validators are typed `Validator<TProperties,
|
|
124
|
-
TSchema>` — pass the validator, not the schema, to `fetchJson`. Validation
|
|
125
|
-
error detail lives on `instancePath` (there is no `path` field). Extra
|
|
126
|
-
payload keys pass through by default; do NOT add
|
|
127
|
-
`additionalProperties: false` to wire schemas.
|
|
128
|
-
- **pi-ai `AuthEvent`** is a union; non-info variants carry no `message`.
|
|
129
|
-
Anything typing an interaction callback as `{type: string; message:
|
|
130
|
-
string}` breaks under contravariance — use `{type: string; message?:
|
|
131
|
-
string}`.
|
|
132
|
-
- **Live pinning of wire shapes is allowed and encouraged** when a shape is
|
|
133
|
-
undocumented: run a free listing GET, pin the parsed envelope in a typebox
|
|
134
|
-
validator, and leave a dated comment. Drift surfaces as
|
|
135
|
-
`code: "schema_mismatch"` with `instancePath` — fail-loud by design.
|
|
136
|
-
|
|
137
|
-
## Testing conventions
|
|
138
|
-
|
|
139
|
-
- Typebox schemas live at module scope (`Compile(Type.Object({...}))`).
|
|
140
|
-
- Tabs, ESM with `.js` import specifiers, single-responsibility modules.
|
|
141
|
-
- Tests verify real behavior through public seams (`combosToModels` pure
|
|
142
|
-
function; real `createProvider` refresh path in test/index.test.ts with
|
|
143
|
-
injected `fetchImpl`). No seams into internals.
|
|
144
|
-
- Deleting a brief-verbatim test line, or "fixing" a failing test by editing
|
|
145
|
-
the test to fit the code rather than the contract, is not allowed without
|
|
146
|
-
naming the contract being violated.
|
|
@@ -1,438 +0,0 @@
|
|
|
1
|
-
# InferHub pi provider extension Implementation Plan
|
|
2
|
-
|
|
3
|
-
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
-
|
|
5
|
-
**Goal:** A pi provider extension that exposes the user's InferHub combos as selectable chat models over InferHub's OpenAI-compatible inference surface.
|
|
6
|
-
|
|
7
|
-
**Architecture:** One provider registered via pi-ai's `createProvider` with `api: openAICompletionsApi()` — no custom streaming. `fetchModels` merges `GET inferhub.dev/api/combos` (account combos) with `GET api.inferhub.dev/v1/models` (member capability/pricing metadata) into `Model<"openai-completions">` entries. Auth is `envApiKeyAuth(["INFERHUB_API_KEY"])` plus a `/login inferhub` prompt-validate-store flow.
|
|
8
|
-
|
|
9
|
-
**Tech Stack:** TypeScript (ESM), `@earendil-works/pi-ai` + `@earendil-works/pi-coding-agent` (types), `typebox/compile` for payload validation, vitest.
|
|
10
|
-
|
|
11
|
-
**Spec:** `docs/superpowers/specs/2026-10-06-inferhub-provider-design.md`
|
|
12
|
-
|
|
13
|
-
## Global Constraints
|
|
14
|
-
|
|
15
|
-
- Provider name: `inferhub`. Constants: `INFERENCE_BASE_URL = "https://api.inferhub.dev/v1"`, `MGMT_BASE_URL = "https://inferhub.dev/api"`, `PROVIDER_NAME = "inferhub"`, `PROVIDER_DISPLAY_NAME = "InferHub"`.
|
|
16
|
-
- Fallback limits: `CONTEXT_WINDOW_FALLBACK = 128_000`, `MAX_TOKENS_FALLBACK = 8_192` (exact values from the spec).
|
|
17
|
-
- Model ids on the wire are `combo/<slug>`; bare-name aliases and `prefix/model` ids are never exposed as pi models.
|
|
18
|
-
- Combo-level metadata (caps, selection, filters, sticky, members) is never placed on the pi-ai `Model` object.
|
|
19
|
-
- `thinkingLevelMap` derives from each combo's resolvable members' union of `reasoning_levels` (see Task 3 for the exact rule).
|
|
20
|
-
- `cost`: `cacheRead`/`cacheWrite` are `0` unless a member supplies them (InferHub's `/v1/models` pricing has no cache fields — they stay 0).
|
|
21
|
-
- Empty catalog (zero combos) is a valid registration state: provider registers with zero models, no error.
|
|
22
|
-
- package.json: `name: "pi-inferhub"`, `"type": "module"`, `pi.extensions: ["./src/index.ts"]`; devDeps `vitest`, `typescript`, `@types/node`; deps `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`, `typebox`. Test command: `npx vitest run`. Typecheck: `npx tsc --noEmit`.
|
|
23
|
-
- HTTP helper contract: JSON-validated GET with Bearer auth, `AbortSignal` honored, `User-Agent: pi-inferhub/<version>`.
|
|
24
|
-
- Payload validation philosophy: typebox schemas validate shape and required fields; unknown extra fields pass through (no `additionalProperties: false`).
|
|
25
|
-
- Never log or persist the API key; `auth.ts` returns credentials to pi, never writes them anywhere else.
|
|
26
|
-
|
|
27
|
-
## Review Focus
|
|
28
|
-
|
|
29
|
-
1. **Member-join mismatch**: a combo member whose `model` string is absent from `/v1/models` (upstream vanished between the two GETs). Expected: that member is skipped silently for derivation; the combo still registers on remaining members; empty resolvable set → safe defaults (spec's alias-member fallback rule applies verbatim). Pinned by Task 3 tests.
|
|
30
|
-
2. **Reasoning-capable combo mapped to non-reasoning**: `reasoning: false` when every member lacks `reasoning_levels` — pi then hides thinking controls, and `reasoning_effort` is not sent. If wrong (true when unsupported), requests may 400 upstream. Pinned by Task 3.
|
|
31
|
-
3. **Image-capability overstatement**: `input: ["text","image"]` on a combo with a text-only member sends images the upstream rejects (worse than understating). Pinned by Task 3 (all-members rule).
|
|
32
|
-
4. **Alias-only combos**: member `{"kind":"alias"}` carries no `model`/`modelId` — indexing members by `model` string must not crash. Pinned by Task 3.
|
|
33
|
-
5. **403 `account_blocked` swallowing**: refresh must surface the ban reason verbatim, not a generic "refresh failed". Pinned by Task 1 (fetchJson error extraction preserves `error.code`/`error.message`); Task 5 step 4 exercises the no-credential path end to end.
|
|
34
|
-
|
|
35
|
-
---
|
|
36
|
-
|
|
37
|
-
### Task 1: Project scaffold + HTTP helper
|
|
38
|
-
|
|
39
|
-
**Files:**
|
|
40
|
-
- Create: `package.json`, `tsconfig.json`, `.gitignore`
|
|
41
|
-
- Create: `src/inferhub.ts`
|
|
42
|
-
- Create: `src/http.ts`
|
|
43
|
-
- Test: `test/http.test.ts`
|
|
44
|
-
|
|
45
|
-
**Interfaces:**
|
|
46
|
-
- Consumes: nothing (first task).
|
|
47
|
-
- Produces: `PROVIDER_NAME`, `PROVIDER_DISPLAY_NAME`, `INFERENCE_BASE_URL`, `MGMT_BASE_URL` from `src/inferhub.ts`; `fetchJson<T>(url: string, token: string, schema: TSchema, signal?: AbortSignal): Promise<T>` from `src/http.ts` (typebox-validated; throws `InferhubHttpError` with `status`, `code`, `message` on non-2xx).
|
|
48
|
-
|
|
49
|
-
- [ ] **Step 1: Write package.json, tsconfig, .gitignore**
|
|
50
|
-
|
|
51
|
-
package.json fields pinned by Global Constraints; tsconfig: `module: "NodeNext"`, `moduleResolution: "NodeNext"`, `target: ES2022`, `strict: true`, `noEmit: true`, `skipLibCheck: true`. `.gitignore`: `node_modules/`.
|
|
52
|
-
|
|
53
|
-
- [ ] **Step 2: Write `src/inferhub.ts` with the four constants**
|
|
54
|
-
|
|
55
|
-
```ts
|
|
56
|
-
export const PROVIDER_NAME = "inferhub";
|
|
57
|
-
export const PROVIDER_DISPLAY_NAME = "InferHub";
|
|
58
|
-
export const INFERENCE_BASE_URL = "https://api.inferhub.dev/v1";
|
|
59
|
-
export const MGMT_BASE_URL = "https://inferhub.dev/api";
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
- [ ] **Step 3: Write the failing tests for `fetchJson`**
|
|
63
|
-
|
|
64
|
-
```ts
|
|
65
|
-
import { describe, expect, it, vi } from "vitest";
|
|
66
|
-
import { Type } from "typebox";
|
|
67
|
-
import { Compile } from "typebox/compile";
|
|
68
|
-
import { fetchJson, InferhubHttpError } from "../src/http.js";
|
|
69
|
-
|
|
70
|
-
const Payload = Type.Object({ object: Type.String() });
|
|
71
|
-
const Validator = Compile(Payload);
|
|
72
|
-
|
|
73
|
-
function respond(status: number, body: string, headers: Record<string, string> = {}): Response {
|
|
74
|
-
const h = new Headers({ "content-type": "application/json", ...headers });
|
|
75
|
-
return new Response(body, { status, headers: h });
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
describe("fetchJson", () => {
|
|
79
|
-
it("sends bearer auth and user agent, parses and validates JSON", async () => {
|
|
80
|
-
const fetchMock = vi.fn(async () => respond(200, JSON.stringify({ object: "list" })));
|
|
81
|
-
const result = await fetchJson("https://x/y", "sk-test", Validator, undefined, { fetchImpl: fetchMock as typeof fetch });
|
|
82
|
-
expect(result).toEqual({ object: "list" });
|
|
83
|
-
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
|
84
|
-
expect(url).toBe("https://x/y");
|
|
85
|
-
expect((init.headers as Record<string, string>)["Authorization"]).toBe("Bearer sk-test");
|
|
86
|
-
expect((init.headers as Record<string, string>)["User-Agent"]).toMatch(/^pi-inferhub\//);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it("throws InferhubHttpError with parsed code/message on error JSON", async () => {
|
|
90
|
-
const fetchMock = vi.fn(async () => respond(403, JSON.stringify({ error: { code: "account_blocked", message: "banned: reason" } })));
|
|
91
|
-
await expect(fetchJson("https://x/y", "k", Validator, undefined, { fetchImpl: fetchMock as typeof fetch }))
|
|
92
|
-
.rejects.toMatchObject({ status: 403, code: "account_blocked", message: "banned: reason" });
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it("aborts via signal and surfaces non-JSON error bodies as raw text", async () => {
|
|
96
|
-
const controller = new AbortController();
|
|
97
|
-
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
|
98
|
-
controller.abort();
|
|
99
|
-
throw new DOMException("aborted", "AbortError");
|
|
100
|
-
});
|
|
101
|
-
await expect(fetchJson("https://x/y", "k", Validator, controller.signal, { fetchImpl: fetchMock as typeof fetch }))
|
|
102
|
-
.rejects.toMatchObject({ name: "AbortError" });
|
|
103
|
-
const fetchMock2 = vi.fn(async () => respond(500, "<html>oops</html>"));
|
|
104
|
-
await expect(fetchJson("https://x/y", "k", Validator, undefined, { fetchImpl: fetchMock2 as typeof fetch }))
|
|
105
|
-
.rejects.toMatchObject({ status: 500, code: "http_500" });
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
- [ ] **Step 4: Run tests to verify they fail**
|
|
111
|
-
|
|
112
|
-
Run: `npx vitest run test/http.test.ts`
|
|
113
|
-
Expected: FAIL — `src/http.js` does not exist.
|
|
114
|
-
|
|
115
|
-
- [ ] **Step 5: Implement `src/http.ts`**
|
|
116
|
-
|
|
117
|
-
`fetchJson<T>(url, token, schema, signal?, opts?)` where `opts: { fetchImpl?: typeof fetch }` is an injectable fetch for tests (default `globalThis.fetch`). Sends `Authorization: Bearer <token>`, `User-Agent: pi-inferhub/<version from package.json via createRequire>`, `Accept: application/json`. Non-2xx: parse body as JSON; extract `error.code`/`error.message` when present (OpenAI-style `{error:{message}}` without `code` → `code: "unknown"`), else raw text with `code: "http_<status>"`; throw `InferhubHttpError extends Error` with `status`, `code`, `message`. On 2xx: `schema.Decode`/validate, rethrow typebox validation failures as `InferhubHttpError` with `status: response.status`, `code: "schema_mismatch"`, and the validator's error text as `message`. Abort errors propagate untouched.
|
|
118
|
-
|
|
119
|
-
- [ ] **Step 6: Run tests to verify they pass**
|
|
120
|
-
|
|
121
|
-
Run: `npx vitest run test/http.test.ts`
|
|
122
|
-
Expected: PASS
|
|
123
|
-
|
|
124
|
-
- [ ] **Step 7: Typecheck and commit**
|
|
125
|
-
|
|
126
|
-
```bash
|
|
127
|
-
npx tsc --noEmit
|
|
128
|
-
git add package.json tsconfig.json .gitignore src/inferhub.ts src/http.ts test/http.test.ts
|
|
129
|
-
git commit -m "feat: scaffold + bearer fetchJson helper"
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
### Task 2: Auth — env resolution + `/login inferhub`
|
|
133
|
-
|
|
134
|
-
**Files:**
|
|
135
|
-
- Create: `src/auth.ts`
|
|
136
|
-
- Test: `test/auth.test.ts`
|
|
137
|
-
|
|
138
|
-
**Interfaces:**
|
|
139
|
-
- Consumes: `MGMT_BASE_URL` (Task 1), `fetchJson` (Task 1), pi-ai's `envApiKeyAuth` from `@earendil-works/pi-ai/compat`.
|
|
140
|
-
- Produces: `loginInferhub(interaction: ProviderAuthInteraction): Promise<ApiKeyCredential>` — prompt for the key (`secret` prompt, placeholder `sk-airo-…`), validate via `GET MGMT_BASE_URL + "/me"` (401 → notify + re-prompt, max 3 attempts then throw), return `{ type: "api_key", key }`. Validation uses an injectable fetch (`opts` param, same pattern as `fetchJson`). Also re-exports `envApiKeyAuth("InferHub API key", ["INFERHUB_API_KEY"])` as `inferhubApiKeyAuth`.
|
|
141
|
-
|
|
142
|
-
- [ ] **Step 1: Write failing tests**
|
|
143
|
-
|
|
144
|
-
```ts
|
|
145
|
-
import { describe, expect, it, vi } from "vitest";
|
|
146
|
-
import { loginInferhub } from "../src/auth.js";
|
|
147
|
-
|
|
148
|
-
function interaction(responses: string[], notified: string[] = []) {
|
|
149
|
-
let i = 0;
|
|
150
|
-
return {
|
|
151
|
-
signal: new AbortController().signal,
|
|
152
|
-
prompt: vi.fn(async () => {
|
|
153
|
-
const v = responses[i++];
|
|
154
|
-
if (v === undefined) throw new Error("prompt exhausted");
|
|
155
|
-
return v;
|
|
156
|
-
}),
|
|
157
|
-
notify: (e: { type: string; message: string }) => notified.push(`${e.type}: ${e.message}`),
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
describe("loginInferhub", () => {
|
|
162
|
-
it("validates the key against /me and returns an api_key credential", async () => {
|
|
163
|
-
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) =>
|
|
164
|
-
new Response(JSON.stringify({}), { status: 200, headers: { "content-type": "application/json" } }));
|
|
165
|
-
const result = await loginInferhub(interaction(["sk-airo-abc"]), { fetchImpl: fetchMock as typeof fetch });
|
|
166
|
-
expect(result).toEqual({ type: "api_key", key: "sk-airo-abc" });
|
|
167
|
-
expect((fetchMock.mock.calls[0][1]?.headers as Record<string, string>)["Authorization"]).toBe("Bearer sk-airo-abc");
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it("re-prompts after a 401 and notifies, up to 3 attempts", async () => {
|
|
171
|
-
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ error: { code: "invalid_api_key", message: "bad key" } }), { status: 401 }));
|
|
172
|
-
const notified: string[] = [];
|
|
173
|
-
const result = await loginInferhub(interaction(["sk-bad", "sk-bad", "sk-airo-good"], notified), { fetchImpl: fetchMock as typeof fetch });
|
|
174
|
-
expect(result.key).toBe("sk-airo-good");
|
|
175
|
-
expect(notified.some((n) => n.startsWith("info:"))).toBe(true);
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
it("throws after 3 failed attempts", async () => {
|
|
179
|
-
const fetchMock = vi.fn(async () => new Response("{}", { status: 401 }));
|
|
180
|
-
await expect(loginInferhub(interaction(["a", "b", "c"]), { fetchImpl: fetchMock as typeof fetch })).rejects.toThrow(/three/);
|
|
181
|
-
});
|
|
182
|
-
});
|
|
183
|
-
```
|
|
184
|
-
|
|
185
|
-
- [ ] **Step 2: Run tests to verify they fail**
|
|
186
|
-
|
|
187
|
-
Run: `npx vitest run test/auth.test.ts`
|
|
188
|
-
Expected: FAIL — `src/auth.js` does not exist.
|
|
189
|
-
|
|
190
|
-
- [ ] **Step 3: Implement `src/auth.ts`**
|
|
191
|
-
|
|
192
|
-
`loginInferhub(interaction, opts?)`: loop up to 3 attempts — `interaction.prompt({ type: "secret", message: "InferHub API key", placeholder: "sk-airo-…" })`; trim; `fetchJson(MGMT_BASE_URL + "/me", key, MeSchema, interaction.signal, opts)` where `MeSchema` accepts any object; on success return `{ type: "api_key", key }`; on `InferhubHttpError` with `status: 401`: `interaction.notify({ type: "info", message: "Key rejected (401). Check the key and try again." })`, continue; on 3rd failure throw `new Error("InferHub login failed after three attempts")`. Re-export `inferhubApiKeyAuth = envApiKeyAuth("InferHub API key", ["INFERHUB_API_KEY"])`.
|
|
193
|
-
|
|
194
|
-
- [ ] **Step 4: Run tests to verify they pass**
|
|
195
|
-
|
|
196
|
-
Run: `npx vitest run test/auth.test.ts`
|
|
197
|
-
Expected: PASS
|
|
198
|
-
|
|
199
|
-
- [ ] **Step 5: Typecheck and commit**
|
|
200
|
-
|
|
201
|
-
```bash
|
|
202
|
-
npx tsc --noEmit
|
|
203
|
-
git add src/auth.ts test/auth.test.ts
|
|
204
|
-
git commit -m "feat: /login inferhub prompt-validate flow"
|
|
205
|
-
```
|
|
206
|
-
|
|
207
|
-
### Task 3: Catalog — combo → Model mapping
|
|
208
|
-
|
|
209
|
-
**Files:**
|
|
210
|
-
- Create: `src/catalog.ts`
|
|
211
|
-
- Test: `test/catalog.test.ts`
|
|
212
|
-
|
|
213
|
-
**Interfaces:**
|
|
214
|
-
- Consumes: `INFERENCE_BASE_URL`, `MGMT_BASE_URL` (Task 1), `fetchJson` (Task 1).
|
|
215
|
-
- Produces:
|
|
216
|
-
- `fetchComboCatalog(token: string, signal?: AbortSignal, opts?: { fetchImpl?: typeof fetch }): Promise<Model<"openai-completions">[]>` — GETs `MGMT_BASE_URL + "/combos"` and `INFERENCE_BASE_URL + "/models"`, maps combos to models (see mapping rules below).
|
|
217
|
-
- `combosToModels(combos: Combo[], catalogModels: CatalogModel[]): Model<"openai-completions">[]` — pure mapping function, exported for direct unit testing.
|
|
218
|
-
- Typebox schemas + exported TS types: `ComboSchema`/`Combo` (id, name, slug, members[]), `MemberSchema`/`Member` (`kind: "model"` with `model: string` | `kind: "alias"` with `alias: string`), `CatalogModelSchema`/`CatalogModel` (id, `modality?`, `input_token_limit?`, `max_output_tokens?`, `reasoning_levels?: string[]`, `pricing?: { official_in, official_out }`).
|
|
219
|
-
|
|
220
|
-
Mapping rules (verbatim from the spec):
|
|
221
|
-
- `id = "combo/" + slug`; `name` = combo `name`, falling back to `slug` when empty/whitespace.
|
|
222
|
-
- Resolvable member = member with `kind === "model"` whose `model` string matches a `CatalogModel.id`.
|
|
223
|
-
- `reasoning` = any resolvable member has non-empty `reasoning_levels`; `thinkingLevelMap` = built from the union of advertised levels across resolvable members.
|
|
224
|
-
- `contextWindow` = min of resolvable members' `input_token_limit` (where present), else `CONTEXT_WINDOW_FALLBACK = 128_000`; `maxTokens` = min of `max_output_tokens` (where present), else `MAX_TOKENS_FALLBACK = 8_192`.
|
|
225
|
-
- `input` = `["text", "image"]` only if every resolvable member's `modality` includes `image` (and there is ≥1 resolvable member); else `["text"]`.
|
|
226
|
-
- `cost` = per-direction min of resolvable members' `pricing.official_in`/`official_out`; `cacheRead`/`cacheWrite` = 0. If no resolvable member has pricing, `cost` = all-zeros.
|
|
227
|
-
- Empty resolvable set (alias-only or all-missed-joins): `input: ["text"]`, `reasoning: true`, `cost` zeros, fallback limits.
|
|
228
|
-
- Output order = input combo order; unknown extra payload fields pass validation.
|
|
229
|
-
- Every model also carries `api: "openai-completions"`, `provider: PROVIDER_NAME`, `baseUrl: INFERENCE_BASE_URL`, `reasoning` flag per above.
|
|
230
|
-
|
|
231
|
-
- [ ] **Step 1: Write failing tests**
|
|
232
|
-
|
|
233
|
-
```ts
|
|
234
|
-
import { describe, expect, it } from "vitest";
|
|
235
|
-
import type { CatalogModel, Combo } from "../src/catalog.js";
|
|
236
|
-
import { combosToModels } from "../src/catalog.js";
|
|
237
|
-
import { INFERENCE_BASE_URL, PROVIDER_NAME } from "../src/inferhub.js";
|
|
238
|
-
|
|
239
|
-
const base = {
|
|
240
|
-
input_token_limit: 200_000,
|
|
241
|
-
max_output_tokens: 32_000,
|
|
242
|
-
modality: "text,image",
|
|
243
|
-
reasoning_levels: ["low", "high"],
|
|
244
|
-
pricing: { official_in: 3.0, official_out: 15.0 },
|
|
245
|
-
};
|
|
246
|
-
|
|
247
|
-
const catalog: CatalogModel[] = [
|
|
248
|
-
{ id: "croc/glm-5.3", ...base },
|
|
249
|
-
{ id: "ag/claude-opus", ...base, modality: "text", input_token_limit: 100_000, max_output_tokens: 16_000, pricing: { official_in: 1.0, official_out: 5.0 } },
|
|
250
|
-
];
|
|
251
|
-
|
|
252
|
-
const combo = (members: Combo["members"], overrides: Partial<Combo> = {}): Combo => ({
|
|
253
|
-
id: "c1", name: "My Combo", slug: "my-combo", members, ...overrides,
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
describe("combosToModels", () => {
|
|
257
|
-
it("maps id, name, provider, baseUrl, api", () => {
|
|
258
|
-
const [m] = combosToModels([combo([{ kind: "model", model: "croc/glm-5.3" }])], catalog);
|
|
259
|
-
expect(m.id).toBe("combo/my-combo");
|
|
260
|
-
expect(m.name).toBe("My Combo");
|
|
261
|
-
expect(m.provider).toBe(PROVIDER_NAME);
|
|
262
|
-
expect(m.baseUrl).toBe(INFERENCE_BASE_URL);
|
|
263
|
-
expect(m.api).toBe("openai-completions");
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
it("derives min limits, all-members image rule, and cheapest-member cost", () => {
|
|
267
|
-
const [m] = combosToModels([combo([{ kind: "model", model: "croc/glm-5.3" }, { kind: "model", model: "ag/claude-opus" }])], catalog);
|
|
268
|
-
expect(m.contextWindow).toBe(100_000);
|
|
269
|
-
expect(m.maxTokens).toBe(16_000);
|
|
270
|
-
expect(m.input).toEqual(["text"]); // one text-only member demotes the combo
|
|
271
|
-
expect(m.reasoning).toBe(true);
|
|
272
|
-
expect(m.cost).toEqual({ input: 1.0, output: 5.0, cacheRead: 0, cacheWrite: 0 });
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
it("falls back to slug when name is blank; empty name whitespace counts", () => {
|
|
276
|
-
const [m] = combosToModels([combo([{ kind: "model", model: "croc/glm-5.3" }], { name: " " })], catalog);
|
|
277
|
-
expect(m.name).toBe("my-combo");
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
it("skips unresolvable members and alias members; alias-only combo gets safe defaults", () => {
|
|
281
|
-
const aliasOnly = combosToModels([combo([{ kind: "alias", alias: "glm-5" }])], catalog);
|
|
282
|
-
expect(aliasOnly[0].input).toEqual(["text"]);
|
|
283
|
-
expect(aliasOnly[0].reasoning).toBe(true);
|
|
284
|
-
expect(aliasOnly[0].cost).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
|
|
285
|
-
expect(aliasOnly[0].contextWindow).toBe(128_000);
|
|
286
|
-
expect(aliasOnly[0].maxTokens).toBe(8_192);
|
|
287
|
-
|
|
288
|
-
const partial = combosToModels([combo([{ kind: "model", model: "ghost/gone" }, { kind: "model", model: "croc/glm-5.3" }])], catalog);
|
|
289
|
-
expect(partial[0].contextWindow).toBe(200_000);
|
|
290
|
-
expect(partial[0].input).toEqual(["text", "image"]);
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
it("preserves combo order and handles empty combo list", () => {
|
|
294
|
-
const models = combosToModels([
|
|
295
|
-
combo([{ kind: "model", model: "ag/claude-opus" }], { slug: "b-combo", name: "B" }),
|
|
296
|
-
combo([{ kind: "model", model: "croc/glm-5.3" }], { slug: "a-combo", name: "A" }),
|
|
297
|
-
], catalog);
|
|
298
|
-
expect(models.map((m) => m.id)).toEqual(["combo/b-combo", "combo/a-combo"]);
|
|
299
|
-
expect(combosToModels([], catalog)).toEqual([]);
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
it("reasoning false when no member advertises levels", () => {
|
|
303
|
-
const noLevels: CatalogModel = { id: "x/plain", modality: "text" };
|
|
304
|
-
const [m] = combosToModels([combo([{ kind: "model", model: "x/plain" }])], [noLevels]);
|
|
305
|
-
expect(m.reasoning).toBe(false);
|
|
306
|
-
});
|
|
307
|
-
});
|
|
308
|
-
```
|
|
309
|
-
|
|
310
|
-
- [ ] **Step 2: Run tests to verify they fail**
|
|
311
|
-
|
|
312
|
-
Run: `npx vitest run test/catalog.test.ts`
|
|
313
|
-
Expected: FAIL — `src/catalog.js` does not exist.
|
|
314
|
-
|
|
315
|
-
- [ ] **Step 3: Implement `src/catalog.ts`**
|
|
316
|
-
|
|
317
|
-
Schemas with typebox per Interfaces; `combosToModels` is a pure function implementing the mapping rules; `fetchComboCatalog` runs the two GETs (Promise.all — independent), then `combosToModels(combos, models)`. `thinkingLevelMap`: build from the union of resolvable members' `reasoning_levels` — map each advertised level through `LOW_LEVEL_MAP`-style identity where the level string matches a pi thinking level (`minimal|low|medium|high|xhigh|max`), else `null` for unsupported pi levels. (Concrete: `{ off: "off", minimal: <"minimal"|"null">, low: ..., ... max }` — a level the combo advertises maps to itself, one it doesn't advertise maps to `null`.) Set `thinkingLevelMap` only when `reasoning` is true.
|
|
318
|
-
|
|
319
|
-
- [ ] **Step 4: Run tests to verify they pass**
|
|
320
|
-
|
|
321
|
-
Run: `npx vitest run test/catalog.test.ts`
|
|
322
|
-
Expected: PASS
|
|
323
|
-
|
|
324
|
-
- [ ] **Step 5: Typecheck and commit**
|
|
325
|
-
|
|
326
|
-
```bash
|
|
327
|
-
npx tsc --noEmit
|
|
328
|
-
git add src/catalog.ts test/catalog.test.ts
|
|
329
|
-
git commit -m "feat: combo -> pi model catalog mapping"
|
|
330
|
-
```
|
|
331
|
-
|
|
332
|
-
### Task 4: Provider registration
|
|
333
|
-
|
|
334
|
-
**Files:**
|
|
335
|
-
- Create: `src/index.ts`
|
|
336
|
-
- Test: `test/index.test.ts`
|
|
337
|
-
|
|
338
|
-
**Interfaces:**
|
|
339
|
-
- Consumes: everything above; pi-ai `createProvider`, `envApiKeyAuth` (via `inferhubApiKeyAuth`), `openAICompletionsApi` from `@earendil-works/pi-ai/compat`; `ProviderAuthInteraction` type from pi-ai.
|
|
340
|
-
- Produces: default-export extension factory `export default function (pi: ExtensionAPI): void` that registers the provider and the `/login inferhub` command. `fetchModels(context: RefreshModelsContext): Promise<readonly ProviderModel<"openai-completions">[]>` = `fetchComboCatalog(token, context.signal)` with the token resolved from `context.credential` (`type === "api_key" ? credential.key : undefined`).
|
|
341
|
-
|
|
342
|
-
- [ ] **Step 1: Write failing test**
|
|
343
|
-
|
|
344
|
-
```ts
|
|
345
|
-
import { describe, expect, it, vi } from "vitest";
|
|
346
|
-
import { createProviderHarness } from "./harness.js";
|
|
347
|
-
|
|
348
|
-
describe("extension registration", () => {
|
|
349
|
-
it("registers the inferhub provider with openai-completions api and combo fetch", async () => {
|
|
350
|
-
const { pi, providers, commands } = createProviderHarness();
|
|
351
|
-
const mod = await import("../src/index.js");
|
|
352
|
-
mod.default(pi);
|
|
353
|
-
expect(providers.has("inferhub")).toBe(true);
|
|
354
|
-
expect(commands.has("login inferhub") || commands.has("/login inferhub")).toBe(true);
|
|
355
|
-
});
|
|
356
|
-
});
|
|
357
|
-
```
|
|
358
|
-
|
|
359
|
-
(The harness shape is decided in Step 3 — the test asserts against whatever `createProviderHarness` records; if a mockable `ExtensionAPI` proves impractical, replace this test with a thin unit test over an exported `registerInferhubProvider(pi)` helper that takes a minimal `{ registerProvider, registerCommand }` interface. The assertion contract is: provider registered under `inferhub` with the combo fetch and env+login auth; `/login inferhub` command registered.)
|
|
360
|
-
|
|
361
|
-
- [ ] **Step 2: Run test to verify it fails**
|
|
362
|
-
|
|
363
|
-
Run: `npx vitest run test/index.test.ts`
|
|
364
|
-
Expected: FAIL — `src/index.js` does not exist.
|
|
365
|
-
|
|
366
|
-
- [ ] **Step 3: Implement `src/index.ts`**
|
|
367
|
-
|
|
368
|
-
Wire per the hyper-provider pattern:
|
|
369
|
-
|
|
370
|
-
```ts
|
|
371
|
-
import { createProvider, type ApiKeyCredential } from "@earendil-works/pi-ai";
|
|
372
|
-
import { openAICompletionsApi } from "@earendil-works/pi-ai/compat";
|
|
373
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
374
|
-
```
|
|
375
|
-
|
|
376
|
-
`registerInferhubProvider(pi)` calls `pi.registerProvider(createProvider({ id: PROVIDER_NAME, name: PROVIDER_DISPLAY_NAME, baseUrl: INFERENCE_BASE_URL, auth: { apiKey: inferhubApiKeyAuth }, models: [], fetchModels: async (context) => { const credential = context.credential; const token = credential?.type === "api_key" ? credential.key : undefined; if (!token) return []; return fetchComboCatalog(token, context.signal); }, api: openAICompletionsApi() }))` — note `models: []` baseline with the combo list arriving via `fetchModels`. Register `pi.registerCommand("inferhub-login", ...)` if pi's command API takes a single name (check `ExtensionAPI.registerCommand`'s signature at implementation time; if it supports a single command name only, the command is `inferhub-login`, not `/login inferhub` — the `/login <provider>` surface is provided by pi's auth system via `envApiKeyAuth`'s built-in `login` + our custom `loginInferhub` wired as `auth.apiKey.login`). Wire `inferhubApiKeyAuth`'s `login` to `loginInferhub` so `/login inferhub` routes through the prompt-validate-store flow.
|
|
377
|
-
|
|
378
|
-
- [ ] **Step 4: Run test to verify it passes**
|
|
379
|
-
|
|
380
|
-
Run: `npx vitest run test/index.test.ts`
|
|
381
|
-
Expected: PASS
|
|
382
|
-
|
|
383
|
-
- [ ] **Step 5: Typecheck and commit**
|
|
384
|
-
|
|
385
|
-
```bash
|
|
386
|
-
npx tsc --noEmit
|
|
387
|
-
git add src/index.ts test/index.test.ts
|
|
388
|
-
git commit -m "feat: register inferhub provider"
|
|
389
|
-
```
|
|
390
|
-
|
|
391
|
-
### Task 5: Live smoke test + README
|
|
392
|
-
|
|
393
|
-
**Files:**
|
|
394
|
-
- Create: `README.md`
|
|
395
|
-
- Modify: `~/.pi/agent/settings.json` (add `"pi-inferhub"` to packages — done manually by the user, not in code)
|
|
396
|
-
|
|
397
|
-
**Interfaces:**
|
|
398
|
-
- Consumes: the installed extension.
|
|
399
|
-
- Produces: a verified-live registration; README documenting install, `/login inferhub`, model selection, refresh behavior, and the catalog-derivation rules (link to the spec).
|
|
400
|
-
|
|
401
|
-
- [ ] **Step 1: Install the extension locally for pi to discover**
|
|
402
|
-
|
|
403
|
-
Run: `cd ~/repos/pi-inferhub && npm install && npm pack --dry-run` (verify pack contents), then either register as a discovered extension for the smoke test: `pi -e ~/repos/pi-inferhub` (pi loads the extension via `pi.extensions` in package.json).
|
|
404
|
-
|
|
405
|
-
- [ ] **Step 2: Live catalog refresh against the real account**
|
|
406
|
-
|
|
407
|
-
Run: `pi -e ~/repos/pi-inferhub --list-models 2>&1 | head -50` (or the equivalent list-models invocation; check `pi --help` for the exact flag).
|
|
408
|
-
Expected: `inferhub` provider appears; models are exactly `combo/<slug>` entries matching the account's combos at https://inferhub.dev/dashboard (cross-check combo count and slugs against the dashboard).
|
|
409
|
-
|
|
410
|
-
- [ ] **Step 3: One cheap live inference through a combo**
|
|
411
|
-
|
|
412
|
-
Run: start `pi -e ~/repos/pi-inferhub`, pick the cheapest combo, send "Reply with the single word: ok".
|
|
413
|
-
Expected: a reply streams back; `usage.cost` reflects in the session (verify pi's cost display shows a nonzero estimate; the authoritative check is that the request completes with text output).
|
|
414
|
-
|
|
415
|
-
- [ ] **Step 4: Negative check — no key, clear error**
|
|
416
|
-
|
|
417
|
-
Run: `INFERHUB_API_KEY= pi -e ~/repos/pi-inferhub --list-models` (env unset, no stored credential).
|
|
418
|
-
Expected: provider appears with zero models or a clear auth-required error — never a crash or a silent empty catalog masquerading as "no combos".
|
|
419
|
-
|
|
420
|
-
- [ ] **Step 5: Write README**
|
|
421
|
-
|
|
422
|
-
Document: what the provider exposes (combos only), install (`npm:pi-inferhub` in settings.json packages, or `pi -e` for local dev), auth (env var or `/login inferhub`), refresh semantics (on-demand, no persistence), cost estimate semantics (cheapest-member official rates; real `usage.cost` runs at-or-under), combo fallback limits, and a link to the spec.
|
|
423
|
-
|
|
424
|
-
- [ ] **Step 6: Commit**
|
|
425
|
-
|
|
426
|
-
```bash
|
|
427
|
-
git add README.md
|
|
428
|
-
git commit -m "docs: README"
|
|
429
|
-
```
|
|
430
|
-
|
|
431
|
-
---
|
|
432
|
-
|
|
433
|
-
## Execution notes
|
|
434
|
-
|
|
435
|
-
- pi subagent dispatch policy: do NOT pin models; `~/.pi/agent/settings.json` `subagents.agentOverrides` is authoritative (worker=gateway/glm-5.3, reviewer=gateway/kimi-k3).
|
|
436
|
-
- Task 4's test may collapse to the `registerInferhubProvider` thin-helper variant if mocking `ExtensionAPI` is impractical — the implementer decides and records why in the task report.
|
|
437
|
-
- The full-suite runs (vitest) are fast (< 5s); no watchdog concerns.
|
|
438
|
-
- Live smoke (Task 5) needs the user's real `INFERHUB_API_KEY` or a stored credential; the executor should hand off to the user for the interactive `/login` step if no credential is stored.
|