@oh-my-pi/pi-ai 15.4.3 → 15.5.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/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.5.0] - 2026-05-26
6
+ ### Added
7
+
8
+ - Added `zhipu-coding-plan` provider for Zhipu (智谱) BigModel's domestic coding-plan SKU at `https://open.bigmodel.cn/api/coding/paas/v4`, with dynamic model discovery (`ZHIPU_API_KEY`), zai-format thinking, `reasoning_content` field, and OAuth login flow ([#1340](https://github.com/can1357/oh-my-pi/issues/1340)).
9
+
10
+ ### Removed
11
+
12
+ - Removed the `pi-ai` CLI binary (`packages/ai/src/cli.ts`) and its `bin` entry. Use the in-process equivalent in the omp coding-agent CLI: `omp auth-broker login [provider]`, `omp auth-broker logout [provider]`, and `omp auth-broker list`. The library API (`AuthStorage.login()`, `getOAuthProviders()`, etc.) is unchanged.
13
+
14
+ ### Fixed
15
+
16
+ - Fixed delayed `toolResult` emissions so real tool results are emitted in the correct assistant `toolCall` window after handoff/compaction, preventing out-of-order or orphaned tool results
17
+ - Fixed delayed `toolResult` handling for aborted calls so a late real result is emitted instead of a synthetic `aborted` result for the same `toolCallId`
18
+ - Fixed usage polling to disable credentials when OAuth refresh fails definitively (for example `invalid_grant`) and clear cached last-good usage data so stale reports no longer remain visible
19
+
5
20
  ## [15.4.3] - 2026-05-26
6
21
 
7
22
  ### Fixed
