@oh-my-pi/pi-coding-agent 12.5.1 → 12.7.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,31 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [12.7.0] - 2026-02-16
6
+ ### Added
7
+
8
+ - Added Z.AI web search provider support via remote MCP endpoint (webSearchPrime)
9
+ - Added `zai` as a selectable web search provider option in settings
10
+ - Added Z.AI to automatic provider fallback chain for web search
11
+
12
+ ## [12.6.0] - 2026-02-16
13
+ ### Added
14
+
15
+ - Added runtime tests covering extension provider registration and deferred model pattern resolution behavior.
16
+
17
+ ### Changed
18
+
19
+ - Improved welcome screen responsiveness to dynamically show or hide the right column based on available terminal width
20
+ - Extended extension `registerProvider()` typing with OAuth provider support and source-aware registration metadata.
21
+
22
+ ### Fixed
23
+
24
+ - Fixed welcome screen layout to gracefully handle small terminal widths and prevent rendering errors on narrow displays
25
+ - Fixed welcome screen title truncation to prevent overflow when content exceeds available width
26
+ - Fixed deferred `--model` resolution so extension-provided models are matched before fallback selection and unresolved explicit patterns no longer silently fallback.
27
+ - Fixed CLI `--api-key` handling for deferred model resolution by applying runtime API key overrides after extension model selection.
28
+ - Fixed extension provider registration cleanup to remove stale source-scoped custom API/OAuth providers across extension reloads.
29
+
5
30
  ## [12.5.1] - 2026-02-15
6
31
  ### Added
7
32
 
@@ -886,6 +886,37 @@ pi.registerCommand("stats", {
886
886
  },
887
887
  });
888
888
  ```
889
+ ### pi.registerProvider(name, config)
890
+
891
+ Register or override providers/models at runtime:
892
+
893
+ ```typescript
894
+ pi.registerProvider("my-provider", {
895
+ baseUrl: "https://api.example.com/v1",
896
+ apiKey: "MY_PROVIDER_API_KEY",
897
+ api: "openai-completions",
898
+ models: [
899
+ {
900
+ id: "my-model",
901
+ name: "My Model",
902
+ reasoning: false,
903
+ input: ["text"],
904
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
905
+ contextWindow: 128000,
906
+ maxTokens: 8192,
907
+ },
908
+ ],
909
+ });
910
+ ```
911
+
912
+ `registerProvider()` also supports:
913
+
914
+ - `streamSimple` for custom API adapters
915
+ - `headers` / `authHeader` for request customization
916
+ - `oauth` for `/login <provider>` support with extension-defined login/refresh behavior
917
+
918
+ Provider registrations are queued during extension load and applied when the session initializes.
919
+
889
920
 
890
921
  ### pi.registerMessageRenderer(customType, renderer)
891
922
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "12.5.1",
3
+ "version": "12.7.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "bin": {
@@ -84,12 +84,12 @@
84
84
  },
85
85
  "dependencies": {
86
86
  "@mozilla/readability": "0.6.0",
87
- "@oh-my-pi/omp-stats": "12.5.1",
88
- "@oh-my-pi/pi-agent-core": "12.5.1",
89
- "@oh-my-pi/pi-ai": "12.5.1",
90
- "@oh-my-pi/pi-natives": "12.5.1",
91
- "@oh-my-pi/pi-tui": "12.5.1",
92
- "@oh-my-pi/pi-utils": "12.5.1",
87
+ "@oh-my-pi/omp-stats": "12.7.0",
88
+ "@oh-my-pi/pi-agent-core": "12.7.0",
89
+ "@oh-my-pi/pi-ai": "12.7.0",
90
+ "@oh-my-pi/pi-natives": "12.7.0",
91
+ "@oh-my-pi/pi-tui": "12.7.0",
92
+ "@oh-my-pi/pi-utils": "12.7.0",
93
93
  "@sinclair/typebox": "^0.34.48",
94
94
  "@xterm/headless": "^6.0.0",
95
95
  "ajv": "^8.18.0",
@@ -59,23 +59,28 @@ function hasBun(): boolean {
59
59
  }
60
60
 
61
61
  /**
62
- * Get the latest release info from GitHub.
62
+ * Get the latest release info from the npm registry.
63
+ * Uses npm instead of GitHub API to avoid unauthenticated rate limiting.
63
64
  */
64
65
  async function getLatestRelease(): Promise<ReleaseInfo> {
65
- const response = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`);
66
+ const response = await fetch(`https://registry.npmjs.org/${PACKAGE}/latest`);
66
67
  if (!response.ok) {
67
68
  throw new Error(`Failed to fetch release info: ${response.statusText}`);
68
69
  }
