@meetopenbot/pi 0.0.3 → 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/README.md CHANGED
@@ -22,13 +22,21 @@ plugins:
22
22
  tools: read,bash,edit,write,grep,find,ls
23
23
  ```
24
24
 
25
+ ## Auth
26
+
27
+ On cloud, `authMode: credits` (the default) uses your workspace credit balance via OpenBot for `openai`, `anthropic`, and `deepseek`. For BYOK, set `authMode: byok` and add the matching provider key under workspace settings (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `DEEPSEEK_API_KEY`, or `GEMINI_API_KEY`).
28
+
29
+ Locally, Pi always uses BYOK from environment variables or `~/.pi/agent/auth.json`.
30
+
31
+ Credits does not cover Google Gemini — Pi uses Gemini's native API, while OpenBot Credits proxies the OpenAI-compatible Gemini surface. Use openai/anthropic/deepseek on credits, or switch to BYOK for Gemini.
32
+
25
33
  ## Configuration
26
34
 
27
35
  | Option | Description |
28
36
  |--------|-------------|
29
- | `cwd` | Working directory for Pi tools and resource discovery. Defaults to the OpenBot channel `cwd`, then `process.cwd()`. |
37
+ | `authMode` | Cloud only: `credits` (default) or `byok`. |
30
38
  | `agentDir` | Pi config directory (credentials, settings, sessions). Default: `~/.pi/agent`. |
31
- | `provider` | Model provider (e.g. `anthropic`, `openai`). |
39
+ | `provider` | Model provider (e.g. `anthropic`, `openai`, `deepseek`). |
32
40
  | `model` | Model id (e.g. `claude-opus-4-5`). |
33
41
  | `thinkingLevel` | Extended thinking: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`. |
34
42
  | `tools` | Comma-separated built-in tools to enable. |
@@ -36,13 +44,7 @@ plugins:
36
44
  | `noTools` | `all` or `builtin` to disable tools. |
37
45
  | `systemPrompt` | Override Pi's system prompt for this agent. |
38
46
 
