pi-yandex-bridge 0.2.5 → 0.2.8

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
@@ -4,13 +4,19 @@ Pi Coding Agent provider bridge for Yandex Cloud AI (YandexGPT).
4
4
 
5
5
  ## Models
6
6
 
7
- | Model | Context | Max output |
8
- | ----------------- | ------- | ---------- |
9
- | YandexGPT Pro 5.1 | 128k | 8k |
10
- | YandexGPT Pro | 128k | 8k |
11
- | YandexGPT Lite | 32k | 4k |
7
+ All models available in your Yandex Cloud folder are fetched dynamically after authentication. Model names are displayed as `name{folder_last5/tag}` — e.g. `yandexgpt-5.1{cffev/l}`.
12
8
 
13
- Unknown models discovered via the API are displayed as `slug {last4ofFolderId}`.
9
+ ## Installation
10
+
11
+ Add to `~/.pi/agent/settings.json`:
12
+
13
+ ```json
14
+ {
15
+ "extensions": ["/path/to/pi-yandex-bridge/dist/index.js"]
16
+ }
17
+ ```
18
+
19
+ Then restart Pi and run `/yalogin`.
14
20
 
15
21
  ## Auth: OAuth (default)
16
22
 
@@ -20,9 +26,9 @@ In the [Yandex Cloud console](https://console.yandex.cloud), select your folder.
20
26
 
21
27
  ### 2. Log in
22
28
 
23
- Run `/yalogin` in Pi. A browser window will open automatically — authorize the app, and the token is captured without any pasting. Pi then prompts for your folder ID.
29
+ Run `/yalogin` in Pi. A browser window opens automatically — authorize the app, and the token is captured without any pasting. Pi then prompts for your folder ID. Available models are fetched immediately after login.
24
30
 
25
- **IAM tokens expire after 12 hours** and are refreshed automatically using the stored OAuth token.
31
+ **IAM tokens expire after 12 hours** and are refreshed automatically using the stored OAuth token. The model list is re-fetched on each refresh.
26
32
 
27
33
  To skip the browser flow, set env vars before starting Pi:
28
34
 
@@ -40,11 +46,4 @@ export YANDEX_API_KEY="your-api-key"
40
46
  export YANDEX_FOLDER_ID="your-folder-id"
41
47
  ```
42
48
 
43
- You can generate an API key in the Yandex Cloud console under **Service accounts → your account → API keys**. The key is shown only once, so copy it immediately.
44
-
45
- Env vars also work for OAuth to skip interactive prompts:
46
-
47
- ```sh
48
- export YANDEX_OAUTH_TOKEN="y0_AgAAAA..."
49
- export YANDEX_FOLDER_ID="b1g..."
50
- ```
49
+ Models are fetched at startup using the API key. You can generate an API key in the [Yandex AI Studio](https://aistudio.yandex.ru) or in the Yandex Cloud console under **Service accounts → your account → API keys**.
package/index.ts CHANGED
@@ -17,6 +17,7 @@ import { readFileSync, writeFileSync } from "fs";
17
17
  import { homedir } from "os";
18
18
  import { join } from "path";
19
19
  import { spawnSync } from "child_process";
20
+ import type { AddressInfo } from "net";
20
21
 
21
22
  import type {
22
23
  ExtensionAPI,
@@ -31,7 +32,7 @@ import type {
31
32
  // ─── constants ────────────────────────────────────────────────────────────────
32
33
 
33
34
  const IAM_TOKEN_URL = "https://iam.api.cloud.yandex.net/iam/v1/tokens";
34
- const AI_BASE_URL = "https://ai.api.cloud.yandex.net/v1";
35
+ const AI_BASE_URL = "https://llm.api.cloud.yandex.net/v1";
35
36
  const AUTH_PATH = join(homedir(), ".pi", "agent", "auth.json");
36
37
 
37
38
  const OAUTH_CLIENT_ID = "0414b7213b22435fa65051f64270584f";
@@ -42,32 +43,6 @@ const OAUTH_URL =
42
43
  `&client_id=${OAUTH_CLIENT_ID}` +
43
44
  `&redirect_uri=${encodeURIComponent(OAUTH_REDIRECT_URI)}`;
44
45
 
45
- // ─── model catalogue ──────────────────────────────────────────────────────────
46
-
47
- const KNOWN_MODELS = [
48
- {
49
- slug: "yandexgpt-5.1",
50
- name: "YandexGPT Pro 5.1",
51
- contextWindow: 128_000,
52
- maxTokens: 8_192,
53
- cost: { input: 8.8, output: 8.8, cacheRead: 0, cacheWrite: 0 },
54
- },
55
- {
56
- slug: "yandexgpt",
57
- name: "YandexGPT Pro",
58
- contextWindow: 128_000,
59
- maxTokens: 8_192,
60
- cost: { input: 8.8, output: 8.8, cacheRead: 0, cacheWrite: 0 },
61
- },
62
- {
63
- slug: "yandexgpt-lite",
64
- name: "YandexGPT Lite",
65
- contextWindow: 32_000,
66
- maxTokens: 4_096,
67
- cost: { input: 2.2, output: 2.2, cacheRead: 0, cacheWrite: 0 },
68
- },
69
- ] as const;
70
-
71
46
  // ─── helpers ──────────────────────────────────────────────────────────────────
72
47
 
73
48
  interface IamTokenResponse {
@@ -75,15 +50,34 @@ interface IamTokenResponse {
75
50
  expiresAt: string;
76
51
  }
77
52
 
78
- /** Turns `gpt://<folderId>/<slug>/latest` into a readable display name.
79
- * Known slugs get their canonical name; unknown ones get `slug {last4ofFolderId}`. */
53
+ // Only applies to yandex gpt:// URIs leaves all other model IDs untouched.
80
54
  function prettyModelName(id: string): string {
81
- const match = id.match(/^gpt:\/\/([^/]+)\/(.+?)(?:\/latest)?$/);
55
+ const match = id.match(/^gpt:\/\/([^/]+)\/(.+?)(?:(\/latest))?$/);
82
56
  if (!match) return id;
83
- const [, folderId, slug] = match;
84
- const known = KNOWN_MODELS.find((m) => m.slug === slug);
85
- if (known) return known.name;
86
- return `${slug} {${folderId.slice(-4)}}`;
57
+ const [, folderId, slug, hasLatest] = match;
58
+ const tag = hasLatest ? "l" : "";
59
+ return `${slug}{${folderId.slice(-5)}${tag ? "/" + tag : ""}}`;
60
+ }
61
+
62
+ function modelEntry(id: string, folderId: string) {
63
+ return {
64
+ id,
65
+ name: prettyModelName(id),
66
+ api: "openai-responses" as const,
67
+ provider: "yandex",
68
+ baseUrl: AI_BASE_URL,
69
+ reasoning: false,
70
+ input: ["text"] as ("text" | "image")[],
71
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
72
+ contextWindow: 128_000,
73
+ maxTokens: 8_192,
74
+ headers: { "OpenAI-Project": folderId },
75
+ compat: {
76
+ supportsDeveloperRole: false,
77
+ supportsReasoningEffort: false,
78
+ maxTokensField: "max_tokens" as const,
79
+ },
80
+ };
87
81
  }
88
82
 
89
83
  function openBrowser(url: string) {
@@ -104,12 +98,10 @@ async function exchangeOAuthForIam(
104
98
  headers: { "Content-Type": "application/json" },
105
99
  body: JSON.stringify({ yandexPassportOauthToken: oauthToken }),
106
100
  });
107
-
108
101
  if (!res.ok) {
109
102
  const text = await res.text().catch(() => res.statusText);
110
103
  throw new Error(`IAM token exchange failed (${res.status}): ${text}`);
111
104
  }
112
-
113
105
  const data = (await res.json()) as IamTokenResponse;
114
106
  return {
115
107
  token: data.iamToken,
@@ -117,25 +109,29 @@ async function exchangeOAuthForIam(
117
109
  };
118
110
  }
119
111
 
120
- function buildModels(folderId: string) {
121
- return KNOWN_MODELS.map((m) => ({
122
- id: `gpt://${folderId}/${m.slug}/latest`,
123
- name: m.name,
124
- api: "openai-responses" as const,
125
- provider: "yandex",
126
- baseUrl: AI_BASE_URL,
127
- reasoning: false,
128
- input: ["text"] as ("text" | "image")[],
129
- cost: m.cost,
130
- contextWindow: m.contextWindow,
131
- maxTokens: m.maxTokens,
132
- headers: { "OpenAI-Project": folderId },
133
- compat: {
134
- supportsDeveloperRole: false,
135
- supportsReasoningEffort: false,
136
- maxTokensField: "max_tokens" as const,
137
- },
138
- }));
112
+ // authHeader: 'Bearer <iam_token>' for OAuth, 'Api-Key <api_key>' for static key
113
+ async function fetchModelIds(
114
+ folderId: string,
115
+ authHeader: string,
116
+ ): Promise<string[]> {
117
+ const ac = new AbortController();
118
+ const timer = setTimeout(() => ac.abort(), 5_000);
119
+ try {
120
+ const res = await fetch(`${AI_BASE_URL}/models`, {
121
+ headers: {
122
+ Authorization: authHeader,
123
+ "OpenAI-Project": folderId,
124
+ },
125
+ signal: ac.signal,
126
+ });
127
+ clearTimeout(timer);
128
+ if (!res.ok) return [];
129
+ const payload = (await res.json()) as { data: Array<{ id: string }> };
130
+ return payload.data.map((m) => m.id);
131
+ } catch {
132
+ clearTimeout(timer);
133
+ return [];
134
+ }
139
135
  }
140
136
 
141
137
  function readAuthJson(): Record<string, unknown> {
@@ -194,11 +190,7 @@ function captureOAuthToken(): Promise<string> {
194
190
 
195
191
  server.on("error", (err: NodeJS.ErrnoException) => {
196
192
  if (err.code === "EADDRINUSE") {
197
- reject(
198
- new Error(
199
- `Port ${OAUTH_CALLBACK_PORT} is already in use. Stop the process using it and try again.`,
200
- ),
201
- );
193
+ reject(new Error(`Port ${OAUTH_CALLBACK_PORT} is already in use.`));
202
194
  } else {
203
195
  reject(new Error(`Local auth server error: ${err.message}`));
204
196
  }
@@ -213,6 +205,7 @@ function captureOAuthToken(): Promise<string> {
213
205
  );
214
206
 
215
207
  server.listen(OAUTH_CALLBACK_PORT, "127.0.0.1", () => {
208
+ void (server.address() as AddressInfo).port;
216
209
  openBrowser(OAUTH_URL);
217
210
  });
218
211
 
@@ -238,13 +231,22 @@ async function yandexLogin(
238
231
  placeholder: "b1g...",
239
232
  });
240
233
  }
234
+
241
235
  const iam = await exchangeOAuthForIam(oauthToken);
242
236
 
237
+ // Fetch available models while we have fresh credentials.
238
+ const modelIds = await fetchModelIds(
239
+ folderId as string,
240
+ `Bearer ${iam.token}`,
241
+ );
242
+
243
243
  return {
244
244
  refresh: oauthToken,
245
245
  access: iam.token,
246
246
  expires: iam.expiresAt,
247
- folderId,
247
+ folderId: folderId as string,
248
+ // Store fetched model IDs so modifyModels can stay synchronous.
249
+ modelIds: JSON.stringify(modelIds),
248
250
  };
249
251
  }
250
252
 
@@ -252,7 +254,17 @@ async function yandexRefreshToken(
252
254
  credentials: OAuthCredentials,
253
255
  ): Promise<OAuthCredentials> {
254
256
  const iam = await exchangeOAuthForIam(credentials.refresh);
255
- return { ...credentials, access: iam.token, expires: iam.expiresAt };
257
+ // Re-fetch models on token refresh to pick up any new models.
258
+ const modelIds = await fetchModelIds(
259
+ credentials.folderId as string,
260
+ `Bearer ${iam.token}`,
261
+ );
262
+ return {
263
+ ...credentials,
264
+ access: iam.token,
265
+ expires: iam.expiresAt,
266
+ modelIds: JSON.stringify(modelIds),
267
+ };
256
268
  }
257
269
 
258
270
  // ─── /yalogin command ─────────────────────────────────────────────────────────
@@ -260,7 +272,6 @@ async function yandexRefreshToken(
260
272
  async function runYaLogin(ctx: ExtensionCommandContext) {
261
273
  try {
262
274
  ctx.ui.notify("Opening browser for Yandex authorization…", "info");
263
-
264
275
  const oauthToken = await captureOAuthToken();
265
276
 
266
277
  let folderId = process.env.YANDEX_FOLDER_ID ?? "";
@@ -276,6 +287,9 @@ async function runYaLogin(ctx: ExtensionCommandContext) {
276
287
  ctx.ui.notify("Exchanging OAuth token for IAM token…", "info");
277
288
  const iam = await exchangeOAuthForIam(oauthToken);
278
289
 
290
+ ctx.ui.notify("Fetching available models…", "info");
291
+ const modelIds = await fetchModelIds(folderId, `Bearer ${iam.token}`);
292
+
279
293
  const auth = readAuthJson();
280
294
  auth.yandex = {
281
295
  type: "oauth",
@@ -283,11 +297,12 @@ async function runYaLogin(ctx: ExtensionCommandContext) {
283
297
  access: iam.token,
284
298
  expires: iam.expiresAt,
285
299
  folderId,
300
+ modelIds: JSON.stringify(modelIds),
286
301
  };
287
302
  writeAuthJson(auth);
288
303
 
289
304
  ctx.ui.notify(
290
- "✓ Yandex credentials saved. Restart Pi to activate the models.",
305
+ `✓ Yandex credentials saved (${modelIds.length} models). Restart Pi to activate.`,
291
306
  "info",
292
307
  );
293
308
  } catch (err) {
@@ -304,72 +319,32 @@ export default async function (pi: ExtensionAPI) {
304
319
  const apiKey = process.env.YANDEX_API_KEY;
305
320
  const folderId = process.env.YANDEX_FOLDER_ID;
306
321
 
307
- // Static API key path — both env vars must be present.
308
322
  if (apiKey && folderId) {
309
- const modelIds = new Set(
310
- KNOWN_MODELS.map((m) => `gpt://${folderId}/${m.slug}/latest`),
311
- );
312
-
313
- try {
314
- const ac = new AbortController();
315
- const timer = setTimeout(() => ac.abort(), 5_000);
316
- const res = await fetch(`${AI_BASE_URL}/models`, {
317
- headers: {
318
- Authorization: `Bearer ${apiKey}`,
319
- "OpenAI-Project": folderId,
320
- },
321
- signal: ac.signal,
322
- });
323
- clearTimeout(timer);
324
- if (res.ok) {
325
- const payload = (await res.json()) as { data: Array<{ id: string }> };
326
- for (const { id } of payload.data) modelIds.add(id);
327
- }
328
- } catch {
329
- /* static list is enough */
330
- }
331
-
332
- const models = [...modelIds].map((id) => {
333
- const slug = id.replace(/^gpt:\/\/[^/]+\//, "").replace(/\/latest$/, "");
334
- const known = KNOWN_MODELS.find((m) => m.slug === slug);
335
- return {
336
- id,
337
- name: prettyModelName(id),
338
- api: "openai-responses" as const,
339
- provider: "yandex",
340
- baseUrl: AI_BASE_URL,
341
- reasoning: false,
342
- input: ["text"] as ("text" | "image")[],
343
- cost: known?.cost ?? {
344
- input: 0,
345
- output: 0,
346
- cacheRead: 0,
347
- cacheWrite: 0,
348
- },
349
- contextWindow: known?.contextWindow ?? 128_000,
350
- maxTokens: known?.maxTokens ?? 8_192,
351
- headers: { "OpenAI-Project": folderId },
352
- compat: {
353
- supportsDeveloperRole: false,
354
- supportsReasoningEffort: false,
355
- maxTokensField: "max_tokens" as const,
356
- },
357
- };
358
- });
359
-
323
+ // Static API key — fetch models immediately, we have credentials.
324
+ const modelIds = await fetchModelIds(folderId, `Api-Key ${apiKey}`);
360
325
  pi.registerProvider("yandex", {
361
326
  name: "Yandex Cloud",
362
327
  baseUrl: AI_BASE_URL,
363
328
  apiKey,
364
329
  api: "openai-responses",
365
- models,
330
+ models: modelIds.map((id) => modelEntry(id, folderId)),
366
331
  } satisfies ProviderConfig);
367
332
  } else {
368
- // OAuth path — seed models from auth.json if folderId is already stored.
369
- let storedFolderId: string | undefined;
333
+ // OAuth path — seed from auth.json if credentials are already stored.
334
+ let seedModels: ReturnType<typeof modelEntry>[] = [];
370
335
  try {
371
- const auth = readAuthJson() as Record<string, { folderId?: string }>;
372
- storedFolderId = auth.yandex?.folderId;
336
+ const auth = readAuthJson() as Record<
337
+ string,
338
+ {
339
+ folderId?: string;
340
+ modelIds?: string;
341
+ }
342
+ >;
343
+ const stored = auth.yandex;
344
+ if (stored?.folderId && stored?.modelIds) {
345
+ const ids = JSON.parse(stored.modelIds) as string[];
346
+ seedModels = ids.map((id) => modelEntry(id, stored.folderId!));
347
+ }
373
348
  } catch {
374
349
  /* auth.json absent or unreadable */
375
350
  }
@@ -378,14 +353,22 @@ export default async function (pi: ExtensionAPI) {
378
353
  name: "Yandex Cloud",
379
354
  baseUrl: AI_BASE_URL,
380
355
  api: "openai-responses",
381
- models: storedFolderId ? buildModels(storedFolderId) : [],
356
+ models: seedModels,
382
357
  oauth: {
383
358
  name: "Yandex Cloud (OAuth)",
384
359
  login: yandexLogin,
385
360
  refreshToken: yandexRefreshToken,
386
361
  getApiKey: (credentials) => credentials.access,
387
- modifyModels: (_, credentials) =>
388
- buildModels(credentials.folderId as string),
362
+ modifyModels: (models, credentials) => {
363
+ const fId = credentials.folderId as string;
364
+ const ids: string[] = credentials.modelIds
365
+ ? (JSON.parse(credentials.modelIds as string) as string[])
366
+ : [];
367
+ return [
368
+ ...models.filter((m) => m.provider !== "yandex"),
369
+ ...ids.map((id) => modelEntry(id, fId)),
370
+ ];
371
+ },
389
372
  },
390
373
  } satisfies ProviderConfig);
391
374
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-yandex-bridge",
3
- "version": "0.2.5",
3
+ "version": "0.2.8",
4
4
  "description": "Pi Coding Agent provider bridge for Yandex Cloud AI (YandexGPT)",
5
5
  "main": "./dist/index.js",
6
6
  "type": "module",
@@ -1,7 +0,0 @@
1
- {
2
- "success": true,
3
- "clones": [],
4
- "duplicatedLines": 0,
5
- "totalLines": 400,
6
- "percentage": 0
7
- }
@@ -1,4 +0,0 @@
1
- {
2
- "timestamp": "2026-05-09T06:22:47.142Z",
3
- "scanDurationMs": 1316
4
- }
@@ -1,21 +0,0 @@
1
- {
2
- "success": true,
3
- "issues": [
4
- {
5
- "type": "file",
6
- "name": "dist/index.d.ts",
7
- "file": "dist/index.d.ts"
8
- }
9
- ],
10
- "unusedExports": [],
11
- "unusedFiles": [
12
- {
13
- "type": "file",
14
- "name": "dist/index.d.ts",
15
- "file": "dist/index.d.ts"
16
- }
17
- ],
18
- "unusedDeps": [],
19
- "unlistedDeps": [],
20
- "summary": "Found 1 issues"
21
- }
@@ -1,3 +0,0 @@
1
- {
2
- "timestamp": "2026-05-09T06:42:14.808Z"
3
- }
@@ -1 +0,0 @@
1
- null
@@ -1,3 +0,0 @@
1
- {
2
- "timestamp": "2026-05-09T06:23:50.558Z"
3
- }
@@ -1,3 +0,0 @@
1
- {
2
- "items": []
3
- }
@@ -1,3 +0,0 @@
1
- {
2
- "timestamp": "2026-05-09T06:22:45.815Z"
3
- }
@@ -1,4 +0,0 @@
1
- {
2
- "signature": "index.ts::🔴 New unresolved imports/deps in modified code (Knip):\n index.ts:29 — unlisted: @earendil-works/pi-ai\n First location: index.ts\n",
3
- "sessionId": "lens-moxfkr7l-92597cdf"
4
- }
@@ -1,3 +0,0 @@
1
- {
2
- "timestamp": "2026-05-08T21:37:51.694Z"
3
- }
@@ -1 +0,0 @@
1
- null
@@ -1,3 +0,0 @@
1
- {
2
- "timestamp": "2026-05-08T21:37:55.621Z"
3
- }