69
70
 
70
- const data = (await response.json()) as {
71
- tag_name: string;
72
- assets: Array<{ name: string; browser_download_url: string }>;
73
- };
71
+ const data = (await response.json()) as { version: string };
72
+ const version = data.version;
73
+ const tag = `v${version}`;
74
74
 
75
+ // Construct deterministic GitHub release download URLs for the current platform
76
+ const makeAsset = (name: string) => ({
77
+ name,
78
+ url: `https://github.com/${REPO}/releases/download/${tag}/${name}`,
79
+ });
75
80
  return {
76
- tag: data.tag_name,
77
- version: data.tag_name.replace(/^v/, ""),
78
- assets: data.assets.map(a => ({ name: a.name, url: a.browser_download_url })),
81
+ tag,
82
+ version,
83
+ assets: [makeAsset(getBinaryName()), makeAsset(getNativeAddonName())],
79
84
  };
80
85
  }
81
86
 
@@ -25,6 +25,7 @@ const PROVIDERS: Array<SearchProviderId | "auto"> = [
25
25
  "perplexity",
26
26
  "exa",
27
27
  "jina",
28
+ "zai",
28
29
  "gemini",
29
30
  "codex",
30
31
  ];
@@ -99,6 +100,7 @@ export async function runSearchCommand(cmd: SearchCommandArgs): Promise<void> {
99
100
  provider: cmd.provider,
100
101
  recency: cmd.recency,
101
102
  limit: cmd.limit,
103
+ no_fallback: cmd.provider !== undefined && cmd.provider !== "auto",
102
104
  };
103
105
 
104
106
  const result = await runSearchQuery(params);
@@ -11,6 +11,7 @@ const PROVIDERS: Array<SearchProviderId | "auto"> = [
11
11
  "perplexity",
12
12
  "exa",
13
13
  "jina",
14
+ "zai",
14
15
  "gemini",
15
16
  "codex",
16
17
  ];
@@ -1,10 +1,19 @@
1
1
  import {
2
2
  type Api,
3
+ type AssistantMessageEventStream,
4
+ type Context,
3
5
  getGitHubCopilotBaseUrl,
4
6
  getModels,
5
7
  getProviders,
6
8
  type Model,
7
9
  normalizeDomain,
10
+ type OAuthCredentials,
11
+ type OAuthLoginCallbacks,
12
+ registerCustomApi,
13
+ registerOAuthProvider,
14
+ type SimpleStreamOptions,
15
+ unregisterCustomApis,
16
+ unregisterOAuthProviders,
8
17
  } from "@oh-my-pi/pi-ai";
9
18
  import { logger } from "@oh-my-pi/pi-utils";
10
19
  import { type Static, Type } from "@sinclair/typebox";
