diff --git a/.env.example b/.env.example index 0b0e324ec..9141d96e4 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,38 @@ GROK_API_KEY=your_grok_api_key_here # Optional: Telegram bot token for /connect → Telegram (see README) # TELEGRAM_BOT_TOKEN=123456:ABC... + +# ---------------------------------------------------------------------- +# Vertex AI Grok backend (optional) +# +# When GROK_PROVIDER=vertex (or `--provider vertex`) is set, the CLI +# routes Grok traffic through Google Cloud Vertex AI instead of the +# native xAI API. Authenticate locally with: +# +# gcloud auth application-default login +# +# and verify with: +# +# gcloud auth application-default print-access-token +# ---------------------------------------------------------------------- + +# Select the Vertex backend. Default is "xai". +# GROK_PROVIDER=vertex + +# Required for Vertex mode. The Google Cloud project that hosts the +# Vertex AI API call. Falls back to GCP_PROJECT_ID if unset. +# GROK_VERTEX_PROJECT_ID=my-gcp-project + +# Optional. Vertex region for the partner-model endpoint. Default is +# "global", which routes to the global Vertex endpoint as documented +# in the Grok-on-Vertex model cards. Use a region (e.g. us-central1, +# europe-west1) only when required by org policy or quota. +# GROK_VERTEX_LOCATION=global + +# Optional. Override the Vertex API host (default: https://aiplatform.googleapis.com). +# Rarely needed; useful for proxies or staging endpoints. +# GROK_VERTEX_BASE_URL=https://aiplatform.googleapis.com + +# Authentication is currently ADC only (`gcloud auth application-default login`). +# Pre-minted OAuth tokens and service-account-bound API keys are tracked as a +# follow-up; no env var to set here for v1. diff --git a/.npmignore b/.npmignore index 11ba487f6..1fcddf391 100644 --- a/.npmignore +++ b/.npmignore @@ -1,6 +1,9 @@ # Source files (only include built dist/) src/ tsconfig.json +vitest.config.ts +biome.json +test-vertex-integration.ts # Lock files from other package managers yarn.lock @@ -50,6 +53,9 @@ tests/ *.test.ts *.spec.js *.spec.ts +dist/**/*.test.* +dist/**/*.spec.* +dist/grok-standalone* coverage/ .nyc_output/ @@ -62,6 +68,15 @@ docs/ .git/ .gitignore +# Local development, agent, and editor runtime state +.cursor/ +.omx/ +.codex/ +.agents/ +.claude/ +.grok/ +.husky/ + # CI/CD files .github/ .gitlab-ci.yml @@ -77,4 +92,4 @@ appveyor.yml # Include only what's needed for the package # The dist/ folder should be included (not ignored) -# package.json and README.md should be included \ No newline at end of file +# package.json and README.md should be included diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d10530c6..1cd504679 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Vertex AI Grok backend.** Select with `--provider vertex` or `GROK_PROVIDER=vertex`. Authenticates via Google Application Default Credentials (`gcloud auth application-default login`), uses the documented Vertex Grok partner-model endpoint, and exposes the four official SKUs (`grok-4.20-{reasoning,non-reasoning}`, `grok-4.1-fast-{reasoning,non-reasoning}`). xAI behavior is unchanged for existing users; the active provider is settable via env var, the new `--provider` CLI flag, or `provider` in `~/.grok/user-settings.json`. +- New `GrokProviderAdapter` contract at `src/providers/`. The Agent, tools, media, and helpers now depend on a backend-agnostic interface with explicit capability flags. xAI-only features (Batch API, hosted web/X search, image and video generation, Telegram STT) return typed errors instead of opaque transport failures when used under a Vertex session. - Dedicated grep tool powered by npm ripgrep WASM (#263) - `/btw` command for side questions (#264) +### Fixed +- TUI: plan-mode questions no longer leak into the chat input. +- TUI: async tool-result and plan-question updates now repaint without waiting for a keyboard event. +- TUI: typing a status nudge ("continue", "status") during an active turn now triggers a repaint instead of enqueueing an accidental follow-up message. +- npm package no longer ships local agent/editor runtime state, stale dist artifacts, or platform-specific standalone binaries; the installed `grok` entrypoint now retains its executable bit across `tsc` builds. +- Local PR-tested installs no longer get clobbered by the auto-updater pulling the latest public release. + ### Changed - Switched Telegram voice/audio transcription from whisper.cpp to Grok STT (`/v1/stt`); removed `whisper-cli`, `ffmpeg`, and model-download requirements (#266, #265) - Install script warns when auto-resolving to a pre-release version (#269) diff --git a/README.md b/README.md index 4846d2b1d..2ca3cbf38 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,75 @@ Optional: `**GROK_BASE_URL**` (default `https://api.x.ai/v1`), `**GROK_MODEL**`, --- +## Vertex AI Grok (Google Cloud backend) + +`grok-cli` can route Grok traffic through **Google Cloud Vertex AI** instead of the native xAI API. The four documented Vertex Grok SKUs are supported: + +- `grok-4.20-reasoning` (200K context — default chat model) +- `grok-4.20-non-reasoning` +- `grok-4.1-fast-reasoning` (128K context) +- `grok-4.1-fast-non-reasoning` (128K context — default title/recap model) + +### Prerequisites + +1. Enable the Vertex AI API on your GCP project. +2. Authenticate locally with **Application Default Credentials**: + ```bash + gcloud auth application-default login + gcloud auth application-default print-access-token # verify + ``` + +### Run + +```bash +# CLI flag +grok --provider vertex + +# Or via env var +GROK_PROVIDER=vertex GROK_VERTEX_PROJECT_ID=my-gcp-project grok + +# Or save in ~/.grok/user-settings.json +{ + "provider": "vertex", + "vertex": { + "projectId": "my-gcp-project", + "location": "global" + } +} +``` + +### What works on Vertex + +- Chat and streaming +- Local function/tool calling (bash, file edits, LSP, etc.) +- Structured outputs +- Image inputs (vision) +- Reasoning vs non-reasoning by SKU selection + +### What's xAI-only (returns a typed error on Vertex) + +- xAI `/responses` endpoint and the multi-agent SKUs +- Hosted web search and X search (`search_web`, `search_x`) +- Image generation (`grok-imagine-image`) and video generation (`grok-imagine-video`) +- xAI Batch API (`--batch-api`) +- Telegram audio transcription via Grok STT + +### Vertex environment variables + +| Var | Purpose | Default | +|---|---|---| +| `GROK_PROVIDER` | Active backend (`xai` or `vertex`) | `xai` | +| `GROK_VERTEX_PROJECT_ID` | GCP project for Vertex requests | — | +| `GCP_PROJECT_ID` | Fallback for `GROK_VERTEX_PROJECT_ID` | — | +| `GROK_VERTEX_LOCATION` | Vertex region (e.g. `us-central1`) | `global` | +| `GROK_VERTEX_BASE_URL` | Vertex API host override | `https://aiplatform.googleapis.com` | + +> Authentication is currently **ADC only** (`gcloud auth application-default login`). Pre-minted OAuth tokens and service-account-bound API keys are documented in the Google Cloud quickstart but require additional CLI plumbing — tracked as a follow-up. + +> **Disclaimer:** Vertex AI access to Grok is provided by Google Cloud as a partner integration. This project is independent of both xAI Corp. and Google. Pricing and availability are governed by your Google Cloud contract. + +--- + ## Telegram (remote control) — short version 1. Create a bot with [@BotFather](https://t.me/BotFather), copy the token. diff --git a/bun.lock b/bun.lock index a77cbfb40..423c2bf82 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,7 @@ "commander": "^12.1.0", "diff": "^8.0.3", "dotenv": "^16.6.1", + "google-auth-library": "^10.6.2", "grammy": "^1.41.1", "react": "^19.2.4", "ripgrep": "^0.3.1", @@ -795,6 +796,8 @@ "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "buffer-reverse": ["buffer-reverse@1.0.1", "", {}, "sha512-M87YIUBsZ6N924W57vDwT/aOu8hw7ZgdByz6ijksLjmHJELBASmYTTlNHRgjE+pTsT9oJXGaDSgqqwfdHotDUg=="], "bufferutil": ["bufferutil@4.1.0", "", { "dependencies": { "node-gyp-build": "^4.3.0" } }, "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw=="], @@ -905,6 +908,8 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + "date-fns": ["date-fns@3.3.1", "", {}, "sha512-y8e109LYGgoQDveiEBD3DYXKba1jWf5BA8YU1FL5Tvm0BTdEfy54WLCwnuYWZNnzzvALy/QQ4Hov+Q9RVRv+Zw=="], "dayjs": ["dayjs@1.11.13", "", {}, "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="], @@ -947,6 +952,8 @@ "duplexify": ["duplexify@4.1.3", "", { "dependencies": { "end-of-stream": "^1.4.1", "inherits": "^2.0.3", "readable-stream": "^3.1.1", "stream-shift": "^1.0.2" } }, "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + "eciesjs": ["eciesjs@0.4.18", "", { "dependencies": { "@ecies/ciphers": "^0.2.5", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" } }, "sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ=="], "ed2curve": ["ed2curve@0.3.0", "", { "dependencies": { "tweetnacl": "1.x.x" } }, "sha512-8w2fmmq3hv9rCrcI7g9hms2pMunQr1JINfcjwR9tAyZqhtyaMN991lF/ZfHfr5tzZQ8c7y7aBgZbjfbd0fjFwQ=="], @@ -1033,6 +1040,8 @@ "express-rate-limit": ["express-rate-limit@8.3.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + "extension-port-stream": ["extension-port-stream@3.0.0", "", { "dependencies": { "readable-stream": "^3.6.2 || ^4.4.2", "webextension-polyfill": ">=0.10.0 <1.0" } }, "sha512-an2S5quJMiy5bnZKEf6AkfH/7r8CzHvhchU40gxN+OM6HPhe7Z9T1FUychcf2M9PpPOO0Hf7BAEfJkw2TDIBDw=="], "eyes": ["eyes@0.1.8", "", {}, "sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ=="], @@ -1053,6 +1062,8 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + "figures": ["figures@3.2.0", "", { "dependencies": { "escape-string-regexp": "^1.0.5" } }, "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg=="], "file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], @@ -1071,6 +1082,8 @@ "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], @@ -1081,6 +1094,10 @@ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "gaxios": ["gaxios@7.1.4", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="], + + "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], + "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], @@ -1095,6 +1112,10 @@ "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + "google-auth-library": ["google-auth-library@10.6.2", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw=="], + + "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], @@ -1205,6 +1226,8 @@ "js-sha3": ["js-sha3@0.8.0", "", {}, "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q=="], + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + "json-parse-even-better-errors": ["json-parse-even-better-errors@5.0.0", "", {}, "sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ=="], "json-rpc-engine": ["json-rpc-engine@6.1.0", "", { "dependencies": { "@metamask/safe-event-emitter": "^2.0.0", "eth-rpc-errors": "^4.0.2" } }, "sha512-NEdLrtrq1jUZyfjkr9OCz9EzCNhnRyWtt1PAnvnhwy6e8XETS0Dtc+ZNCO2gvuAoKsIn2+vCSowXTYE4CkgnAQ=="], @@ -1227,6 +1250,10 @@ "just-diff-apply": ["just-diff-apply@5.5.0", "", {}, "sha512-OYTthRfSh55WOItVqwpefPtNt2VdKsq5AnAK6apdtR6yCH8pr0CmSr710J0Mf+WdQy7K/OzMy7K2MgAfdQURDw=="], + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + "keccak": ["keccak@3.0.4", "", { "dependencies": { "node-addon-api": "^2.0.0", "node-gyp-build": "^4.2.0", "readable-stream": "^3.6.0" } }, "sha512-3vKuW0jV8J3XNTzvfyicFR5qvxrSAGl7KIhvgOu5cmWwM7tZRj3fMbj/pfIf4be7aznbc+prBWGjywox/g2Y6Q=="], "keyvaluestorage-interface": ["keyvaluestorage-interface@1.0.0", "", {}, "sha512-8t6Q3TclQ4uZynJY9IGr2+SsIGwK9JHcO6ootkHCGA0CrQCRy+VkouYNO2xicET6b9al7QKzpebNow+gkpCL8g=="], @@ -1341,6 +1368,8 @@ "node-addon-api": ["node-addon-api@5.1.0", "", {}, "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA=="], + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], @@ -1773,6 +1802,8 @@ "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], "web3-utils": ["web3-utils@1.10.4", "", { "dependencies": { "@ethereumjs/util": "^8.1.0", "bn.js": "^5.2.1", "ethereum-bloom-filters": "^1.0.6", "ethereum-cryptography": "^2.1.2", "ethjs-unit": "0.1.6", "number-to-bn": "1.7.0", "randombytes": "^2.1.0", "utf8": "3.0.0" } }, "sha512-tsu8FiKJLk2PzhDl9fXbGUWTkkVXYhtTA+SmEFkKft+9BgwLxfCRpU96sWv7ICC8zixBNd3JURVoiR3dUXgP8A=="], @@ -2167,6 +2198,8 @@ "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "hash-base/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], diff --git a/package.json b/package.json index 150448f2d..26179f6e1 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,10 @@ }, "scripts": { "dev": "bun run src/index.ts", - "build": "tsc", + "clean:dist": "bun -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true })\"", + "build": "bun run clean:dist && tsc && bun -e \"import { chmodSync } from 'node:fs'; chmodSync('dist/index.js', 0o755)\"", "build:binary": "bun build --compile --outfile dist/grok-standalone ./src/index.ts", + "prepack": "bun run build", "start": "bun run dist/index.js", "typecheck": "tsc --noEmit", "test": "bunx vitest run", @@ -56,6 +58,7 @@ "commander": "^12.1.0", "diff": "^8.0.3", "dotenv": "^16.6.1", + "google-auth-library": "^10.6.2", "grammy": "^1.41.1", "react": "^19.2.4", "ripgrep": "^0.3.1", diff --git a/src/agent/agent.ts b/src/agent/agent.ts index bfb190191..4f5b999a6 100644 --- a/src/agent/agent.ts +++ b/src/agent/agent.ts @@ -18,7 +18,6 @@ import { generateRecap as genRecap, generateTitle as genTitle, resolveModelRuntime, - type XaiProvider, } from "../grok/client"; import { DEFAULT_MODEL, getModelInfo, normalizeModelId } from "../grok/models"; import { toolSetToBatchTools } from "../grok/tool-schemas"; @@ -40,6 +39,7 @@ import type { } from "../hooks/types"; import { shutdownWorkspaceLspManager } from "../lsp/runtime"; import { buildMcpToolSet } from "../mcp/runtime"; +import { createProvider as createProviderFromKind, type GrokProviderAdapter } from "../providers"; import { appendCompaction, appendMessages, @@ -72,10 +72,12 @@ import type { import { loadCustomInstructions } from "../utils/instructions"; import { type CustomSubagentConfig, + getActiveProvider, getCurrentModel, getModeSpecificModel, loadMcpServers, loadValidSubAgents, + resolveVertexSettings, type SandboxMode, type SandboxSettings, } from "../utils/settings"; @@ -531,8 +533,21 @@ function applyModelConstraints(system: string, modelId: string): string { ].join("\n"); } +function requireResponsesModel( + provider: GrokProviderAdapter, + contextLabel: string, +): NonNullable { + const responsesModel = provider.responsesModel; + if (!responsesModel) { + throw new Error( + `${contextLabel} requires the Responses API, which is not supported by the ${provider.kind} provider.`, + ); + } + return responsesModel.bind(provider); +} + export class Agent { - private provider: XaiProvider | null = null; + private provider: GrokProviderAdapter | null = null; private apiKey: string | null = null; private baseURL: string | null = null; private bash: BashTool; @@ -562,8 +577,13 @@ export class Agent { options: AgentOptions = {}, ) { this.baseURL = baseURL || null; + // For xAI: only construct the provider when an apiKey is supplied. + // For Vertex: construct when settings are present, regardless of apiKey + // (Vertex auth is handled by Google ADC, not a static key). if (apiKey) { this.setApiKey(apiKey, baseURL); + } else if (this.canBootstrapVertexProvider()) { + this.setApiKey("", baseURL); } this.bash = new BashTool(process.cwd(), { sandboxMode: options.sandboxMode ?? "off", @@ -649,14 +669,37 @@ export class Agent { this.sendTelegramFile = fn; } + /** + * True when the active provider has the credentials it needs to run. + * - xAI: requires an API key. + * - Vertex: requires Vertex settings (project id) regardless of apiKey. + * + * Kept named hasApiKey() for backward-compat with the existing UI surface + * which polls this method to decide whether to render the auth modal. + */ hasApiKey(): boolean { + if (getActiveProvider() === "vertex") { + return !!this.provider; + } return !!this.apiKey; } setApiKey(apiKey: string, baseURL = this.baseURL ?? undefined): void { this.apiKey = apiKey; this.baseURL = baseURL || null; - this.provider = createProvider(apiKey, baseURL); + this.provider = this.buildProvider(apiKey, baseURL); + } + + private buildProvider(apiKey: string, baseURL?: string): GrokProviderAdapter { + if (getActiveProvider() === "vertex") { + return createProviderFromKind({ kind: "vertex" }); + } + return createProvider(apiKey, baseURL); + } + + private canBootstrapVertexProvider(): boolean { + if (getActiveProvider() !== "vertex") return false; + return !!resolveVertexSettings().projectId; } getCwd(): string { @@ -943,12 +986,14 @@ export class Agent { } private getBatchClientOptions(signal?: AbortSignal): BatchClientOptions { - if (!this.apiKey) { - throw new Error("API key required. Add an API key to continue."); + const provider = this.requireProvider(); + if (!provider.capabilities.batchApi || !provider.getBatchClientApiKey) { + throw new Error( + `xAI Batch API is not available with the ${provider.kind} provider. Use streaming/headless mode, or switch to provider=xai with GROK_API_KEY configured.`, + ); } - return { - apiKey: this.apiKey, + apiKey: provider.getBatchClientApiKey(), baseURL: this.baseURL ?? undefined, signal, }; @@ -1067,7 +1112,7 @@ export class Agent { childRuntime.modelInfo?.supportsMaxOutputTokens === false ? undefined : Math.min(this.maxTokens, 8_192), - reasoningEffort: childRuntime.providerOptions?.xai.reasoningEffort, + reasoningEffort: childRuntime.providerOptions?.xai?.reasoningEffort, tools: batchTools, }), }, @@ -1248,7 +1293,10 @@ export class Agent { : this.modelId, ); const childRuntime = isVision - ? { ...resolveModelRuntime(provider, childModelId), model: provider.responses(childModelId) } + ? { + ...resolveModelRuntime(provider, childModelId), + model: requireResponsesModel(provider, "vision sub-agent")(childModelId), + } : resolveModelRuntime(provider, childModelId); if (isComputer && childRuntime.modelInfo?.supportsClientTools === false) { return { @@ -1518,7 +1566,7 @@ export class Agent { } private async compactForContext( - provider: XaiProvider, + provider: GrokProviderAdapter, system: string, contextWindow: number, signal: AbortSignal, @@ -1564,7 +1612,7 @@ export class Agent { private async *processMessageBatchTurn(args: { userModelMessage: ModelMessage; observer?: ProcessMessageObserver; - provider: XaiProvider; + provider: GrokProviderAdapter; subagents: CustomSubagentConfig[]; system: string; runtime: ReturnType; @@ -1646,7 +1694,7 @@ export class Agent { messages: [...this.messages, ...turnMessages], temperature: 0.7, maxOutputTokens: runtime.modelInfo?.supportsMaxOutputTokens === false ? undefined : this.maxTokens, - reasoningEffort: runtime.providerOptions?.xai.reasoningEffort, + reasoningEffort: runtime.providerOptions?.xai?.reasoningEffort, tools: batchTools, }), }, @@ -2196,8 +2244,13 @@ export class Agent { } } - private requireProvider(): XaiProvider { + private requireProvider(): GrokProviderAdapter { if (!this.provider) { + if (getActiveProvider() === "vertex") { + throw new Error( + "Vertex AI is selected but no project id is configured. Set GROK_VERTEX_PROJECT_ID (or GCP_PROJECT_ID), or save `vertex.projectId` to ~/.grok/user-settings.json.", + ); + } throw new Error("API key required. Add an API key to continue."); } diff --git a/src/agent/compaction.ts b/src/agent/compaction.ts index a39307055..9c1f18a01 100644 --- a/src/agent/compaction.ts +++ b/src/agent/compaction.ts @@ -1,5 +1,6 @@ import { generateText, type ModelMessage } from "ai"; -import { resolveModelRuntime, type XaiProvider } from "../grok/client"; +import { resolveModelRuntime } from "../grok/client"; +import type { GrokProviderAdapter } from "../providers"; import { containsEncryptedReasoning } from "./reasoning"; export interface CompactionSettings { @@ -391,7 +392,7 @@ export function serializeConversation(messages: ModelMessage[]): string { } async function summarizeConversation( - provider: XaiProvider, + provider: GrokProviderAdapter, modelId: string, messages: ModelMessage[], reserveTokens: number, @@ -432,7 +433,7 @@ async function summarizeConversation( } export async function generateCompactionSummary( - provider: XaiProvider, + provider: GrokProviderAdapter, modelId: string, preparation: PreparedCompaction, customInstructions?: string, diff --git a/src/audio/stt/engine.ts b/src/audio/stt/engine.ts index 26ba9f81f..5aae4d931 100644 --- a/src/audio/stt/engine.ts +++ b/src/audio/stt/engine.ts @@ -1,5 +1,5 @@ import type { TelegramSettings } from "../../utils/settings"; -import { getApiKey, getBaseURL, resolveTelegramAudioInputSettings } from "../../utils/settings"; +import { getActiveProvider, getApiKey, getBaseURL, resolveTelegramAudioInputSettings } from "../../utils/settings"; import { GrokSttEngine, type GrokSttTranscriptionResult } from "./grok-stt"; export interface AudioTranscriptionInput { @@ -18,6 +18,12 @@ export function createTelegramAudioInputEngine( telegramSettings: TelegramSettings | undefined, ): AudioTranscriptionEngine { const resolved = resolveTelegramAudioInputSettings(telegramSettings); + const provider = getActiveProvider(); + if (provider !== "xai") { + throw new Error( + `Telegram audio transcription via Grok STT is only available with the xAI provider; the active provider is ${provider}. Disable telegram.audioInput.enabled or switch to provider=xai.`, + ); + } const apiKey = getApiKey(); if (!apiKey) { throw new Error( diff --git a/src/grok/client.test.ts b/src/grok/client.test.ts index 07c8dc033..309bf3dae 100644 --- a/src/grok/client.test.ts +++ b/src/grok/client.test.ts @@ -1,6 +1,6 @@ -import { createXai } from "@ai-sdk/xai"; import type { generateText } from "ai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createProvider } from "../providers"; import * as settings from "../utils/settings"; import { generateRecap, resolveModelRuntime } from "./client"; @@ -13,7 +13,8 @@ vi.mock("ai", () => { }); describe("client", () => { - const mockProvider = createXai({ + const mockProvider = createProvider({ + kind: "xai", apiKey: "test-key", baseURL: "https://api.x.ai/v1", }); diff --git a/src/grok/client.ts b/src/grok/client.ts index 7d5445846..15052b134 100644 --- a/src/grok/client.ts +++ b/src/grok/client.ts @@ -1,13 +1,10 @@ -import { createXai } from "@ai-sdk/xai"; import { generateText } from "ai"; -import type { ModelInfo, ReasoningEffort } from "../types/index"; -import { getReasoningEffortForModel } from "../utils/settings"; -import { getEffectiveReasoningEffort, getModelInfo, normalizeModelId } from "./models"; -export type XaiProvider = ReturnType; -export type XaiChatModel = ReturnType; -export type XaiResponsesModel = ReturnType; -export type GrokRuntimeModel = XaiChatModel | XaiResponsesModel; +import { + createProvider as createProviderInternal, + type GrokProviderAdapter, + type ResolvedModelRuntime, +} from "../providers"; const DEFAULT_TITLE_MODEL = "grok-4.20-non-reasoning"; const DEFAULT_RECAP_MODEL = "grok-4.20-non-reasoning"; @@ -29,45 +26,36 @@ export interface GeneratedRecap extends GeneratedTextResult { recap: string; } -export interface ResolvedModelRuntime { - model: GrokRuntimeModel; - modelId: string; - modelInfo?: ModelInfo; - providerOptions?: { - xai: { - reasoningEffort: ReasoningEffort; - }; - }; -} +/** + * Backward-compatible re-exports. Prefer importing GrokProviderAdapter and + * ResolvedModelRuntime from "../providers" directly in new code. + */ +export type { GrokProviderAdapter, ResolvedModelRuntime } from "../providers"; -export function createProvider(apiKey: string, baseURL?: string): XaiProvider { - return createXai({ - apiKey, - baseURL: baseURL || process.env.GROK_BASE_URL || "https://api.x.ai/v1", - }); -} +/** + * @deprecated Alias retained so existing imports keep compiling. New code + * should reference GrokProviderAdapter from "../providers". + */ +export type XaiProvider = GrokProviderAdapter; -export function resolveModelRuntime(provider: XaiProvider, requestedModelId: string): ResolvedModelRuntime { - const modelId = normalizeModelId(requestedModelId); - const modelInfo = getModelInfo(modelId); - const reasoningEffort = getEffectiveReasoningEffort(modelId, getReasoningEffortForModel(modelId)); +/** + * Constructs the default (xAI) provider adapter. + * + * Kept on the legacy `(apiKey, baseURL?)` signature so the provider + * abstraction migration stays a single self-contained refactor. New code + * should call `createProvider({ kind, ... })` from "../providers" directly. + */ +export function createProvider(apiKey: string, baseURL?: string): GrokProviderAdapter { + return createProviderInternal({ kind: "xai", apiKey, baseURL }); +} - return { - model: modelInfo?.responsesOnly ? provider.responses(modelId) : provider(modelId), - modelId, - modelInfo, - providerOptions: reasoningEffort - ? { - xai: { - reasoningEffort, - }, - } - : undefined, - }; +/** Delegates to provider.resolveRuntime. Retained for legacy import paths. */ +export function resolveModelRuntime(provider: GrokProviderAdapter, requestedModelId: string): ResolvedModelRuntime { + return provider.resolveRuntime(requestedModelId); } -export async function generateTitle(provider: XaiProvider, userMessage: string): Promise { - const runtime = resolveModelRuntime(provider, DEFAULT_TITLE_MODEL); +export async function generateTitle(provider: GrokProviderAdapter, userMessage: string): Promise { + const runtime = provider.resolveRuntime(DEFAULT_TITLE_MODEL); try { const { text, usage } = await generateText({ model: runtime.model, @@ -98,11 +86,11 @@ export async function generateTitle(provider: XaiProvider, userMessage: string): } export async function generateRecap( - provider: XaiProvider, + provider: GrokProviderAdapter, transcript: string, signal?: AbortSignal, ): Promise { - const runtime = resolveModelRuntime(provider, DEFAULT_RECAP_MODEL); + const runtime = provider.resolveRuntime(DEFAULT_RECAP_MODEL); try { const { text, usage } = await generateText({ model: runtime.model, diff --git a/src/grok/media.test.ts b/src/grok/media.test.ts index 1de1eb995..def4b6fce 100644 --- a/src/grok/media.test.ts +++ b/src/grok/media.test.ts @@ -41,7 +41,9 @@ describe("media tools", () => { }); const provider = { - image: vi.fn((modelId: string) => ({ modelId })), + kind: "xai" as const, + capabilities: { imageGeneration: true }, + imageModel: vi.fn((modelId: string) => ({ modelId })), }; const result = await generateImageTool( @@ -66,7 +68,7 @@ describe("media tools", () => { }, }, }); - expect(provider.image).toHaveBeenCalledWith("grok-imagine-image"); + expect(provider.imageModel).toHaveBeenCalledWith("grok-imagine-image"); const media = result.media ?? []; expect(media).toHaveLength(1); @@ -92,7 +94,9 @@ describe("media tools", () => { }); const provider = { - video: vi.fn((modelId: string) => ({ modelId })), + kind: "xai" as const, + capabilities: { videoGeneration: true }, + videoModel: vi.fn((modelId: string) => ({ modelId })), }; const result = await generateVideoTool( @@ -124,7 +128,7 @@ describe("media tools", () => { }, }, }); - expect(provider.video).toHaveBeenCalledWith("grok-imagine-video"); + expect(provider.videoModel).toHaveBeenCalledWith("grok-imagine-video"); const prompt = generateVideoMock.mock.calls[0]?.[0]?.prompt as { image?: string }; expect(prompt.image?.startsWith("data:image/jpeg;base64,")).toBe(true); diff --git a/src/grok/media.ts b/src/grok/media.ts index eafb036b9..60da117c4 100644 --- a/src/grok/media.ts +++ b/src/grok/media.ts @@ -1,8 +1,8 @@ import { generateImage, experimental_generateVideo as generateVideo } from "ai"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { dirname, extname, isAbsolute, join, resolve } from "path"; +import type { GrokProviderAdapter } from "../providers"; import type { MediaAsset, ToolResult } from "../types/index"; -import type { XaiProvider } from "./client"; const GENERATED_MEDIA_DIR = ".grok/generated-media"; const IMAGE_MODEL_ID = "grok-imagine-image"; @@ -63,16 +63,22 @@ interface ResolvedImageSource { } export async function generateImageTool( - provider: XaiProvider, + provider: GrokProviderAdapter, input: GenerateImageToolInput, cwd: string, abortSignal?: AbortSignal, ): Promise { try { + if (!provider.imageModel) { + return failureResult( + "Image generation failed", + new Error(`Image generation is not supported by the ${provider.kind} provider.`), + ); + } const source = input.source ? await resolveImageSource(input.source, cwd, abortSignal) : null; const prompt = source ? { text: input.prompt, images: [source.data] } : input.prompt; const response = await generateImage({ - model: provider.image(IMAGE_MODEL_ID), + model: provider.imageModel(IMAGE_MODEL_ID), prompt, n: input.n, aspectRatio: toSdkAspectRatio(input.aspect_ratio), @@ -121,16 +127,22 @@ export async function generateImageTool( } export async function generateVideoTool( - provider: XaiProvider, + provider: GrokProviderAdapter, input: GenerateVideoToolInput, cwd: string, abortSignal?: AbortSignal, ): Promise { try { + if (!provider.videoModel) { + return failureResult( + "Video generation failed", + new Error(`Video generation is not supported by the ${provider.kind} provider.`), + ); + } const source = input.source ? await resolveImageSource(input.source, cwd, abortSignal) : null; const prompt = source ? { text: input.prompt, image: source.dataUrl } : input.prompt; const response = await generateVideo({ - model: provider.video(VIDEO_MODEL_ID), + model: provider.videoModel(VIDEO_MODEL_ID), prompt, duration: input.duration, aspectRatio: input.aspect_ratio, diff --git a/src/grok/models.ts b/src/grok/models.ts index 3b6e81c0e..dba4e4e78 100644 --- a/src/grok/models.ts +++ b/src/grok/models.ts @@ -1,117 +1,15 @@ -import type { ModelInfo, ReasoningEffort } from "../types/index"; - -export const MODELS: ModelInfo[] = [ - { - id: "grok-4.3", - name: "Grok 4.3", - contextWindow: 1_000_000, - inputPrice: 1.25, - outputPrice: 2.5, - reasoning: true, - description: "Recommended flagship reasoning model", - aliases: [ - "grok-4-1-fast-reasoning", - "grok-4-1-fast", - "grok-4-fast-reasoning", - "grok-4-fast", - "grok-4-0709", - "grok-code-fast-1", - "grok-code-fast", - ], - }, - { - id: "grok-4.20-multi-agent-0309", - name: "Grok 4.20 Multi-Agent", - contextWindow: 2_000_000, - inputPrice: 2.0, - outputPrice: 6.0, - reasoning: true, - description: "Realtime multi-agent research model", - aliases: ["grok-4.20-multi-agent", "grok-4.20-multi-agent-beta"], - responsesOnly: true, - multiAgent: true, - supportsClientTools: false, - supportsMaxOutputTokens: false, - defaultReasoningEffort: "low", - }, - { - id: "grok-4.20-0309-reasoning", - name: "Grok 4.20 Reasoning", - contextWindow: 2_000_000, - inputPrice: 2.0, - outputPrice: 6.0, - reasoning: true, - description: "Grok 4.20 reasoning release", - aliases: ["grok-4.20-beta-0309", "grok-4.20-beta", "grok-beta"], - }, - { - id: "grok-4.20-non-reasoning", - name: "Grok 4.20 Non-Reasoning", - contextWindow: 2_000_000, - inputPrice: 2.0, - outputPrice: 6.0, - reasoning: false, - description: "Recommended non-reasoning model", - aliases: ["grok-4.20-0309-non-reasoning", "grok-4-1-fast-non-reasoning", "grok-4-fast-non-reasoning", "grok-3"], - }, - { - id: "grok-3-mini", - name: "Grok 3 Mini", - contextWindow: 131_072, - inputPrice: 0.3, - outputPrice: 0.5, - reasoning: false, - description: "Budget-friendly compact model", - aliases: ["grok-3-mini-fast"], - supportsReasoningEffort: true, - }, -]; - -const PROVIDER_PREFIX_RE = /^(x-ai|xai)\//i; -const aliasMap = new Map(); - -for (const model of MODELS) { - aliasMap.set(model.id.toLowerCase(), model.id); - for (const alias of model.aliases ?? []) { - aliasMap.set(alias.toLowerCase(), model.id); - } -} - -export const DEFAULT_MODEL = MODELS.find((model) => model.id === "grok-4.3")?.id ?? MODELS[0]?.id ?? "grok-4.3"; - -export function normalizeModelId(modelId: string): string { - const trimmed = modelId.trim(); - if (!trimmed) return trimmed; - - const withoutProviderPrefix = trimmed.replace(PROVIDER_PREFIX_RE, ""); - return aliasMap.get(withoutProviderPrefix.toLowerCase()) ?? withoutProviderPrefix; -} - -export function getModelInfo(modelId: string): ModelInfo | undefined { - const normalized = normalizeModelId(modelId); - return MODELS.find((m) => m.id === normalized); -} - -export function getModelIds(): string[] { - return MODELS.map((m) => m.id); -} - -export function isKnownModelId(modelId: string): boolean { - return !!getModelInfo(modelId); -} - -export function getSupportedReasoningEfforts(modelId: string): ReasoningEffort[] { - const modelInfo = getModelInfo(modelId); - if (!modelInfo?.supportsReasoningEffort) return []; - // Currently only grok-3-mini supports reasoning_effort per xAI docs - // It supports "low" and "high" efforts - return ["low", "high"]; -} - -export function getEffectiveReasoningEffort(modelId: string, override?: ReasoningEffort): ReasoningEffort | undefined { - const supported = getSupportedReasoningEfforts(modelId); - if (supported.length === 0) return undefined; - if (override && supported.includes(override)) return override; - const defaultEffort = getModelInfo(modelId)?.defaultReasoningEffort; - return defaultEffort && supported.includes(defaultEffort) ? defaultEffort : undefined; -} +/** + * Backward-compatible re-exports for the xAI model catalog. + * The catalog itself lives at "../providers/xai-models". Prefer importing + * from there in new code. + */ +export { + DEFAULT_XAI_MODEL as DEFAULT_MODEL, + getXaiEffectiveReasoningEffort as getEffectiveReasoningEffort, + getXaiModelIds as getModelIds, + getXaiModelInfo as getModelInfo, + getXaiSupportedReasoningEfforts as getSupportedReasoningEfforts, + isKnownXaiModelId as isKnownModelId, + normalizeXaiModelId as normalizeModelId, + XAI_MODELS as MODELS, +} from "../providers/xai-models"; diff --git a/src/grok/tools.ts b/src/grok/tools.ts index 1e9e69c07..afc331db9 100644 --- a/src/grok/tools.ts +++ b/src/grok/tools.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { executePostToolFailureHooks, executePostToolHooks, executePreToolHooks } from "../hooks/index"; import { isLspToolEnabled, queryLsp } from "../lsp/runtime"; import { LSP_TOOL_OPERATIONS } from "../lsp/types"; +import type { GrokProviderAdapter } from "../providers"; import type { BashTool } from "../tools/bash"; import { computerClick, @@ -23,7 +24,6 @@ import { executeGrep } from "../tools/grep"; import type { ScheduleDaemonStatus, ScheduleManager, StoredSchedule } from "../tools/schedule"; import type { AgentMode, TaskRequest, ToolResult } from "../types/index"; import { type CustomSubagentConfig, loadPaymentSettings, loadValidSubAgents } from "../utils/settings"; -import type { XaiProvider } from "./client"; import { type GenerateImageToolInput, type GenerateVideoToolInput, @@ -50,7 +50,7 @@ interface CreateToolsOptions { export function createTools( bash: BashTool, - provider: XaiProvider, + provider: GrokProviderAdapter, mode: AgentMode = "agent", options: CreateToolsOptions = {}, ) { @@ -62,14 +62,23 @@ export function createTools( abortSignal?: AbortSignal, ): Promise<{ success: boolean; output: string }> => { try { + const responsesModel = provider.responsesModel; + const hostedTools = provider.hostedTools; + if (!responsesModel || !hostedTools) { + const label = toolName === "web_search" ? "Web search" : "X search"; + return { + success: false, + output: `${label} requires a provider that supports hosted search; the active ${provider.kind} provider does not.`, + }; + } const { text } = await generateText({ - model: provider.responses(RESPONSES_SEARCH_MODEL), + model: responsesModel.call(provider, RESPONSES_SEARCH_MODEL), maxOutputTokens: 4096, prompt: query, abortSignal, tools: { - ...(toolName === "web_search" ? { web_search: provider.tools.webSearch() } : {}), - ...(toolName === "x_search" ? { x_search: provider.tools.xSearch() } : {}), + ...(toolName === "web_search" ? { web_search: hostedTools.webSearch() } : {}), + ...(toolName === "x_search" ? { x_search: hostedTools.xSearch() } : {}), }, }); diff --git a/src/index.ts b/src/index.ts index d56630d19..89875459e 100755 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ import { startScheduleDaemon } from "./tools/schedule"; import { processAtMentions } from "./utils/at-mentions.js"; import { runScriptManagedUninstall } from "./utils/install-manager"; import { + getActiveProvider, getApiKey, getBaseURL, getCurrentSandboxMode, @@ -257,9 +258,14 @@ function resolveConfig(options: CliOptions) { } function requireApiKey(apiKey: string | undefined): string { + // Vertex mode authenticates via Google ADC, not a static API key. Allow + // empty apiKey through so the Agent can construct the Vertex provider. + if (getActiveProvider() === "vertex") { + return apiKey ?? ""; + } if (!apiKey) { console.error( - "Error: API key required. Set GROK_API_KEY env var, use --api-key, or save to ~/.grok/user-settings.json", + "Error: API key required. Set GROK_API_KEY env var, use --api-key, or save to ~/.grok/user-settings.json. (To use Vertex AI instead, pass --provider vertex.)", ); process.exit(1); } @@ -275,6 +281,24 @@ function parseHeadlessOutputFormat(value: string): HeadlessOutputFormat { throw new InvalidArgumentError(`Invalid headless format "${value}". Expected "text" or "json".`); } +function parseProviderKind(value: string): "xai" | "vertex" { + const normalized = value.trim().toLowerCase(); + if (normalized === "xai" || normalized === "vertex") return normalized; + throw new InvalidArgumentError(`Invalid provider "${value}". Expected "xai" or "vertex".`); +} + +/** + * Applies the --provider CLI flag by exporting GROK_PROVIDER for the rest + * of the process. Settings reads consult this env var first, so all + * downstream provider lookups (Agent, capability checks, etc.) see the + * override without further plumbing. + */ +function applyProviderOverride(value: string | undefined): void { + if (value === "xai" || value === "vertex") { + process.env.GROK_PROVIDER = value; + } +} + program .name("grok") .description("AI coding agent powered by Grok — built with Bun and OpenTUI") @@ -296,6 +320,11 @@ program .option("--background-task-file ", "Run a persisted background delegation") .option("--max-tool-rounds ", "Max tool execution rounds", "400") .option("--batch-api", "Use xAI Batch API for model calls (async, lower cost)") + .option( + "--provider ", + "Backend provider: 'xai' (default) or 'vertex' (Google Cloud Vertex AI Grok). Equivalent to GROK_PROVIDER env var.", + parseProviderKind, + ) .option("--update", "Update grok to the latest version and exit") .action(async (message: string[], options) => { if (options.update) { @@ -306,6 +335,7 @@ program } changeDirectoryOrExit(options.directory); + applyProviderOverride(stringOption(options.provider)); if (options.backgroundTaskFile) { await runBackgroundDelegation(options.backgroundTaskFile, options); diff --git a/src/providers/index.ts b/src/providers/index.ts new file mode 100644 index 000000000..33d11e352 --- /dev/null +++ b/src/providers/index.ts @@ -0,0 +1,39 @@ +import type { GrokProviderAdapter, ProviderFactoryConfig } from "./types"; +import { createVertexAdapter } from "./vertex"; +import { createXaiAdapter } from "./xai"; + +export type { + GrokProviderAdapter, + HostedToolNamespace, + ProviderCapabilities, + ProviderFactoryConfig, + ProviderKind, + ProviderRequestOptions, + ResolvedModelRuntime, +} from "./types"; +export { ProviderCapabilityError } from "./types"; +export { createVertexAdapter, VERTEX_DEFAULT_MODEL, VERTEX_DEFAULT_TITLE_MODEL } from "./vertex"; +export { createXaiAdapter, XAI_DEFAULT_MODEL } from "./xai"; + +/** + * Constructs a provider adapter for the requested backend. + * + * The adapter exposes a backend-agnostic surface (chatModel, capabilities, + * resolveRuntime, ...). Consumer code should depend on the GrokProviderAdapter + * interface, never on a concrete provider type. + */ +export function createProvider(config: ProviderFactoryConfig): GrokProviderAdapter { + switch (config.kind) { + case "xai": + return createXaiAdapter({ + apiKey: config.apiKey ?? "", + baseURL: config.baseURL, + }); + case "vertex": + return createVertexAdapter(); + default: { + const exhaustive: never = config.kind; + throw new Error(`Unknown provider kind: ${String(exhaustive)}`); + } + } +} diff --git a/src/providers/provider-contract.test.ts b/src/providers/provider-contract.test.ts new file mode 100644 index 000000000..7e6b8dc67 --- /dev/null +++ b/src/providers/provider-contract.test.ts @@ -0,0 +1,117 @@ +import { beforeAll, describe, expect, it } from "vitest"; + +import { createProvider, type GrokProviderAdapter, ProviderCapabilityError } from "./index"; + +describe("provider contract", () => { + describe("xai adapter", () => { + let adapter: GrokProviderAdapter; + + beforeAll(() => { + adapter = createProvider({ kind: "xai", apiKey: "test-key" }); + }); + + it("identifies as xai", () => { + expect(adapter.kind).toBe("xai"); + }); + + it("declares the full xAI capability set", () => { + expect(adapter.capabilities).toEqual({ + responsesApi: true, + hostedSearch: true, + imageGeneration: true, + videoGeneration: true, + batchApi: true, + reasoningEffort: true, + audioStt: true, + }); + }); + + it("returns a chat model object that the AI SDK can consume", () => { + const model = adapter.chatModel("grok-4.3"); + expect(model).toBeDefined(); + // The AI SDK's LanguageModel objects always carry a modelId or similar. + // We do not assert on the exact shape — that is the SDK's contract. + }); + + it("returns a Responses API model when capability is on", () => { + expect(adapter.responsesModel).toBeDefined(); + const model = adapter.responsesModel?.("grok-4.20-multi-agent-0309"); + expect(model).toBeDefined(); + }); + + it("exposes hosted tool factories when capability is on", () => { + expect(adapter.hostedTools).toBeDefined(); + const webSearchTool = adapter.hostedTools?.webSearch(); + expect(webSearchTool).toBeDefined(); + const xSearchTool = adapter.hostedTools?.xSearch(); + expect(xSearchTool).toBeDefined(); + }); + + it("returns image and video model factories when capabilities are on", () => { + expect(adapter.imageModel).toBeDefined(); + expect(adapter.videoModel).toBeDefined(); + const imageModel = adapter.imageModel?.("grok-imagine-image"); + expect(imageModel).toBeDefined(); + const videoModel = adapter.videoModel?.("grok-imagine-video"); + expect(videoModel).toBeDefined(); + }); + + it("resolves runtime metadata for known xAI models", () => { + const runtime = adapter.resolveRuntime("grok-4.3"); + expect(runtime.modelId).toBe("grok-4.3"); + expect(runtime.modelInfo?.name).toBe("Grok 4.3"); + expect(runtime.model).toBeDefined(); + }); + + it("normalizes shorthand aliases when resolving", () => { + const runtime = adapter.resolveRuntime("grok-4-fast-reasoning"); + expect(runtime.modelId).toBe("grok-4.3"); + }); + + it("returns the apiKey for batch usage", () => { + expect(adapter.getBatchClientApiKey?.()).toBe("test-key"); + }); + }); + + describe("createProvider factory", () => { + it("constructs an xai adapter for kind: 'xai'", () => { + const adapter = createProvider({ kind: "xai", apiKey: "k" }); + expect(adapter.kind).toBe("xai"); + }); + + it("constructs a vertex adapter for kind: 'vertex' when project id is configured", () => { + const previous = process.env.GROK_VERTEX_PROJECT_ID; + process.env.GROK_VERTEX_PROJECT_ID = "test-project"; + try { + const adapter = createProvider({ kind: "vertex" }); + expect(adapter.kind).toBe("vertex"); + } finally { + if (previous === undefined) delete process.env.GROK_VERTEX_PROJECT_ID; + else process.env.GROK_VERTEX_PROJECT_ID = previous; + } + }); + + it("vertex adapter throws a typed error when the batch API is requested", () => { + const previous = process.env.GROK_VERTEX_PROJECT_ID; + process.env.GROK_VERTEX_PROJECT_ID = "test-project"; + try { + const adapter = createProvider({ kind: "vertex" }); + expect(adapter.capabilities.batchApi).toBe(false); + expect(() => adapter.getBatchClientApiKey?.()).toThrow(/batchApi is not supported by the vertex provider/); + } finally { + if (previous === undefined) delete process.env.GROK_VERTEX_PROJECT_ID; + else process.env.GROK_VERTEX_PROJECT_ID = previous; + } + }); + }); + + describe("ProviderCapabilityError", () => { + it("carries the providerKind and capability fields", () => { + const err = new ProviderCapabilityError("vertex", "batchApi", "Disable --use-batch-api or switch to xAI."); + expect(err.providerKind).toBe("vertex"); + expect(err.capability).toBe("batchApi"); + expect(err.message).toContain("batchApi is not supported by the vertex provider"); + expect(err.message).toContain("Disable --use-batch-api"); + }); + }); +}); diff --git a/src/providers/types.ts b/src/providers/types.ts new file mode 100644 index 000000000..75177a1fb --- /dev/null +++ b/src/providers/types.ts @@ -0,0 +1,105 @@ +import type { Experimental_VideoModelV3 as VideoModel } from "@ai-sdk/provider"; +import type { ImageModel, LanguageModel, Tool } from "ai"; + +import type { ModelInfo, ReasoningEffort } from "../types/index"; + +export type ProviderKind = "xai" | "vertex"; + +export interface ProviderCapabilities { + /** xAI's `/responses` endpoint (Grok 4.20 Multi-Agent and similar). */ + responsesApi: boolean; + /** Provider-hosted web/X search tools (e.g. xAI's webSearch, xSearch). */ + hostedSearch: boolean; + /** Provider-hosted image generation (e.g. xAI's grok-2-image). */ + imageGeneration: boolean; + /** Provider-hosted video generation. */ + videoGeneration: boolean; + /** xAI's `/batches` endpoint. */ + batchApi: boolean; + /** Per-request `reasoning_effort` parameter (xAI). Vertex selects via SKU. */ + reasoningEffort: boolean; + /** Speech-to-text transcription used by the Telegram audio bridge. */ + audioStt: boolean; +} + +export type ProviderRequestOptions = { + xai?: { reasoningEffort: ReasoningEffort }; +}; + +export interface ResolvedModelRuntime { + model: LanguageModel; + modelId: string; + modelInfo?: ModelInfo; + providerOptions?: ProviderRequestOptions; +} + +export interface HostedToolNamespace { + webSearch(): Tool; + xSearch(): Tool; +} + +export interface GrokProviderAdapter { + readonly kind: ProviderKind; + readonly capabilities: ProviderCapabilities; + + /** Returns the chat-completions LanguageModel for the given model id. */ + chatModel(modelId: string): LanguageModel; + + /** + * Returns the Responses API LanguageModel for the given model id. + * Only present when capabilities.responsesApi is true. + */ + responsesModel?(modelId: string): LanguageModel; + + /** + * Returns the image-generation model. + * Only present when capabilities.imageGeneration is true. + */ + imageModel?(modelId: string): ImageModel; + + /** + * Returns the video-generation model. + * Only present when capabilities.videoGeneration is true. + */ + videoModel?(modelId: string): VideoModel; + + /** + * Hosted tool definitions (e.g. provider-native web search). + * Only present when capabilities.hostedSearch is true. + */ + hostedTools?: HostedToolNamespace; + + /** Resolves a requested model id into runtime configuration. */ + resolveRuntime(requestedModelId: string): ResolvedModelRuntime; + + /** + * Returns the API key required by the batch client. + * Only present when capabilities.batchApi is true. + */ + getBatchClientApiKey?(): string; +} + +/** + * Thrown when consumer code tries to use a capability that the active provider + * does not support. Carries a typed payload so call sites can render + * provider-aware error messages. + */ +export class ProviderCapabilityError extends Error { + readonly providerKind: ProviderKind; + readonly capability: keyof ProviderCapabilities; + + constructor(providerKind: ProviderKind, capability: keyof ProviderCapabilities, suggestion?: string) { + const base = `${capability} is not supported by the ${providerKind} provider.`; + super(suggestion ? `${base} ${suggestion}` : base); + this.name = "ProviderCapabilityError"; + this.providerKind = providerKind; + this.capability = capability; + } +} + +/** Configuration accepted by the createProvider factory. */ +export interface ProviderFactoryConfig { + kind: ProviderKind; + apiKey?: string; + baseURL?: string; +} diff --git a/src/providers/vertex/auth.test.ts b/src/providers/vertex/auth.test.ts new file mode 100644 index 000000000..b67b96943 --- /dev/null +++ b/src/providers/vertex/auth.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { getAccessTokenMock, googleAuthCtor } = vi.hoisted(() => { + const getAccessTokenMock = vi.fn(); + const googleAuthCtor = vi.fn(function GoogleAuth() { + return { getAccessToken: getAccessTokenMock }; + }); + return { getAccessTokenMock, googleAuthCtor }; +}); + +vi.mock("google-auth-library", () => ({ + GoogleAuth: googleAuthCtor, +})); + +beforeEach(() => { + vi.resetModules(); + getAccessTokenMock.mockReset(); + googleAuthCtor.mockClear(); +}); + +afterEach(() => { + getAccessTokenMock.mockReset(); +}); + +describe("getVertexAccessToken (adc)", () => { + it("returns the ADC token when google-auth-library yields one", async () => { + getAccessTokenMock.mockResolvedValue("adc-token-123"); + const auth = await import("./auth"); + const token = await auth.getVertexAccessToken({ mode: "adc" }); + expect(token).toBe("adc-token-123"); + }); + + it("throws a descriptive error when ADC returns null/undefined", async () => { + getAccessTokenMock.mockResolvedValue(null); + const auth = await import("./auth"); + await expect(auth.getVertexAccessToken({ mode: "adc" })).rejects.toThrow( + /Could not obtain a Google Cloud access token/, + ); + }); + + it("surfaces a re-auth message when ADC returns invalid_rapt", async () => { + getAccessTokenMock.mockRejectedValue({ + response: { data: { error: "invalid_rapt", error_description: "Reauth required" } }, + }); + const auth = await import("./auth"); + await expect(auth.getVertexAccessToken({ mode: "adc" })).rejects.toThrow(/need reauthentication/); + }); + + it("surfaces a generic error message when ADC fails for an unknown reason", async () => { + getAccessTokenMock.mockRejectedValue(new Error("network unreachable")); + const auth = await import("./auth"); + await expect(auth.getVertexAccessToken({ mode: "adc" })).rejects.toThrow(/Google auth error: network unreachable/); + }); + + it("caches the GoogleAuth client across calls", async () => { + getAccessTokenMock.mockResolvedValue("adc-token"); + const auth = await import("./auth"); + await auth.getVertexAccessToken({ mode: "adc" }); + await auth.getVertexAccessToken({ mode: "adc" }); + + expect(googleAuthCtor).toHaveBeenCalledTimes(1); + }); + + it("rebuilds the GoogleAuth client after resetVertexAuthClient", async () => { + getAccessTokenMock.mockResolvedValue("adc-token"); + const auth = await import("./auth"); + await auth.getVertexAccessToken({ mode: "adc" }); + auth.resetVertexAuthClient(); + await auth.getVertexAccessToken({ mode: "adc" }); + + expect(googleAuthCtor).toHaveBeenCalledTimes(2); + }); +}); + +describe("getVertexAccessToken (unsupported modes)", () => { + it("throws a descriptive error when an unsupported mode is requested", async () => { + const auth = await import("./auth"); + // Cast to bypass the union narrowing — defensive coverage for the day + // VertexAuthMode widens beyond "adc". + await expect(auth.getVertexAccessToken({ mode: "oauth_token" as unknown as "adc" })).rejects.toThrow( + /Unsupported Vertex auth mode/, + ); + }); +}); + +describe("formatVertexAuthErrorMessage", () => { + it("identifies invalid_grant as a re-auth scenario", async () => { + const auth = await import("./auth"); + const message = auth.formatVertexAuthErrorMessage({ + response: { data: { error: "invalid_grant" } }, + }); + expect(message).toContain("need reauthentication"); + }); + + it("falls back to generic guidance for unknown errors", async () => { + const auth = await import("./auth"); + const message = auth.formatVertexAuthErrorMessage(new Error("DNS lookup failed")); + expect(message).toContain("Google auth error: DNS lookup failed"); + expect(message).toContain("application-default login"); + }); + + it("handles bare-string errors", async () => { + const auth = await import("./auth"); + const message = auth.formatVertexAuthErrorMessage("something went wrong"); + expect(message).toContain("something went wrong"); + }); + + it("handles JSON-string errors as if parsed", async () => { + const auth = await import("./auth"); + const message = auth.formatVertexAuthErrorMessage('{"error":"invalid_rapt","error_description":"reauth needed"}'); + expect(message).toContain("need reauthentication"); + }); +}); diff --git a/src/providers/vertex/auth.ts b/src/providers/vertex/auth.ts new file mode 100644 index 000000000..35ff88a49 --- /dev/null +++ b/src/providers/vertex/auth.ts @@ -0,0 +1,174 @@ +import { GoogleAuth } from "google-auth-library"; + +import type { VertexAuthMode } from "../../utils/settings"; + +const VERTEX_AUTH_SCOPES = ["https://www.googleapis.com/auth/cloud-platform"]; + +/** + * Forwards to the global fetch so test environments and CLIs that override + * `globalThis.fetch` (e.g. for proxying or recording) continue to work + * through the Google auth client. + */ +const fetchImplementation: typeof fetch = (input, init) => globalThis.fetch(input, init); + +/** + * Singleton GoogleAuth client. Re-used across requests so token caching and + * refresh handled by the client are not bypassed. Use resetVertexAuthClient + * to clear it (test isolation, recovery from auth failure). + */ +let cachedAuthClient: GoogleAuth | undefined; + +export interface VertexAuthOptions { + mode: VertexAuthMode; +} + +/** + * Resolves a Google Cloud access token for Vertex AI requests. + * + * Currently only Application Default Credentials (`mode: "adc"`) is + * supported. ADC looks up `gcloud auth application-default login` + * credentials, GOOGLE_APPLICATION_CREDENTIALS, or workload identity, in + * that order. + * + * Other auth modes (pre-minted OAuth bearer tokens, service-account-bound + * API keys) are documented in the Vertex AI quickstart but require + * additional settings/env wiring and live verification against the + * Grok-on-Vertex endpoint before being safe to expose. Tracked as a + * follow-up. + */ +export async function getVertexAccessToken(options: VertexAuthOptions): Promise { + if (options.mode !== "adc") { + // VertexAuthMode currently narrows to "adc"; this branch is defensive + // for the future when the type union widens. + throw new Error(`Unsupported Vertex auth mode "${String(options.mode)}". Only "adc" is wired up.`); + } + return resolveAdcAccessToken(); +} + +/** Clears the cached GoogleAuth client. Call after an auth failure or in tests. */ +export function resetVertexAuthClient(): void { + cachedAuthClient = undefined; +} + +async function resolveAdcAccessToken(): Promise { + if (!cachedAuthClient) { + cachedAuthClient = new GoogleAuth({ + scopes: VERTEX_AUTH_SCOPES, + clientOptions: { + transporterOptions: { + fetchImplementation, + }, + }, + }); + } + + let token: string | null | undefined; + try { + token = await cachedAuthClient.getAccessToken(); + } catch (err: unknown) { + throw new Error(formatVertexAuthErrorMessage(err)); + } + + if (!token) { + throw new Error( + "Could not obtain a Google Cloud access token from Application Default Credentials. Run `gcloud auth application-default login`, or configure ADC for this environment.", + ); + } + + return token; +} + +/** + * Builds a human-friendly error message from whatever Google's auth client + * actually threw. Detects the re-auth flow specifically because that's the + * single most common failure when a long-lived ADC session expires. + */ +export function formatVertexAuthErrorMessage(err: unknown): string { + const detail = extractGoogleAuthDetail(err); + if (isReauthError(detail)) { + return [ + "Google Application Default Credentials need reauthentication.", + "", + "Application Default Credentials (ADC) are the separate local credentials Google client libraries use; they are not the same as the active account shown by `gcloud auth list`.", + "Run `gcloud auth application-default login` exactly as shown in another terminal, then return to Grok and retry Vertex auth.", + "Verify ADC with `gcloud auth application-default print-access-token`.", + "If that still fails, run `gcloud auth application-default revoke` and then `gcloud auth application-default login` again.", + "For SSH/headless environments, use `gcloud auth application-default login --no-launch-browser`.", + ].join("\n"); + } + + return [ + "Could not obtain a Google Cloud access token from Application Default Credentials.", + detail ? `Google auth error: ${detail}` : "Google auth returned an unknown error.", + "", + "Application Default Credentials (ADC) are separate from the active account shown by `gcloud auth list`.", + "Run `gcloud auth application-default login`, verify with `gcloud auth application-default print-access-token`, then retry Vertex auth.", + ].join("\n"); +} + +function isReauthError(detail: string): boolean { + return /invalid_rapt|invalid_grant|reauth|application-default login|cannot prompt/i.test(detail); +} + +function extractGoogleAuthDetail(err: unknown): string { + const fields = extractGoogleAuthFields(err); + const joined = [fields.error, fields.errorDescription, fields.errorSubtype, fields.message] + .filter(Boolean) + .join(": "); + if (joined) return joined; + if (err instanceof Error) return err.message; + return typeof err === "string" ? err : ""; +} + +function extractGoogleAuthFields(value: unknown): { + error?: string; + errorDescription?: string; + errorSubtype?: string; + message?: string; +} { + if (typeof value === "string") { + return parseGoogleAuthJson(value) ?? { message: value }; + } + if (!value || typeof value !== "object") return {}; + + const record = value as Record; + const data = getNestedRecord(record, "response", "data") ?? getNestedRecord(record, "data"); + const parsedMessage = typeof record.message === "string" ? parseGoogleAuthJson(record.message) : undefined; + const source = data ?? parsedMessage ?? record; + + return { + error: stringField(source, "error"), + errorDescription: stringField(source, "error_description") ?? stringField(source, "errorDescription"), + errorSubtype: stringField(source, "error_subtype") ?? stringField(source, "errorSubtype"), + message: typeof record.message === "string" && !parsedMessage ? record.message : stringField(source, "message"), + }; +} + +function parseGoogleAuthJson(value: string): Record | undefined { + const trimmed = value.trim(); + if (!trimmed.startsWith("{")) return undefined; + try { + const parsed = JSON.parse(trimmed) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : undefined; + } catch { + return undefined; + } +} + +function getNestedRecord(record: Record, ...path: string[]): Record | undefined { + let current: unknown = record; + for (const part of path) { + if (!current || typeof current !== "object" || Array.isArray(current)) return undefined; + current = (current as Record)[part]; + } + return current && typeof current === "object" && !Array.isArray(current) + ? (current as Record) + : undefined; +} + +function stringField(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} diff --git a/src/providers/vertex/index.ts b/src/providers/vertex/index.ts new file mode 100644 index 000000000..1ca14987b --- /dev/null +++ b/src/providers/vertex/index.ts @@ -0,0 +1,100 @@ +import { createXai } from "@ai-sdk/xai"; + +import { requireVertexSettings } from "../../utils/settings"; +import type { GrokProviderAdapter, ProviderCapabilities, ResolvedModelRuntime } from "../types"; +import { ProviderCapabilityError } from "../types"; +import { DEFAULT_VERTEX_MODEL, DEFAULT_VERTEX_TITLE_MODEL, getVertexModelInfo, normalizeVertexModelId } from "./models"; +import { createVertexFetch } from "./openapi"; + +/** + * Capabilities advertised by the Vertex AI Grok backend, derived from the + * official Vertex Grok model cards. Hosted search, image generation, video + * generation, and the xAI batch API are not part of Vertex's documented + * surface for Grok and are deliberately turned off — capability gating + * (commit 9) translates these into typed errors at the call site. + */ +const VERTEX_CAPABILITIES: ProviderCapabilities = { + responsesApi: false, + hostedSearch: false, + imageGeneration: false, + videoGeneration: false, + batchApi: false, + reasoningEffort: false, + audioStt: false, +}; + +export interface VertexAdapterOptions { + /** + * Optional fetch override. Used by tests to inject a canned transport; + * production callers should leave this undefined so the default + * createVertexFetch wraps globalThis.fetch. + */ + fetch?: typeof fetch; +} + +/** + * Sentinel API key used as the placeholder when constructing the underlying + * @ai-sdk/xai client for Vertex mode. The real authorization happens inside + * createVertexFetch via Google ADC; the SDK never sees this string on the + * wire because the fetch shim rewrites the Authorization header. + */ +const VERTEX_SDK_PLACEHOLDER_KEY = "vertex-adc-placeholder"; + +const VERTEX_DEFAULT_BASE_URL = "https://api.x.ai/v1"; + +class VertexProviderAdapter implements GrokProviderAdapter { + readonly kind = "vertex" as const; + readonly capabilities = VERTEX_CAPABILITIES; + + private readonly sdk: ReturnType; + + constructor(options: VertexAdapterOptions = {}) { + // Eagerly validate the Vertex settings so misconfiguration fails at + // provider construction rather than at the first request. The fetch + // shim re-resolves settings per request (see createVertexFetch), so + // we don't need to keep the resolved object around — the side-effect + // of throwing is the value here. + requireVertexSettings(); + this.sdk = createXai({ + apiKey: VERTEX_SDK_PLACEHOLDER_KEY, + baseURL: VERTEX_DEFAULT_BASE_URL, + fetch: createVertexFetch(options.fetch), + }); + } + + chatModel(modelId: string) { + return this.sdk(normalizeVertexModelId(modelId)); + } + + resolveRuntime(requestedModelId: string): ResolvedModelRuntime { + const modelId = normalizeVertexModelId(requestedModelId); + const modelInfo = getVertexModelInfo(modelId); + // Vertex selects reasoning by SKU (e.g. grok-4.20-reasoning vs + // grok-4.20-non-reasoning), not via a per-request reasoning_effort + // knob. Per the capability matrix (capabilities.reasoningEffort: + // false), we deliberately omit providerOptions here so the agent + // does not surface a setting that Vertex will silently ignore. + return { + model: this.sdk(modelId), + modelId, + modelInfo, + providerOptions: undefined, + }; + } + + /** + * Vertex Grok does not support the batch API. Calling this throws a + * typed error rather than returning a placeholder string so capability + * gating fails loudly at the right boundary. + */ + getBatchClientApiKey(): never { + throw new ProviderCapabilityError("vertex", "batchApi", "Use streaming/headless mode or switch provider to xai."); + } +} + +export function createVertexAdapter(options: VertexAdapterOptions = {}): GrokProviderAdapter { + return new VertexProviderAdapter(options); +} + +export const VERTEX_DEFAULT_MODEL = DEFAULT_VERTEX_MODEL; +export const VERTEX_DEFAULT_TITLE_MODEL = DEFAULT_VERTEX_TITLE_MODEL; diff --git a/src/providers/vertex/models.test.ts b/src/providers/vertex/models.test.ts new file mode 100644 index 000000000..45ca1d315 --- /dev/null +++ b/src/providers/vertex/models.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_VERTEX_MODEL, + DEFAULT_VERTEX_TITLE_MODEL, + getVertexModelIds, + getVertexModelInfo, + getVertexRequestModelId, + isKnownVertexModelId, + normalizeVertexModelId, + VERTEX_MODELS, +} from "./models"; + +describe("Vertex Grok catalog", () => { + it("exposes exactly the 4 documented Vertex Grok SKUs", () => { + expect(getVertexModelIds()).toEqual([ + "grok-4.20-reasoning", + "grok-4.20-non-reasoning", + "grok-4.1-fast-reasoning", + "grok-4.1-fast-non-reasoning", + ]); + }); + + it("publishes the documented context windows per Vertex model card", () => { + expect(getVertexModelInfo("grok-4.20-reasoning")?.contextWindow).toBe(200_000); + expect(getVertexModelInfo("grok-4.20-non-reasoning")?.contextWindow).toBe(200_000); + expect(getVertexModelInfo("grok-4.1-fast-reasoning")?.contextWindow).toBe(128_000); + expect(getVertexModelInfo("grok-4.1-fast-non-reasoning")?.contextWindow).toBe(128_000); + }); + + it("flags reasoning vs non-reasoning correctly", () => { + expect(getVertexModelInfo("grok-4.20-reasoning")?.reasoning).toBe(true); + expect(getVertexModelInfo("grok-4.20-non-reasoning")?.reasoning).toBe(false); + expect(getVertexModelInfo("grok-4.1-fast-reasoning")?.reasoning).toBe(true); + expect(getVertexModelInfo("grok-4.1-fast-non-reasoning")?.reasoning).toBe(false); + }); + + it("does not advertise reasoning-effort support (Vertex selects via SKU)", () => { + for (const model of VERTEX_MODELS) { + expect(model.supportsReasoningEffort).toBe(false); + } + }); + + it("supports client-side function calling on every SKU", () => { + for (const model of VERTEX_MODELS) { + expect(model.supportsClientTools).toBe(true); + } + }); + + it("chooses grok-4.20-reasoning as the default chat model", () => { + expect(DEFAULT_VERTEX_MODEL).toBe("grok-4.20-reasoning"); + }); + + it("chooses grok-4.1-fast-non-reasoning as the default title/recap model", () => { + expect(DEFAULT_VERTEX_TITLE_MODEL).toBe("grok-4.1-fast-non-reasoning"); + }); +}); + +describe("normalizeVertexModelId", () => { + it("returns the canonical id unchanged", () => { + expect(normalizeVertexModelId("grok-4.20-reasoning")).toBe("grok-4.20-reasoning"); + }); + + it("strips xai/ publisher prefix", () => { + expect(normalizeVertexModelId("xai/grok-4.20-reasoning")).toBe("grok-4.20-reasoning"); + }); + + it("strips x-ai/ publisher prefix variant", () => { + expect(normalizeVertexModelId("x-ai/grok-4.20-reasoning")).toBe("grok-4.20-reasoning"); + }); + + it("resolves declared aliases to their canonical id", () => { + expect(normalizeVertexModelId("grok-4-1-fast-reasoning")).toBe("grok-4.1-fast-reasoning"); + expect(normalizeVertexModelId("grok-4-fast-reasoning")).toBe("grok-4.1-fast-reasoning"); + expect(normalizeVertexModelId("grok-4.20-0309-non-reasoning")).toBe("grok-4.20-non-reasoning"); + }); + + it("is case-insensitive on aliases", () => { + expect(normalizeVertexModelId("GROK-4-FAST-REASONING")).toBe("grok-4.1-fast-reasoning"); + }); + + it("trims whitespace", () => { + expect(normalizeVertexModelId(" grok-4.20-reasoning ")).toBe("grok-4.20-reasoning"); + }); + + it("returns the input bare value when unknown so error messages can surface the original", () => { + expect(normalizeVertexModelId("grok-7-quantum")).toBe("grok-7-quantum"); + }); +}); + +describe("isKnownVertexModelId", () => { + it("returns true for every catalog id", () => { + for (const id of getVertexModelIds()) { + expect(isKnownVertexModelId(id)).toBe(true); + } + }); + + it("returns true for declared aliases", () => { + expect(isKnownVertexModelId("grok-4-1-fast-reasoning")).toBe(true); + }); + + it("returns false for unknown models", () => { + expect(isKnownVertexModelId("grok-7-quantum")).toBe(false); + }); +}); + +describe("getVertexRequestModelId", () => { + it("prepends xai/ to the canonical id", () => { + expect(getVertexRequestModelId("grok-4.20-reasoning")).toBe("xai/grok-4.20-reasoning"); + }); + + it("normalizes aliases before formatting", () => { + expect(getVertexRequestModelId("grok-4-1-fast-reasoning")).toBe("xai/grok-4.1-fast-reasoning"); + }); + + it("does not double-prefix when input already has xai/", () => { + expect(getVertexRequestModelId("xai/grok-4.20-reasoning")).toBe("xai/grok-4.20-reasoning"); + }); + + it("preserves unknown ids so the error surface includes the original input", () => { + expect(getVertexRequestModelId("grok-7-quantum")).toBe("xai/grok-7-quantum"); + }); +}); diff --git a/src/providers/vertex/models.ts b/src/providers/vertex/models.ts new file mode 100644 index 000000000..1a32fd487 --- /dev/null +++ b/src/providers/vertex/models.ts @@ -0,0 +1,134 @@ +import type { ModelInfo } from "../../types/index"; + +/** + * The authoritative Vertex AI Grok catalog. Mirrors the four SKUs documented + * on https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-grok + * as of the integration cut-off. + * + * Important divergences from the native xAI catalog: + * - Vertex publishes separate reasoning vs non-reasoning SKUs rather than + * a single model with a `reasoning_effort` knob. Consequently + * supportsReasoningEffort is `false` on all Vertex SKUs and the agent + * selects reasoning behavior by picking the right SKU. + * - Smaller context windows than the xAI flagship: 200K for grok-4.20-*, + * 128K for grok-4.1-fast-*. + * - Batch predictions are NOT supported for any Vertex Grok SKU. + * - Pricing fields below mirror current xAI public pricing for the same + * model family. Actual billing on Vertex flows through the customer's + * Google Cloud contract and may differ; treat these as estimates for + * in-CLI cost surfacing only. + */ +export const VERTEX_MODELS: ModelInfo[] = [ + { + id: "grok-4.20-reasoning", + name: "Grok 4.20 Reasoning (Vertex)", + contextWindow: 200_000, + inputPrice: 2.0, + outputPrice: 6.0, + reasoning: true, + description: "Flagship reasoning model on Vertex AI", + aliases: ["grok-4.20-0309-reasoning"], + supportsClientTools: true, + supportsMaxOutputTokens: true, + supportsReasoningEffort: false, + }, + { + id: "grok-4.20-non-reasoning", + name: "Grok 4.20 Non-Reasoning (Vertex)", + contextWindow: 200_000, + inputPrice: 2.0, + outputPrice: 6.0, + reasoning: false, + description: "Flagship non-reasoning model on Vertex AI", + aliases: ["grok-4.20-0309-non-reasoning"], + supportsClientTools: true, + supportsMaxOutputTokens: true, + supportsReasoningEffort: false, + }, + { + id: "grok-4.1-fast-reasoning", + name: "Grok 4.1 Fast Reasoning (Vertex)", + contextWindow: 128_000, + inputPrice: 1.25, + outputPrice: 2.5, + reasoning: true, + description: "Cost-efficient reasoning model on Vertex AI", + aliases: ["grok-4-1-fast-reasoning", "grok-4-fast-reasoning"], + supportsClientTools: true, + supportsMaxOutputTokens: true, + supportsReasoningEffort: false, + }, + { + id: "grok-4.1-fast-non-reasoning", + name: "Grok 4.1 Fast Non-Reasoning (Vertex)", + contextWindow: 128_000, + inputPrice: 1.25, + outputPrice: 2.5, + reasoning: false, + description: "Cost-efficient non-reasoning model on Vertex AI", + aliases: ["grok-4-1-fast-non-reasoning", "grok-4-fast-non-reasoning"], + supportsClientTools: true, + supportsMaxOutputTokens: true, + supportsReasoningEffort: false, + }, +]; + +const VERTEX_PUBLISHER_PREFIX = "xai/"; +const VERTEX_REQUEST_PREFIX_RE = /^xai\//i; +const PROVIDER_PREFIX_RE = /^(x-ai|xai)\//i; + +const aliasMap = new Map(); +for (const model of VERTEX_MODELS) { + aliasMap.set(model.id.toLowerCase(), model.id); + for (const alias of model.aliases ?? []) { + aliasMap.set(alias.toLowerCase(), model.id); + } +} + +/** Default Vertex chat model selected when the user has not chosen one. */ +export const DEFAULT_VERTEX_MODEL: string = + VERTEX_MODELS.find((model) => model.id === "grok-4.20-reasoning")?.id ?? + VERTEX_MODELS[0]?.id ?? + "grok-4.20-reasoning"; + +/** Default Vertex model used for short helper generations (titles, recaps). */ +export const DEFAULT_VERTEX_TITLE_MODEL: string = + VERTEX_MODELS.find((model) => model.id === "grok-4.1-fast-non-reasoning")?.id ?? DEFAULT_VERTEX_MODEL; + +/** + * Normalizes a requested model id into a canonical Vertex SKU. Accepts the + * canonical id, declared aliases, or the on-the-wire `xai/` form. Returns + * the input trimmed if no match is found, so callers can still surface the + * original string in error messages. + */ +export function normalizeVertexModelId(modelId: string): string { + const trimmed = modelId.trim(); + if (!trimmed) return trimmed; + const withoutProviderPrefix = trimmed.replace(PROVIDER_PREFIX_RE, ""); + return aliasMap.get(withoutProviderPrefix.toLowerCase()) ?? withoutProviderPrefix; +} + +export function getVertexModelInfo(modelId: string): ModelInfo | undefined { + const normalized = normalizeVertexModelId(modelId); + return VERTEX_MODELS.find((m) => m.id === normalized); +} + +export function getVertexModelIds(): string[] { + return VERTEX_MODELS.map((m) => m.id); +} + +export function isKnownVertexModelId(modelId: string): boolean { + return !!getVertexModelInfo(modelId); +} + +/** + * Returns the on-the-wire model string used in Vertex OpenAPI chat-completions + * requests. The Vertex partner-model endpoint expects the publisher prefix + * (e.g. `xai/grok-4.20-reasoning`) even though the model cards and overview + * pages list the bare ids. + */ +export function getVertexRequestModelId(modelId: string): string { + const normalized = normalizeVertexModelId(modelId); + if (VERTEX_REQUEST_PREFIX_RE.test(normalized)) return normalized; + return `${VERTEX_PUBLISHER_PREFIX}${normalized}`; +} diff --git a/src/providers/vertex/openapi.test.ts b/src/providers/vertex/openapi.test.ts new file mode 100644 index 000000000..e1863d81b --- /dev/null +++ b/src/providers/vertex/openapi.test.ts @@ -0,0 +1,566 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getVertexAccessToken } from "./auth"; +import { + buildVertexModelUrl, + convertMessagesToVertexContents, + convertVertexGenerateResponseToOpenAI, + convertVertexStreamResponseToOpenAIChunks, + convertXaiChatRequestToVertex, + createVertexFetch, + createVertexSseStream, + getVertexModelId, + sanitizeVertexSchema, +} from "./openapi"; + +const getVertexAccessTokenMock = vi.mocked(getVertexAccessToken); + +vi.mock("./auth", () => ({ + getVertexAccessToken: vi.fn(async () => "adc-token"), +})); + +const originalEnv = { + GROK_VERTEX_PROJECT_ID: process.env.GROK_VERTEX_PROJECT_ID, + GROK_VERTEX_LOCATION: process.env.GROK_VERTEX_LOCATION, + GROK_VERTEX_BASE_URL: process.env.GROK_VERTEX_BASE_URL, + GROK_VERTEX_DISABLE_TOOLS: process.env.GROK_VERTEX_DISABLE_TOOLS, + GCP_PROJECT_ID: process.env.GCP_PROJECT_ID, + GCP_REGION: process.env.GCP_REGION, + GCP_VERTEX_LOCATION: process.env.GCP_VERTEX_LOCATION, + GCP_VERTEX_BASE_URL: process.env.GCP_VERTEX_BASE_URL, +}; + +function restoreVertexEnv(): void { + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + +describe("Vertex Grok adapter", () => { + afterEach(() => { + restoreVertexEnv(); + vi.clearAllMocks(); + getVertexAccessTokenMock.mockResolvedValue("adc-token"); + }); + + it("uses the global Vertex host with the global location path", () => { + expect( + buildVertexModelUrl( + { + projectId: "project-1", + location: "global", + baseURL: "https://aiplatform.googleapis.com", + authMode: "adc", + }, + "grok-4-1-fast-reasoning", + false, + ), + ).toBe( + "https://aiplatform.googleapis.com/v1/projects/project-1/locations/global/publishers/xai/models/grok-4.1-fast-reasoning:generateContent", + ); + }); + + it("still allows explicit regional location paths", () => { + expect( + buildVertexModelUrl( + { + projectId: "project-1", + location: "europe-west1", + baseURL: "https://aiplatform.googleapis.com", + authMode: "adc", + }, + "grok-4-1-fast-reasoning", + false, + ), + ).toBe( + "https://aiplatform.googleapis.com/v1/projects/project-1/locations/europe-west1/publishers/xai/models/grok-4.1-fast-reasoning:generateContent", + ); + }); + + it("requests SSE output from Vertex streaming endpoints", () => { + expect( + buildVertexModelUrl( + { + projectId: "project-1", + location: "europe-west1", + baseURL: "https://aiplatform.googleapis.com/", + authMode: "adc", + }, + "grok-4-1-fast-reasoning", + true, + ), + ).toBe( + "https://aiplatform.googleapis.com/v1/projects/project-1/locations/europe-west1/publishers/xai/models/grok-4.1-fast-reasoning:streamGenerateContent?alt=sse", + ); + }); + + it("maps native xAI model IDs to Vertex xAI publisher IDs", () => { + expect(getVertexModelId("grok-4-1-fast-reasoning")).toBe("grok-4.1-fast-reasoning"); + expect(getVertexModelId("grok-4-1-fast-non-reasoning")).toBe("grok-4.1-fast-non-reasoning"); + expect(getVertexModelId("grok-4.20-0309-reasoning")).toBe("grok-4.20-reasoning"); + expect(getVertexModelId("custom-model")).toBe("custom-model"); + }); + + it("maps OpenAI-style chat messages to Vertex contents", () => { + const contents = convertMessagesToVertexContents([ + { role: "system", content: "Follow policy." }, + { role: "user", content: "Hello" }, + { + role: "assistant", + content: "I can help.", + tool_calls: [ + { + id: "call-1", + type: "function", + function: { name: "lookup", arguments: '{"query":"docs"}' }, + }, + ], + }, + { role: "tool", tool_call_id: "call-1", content: '{"ok":true}' }, + ]); + + expect(contents).toEqual([ + { role: "user", parts: [{ text: "Hello" }] }, + { + role: "model", + parts: [{ text: "I can help." }, { functionCall: { name: "lookup", args: { query: "docs" } } }], + }, + { role: "user", parts: [{ functionResponse: { name: "lookup", response: { ok: true } } }] }, + ]); + }); + + it("sends system prompts through Vertex systemInstruction", () => { + const request = convertXaiChatRequestToVertex({ + model: "grok-4-1-fast-reasoning", + messages: [ + { role: "system", content: "Follow policy." }, + { role: "user", content: "Hello" }, + ], + }); + + expect(request).toMatchObject({ + contents: [{ role: "user", parts: [{ text: "Hello" }] }], + systemInstruction: { parts: [{ text: "Follow policy." }] }, + }); + }); + + it("maps and sanitizes function declarations by default", () => { + const request = convertXaiChatRequestToVertex({ + model: "grok-4-1-fast-reasoning", + messages: [{ role: "user", content: "Search docs" }], + tools: [ + { + type: "function", + function: { + name: "search", + description: "Search docs", + parameters: { + type: "object", + additionalProperties: false, + properties: { + query: { type: "string", minLength: 1 }, + limit: { anyOf: [{ type: "integer" }, { type: "null" }], description: "Result count" }, + }, + required: ["query"], + }, + }, + }, + ], + tool_choice: { type: "function", function: { name: "search" } }, + }); + + expect(request.tools).toEqual([ + { + functionDeclarations: [ + { + name: "search", + description: "Search docs", + parameters: { + type: "OBJECT", + properties: { + query: { type: "STRING" }, + limit: { type: "INTEGER", description: "Result count", nullable: true }, + }, + required: ["query"], + }, + }, + ], + }, + ]); + expect(request.toolConfig).toEqual({ + functionCallingConfig: { mode: "ANY", allowedFunctionNames: ["search"] }, + }); + }); + + it("can omit function declarations with the emergency Vertex tool disable flag", () => { + process.env.GROK_VERTEX_DISABLE_TOOLS = "1"; + const request = convertXaiChatRequestToVertex({ + model: "grok-4-1-fast-reasoning", + messages: [{ role: "user", content: "Search docs" }], + max_completion_tokens: 512, + temperature: 0.2, + top_p: 0.9, + tools: [ + { + type: "function", + function: { + name: "search", + description: "Search docs", + parameters: { type: "object", properties: { query: { type: "string" } } }, + }, + }, + ], + tool_choice: { type: "function", function: { name: "search" } }, + }); + + expect(request.generationConfig).toEqual({ maxOutputTokens: 512, temperature: 0.2, topP: 0.9 }); + expect(request.tools).toBeUndefined(); + expect(request.toolConfig).toBeUndefined(); + }); + + it("drops invalid function names instead of sending declarations Vertex will reject", () => { + const request = convertXaiChatRequestToVertex({ + model: "grok-4-1-fast-reasoning", + messages: [{ role: "user", content: "Search docs" }], + tools: [ + { + type: "function", + function: { + name: "not allowed", + description: "Invalid Vertex function name", + parameters: { type: "object" }, + }, + }, + ], + tool_choice: { type: "function", function: { name: "not allowed" } }, + }); + + expect(request.tools).toBeUndefined(); + expect(request.toolConfig).toBeUndefined(); + }); + + it("converts JSON schema to Vertex's function schema subset", () => { + expect( + sanitizeVertexSchema({ + type: "object", + $schema: "https://json-schema.org/draft/2020-12/schema", + additionalProperties: false, + properties: { + path: { type: "string", title: "Path" }, + count: { type: ["integer", "null"], default: 10, nullable: true }, + mode: { enum: ["read", "write"] }, + tags: { type: "array", items: { type: "string", minLength: 1 } }, + }, + required: ["path"], + }), + ).toEqual({ + type: "OBJECT", + properties: { + path: { type: "STRING" }, + count: { type: "INTEGER", nullable: true }, + mode: { type: "STRING", enum: ["read", "write"] }, + tags: { type: "ARRAY", items: { type: "STRING" } }, + }, + required: ["path"], + }); + }); + + it("preserves nullable unions when sanitizing function schemas", () => { + expect( + sanitizeVertexSchema({ + type: "object", + properties: { + maybeText: { oneOf: [{ type: "null" }, { type: "string", description: "Optional text" }] }, + maybeCount: { anyOf: [{ type: "integer" }, { type: "null" }] }, + }, + }), + ).toEqual({ + type: "OBJECT", + properties: { + maybeText: { type: "STRING", description: "Optional text", nullable: true }, + maybeCount: { type: "INTEGER", nullable: true }, + }, + }); + }); + + it("maps Vertex generateContent responses back to OpenAI chat completions", () => { + const converted = convertVertexGenerateResponseToOpenAI( + { + candidates: [ + { + index: 0, + finishReason: "STOP", + content: { + parts: [{ text: "done" }, { functionCall: { name: "save", args: { path: "file.txt" } } }], + }, + }, + ], + usageMetadata: { promptTokenCount: 3, candidatesTokenCount: 4, totalTokenCount: 7 }, + }, + { id: "chatcmpl-test", model: "grok-4-1-fast-reasoning", created: 123 }, + ); + + expect(converted).toMatchObject({ + id: "chatcmpl-test", + object: "chat.completion", + choices: [ + { + index: 0, + message: { + role: "assistant", + content: "done", + tool_calls: [ + { + type: "function", + function: { name: "save", arguments: '{"path":"file.txt"}' }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + usage: { prompt_tokens: 3, completion_tokens: 4, total_tokens: 7 }, + }); + }); + + it("forwards Vertex generateContent error responses when status or code is present without a message", () => { + const converted = convertVertexGenerateResponseToOpenAI( + { + error: { + status: "RESOURCE_EXHAUSTED", + code: 8, + }, + }, + { id: "chatcmpl-error", model: "grok-4-1-fast-reasoning", created: 123 }, + ); + + expect(converted).toEqual({ + error: { + status: "RESOURCE_EXHAUSTED", + code: 8, + }, + }); + }); + + it("wraps Vertex JSON stream chunks as OpenAI SSE events", async () => { + const encoder = new TextEncoder(); + const vertexBody = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + '[{"candidates":[{"content":{"parts":[{"text":"hel"}]}}]},{"candidates":[{"content":{"parts":[{"text":"lo"}]},"finishReason":"STOP"}]}]', + ), + ); + controller.close(); + }, + }); + + const text = await new Response( + createVertexSseStream(vertexBody, { id: "chatcmpl-stream", model: "grok-4-1-fast-reasoning", created: 123 }), + ).text(); + + expect(text).toContain('"object":"chat.completion.chunk"'); + expect(text).toContain('"content":"hel"'); + expect(text).toContain('"content":"lo"'); + expect(text).toContain('"finish_reason":"stop"'); + expect(text.trim().endsWith("data: [DONE]")).toBe(true); + }); + + it("forwards Vertex streaming error chunks as OpenAI SSE error events", async () => { + const encoder = new TextEncoder(); + const vertexBody = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode('{"error":{"message":"quota exceeded","status":"RESOURCE_EXHAUSTED","code":8}}'), + ); + controller.close(); + }, + }); + + const text = await new Response( + createVertexSseStream(vertexBody, { id: "chatcmpl-stream", model: "grok-4-1-fast-reasoning", created: 123 }), + ).text(); + + expect(text).toContain('"error":{"message":"quota exceeded","status":"RESOURCE_EXHAUSTED","code":8}'); + expect(text.trim().endsWith("data: [DONE]")).toBe(true); + }); + + it("includes OpenAI stream indexes for Vertex function-call chunks", () => { + const chunks = convertVertexStreamResponseToOpenAIChunks( + { + candidates: [ + { + index: 0, + content: { + parts: [{ functionCall: { name: "read_file", args: { path: "README.md" } } }], + }, + }, + ], + }, + { id: "chatcmpl-tool", model: "grok-4-1-fast-reasoning", created: 123 }, + ); + + expect(chunks).toMatchObject([ + { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_chatcmpl-tool_0_0", + type: "function", + function: { name: "read_file", arguments: '{"path":"README.md"}' }, + }, + ], + }, + }, + ], + }, + { + choices: [{ finish_reason: "tool_calls" }], + }, + ]); + }); + + it("numbers stream function calls by function-call order, not part position", () => { + const chunks = convertVertexStreamResponseToOpenAIChunks( + { + candidates: [ + { + index: 0, + content: { + parts: [ + { text: "I will use tools." }, + { functionCall: { name: "read_file", args: { path: "README.md" } } }, + { text: " Then another." }, + { functionCall: { name: "grep", args: { pattern: "Vertex" } } }, + ], + }, + }, + ], + }, + { id: "chatcmpl-tool-mixed", model: "grok-4-1-fast-reasoning", created: 123 }, + ); + + const toolChunk = chunks.find((chunk) => { + const record = chunk as { choices?: Array<{ delta?: { tool_calls?: unknown[] } }> }; + return Boolean(record.choices?.[0]?.delta?.tool_calls); + }) as { choices: Array<{ delta: { tool_calls: Array<{ index: number; id: string }> } }> }; + + expect(toolChunk.choices[0].delta.tool_calls).toMatchObject([ + { index: 0, id: "call_chatcmpl-tool-mixed_0_0" }, + { index: 1, id: "call_chatcmpl-tool-mixed_0_1" }, + ]); + }); + + it("fetches Vertex with ADC bearer auth and returns translated chat JSON", async () => { + process.env.GROK_VERTEX_PROJECT_ID = "project-1"; + process.env.GROK_VERTEX_LOCATION = "europe-west1"; + + const baseFetch = vi.fn(async (url, init) => { + expect(String(url)).toBe( + "https://aiplatform.googleapis.com/v1/projects/project-1/locations/europe-west1/publishers/xai/models/grok-4.1-fast-reasoning:generateContent", + ); + expect((init?.headers as Record).Authorization).toBe("Bearer adc-token"); + expect(JSON.parse(String(init?.body))).toMatchObject({ + contents: [{ role: "user", parts: [{ text: "Hi" }] }], + }); + + return new Response( + JSON.stringify({ + candidates: [{ content: { parts: [{ text: "Hello" }] }, finishReason: "STOP" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }); + + const response = await createVertexFetch(baseFetch)("https://api.x.ai/v1/chat/completions", { + method: "POST", + body: JSON.stringify({ + model: "grok-4-1-fast-reasoning", + messages: [{ role: "user", content: "Hi" }], + }), + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + object: "chat.completion", + choices: [{ message: { role: "assistant", content: "Hello" }, finish_reason: "stop" }], + }); + }); + + it("returns an actionable Vertex auth response when ADC token refresh fails", async () => { + process.env.GROK_VERTEX_PROJECT_ID = "project-1"; + getVertexAccessTokenMock.mockRejectedValueOnce( + new Error( + "Google Application Default Credentials need reauthentication.\n\nRun `gcloud auth application-default login`.", + ), + ); + const baseFetch = vi.fn(); + + const response = await createVertexFetch(baseFetch)("https://api.x.ai/v1/chat/completions", { + method: "POST", + body: JSON.stringify({ + model: "grok-4-1-fast-reasoning", + messages: [{ role: "user", content: "Hi" }], + }), + }); + + expect(response.status).toBe(401); + expect(baseFetch).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toMatchObject({ + error: { + message: expect.stringContaining("Google Application Default Credentials need reauthentication."), + code: "vertex_auth_failed", + }, + }); + }); + + it("returns structured errors for unsupported xAI request shapes", async () => { + process.env.GROK_VERTEX_PROJECT_ID = "project-1"; + const baseFetch = vi.fn(); + + const response = await createVertexFetch(baseFetch)("https://api.x.ai/v1/chat/completions", { + method: "POST", + body: JSON.stringify({ + model: "grok-4-1-fast-reasoning", + messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "https://example.com/a.png" } }] }], + }), + }); + + expect(response.status).toBe(400); + expect(baseFetch).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toMatchObject({ + error: { + message: expect.stringContaining("image_url message parts are not supported"), + code: "vertex_request_invalid", + }, + }); + }); + + it("returns structured errors for empty Vertex conversations", async () => { + process.env.GROK_VERTEX_PROJECT_ID = "project-1"; + const baseFetch = vi.fn(); + + const response = await createVertexFetch(baseFetch)("https://api.x.ai/v1/chat/completions", { + method: "POST", + body: JSON.stringify({ + model: "grok-4-1-fast-reasoning", + messages: [], + }), + }); + + expect(response.status).toBe(400); + expect(baseFetch).not.toHaveBeenCalled(); + await expect(response.json()).resolves.toMatchObject({ + error: { + message: "Cannot send an empty conversation to Vertex AI.", + code: "vertex_request_invalid", + }, + }); + }); +}); diff --git a/src/providers/vertex/openapi.ts b/src/providers/vertex/openapi.ts new file mode 100644 index 000000000..ad0765332 --- /dev/null +++ b/src/providers/vertex/openapi.ts @@ -0,0 +1,977 @@ +import { requireVertexSettings, type VertexSettings } from "../../utils/settings"; +import { getVertexAccessToken } from "./auth"; + +function isTruthyEnv(value: string | undefined): boolean { + if (!value) return false; + const lower = value.trim().toLowerCase(); + return lower === "1" || lower === "true" || lower === "yes" || lower === "on"; +} + +type JsonRecord = Record; + +interface XaiToolCall { + id: string; + type: "function"; + function: { + name: string; + arguments: string; + }; +} + +interface XaiMessage { + role: "system" | "user" | "assistant" | "tool"; + content?: unknown; + tool_calls?: XaiToolCall[]; + tool_call_id?: string; +} + +interface XaiChatRequest { + model: string; + messages?: XaiMessage[]; + stream?: boolean; + temperature?: number; + top_p?: number; + seed?: number; + max_completion_tokens?: number; + tools?: Array<{ + type: "function"; + function: { + name: string; + description?: string; + parameters?: unknown; + }; + }>; + tool_choice?: + | "auto" + | "none" + | "required" + | { + type: "function"; + function: { + name: string; + }; + }; +} + +interface VertexPart { + text?: string; + functionCall?: { + name: string; + args?: JsonRecord; + }; + functionResponse?: { + name: string; + response: JsonRecord; + }; +} + +interface VertexContent { + role: "user" | "model"; + parts: VertexPart[]; +} + +interface VertexRequest { + contents: VertexContent[]; + systemInstruction?: { + parts: VertexPart[]; + }; + generationConfig?: JsonRecord; + tools?: Array<{ + functionDeclarations: Array<{ + name: string; + description?: string; + parameters?: unknown; + }>; + }>; + toolConfig?: JsonRecord; +} + +interface VertexCandidate { + index?: number; + content?: { + role?: string; + parts?: VertexPart[]; + }; + finishReason?: string; +} + +interface VertexResponse { + candidates?: VertexCandidate[]; + usageMetadata?: { + promptTokenCount?: number; + candidatesTokenCount?: number; + totalTokenCount?: number; + }; + error?: { + message?: string; + status?: string; + code?: number; + }; +} + +interface OpenAIContext { + id: string; + model: string; + created: number; +} + +const VERTEX_MODEL_IDS: Record = { + "grok-4-1-fast-reasoning": "grok-4.1-fast-reasoning", + "grok-4-1-fast-non-reasoning": "grok-4.1-fast-non-reasoning", + "grok-4.20-0309-reasoning": "grok-4.20-reasoning", + "grok-4.20-0309-non-reasoning": "grok-4.20-non-reasoning", +}; + +const VERTEX_FUNCTION_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/; +const VERTEX_SCHEMA_TYPES = new Set(["STRING", "INTEGER", "BOOLEAN", "NUMBER", "ARRAY", "OBJECT"]); +const UNSUPPORTED_SCHEMA_KEYS = new Set([ + "$defs", + "$id", + "$schema", + "additionalItems", + "additionalProperties", + "allOf", + "anyOf", + "const", + "contains", + "default", + "definitions", + "dependencies", + "dependentRequired", + "dependentSchemas", + "else", + "examples", + "exclusiveMaximum", + "exclusiveMinimum", + "format", + "if", + "maxItems", + "maxLength", + "maximum", + "minItems", + "minLength", + "minimum", + "multipleOf", + "not", + "oneOf", + "pattern", + "patternProperties", + "prefixItems", + "propertyNames", + "readOnly", + "strict", + "then", + "title", + "unevaluatedProperties", + "uniqueItems", + "writeOnly", +]); + +export function createVertexFetch(baseFetch: typeof fetch = globalThis.fetch): typeof fetch { + return async (input, init) => { + const url = getRequestUrl(input); + + if (!url.pathname.endsWith("/chat/completions")) { + return unsupportedVertexEndpointResponse(url); + } + + let xaiRequest: XaiChatRequest; + let vertexSettings: VertexSettings; + let vertexRequest: VertexRequest; + try { + xaiRequest = (await readJsonRequest(input, init)) as XaiChatRequest; + vertexSettings = requireVertexSettings(); + vertexRequest = convertXaiChatRequestToVertex(xaiRequest); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + return vertexErrorResponse(message, 400, "vertex_request_invalid"); + } + + const isStreaming = xaiRequest.stream === true; + let accessToken: string; + try { + accessToken = await getVertexAccessToken({ mode: vertexSettings.authMode }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + return vertexErrorResponse(message, 401, "vertex_auth_failed"); + } + const vertexUrl = buildVertexModelUrl(vertexSettings, xaiRequest.model, isStreaming); + const response = await baseFetch(vertexUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(vertexRequest), + signal: init?.signal ?? (input instanceof Request ? input.signal : undefined), + }); + + if (!response.ok) { + return translateVertexError(response, vertexUrl); + } + + const context = createOpenAIContext(xaiRequest.model); + if (isStreaming) { + if (!response.body) { + return vertexErrorResponse("Vertex AI returned a streaming response without a readable body.", 502); + } + return new Response(createVertexSseStream(response.body, context), { + status: 200, + headers: { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + } + + const payload = await response.json(); + return new Response(JSON.stringify(convertVertexGenerateResponseToOpenAI(payload, context)), { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }); + }; +} + +export function buildVertexModelUrl(settings: VertexSettings, modelId: string, isStreaming: boolean): string { + const method = isStreaming ? "streamGenerateContent" : "generateContent"; + const baseURL = settings.baseURL.replace(/\/+$/, ""); + const vertexModelId = getVertexModelId(modelId); + const url = `${baseURL}/v1/projects/${encodeURIComponent(settings.projectId)}/locations/${encodeURIComponent( + settings.location, + )}/publishers/xai/models/${encodeURIComponent(vertexModelId)}:${method}`; + return isStreaming ? `${url}?alt=sse` : url; +} + +export function getVertexModelId(modelId: string): string { + return VERTEX_MODEL_IDS[modelId] ?? modelId; +} + +export function convertXaiChatRequestToVertex(request: XaiChatRequest): VertexRequest { + const conversation = convertMessagesToVertexConversation(request.messages ?? []); + const generationConfig = removeUndefined({ + maxOutputTokens: request.max_completion_tokens, + temperature: request.temperature, + topP: request.top_p, + seed: request.seed, + }); + const functionDeclarations = shouldForwardVertexTools() + ? (request.tools ?? []) + .filter((tool) => tool.type === "function" && isValidVertexFunctionName(tool.function.name)) + .map((tool) => + removeUndefined({ + name: tool.function.name, + description: tool.function.description, + parameters: sanitizeVertexSchema(tool.function.parameters), + }), + ) + : []; + + return removeUndefined({ + contents: conversation.contents, + systemInstruction: conversation.systemInstruction, + generationConfig: Object.keys(generationConfig).length > 0 ? generationConfig : undefined, + tools: functionDeclarations.length > 0 ? [{ functionDeclarations }] : undefined, + toolConfig: + functionDeclarations.length > 0 + ? convertToolChoiceToVertexToolConfig( + request.tool_choice, + new Set(functionDeclarations.map((declaration) => declaration.name)), + ) + : undefined, + }) as VertexRequest; +} + +function shouldForwardVertexTools(): boolean { + return !isTruthyEnv(process.env.GROK_VERTEX_DISABLE_TOOLS); +} + +function isValidVertexFunctionName(name: string): boolean { + return VERTEX_FUNCTION_NAME_PATTERN.test(name); +} + +export function sanitizeVertexSchema(schema: unknown): unknown { + const normalized = sanitizeVertexSchemaValue(schema); + if (!isRecord(normalized)) { + return { type: "OBJECT", properties: {} }; + } + if (!normalized.type) { + if (isRecord(normalized.properties)) { + normalized.type = "OBJECT"; + } else if (normalized.items !== undefined) { + normalized.type = "ARRAY"; + } else { + normalized.type = "OBJECT"; + } + } + if (normalized.type === "OBJECT" && !isRecord(normalized.properties)) { + normalized.properties = {}; + } + return normalized; +} + +function sanitizeVertexSchemaValue(schema: unknown): unknown { + if (Array.isArray(schema)) { + return schema.map((item) => sanitizeVertexSchemaValue(item)); + } + + if (!isRecord(schema)) { + return undefined; + } + + const unionSchema = pickUnionSchema(schema); + if (unionSchema && unionSchema !== schema) { + const unionResult = sanitizeVertexSchemaValue(unionSchema); + if (isRecord(unionResult)) { + return copySchemaMetadata(schema, unionResult); + } + } + + const result: JsonRecord = {}; + const type = normalizeVertexSchemaType(schema.type); + if (type) { + result.type = type; + } + if (schemaAllowsNull(schema.type)) { + result.nullable = true; + } + + for (const [key, value] of Object.entries(schema)) { + if (key === "type" || UNSUPPORTED_SCHEMA_KEYS.has(key)) continue; + + switch (key) { + case "description": + if (typeof value === "string" && value.trim()) { + result.description = value; + } + break; + case "nullable": + if (typeof value === "boolean") { + result.nullable = value; + } + break; + case "enum": { + const enumValues = Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : []; + if (enumValues.length > 0) { + result.enum = enumValues; + } + break; + } + case "required": { + const required = Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : []; + if (required.length > 0) { + result.required = required; + } + break; + } + case "properties": { + if (!isRecord(value)) break; + const properties = Object.fromEntries( + Object.entries(value) + .map(([propertyName, propertySchema]) => [propertyName, sanitizeVertexSchemaValue(propertySchema)]) + .filter((entry): entry is [string, unknown] => entry[1] !== undefined), + ); + result.properties = properties; + if (!result.type) { + result.type = "OBJECT"; + } + break; + } + case "items": { + if (Array.isArray(value)) { + const firstItem = value.find((item) => item !== undefined); + const itemSchema = sanitizeVertexSchemaValue(firstItem); + if (itemSchema !== undefined) { + result.items = itemSchema; + } + } else { + const itemSchema = sanitizeVertexSchemaValue(value); + if (itemSchema !== undefined) { + result.items = itemSchema; + } + } + if (!result.type) { + result.type = "ARRAY"; + } + break; + } + } + } + + if (!result.type && result.enum) { + result.type = "STRING"; + } + if (result.type === "OBJECT" && !isRecord(result.properties)) { + result.properties = {}; + } + if (Array.isArray(result.required) && isRecord(result.properties)) { + const propertyNames = new Set(Object.keys(result.properties)); + const required = result.required.filter( + (entry): entry is string => typeof entry === "string" && propertyNames.has(entry), + ); + if (required.length > 0) { + result.required = required; + } else { + delete result.required; + } + } + if (result.type === "ARRAY" && result.items === undefined) { + result.items = { type: "STRING" }; + } + + return Object.keys(result).length > 0 ? result : undefined; +} + +function pickUnionSchema(schema: JsonRecord): unknown { + for (const key of ["anyOf", "oneOf", "allOf"] as const) { + const variants = schema[key]; + if (!Array.isArray(variants)) continue; + const nonNull = variants.find((variant) => !isNullSchema(variant)); + if (nonNull !== undefined) { + const picked = isRecord(nonNull) ? { ...nonNull } : nonNull; + if (isRecord(picked) && variants.some((variant) => isNullSchema(variant))) { + picked.nullable = true; + } + return picked; + } + } + return undefined; +} + +function copySchemaMetadata(source: JsonRecord, target: JsonRecord): JsonRecord { + if (typeof source.description === "string" && !target.description) { + target.description = source.description; + } + if (source.nullable === true && target.nullable === undefined) { + target.nullable = true; + } + return target; +} + +function normalizeVertexSchemaType(value: unknown): string | undefined { + if (typeof value === "string") { + const normalized = value.toUpperCase(); + return VERTEX_SCHEMA_TYPES.has(normalized) ? normalized : undefined; + } + if (Array.isArray(value)) { + const first = value.find((entry) => typeof entry === "string" && entry.toUpperCase() !== "NULL"); + return normalizeVertexSchemaType(first); + } + return undefined; +} + +function schemaAllowsNull(value: unknown): boolean { + return Array.isArray(value) && value.some((entry) => typeof entry === "string" && entry.toUpperCase() === "NULL"); +} + +function isNullSchema(value: unknown): boolean { + return isRecord(value) && typeof value.type === "string" && value.type.toUpperCase() === "NULL"; +} + +export function convertMessagesToVertexContents(messages: XaiMessage[]): VertexContent[] { + return convertMessagesToVertexConversation(messages).contents; +} + +function convertMessagesToVertexConversation(messages: XaiMessage[]): { + contents: VertexContent[]; + systemInstruction?: { parts: VertexPart[] }; +} { + const contents: VertexContent[] = []; + const toolNamesById = new Map(); + const systemParts = messages + .filter((message) => message.role === "system") + .flatMap((message) => textPartsFromContent(message.content)); + + const append = (role: VertexContent["role"], parts: VertexPart[]) => { + const cleanParts = parts.filter((part) => hasVertexPartValue(part)); + if (cleanParts.length === 0) return; + + const last = contents[contents.length - 1]; + if (last?.role === role) { + last.parts.push(...cleanParts); + return; + } + + contents.push({ role, parts: cleanParts }); + }; + + for (const message of messages) { + switch (message.role) { + case "system": + break; + case "user": + append("user", textPartsFromContent(message.content)); + break; + case "assistant": { + const parts = textPartsFromContent(message.content); + for (const toolCall of message.tool_calls ?? []) { + toolNamesById.set(toolCall.id, toolCall.function.name); + parts.push({ + functionCall: { + name: toolCall.function.name, + args: parseJsonObject(toolCall.function.arguments), + }, + }); + } + append("model", parts); + break; + } + case "tool": { + const toolName = (message.tool_call_id ? toolNamesById.get(message.tool_call_id) : undefined) ?? "tool_result"; + append("user", [ + { + functionResponse: { + name: toolName, + response: responseObjectFromToolContent(message.content), + }, + }, + ]); + break; + } + } + } + + if (contents.length === 0) { + if (systemParts.length === 0) { + throw new Error("Cannot send an empty conversation to Vertex AI."); + } + contents.push({ role: "user", parts: [{ text: "Continue." }] }); + } + + if (contents[0]?.role === "model") { + contents.unshift({ role: "user", parts: [{ text: "Continue." }] }); + } + + return removeUndefined({ + contents, + systemInstruction: systemParts.length > 0 ? { parts: systemParts } : undefined, + }) as { + contents: VertexContent[]; + systemInstruction?: { parts: VertexPart[] }; + }; +} + +export function convertVertexGenerateResponseToOpenAI(payload: unknown, context: OpenAIContext) { + const response = normalizeVertexResponse(payload); + if (hasVertexError(response)) { + return { + error: response.error, + }; + } + + const candidates = response.candidates?.length ? response.candidates : [{}]; + return { + id: context.id, + object: "chat.completion", + created: context.created, + model: context.model, + choices: candidates.map((candidate, index) => { + const toolCalls = extractFunctionCalls(candidate, context.id, index, false); + const content = extractTextFromVertexCandidate(candidate); + return { + index: candidate.index ?? index, + message: { + role: "assistant", + content: content || null, + ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}), + }, + finish_reason: toolCalls.length > 0 ? "tool_calls" : mapVertexFinishReason(candidate.finishReason), + }; + }), + usage: convertVertexUsage(response.usageMetadata), + }; +} + +export function createVertexSseStream( + vertexBody: ReadableStream, + context: OpenAIContext, +): ReadableStream { + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + let currentObject = ""; + let depth = 0; + let inString = false; + let escaped = false; + + const enqueueSse = (controller: TransformStreamDefaultController, value: unknown) => { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(value)}\n\n`)); + }; + + const processText = (text: string, controller: TransformStreamDefaultController) => { + for (const char of text) { + if (depth === 0) { + if (char === "{") { + currentObject = char; + depth = 1; + inString = false; + escaped = false; + } + continue; + } + + currentObject += char; + + if (escaped) { + escaped = false; + continue; + } + + if (inString && char === "\\") { + escaped = true; + continue; + } + + if (char === '"') { + inString = !inString; + continue; + } + + if (inString) continue; + + if (char === "{") { + depth += 1; + continue; + } + + if (char === "}") { + depth -= 1; + if (depth === 0) { + const parsed = JSON.parse(currentObject) as VertexResponse; + currentObject = ""; + for (const chunk of convertVertexStreamResponseToOpenAIChunks(parsed, context)) { + enqueueSse(controller, chunk); + } + } + } + } + }; + + return vertexBody.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + processText(decoder.decode(chunk, { stream: true }), controller); + }, + flush(controller) { + const tail = decoder.decode(); + if (tail) { + processText(tail, controller); + } + if (depth !== 0) { + throw new Error("Vertex AI returned an incomplete JSON stream."); + } + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + }, + }), + ); +} + +export function convertVertexStreamResponseToOpenAIChunks(payload: unknown, context: OpenAIContext): JsonRecord[] { + const response = normalizeVertexResponse(payload); + if (hasVertexError(response)) { + return [{ error: response.error }]; + } + + const usage = convertVertexUsage(response.usageMetadata); + const chunks: JsonRecord[] = []; + + for (const candidate of response.candidates ?? []) { + const index = candidate.index ?? 0; + const text = extractTextFromVertexCandidate(candidate); + const toolCalls = extractFunctionCalls(candidate, context.id, index, true); + const finishReason = toolCalls.length > 0 ? "tool_calls" : mapVertexFinishReason(candidate.finishReason); + + if (text) { + chunks.push({ + id: context.id, + object: "chat.completion.chunk", + created: context.created, + model: context.model, + choices: [{ index, delta: { content: text }, finish_reason: null }], + }); + } + + if (toolCalls.length > 0) { + chunks.push({ + id: context.id, + object: "chat.completion.chunk", + created: context.created, + model: context.model, + choices: [{ index, delta: { tool_calls: toolCalls }, finish_reason: null }], + }); + } + + if (finishReason) { + chunks.push({ + id: context.id, + object: "chat.completion.chunk", + created: context.created, + model: context.model, + choices: [{ index, delta: {}, finish_reason: finishReason }], + ...(usage ? { usage } : {}), + }); + } + } + + if (chunks.length === 0 && usage) { + chunks.push({ + id: context.id, + object: "chat.completion.chunk", + created: context.created, + model: context.model, + choices: [{ index: 0, delta: {}, finish_reason: null }], + usage, + }); + } + + return chunks; +} + +function createOpenAIContext(model: string): OpenAIContext { + return { + id: `chatcmpl-vertex-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`, + model, + created: Math.floor(Date.now() / 1000), + }; +} + +function getRequestUrl(input: Request | URL | string): URL { + if (input instanceof Request) return new URL(input.url); + return new URL(String(input)); +} + +async function readJsonRequest(input: Request | URL | string, init: RequestInit | undefined): Promise { + if (init?.body !== undefined && init.body !== null) { + return JSON.parse(await readBodyAsText(init.body)); + } + + if (input instanceof Request) { + return input.json(); + } + + throw new Error("Vertex adapter received a chat request without a JSON body."); +} + +async function readBodyAsText(body: NonNullable): Promise { + if (typeof body === "string") return body; + if (body instanceof URLSearchParams) return body.toString(); + if (body instanceof Blob) return body.text(); + if (body instanceof ArrayBuffer) return new TextDecoder().decode(body); + if (ArrayBuffer.isView(body)) return new TextDecoder().decode(body); + if (body instanceof ReadableStream) { + const response = new Response(body); + return response.text(); + } + + throw new Error("Vertex adapter expected a JSON request body."); +} + +function unsupportedVertexEndpointResponse(url: URL): Response { + return vertexErrorResponse( + `The Vertex AI provider supports chat completions only. The xAI endpoint "${url.pathname}" is not available on Vertex; native xAI-only features (Responses API search, image/video generation, STT, Batch API) require switching back to the xAI provider — unset GROK_PROVIDER (or pass --provider xai) and configure GROK_API_KEY.`, + 400, + "vertex_unsupported_endpoint", + ); +} + +async function translateVertexError(response: Response, vertexUrl: string): Promise { + const body = await response.text(); + const detail = extractVertexErrorMessage(body) || response.statusText || `HTTP ${response.status}`; + return vertexErrorResponse( + `Vertex AI request failed (${response.status}) for ${vertexUrl}: ${detail}`, + response.status, + ); +} + +function vertexErrorResponse(message: string, status: number, code = "vertex_request_failed"): Response { + return new Response( + JSON.stringify({ + error: { + message, + type: "vertex_ai_error", + code, + }, + }), + { + status, + headers: { + "Content-Type": "application/json", + }, + }, + ); +} + +function extractVertexErrorMessage(body: string): string | undefined { + try { + const parsed = JSON.parse(body) as { error?: { message?: string; status?: string } }; + return parsed.error?.message || parsed.error?.status; + } catch { + return body.trim() || undefined; + } +} + +function hasVertexError(response: VertexResponse): boolean { + return Boolean(response.error?.message || response.error?.status || response.error?.code !== undefined); +} + +function normalizeVertexResponse(payload: unknown): VertexResponse { + if (Array.isArray(payload)) { + return (payload[payload.length - 1] ?? {}) as VertexResponse; + } + return (payload ?? {}) as VertexResponse; +} + +function convertToolChoiceToVertexToolConfig( + toolChoice: XaiChatRequest["tool_choice"], + availableFunctionNames: Set, +): JsonRecord | undefined { + if (!toolChoice || toolChoice === "auto") { + return { functionCallingConfig: { mode: "AUTO" } }; + } + if (toolChoice === "none") { + return { functionCallingConfig: { mode: "NONE" } }; + } + if (toolChoice === "required") { + return { functionCallingConfig: { mode: "ANY" } }; + } + if (toolChoice.type === "function" && availableFunctionNames.has(toolChoice.function.name)) { + return { + functionCallingConfig: { + mode: "ANY", + allowedFunctionNames: [toolChoice.function.name], + }, + }; + } + return { functionCallingConfig: { mode: "AUTO" } }; +} + +function textPartsFromContent(content: unknown): VertexPart[] { + const text = extractTextContent(content); + return text ? [{ text }] : []; +} + +function extractTextContent(content: unknown): string { + if (content == null) return ""; + if (typeof content === "string") return content; + if (!Array.isArray(content)) return String(content); + + const parts: string[] = []; + for (const part of content) { + if (!part || typeof part !== "object") continue; + const record = part as JsonRecord; + if (record.type === "text" && typeof record.text === "string") { + parts.push(record.text); + continue; + } + if (record.type === "image_url") { + throw new Error( + "Vertex Grok adapter supports text chat and tool payloads; image_url message parts are not supported.", + ); + } + } + return parts.join("\n"); +} + +function hasVertexPartValue(part: VertexPart): boolean { + return Boolean(part.text || part.functionCall || part.functionResponse); +} + +function parseJsonObject(value: string): JsonRecord { + if (!value.trim()) return {}; + try { + const parsed = JSON.parse(value) as unknown; + return isRecord(parsed) ? parsed : { value: parsed }; + } catch { + return { value }; + } +} + +function responseObjectFromToolContent(content: unknown): JsonRecord { + const text = extractTextContent(content); + if (!text.trim()) return {}; + try { + const parsed = JSON.parse(text) as unknown; + return isRecord(parsed) ? parsed : { result: parsed }; + } catch { + return { result: text }; + } +} + +function extractTextFromVertexCandidate(candidate: VertexCandidate): string { + return (candidate.content?.parts ?? []) + .map((part) => (typeof part.text === "string" ? part.text : "")) + .filter(Boolean) + .join(""); +} + +function extractFunctionCalls( + candidate: VertexCandidate, + responseId: string, + candidateIndex: number, + includeDeltaIndex: boolean, +) { + const toolCalls = []; + let toolCallIndex = 0; + + for (const part of candidate.content?.parts ?? []) { + if (!part.functionCall) continue; + const index = toolCallIndex++; + toolCalls.push({ + ...(includeDeltaIndex ? { index } : {}), + id: `call_${responseId}_${candidateIndex}_${index}`, + type: "function", + function: { + name: part.functionCall.name, + arguments: JSON.stringify(part.functionCall.args ?? {}), + }, + }); + } + + return toolCalls; +} + +function mapVertexFinishReason(reason: string | undefined): string | null { + switch (reason) { + case undefined: + case "": + return null; + case "STOP": + return "stop"; + case "MAX_TOKENS": + return "length"; + case "MALFORMED_FUNCTION_CALL": + return "tool_calls"; + case "SAFETY": + case "RECITATION": + case "BLOCKLIST": + case "PROHIBITED_CONTENT": + case "SPII": + return "content_filter"; + default: + return "stop"; + } +} + +function convertVertexUsage(usage: VertexResponse["usageMetadata"]) { + if (!usage) return undefined; + const promptTokens = usage.promptTokenCount ?? 0; + const completionTokens = usage.candidatesTokenCount ?? 0; + return { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: usage.totalTokenCount ?? promptTokens + completionTokens, + }; +} + +function removeUndefined(value: T): T { + return Object.fromEntries(Object.entries(value).filter((entry) => entry[1] !== undefined)) as T; +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/providers/xai-models.ts b/src/providers/xai-models.ts new file mode 100644 index 000000000..8c36ed92a --- /dev/null +++ b/src/providers/xai-models.ts @@ -0,0 +1,126 @@ +import type { ModelInfo, ReasoningEffort } from "../types/index"; + +/** + * The native xAI model catalog. Each entry is the canonical id used internally; + * `aliases` accept legacy or shorthand names. When new providers (e.g. Vertex) + * ship their own catalogs, those live alongside this one rather than mixing in. + */ +export const XAI_MODELS: ModelInfo[] = [ + { + id: "grok-4.3", + name: "Grok 4.3", + contextWindow: 1_000_000, + inputPrice: 1.25, + outputPrice: 2.5, + reasoning: true, + description: "Recommended flagship reasoning model", + aliases: [ + "grok-4-1-fast-reasoning", + "grok-4-1-fast", + "grok-4-fast-reasoning", + "grok-4-fast", + "grok-4-0709", + "grok-code-fast-1", + "grok-code-fast", + ], + }, + { + id: "grok-4.20-multi-agent-0309", + name: "Grok 4.20 Multi-Agent", + contextWindow: 2_000_000, + inputPrice: 2.0, + outputPrice: 6.0, + reasoning: true, + description: "Realtime multi-agent research model", + aliases: ["grok-4.20-multi-agent", "grok-4.20-multi-agent-beta"], + responsesOnly: true, + multiAgent: true, + supportsClientTools: false, + supportsMaxOutputTokens: false, + defaultReasoningEffort: "low", + }, + { + id: "grok-4.20-0309-reasoning", + name: "Grok 4.20 Reasoning", + contextWindow: 2_000_000, + inputPrice: 2.0, + outputPrice: 6.0, + reasoning: true, + description: "Grok 4.20 reasoning release", + aliases: ["grok-4.20-beta-0309", "grok-4.20-beta", "grok-beta"], + }, + { + id: "grok-4.20-non-reasoning", + name: "Grok 4.20 Non-Reasoning", + contextWindow: 2_000_000, + inputPrice: 2.0, + outputPrice: 6.0, + reasoning: false, + description: "Recommended non-reasoning model", + aliases: ["grok-4.20-0309-non-reasoning", "grok-4-1-fast-non-reasoning", "grok-4-fast-non-reasoning", "grok-3"], + }, + { + id: "grok-3-mini", + name: "Grok 3 Mini", + contextWindow: 131_072, + inputPrice: 0.3, + outputPrice: 0.5, + reasoning: false, + description: "Budget-friendly compact model", + aliases: ["grok-3-mini-fast"], + supportsReasoningEffort: true, + }, +]; + +const PROVIDER_PREFIX_RE = /^(x-ai|xai)\//i; +const aliasMap = new Map(); + +for (const model of XAI_MODELS) { + aliasMap.set(model.id.toLowerCase(), model.id); + for (const alias of model.aliases ?? []) { + aliasMap.set(alias.toLowerCase(), model.id); + } +} + +export const DEFAULT_XAI_MODEL = + XAI_MODELS.find((model) => model.id === "grok-4.3")?.id ?? XAI_MODELS[0]?.id ?? "grok-4.3"; + +export function normalizeXaiModelId(modelId: string): string { + const trimmed = modelId.trim(); + if (!trimmed) return trimmed; + + const withoutProviderPrefix = trimmed.replace(PROVIDER_PREFIX_RE, ""); + return aliasMap.get(withoutProviderPrefix.toLowerCase()) ?? withoutProviderPrefix; +} + +export function getXaiModelInfo(modelId: string): ModelInfo | undefined { + const normalized = normalizeXaiModelId(modelId); + return XAI_MODELS.find((m) => m.id === normalized); +} + +export function getXaiModelIds(): string[] { + return XAI_MODELS.map((m) => m.id); +} + +export function isKnownXaiModelId(modelId: string): boolean { + return !!getXaiModelInfo(modelId); +} + +export function getXaiSupportedReasoningEfforts(modelId: string): ReasoningEffort[] { + const modelInfo = getXaiModelInfo(modelId); + if (!modelInfo?.supportsReasoningEffort) return []; + // Currently only grok-3-mini supports reasoning_effort per xAI docs. + // It supports "low" and "high" efforts. + return ["low", "high"]; +} + +export function getXaiEffectiveReasoningEffort( + modelId: string, + override?: ReasoningEffort, +): ReasoningEffort | undefined { + const supported = getXaiSupportedReasoningEfforts(modelId); + if (supported.length === 0) return undefined; + if (override && supported.includes(override)) return override; + const defaultEffort = getXaiModelInfo(modelId)?.defaultReasoningEffort; + return defaultEffort && supported.includes(defaultEffort) ? defaultEffort : undefined; +} diff --git a/src/providers/xai.ts b/src/providers/xai.ts new file mode 100644 index 000000000..7ea723cf3 --- /dev/null +++ b/src/providers/xai.ts @@ -0,0 +1,86 @@ +import { createXai } from "@ai-sdk/xai"; + +import { getReasoningEffortForModel } from "../utils/settings"; +import type { GrokProviderAdapter, HostedToolNamespace, ProviderCapabilities, ResolvedModelRuntime } from "./types"; +import { DEFAULT_XAI_MODEL, getXaiEffectiveReasoningEffort, getXaiModelInfo, normalizeXaiModelId } from "./xai-models"; + +const DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1"; + +const XAI_CAPABILITIES: ProviderCapabilities = { + responsesApi: true, + hostedSearch: true, + imageGeneration: true, + videoGeneration: true, + batchApi: true, + reasoningEffort: true, + audioStt: true, +}; + +/** Re-exported for legacy consumers that still type against the SDK shape. */ +export type XaiSdkProvider = ReturnType; + +export interface XaiAdapterOptions { + apiKey: string; + baseURL?: string; +} + +class XaiProviderAdapter implements GrokProviderAdapter { + readonly kind = "xai" as const; + readonly capabilities = XAI_CAPABILITIES; + readonly hostedTools: HostedToolNamespace; + + private readonly sdk: XaiSdkProvider; + private readonly apiKey: string; + + constructor(options: XaiAdapterOptions) { + this.apiKey = options.apiKey; + this.sdk = createXai({ + apiKey: options.apiKey, + baseURL: options.baseURL || process.env.GROK_BASE_URL || DEFAULT_XAI_BASE_URL, + }); + this.hostedTools = { + webSearch: () => this.sdk.tools.webSearch(), + xSearch: () => this.sdk.tools.xSearch(), + }; + } + + chatModel(modelId: string) { + return this.sdk(modelId); + } + + responsesModel(modelId: string) { + return this.sdk.responses(modelId); + } + + imageModel(modelId: string) { + return this.sdk.image(modelId); + } + + videoModel(modelId: string) { + return this.sdk.video(modelId); + } + + resolveRuntime(requestedModelId: string): ResolvedModelRuntime { + const modelId = normalizeXaiModelId(requestedModelId); + const modelInfo = getXaiModelInfo(modelId); + const reasoningEffort = getXaiEffectiveReasoningEffort(modelId, getReasoningEffortForModel(modelId)); + + return { + model: modelInfo?.responsesOnly ? this.sdk.responses(modelId) : this.sdk(modelId), + modelId, + modelInfo, + providerOptions: reasoningEffort ? { xai: { reasoningEffort } } : undefined, + }; + } + + getBatchClientApiKey(): string { + return this.apiKey; + } +} + +/** Constructs the native xAI adapter. */ +export function createXaiAdapter(options: XaiAdapterOptions): GrokProviderAdapter { + return new XaiProviderAdapter(options); +} + +export const XAI_DEFAULT_MODEL = DEFAULT_XAI_MODEL; diff --git a/src/ui/app.tsx b/src/ui/app.tsx index c6aabd91e..923bdd337 100644 --- a/src/ui/app.tsx +++ b/src/ui/app.tsx @@ -75,12 +75,21 @@ import { Markdown } from "./markdown"; import { buildMcpBrowseRows, McpBrowserModal, McpEditorModal } from "./mcp-modal"; import { createEmptyMcpEditorDraft, type McpEditorDraft, type McpEditorField } from "./mcp-modal-types"; import { + arePlanQuestionsAnswered, + firstUnansweredPlanQuestionIndex, formatPlanAnswers, + hasPlanAnswer, initialPlanQuestionsState, + isSinglePlanQuestionSet, + movePlanQuestionTab, + nextPlanTabAfterAnswer, + PLAN_QUESTION_REQUIRED_NOTICE, + type PlanAnswers, PlanQuestionsPanel, type PlanQuestionsState, PlanView, } from "./plan"; +import { isProcessingStatusNudge } from "./processing-input"; import { buildScheduleBrowseRows, ScheduleBrowserModal } from "./schedule-modal"; import { filterSlashMenuItems, SLASH_MENU_ITEMS, type SlashMenuItem } from "./slash-menu"; import { @@ -1353,7 +1362,13 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) } catch { /* */ } - }, []); + + try { + renderer.requestRender(); + } catch { + /* */ + } + }, [renderer]); const clearLiveTurnUi = useCallback(() => { setStreamContent(""); @@ -2417,8 +2432,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) const showPlanPanel = !!activePlan?.questions?.length; const planQuestions = activePlan?.questions ?? []; - const isSinglePlan = planQuestions.length === 1 && planQuestions[0]?.type !== "multiselect"; - const planTabCount = isSinglePlan ? 1 : planQuestions.length + 1; + const isSinglePlan = isSinglePlanQuestionSet(planQuestions); const isPlanConfirmTab = !isSinglePlan && pqs.tab === planQuestions.length; const dismissPlan = useCallback(() => { @@ -2426,13 +2440,52 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) setPqs(initialPlanQuestionsState()); }, []); + const submitPlanAnswersNow = useCallback( + (answers: PlanAnswers) => { + if (!activePlan?.questions?.length) return; + const text = formatPlanAnswers(activePlan.questions, answers); + setActivePlan(null); + setPqs(initialPlanQuestionsState()); + processMessage(text); + }, + [activePlan, processMessage], + ); + const submitPlanAnswers = useCallback(() => { if (!activePlan?.questions?.length) return; - const text = formatPlanAnswers(activePlan.questions, pqs.answers); - setActivePlan(null); - setPqs(initialPlanQuestionsState()); - processMessage(text); - }, [activePlan, pqs.answers, processMessage]); + if (!arePlanQuestionsAnswered(activePlan.questions, pqs.answers)) { + const tab = firstUnansweredPlanQuestionIndex(activePlan.questions, pqs.answers) ?? 0; + setPqs((s) => ({ + ...s, + tab, + selected: 0, + editing: false, + notice: PLAN_QUESTION_REQUIRED_NOTICE, + })); + return; + } + submitPlanAnswersNow(pqs.answers); + }, [activePlan, pqs.answers, submitPlanAnswersNow]); + + const savePlanAnswerAndAdvance = useCallback( + (q: PlanQuestion, value: string | string[]) => { + const nextAnswers = { ...pqs.answers, [q.id]: value }; + if (isSinglePlan) { + submitPlanAnswersNow(nextAnswers); + return; + } + const nextTab = nextPlanTabAfterAnswer(planQuestions, pqs.tab, nextAnswers); + setPqs((s) => ({ + ...s, + answers: nextAnswers, + tab: nextTab, + selected: 0, + editing: false, + notice: undefined, + })); + }, + [isSinglePlan, planQuestions, pqs.answers, pqs.tab, submitPlanAnswersNow], + ); const handlePlanSelect = useCallback( (q: PlanQuestion, idx: number, options: { id: string; label: string }[], showCustom: boolean) => { @@ -2443,15 +2496,20 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) if (customVal) { const existing = (pqs.answers[q.id] as string[] | undefined) ?? []; if (existing.includes(customVal)) { - setPqs((s) => ({ ...s, answers: { ...s.answers, [q.id]: existing.filter((x) => x !== customVal) } })); + const nextAnswers = { ...pqs.answers, [q.id]: existing.filter((x) => x !== customVal) }; + setPqs((s) => ({ + ...s, + answers: nextAnswers, + notice: hasPlanAnswer(nextAnswers, q) ? undefined : s.notice, + })); } else { - setPqs((s) => ({ ...s, editing: true })); + setPqs((s) => ({ ...s, editing: true, notice: undefined })); } } else { - setPqs((s) => ({ ...s, editing: true })); + setPqs((s) => ({ ...s, editing: true, notice: undefined })); } } else { - setPqs((s) => ({ ...s, editing: true })); + setPqs((s) => ({ ...s, editing: true, notice: undefined })); } return; } @@ -2459,21 +2517,19 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) if (!opt) return; if (q.type === "multiselect") { - setPqs((s) => { - const existing = (s.answers[q.id] as string[] | undefined) ?? []; - const next = existing.includes(opt.id) ? existing.filter((x) => x !== opt.id) : [...existing, opt.id]; - return { ...s, answers: { ...s.answers, [q.id]: next } }; - }); + const existing = (pqs.answers[q.id] as string[] | undefined) ?? []; + const next = existing.includes(opt.id) ? existing.filter((x) => x !== opt.id) : [...existing, opt.id]; + const nextAnswers = { ...pqs.answers, [q.id]: next }; + setPqs((s) => ({ + ...s, + answers: nextAnswers, + notice: hasPlanAnswer(nextAnswers, q) ? undefined : s.notice, + })); } else { - setPqs((s) => ({ ...s, answers: { ...s.answers, [q.id]: opt.id } })); - if (isSinglePlan) { - submitPlanAnswers(); - return; - } - setPqs((s) => ({ ...s, tab: s.tab + 1, selected: 0 })); + savePlanAnswerAndAdvance(q, opt.id); } }, - [pqs, isSinglePlan, submitPlanAnswers], + [pqs, savePlanAnswerAndAdvance], ); const dismissBtw = useCallback(() => { @@ -2492,6 +2548,8 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) return; } if (showPlanPanel) { + key.preventDefault(); + key.stopPropagation(); const q = planQuestions[pqs.tab]; // Escape always dismisses @@ -2510,24 +2568,17 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) if (q.type === "multiselect") { const existing = (pqs.answers[qId] as string[] | undefined) ?? []; const next = existing.includes(text) ? existing : [...existing, text]; - setPqs((s) => ({ ...s, editing: false, answers: { ...s.answers, [qId]: next } })); - } else if (q.type === "text") { - setPqs((s) => ({ ...s, editing: false, answers: { ...s.answers, [qId]: text } })); - if (isSinglePlan) { - submitPlanAnswers(); - return; - } - setPqs((s) => ({ ...s, tab: s.tab + 1, selected: 0 })); + setPqs((s) => ({ + ...s, + editing: false, + answers: { ...s.answers, [qId]: next }, + notice: undefined, + })); } else { - setPqs((s) => ({ ...s, editing: false, answers: { ...s.answers, [qId]: text } })); - if (isSinglePlan) { - submitPlanAnswers(); - return; - } - setPqs((s) => ({ ...s, tab: s.tab + 1, selected: 0 })); + savePlanAnswerAndAdvance(q, text); } } else { - setPqs((s) => ({ ...s, editing: false })); + setPqs((s) => ({ ...s, editing: false, notice: PLAN_QUESTION_REQUIRED_NOTICE })); } } return; @@ -2556,15 +2607,15 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) // Tab / left / right — switch between question tabs if (key.name === "tab") { const dir = key.shift ? -1 : 1; - setPqs((s) => ({ ...s, tab: (s.tab + dir + planTabCount) % planTabCount, selected: 0 })); + setPqs((s) => movePlanQuestionTab(planQuestions, s, dir)); return; } if (key.name === "left" || key.name === "h") { - setPqs((s) => ({ ...s, tab: (s.tab - 1 + planTabCount) % planTabCount, selected: 0 })); + setPqs((s) => movePlanQuestionTab(planQuestions, s, -1)); return; } if (key.name === "right" || key.name === "l") { - setPqs((s) => ({ ...s, tab: (s.tab + 1) % planTabCount, selected: 0 })); + setPqs((s) => movePlanQuestionTab(planQuestions, s, 1)); return; } @@ -2581,6 +2632,19 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) // Text-only question (no options) if (q.type === "text") { + if (key.name === "return") { + setPqs((s) => ({ ...s, editing: true, notice: PLAN_QUESTION_REQUIRED_NOTICE })); + return; + } + if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) { + setPqs((s) => ({ + ...s, + editing: true, + customInputs: { ...s.customInputs, [q.id]: (s.customInputs[q.id] ?? "") + key.sequence }, + notice: undefined, + })); + return; + } setPqs((s) => ({ ...s, editing: true })); return; } @@ -3225,7 +3289,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) interruptActiveRun, isPlanConfirmTab, isProcessing, - isSinglePlan, + savePlanAnswerAndAdvance, mcpEditorField, mcpEditorFields, mcpModalIndex, @@ -3245,7 +3309,6 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) submitMcpEditor, submitSubagentEditor, planQuestions, - planTabCount, pqs, removeEditingSubagent, applySandboxMode, @@ -3279,6 +3342,11 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) const handlePaste = useCallback( (event: PasteEvent) => { + if (showPlanPanel) { + event.preventDefault(); + return; + } + if (!hasApiKeyRef.current) { event.preventDefault(); openApiKeyModal(); @@ -3304,10 +3372,12 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) replacePasteBlocks([...pasteBlocksRef.current, block]); inputRef.current?.insertText(getPasteBlockToken(block)); }, - [openApiKeyModal, replacePasteBlocks], + [openApiKeyModal, replacePasteBlocks, showPlanPanel], ); const handleSubmit = useCallback(() => { + if (showPlanPanel) return; + const raw = inputRef.current?.plainText || ""; if (!raw.trim() && pasteBlocksRef.current.length === 0) { if (queuedMessagesRef.current.length > 0 && isProcessingRef.current) { @@ -3340,13 +3410,26 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) if (handleCommand(message)) return; const { enhancedMessage } = processAtMentions(message.trim(), agent.getCwd()); if (isProcessingRef.current) { + if (isProcessingStatusNudge(displayText)) { + setTimeout(scrollToBottom, 10); + return; + } queuedMessagesRef.current.push({ text: enhancedMessage, displayText }); setQueuedMessages(queuedMessagesRef.current.map((msg) => msg.displayText)); setTimeout(scrollToBottom, 10); return; } processMessage(enhancedMessage, displayText); - }, [agent, clearLiveTurnUi, handleCommand, openApiKeyModal, processMessage, replacePasteBlocks, scrollToBottom]); + }, [ + agent, + clearLiveTurnUi, + handleCommand, + openApiKeyModal, + processMessage, + replacePasteBlocks, + scrollToBottom, + showPlanPanel, + ]); const hasMessages = messages.length > 0 || streamContent || isProcessing; diff --git a/src/ui/plan.test.ts b/src/ui/plan.test.ts new file mode 100644 index 000000000..7898f194d --- /dev/null +++ b/src/ui/plan.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import type { PlanQuestion } from "../types"; +import { + arePlanQuestionsAnswered, + firstUnansweredPlanQuestionIndex, + hasPlanAnswer, + initialPlanQuestionsState, + movePlanQuestionTab, + nextPlanTabAfterAnswer, + PLAN_QUESTION_REQUIRED_NOTICE, +} from "./plan"; + +const questions = [ + { + id: "where", + question: "Where should the test file be created?", + type: "select", + options: [ + { id: "tests", label: "tests/" }, + { id: "src-test", label: "src/test/" }, + ], + }, + { + id: "notes", + question: "Any extra notes?", + type: "text", + }, +] satisfies PlanQuestion[]; + +describe("plan question helpers", () => { + it("treats empty strings and empty arrays as unanswered", () => { + expect(hasPlanAnswer({ where: "" }, questions[0]!)).toBe(false); + expect(hasPlanAnswer({ where: [] }, questions[0]!)).toBe(false); + expect(hasPlanAnswer({ where: "tests" }, questions[0]!)).toBe(true); + expect(hasPlanAnswer({ where: ["tests"] }, questions[0]!)).toBe(true); + }); + + it("finds unanswered questions before allowing final submission", () => { + expect(arePlanQuestionsAnswered(questions, { where: "tests" })).toBe(false); + expect(firstUnansweredPlanQuestionIndex(questions, { where: "tests" })).toBe(1); + expect(arePlanQuestionsAnswered(questions, { where: "tests", notes: "ship it" })).toBe(true); + expect(firstUnansweredPlanQuestionIndex(questions, { where: "tests", notes: "ship it" })).toBeNull(); + }); + + it("advances to the next unanswered question and only then to confirm", () => { + expect(nextPlanTabAfterAnswer(questions, 0, { where: "tests" })).toBe(1); + expect(nextPlanTabAfterAnswer(questions, 1, { where: "tests", notes: "done" })).toBe(2); + }); + + it("blocks forward tabbing from an unanswered question but allows moving back", () => { + const state = { ...initialPlanQuestionsState(), tab: 1 }; + expect(movePlanQuestionTab(questions, state, 1)).toMatchObject({ + tab: 1, + notice: PLAN_QUESTION_REQUIRED_NOTICE, + }); + + expect(movePlanQuestionTab(questions, state, -1)).toMatchObject({ + tab: 0, + selected: 0, + editing: false, + notice: undefined, + }); + }); +}); diff --git a/src/ui/plan.tsx b/src/ui/plan.tsx index a979137e4..7b9fa1c3b 100644 --- a/src/ui/plan.tsx +++ b/src/ui/plan.tsx @@ -2,6 +2,7 @@ import type { Plan, PlanQuestion } from "../types/index"; import type { Theme } from "./theme"; export type PlanAnswers = Record; +export const PLAN_QUESTION_REQUIRED_NOTICE = "Answer this question before continuing."; /* ── Plan Steps (inline in chat) ───────────────────────────── */ @@ -77,6 +78,7 @@ export interface PlanQuestionsState { answers: PlanAnswers; customInputs: Record; editing: boolean; + notice?: string; } export function initialPlanQuestionsState(): PlanQuestionsState { @@ -96,7 +98,7 @@ interface PlanQuestionsPanelProps { } export function PlanQuestionsPanel({ t, questions, state }: PlanQuestionsPanelProps) { - const isSingle = questions.length === 1 && questions[0]?.type !== "multiselect"; + const isSingle = isSinglePlanQuestionSet(questions); const isConfirmTab = !isSingle && state.tab === questions.length; const q = questions[state.tab]; @@ -118,7 +120,7 @@ export function PlanQuestionsPanel({ t, questions, state }: PlanQuestionsPanelPr {questions.map((q, i) => { const isActive = i === state.tab; - const isAnswered = hasAnswer(state.answers, q); + const isAnswered = hasPlanAnswer(state.answers, q); const label = tabLabel(q); return ( @@ -152,6 +154,12 @@ export function PlanQuestionsPanel({ t, questions, state }: PlanQuestionsPanelPr ) : null} + {state.notice ? ( + + {state.notice} + + ) : null} + {/* Footer hints */} {!isSingle && ( @@ -307,13 +315,67 @@ function tabLabel(q: PlanQuestion): string { return key ?? words[0] ?? "Question"; } -function hasAnswer(answers: PlanAnswers, q: PlanQuestion): boolean { +export function isSinglePlanQuestionSet(questions: PlanQuestion[]): boolean { + return questions.length === 1 && questions[0]?.type !== "multiselect"; +} + +export function getPlanTabCount(questions: PlanQuestion[]): number { + return isSinglePlanQuestionSet(questions) ? 1 : questions.length + 1; +} + +export function hasPlanAnswer(answers: PlanAnswers, q: PlanQuestion): boolean { const a = answers[q.id]; if (!a) return false; if (Array.isArray(a)) return a.length > 0; return a.trim().length > 0; } +export function arePlanQuestionsAnswered(questions: PlanQuestion[], answers: PlanAnswers): boolean { + return questions.every((q) => hasPlanAnswer(answers, q)); +} + +export function firstUnansweredPlanQuestionIndex(questions: PlanQuestion[], answers: PlanAnswers): number | null { + const index = questions.findIndex((q) => !hasPlanAnswer(answers, q)); + return index >= 0 ? index : null; +} + +export function nextPlanTabAfterAnswer(questions: PlanQuestion[], currentTab: number, answers: PlanAnswers): number { + if (isSinglePlanQuestionSet(questions)) return 0; + + for (let i = currentTab + 1; i < questions.length; i++) { + if (!hasPlanAnswer(answers, questions[i]!)) return i; + } + for (let i = 0; i <= currentTab && i < questions.length; i++) { + if (!hasPlanAnswer(answers, questions[i]!)) return i; + } + return questions.length; +} + +export function movePlanQuestionTab( + questions: PlanQuestion[], + state: PlanQuestionsState, + direction: 1 | -1, +): PlanQuestionsState { + const tabCount = getPlanTabCount(questions); + if (tabCount <= 1) return state; + + const currentQuestion = questions[state.tab]; + if (direction > 0 && currentQuestion && !hasPlanAnswer(state.answers, currentQuestion)) { + return { + ...state, + notice: PLAN_QUESTION_REQUIRED_NOTICE, + }; + } + + return { + ...state, + tab: (state.tab + direction + tabCount) % tabCount, + selected: 0, + editing: false, + notice: undefined, + }; +} + function isOptionPicked(answers: PlanAnswers, q: PlanQuestion, optionId: string): boolean { const a = answers[q.id]; if (!a) return false; diff --git a/src/ui/processing-input.test.ts b/src/ui/processing-input.test.ts new file mode 100644 index 000000000..68ec5c106 --- /dev/null +++ b/src/ui/processing-input.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { isProcessingStatusNudge } from "./processing-input"; + +describe("processing input helpers", () => { + it("classifies common liveness nudges while a turn is processing", () => { + expect(isProcessingStatusNudge("continue")).toBe(true); + expect(isProcessingStatusNudge(" Continue ")).toBe(true); + expect(isProcessingStatusNudge("status?")).toBe(true); + expect(isProcessingStatusNudge("are you stuck?")).toBe(true); + }); + + it("does not classify substantive follow-ups as status nudges", () => { + expect(isProcessingStatusNudge("continue by adding tests")).toBe(false); + expect(isProcessingStatusNudge("now update the README")).toBe(false); + expect(isProcessingStatusNudge("use option 2")).toBe(false); + }); +}); diff --git a/src/ui/processing-input.ts b/src/ui/processing-input.ts new file mode 100644 index 000000000..ad3f170d2 --- /dev/null +++ b/src/ui/processing-input.ts @@ -0,0 +1,21 @@ +const PROCESSING_STATUS_NUDGES = new Set([ + "continue", + "status", + "ping", + "still running", + "still working", + "are you stuck", + "are you frozen", +]); + +function normalizeProcessingInput(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[.!?]+$/g, "") + .replace(/\s+/g, " "); +} + +export function isProcessingStatusNudge(value: string): boolean { + return PROCESSING_STATUS_NUDGES.has(normalizeProcessingInput(value)); +} diff --git a/src/utils/install-manager.test.ts b/src/utils/install-manager.test.ts index a4fbf269b..bdf3733ee 100644 --- a/src/utils/install-manager.test.ts +++ b/src/utils/install-manager.test.ts @@ -8,6 +8,7 @@ import { getReleaseTargetForPlatform, getScriptInstallContext, getScriptInstallDir, + isCurrentScriptManagedInstall, loadScriptInstallMetadata, parseChecksumsFile, saveScriptInstallMetadata, @@ -111,6 +112,61 @@ describe("getScriptInstallContext", () => { }); }); +describe("isCurrentScriptManagedInstall", () => { + it("returns true when the current executable path matches install metadata", () => { + const homeDir = createTempDir("grok-current-"); + const installDir = getScriptInstallDir(homeDir); + const currentTarget = getReleaseTargetForPlatform()!; + const binaryPath = path.join(installDir, currentTarget.binaryName); + fs.mkdirSync(installDir, { recursive: true }); + fs.writeFileSync(binaryPath, "#!/usr/bin/env bash\n", { mode: 0o755 }); + + saveScriptInstallMetadata( + { + schemaVersion: 1, + installMethod: "script" as const, + version: "1.2.3", + repo: "superagent-ai/grok-cli", + binaryPath, + installDir, + assetName: currentTarget.assetName, + target: currentTarget.key, + installedAt: "2026-04-03T00:00:00.000Z", + }, + homeDir, + ); + + expect(isCurrentScriptManagedInstall(homeDir, [binaryPath])).toBe(true); + }); + + it("returns false when metadata exists for a different binary", () => { + const homeDir = createTempDir("grok-other-current-"); + const installDir = getScriptInstallDir(homeDir); + const currentTarget = getReleaseTargetForPlatform()!; + const binaryPath = path.join(installDir, currentTarget.binaryName); + const sourcePath = path.join(homeDir, "checkout", "dist", "index.js"); + fs.mkdirSync(path.dirname(sourcePath), { recursive: true }); + fs.writeFileSync(sourcePath, "#!/usr/bin/env bun\n", { mode: 0o755 }); + + saveScriptInstallMetadata( + { + schemaVersion: 1, + installMethod: "script" as const, + version: "1.2.3", + repo: "superagent-ai/grok-cli", + binaryPath, + installDir, + assetName: currentTarget.assetName, + target: currentTarget.key, + installedAt: "2026-04-03T00:00:00.000Z", + }, + homeDir, + ); + + expect(isCurrentScriptManagedInstall(homeDir, [sourcePath])).toBe(false); + }); +}); + describe("buildScriptUninstallPlan", () => { it("removes the full ~/.grok directory by default", () => { const homeDir = createTempDir("grok-uninstall-"); diff --git a/src/utils/install-manager.ts b/src/utils/install-manager.ts index 9a36565ff..6e3e1e92c 100644 --- a/src/utils/install-manager.ts +++ b/src/utils/install-manager.ts @@ -152,6 +152,21 @@ export function getScriptInstallContext(homeDir = os.homedir()): ScriptInstallCo return null; } +export function getCurrentExecutablePaths(): string[] { + return [process.execPath, process.argv[1]].filter((value): value is string => Boolean(value)); +} + +export function isCurrentScriptManagedInstall( + homeDir = os.homedir(), + executablePaths = getCurrentExecutablePaths(), +): boolean { + const context = getScriptInstallContext(homeDir); + if (!context) return false; + + const scriptBinaryPath = normalizeComparablePath(context.binaryPath); + return executablePaths.some((executablePath) => normalizeComparablePath(executablePath) === scriptBinaryPath); +} + export async function fetchLatestReleaseVersion(): Promise { const release = await fetchReleaseJson(`${GROK_RELEASES_API}/latest`); return release ? normalizeReleaseVersion(release.tag_name) : null; @@ -289,6 +304,17 @@ function notScriptManaged(action: string): ScriptUpdateRunResult { }; } +function normalizeComparablePath(filePath: string): string { + const resolvedPath = path.resolve(filePath); + let normalizedPath = resolvedPath; + try { + normalizedPath = fs.realpathSync.native(resolvedPath); + } catch { + normalizedPath = resolvedPath; + } + return process.platform === "win32" ? normalizedPath.toLowerCase() : normalizedPath; +} + function getReleaseTargetForPlatformKey(key: string): ReleaseTarget | null { switch (key) { case "darwin-arm64": diff --git a/src/utils/settings-vertex.test.ts b/src/utils/settings-vertex.test.ts new file mode 100644 index 000000000..3d84a3bc4 --- /dev/null +++ b/src/utils/settings-vertex.test.ts @@ -0,0 +1,234 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { readFileSyncMock, existsSyncMock, writeFileSyncMock, mkdirSyncMock } = vi.hoisted(() => ({ + readFileSyncMock: vi.fn(), + existsSyncMock: vi.fn(), + writeFileSyncMock: vi.fn(), + mkdirSyncMock: vi.fn(), +})); + +vi.mock("fs", () => ({ + readFileSync: readFileSyncMock, + existsSync: existsSyncMock, + writeFileSync: writeFileSyncMock, + mkdirSync: mkdirSyncMock, +})); + +const ENV_KEYS = [ + "GROK_PROVIDER", + "GROK_VERTEX_PROJECT_ID", + "GROK_VERTEX_LOCATION", + "GROK_VERTEX_BASE_URL", + "GROK_VERTEX_AUTH_MODE", + "GCP_PROJECT_ID", + "GCP_VERTEX_LOCATION", +] as const; + +function clearVertexEnv() { + for (const key of ENV_KEYS) { + delete process.env[key]; + } +} + +function stubSettingsFile(payload: unknown) { + existsSyncMock.mockReturnValue(true); + readFileSyncMock.mockReturnValue(JSON.stringify(payload)); +} + +function stubMissingSettingsFile() { + existsSyncMock.mockReturnValue(false); + readFileSyncMock.mockReturnValue(""); +} + +beforeEach(() => { + vi.resetModules(); + readFileSyncMock.mockReset(); + existsSyncMock.mockReset(); + writeFileSyncMock.mockReset(); + mkdirSyncMock.mockReset(); + clearVertexEnv(); +}); + +afterEach(() => { + clearVertexEnv(); +}); + +describe("provider selection", () => { + it("defaults to xai when nothing is configured", async () => { + stubMissingSettingsFile(); + const settings = await import("./settings"); + expect(settings.getActiveProvider()).toBe("xai"); + expect(settings.isVertexProviderActive()).toBe(false); + }); + + it("reads provider from user settings", async () => { + stubSettingsFile({ provider: "vertex" }); + const settings = await import("./settings"); + expect(settings.getActiveProvider()).toBe("vertex"); + expect(settings.isVertexProviderActive()).toBe(true); + }); + + it("lets GROK_PROVIDER env override saved settings", async () => { + stubSettingsFile({ provider: "vertex" }); + process.env.GROK_PROVIDER = "xai"; + const settings = await import("./settings"); + expect(settings.getActiveProvider()).toBe("xai"); + }); + + it("ignores unknown provider values from settings", async () => { + stubSettingsFile({ provider: "anthropic" }); + const settings = await import("./settings"); + expect(settings.getActiveProvider()).toBe("xai"); + }); + + it("normalizes case and whitespace from env", async () => { + stubMissingSettingsFile(); + process.env.GROK_PROVIDER = " Vertex "; + const settings = await import("./settings"); + expect(settings.getActiveProvider()).toBe("vertex"); + }); +}); + +describe("resolveVertexSettings", () => { + it("falls back to defaults when nothing is set", async () => { + stubMissingSettingsFile(); + const settings = await import("./settings"); + const resolved = settings.resolveVertexSettings(); + expect(resolved).toEqual({ + projectId: undefined, + location: settings.DEFAULT_VERTEX_LOCATION, + baseURL: settings.DEFAULT_VERTEX_BASE_URL, + authMode: settings.DEFAULT_VERTEX_AUTH_MODE, + }); + }); + + it("loads saved Vertex settings from user-settings.json", async () => { + stubSettingsFile({ + provider: "vertex", + vertex: { projectId: "my-proj", location: "us-central1", authMode: "adc" }, + }); + const settings = await import("./settings"); + const resolved = settings.resolveVertexSettings(); + expect(resolved.projectId).toBe("my-proj"); + expect(resolved.location).toBe("us-central1"); + expect(resolved.authMode).toBe("adc"); + expect(resolved.baseURL).toBe(settings.DEFAULT_VERTEX_BASE_URL); + }); + + it("normalizes a saved unsupported authMode back to the adc default", async () => { + stubSettingsFile({ + provider: "vertex", + // The user (or a future-version of the CLI) wrote a value the + // current build does not support. We want to fall back to the + // safe default rather than honor a mode the auth module would + // reject at request time. + vertex: { projectId: "my-proj", authMode: "oauth_token" as never }, + }); + const settings = await import("./settings"); + expect(settings.resolveVertexSettings().authMode).toBe(settings.DEFAULT_VERTEX_AUTH_MODE); + }); + + it("env overrides saved settings", async () => { + stubSettingsFile({ + vertex: { projectId: "saved-proj", location: "us-central1" }, + }); + process.env.GROK_VERTEX_PROJECT_ID = "env-proj"; + process.env.GROK_VERTEX_LOCATION = "europe-west4"; + const settings = await import("./settings"); + const resolved = settings.resolveVertexSettings(); + expect(resolved.projectId).toBe("env-proj"); + expect(resolved.location).toBe("europe-west4"); + }); + + it("falls back to GCP_PROJECT_ID when GROK_VERTEX_PROJECT_ID is unset", async () => { + stubMissingSettingsFile(); + process.env.GCP_PROJECT_ID = "gcp-proj"; + const settings = await import("./settings"); + expect(settings.resolveVertexSettings().projectId).toBe("gcp-proj"); + }); + + it("strips trailing slashes from baseURL", async () => { + stubMissingSettingsFile(); + process.env.GROK_VERTEX_BASE_URL = "https://aiplatform.googleapis.com//"; + const settings = await import("./settings"); + expect(settings.resolveVertexSettings().baseURL).toBe("https://aiplatform.googleapis.com"); + }); + + it("ignores blank string values from settings and env", async () => { + stubSettingsFile({ vertex: { projectId: " ", location: "" } }); + process.env.GROK_VERTEX_LOCATION = " "; + const settings = await import("./settings"); + const resolved = settings.resolveVertexSettings(); + expect(resolved.projectId).toBeUndefined(); + expect(resolved.location).toBe("global"); + }); +}); + +describe("requireVertexSettings", () => { + it("returns the resolved tuple when projectId is present", async () => { + stubSettingsFile({ vertex: { projectId: "my-proj" } }); + const settings = await import("./settings"); + expect(settings.requireVertexSettings()).toEqual({ + projectId: "my-proj", + location: settings.DEFAULT_VERTEX_LOCATION, + baseURL: settings.DEFAULT_VERTEX_BASE_URL, + authMode: settings.DEFAULT_VERTEX_AUTH_MODE, + }); + }); + + it("throws a descriptive error when projectId is missing", async () => { + stubMissingSettingsFile(); + const settings = await import("./settings"); + expect(() => settings.requireVertexSettings()).toThrow(/no project id is configured/); + }); +}); + +describe("saveUserSettings vertex normalization", () => { + it("persists provider and vertex fields together", async () => { + stubMissingSettingsFile(); + const settings = await import("./settings"); + settings.saveUserSettings({ + provider: "vertex", + vertex: { projectId: " my-proj ", location: "us-central1" }, + }); + + expect(writeFileSyncMock).toHaveBeenCalledTimes(1); + const written = JSON.parse(writeFileSyncMock.mock.calls[0]?.[1] as string); + expect(written.provider).toBe("vertex"); + expect(written.vertex).toEqual({ projectId: "my-proj", location: "us-central1" }); + }); + + it("merges new vertex fields over existing saved fields", async () => { + stubSettingsFile({ + provider: "vertex", + vertex: { projectId: "old-proj", location: "us-east1", authMode: "adc" }, + }); + const settings = await import("./settings"); + settings.saveUserSettings({ vertex: { projectId: "new-proj" } }); + + const written = JSON.parse(writeFileSyncMock.mock.calls[0]?.[1] as string); + expect(written.vertex).toEqual({ + projectId: "new-proj", + location: "us-east1", + authMode: "adc", + }); + }); + + it("rejects invalid provider values silently", async () => { + stubMissingSettingsFile(); + const settings = await import("./settings"); + settings.saveUserSettings({ provider: "anthropic" as never }); + + const written = JSON.parse(writeFileSyncMock.mock.calls[0]?.[1] as string); + expect(written.provider).toBeUndefined(); + }); + + it("rejects invalid authMode values silently", async () => { + stubMissingSettingsFile(); + const settings = await import("./settings"); + settings.saveUserSettings({ vertex: { authMode: "weird-mode" as never } }); + + const written = JSON.parse(writeFileSyncMock.mock.calls[0]?.[1] as string); + expect(written.vertex).toEqual({}); + }); +}); diff --git a/src/utils/settings.ts b/src/utils/settings.ts index 29c0fce49..f7f0861a8 100644 --- a/src/utils/settings.ts +++ b/src/utils/settings.ts @@ -10,12 +10,43 @@ import type { LspSettings, NormalizedLspSettings, } from "../lsp/types"; +import type { ProviderKind } from "../providers/types"; import type { AgentMode, ReasoningEffort } from "../types/index"; export type TelegramStreamingMode = "off" | "partial"; export type SandboxMode = "off" | "shuru"; export type PaymentChain = "base" | "base-sepolia"; +/** + * Vertex AI auth mode. Currently only Application Default Credentials + * (`adc`) is supported. Service-account-bound API keys and pre-minted + * OAuth bearer tokens are documented in the Google Cloud quickstart but + * deliberately out of scope for this PR — they need additional settings + * fields (token / key) and live verification against the Grok-on-Vertex + * endpoint before being safe to expose. Tracked as a follow-up. + */ +export type VertexAuthMode = "adc"; + +export const DEFAULT_VERTEX_LOCATION = "global"; +export const DEFAULT_VERTEX_BASE_URL = "https://aiplatform.googleapis.com"; +export const DEFAULT_VERTEX_AUTH_MODE: VertexAuthMode = "adc"; + +/** Fully resolved Vertex settings used by the Vertex provider at request time. */ +export interface VertexSettings { + projectId: string; + location: string; + baseURL: string; + authMode: VertexAuthMode; +} + +/** Vertex configuration as persisted in user-settings.json. All fields optional. */ +export interface VertexUserSettings { + projectId?: string; + location?: string; + baseURL?: string; + authMode?: VertexAuthMode; +} + export interface PaymentApprovalSettings { autoApprove?: boolean; } @@ -172,6 +203,10 @@ export interface UserSettings { hooks?: HooksConfig; payments?: PaymentSettings; modeModels?: Partial>; + /** Active provider backend. Defaults to "xai" when unset. */ + provider?: ProviderKind; + /** Vertex AI configuration. Only consulted when provider === "vertex". */ + vertex?: VertexUserSettings; } export interface ProjectSettings { @@ -279,6 +314,24 @@ export function saveUserSettings(partial: Partial): void { }, } : {}), + ...(() => { + // When the partial supplies a provider value, ALWAYS override the + // spread above so an invalid value (e.g. saveUserSettings({ provider: + // "anthropic" })) is filtered out. Falls back to the previously saved + // provider so an invalid update never clobbers a valid existing one. + // JSON.stringify will omit the field entirely if both end up undefined. + if (partial.provider === undefined) return {}; + const normalized = normalizeProviderKind(partial.provider); + return { provider: normalized ?? current.provider }; + })(), + ...(partial.vertex !== undefined + ? { + vertex: normalizeVertexUserSettings({ + ...current.vertex, + ...partial.vertex, + }), + } + : {}), }; writeJson(USER_SETTINGS_PATH, next); @@ -316,6 +369,119 @@ export function getBaseURL(): string { return process.env.GROK_BASE_URL || "https://api.x.ai/v1"; } +function normalizeProviderKind(value: unknown): ProviderKind | undefined { + if (value === "xai" || value === "vertex") return value; + if (typeof value === "string") { + const lower = value.trim().toLowerCase(); + if (lower === "xai" || lower === "vertex") return lower; + } + return undefined; +} + +function normalizeVertexAuthMode(value: unknown): VertexAuthMode | undefined { + if (value === "adc") return value; + if (typeof value === "string" && value.trim().toLowerCase() === "adc") return "adc"; + return undefined; +} + +function normalizeVertexLocation(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +function normalizeVertexBaseURL(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim().replace(/\/+$/, ""); + return trimmed ? trimmed : undefined; +} + +function normalizeVertexProjectId(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +function normalizeVertexUserSettings(raw: VertexUserSettings | undefined): VertexUserSettings { + if (!raw) return {}; + const result: VertexUserSettings = {}; + const projectId = normalizeVertexProjectId(raw.projectId); + if (projectId) result.projectId = projectId; + const location = normalizeVertexLocation(raw.location); + if (location) result.location = location; + const baseURL = normalizeVertexBaseURL(raw.baseURL); + if (baseURL) result.baseURL = baseURL; + const authMode = normalizeVertexAuthMode(raw.authMode); + if (authMode) result.authMode = authMode; + return result; +} + +/** + * Returns the active provider, resolved from (in priority order): + * 1. GROK_PROVIDER env var + * 2. UserSettings.provider + * 3. Default "xai" + */ +export function getActiveProvider(): ProviderKind { + const fromEnv = normalizeProviderKind(process.env.GROK_PROVIDER); + if (fromEnv) return fromEnv; + const fromSettings = normalizeProviderKind(loadUserSettings().provider); + return fromSettings ?? "xai"; +} + +/** Convenience for capability checks. */ +export function isVertexProviderActive(): boolean { + return getActiveProvider() === "vertex"; +} + +/** + * Resolves the full Vertex settings tuple by merging env vars over saved + * settings over defaults. Returns undefined for projectId when neither + * env nor saved settings supply one — call sites that require a project + * id should use requireVertexSettings instead. + */ +export function resolveVertexSettings(userVertex?: VertexUserSettings): { + projectId: string | undefined; + location: string; + baseURL: string; + authMode: VertexAuthMode; +} { + const saved = normalizeVertexUserSettings(userVertex ?? loadUserSettings().vertex); + return { + projectId: + normalizeVertexProjectId(process.env.GROK_VERTEX_PROJECT_ID) ?? + normalizeVertexProjectId(process.env.GCP_PROJECT_ID) ?? + saved.projectId, + location: + normalizeVertexLocation(process.env.GROK_VERTEX_LOCATION) ?? + normalizeVertexLocation(process.env.GCP_VERTEX_LOCATION) ?? + saved.location ?? + DEFAULT_VERTEX_LOCATION, + baseURL: normalizeVertexBaseURL(process.env.GROK_VERTEX_BASE_URL) ?? saved.baseURL ?? DEFAULT_VERTEX_BASE_URL, + authMode: normalizeVertexAuthMode(process.env.GROK_VERTEX_AUTH_MODE) ?? saved.authMode ?? DEFAULT_VERTEX_AUTH_MODE, + }; +} + +/** + * Same as resolveVertexSettings but throws a descriptive error when + * projectId is missing. Use this from request-time code paths that + * cannot proceed without a project id. + */ +export function requireVertexSettings(): VertexSettings { + const resolved = resolveVertexSettings(); + if (!resolved.projectId) { + throw new Error( + "Vertex AI is selected but no project id is configured. Set GROK_VERTEX_PROJECT_ID (or GCP_PROJECT_ID) in the environment, or save `vertex.projectId` to ~/.grok/user-settings.json.", + ); + } + return { + projectId: resolved.projectId, + location: resolved.location, + baseURL: resolved.baseURL, + authMode: resolved.authMode, + }; +} + export function getCurrentModel(mode?: AgentMode): string { if (process.env.GROK_MODEL) return normalizeModelId(process.env.GROK_MODEL); diff --git a/src/utils/side-question.ts b/src/utils/side-question.ts index e353e9108..03befc665 100644 --- a/src/utils/side-question.ts +++ b/src/utils/side-question.ts @@ -1,5 +1,6 @@ import { generateText } from "ai"; -import { resolveModelRuntime, type XaiProvider } from "../grok/client.js"; +import { resolveModelRuntime } from "../grok/client.js"; +import type { GrokProviderAdapter } from "../providers/index.js"; export interface SideQuestionResult { response: string; @@ -12,7 +13,7 @@ If conversation context is provided below, use it to give a more relevant answer export async function runSideQuestion( question: string, - provider: XaiProvider, + provider: GrokProviderAdapter, modelId: string, conversationContext: string, signal?: AbortSignal, diff --git a/src/utils/update-checker.test.ts b/src/utils/update-checker.test.ts index a5f684546..e2623c322 100644 --- a/src/utils/update-checker.test.ts +++ b/src/utils/update-checker.test.ts @@ -1,9 +1,29 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const RELEASE_URL = "https://api.github.com/repos/superagent-ai/grok-cli/releases/latest"; +const isCurrentScriptManagedInstallMock = vi.hoisted(() => vi.fn(() => true)); +const getScriptInstallContextMock = vi.hoisted(() => + vi.fn(() => ({ + metadata: { version: "1.0.0" }, + })), +); beforeEach(() => { vi.stubGlobal("fetch", vi.fn()); + isCurrentScriptManagedInstallMock.mockReset(); + isCurrentScriptManagedInstallMock.mockReturnValue(true); + getScriptInstallContextMock.mockReset(); + getScriptInstallContextMock.mockReturnValue({ + metadata: { version: "1.0.0" }, + }); + vi.doMock("./install-manager", async () => { + const actual = await vi.importActual("./install-manager"); + return { + ...actual, + getScriptInstallContext: getScriptInstallContextMock, + isCurrentScriptManagedInstall: isCurrentScriptManagedInstallMock, + }; + }); }); afterEach(() => { @@ -16,6 +36,32 @@ async function importModule() { } describe("checkForUpdate", () => { + it("returns null without fetching when the current process is not the script-managed install", async () => { + isCurrentScriptManagedInstallMock.mockReturnValue(false); + const mockFetch = vi.fn(); + vi.stubGlobal("fetch", mockFetch); + + const { checkForUpdate } = await importModule(); + const result = await checkForUpdate("1.0.0"); + + expect(result).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("returns null without fetching when the running version does not match script metadata", async () => { + getScriptInstallContextMock.mockReturnValue({ + metadata: { version: "1.1.6" }, + }); + const mockFetch = vi.fn(); + vi.stubGlobal("fetch", mockFetch); + + const { checkForUpdate } = await importModule(); + const result = await checkForUpdate("1.1.5"); + + expect(result).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + it("returns hasUpdate=true when release version is newer", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, @@ -53,6 +99,9 @@ describe("checkForUpdate", () => { }); it("detects update from prerelease to stable release", async () => { + getScriptInstallContextMock.mockReturnValue({ + metadata: { version: "1.0.0-rc7" }, + }); vi.stubGlobal( "fetch", vi.fn().mockResolvedValue({ @@ -71,6 +120,9 @@ describe("checkForUpdate", () => { }); it("returns hasUpdate=false when prerelease is newer than registry", async () => { + getScriptInstallContextMock.mockReturnValue({ + metadata: { version: "1.0.0-rc7" }, + }); vi.stubGlobal( "fetch", vi.fn().mockResolvedValue({ @@ -148,11 +200,35 @@ describe("checkForUpdate", () => { }); describe("runUpdate", () => { + it("returns failure when the current process is not the script-managed install", async () => { + isCurrentScriptManagedInstallMock.mockReturnValue(false); + + const { runUpdate } = await importModule(); + const result = await runUpdate("1.0.0"); + + expect(result.success).toBe(false); + expect(result.output).toContain("not the active script-managed release install"); + }); + + it("returns failure when the running version does not match script metadata", async () => { + getScriptInstallContextMock.mockReturnValue({ + metadata: { version: "1.1.6" }, + }); + + const { runUpdate } = await importModule(); + const result = await runUpdate("1.1.5"); + + expect(result.success).toBe(false); + expect(result.output).toContain("not the active script-managed release install"); + }); + it("returns success when the script-managed updater succeeds", async () => { vi.doMock("./install-manager", async () => { const actual = await vi.importActual("./install-manager"); return { ...actual, + getScriptInstallContext: getScriptInstallContextMock, + isCurrentScriptManagedInstall: isCurrentScriptManagedInstallMock, runScriptManagedUpdate: vi.fn().mockResolvedValue({ success: true, output: "Updated to Grok 2.0.0." }), }; }); @@ -169,6 +245,8 @@ describe("runUpdate", () => { const actual = await vi.importActual("./install-manager"); return { ...actual, + getScriptInstallContext: getScriptInstallContextMock, + isCurrentScriptManagedInstall: isCurrentScriptManagedInstallMock, runScriptManagedUpdate: vi.fn().mockResolvedValue({ success: false, output: "permission denied" }), }; }); diff --git a/src/utils/update-checker.ts b/src/utils/update-checker.ts index efb5f92cd..7da2fa7bc 100644 --- a/src/utils/update-checker.ts +++ b/src/utils/update-checker.ts @@ -1,6 +1,11 @@ import semverGt from "semver/functions/gt.js"; import semverValid from "semver/functions/valid.js"; -import { fetchLatestReleaseVersion, runScriptManagedUpdate } from "./install-manager"; +import { + fetchLatestReleaseVersion, + getScriptInstallContext, + isCurrentScriptManagedInstall, + runScriptManagedUpdate, +} from "./install-manager"; export interface UpdateCheckResult { currentVersion: string; @@ -15,6 +20,8 @@ export interface UpdateRunResult { export async function checkForUpdate(currentVersion: string): Promise { try { + if (!canUseScriptManagedUpdate(currentVersion)) return null; + const latestVersion = await fetchLatestReleaseVersion(); if (!latestVersion || !semverValid(latestVersion)) return null; @@ -29,5 +36,24 @@ export async function checkForUpdate(currentVersion: string): Promise { + if (!canUseScriptManagedUpdate(currentVersion)) { + return Promise.resolve({ + success: false, + output: + "This Grok command is not the active script-managed release install, so `grok update` was skipped to avoid replacing a different binary. Use the package manager or source checkout you launched Grok from.", + }); + } + return runScriptManagedUpdate(currentVersion); } + +function canUseScriptManagedUpdate(currentVersion: string): boolean { + if (!isCurrentScriptManagedInstall()) return false; + + const context = getScriptInstallContext(); + const normalizedCurrent = semverValid(currentVersion); + const normalizedMetadata = context?.metadata.version ? semverValid(context.metadata.version) : null; + if (normalizedCurrent && normalizedMetadata && normalizedCurrent !== normalizedMetadata) return false; + + return true; +}