pi-incoai 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sergiu Truta
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 ADDED
@@ -0,0 +1,99 @@
1
+ # pi-incoai
2
+
3
+ [Inco AI](https://inco.ai) (`inco.ai`) model provider extension for the
4
+ [Pi coding agent](https://pi.dev).
5
+
6
+ Inco serves open-weight models (Kimi K3, GLM 5.3, MiniMax M3, DeepSeek V4.1, …)
7
+ over an OpenAI-compatible API at `https://api.inco.ai/v1`. This package registers
8
+ the `incoai` provider, adds the public model catalog, and refreshes it from
9
+ `GET /v1/models` so workspace/private models show up too.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pi install npm:pi-incoai # from npm
15
+ pi install git:github.com/serggiu/pi-incoai # from git
16
+ pi install /path/to/pi-incoai # from a local checkout
17
+ ```
18
+
19
+ or try it for one run without installing:
20
+
21
+ ```bash
22
+ pi -e /path/to/pi-incoai
23
+ ```
24
+
25
+ ## Authenticate
26
+
27
+ 1. Create an API key at <https://platform.inco.ai/keys> (it looks like `sk-inco-…`).
28
+ 2. Either set the environment variable in the shell that starts Pi:
29
+
30
+ ```bash
31
+ export INCO_API_KEY=sk-inco-...
32
+ ```
33
+
34
+ or sign in interactively:
35
+
36
+ ```
37
+ /login
38
+ ```
39
+
40
+ Pick **incoai** → *Sign in with an API key* → paste the key. Pi verifies the
41
+ key against `GET /v1/models` before saving it, then refreshes the model
42
+ catalog. Credentials are stored in `~/.pi/agent/auth.json`.
43
+
44
+ ## Use
45
+
46
+ ```
47
+ /model # search for "incoai"
48
+ ```
49
+
50
+ ```bash
51
+ pi --model incoai/kimi-k3 "…"
52
+ pi --model incoai/glm-5.3:fast "…"
53
+ ```
54
+
55
+ Model IDs keep their `:fast` suffix; Pi resolves the full id before treating a
56
+ trailing `:level` as a thinking level.
57
+
58
+ ## Models
59
+
60
+ The bundled baseline catalog is replaced by the live catalog on refresh. Pricing,
61
+ context length, and reasoning support come from `GET /v1/models`.
62
+
63
+ | Model | Context | Max output | Image input |
64
+ |---|---|---|---|
65
+ | `kimi-k3` | 1.0M | 131K | yes |
66
+ | `kimi-k3:fast` | 1.0M | 131K | yes |
67
+ | `deepseek-v4.1-flash` | 1.0M | 384K | yes |
68
+ | `deepseek-v4.1-flash:fast` | 1.0M | 384K | yes |
69
+ | `glm-5.3` | 1.0M | 131K | no |
70
+ | `glm-5.3:fast` | 1.0M | 131K | no |
71
+ | `glm-5.3-flash` | 1.0M | 131K | yes |
72
+ | `glm-5.3-flash:fast` | 1.0M | 131K | yes |
73
+ | `minimax-m3` | 1.0M | 512K | yes |
74
+ | `minimax-m3:fast` | 1.0M | 512K | yes |
75
+
76
+ ## Notes
77
+
78
+ - Inco is prepaid: requests return `402` when the balance is empty.
79
+ - Inco exposes both OpenAI Chat Completions and Anthropic Messages. This
80
+ extension uses Chat Completions, the surface its catalog metadata
81
+ (`reasoning_effort`, `reasoning_content`) is documented for.
82
+ - Models that advertise `capabilities.reasoning_effort` receive `reasoning_effort`.
83
+ On the others Pi still surfaces streamed `reasoning_content` but sends no effort.
84
+ - `--list-models` and `pi auth check` do not load extensions, so they show the
85
+ bundled baseline catalog only. Opening `/model` triggers the live refresh.
86
+
87
+ ## Development
88
+
89
+ ```text
90
+ extensions/pi-incoai/index.ts # the provider extension
91
+ ```
92
+
93
+ Pi loads extensions from the `extensions/` directory of a package. Edit the file
94
+ and run `/reload` in an active session, or restart Pi. To exercise the catalog
95
+ mapping without a key, `GET https://api.inco.ai/v1/models` is public.
96
+
97
+ ## License
98
+
99
+ MIT
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Inco AI (inco.ai) provider extension for the Pi coding agent.
3
+ *
4
+ * Inco serves open-weight models (Kimi K3, GLM 5.3, MiniMax M3, DeepSeek V4.1,
5
+ * …) through an OpenAI-compatible API at https://api.inco.ai/v1.
6
+ *
7
+ * This extension:
8
+ * - registers the `incoai` provider using pi-ai's OpenAI Chat Completions implementation;
9
+ * - authenticates with `INCO_API_KEY` or `pi`'s `/login incoai` flow, verifying the
10
+ * key against `GET /v1/models` before it is stored;
11
+ * - ships a baseline catalog of the public models, refreshed from `GET /v1/models`
12
+ * (the live catalog also includes private/workspace models when a key is configured).
13
+ */
14
+
15
+ import {
16
+ createProvider,
17
+ envApiKeyAuth,
18
+ openAICompletionsApi,
19
+ type ApiKeyAuth,
20
+ type Model,
21
+ type RefreshModelsContext,
22
+ } from "@earendil-works/pi-ai/compat";
23
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
24
+
25
+ const PROVIDER_ID = "incoai";
26
+ const PROVIDER_NAME = "incoai";
27
+ const BASE_URL = "https://api.inco.ai/v1";
28
+ const MODELS_URL = `${BASE_URL}/models`;
29
+
30
+ const DEFAULT_CONTEXT_WINDOW = 1_000_000;
31
+ const DEFAULT_MAX_TOKENS = 131_072;
32
+
33
+ /** One entry of `GET /v1/models` (fields are optional; the API may omit them). */
34
+ interface IncoCatalogEntry {
35
+ id: string;
36
+ name?: string;
37
+ context_length?: number;
38
+ pricing?: {
39
+ input?: number;
40
+ cached_input?: number;
41
+ output?: number;
42
+ };
43
+ capabilities?: {
44
+ reasoning_effort?: boolean;
45
+ };
46
+ modalities?: {
47
+ input?: string[];
48
+ output?: string[];
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Metadata the Inco catalog does not report, kept per model id.
54
+ *
55
+ * `maxTokens` and text/image support are not part of `GET /v1/models`, so this
56
+ * supplies conservative values and fills gaps when `modalities` is absent.
57
+ */
58
+ interface CuratedModel {
59
+ maxTokens: number;
60
+ input: ("text" | "image")[];
61
+ }
62
+
63
+ const CURATED: Record<string, CuratedModel> = {
64
+ "kimi-k3": { maxTokens: 131_072, input: ["text", "image"] },
65
+ "kimi-k3:fast": { maxTokens: 131_072, input: ["text", "image"] },
66
+ "deepseek-v4.1-flash": { maxTokens: 384_000, input: ["text", "image"] },
67
+ "deepseek-v4.1-flash:fast": { maxTokens: 384_000, input: ["text", "image"] },
68
+ "glm-5.3": { maxTokens: 131_072, input: ["text"] },
69
+ "glm-5.3:fast": { maxTokens: 131_072, input: ["text"] },
70
+ "glm-5.3-flash": { maxTokens: 131_072, input: ["text", "image"] },
71
+ "glm-5.3-flash:fast": { maxTokens: 131_072, input: ["text", "image"] },
72
+ "minimax-m3": { maxTokens: 512_000, input: ["text", "image"] },
73
+ "minimax-m3:fast": { maxTokens: 512_000, input: ["text", "image"] },
74
+ };
75
+
76
+ /**
77
+ * Snapshot of the public catalog, used as the baseline model list so models
78
+ * appear immediately. A successful `GET /v1/models` refresh replaces entries
79
+ * with the same id and adds any workspace-private models.
80
+ */
81
+ const CATALOG_SNAPSHOT: IncoCatalogEntry[] = [
82
+ { id: "kimi-k3", name: "Kimi K3", context_length: 1048576, pricing: { input: 3, cached_input: 0.3, output: 15 }, capabilities: { reasoning_effort: true } },
83
+ { id: "deepseek-v4.1-flash", name: "DeepSeek V4.1 Flash", context_length: 1048576, pricing: { input: 0.3, cached_input: 0.006, output: 1.2 }, capabilities: { reasoning_effort: true } },
84
+ { id: "glm-5.3-flash", name: "GLM 5.3 Flash", context_length: 1048576, pricing: { input: 0.15, cached_input: 0.03, output: 0.5 }, capabilities: { reasoning_effort: false } },
85
+ { id: "kimi-k3:fast", name: "Kimi K3 (Fast)", context_length: 1048576, pricing: { input: 6, cached_input: 0.6, output: 30 }, capabilities: { reasoning_effort: false } },
86
+ { id: "deepseek-v4.1-flash:fast", name: "DeepSeek V4.1 Flash (Fast)", context_length: 1048576, pricing: { input: 0.6, cached_input: 0.012, output: 2.4 }, capabilities: { reasoning_effort: false }, modalities: { input: ["text", "image"] } },
87
+ { id: "glm-5.3", name: "GLM 5.3", context_length: 1048576, pricing: { input: 1.4, cached_input: 0.26, output: 4.4 }, capabilities: { reasoning_effort: true } },
88
+ { id: "glm-5.3:fast", name: "GLM 5.3 (Fast)", context_length: 1048576, pricing: { input: 2.8, cached_input: 0.52, output: 8.8 }, capabilities: { reasoning_effort: false } },
89
+ { id: "glm-5.3-flash:fast", name: "GLM 5.3 Flash (Fast)", context_length: 1048576, pricing: { input: 0.15, cached_input: 0.03, output: 0.5 }, capabilities: { reasoning_effort: false }, modalities: { input: ["text", "image"] } },
90
+ { id: "minimax-m3", name: "MiniMax M3", context_length: 1048576, pricing: { input: 0.3, cached_input: 0.06, output: 1.2 }, capabilities: { reasoning_effort: true }, modalities: { input: ["text", "image"] } },
91
+ { id: "minimax-m3:fast", name: "MiniMax M3 (Fast)", context_length: 1048576, pricing: { input: 0.6, cached_input: 0.12, output: 2.4 }, capabilities: { reasoning_effort: false }, modalities: { input: ["text", "image"] } },
92
+ ];
93
+
94
+ /** Build an error from a failed catalog response, preferring Inco's error envelope. */
95
+ async function catalogError(response: Response): Promise<Error> {
96
+ const status = `HTTP ${response.status} ${response.statusText}`.trim();
97
+ let message = `Inco model catalog request failed: ${status}`;
98
+ try {
99
+ const body = (await response.json()) as { error?: { message?: string; code?: string } };
100
+ const detail = body?.error?.message;
101
+ const code = body?.error?.code;
102
+ if (detail) {
103
+ message = `Inco model catalog request failed: ${status}${code ? ` (${code})` : ""} - ${detail}`;
104
+ }
105
+ } catch {
106
+ // Non-JSON error body; keep the status line.
107
+ }
108
+ return new Error(message);
109
+ }
110
+
111
+ /** Authenticated (or public) catalog request, used by both login and refresh. */
112
+ function requestCatalog(apiKey: string | undefined, signal: AbortSignal): Promise<Response> {
113
+ const headers: Record<string, string> = { Accept: "application/json" };
114
+ if (apiKey) {
115
+ headers.Authorization = `Bearer ${apiKey}`;
116
+ }
117
+ return fetch(MODELS_URL, { headers, signal });
118
+ }
119
+
120
+ function isInputModality(value: string): value is "text" | "image" {
121
+ return value === "text" || value === "image";
122
+ }
123
+
124
+ /** Resolve the input modalities the catalog reports, falling back to curated metadata. */
125
+ function resolveInput(entry: IncoCatalogEntry, curated: CuratedModel | undefined): ("text" | "image")[] {
126
+ const reported = entry.modalities?.input?.filter(isInputModality) ?? [];
127
+ if (reported.length === 0) {
128
+ return curated?.input ?? ["text"];
129
+ }
130
+ return reported.includes("text") ? reported : (["text", ...reported] as ("text" | "image")[]);
131
+ }
132
+
133
+ /** Convert one Inco catalog entry into a Pi chat model. */
134
+ function toModel(entry: IncoCatalogEntry): Model<"openai-completions"> {
135
+ const curated = CURATED[entry.id];
136
+ const reasoningEffort = entry.capabilities?.reasoning_effort === true;
137
+ return {
138
+ id: entry.id,
139
+ name: entry.name || entry.id,
140
+ api: "openai-completions",
141
+ provider: PROVIDER_ID,
142
+ baseUrl: BASE_URL,
143
+ reasoning: true,
144
+ input: resolveInput(entry, curated),
145
+ cost: {
146
+ input: entry.pricing?.input ?? 0,
147
+ output: entry.pricing?.output ?? 0,
148
+ cacheRead: entry.pricing?.cached_input ?? 0,
149
+ cacheWrite: 0,
150
+ },
151
+ contextWindow: entry.context_length ?? DEFAULT_CONTEXT_WINDOW,
152
+ maxTokens: curated?.maxTokens ?? DEFAULT_MAX_TOKENS,
153
+ compat: {
154
+ // Inco is not OpenAI: send only fields its OpenAI-compatible surface documents.
155
+ supportsStore: false,
156
+ supportsDeveloperRole: false,
157
+ supportsReasoningEffort: reasoningEffort,
158
+ supportsUsageInStreaming: true,
159
+ maxTokensField: "max_completion_tokens",
160
+ // Inco returns reasoning in `reasoning_content` and expects it replayed.
161
+ requiresReasoningContentOnAssistantMessages: true,
162
+ thinkingFormat: "openai",
163
+ },
164
+ };
165
+ }
166
+
167
+ /** Fetch the live catalog, authenticating when a key is available. */
168
+ async function fetchIncoModels(context: RefreshModelsContext): Promise<Model<"openai-completions">[]> {
169
+ const apiKey = context.credential?.type === "oauth" ? context.credential.access : context.credential?.key;
170
+ const response = await requestCatalog(apiKey, context.signal);
171
+ if (!response.ok) {
172
+ throw await catalogError(response);
173
+ }
174
+
175
+ const payload = (await response.json()) as { data?: IncoCatalogEntry[] };
176
+ const entries = Array.isArray(payload?.data) ? payload.data : [];
177
+ if (entries.length === 0) {
178
+ throw new Error("Inco model catalog returned no models");
179
+ }
180
+
181
+ return entries.filter((entry) => entry && typeof entry.id === "string").map(toModel);
182
+ }
183
+
184
+ /**
185
+ * Standard api-key auth plus a login that verifies the key against the catalog
186
+ * before Pi persists it, so a typo fails in the login dialog instead of on the
187
+ * first message.
188
+ */
189
+ function incoApiKeyAuth(): ApiKeyAuth {
190
+ const standard = envApiKeyAuth("Inco AI API key", ["INCO_API_KEY"]);
191
+ return {
192
+ ...standard,
193
+ async login(interaction) {
194
+ if (!standard.login) {
195
+ throw new Error("Inco AI API key login is unavailable");
196
+ }
197
+ const credential = await standard.login(interaction);
198
+ const key = credential.key?.trim();
199
+ if (!key) {
200
+ throw new Error("No API key provided");
201
+ }
202
+ interaction.notify({ type: "progress", message: "Verifying API key…" });
203
+ const response = await requestCatalog(key, interaction.signal);
204
+ if (!response.ok) {
205
+ throw await catalogError(response);
206
+ }
207
+ await response.arrayBuffer();
208
+ return { ...credential, key };
209
+ },
210
+ };
211
+ }
212
+
213
+ export default function incoProviderExtension(pi: ExtensionAPI): void {
214
+ pi.registerProvider(
215
+ createProvider({
216
+ id: PROVIDER_ID,
217
+ name: PROVIDER_NAME,
218
+ baseUrl: BASE_URL,
219
+ auth: { apiKey: incoApiKeyAuth() },
220
+ models: CATALOG_SNAPSHOT.map(toModel),
221
+ api: openAICompletionsApi(),
222
+ fetchModels: fetchIncoModels,
223
+ }),
224
+ );
225
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "pi-incoai",
3
+ "version": "0.1.0",
4
+ "description": "Inco AI (inco.ai) model provider extension for the Pi coding agent",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "pi-coding-agent",
9
+ "inco",
10
+ "incoai",
11
+ "inference",
12
+ "provider"
13
+ ],
14
+ "license": "MIT",
15
+ "author": "Sergiu Truta",
16
+ "homepage": "https://github.com/serggiu/pi-incoai#readme",
17
+ "bugs": {
18
+ "url": "https://github.com/serggiu/pi-incoai/issues"
19
+ },
20
+ "type": "module",
21
+ "files": [
22
+ "extensions",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/serggiu/pi-incoai.git"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "peerDependencies": {
34
+ "@earendil-works/pi-ai": "*",
35
+ "@earendil-works/pi-coding-agent": "*"
36
+ }
37
+ }