@@ -291,6 +300,7 @@ export class ModelRegistry {
291
300
  #modelOverrides: Map<string, Map<string, ModelOverride>> = new Map();
292
301
  #configError: ConfigError | undefined = undefined;
293
302
  #modelsConfigFile: ConfigFile<ModelsConfig>;
303
+ #registeredProviderSources: Set<string> = new Set();
294
304
 
295
305
  /**
296
306
  * @param authStorage - Auth storage for API key resolution
@@ -705,4 +715,162 @@ export class ModelRegistry {
705
715
  isUsingOAuth(model: Model<Api>): boolean {
706
716
  return this.authStorage.hasOAuth(model.provider);
707
717
  }
718
+
719
+ /**
720
+ * Remove custom API/OAuth registrations for a specific extension source.
721
+ */
722
+ clearSourceRegistrations(sourceId: string): void {
723
+ unregisterCustomApis(sourceId);
724
+ unregisterOAuthProviders(sourceId);
725
+ }
726
+
727
+ /**
728
+ * Remove registrations for extension sources that are no longer active.
729
+ */
730
+ syncExtensionSources(activeSourceIds: string[]): void {
731
+ const activeSources = new Set(activeSourceIds);
732
+ for (const sourceId of this.#registeredProviderSources) {
733
+ if (activeSources.has(sourceId)) {
734
+ continue;
735
+ }
736
+ this.clearSourceRegistrations(sourceId);
737
+ this.#registeredProviderSources.delete(sourceId);
738
+ }
739
+ }
740
+
741
+ /**
742
+ * Register a provider dynamically (from extensions).
743
+ *
744
+ * If provider has models: replaces all existing models for this provider.
745
+ * If provider has only baseUrl/headers: overrides existing models' URLs.
746
+ * If provider has streamSimple: registers a custom API streaming function.
747
+ * If provider has oauth: registers OAuth provider for /login support.
748
+ */
749
+ registerProvider(providerName: string, config: ProviderConfigInput, sourceId?: string): void {
750
+ if (config.streamSimple && !config.api) {
751
+ throw new Error(`Provider ${providerName}: "api" is required when registering streamSimple.`);
752
+ }
753
+
754
+ if (config.models && config.models.length > 0) {
755
+ if (!config.baseUrl) {
756
+ throw new Error(`Provider ${providerName}: "baseUrl" is required when defining models.`);
757
+ }
758
+ if (!config.apiKey && !config.oauth) {
759
+ throw new Error(`Provider ${providerName}: "apiKey" or "oauth" is required when defining models.`);
760
+ }
761
+ for (const modelDef of config.models) {
762
+ const api = modelDef.api || config.api;
763
+ if (!api) {
764
+ throw new Error(`Provider ${providerName}, model ${modelDef.id}: no "api" specified.`);
765
+ }
766
+ }
767
+ }
768
+
769
+ if (config.streamSimple && config.api) {
770
+ const streamSimple = config.streamSimple;
771
+ registerCustomApi(config.api, streamSimple, sourceId, (model, context, options) =>
772
+ streamSimple(model, context, options as SimpleStreamOptions),
773
+ );
774
+ }
775
+
776
+ if (config.oauth) {
777
+ registerOAuthProvider({
778
+ ...config.oauth,
779
+ id: providerName,
780
+ sourceId,
781
+ });
782
+ }
783
+
784
+ if (sourceId) {
785
+ this.#registeredProviderSources.add(sourceId);
786
+ }
787
+ if (config.apiKey) {
788
+ this.#customProviderApiKeys.set(providerName, config.apiKey);
789
+ }
790
+
791
+ if (config.models && config.models.length > 0) {
792
+ const nextModels = this.#models.filter(m => m.provider !== providerName);
793
+ for (const modelDef of config.models) {
794
+ const api = modelDef.api || config.api;
795
+ if (!api) {
796
+ throw new Error(`Provider ${providerName}, model ${modelDef.id}: no "api" specified.`);
797
+ }
798
+ let headers = config.headers || modelDef.headers ? { ...config.headers, ...modelDef.headers } : undefined;
799
+ if (config.authHeader && config.apiKey) {
800
+ const resolvedKey = resolveApiKeyConfig(config.apiKey);
801
+ if (resolvedKey) {
802
+ headers = { ...headers, Authorization: `Bearer ${resolvedKey}` };
803
+ }
804
+ }
805
+
806
+ nextModels.push({
807
+ id: modelDef.id,
808
+ name: modelDef.name,
809
+ api,
810
+ provider: providerName,
811
+ baseUrl: config.baseUrl!,
812
+ reasoning: modelDef.reasoning,
813
+ input: modelDef.input as ("text" | "image")[],
814
+ cost: modelDef.cost,
815
+ contextWindow: modelDef.contextWindow,
816
+ maxTokens: modelDef.maxTokens,
817
+ headers,
818
+ compat: modelDef.compat,
819
+ } as Model<Api>);
820
+ }
821
+
822
+ if (config.oauth?.modifyModels) {
823
+ const credential = this.authStorage.getOAuthCredential(providerName);
824
+ if (credential) {
825
+ this.#models = config.oauth.modifyModels(nextModels, credential);
826
+ return;
827
+ }
828
+ }
829
+
830
+ this.#models = nextModels;
831
+ return;
832
+ }
833
+
834
+ if (config.baseUrl) {
835
+ this.#models = this.#models.map(m => {
836
+ if (m.provider !== providerName) return m;
837
+ return {
838
+ ...m,
839
+ baseUrl: config.baseUrl ?? m.baseUrl,
840
+ headers: config.headers ? { ...m.headers, ...config.headers } : m.headers,
841
+ };
842
+ });
843
+ }
844
+ }
845
+ }
846
+
847
+ /**
848
+ * Input type for registerProvider API (from extensions).
849
+ */
850
+ export interface ProviderConfigInput {
851
+ baseUrl?: string;
852
+ apiKey?: string;
853
+ api?: Api;
854
+ streamSimple?: (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream;
855
+ headers?: Record<string, string>;
856
+ authHeader?: boolean;
857
+ oauth?: {
858
+ name: string;
859
+ login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials | string>;
860
+ refreshToken?(credentials: OAuthCredentials): Promise<OAuthCredentials>;
861
+ getApiKey?(credentials: OAuthCredentials): string;
862
+ modifyModels?(models: Model<Api>[], credentials: OAuthCredentials): Model<Api>[];
863
+ };
864
+ models?: Array<{
865
+ id: string;
866
+ name: string;
867
+ api?: Api;
868
+ reasoning: boolean;
869
+ input: ("text" | "image")[];
870
+ cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
871
+ contextWindow: number;
872
+ maxTokens: number;
873
+ headers?: Record<string, string>;
874
+ compat?: Model<Api>["compat"];
875
+ }>;
708
876
  }
@@ -258,6 +258,7 @@ function parseModelPatternWithContext(
258
258
  pattern: string,
259
259
  availableModels: Model<Api>[],
260
260
  context: ModelPreferenceContext,
261
+ options?: { allowInvalidThinkingLevelFallback?: boolean },
261
262
  ): ParsedModelResult {
262
263
  // Try exact match first
263
264
  const exactMatch = tryMatchModel(pattern, availableModels, context);
@@ -277,7 +278,7 @@ function parseModelPatternWithContext(
277
278
 
278
279
  if (isValidThinkingLevel(suffix)) {
279
280
  // Valid thinking level - recurse on prefix and use this level
280
- const result = parseModelPatternWithContext(prefix, availableModels, context);
281
+ const result = parseModelPatternWithContext(prefix, availableModels, context, options);
281
282
  if (result.model) {
282
283
  // Only use this thinking level if no warning from inner recursion
283
284
  const explicitThinkingLevel = !result.warning;
@@ -291,8 +292,13 @@ function parseModelPatternWithContext(
291
292
  return result;
292
293
  }
293
294
 
295
+ const allowFallback = options?.allowInvalidThinkingLevelFallback ?? true;
296
+ if (!allowFallback) {
297
+ return { model: undefined, thinkingLevel: undefined, warning: undefined, explicitThinkingLevel: false };
298
+ }
299
+
294
300
  // Invalid suffix - recurse on prefix and warn
295
- const result = parseModelPatternWithContext(prefix, availableModels, context);
301
+ const result = parseModelPatternWithContext(prefix, availableModels, context, options);
296
302
  if (result.model) {
297
303
  return {
298
304
  model: result.model,
@@ -308,9 +314,10 @@ export function parseModelPattern(
308
314
  pattern: string,
309
315
  availableModels: Model<Api>[],
310
316
  preferences?: ModelMatchPreferences,
317
+ options?: { allowInvalidThinkingLevelFallback?: boolean },
311
318
  ): ParsedModelResult {
312
319
  const context = buildPreferenceContext(availableModels, preferences);
313
- return parseModelPatternWithContext(pattern, availableModels, context);
320
+ return parseModelPatternWithContext(pattern, availableModels, context, options);
314
321
  }
315
322
 
316
323
  const PREFIX_MODEL_ROLE = "pi/";
@@ -486,6 +493,98 @@ export async function resolveModelScope(
486
493
  return scopedModels;
487
494
  }
488
495
 
496
+ export interface ResolveCliModelResult {
497
+ model: Model<Api> | undefined;
498
+ thinkingLevel?: ThinkingLevel;
499
+ warning: string | undefined;
500
+ error: string | undefined;
501
+ }
502
+
503
+ /**
504
+ * Resolve a single model from CLI flags.
505
+ */
506
+ export function resolveCliModel(options: {
507
+ cliProvider?: string;
508
+ cliModel?: string;
509
+ modelRegistry: ModelRegistry;
510
+ preferences?: ModelMatchPreferences;
511
+ }): ResolveCliModelResult {
512
+ const { cliProvider, cliModel, modelRegistry, preferences } = options;
513
+
514
+ if (!cliModel) {
515
+ return { model: undefined, warning: undefined, error: undefined };
516
+ }
517
+
518
+ const availableModels = modelRegistry.getAll();
519
+ if (availableModels.length === 0) {
520
+ return {
521
+ model: undefined,
522
+ warning: undefined,
523
+ error: "No models available. Check your installation or add models to models.json.",
524
+ };
525
+ }
526
+
527
+ const providerMap = new Map<string, string>();
528
+ for (const model of availableModels) {
529
+ providerMap.set(model.provider.toLowerCase(), model.provider);
530
+ }
531
+
532
+ let provider = cliProvider ? providerMap.get(cliProvider.toLowerCase()) : undefined;
533
+ if (cliProvider && !provider) {
534
+ return {
535
+ model: undefined,
536
+ warning: undefined,
537
+ error: `Unknown provider "${cliProvider}". Use --list-models to see available providers/models.`,
538
+ };
539
+ }
540
+
541
+ if (!provider) {
542
+ const lower = cliModel.toLowerCase();
543
+ const exact = availableModels.find(
544
+ model => model.id.toLowerCase() === lower || `${model.provider}/${model.id}`.toLowerCase() === lower,
545
+ );
546
+ if (exact) {
547
+ return { model: exact, warning: undefined, thinkingLevel: undefined, error: undefined };
548
+ }
549
+ }
550
+
551
+ let pattern = cliModel;
552
+
553
+ if (!provider) {
554
+ const slashIndex = cliModel.indexOf("/");
555
+ if (slashIndex !== -1) {
556
+ const maybeProvider = cliModel.substring(0, slashIndex);
557
+ const canonical = providerMap.get(maybeProvider.toLowerCase());
558
+ if (canonical) {
559
+ provider = canonical;
560
+ pattern = cliModel.substring(slashIndex + 1);
561
+ }
562
+ }
563
+ } else {
564
+ const prefix = `${provider}/`;
565
+ if (cliModel.toLowerCase().startsWith(prefix.toLowerCase())) {
566
+ pattern = cliModel.substring(prefix.length);
567
+ }
568
+ }
569
+
570
+ const candidates = provider ? availableModels.filter(model => model.provider === provider) : availableModels;
571
+ const { model, thinkingLevel, warning } = parseModelPattern(pattern, candidates, preferences, {
572
+ allowInvalidThinkingLevelFallback: false,
573
+ });
574
+
575
+ if (!model) {
576
+ const display = provider ? `${provider}/${pattern}` : cliModel;
577
+ return {
578
+ model: undefined,
579
+ thinkingLevel: undefined,
580
+ warning,
581
+ error: `Model "${display}" not found. Use --list-models to see available models.`,
582
+ };
583
+ }
584
+
585
+ return { model, thinkingLevel, warning, error: undefined };
586
+ }
587
+
489
588
  export interface InitialModelResult {
490
589
  model: Model<Api> | undefined;
491
590
  thinkingLevel: ThinkingLevel;
@@ -617,7 +617,7 @@ export const SETTINGS_SCHEMA = {
617
617
  // ─────────────────────────────────────────────────────────────────────────
618
618
  "providers.webSearch": {
619
619
  type: "enum",
620
- values: ["auto", "exa", "jina", "perplexity", "anthropic"] as const,
620
+ values: ["auto", "exa", "jina", "zai", "perplexity", "anthropic"] as const,
621
621
  default: "auto",
622
622
  ui: { tab: "services", label: "Web search provider", description: "Provider for web search tool", submenu: true },
623
623
  },
@@ -67,9 +67,16 @@ export type {
67
67
  InputEventResult,
68
68
  KeybindingsManager,
69
69
  LoadExtensionsResult,
70
+ // Events - Message
71
+ MessageEndEvent,
70
72
  // Message Rendering
71
73
  MessageRenderer,
72
74
  MessageRenderOptions,
75
+ MessageStartEvent,
76
+ MessageUpdateEvent,
77
+ // Provider Registration
78
+ ProviderConfig,
79
+ ProviderModelConfig,
73
80
  ReadToolCallEvent,
74
81
  ReadToolResultEvent,
75
82
  // Commands
@@ -101,11 +108,16 @@ export type {
101
108
  SetActiveToolsHandler,
102
109
  SetModelHandler,
103
110
  SetThinkingLevelHandler,
111
+ TerminalInputHandler,
104
112
  // Events - Tool
105
113
  ToolCallEvent,
106
114
  ToolCallEventResult,
107
115
  // Tools
108
116
  ToolDefinition,
117
+ // Events - Tool Execution
118
+ ToolExecutionEndEvent,
119
+ ToolExecutionStartEvent,
120
+ ToolExecutionUpdateEvent,
109
121
  ToolRenderResultOptions,
110
122
  ToolResultEvent,
111
123
  ToolResultEventResult,
@@ -52,6 +52,8 @@ export class ExtensionRuntimeNotInitializedError extends Error {
52
52
  */
53
53
  export class ExtensionRuntime implements IExtensionRuntime {
54
54
  flagValues = new Map<string, boolean | string>();
55
+ pendingProviderRegistrations: Array<{ name: string; config: import("./types").ProviderConfig; sourceId: string }> =
56
+ [];
55
57
 
56
58
  sendMessage(): void {
57
59
  throw new ExtensionRuntimeNotInitializedError();
@@ -108,6 +110,11 @@ class ConcreteExtensionAPI implements ExtensionAPI, IExtensionRuntime {
108
110
  readonly typebox = TypeBox;
109
111
  readonly pi = piCodingAgent;
110
112
  readonly flagValues = new Map<string, boolean | string>();
113
+ readonly pendingProviderRegistrations: Array<{
114
+ name: string;
115
+ config: import("./types").ProviderConfig;
116
+ sourceId: string;
117
+ }> = [];
111
118
 
112
119
  constructor(
113
120
  private readonly extension: Extension,
@@ -222,6 +229,10 @@ class ConcreteExtensionAPI implements ExtensionAPI, IExtensionRuntime {
222
229
  setThinkingLevel(level: ThinkingLevel, persist?: boolean): void {
223
230
  this.runtime.setThinkingLevel(level, persist);
224
231
  }
232
+
233
+ registerProvider(name: string, config: import("./types").ProviderConfig): void {
234
+ this.runtime.pendingProviderRegistrations.push({ name, config, sourceId: this.extension.path });
235
+ }
225
236
  }
226
237
 
227
238
  /**
@@ -130,6 +130,7 @@ const noOpUIContext: ExtensionUIContext = {
130
130
  confirm: async (_title, _message, _dialogOptions) => false,
131
131
  input: async (_title, _placeholder, _dialogOptions) => undefined,
132
132
  notify: () => {},
133
+ onTerminalInput: () => () => {},
133
134
  setStatus: () => {},
134
135
  setWorkingMessage: () => {},
135
136
  setWidget: () => {},