package/README.md CHANGED
@@ -1057,13 +1057,14 @@ Official docs: [Application Default Credentials](https://cloud.google.com/docs/a
1057
1057
 
1058
1058
  ### CLI Login
1059
1059
 
1060
- The quickest way to authenticate:
1060
+ Authenticate via the [`omp`](https://omp.sh) coding-agent CLI, which drives this library's OAuth/API-key flows in-process and persists into `agent.db`:
1061
1061
 
1062
1062
  ```bash
1063
- bunx @oh-my-pi/pi-ai login # interactive provider selection
1064
- bunx @oh-my-pi/pi-ai login anthropic # login to specific provider
1065
- bunx @oh-my-pi/pi-ai login vllm # store vLLM API key (or placeholder for local no-auth)
1066
- bunx @oh-my-pi/pi-ai list # list available providers
1063
+ omp auth-broker login # interactive provider selection
1064
+ omp auth-broker login anthropic # login to a specific provider
1065
+ omp auth-broker login vllm # store vLLM API key (or placeholder for local no-auth)
1066
+ omp auth-broker list # list supported providers
1067
+ omp auth-broker logout # interactive — pick a stored credential to remove
1067
1068
  ```
1068
1069
 
1069
1070
  Credentials are saved to `agent.db` in the agent directory. `/login qianfan` opens the Qianfan console and stores the pasted API key.
@@ -1,4 +1,4 @@
1
- import type { AuthStorage } from "../auth-storage";
1
+ import { type AuthStorage } from "../auth-storage";
2
2
  export interface AuthBrokerRefresherOptions {
3
3
  storage: AuthStorage;
4
4
  /** Refresh credentials expiring within this window. Default 5 min. */
@@ -281,6 +281,7 @@ export type AuthStorageOptions = {
281
281
  */
282
282
  fetchUsageReports?: (signal?: AbortSignal) => Promise<UsageReport[] | null>;
283
283
  };
284
+ export declare function isDefinitiveOAuthFailure(errorMsg: string): boolean;
284
285
  type AuthApiKeyOptions = {
285
286
  baseUrl?: string;
286
287
  modelId?: string;
@@ -58,6 +58,11 @@ export interface DeepSeekModelManagerConfig {
58
58
  baseUrl?: string;
59
59
  }
60
60
  export declare function deepseekModelManagerOptions(config?: DeepSeekModelManagerConfig): ModelManagerOptions<"openai-completions">;
61
+ export interface ZhipuCodingPlanModelManagerConfig {
62
+ apiKey?: string;
63
+ baseUrl?: string;
64
+ }
65
+ export declare function zhipuCodingPlanModelManagerOptions(config?: ZhipuCodingPlanModelManagerConfig): ModelManagerOptions<"openai-completions">;
61
66
  export interface FireworksModelManagerConfig {
62
67
  apiKey?: string;
63
68
  baseUrl?: string;
@@ -48,7 +48,7 @@ export interface ThinkingConfig {
48
48
  /** Provider-specific transport used to encode the selected effort. */
49
49
  mode: ThinkingControlMode;
50
50
  }
51
- export type KnownProvider = "alibaba-coding-plan" | "amazon-bedrock" | "anthropic" | "google" | "google-gemini-cli" | "google-antigravity" | "google-vertex" | "openai" | "openai-codex" | "kimi-code" | "minimax-code" | "minimax-code-cn" | "github-copilot" | "fireworks" | "firepass" | "gitlab-duo" | "cursor" | "deepseek" | "xai" | "groq" | "cerebras" | "openrouter" | "kilo" | "vercel-ai-gateway" | "zai" | "mistral" | "minimax" | "opencode-go" | "opencode-zen" | "synthetic" | "cloudflare-ai-gateway" | "huggingface" | "litellm" | "moonshot" | "nvidia" | "nanogpt" | "ollama" | "ollama-cloud" | "qianfan" | "qwen-portal" | "together" | "venice" | "vllm" | "xiaomi" | "zenmux" | "lm-studio";
51
+ export type KnownProvider = "alibaba-coding-plan" | "amazon-bedrock" | "anthropic" | "google" | "google-gemini-cli" | "google-antigravity" | "google-vertex" | "openai" | "openai-codex" | "kimi-code" | "minimax-code" | "minimax-code-cn" | "github-copilot" | "fireworks" | "firepass" | "gitlab-duo" | "cursor" | "deepseek" | "xai" | "groq" | "cerebras" | "openrouter" | "kilo" | "vercel-ai-gateway" | "zai" | "zhipu-coding-plan" | "mistral" | "minimax" | "opencode-go" | "opencode-zen" | "synthetic" | "cloudflare-ai-gateway" | "huggingface" | "litellm" | "moonshot" | "nvidia" | "nanogpt" | "ollama" | "ollama-cloud" | "qianfan" | "qwen-portal" | "together" | "venice" | "vllm" | "xiaomi" | "zenmux" | "lm-studio";
52
52
  export type Provider = KnownProvider | string;
53
53
  import type { Effort } from "./model-thinking";
54
54
  /** Token budgets for each thinking level (token-based providers only) */
@@ -7,7 +7,7 @@ export type OAuthCredentials = {
7
7
  email?: string;
8
8
  accountId?: string;
9
9
  };
10
- export type OAuthProvider = "alibaba-coding-plan" | "anthropic" | "cerebras" | "cloudflare-ai-gateway" | "cursor" | "deepseek" | "fireworks" | "firepass" | "github-copilot" | "google-gemini-cli" | "google-antigravity" | "gitlab-duo" | "huggingface" | "kimi-code" | "kilo" | "kagi" | "litellm" | "lm-studio" | "minimax-code" | "minimax-code-cn" | "moonshot" | "nvidia" | "nanogpt" | "ollama" | "ollama-cloud" | "openai-codex" | "openai-codex-device" | "opencode-go" | "opencode-zen" | "parallel" | "perplexity" | "qianfan" | "qwen-portal" | "synthetic" | "tavily" | "together" | "venice" | "vercel-ai-gateway" | "vllm" | "xiaomi" | "zenmux" | "zai";
10
+ export type OAuthProvider = "alibaba-coding-plan" | "anthropic" | "cerebras" | "cloudflare-ai-gateway" | "cursor" | "deepseek" | "fireworks" | "firepass" | "github-copilot" | "google-gemini-cli" | "google-antigravity" | "gitlab-duo" | "huggingface" | "kimi-code" | "kilo" | "kagi" | "litellm" | "lm-studio" | "minimax-code" | "minimax-code-cn" | "moonshot" | "nvidia" | "nanogpt" | "ollama" | "ollama-cloud" | "openai-codex" | "openai-codex-device" | "opencode-go" | "opencode-zen" | "parallel" | "perplexity" | "qianfan" | "qwen-portal" | "synthetic" | "tavily" | "together" | "venice" | "vercel-ai-gateway" | "vllm" | "xiaomi" | "zenmux" | "zai" | "zhipu-coding-plan";
11
11
  export type OAuthProviderId = OAuthProvider | (string & {});
12
12
  export type OAuthPrompt = {
13
13
  message: string;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Zhipu Coding Plan login flow.
3
+ *
4
+ * Zhipu BigModel (智谱) provides an OpenAI-compatible API.
5
+ * API docs: https://docs.bigmodel.cn/cn/guide/develop/openai/introduction
6
+ *
7
+ * Simple API key flow:
8
+ * 1. User gets their API key from https://open.bigmodel.cn
9
+ * 2. User pastes the API key into the CLI
10
+ */
11
+ import type { OAuthController } from "./types";
12
+ /**
13
+ * Login to Zhipu Coding Plan.
14
+ *
15
+ * Opens browser to API keys page, prompts user to paste their API key.
16
+ * Returns the API key directly (not OAuthCredentials - this isn't OAuth).
17
+ */
18
+ export declare function loginZhipuCodingPlan(options: OAuthController): Promise<string>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "15.4.3",
4
+ "version": "15.5.0",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -28,9 +28,6 @@
28
28
  ],
29
29
  "main": "./src/index.ts",
30
30
  "types": "./dist/types/index.d.ts",
31
- "bin": {
32
- "pi-ai": "./src/cli.ts"
33
- },
34
31
  "scripts": {
35
32
  "check": "biome check . && bun run check:types",
36
33
  "check:types": "tsgo -p tsconfig.json --noEmit",
@@ -43,7 +40,7 @@
43
40
  "dependencies": {
44
41
  "@anthropic-ai/sdk": "^0.94.0",
45
42
  "@bufbuild/protobuf": "^2.12.0",
46
- "@oh-my-pi/pi-utils": "15.4.3",
43
+ "@oh-my-pi/pi-utils": "15.5.0",
47
44
  "openai": "^6.36.0",
48
45
  "partial-json": "^0.1.7",
49
46
  "zod": "4.4.3"
@@ -10,7 +10,7 @@
10
10
  * snapshot pull surfaces a clean delete on the client.
11
11
  */
12
12
  import { logger } from "@oh-my-pi/pi-utils";
13
- import type { AuthStorage } from "../auth-storage";
13
+ import { type AuthStorage, isDefinitiveOAuthFailure } from "../auth-storage";
14
14
  import { DEFAULT_REFRESH_INTERVAL_MS, DEFAULT_REFRESH_SKEW_MS } from "./types";
15
15
 
16
16
  export interface AuthBrokerRefresherOptions {
@@ -23,16 +23,6 @@ export interface AuthBrokerRefresherOptions {
23
23
  now?: () => number;
24
24
  }
25
25
 
26
- const INVALID_GRANT_REGEX = /invalid_grant|invalid_token|revoked|unauthorized|expired.*refresh|refresh.*expired/i;
27
- const TRANSIENT_REGEX = /timeout|network|fetch failed|ECONNREFUSED/i;
28
- const HTTP_401_403_REGEX = /\b(401|403)\b/;
29
-
30
- function isDefinitiveFailure(errorMsg: string): boolean {
31
- if (INVALID_GRANT_REGEX.test(errorMsg)) return true;
32
- if (HTTP_401_403_REGEX.test(errorMsg) && !TRANSIENT_REGEX.test(errorMsg)) return true;
33
- return false;
34
- }
35
-
36
26
  export interface AuthBrokerRefresherSchedule {
37
27
  enabled: boolean;
38
28
  intervalMs: number;
@@ -113,7 +103,7 @@ export class AuthBrokerRefresher {
113
103
  await this.#storage.refreshCredentialById(id);
114
104
  } catch (error) {
115
105
  const errorMsg = String(error);
116
- if (isDefinitiveFailure(errorMsg)) {
106
+ if (isDefinitiveOAuthFailure(errorMsg)) {
117
107
  logger.warn("auth-broker refresh failed definitively; disabling credential", {
118
108
  id,
119
109
  error: errorMsg,
@@ -414,6 +414,29 @@ const OAUTH_REFRESH_SKEW_MS = 60_000;
414
414
  */
415
415
  const MAX_PENDING_DISABLED_EVENTS = 32;
416
416
 
417
+ /**
418
+ * Classify an OAuth refresh error as a definitive credential failure (the
419
+ * refresh token is dead — re-login required) versus a transient blip
420
+ * (network/5xx — retry next sweep).
421
+ *
422
+ * Anchored at module scope so all three refresh sites — in-stream
423
+ * {@link AuthStorage.getApiKey}, the usage probe in
424
+ * {@link AuthStorage.fetchUsageReports}, and the auth-broker background
425
+ * refresher — disable rows on the same criteria. A drifting classifier
426
+ * between sites would let stale last-good usage reports surface indefinitely
427
+ * while streaming requests correctly tear the row down.
428
+ */
429
+ const OAUTH_DEFINITIVE_FAILURE_REGEX =
430
+ /invalid_grant|invalid_token|revoked|unauthorized|expired.*refresh|refresh.*expired/i;
431
+ const OAUTH_TRANSIENT_FAILURE_REGEX = /timeout|network|fetch failed|ECONNREFUSED/i;
432
+ const OAUTH_HTTP_AUTH_REGEX = /\b(401|403)\b/;
433
+
434
+ export function isDefinitiveOAuthFailure(errorMsg: string): boolean {
435
+ if (OAUTH_DEFINITIVE_FAILURE_REGEX.test(errorMsg)) return true;
436
+ if (OAUTH_HTTP_AUTH_REGEX.test(errorMsg) && !OAUTH_TRANSIENT_FAILURE_REGEX.test(errorMsg)) return true;
437
+ return false;
438
+ }
439
+
417
440
  type UsageCacheEntry<T> = {
418
441
  value: T;
419
442
  expiresAt: number;
@@ -1497,6 +1520,12 @@ export class AuthStorage {
1497
1520
  await saveApiKeyCredential(apiKey);
1498
1521
  return;
1499
1522
  }
1523
+ case "zhipu-coding-plan": {
1524
+ const { loginZhipuCodingPlan } = await import("./utils/oauth/zhipu");
1525
+ const apiKey = await loginZhipuCodingPlan(ctrl);
1526
+ await saveApiKeyCredential(apiKey);
1527
+ return;
1528
+ }
1500
1529
  case "qianfan": {
1501
1530
  const { loginQianfan } = await import("./utils/oauth/qianfan");
1502
1531
  const apiKey = await loginQianfan(ctrl);
@@ -1832,9 +1861,50 @@ export class AuthStorage {
1832
1861
  credential: refreshedCredential,
1833
1862
  };
1834
1863
  } catch (error) {
1864
+ const errorMsg = String(error);
1865
+ // Definitive failure (invalid_grant / 401 not from a network blip) means
1866
+ // the refresh token itself is dead — probing with the original credential
1867
+ // will 401, the catch below will return null, and #fetchUsageCached's
1868
+ // last-good fallback will surface yesterday's report indefinitely
1869
+ // (including its already-elapsed `resetsAt`). CAS-disable the row and
1870
+ // clear the cache so the credential drops out of the report instead of
1871
+ // freezing in place until the user notices and re-logs in.
1872
+ if (isDefinitiveOAuthFailure(errorMsg)) {
1873
+ const credentialId = this.#findStoredCredentialIdForUsageCredential(
1874
+ request.provider,
1875
+ request.credential,
1876
+ );
1877
+ if (credentialId !== undefined) {
1878
+ const entries = this.#getStoredCredentials(request.provider);
1879
+ const index = entries.findIndex(entry => entry.id === credentialId);
1880
+ if (index !== -1) {
1881
+ const disabled = this.#tryDisableCredentialAtIfMatches(
1882
+ request.provider,
1883
+ index,
1884
+ refreshableCredential,
1885
+ `oauth refresh failed during usage probe: ${errorMsg}`,
1886
+ );
1887
+ if (disabled) {
1888
+ this.#usageLogger?.warn(
1889
+ "Usage credential refresh failed definitively; credential disabled",
1890
+ { provider: request.provider, credentialId, error: errorMsg },
1891
+ );
1892
+ // Neutralize last-good for this cache key: write a null
1893
+ // entry with an immediately-elapsed expiry so a future
1894
+ // getStale lookup (e.g. on re-login under the same
1895
+ // account identity) can't replay the stale report.
1896
+ this.#usageCache.set(this.#buildUsageReportCacheKey(request), {
1897
+ value: null,
1898
+ expiresAt: 0,
1899
+ });
1900
+ return null;
1901
+ }
1902
+ }
1903
+ }
1904
+ }
1835
1905
  this.#usageLogger?.debug("Usage credential refresh failed, using original credential", {
1836
1906
  provider: request.provider,
1837
- error: String(error),
1907
+ error: errorMsg,
1838
1908
  });
1839
1909
  }
1840
1910
  }
@@ -2877,9 +2947,7 @@ export class AuthStorage {
2877
2947
  const errorMsg = String(error);
2878
2948
  // Only remove credentials for definitive auth failures
2879
2949
  // Keep credentials for transient errors (network, 5xx) and block temporarily
2880
- const isDefinitiveFailure =
2881
- /invalid_grant|invalid_token|revoked|unauthorized|expired.*refresh|refresh.*expired/i.test(errorMsg) ||
2882
- (/\b(401|403)\b/.test(errorMsg) && !/timeout|network|fetch failed|ECONNREFUSED/i.test(errorMsg));
2950
+ const isDefinitiveFailure = isDefinitiveOAuthFailure(errorMsg);
2883
2951
 
2884
2952
  logger.warn("OAuth token refresh failed", {
2885
2953
  provider,
@@ -42,6 +42,7 @@ import {
42
42
  xaiModelManagerOptions,
43
43
  xiaomiModelManagerOptions,
44
44
  zenmuxModelManagerOptions,
45
+ zhipuCodingPlanModelManagerOptions,
45
46
  } from "./openai-compat";
46
47
  import { cursorModelManagerOptions, zaiModelManagerOptions } from "./special";
47
48
 
@@ -153,6 +154,17 @@ export const PROVIDER_DESCRIPTORS: readonly ProviderDescriptor[] = [
153
154
  config => fireworksModelManagerOptions(config),
154
155
  catalog("Fireworks", ["FIREWORKS_API_KEY"]),
155
156
  ),
157
+ // Fire Pass does not expose a /v1/models endpoint — the API returns HTTP 403
158
+ // on any catalog-discovery request, so dynamic model listing is not feasible.
159
+ //
160
+ // The single model `kimi-k2.6-turbo` is seeded via the `prevModelsJson`
161
+ // fallback in `generate-models.ts`, which preserves entries from the previous
162
+ // catalog snapshot when a provider does not surface them dynamically.
163
+ //
164
+ // IMPORTANT: Do NOT delete the firepass section from models.json. No
165
+ // descriptor here produces that entry dynamically — removing it from
166
+ // models.json would permanently drop the model from the catalog with no
167
+ // automated mechanism to restore it.
156
168
  descriptor("firepass", "kimi-k2.6-turbo", config => firepassModelManagerOptions(config)),
157
169
  descriptor("xai", "grok-4-fast-non-reasoning", config => xaiModelManagerOptions(config)),
158
170
  catalogDescriptor(
@@ -281,6 +293,12 @@ export const PROVIDER_DESCRIPTORS: readonly ProviderDescriptor[] = [
281
293
  catalog("ZenMux", ["ZENMUX_API_KEY"]),
282
294
  ),
283
295
  catalogDescriptor("zai", "glm-5.1", config => zaiModelManagerOptions(config), catalog("zAI", ["ZAI_API_KEY"])),
296
+ catalogDescriptor(
297
+ "zhipu-coding-plan",
298
+ "glm-5.1",
299
+ config => zhipuCodingPlanModelManagerOptions(config),
300
+ catalog("Zhipu Coding Plan", ["ZHIPU_API_KEY"]),
301
+ ),
284
302
  descriptor("github-copilot", "gpt-4o", config => githubCopilotModelManagerOptions(config)),
285
303
  descriptor("google", "gemini-2.5-pro", config => googleModelManagerOptions(config)),
286
304
  descriptor("google-vertex", "gemini-3-pro-preview", config => googleVertexModelManagerOptions(config), {
@@ -40,7 +40,11 @@ export function googleModelManagerOptions(
40
40
 
41
41
  export function googleVertexModelManagerOptions(config?: GoogleVertexModelManagerConfig): ModelManagerOptions {
42
42
  const project = resolveVertexProject(config);
43
+ const hasApiKey = (config?.apiKey ?? Bun.env.GOOGLE_CLOUD_API_KEY ?? "").trim().length > 0;
43
44
  const location = resolveVertexLocation(config);
45
+ if (hasApiKey) {
46
+ return { providerId: "google-vertex" };
47
+ }
44
48
  if (project && location) {
45
49
  return {
46
50
  providerId: "google-vertex",
@@ -54,15 +58,10 @@ export function googleVertexModelManagerOptions(config?: GoogleVertexModelManage
54
58
  }),
55
59
  };
56
60
  }
57
- // API-key-only callers hit `aiplatform.googleapis.com/v1/publishers/google/models/...`
58
- // directly, so the bundled Gemini catalog is the right fallback. Otherwise no
59
- // project, no location, no API key drop the bundled static models so stale
60
- // fallbacks (e.g. `gemini-1.5-*`) cannot leak into `/models` alongside an
61
- // authoritative cached Vertex project catalog on the next refresh.
62
- const hasApiKey = (config?.apiKey ?? Bun.env.GOOGLE_CLOUD_API_KEY ?? "").trim().length > 0;
63
- if (hasApiKey) {
64
- return { providerId: "google-vertex" };
65
- }
61
+ // With neither ADC project+location nor API key auth configured, drop the
62
+ // bundled static catalog so stale fallbacks (e.g. `gemini-1.5-*`) cannot leak
63
+ // into `/models` alongside an authoritative cached Vertex project catalog on
64
+ // the next refresh.
66
65
  return { providerId: "google-vertex", staticModels: [] };
67
66
  }
68
67
  function resolveVertexProject(config?: GoogleVertexModelManagerConfig): string | undefined {
@@ -600,6 +600,70 @@ export function deepseekModelManagerOptions(
600
600
  ): ModelManagerOptions<"openai-completions"> {
601
601
  return createSimpleOpenAICompletionsOptions("deepseek", "https://api.deepseek.com", config);
602
602
  }
603
+ // ---------------------------------------------------------------------------
604
+ // 6.7 Zhipu Coding Plan
605
+ // ---------------------------------------------------------------------------
606
+
607
+ export interface ZhipuCodingPlanModelManagerConfig {
608
+ apiKey?: string;
609
+ baseUrl?: string;
610
+ }
611
+
612
+ export function zhipuCodingPlanModelManagerOptions(
613
+ config?: ZhipuCodingPlanModelManagerConfig,
614
+ ): ModelManagerOptions<"openai-completions"> {
615
+ const apiKey = config?.apiKey;
616
+ const baseUrl = config?.baseUrl ?? "https://open.bigmodel.cn/api/paas/v4";
617
+ return {
618
+ providerId: "zhipu-coding-plan",
619
+ ...(apiKey && {
620
+ fetchDynamicModels: () =>
621
+ fetchOpenAICompatibleModels({
622
+ api: "openai-completions",
623
+ provider: "zhipu-coding-plan",
624
+ baseUrl,
625
+ apiKey,
626
+ mapModel: (
627
+ _entry: OpenAICompatibleModelRecord,
628
+ defaults: Model<"openai-completions">,
629
+ _context: OpenAICompatibleModelMapperContext<"openai-completions">,
630
+ ): Model<"openai-completions"> => {
631
+ const id = defaults.id;
632
+ return {
633
+ ...defaults,
634
+ reasoning: ZHIPU_REASONING_MODELS[id] === true || id.includes("thinking"),
635
+ input: ZHIPU_VISION_PATTERN.test(id) ? (["text", "image"] as const) : ["text"],
636
+ compat: {
637
+ thinkingFormat: "zai",
638
+ reasoningContentField: "reasoning_content",
639
+ supportsDeveloperRole: false,
640
+ },
641
+ };
642
+ },
643
+ }),
644
+ }),
645
+ };
646
+ }
647
+
648
+ // Reasoning-capable GLM models on the BigModel coding-plan SKU. Keep this
649
+ // explicit rather than regex-matching `glm-[45]\.\d` so newly-added integers
650
+ // like `glm-5` / `glm-5-turbo` are covered and unrelated future SKUs (e.g.
651
+ // `glm-5-preview`) do not silently flip into thinking mode.
652
+ const ZHIPU_REASONING_MODELS: Readonly<Record<string, true>> = {
653
+ "glm-4.5": true,
654
+ "glm-4.5-air": true,
655
+ "glm-4.6": true,
656
+ "glm-4.7": true,
657
+ "glm-5": true,
658
+ "glm-5-turbo": true,
659
+ "glm-5.1": true,
660
+ };
661
+
662
+ // Vision-capable GLM models follow the `glm-<N>[.<N>]v[-<variant>]` shape
663
+ // (e.g. `glm-4v`, `glm-4.5v`, `glm-4v-plus`). The previous `id.includes("v")`
664
+ // check matched anything with a `v` — including the non-vision `glm-5-preview`.
665
+ const ZHIPU_VISION_PATTERN = /^glm-[45](?:\.\d+)?v(?:-|$)/;
666
+
603
667
  // ---------------------------------------------------------------------------
604
668
  // 7.5 Fireworks
605
669
  // ---------------------------------------------------------------------------
@@ -2184,6 +2248,14 @@ const MODELS_DEV_PROVIDER_DESCRIPTORS_CODING_PLANS: readonly ModelsDevProviderDe
2184
2248
  },
2185
2249
  },
2186
2250
  ),
2251
+ // --- Zhipu Coding Plan ---
2252
+ openAiCompletionsDescriptor("zhipu-coding-plan", "zhipu-coding-plan", "https://open.bigmodel.cn/api/paas/v4", {
2253
+ compat: {
2254
+ thinkingFormat: "zai",
2255
+ reasoningContentField: "reasoning_content",
2256
+ supportsDeveloperRole: false,
2257
+ },
2258
+ }),
2187
2259
  ];
2188
2260
 
2189
2261
  const filterActiveToolCallModels = (_id: string, m: ModelsDevModel): boolean => {
@@ -51,6 +51,7 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB
51
51
 
52
52
  const isCerebras = provider === "cerebras" || baseUrl.includes("cerebras.ai");
53
53
  const isZai = provider === "zai" || baseUrl.includes("api.z.ai");
54
+ const isZhipu = provider === "zhipu-coding-plan" || baseUrl.includes("open.bigmodel.cn");
54
55
  const isKilo = provider === "kilo" || baseUrl.includes("api.kilo.ai");
55
56
  const isKimiModel = model.id.includes("moonshotai/kimi") || /(^|\/)kimi[-.]/i.test(model.id);
56
57
  const isMoonshotKimi =
@@ -97,6 +98,7 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB
97
98
  baseUrl.includes("fireworks.ai") ||
98
99
  isAlibaba ||
99
100
  isZai ||
101
+ isZhipu ||
100
102
  isKilo ||
101
103
  isQwen ||
102
104
  provider === "opencode-zen" ||
@@ -156,6 +158,7 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB
156
158
  isMistral ||
157
159
  isGrok ||
158
160
  isZai ||
161
+ isZhipu ||
159
162
  isCopilotHost ||
160
163
  isZenmuxHost);
161
164
 
@@ -194,7 +197,7 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB
194
197
  // OpenAI's reasoning-API surface.
195
198
  supportsDeveloperRole: isOpenAIHost || isAzureHost,
196
199
  supportsMultipleSystemMessages: supportsMultipleSystemMessagesDefault,
197
- supportsReasoningEffort: !isGrok && !isZai,
200
+ supportsReasoningEffort: !isGrok && !isZai && !isZhipu,
198
201
  reasoningEffortMap,
199
202
  supportsUsageInStreaming: !isCerebras,
200
203
  disableReasoningOnForcedToolChoice: isKimiModel || isAnthropicModel,
@@ -206,7 +209,7 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB
206
209
  requiresThinkingAsText: isMistral,
207
210
  requiresMistralToolIds: isMistral,
208
211
  thinkingFormat:
209
- isZai || isMoonshotKimi
212
+ isZai || isZhipu || isMoonshotKimi
210
213
  ? "zai"
211
214
  : provider === "openrouter" || baseUrl.includes("openrouter.ai")
212
215
  ? "openrouter"
@@ -11,9 +11,9 @@ import type {
11
11
  } from "../types";
12
12
 
13
13
  const enum ToolCallStatus {
14
- /** Tool call has received a result (real or synthetic for orphan) */
14
+ /** A tool result has already been emitted for this tool call; later duplicates must be skipped. */
15
15
  Resolved = 1,
16
- /** Tool call was from an aborted message; synthetic result injected, skip real results */
16
+ /** A synthetic aborted result was emitted; later real results must be skipped. */
17
17
  Aborted = 2,
18
18
  }
19
19
 
@@ -131,9 +131,12 @@ export function transformMessages<TApi extends Api>(
131
131
  }
132
132
  return msg;
133
133
  });
134
- const realToolResultIds = new Set(
135
- transformed.filter((msg): msg is ToolResultMessage => msg.role === "toolResult").map(msg => msg.toolCallId),
136
- );
134
+ const realToolResultsById = new Map<string, ToolResultMessage>();
135
+ for (const msg of transformed) {
136
+ if (msg.role === "toolResult" && !realToolResultsById.has(msg.toolCallId)) {
137
+ realToolResultsById.set(msg.toolCallId, msg);
138
+ }
139
+ }
137
140
 
138
141
  // Anthropic rejects `tool_result` blocks whose `tool_use_id` does not appear in a prior
139
142
  // `tool_use` block. After handoff/compaction folds an assistant turn into a summary
@@ -148,29 +151,35 @@ export function transformMessages<TApi extends Api>(
148
151
  }
149
152
  }
150
153
 
151
- // Second pass: insert synthetic empty tool results for orphaned tool calls
152
- // and preserve aborted/errored tool results when they were already persisted.
154
+ // Second pass: ensure each surviving assistant tool call is immediately
155
+ // followed by exactly one corresponding tool result.
153
156
  const result: Message[] = [];
154
157
  let pendingToolCalls: ToolCall[] = [];
155
158
  let pendingAbortedToolCalls = new Map<string, ToolCall>();
156
159
  let pendingAbortedTimestamp: number | undefined;
157
- // Track tool call status: whether resolved (has result) or aborted (synthetic result injected, skip later real results)
160
+ // Track which tool calls already have an emitted result so delayed/duplicate
161
+ // toolResult messages cannot create a second provider-visible result.
158
162
  const toolCallStatus = new Map<string, ToolCallStatus>();
159
163
 
160
164
  const flushPendingToolCalls = (timestamp: number): void => {
161
165
  if (pendingToolCalls.length === 0) return;
162
166
  for (const tc of pendingToolCalls) {
163
- if (!toolCallStatus.has(tc.id) && !realToolResultIds.has(tc.id)) {
164
- result.push({
165
- role: "toolResult",
166
- toolCallId: tc.id,
167
- toolName: tc.name,
168
- content: [{ type: "text", text: "No result provided" }],
169
- isError: true,
170
- timestamp,
171
- } as ToolResultMessage);
167
+ if (toolCallStatus.has(tc.id)) continue;
168
+ const realToolResult = realToolResultsById.get(tc.id);
169
+ if (realToolResult) {
170
+ result.push(realToolResult);
172
171
  toolCallStatus.set(tc.id, ToolCallStatus.Resolved);
172
+ continue;
173
173
  }
174
+ result.push({
175
+ role: "toolResult",
176
+ toolCallId: tc.id,
177
+ toolName: tc.name,
178
+ content: [{ type: "text", text: "No result provided" }],
179
+ isError: true,
180
+ timestamp,
181
+ } as ToolResultMessage);
182
+ toolCallStatus.set(tc.id, ToolCallStatus.Resolved);
174
183
  }
175
184
  pendingToolCalls = [];
176
185
  };
@@ -178,17 +187,22 @@ export function transformMessages<TApi extends Api>(
178
187
  const flushPendingAbortedToolCalls = (): void => {
179
188
  if (pendingAbortedTimestamp === undefined) return;
180
189
  for (const tc of pendingAbortedToolCalls.values()) {
181
- if (!toolCallStatus.has(tc.id)) {
182
- result.push({
183
- role: "toolResult",
184
- toolCallId: tc.id,
185
- toolName: tc.name,
186
- content: [{ type: "text", text: "aborted" }],
187
- isError: true,
188
- timestamp: pendingAbortedTimestamp,
189
- } as ToolResultMessage);
190
- toolCallStatus.set(tc.id, ToolCallStatus.Aborted);
190
+ if (toolCallStatus.has(tc.id)) continue;
191
+ const realToolResult = realToolResultsById.get(tc.id);
192
+ if (realToolResult) {
193
+ result.push(realToolResult);
194
+ toolCallStatus.set(tc.id, ToolCallStatus.Resolved);
195
+ continue;
191
196
  }
197
+ result.push({
198
+ role: "toolResult",
199
+ toolCallId: tc.id,
200
+ toolName: tc.name,
201
+ content: [{ type: "text", text: "aborted" }],
202
+ isError: true,
203
+ timestamp: pendingAbortedTimestamp,
204
+ } as ToolResultMessage);
205
+ toolCallStatus.set(tc.id, ToolCallStatus.Aborted);
192
206
  }
193
207
  result.push({
194
208
  role: "developer",
@@ -236,8 +250,9 @@ export function transformMessages<TApi extends Api>(
236
250
  }
237
251
 
238
252
  if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") {
239
- // Keep the assistant message with tool calls intact. If real tool results follow, preserve them;
240
- // otherwise synthesize aborted results before the next turn boundary.
253
+ // Keep the assistant message with tool calls intact. Real tool results are
254
+ // emitted immediately if available; otherwise synthesize aborted results
255
+ // before the next turn boundary.
241
256
  result.push(msg);
242
257
  pendingAbortedToolCalls = new Map(toolCalls.map(toolCall => [toolCall.id, toolCall] as const));
243
258
  pendingAbortedTimestamp = assistantMsg.timestamp;
@@ -250,6 +265,8 @@ export function transformMessages<TApi extends Api>(
250
265
 
251
266
  result.push(msg);
252
267
  } else if (msg.role === "toolResult") {
268
+ if (toolCallStatus.has(msg.toolCallId)) continue;
269
+
253
270
  if (pendingAbortedToolCalls.has(msg.toolCallId)) {
254
271
  pendingAbortedToolCalls.delete(msg.toolCallId);
255
272
  toolCallStatus.set(msg.toolCallId, ToolCallStatus.Resolved);
@@ -257,7 +274,11 @@ export function transformMessages<TApi extends Api>(
257
274
  continue;
258
275
  }
259
276
 
260
- if (toolCallStatus.get(msg.toolCallId) === ToolCallStatus.Aborted) continue;
277
+ if (pendingToolCalls.some(tc => tc.id === msg.toolCallId)) {
278
+ toolCallStatus.set(msg.toolCallId, ToolCallStatus.Resolved);
279
+ result.push(msg);
280
+ continue;
281
+ }
261
282
 
262
283
  if (!validToolUseIds.has(msg.toolCallId)) {
263
284
  // Orphan `tool_result`: the originating `tool_use` is not present in the
@@ -272,16 +293,13 @@ export function transformMessages<TApi extends Api>(
272
293
  // * Anthropic requires the next message after an assistant `tool_use`
273
294
  // to be the matching `tool_result`. Inserting a developer message
274
295
  // would break that contiguity.
275
- // * `flushPendingAbortedToolCalls` synthesizes "aborted" results
276
- // without checking whether a real result lands later in history
277
- // (unlike `flushPendingToolCalls`, which is gated by
278
- // `realToolResultIds`). Calling it here would convert a legitimate
279
- // later `tool_result` into a synthetic "aborted" one via the
280
- // `ToolCallStatus.Aborted` skip-guard.
296
+ // * Flushing pending aborted calls here would wedge synthetic results
297
+ // between the assistant turn and a real result that may still arrive
298
+ // inside the current contiguous result window.
281
299
  //
282
- // Drop the orphan silently in that case; the upcoming real
283
- // `tool_result` will land normally on the next iteration.
284
- if (pendingToolCalls.length > 0 || pendingAbortedToolCalls.size > 0) {
300
+ // Drop the orphan silently in that case; the pending calls will be
301
+ // resolved in their own contiguous result window or at the next boundary.
302
+ if (pendingToolCalls.some(tc => !toolCallStatus.has(tc.id)) || pendingAbortedToolCalls.size > 0) {
285
303
  continue;
286
304
  }
287
305
  // No pending tool-call window: safe to preserve the text payload so the
@@ -311,11 +329,12 @@ export function transformMessages<TApi extends Api>(
311
329
  timestamp: messageTimestamp,
312
330
  } as UserMessage);
313
331
  }
314
- continue;
315
332
  }
316
333
 
317
- toolCallStatus.set(msg.toolCallId, ToolCallStatus.Resolved);
318
- result.push(msg);
334
+ // The matching tool_use exists elsewhere, but this result is not in
335
+ // the currently open result window. Emitting it here would break the
336
+ // provider invariant; the first real result is pulled into the correct
337
+ // slot by the pending-call flush instead.
319
338
  } else if (msg.role === "user" || msg.role === "developer") {
320
339
  flushPendingToolCalls(messageTimestamp);
321
340
  flushPendingAbortedToolCalls();
package/src/stream.ts CHANGED
@@ -108,6 +108,7 @@ const serviceProviderMap: Record<string, KeyResolver> = {
108
108
  kilo: "KILO_API_KEY",
109
109
  "vercel-ai-gateway": "AI_GATEWAY_API_KEY",
110
110
  zai: "ZAI_API_KEY",
111
+ "zhipu-coding-plan": "ZHIPU_API_KEY",
111
112
  mistral: "MISTRAL_API_KEY",
112
113
  minimax: "MINIMAX_API_KEY",
113
114
  "minimax-code": "MINIMAX_CODE_API_KEY",
package/src/types.ts CHANGED
@@ -121,6 +121,7 @@ export type KnownProvider =
121
121
  | "kilo"
122
122
  | "vercel-ai-gateway"
123
123
  | "zai"
124
+ | "zhipu-coding-plan"
124
125
  | "mistral"
125
126
  | "minimax"
126
127
  | "opencode-go"
@@ -150,6 +150,11 @@ const builtInOAuthProviders: OAuthProviderInfo[] = [
150
150
  name: "Z.AI (GLM Coding Plan)",
151
151
  available: true,
152
152
  },
153
+ {
154
+ id: "zhipu-coding-plan",
155
+ name: "Zhipu Coding Plan (智谱)",
156
+ available: true,
157
+ },
153
158
  {
154
159
  id: "minimax-code",
155
160
  name: "MiniMax Coding Plan (International)",
@@ -328,6 +333,7 @@ export async function refreshOAuthToken(
328
333
  case "ollama-cloud":
329
334
  case "xiaomi":
330
335
  case "zai":
336
+ case "zhipu-coding-plan":
331
337
  case "qianfan":
332
338
  case "venice":
333
339
  case "minimax-code":
@@ -50,7 +50,8 @@ export type OAuthProvider =
50
50
  | "vllm"
51
51
  | "xiaomi"
52
52
  | "zenmux"
53
- | "zai";
53
+ | "zai"
54
+ | "zhipu-coding-plan";
54
55
 
55
56
  export type OAuthProviderId = OAuthProvider | (string & {});
56
57
 
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Zhipu Coding Plan login flow.
3
+ *
4
+ * Zhipu BigModel (智谱) provides an OpenAI-compatible API.
5
+ * API docs: https://docs.bigmodel.cn/cn/guide/develop/openai/introduction
6
+ *
7
+ * Simple API key flow:
8
+ * 1. User gets their API key from https://open.bigmodel.cn
9
+ * 2. User pastes the API key into the CLI
10
+ */
11
+
12
+ import { validateOpenAICompatibleApiKey } from "./api-key-validation";
13
+ import type { OAuthController } from "./types";
14
+
15
+ const AUTH_URL = "https://open.bigmodel.cn/usercenter/apikeys";
16
+ const API_BASE_URL = "https://open.bigmodel.cn/api/paas/v4";
17
+ const VALIDATION_MODEL = "glm-5.1";
18
+
19
+ /**
20
+ * Login to Zhipu Coding Plan.
21
+ *
22
+ * Opens browser to API keys page, prompts user to paste their API key.
23
+ * Returns the API key directly (not OAuthCredentials - this isn't OAuth).
24
+ */
25
+ export async function loginZhipuCodingPlan(options: OAuthController): Promise<string> {
26
+ if (!options.onPrompt) {
27
+ throw new Error("Zhipu Coding Plan login requires onPrompt callback");
28
+ }
29
+
30
+ // Open browser to API keys page
31
+ options.onAuth?.({
32
+ url: AUTH_URL,
33
+ instructions: "Copy your API key from the dashboard",
34
+ });
35
+
36
+ // Prompt user to paste their API key
37
+ const apiKey = await options.onPrompt({
38
+ message: "Paste your Zhipu API key",
39
+ placeholder: "sk-...",
40
+ });
41
+
42
+ if (options.signal?.aborted) {
43
+ throw new Error("Login cancelled");
44
+ }
45
+
46
+ const trimmed = apiKey.trim();
47
+ if (!trimmed) {
48
+ throw new Error("API key is required");
49
+ }
50
+
51
+ options.onProgress?.("Validating API key...");
52
+ await validateOpenAICompatibleApiKey({
53
+ provider: "Zhipu",
54
+ apiKey: trimmed,
55
+ baseUrl: API_BASE_URL,
56
+ model: VALIDATION_MODEL,
57
+ signal: options.signal,
58
+ });
59
+ return trimmed;
60
+ }
@@ -34,6 +34,7 @@ export const UNSUPPORTED_SCHEMA_FIELDS: Record<string, true> = {
34
34
  maximum: true,
35
35
  exclusiveMinimum: true,
36
36
  exclusiveMaximum: true,
37
+ multipleOf: true,
37
38
  pattern: true,
38
39
  format: true,
39
40
  };
@@ -6,6 +6,5 @@ export function isJsonObject(value: unknown): value is JsonObject {
6
6
 
7
7
  /** True when `value` is a plain JSON object with no own enumerable keys. */
8
8
  export function isJsonObjectEmpty(value: JsonObject): boolean {
9
- for (const _ in value) return false;
10
- return true;
9
+ return Object.keys(value).length === 0;
11
10
  }
@@ -99,8 +99,7 @@ const SCHEMA_ARRAY_KEYS = ["anyOf", "oneOf", "allOf", "prefixItems"] as const;
99
99
  /** True when `val` is a plain empty object `{}`. */
100
100
  function isEmptyObject(val: unknown): val is Record<string, never> {
101
101
  if (val === null || typeof val !== "object" || Array.isArray(val)) return false;
102
- for (const _ in val as object) return false;
103
- return true;
102
+ return Object.keys(val).length === 0;
104
103
  }
105
104
 
106
105
  function walk(node: unknown): void {
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env bun
2
- export {};
package/src/cli.ts DELETED
@@ -1,262 +0,0 @@
1
- #!/usr/bin/env bun
2
- import * as readline from "node:readline";
3
- import { AuthStorage, SqliteAuthCredentialStore } from "./auth-storage";
4
- import { getOAuthProviders } from "./utils/oauth";
5
- import type { OAuthProvider } from "./utils/oauth/types";
6
-
7
- const PROVIDERS = getOAuthProviders();
8
-
9
- function prompt(rl: readline.Interface, question: string): Promise<string> {
10
- const { promise, resolve, reject } = Promise.withResolvers<string>();
11
- const input = process.stdin as NodeJS.ReadStream;
12
- const supportsRawMode = input.isTTY && typeof input.setRawMode === "function";
13
- const wasRaw = supportsRawMode ? input.isRaw : false;
14
- let settled = false;
15
-
16
- const cleanup = () => {
17
- rl.off("SIGINT", onSigint);
18
- if (supportsRawMode) {
19
- input.off("keypress", onKeypress);
20
- input.setRawMode?.(wasRaw);
21
- }
22
- };
23
-
24
- const finish = (result: () => void) => {
25
- if (settled) return;
26
- settled = true;
27
- cleanup();
28
- result();
29
- };
30
-
31
- const cancel = () => {
32
- finish(() => reject(new Error("Login cancelled")));
33
- };
34
-
35
- const onSigint = () => {
36
- cancel();
37
- };
38
-
39
- const onKeypress = (_str: string, key: readline.Key) => {
40
- if (key.name === "escape" || (key.ctrl && key.name === "c")) {
41
- cancel();
42
- rl.close();
43
- }
44
- };
45
-
46
- if (supportsRawMode) {
47
- readline.emitKeypressEvents(input, rl);
48
- input.setRawMode(true);
49
- input.on("keypress", onKeypress);
50
- }
51
-
52
- rl.once("SIGINT", onSigint);
53
- rl.question(question, answer => {
54
- finish(() => resolve(answer));
55
- });
56
- return promise;
57
- }
58
-
59
- async function login(provider: OAuthProvider): Promise<void> {
60
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
61
- const promptFn = (msg: string) => prompt(rl, `${msg} `);
62
- const store = await SqliteAuthCredentialStore.open();
63
- const storage = new AuthStorage(store);
64
- await storage.reload();
65
-
66
- try {
67
- await storage.login(provider, {
68
- onAuth(info) {
69
- const { url, instructions } = info;
70
- console.log(`\nOpen this URL in your browser:\n${url}`);
71
- if (instructions) console.log(instructions);
72
- console.log();
73
- },
74
- onProgress(message) {
75
- console.log(message);
76
- },
77
- onPrompt(p) {
78
- return promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`);
79
- },
80
- });
81
- console.log(`\nCredentials saved to ~/.omp/agent/agent.db`);
82
- } finally {
83
- store.close();
84
- rl.close();
85
- }
86
- }
87
-
88
- async function main(): Promise<void> {
89
- const args = process.argv.slice(2);
90
- const command = args[0];
91
-
92
- if (!command || command === "help" || command === "--help" || command === "-h") {
93
- console.log(`Usage: bunx @oh-my-pi/pi-ai <command> [provider]
94
-
95
- Commands:
96
- login [provider] Login to a provider
97
- logout [provider] Logout from a provider
98
- status Show logged-in providers
99
- list List available providers
100
-
101
- Providers:
102
- anthropic Anthropic (Claude Pro/Max)
103
- github-copilot GitHub Copilot
104
- google-gemini-cli Google Gemini CLI
105
- google-antigravity Antigravity (Gemini 3, Claude, GPT-OSS)
106
- openai-codex OpenAI Codex (ChatGPT Plus/Pro)
107
- kimi-code Kimi Code
108
- kilo Kilo Gateway
109
- kagi Kagi
110
- tavily Tavily
111
- zai Z.AI (GLM Coding Plan)
112
- deepseek DeepSeek
113
- nanogpt NanoGPT
114
- minimax-code MiniMax Coding Plan (International)
115
- minimax-code-cn MiniMax Coding Plan (China)
116
- cursor Cursor (Claude, GPT, etc.)
117
- zenmux ZenMux
118
- ollama-cloud Ollama Cloud
119
-
120
- Examples:
121
- bunx @oh-my-pi/pi-ai login # interactive provider selection
122
- bunx @oh-my-pi/pi-ai login anthropic # login to specific provider
123
- bunx @oh-my-pi/pi-ai logout anthropic # logout from specific provider
124
- bunx @oh-my-pi/pi-ai status # show logged-in providers
125
- bunx @oh-my-pi/pi-ai list # list providers
126
- `);
127
- return;
128
- }
129
-
130
- if (command === "status") {
131
- const storage = await SqliteAuthCredentialStore.open();
132
- try {
133
- const providers = storage.listProviders();
134
- if (providers.length === 0) {
135
- console.log("No credentials stored.");
136
- console.log(`Use 'bunx @oh-my-pi/pi-ai login' to authenticate.`);
137
- } else {
138
- console.log("Logged-in providers:\n");
139
- for (const provider of providers) {
140
- const oauth = storage.getOAuth(provider);
141
- if (oauth) {
142
- const expires = new Date(oauth.expires);
143
- const expired = Date.now() >= oauth.expires;
144
- const status = expired ? "(expired)" : `(expires ${expires.toLocaleString()})`;
145
- console.log(` ${provider.padEnd(20)} ${status}`);
146
- continue;
147
- }
148
- const apiKey = storage.getApiKey(provider);
149
- if (apiKey) {
150
- console.log(` ${provider.padEnd(20)} (api key)`);
151
- }
152
- }
153
- }
154
- } finally {
155
- storage.close();
156
- }
157
- return;
158
- }
159
-
160
- if (command === "list") {
161
- console.log("Available providers:\n");
162
- for (const p of PROVIDERS) {
163
- console.log(` ${p.id.padEnd(20)} ${p.name}`);
164
- }
165
- return;
166
- }
167
-
168
- if (command === "logout") {
169
- let provider = args[1] as OAuthProvider | undefined;
170
- const storage = await SqliteAuthCredentialStore.open();
171
-
172
- try {
173
- if (!provider) {
174
- const providers = storage.listProviders();
175
- if (providers.length === 0) {
176
- console.log("No credentials stored.");
177
- return;
178
- }
179
-
180
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
181
- console.log("Select a provider to logout:\n");
182
- for (let i = 0; i < providers.length; i++) {
183
- console.log(` ${i + 1}. ${providers[i]}`);
184
- }
185
- console.log();
186
-
187
- const choice = await prompt(rl, `Enter number (1-${providers.length}): `);
188
- rl.close();
189
-
190
- const index = parseInt(choice, 10) - 1;
191
- if (index < 0 || index >= providers.length) {
192
- console.error("Invalid selection");
193
- process.exit(1);
194
- }
195
- provider = providers[index] as OAuthProvider;
196
- }
197
- if (!provider) {
198
- console.error("No provider selected");
199
- process.exit(1);
200
- }
201
-
202
- const oauth = storage.getOAuth(provider);
203
- const apiKey = storage.getApiKey(provider);
204
- if (!oauth && !apiKey) {
205
- console.error(`Not logged in to ${provider}`);
206
- process.exit(1);
207
- }
208
-
209
- storage.deleteProvider(provider);
210
- console.log(`Logged out from ${provider}`);
211
- } finally {
212
- storage.close();
213
- }
214
- return;
215
- }
216
-
217
- if (command === "login") {
218
- let provider = args[1] as OAuthProvider | undefined;
219
-
220
- if (!provider) {
221
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
222
- console.log("Select a provider:\n");
223
- for (let i = 0; i < PROVIDERS.length; i++) {
224
- console.log(` ${i + 1}. ${PROVIDERS[i].name}`);
225
- }
226
- console.log();
227
-
228
- const choice = await prompt(rl, `Enter number (1-${PROVIDERS.length}): `);
229
- rl.close();
230
-
231
- const index = parseInt(choice, 10) - 1;
232
- if (index < 0 || index >= PROVIDERS.length) {
233
- console.error("Invalid selection");
234
- process.exit(1);
235
- }
236
- provider = PROVIDERS[index].id as OAuthProvider;
237
- }
238
- if (!provider) {
239
- console.error("No provider selected");
240
- process.exit(1);
241
- }
242
-
243
- if (!PROVIDERS.some(p => p.id === provider)) {
244
- console.error(`Unknown provider: ${provider}`);
245
- console.error(`Use 'bunx @oh-my-pi/pi-ai list' to see available providers`);
246
- process.exit(1);
247
- }
248
-
249
- console.log(`Logging in to ${provider}…`);
250
- await login(provider);
251
- return;
252
- }
253
-
254
- console.error(`Unknown command: ${command}`);
255
- console.error(`Use 'bunx @oh-my-pi/pi-ai --help' for usage`);
256
- process.exit(1);
257
- }
258
-
259
- main().catch(err => {
260
- console.error("Error:", err.message);
261
- process.exit(1);
262
- });