@oh-my-pi/pi-coding-agent 15.5.6 → 15.5.7

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,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.5.7] - 2026-05-27
6
+ ### Added
7
+ - `providers.openrouterVariant` setting (Settings → Providers → "OpenRouter Routing") to default OpenRouter requests to a routing-variant suffix (`:nitro`, `:floor`, `:online`, `:exacto`). Selectors that already name a variant (e.g. `openrouter/anthropic/claude-haiku:nitro`) keep precedence.
8
+
9
+ - `generate_image` supports xAI Grok Imagine via `providers.image=xai`. Supports `grok-imagine-image` (default) and `grok-imagine-image-quality` at aspect ratios `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `3:2`, `2:3`. Uses the xAI Grok OAuth credential when available, otherwise `XAI_API_KEY`.
10
+ - New `tts` tool synthesises speech via xAI Grok Voice behind the disabled-by-default `tts.enabled` setting. Built-in voices `ara`, `eve` (default), `leo`, `rex`, `sal`; custom voice IDs also accepted. Output codec inferred from the `output_path` suffix (`.wav` → `wav`, else `mp3`). Up to 15,000 characters per request.
11
+
12
+ ### Fixed
13
+
14
+ - Fixed plan-mode re-entry after approval reopening a fresh `local://PLAN.md` instead of the approved titled plan artifact, which could duplicate plan content and fail approval on an existing destination.
15
+ - Fixed `read` URL reader mode aborting after a stalled Jina request instead of falling back to trafilatura/lynx/native: Jina (and Parallel extract) now have their own per-attempt sub-budget capped at 10s, the catch handler honours only real user cancellation, and the in-process native renderer is always attempted on already-loaded HTML ([#1449](https://github.com/can1357/oh-my-pi/issues/1449))
16
+
5
17
  ## [15.5.6] - 2026-05-27
6
18
  ### Added
7
19
 
@@ -57,6 +69,10 @@
57
69
 
58
70
  - Fixed `omp` startup and `/changelog` reading the host project's `CHANGELOG.md` as omp's — `getPackageDir()` no longer falls back to the user's `cwd` when no owning `package.json` is locatable, preventing spurious `lastChangelogVersion` writes ([#1423](https://github.com/can1357/oh-my-pi/issues/1423))
59
71
 
72
+ ### Fixed
73
+
74
+ - Fixed hashline session-chain replay silently overwriting in-session edits when the model re-targeted a previously rewritten line with a stale file hash; replay now refuses unless every edit's anchor line content matches between the snapshot and the current file ([#1422](https://github.com/can1357/oh-my-pi/pull/1422))
75
+
60
76
  ## [15.5.3] - 2026-05-27
61
77
  ### Breaking Changes
62
78
 
@@ -66,6 +82,10 @@
66
82
 
67
83
  - Warned when legacy inline `LINE:TEXT` lines are accepted as payload continuations only when inside a pending multi-line `A-B:` replacement
68
84
 
85
+ ### Fixed
86
+
87
+ - Fixed runtime model registry refresh and cache loading so providers with authoritative dynamic catalogs, including Synthetic, do not re-add deprecated bundled model IDs after discovery ([#1417](https://github.com/can1357/oh-my-pi/issues/1417)).
88
+
69
89
  ## [15.5.2] - 2026-05-26
70
90
  ### Breaking Changes
71
91
 
@@ -8867,4 +8887,4 @@ Initial public release.
8867
8887
  - Git branch display in footer
8868
8888
  - Message queueing during streaming responses
8869
8889
  - OAuth integration for Gmail and Google Calendar access
8870
- - HTML export with syntax highlighting and collapsible sections
8890
+ - HTML export with syntax highlighting and collapsible sections
@@ -2393,6 +2393,15 @@ export declare const SETTINGS_SCHEMA: {
2393
2393
  readonly description: "Enable the calculator tool for basic calculations";
2394
2394
  };
2395
2395
  };
2396
+ readonly "tts.enabled": {
2397
+ readonly type: "boolean";
2398
+ readonly default: false;
2399
+ readonly ui: {
2400
+ readonly tab: "tools";
2401
+ readonly label: "Text-to-Speech";
2402
+ readonly description: "Enable the tts tool for xAI Grok Voice speech synthesis";
2403
+ };
2404
+ };
2396
2405
  readonly "recipe.enabled": {
2397
2406
  readonly type: "boolean";
2398
2407
  readonly default: true;
@@ -3118,7 +3127,7 @@ export declare const SETTINGS_SCHEMA: {
3118
3127
  };
3119
3128
  readonly "providers.image": {
3120
3129
  readonly type: "enum";
3121
- readonly values: readonly ["auto", "openai", "gemini", "openrouter"];
3130
+ readonly values: readonly ["auto", "openai", "antigravity", "xai", "gemini", "openrouter"];
3122
3131
  readonly default: "auto";
3123
3132
  readonly ui: {
3124
3133
  readonly tab: "providers";
@@ -3127,11 +3136,19 @@ export declare const SETTINGS_SCHEMA: {
3127
3136
  readonly options: readonly [{
3128
3137
  readonly value: "auto";
3129
3138
  readonly label: "Auto";
3130
- readonly description: "Priority: GPT model image tool > Antigravity > OpenRouter > Gemini";
3139
+ readonly description: "Priority: GPT model image tool > Antigravity > xAI > OpenRouter > Gemini";
3131
3140
  }, {
3132
3141
  readonly value: "openai";
3133
3142
  readonly label: "OpenAI";
3134
3143
  readonly description: "Uses the active GPT Responses/Codex model";
3144
+ }, {
3145
+ readonly value: "antigravity";
3146
+ readonly label: "Antigravity";
3147
+ readonly description: "Requires google-antigravity OAuth";
3148
+ }, {
3149
+ readonly value: "xai";
3150
+ readonly label: "xAI Grok Imagine";
3151
+ readonly description: "Requires xAI Grok OAuth or XAI_API_KEY";
3135
3152
  }, {
3136
3153
  readonly value: "gemini";
3137
3154
  readonly label: "Gemini";
@@ -3185,6 +3202,37 @@ export declare const SETTINGS_SCHEMA: {
3185
3202
  }];
3186
3203
  };
3187
3204
  };
3205
+ readonly "providers.openrouterVariant": {
3206
+ readonly type: "enum";
3207
+ readonly values: readonly ["default", "nitro", "floor", "online", "exacto"];
3208
+ readonly default: "default";
3209
+ readonly ui: {
3210
+ readonly tab: "providers";
3211
+ readonly label: "OpenRouter Routing";
3212
+ readonly description: "Default routing-variant suffix appended to OpenRouter model IDs (overridden when the selector already names a variant)";
3213
+ readonly options: readonly [{
3214
+ readonly value: "default";
3215
+ readonly label: "Default";
3216
+ readonly description: "No suffix; use OpenRouter's default routing";
3217
+ }, {
3218
+ readonly value: "nitro";
3219
+ readonly label: ":nitro";
3220
+ readonly description: "Prioritize throughput / lowest latency";
3221
+ }, {
3222
+ readonly value: "floor";
3223
+ readonly label: ":floor";
3224
+ readonly description: "Prioritize cheapest available provider";
3225
+ }, {
3226
+ readonly value: "online";
3227
+ readonly label: ":online";
3228
+ readonly description: "Enable OpenRouter's web-search plugin";
3229
+ }, {
3230
+ readonly value: "exacto";
3231
+ readonly label: ":exacto";
3232
+ readonly description: "Cherry-picked high-quality providers (only defined for select models)";
3233
+ }];
3234
+ };
3235
+ };
3188
3236
  readonly "providers.parallelFetch": {
3189
3237
  readonly type: "boolean";
3190
3238
  readonly default: true;
@@ -0,0 +1,40 @@
1
+ import type { ModelRegistry } from "../config/model-registry";
2
+ interface XAICredentials {
3
+ provider: "xai-oauth" | "xai";
4
+ apiKey: string;
5
+ baseURL: string;
6
+ }
7
+ export declare function ohMyPiXAIUserAgent(): string;
8
+ /**
9
+ * Resolve xAI credentials for HTTP tool calls.
10
+ *
11
+ * Credential priority:
12
+ * 1. xai-oauth — only when a *dedicated* xai-oauth source exists. Composed
13
+ * of two checks against the registry layer:
14
+ * a. `authStorage.hasNonEnvCredential("xai-oauth")` covers stored
15
+ * credentials (OAuth or api_key), runtime overrides (CLI
16
+ * `--api-key` for xai-oauth), config overrides (models.yml
17
+ * `providers.xai-oauth.apiKey`), and fallback resolvers.
18
+ * b. `$env.XAI_OAUTH_TOKEN` covers the xai-oauth-specific env var.
19
+ * `XAI_API_KEY` is intentionally NOT a signal here, even though the
20
+ * env-fallback map (`stream.ts: "xai-oauth"`) lets xai-oauth borrow it
21
+ * as a back-compat convenience: the borrow lets API-key-only setups
22
+ * satisfy the xai-oauth branch and then resolve baseUrl under
23
+ * xai-oauth instead of xai, silently bypassing `providers.xai.baseUrl`
24
+ * overrides for image/TTS traffic. The gate routes the borrow case to
25
+ * step 2 while preserving every dedicated xai-oauth path.
26
+ * 2. xai (plain API key). Delegates to ModelRegistry.getApiKeyForProvider
27
+ * which runs AuthStorage.getApiKey's full cascade: runtime override →
28
+ * models.yml config override → stored api_key credential → OAuth
29
+ * resolution → XAI_API_KEY env var → custom fallback resolver.
30
+ *
31
+ * baseURL: see `resolveXAIBaseURL` above. Resolved AFTER the credential
32
+ * decision so the scoped (provider, id) lookup is unambiguous. `modelId`
33
+ * is optional; probes / tool-availability checks pass `undefined` and fall
34
+ * through to env/default.
35
+ *
36
+ * Returns null when neither credential is available. Caller is responsible
37
+ * for surfacing an actionable error message in that case.
38
+ */
39
+ export declare function resolveXAIHttpCredentials(modelRegistry: ModelRegistry, modelId?: string): Promise<XAICredentials | null>;
40
+ export {};
@@ -454,6 +454,7 @@ export declare class AgentSession {
454
454
  get goalRuntime(): GoalRuntime;
455
455
  markPlanReferenceSent(): void;
456
456
  setPlanReferencePath(path: string): void;
457
+ getPlanReferencePath(): string;
457
458
  get clientBridge(): ClientBridge | undefined;
458
459
  setClientBridge(bridge: ClientBridge | undefined): void;
459
460
  getCheckpointState(): CheckpointState | undefined;
@@ -1,8 +1,10 @@
1
1
  import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
2
2
  import { type Component } from "@oh-my-pi/pi-tui";
3
+ import type { Settings } from "../config/settings";
3
4
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
4
5
  import { type Theme } from "../modes/theme/theme";
5
6
  import type { ToolSession } from "../sdk";
7
+ import type { AgentStorage } from "../session/agent-storage";
6
8
  import { type OutputMeta } from "./output-meta";
7
9
  import { type LineRange } from "./path-utils";
8
10
  export declare function isReadableUrlPath(value: string): boolean;
@@ -15,6 +17,20 @@ export interface ParsedReadUrlTarget {
15
17
  ranges?: readonly LineRange[];
16
18
  }
17
19
  export declare function parseReadUrlTarget(readPath: string): ParsedReadUrlTarget | null;
20
+ /**
21
+ * Render HTML to markdown using Parallel, jina, trafilatura, lynx, then the
22
+ * in-process native converter. The overall `timeout` budget bounds the call,
23
+ * but remote reader requests are additionally capped at `REMOTE_READER_MAX_MS`
24
+ * so that a hung remote endpoint cannot prevent local fallbacks from running.
25
+ * Only a real `userSignal` cancellation aborts the chain — remote per-attempt
26
+ * timeouts and the overall reader-mode timeout still allow later renderers
27
+ * (especially the purely-local native converter) to be tried.
28
+ */
29
+ export declare function renderHtmlToText(url: string, html: string, timeout: number, settings: Settings, userSignal: AbortSignal | undefined, storage: AgentStorage | null): Promise<{
30
+ content: string;
31
+ ok: boolean;
32
+ method: string;
33
+ }>;
18
34
  interface FetchImagePayload {
19
35
  data: string;
20
36
  mimeType: string;
@@ -2,7 +2,8 @@ import { type Model } from "@oh-my-pi/pi-ai";
2
2
  import * as z from "zod/v4";
3
3
  import { type ModelRegistry } from "../config/model-registry";
4
4
  import type { CustomTool } from "../extensibility/custom-tools/types";
5
- type ImageProvider = "antigravity" | "gemini" | "openai" | "openai-codex" | "openrouter";
5
+ export type ImageProvider = "antigravity" | "gemini" | "openai" | "openai-codex" | "openrouter" | "xai";
6
+ export type ImageProviderPreference = Exclude<ImageProvider, "openai-codex"> | "auto";
6
7
  declare const responseModalitySchema: z.ZodEnum<{
7
8
  IMAGE: "IMAGE";
8
9
  TEXT: "TEXT";
@@ -19,6 +20,8 @@ export declare const imageGenSchema: z.ZodObject<{
19
20
  aspect_ratio: z.ZodOptional<z.ZodEnum<{
20
21
  "16:9": "16:9";
21
22
  "1:1": "1:1";
23
+ "2:3": "2:3";
24
+ "3:2": "3:2";
22
25
  "3:4": "3:4";
23
26
  "4:3": "4:3";
24
27
  "9:16": "9:16";
@@ -70,8 +73,9 @@ interface InlineImageData {
70
73
  data: string;
71
74
  mimeType: string;
72
75
  }
76
+ export declare function isImageProviderPreference(value: unknown): value is ImageProviderPreference;
73
77
  /** Set the preferred image provider from settings */
74
- export declare function setPreferredImageProvider(provider: ImageProvider | "auto"): void;
78
+ export declare function setPreferredImageProvider(provider: ImageProviderPreference): void;
75
79
  export declare const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails>;
76
80
  export declare function getImageGenTools(modelRegistry?: ModelRegistry, activeModel?: Model): Promise<Array<CustomTool<typeof imageGenSchema, ImageGenToolDetails>>>;
77
81
  export declare function getImageGenToolsWithRegistry(modelRegistry: ModelRegistry, activeModel?: Model): Promise<Array<CustomTool<typeof imageGenSchema, ImageGenToolDetails>>>;
@@ -54,6 +54,7 @@ export * from "./search";
54
54
  export * from "./search-tool-bm25";
55
55
  export * from "./ssh";
56
56
  export * from "./todo-write";
57
+ export * from "./tts";
57
58
  export * from "./write";
58
59
  export * from "./yield";
59
60
  /** Tool type (AgentTool from pi-ai) */
@@ -2,12 +2,11 @@ import type { ToolSession } from ".";
2
2
  /**
3
3
  * Resolve a write/edit target to its absolute filesystem path.
4
4
  *
5
- * In plan mode, transparently redirects targets whose basename matches the
6
- * plan file's basename (e.g. a bare `PLAN.md` or `./PLAN.md`) to the canonical
7
- * plan file location at `state.planFilePath`. This lets `write` and `edit`
8
- * accept the unqualified plan filename and have the change land at the
9
- * session-scoped `local://PLAN.md` artifact instead of a stray cwd-relative
10
- * file the plan-mode guard would otherwise reject.
5
+ * In plan mode, transparently redirects `PLAN.md` aliases and targets whose
6
+ * basename matches the plan file's basename to the canonical plan file
7
+ * location at `state.planFilePath`. This lets `write` and `edit` accept the
8
+ * habitual plan filename after approval even when the active artifact has a
9
+ * titled path such as `local://APPROVED.md`.
11
10
  *
12
11
  * Outside plan mode (or when the basename does not match) this is a no-op.
13
12
  */
@@ -0,0 +1,18 @@
1
+ import * as z from "zod/v4";
2
+ import type { CustomTool } from "../extensibility/custom-tools/types";
3
+ type TtsCodec = "mp3" | "wav";
4
+ declare const ttsSchema: z.ZodObject<{
5
+ text: z.ZodString;
6
+ voice_id: z.ZodDefault<z.ZodString>;
7
+ language: z.ZodDefault<z.ZodString>;
8
+ output_path: z.ZodString;
9
+ sample_rate: z.ZodOptional<z.ZodNumber>;
10
+ bit_rate: z.ZodOptional<z.ZodNumber>;
11
+ }, z.core.$strip>;
12
+ interface TtsToolDetails {
13
+ bytes: number;
14
+ voiceId: string;
15
+ codec: TtsCodec;
16
+ }
17
+ export declare const ttsTool: CustomTool<typeof ttsSchema, TtsToolDetails>;
18
+ export {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "15.5.6",
4
+ "version": "15.5.7",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -47,13 +47,13 @@
47
47
  "@agentclientprotocol/sdk": "0.21.0",
48
48
  "@babel/parser": "^7.29.3",
49
49
  "@mozilla/readability": "^0.6.0",
50
- "@oh-my-pi/hashline": "15.5.6",
51
- "@oh-my-pi/omp-stats": "15.5.6",
52
- "@oh-my-pi/pi-agent-core": "15.5.6",
53
- "@oh-my-pi/pi-ai": "15.5.6",
54
- "@oh-my-pi/pi-natives": "15.5.6",
55
- "@oh-my-pi/pi-tui": "15.5.6",
56
- "@oh-my-pi/pi-utils": "15.5.6",
50
+ "@oh-my-pi/hashline": "15.5.7",
51
+ "@oh-my-pi/omp-stats": "15.5.7",
52
+ "@oh-my-pi/pi-agent-core": "15.5.7",
53
+ "@oh-my-pi/pi-ai": "15.5.7",
54
+ "@oh-my-pi/pi-natives": "15.5.7",
55
+ "@oh-my-pi/pi-tui": "15.5.7",
56
+ "@oh-my-pi/pi-utils": "15.5.7",
57
57
  "@puppeteer/browsers": "^2.13.0",
58
58
  "@types/turndown": "5.0.6",
59
59
  "@xterm/headless": "^6.0.0",
@@ -291,6 +291,12 @@ export function mergeDiscoveredModel<TApi extends Api>(
291
291
  return model;
292
292
  }
293
293
 
294
+ const AUTHORITATIVE_RUNTIME_CATALOG_PROVIDERS = new Set<string>(
295
+ PROVIDER_DESCRIPTORS.filter(descriptor => descriptor.dynamicModelsAuthoritative).map(
296
+ descriptor => descriptor.providerId,
297
+ ),
298
+ );
299
+
294
300
  function isAuthoritativeProjectCatalogModel(model: Model<Api>): boolean {
295
301
  return (
296
302
  model.provider === "google-vertex" &&
@@ -323,6 +329,11 @@ interface DiscoveryProviderConfig {
323
329
  optional?: boolean;
324
330
  }
325
331
 
332
+ interface BuiltInDiscoveryResult {
333
+ models: Model<Api>[];
334
+ authoritativeProviders: Set<string>;
335
+ }
336
+
326
337
  export type ProviderDiscoveryStatus = "idle" | "ok" | "empty" | "cached" | "unavailable" | "unauthenticated";
327
338
 
328
339
  export interface ProviderDiscoveryState {
@@ -914,6 +925,11 @@ export class ModelRegistry {
914
925
  cachedAuthoritativeProviders.add(provider);
915
926
  }
916
927
  }
928
+ for (const provider of cachedStandardResult.authoritativeFreshProviders) {
929
+ if (AUTHORITATIVE_RUNTIME_CATALOG_PROVIDERS.has(provider)) {
930
+ cachedAuthoritativeProviders.add(provider);
931
+ }
932
+ }
917
933
  if (cachedAuthoritativeProviders.size > 0) {
918
934
  builtInModels = dropProviderModels(builtInModels, cachedAuthoritativeProviders);
919
935
  }
@@ -1253,12 +1269,12 @@ export class ModelRegistry {
1253
1269
  : Promise.all(
1254
1270
  selectedDiscoverableProviders.map(provider => this.#discoverProviderModels(provider, strategy)),
1255
1271
  ).then(results => results.flat());
1256
- const [configuredDiscovered, builtInDiscovered] = await Promise.all([
1272
+ const [configuredDiscovered, builtInDiscovery] = await Promise.all([
1257
1273
  configuredDiscoveriesPromise,
1258
1274
  this.#discoverBuiltInProviderModels(strategy, providerFilter),
1259
1275
  ]);
1260
- const discovered = [...configuredDiscovered, ...builtInDiscovered];
1261
- if (discovered.length === 0) {
1276
+ const discovered = [...configuredDiscovered, ...builtInDiscovery.models];
1277
+ if (discovered.length === 0 && builtInDiscovery.authoritativeProviders.size === 0) {
1262
1278
  return;
1263
1279
  }
1264
1280
  const discoveredModels = this.#applyHardcodedModelPolicies(
@@ -1271,6 +1287,9 @@ export class ModelRegistry {
1271
1287
  ),
1272
1288
  );
1273
1289
  const authoritativeProviders = providersWithAuthoritativeProjectCatalog(discoveredModels);
1290
+ for (const provider of builtInDiscovery.authoritativeProviders) {
1291
+ authoritativeProviders.add(provider);
1292
+ }
1274
1293
  const baseModels =
1275
1294
  authoritativeProviders.size > 0 ? dropProviderModels(this.#models, authoritativeProviders) : this.#models;
1276
1295
  const resolved = this.#mergeResolvedModels(baseModels, discoveredModels);
@@ -1385,7 +1404,7 @@ export class ModelRegistry {
1385
1404
  async #discoverBuiltInProviderModels(
1386
1405
  strategy: ModelRefreshStrategy,
1387
1406
  providerFilter?: ReadonlySet<string>,
1388
- ): Promise<Model<Api>[]> {
1407
+ ): Promise<BuiltInDiscoveryResult> {
1389
1408
  // Skip providers already handled by configured discovery (e.g. user-configured ollama with discovery.type)
1390
1409
  const configuredDiscoveryProviders = new Set(this.#discoverableProviders.map(p => p.provider));
1391
1410
  const managerOptions = (await this.#collectBuiltInModelManagerOptions()).filter(opts => {
@@ -1395,12 +1414,20 @@ export class ModelRegistry {
1395
1414
  return providerFilter ? providerFilter.has(opts.providerId) : true;
1396
1415
  });
1397
1416
  if (managerOptions.length === 0) {
1398
- return [];
1417
+ return { models: [], authoritativeProviders: new Set() };
1399
1418
  }
1400
1419
  const discoveries = await Promise.all(
1401
1420
  managerOptions.map(options => this.#discoverWithModelManager(options, strategy)),
1402
1421
  );
1403
- return discoveries.flat();
1422
+ const authoritativeProviders = new Set<string>();
1423
+ const models: Model<Api>[] = [];
1424
+ for (const discovery of discoveries) {
1425
+ models.push(...discovery.models);
1426
+ for (const provider of discovery.authoritativeProviders) {
1427
+ authoritativeProviders.add(provider);
1428
+ }
1429
+ }
1430
+ return { models, authoritativeProviders };
1404
1431
  }
1405
1432
 
1406
1433
  async #collectBuiltInModelManagerOptions(): Promise<ModelManagerOptions<Api>[]> {
@@ -1482,19 +1509,24 @@ export class ModelRegistry {
1482
1509
  async #discoverWithModelManager(
1483
1510
  options: ModelManagerOptions<Api>,
1484
1511
  strategy: ModelRefreshStrategy,
1485
- ): Promise<Model<Api>[]> {
1512
+ ): Promise<BuiltInDiscoveryResult> {
1486
1513
  try {
1487
1514
  const manager = createModelManager({ ...options, cacheDbPath: this.#cacheDbPath });
1488
1515
  const result = await manager.refresh(strategy);
1489
- return result.models.map(model =>
1516
+ const models = result.models.map(model =>
1490
1517
  model.provider === options.providerId ? model : { ...model, provider: options.providerId },
1491
1518
  );
1519
+ const authoritativeProviders = new Set<string>();
1520
+ if (options.dynamicModelsAuthoritative && !result.stale) {
1521
+ authoritativeProviders.add(options.providerId);
1522
+ }
1523
+ return { models, authoritativeProviders };
1492
1524
  } catch (error) {
1493
1525
  logger.warn("model discovery failed for provider", {
1494
1526
  provider: options.providerId,
1495
1527
  error: error instanceof Error ? error.message : String(error),
1496
1528
  });
1497
- return [];
1529
+ return { models: [], authoritativeProviders: new Set() };
1498
1530
  }
1499
1531
  }
1500
1532
 
@@ -2036,6 +2036,15 @@ export const SETTINGS_SCHEMA = {
2036
2036
  },
2037
2037
  },
2038
2038
 
2039
+ "tts.enabled": {
2040
+ type: "boolean",
2041
+ default: false,
2042
+ ui: {
2043
+ tab: "tools",
2044
+ label: "Text-to-Speech",
2045
+ description: "Enable the tts tool for xAI Grok Voice speech synthesis",
2046
+ },
2047
+ },
2039
2048
  "recipe.enabled": {
2040
2049
  type: "boolean",
2041
2050
  default: true,
@@ -2698,7 +2707,7 @@ export const SETTINGS_SCHEMA = {
2698
2707
  },
2699
2708
  "providers.image": {
2700
2709
  type: "enum",
2701
- values: ["auto", "openai", "gemini", "openrouter"] as const,
2710
+ values: ["auto", "openai", "antigravity", "xai", "gemini", "openrouter"] as const,
2702
2711
  default: "auto",
2703
2712
  ui: {
2704
2713
  tab: "providers",
@@ -2708,9 +2717,19 @@ export const SETTINGS_SCHEMA = {
2708
2717
  {
2709
2718
  value: "auto",
2710
2719
  label: "Auto",
2711
- description: "Priority: GPT model image tool > Antigravity > OpenRouter > Gemini",
2720
+ description: "Priority: GPT model image tool > Antigravity > xAI > OpenRouter > Gemini",
2712
2721
  },
2713
2722
  { value: "openai", label: "OpenAI", description: "Uses the active GPT Responses/Codex model" },
2723
+ {
2724
+ value: "antigravity",
2725
+ label: "Antigravity",
2726
+ description: "Requires google-antigravity OAuth",
2727
+ },
2728
+ {
2729
+ value: "xai",
2730
+ label: "xAI Grok Imagine",
2731
+ description: "Requires xAI Grok OAuth or XAI_API_KEY",
2732
+ },
2714
2733
  { value: "gemini", label: "Gemini", description: "Requires GEMINI_API_KEY" },
2715
2734
  { value: "openrouter", label: "OpenRouter", description: "Requires OPENROUTER_API_KEY" },
2716
2735
  ],
@@ -2748,6 +2767,28 @@ export const SETTINGS_SCHEMA = {
2748
2767
  },
2749
2768
  },
2750
2769
 
2770
+ "providers.openrouterVariant": {
2771
+ type: "enum",
2772
+ values: ["default", "nitro", "floor", "online", "exacto"] as const,
2773
+ default: "default",
2774
+ ui: {
2775
+ tab: "providers",
2776
+ label: "OpenRouter Routing",
2777
+ description:
2778
+ "Default routing-variant suffix appended to OpenRouter model IDs (overridden when the selector already names a variant)",
2779
+ options: [
2780
+ { value: "default", label: "Default", description: "No suffix; use OpenRouter's default routing" },
2781
+ { value: "nitro", label: ":nitro", description: "Prioritize throughput / lowest latency" },
2782
+ { value: "floor", label: ":floor", description: "Prioritize cheapest available provider" },
2783
+ { value: "online", label: ":online", description: "Enable OpenRouter's web-search plugin" },
2784
+ {
2785
+ value: "exacto",
2786
+ label: ":exacto",
2787
+ description: "Cherry-picked high-quality providers (only defined for select models)",
2788
+ },
2789
+ ],
2790
+ },
2791
+ },
2751
2792
  "providers.parallelFetch": {
2752
2793
  type: "boolean",
2753
2794
  default: true,
@@ -0,0 +1,124 @@
1
+ // Ported from NousResearch/hermes-agent (MIT) — tools/xai_http.py.
2
+
3
+ import { getBundledModels } from "@oh-my-pi/pi-ai";
4
+ import { $env } from "@oh-my-pi/pi-utils";
5
+ import type { ModelRegistry } from "../config/model-registry";
6
+
7
+ const DEFAULT_BASE_URL = "https://api.x.ai/v1";
8
+
9
+ interface XAICredentials {
10
+ provider: "xai-oauth" | "xai";
11
+ apiKey: string;
12
+ baseURL: string;
13
+ }
14
+
15
+ export function ohMyPiXAIUserAgent(): string {
16
+ return "oh-my-pi/xai";
17
+ }
18
+
19
+ type XAIProvider = "xai-oauth" | "xai";
20
+
21
+ /**
22
+ * Resolve the HTTP base URL for an xAI tool call.
23
+ *
24
+ * Precedence:
25
+ * 1. `model.baseUrl` from the registry IF the user pinned a per-model
26
+ * override — i.e. `merged.baseUrl` differs from the seeded/bundled
27
+ * default for the (provider, id) pair. Mirrors the chat path's per-model
28
+ * contract (`openai-responses.ts: model.baseUrl`).
29
+ * 2. `ModelRegistry.getProviderBaseUrl(provider)` — provider-level override
30
+ * (e.g. `providers.xai-oauth.baseUrl` from models.yml). Reached when the
31
+ * modelId does not appear in the registry under this provider, which
32
+ * happens for tool-only ids like `grok-imagine-image` that
33
+ * `applyXAIOAuthCuration` filters out via `XAI_NON_CHAT_PREFIXES`.
34
+ * Without this leg, a registry-configured proxy is silently bypassed for
35
+ * image/TTS traffic.
36
+ * 3. `XAI_BASE_URL` env var (legacy global override, preserved).
37
+ * 4. `DEFAULT_BASE_URL = "https://api.x.ai/v1"`.
38
+ *
39
+ * The override gate at step 1 uses `bundled?.baseUrl ?? DEFAULT_BASE_URL` as
40
+ * the canonical default sentinel. For xai (which has bundled entries) this
41
+ * compares against the bundled value; for xai-oauth (no bundled entries —
42
+ * models.json carries no xai-oauth records when the seed is absent, the
43
+ * picker is seeded statically from `xaiOAuthModelManagerOptions` with
44
+ * `baseUrl: DEFAULT_BASE_URL`) the sentinel falls back to DEFAULT_BASE_URL
45
+ * so the env leg remains reachable. Without that fallback, every xai-oauth
46
+ * model id forces `!bundled === true` and short-circuits XAI_BASE_URL
47
+ * silently. Lookup is scoped to (provider, id); matching by id alone would
48
+ * let xai-oauth entries hijack a xai tool call (or vice versa) when the
49
+ * same model id ships under both descriptors.
50
+ */
51
+ function resolveXAIBaseURL(modelRegistry: ModelRegistry, provider: XAIProvider, modelId: string | undefined): string {
52
+ if (modelId) {
53
+ const merged = modelRegistry.getAll().find(m => m.id === modelId && m.provider === provider);
54
+ if (merged?.baseUrl) {
55
+ const bundled = getBundledModels(provider as Parameters<typeof getBundledModels>[0]).find(
56
+ m => m.id === modelId,
57
+ );
58
+ const providerDefault = bundled?.baseUrl ?? DEFAULT_BASE_URL;
59
+ if (merged.baseUrl !== providerDefault) {
60
+ return merged.baseUrl.replace(/\/$/, "");
61
+ }
62
+ }
63
+ }
64
+ const providerBaseUrl = modelRegistry.getProviderBaseUrl(provider);
65
+ if (providerBaseUrl) {
66
+ const normalized = providerBaseUrl.replace(/\/$/, "");
67
+ if (normalized !== DEFAULT_BASE_URL) return normalized;
68
+ }
69
+ return ($env.XAI_BASE_URL || DEFAULT_BASE_URL).replace(/\/$/, "");
70
+ }
71
+
72
+ /**
73
+ * Resolve xAI credentials for HTTP tool calls.
74
+ *
75
+ * Credential priority:
76
+ * 1. xai-oauth — only when a *dedicated* xai-oauth source exists. Composed
77
+ * of two checks against the registry layer:
78
+ * a. `authStorage.hasNonEnvCredential("xai-oauth")` covers stored
79
+ * credentials (OAuth or api_key), runtime overrides (CLI
80
+ * `--api-key` for xai-oauth), config overrides (models.yml
81
+ * `providers.xai-oauth.apiKey`), and fallback resolvers.
82
+ * b. `$env.XAI_OAUTH_TOKEN` covers the xai-oauth-specific env var.
83
+ * `XAI_API_KEY` is intentionally NOT a signal here, even though the
84
+ * env-fallback map (`stream.ts: "xai-oauth"`) lets xai-oauth borrow it
85
+ * as a back-compat convenience: the borrow lets API-key-only setups
86
+ * satisfy the xai-oauth branch and then resolve baseUrl under
87
+ * xai-oauth instead of xai, silently bypassing `providers.xai.baseUrl`
88
+ * overrides for image/TTS traffic. The gate routes the borrow case to
89
+ * step 2 while preserving every dedicated xai-oauth path.
90
+ * 2. xai (plain API key). Delegates to ModelRegistry.getApiKeyForProvider
91
+ * which runs AuthStorage.getApiKey's full cascade: runtime override →
92
+ * models.yml config override → stored api_key credential → OAuth
93
+ * resolution → XAI_API_KEY env var → custom fallback resolver.
94
+ *
95
+ * baseURL: see `resolveXAIBaseURL` above. Resolved AFTER the credential
96
+ * decision so the scoped (provider, id) lookup is unambiguous. `modelId`
97
+ * is optional; probes / tool-availability checks pass `undefined` and fall
98
+ * through to env/default.
99
+ *
100
+ * Returns null when neither credential is available. Caller is responsible
101
+ * for surfacing an actionable error message in that case.
102
+ */
103
+ export async function resolveXAIHttpCredentials(
104
+ modelRegistry: ModelRegistry,
105
+ modelId?: string,
106
+ ): Promise<XAICredentials | null> {
107
+ const hasDedicatedXaiOAuth =
108
+ modelRegistry.authStorage.hasNonEnvCredential("xai-oauth") || Boolean($env.XAI_OAUTH_TOKEN);
109
+ if (hasDedicatedXaiOAuth) {
110
+ const oauthKey = await modelRegistry.getApiKeyForProvider("xai-oauth");
111
+ if (oauthKey) {
112
+ const baseURL = resolveXAIBaseURL(modelRegistry, "xai-oauth", modelId);
113
+ return { provider: "xai-oauth", apiKey: oauthKey, baseURL };
114
+ }
115
+ }
116
+
117
+ const apiKey = await modelRegistry.getApiKeyForProvider("xai");
118
+ if (apiKey) {
119
+ const baseURL = resolveXAIBaseURL(modelRegistry, "xai", modelId);
120
+ return { provider: "xai", apiKey, baseURL };
121
+ }
122
+
123
+ return null;
124
+ }