39
- ## API Keys
40
-
41
- Pi resolves credentials via `AuthStorage` (see [Pi SDK docs](https://pi.dev/docs/latest/sdk)):
42
-
43
- 1. Runtime overrides
44
- 2. `~/.pi/agent/auth.json`
45
- 3. Environment variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.)
47
+ Working directory always comes from the OpenBot channel `cwd`.
46
48
 
47
49
  ## Sessions
48
50
 
@@ -0,0 +1,10 @@
1
+ /** True when this runtime is a platform-managed cloud deployment. */
2
+ export const isCloudMode = () => process.env.OPENBOT_CLOUD_MODE === '1';
3
+ /** Default auth mode: Credits on cloud, BYOK locally. */
4
+ export const defaultAuthMode = () => (isCloudMode() ? 'credits' : 'byok');
5
+ export function resolveAuthMode(config) {
6
+ if (config.authMode === 'byok' || config.authMode === 'credits') {
7
+ return config.authMode;
8
+ }
9
+ return defaultAuthMode();
10
+ }
package/dist/config.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { resolveAuthMode } from './cloud-mode.js';
1
2
  const THINKING_LEVELS = new Set([
2
3
  'off',
3
4
  'minimal',
@@ -8,19 +9,21 @@ const THINKING_LEVELS = new Set([
8
9
  ]);
9
10
  export const resolveConfig = (context, channelCwd) => {
10
11
  const config = context.config;
11
- const thinkingLevel = config.thinkingLevel && THINKING_LEVELS.has(config.thinkingLevel)
12
+ const thinkingLevel = typeof config.thinkingLevel === 'string' && THINKING_LEVELS.has(config.thinkingLevel)
12
13
  ? config.thinkingLevel
13
14
  : undefined;
15
+ const asString = (value) => typeof value === 'string' && value.trim() ? value.trim() : undefined;
14
16
  return {
15
- cwd: channelCwd || config.cwd,
16
- agentDir: config.agentDir,
17
- provider: config.provider?.trim() || undefined,
18
- model: config.model?.trim() || undefined,
17
+ cwd: channelCwd || process.cwd(),
18
+ agentDir: asString(config.agentDir),
19
+ provider: asString(config.provider),
20
+ model: asString(config.model),
19
21
  thinkingLevel,
20
- tools: config.tools?.trim() || undefined,
21
- excludeTools: config.excludeTools?.trim() || undefined,
22
- noTools: config.noTools,
23
- systemPrompt: config.systemPrompt?.trim() || undefined,
22
+ tools: asString(config.tools),
23
+ excludeTools: asString(config.excludeTools),
24
+ noTools: config.noTools === 'all' || config.noTools === 'builtin' ? config.noTools : undefined,
25
+ systemPrompt: asString(config.systemPrompt),
26
+ authMode: resolveAuthMode(config),
24
27
  };
25
28
  };
26
29
  export const parseToolList = (value) => {
@@ -0,0 +1,90 @@
1
+ export const INTEGRATIONS_TOKEN_HEADER = 'x-openbot-integrations-token';
2
+ export const CREDITS_API_KEY_PLACEHOLDER = 'openbot-credits';
3
+ /**
4
+ * Gateway paths matching each Pi provider's default base URL shape:
5
+ * - openai models use `https://api.openai.com/v1`
6
+ * - anthropic models use `https://api.anthropic.com` (SDK appends `/v1`)
7
+ * - deepseek is OpenAI-compat; `/v1` keeps metering on `/v1/chat/completions`
8
+ *
9
+ * Google Gemini is omitted: Pi speaks the native Generative Language API, while
10
+ * the credits gateway only proxies Gemini's OpenAI-compatible surface.
11
+ */
12
+ export const CREDITS_PROVIDERS = [
13
+ { id: 'openai', basePath: 'openai/v1', envVar: 'OPENAI_API_KEY' },
14
+ { id: 'anthropic', basePath: 'anthropic', envVar: 'ANTHROPIC_API_KEY' },
15
+ { id: 'deepseek', basePath: 'deepseek/v1', envVar: 'DEEPSEEK_API_KEY' },
16
+ ];
17
+ const CREDITS_PROVIDER_IDS = new Set(CREDITS_PROVIDERS.map((provider) => provider.id));
18
+ export function isCreditsProvider(provider) {
19
+ return CREDITS_PROVIDER_IDS.has(provider);
20
+ }
21
+ /** Cloud host injects these when routing LLM calls through OpenBot Credits. */
22
+ export function resolveCreditsAuthConfig() {
23
+ const baseUrl = process.env.OPENBOT_INTEGRATIONS_BASE_URL?.trim();
24
+ const token = process.env.OPENBOT_INTEGRATIONS_TOKEN?.trim();
25
+ if (!baseUrl || !token)
26
+ return undefined;
27
+ return { baseUrl: baseUrl.replace(/\/$/, ''), token };
28
+ }
29
+ export function creditsProviderBaseUrl(config, provider) {
30
+ const match = CREDITS_PROVIDERS.find((entry) => entry.id === provider);
31
+ if (!match) {
32
+ throw new Error(`Unsupported credits provider: ${provider}`);
33
+ }
34
+ return `${config.baseUrl}/${match.basePath}`;
35
+ }
36
+ export function applyCreditsProviders(modelRegistry, authStorage, config) {
37
+ const apiKey = config.token || CREDITS_API_KEY_PLACEHOLDER;
38
+ const headers = { [INTEGRATIONS_TOKEN_HEADER]: config.token };
39
+ for (const provider of CREDITS_PROVIDERS) {
40
+ modelRegistry.registerProvider(provider.id, {
41
+ baseUrl: creditsProviderBaseUrl(config, provider.id),
42
+ headers,
43
+ });
44
+ authStorage.setRuntimeApiKey(provider.id, apiKey);
45
+ }
46
+ }
47
+ export function isCreditsErrorMessage(message) {
48
+ const lower = message.toLowerCase();
49
+ return (lower.includes('insufficient_credits') ||
50
+ lower.includes('insufficient credits') ||
51
+ lower.includes('402'));
52
+ }
53
+ export function isAuthErrorMessage(message) {
54
+ const lower = message.toLowerCase();
55
+ return (lower.includes('api key') ||
56
+ lower.includes('apikey') ||
57
+ lower.includes('401') ||
58
+ lower.includes('unauthorized') ||
59
+ lower.includes('authentication') ||
60
+ lower.includes('not logged in') ||
61
+ lower.includes('login'));
62
+ }
63
+ export function isIntegrationsProviderError(message) {
64
+ const lower = message.toLowerCase();
65
+ return (lower.includes('provider api key not configured') ||
66
+ (lower.includes('503') && lower.includes('provider')));
67
+ }
68
+ export const CREDITS_NOT_CONFIGURED_MESSAGE = 'OpenBot Credits is not configured on this runtime. The cloud host must set OPENBOT_INTEGRATIONS_BASE_URL and OPENBOT_INTEGRATIONS_TOKEN (try redeploying the workspace).';
69
+ export const CREDITS_PROVIDER_UNAVAILABLE_MESSAGE = 'OpenBot Credits could not reach the model provider — the platform provider API key is not configured yet. Try again later or switch this agent to BYOK mode.';
70
+ export const CREDITS_AUTH_FAILED_MESSAGE = 'Pi could not authenticate via OpenBot Credits. Check your workspace credit balance in settings, or switch this agent to BYOK mode.';
71
+ export function creditsErrorMessage(message) {
72
+ if (isIntegrationsProviderError(message)) {
73
+ return CREDITS_PROVIDER_UNAVAILABLE_MESSAGE;
74
+ }
75
+ if (isCreditsErrorMessage(message)) {
76
+ return 'Insufficient workspace credits. Add credits in workspace settings or switch this agent to BYOK mode.';
77
+ }
78
+ if (isAuthErrorMessage(message))
79
+ return CREDITS_AUTH_FAILED_MESSAGE;
80
+ return undefined;
81
+ }
82
+ export function remapPiError(message, authMode) {
83
+ if (authMode !== 'credits')
84
+ return message;
85
+ return creditsErrorMessage(message) ?? message;
86
+ }
87
+ export function unsupportedCreditsProviderMessage(provider) {
88
+ return (`OpenBot Credits does not support the "${provider}" provider. ` +
89
+ 'Use openai, anthropic, or deepseek, or switch this agent to BYOK mode.');
90
+ }
package/dist/index.js CHANGED
@@ -1,8 +1,64 @@
1
1
  import { agentOutput, buildDiffWidget, definePlugin, diffFileFromMutationTool, resolveRunDiffFiles, shouldHandleInvoke, snapshotWorkspace, toolTraceWidget, uiWidget, } from '@meetopenbot/plugin-sdk';
2
+ import { isCloudMode } from './cloud-mode.js';
2
3
  import { resolveConfig } from './config.js';
4
+ import { isAuthErrorMessage, remapPiError, } from './credits-auth.js';
3
5
  import { formatPiError, formatToolResult } from './format.js';
4
- import { getOrCreatePiSession } from './session.js';
6
+ import { disposePiSessionsForThread, getOrCreatePiSession } from './session.js';
5
7
  import { streamPiPrompt } from './stream.js';
8
+ const BYOK_PROVIDERS = {
9
+ anthropic: {
10
+ envVar: 'ANTHROPIC_API_KEY',
11
+ label: 'Anthropic',
12
+ placeholder: 'sk-ant-...',
13
+ },
14
+ openai: {
15
+ envVar: 'OPENAI_API_KEY',
16
+ label: 'OpenAI',
17
+ placeholder: 'sk-...',
18
+ },
19
+ deepseek: {
20
+ envVar: 'DEEPSEEK_API_KEY',
21
+ label: 'DeepSeek',
22
+ placeholder: 'sk-...',
23
+ },
24
+ google: {
25
+ envVar: 'GEMINI_API_KEY',
26
+ label: 'Google Gemini',
27
+ placeholder: 'AIza...',
28
+ },
29
+ };
30
+ const resolveByokProvider = (provider) => BYOK_PROVIDERS[provider ?? ''] ?? BYOK_PROVIDERS.openai;
31
+ const buildApiKeyWidget = (agentId, threadId, reason, provider) => {
32
+ const byok = resolveByokProvider(provider);
33
+ return uiWidget({
34
+ agentId,
35
+ threadId,
36
+ widget: {
37
+ kind: 'form',
38
+ widgetId: `pi_api_key_request_${Date.now()}`,
39
+ title: `${byok.label} API Key Required`,
40
+ description: `Pi could not authenticate (${reason}). ` +
41
+ `Provide a ${byok.label} API key to continue. ` +
42
+ 'The key is stored as a workspace variable on your machine and never leaves your local runtime.',
43
+ fields: [
44
+ {
45
+ id: 'apiKey',
46
+ label: 'API Key',
47
+ type: 'password',
48
+ placeholder: byok.placeholder,
49
+ required: true,
50
+ },
51
+ ],
52
+ submitLabel: 'Save API Key',
53
+ metadata: {
54
+ type: 'api_key_request',
55
+ provider: provider && BYOK_PROVIDERS[provider] ? provider : 'openai',
56
+ envVar: byok.envVar,
57
+ source: 'pi',
58
+ },
59
+ },
60
+ });
61
+ };
6
62
  export default definePlugin({
7
63
  id: 'pi',
8
64
  name: 'Pi',
@@ -10,17 +66,23 @@ export default definePlugin({
10
66
  configSchema: {
11
67
  type: 'object',
12
68
  properties: {
13
- cwd: {
14
- type: 'string',
15
- description: 'Working directory for Pi tools and resource discovery.',
16
- },
69
+ ...(isCloudMode()
70
+ ? {
71
+ authMode: {
72
+ type: 'string',
73
+ description: 'Credits — use your workspace credit balance via OpenBot. BYOK — bring your own provider API key.',
74
+ enum: ['credits', 'byok'],
75
+ default: 'credits',
76
+ },
77
+ }
78
+ : {}),
17
79
  agentDir: {
18
80
  type: 'string',
19
81
  description: 'Pi config directory (default: ~/.pi/agent).',
20
82
  },
21
83
  provider: {
22
84
  type: 'string',
23
- description: 'Model provider (e.g. anthropic, openai).',
85
+ description: 'Model provider (e.g. anthropic, openai, deepseek).',
24
86
  },
25
87
  model: {
26
88
  type: 'string',
@@ -67,6 +129,17 @@ export default definePlugin({
67
129
  }
68
130
  const channelCwd = handlerCtx.state.channelDetails?.cwd;
69
131
  const config = resolveConfig(context, channelCwd);
132
+ const fail = function* (message) {
133
+ const mapped = remapPiError(message, config.authMode);
134
+ if (config.authMode === 'byok' && isAuthErrorMessage(message)) {
135
+ yield buildApiKeyWidget(context.agentId, threadId, mapped, config.provider);
136
+ }
137
+ yield agentOutput({
138
+ agentId: context.agentId,
139
+ content: `**Pi error:** ${mapped}`,
140
+ threadId,
141
+ });
142
+ };
70
143
  try {
71
144
  const { session } = await getOrCreatePiSession({
72
145
  config,
@@ -137,11 +210,15 @@ export default definePlugin({
137
210
  }),
138
211
  });
139
212
  break;
140
- case 'text':
213
+ case 'text': {
214
+ const content = chunk.content.startsWith('**Pi error:** ')
215
+ ? `**Pi error:** ${remapPiError(chunk.content.slice('**Pi error:** '.length), config.authMode)}`
216
+ : chunk.content;
141
217
  if (fullTextContent)
142
218
  fullTextContent += '\n\n';
143
- fullTextContent += chunk.content;
219
+ fullTextContent += content;
144
220
  break;
221
+ }
145
222
  }
146
223
  }
147
224
  if (fullTextContent.trim()) {
@@ -167,10 +244,54 @@ export default definePlugin({
167
244
  }
168
245
  }
169
246
  catch (error) {
247
+ yield* fail(formatPiError(error));
248
+ }
249
+ });
250
+ builder.on('client:ui:widget:response', async function* (event, handlerCtx) {
251
+ const { metadata, values, widgetId } = event.data ?? {};
252
+ if (!metadata || metadata.type !== 'api_key_request')
253
+ return;
254
+ if (metadata.source !== 'pi')
255
+ return;
256
+ const apiKey = values?.apiKey;
257
+ if (typeof apiKey !== 'string' || !apiKey)
258
+ return;
259
+ const envVar = typeof metadata.envVar === 'string' ? metadata.envVar : 'OPENAI_API_KEY';
260
+ const storage = context.storage;
261
+ if (!storage) {
262
+ yield agentOutput({
263
+ agentId: context.agentId,
264
+ content: '[pi] no storage available; cannot persist API key.',
265
+ threadId: handlerCtx.state.threadId,
266
+ });
267
+ return;
268
+ }
269
+ try {
270
+ await storage.createVariable({ key: envVar, value: apiKey, secret: true });
271
+ process.env[envVar] = apiKey;
272
+ disposePiSessionsForThread(handlerCtx.state);
273
+ yield uiWidget({
274
+ agentId: context.agentId,
275
+ widget: {
276
+ widgetId: widgetId ?? `pi_api_key_saved_${Date.now()}`,
277
+ kind: 'message',
278
+ title: 'API Key Saved',
279
+ body: `Saved ${envVar} as a workspace variable. You can now continue the conversation.`,
280
+ state: 'submitted',
281
+ },
282
+ });
283
+ yield agentOutput({
284
+ agentId: context.agentId,
285
+ content: `Saved ${envVar} to workspace variables. Re-send your last message to retry.`,
286
+ threadId: handlerCtx.state.threadId,
287
+ });
288
+ }
289
+ catch (error) {
290
+ const errorMessage = error instanceof Error ? error.message : String(error);
170
291
  yield agentOutput({
171
292
  agentId: context.agentId,
172
- content: `**Pi error:** ${formatPiError(error)}`,
173
- threadId,
293
+ content: `[pi] failed to save API key: ${errorMessage}`,
294
+ threadId: handlerCtx.state.threadId,
174
295
  });
175
296
  }
176
297
  });
package/dist/session.js CHANGED
@@ -1,8 +1,12 @@
1
1
  import { AuthStorage, createAgentSession, DefaultResourceLoader, getAgentDir, ModelRegistry, SessionManager, SettingsManager, } from '@earendil-works/pi-coding-agent';
2
2
  import { parseToolList } from './config.js';
3
+ import { applyCreditsProviders, CREDITS_NOT_CONFIGURED_MESSAGE, isCreditsProvider, resolveCreditsAuthConfig, unsupportedCreditsProviderMessage, } from './credits-auth.js';
3
4
  import { persistPiState, readPersistedState } from './state.js';
4
5
  const sessionCache = new Map();
5
- const buildSessionKey = (state) => state.threadId ? `${state.channelId}:${state.threadId}` : state.channelId;
6
+ const buildSessionKey = (state, config) => {
7
+ const scope = state.threadId ? `${state.channelId}:${state.threadId}` : state.channelId;
8
+ return `${scope}:${config.authMode}`;
9
+ };
6
10
  const resolveModel = async (config, modelRegistry) => {
7
11
  if (config.provider && config.model) {
8
12
  return modelRegistry.find(config.provider, config.model) ?? undefined;
@@ -12,24 +16,32 @@ const resolveModel = async (config, modelRegistry) => {
12
16
  };
13
17
  export const getOrCreatePiSession = async (args) => {
14
18
  const { config, state, storage } = args;
15
- const sessionKey = buildSessionKey(state);
19
+ const sessionKey = buildSessionKey(state, config);
16
20
  const cached = sessionCache.get(sessionKey);
17
21
  if (cached)
18
22
  return cached;
23
+ if (config.authMode === 'credits' && config.provider && !isCreditsProvider(config.provider)) {
24
+ throw new Error(unsupportedCreditsProviderMessage(config.provider));
25
+ }
19
26
  const cwd = config.cwd || process.cwd();
20
27
  const agentDir = config.agentDir || getAgentDir();
21
28
  const persisted = readPersistedState(state);
22
29
  const authStorage = AuthStorage.create(`${agentDir}/auth.json`);
23
30
  const modelRegistry = ModelRegistry.create(authStorage, `${agentDir}/models.json`);
31
+ if (config.authMode === 'credits') {
32
+ const credits = resolveCreditsAuthConfig();
33
+ if (!credits) {
34
+ throw new Error(CREDITS_NOT_CONFIGURED_MESSAGE);
35
+ }
36
+ applyCreditsProviders(modelRegistry, authStorage, credits);
37
+ }
24
38
  const model = await resolveModel(config, modelRegistry);
25
39
  const settingsManager = SettingsManager.create(cwd, agentDir);
26
40
  const loader = new DefaultResourceLoader({
27
41
  cwd,
28
42
  agentDir,
29
43
  settingsManager,
30
- ...(config.systemPrompt
31
- ? { systemPromptOverride: () => config.systemPrompt }
32
- : {}),
44
+ ...(config.systemPrompt ? { systemPromptOverride: () => config.systemPrompt } : {}),
33
45
  });
34
46
  await loader.reload();
35
47
  const sessionManager = persisted.sessionFile
@@ -66,3 +78,10 @@ export const disposePiSession = (sessionKey) => {
66
78
  cached.session.dispose();
67
79
  sessionCache.delete(sessionKey);
68
80
  };
81
+ export const disposePiSessionsForThread = (state) => {
82
+ const prefix = state.threadId ? `${state.channelId}:${state.threadId}:` : `${state.channelId}:`;
83
+ for (const key of sessionCache.keys()) {
84
+ if (key.startsWith(prefix))
85
+ disposePiSession(key);
86
+ }
87
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meetopenbot/pi",
3
- "version": "0.0.3",
3
+ "version": "0.1.0",
4
4
  "description": "OpenBot agent plugin powered by the Pi coding agent SDK.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -31,7 +31,7 @@
31
31
  "scripts": {
32
32
  "build": "tsc -p tsconfig.json && node ../../scripts/write-plugin-declaration.mjs",
33
33
  "typecheck": "tsc -p tsconfig.json --noEmit",
34
- "test": "node --experimental-strip-types --test src/diff.test.ts",
34
+ "test": "node --experimental-strip-types --test src/*.test.ts",
35
35
  "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput"
36
36
  }
37
37
  }