fix: clean provider surfaces and core build
This commit is contained in:
parent
4c98cb8c33
commit
ab6cad4c84
67 changed files with 655 additions and 1538 deletions
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
PRs without a linked issue will be closed.
|
PRs without a linked issue will be closed.
|
||||||
Open or find an issue first: https://github.com/singularity-forge/sf-run/issues
|
Open or find an issue first: https://github.com/singularity-ng/singularity-forge/issues
|
||||||
-->
|
-->
|
||||||
|
|
||||||
Closes #<!-- issue number — required -->
|
Closes #<!-- issue number — required -->
|
||||||
|
|
|
||||||
14
README.md
14
README.md
|
|
@ -4,8 +4,8 @@
|
||||||
|
|
||||||
**The evolution of [Singularity Forge](https://github.com/sf-build/get-shit-done) — now a real coding agent.**
|
**The evolution of [Singularity Forge](https://github.com/sf-build/get-shit-done) — now a real coding agent.**
|
||||||
|
|
||||||
[](https://www.npmjs.com/package/sf-run)
|
[](https://www.npmjs.com/package/singularity-forge)
|
||||||
[](https://www.npmjs.com/package/sf-run)
|
[](https://www.npmjs.com/package/singularity-forge)
|
||||||
[](https://github.com/sf-build/SF)
|
[](https://github.com/sf-build/SF)
|
||||||
[](https://discord.com/invite/nKXTsAcmbT)
|
[](https://discord.com/invite/nKXTsAcmbT)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
|
@ -17,7 +17,7 @@ This version is different. SF is now a standalone CLI built on the [Pi SDK](http
|
||||||
|
|
||||||
One command. Walk away. Come back to a built project with clean git history.
|
One command. Walk away. Come back to a built project with clean git history.
|
||||||
|
|
||||||
<pre><code>npm install -g sf-run@latest</code></pre>
|
<pre><code>npm install -g singularity-forge@latest</code></pre>
|
||||||
|
|
||||||
> SF now provisions a managed [RTK](https://github.com/rtk-ai/rtk) binary on supported macOS, Linux, and Windows installs to compress shell-command output in `bash`, `async_bash`, `bg_shell`, and verification flows. SF forces `RTK_TELEMETRY_DISABLED=1` for all managed invocations. Set `SF_RTK_DISABLED=1` to disable the integration.
|
> SF now provisions a managed [RTK](https://github.com/rtk-ai/rtk) binary on supported macOS, Linux, and Windows installs to compress shell-command output in `bash`, `async_bash`, `bg_shell`, and verification flows. SF forces `RTK_TELEMETRY_DISABLED=1` for all managed invocations. Set `SF_RTK_DISABLED=1` to disable the integration.
|
||||||
|
|
||||||
|
|
@ -279,7 +279,7 @@ Step mode is the on-ramp. Auto mode is the highway.
|
||||||
### Install
|
### Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install -g sf-run
|
npm install -g singularity-forge
|
||||||
```
|
```
|
||||||
|
|
||||||
### Log in to a provider
|
### Log in to a provider
|
||||||
|
|
@ -771,8 +771,8 @@ Use expensive models where quality matters (planning, complex execution) and che
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
<a href="https://star-history.com/#singularity-forge/sf-run&Date">
|
<a href="https://star-history.com/#singularity-ng/singularity-forge&Date">
|
||||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=singularity-forge/sf-run&type=Date" />
|
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=singularity-ng/singularity-forge&type=Date" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -787,6 +787,6 @@ Use expensive models where quality matters (planning, complex execution) and che
|
||||||
|
|
||||||
**The original SF showed what was possible. This version delivers it.**
|
**The original SF showed what was possible. This version delivers it.**
|
||||||
|
|
||||||
**`npm install -g sf-run && sf`**
|
**`npm install -g singularity-forge && sf`**
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
|
||||||
# Install SF globally — version controlled via build arg
|
# Install SF globally — version controlled via build arg
|
||||||
ARG SF_VERSION=latest
|
ARG SF_VERSION=latest
|
||||||
RUN npm install -g sf-run@${SF_VERSION}
|
RUN npm install -g singularity-forge@${SF_VERSION}
|
||||||
|
|
||||||
# Create non-root user for sandbox isolation
|
# Create non-root user for sandbox isolation
|
||||||
RUN groupadd --gid 1000 sf \
|
RUN groupadd --gid 1000 sf \
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ git --version # should print 2.20+
|
||||||
**Step 4 — Install SF:**
|
**Step 4 — Install SF:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install -g sf-run
|
npm install -g singularity-forge
|
||||||
```
|
```
|
||||||
|
|
||||||
**Step 5 — Set up your LLM provider:**
|
**Step 5 — Set up your LLM provider:**
|
||||||
|
|
@ -116,7 +116,7 @@ git --version # should print 2.20+
|
||||||
**Step 4 — Install SF:**
|
**Step 4 — Install SF:**
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
npm install -g sf-run
|
npm install -g singularity-forge
|
||||||
```
|
```
|
||||||
|
|
||||||
**Step 5 — Set up your LLM provider:**
|
**Step 5 — Set up your LLM provider:**
|
||||||
|
|
@ -220,7 +220,7 @@ git --version # should print 2.20+
|
||||||
**Step 3 — Install SF:**
|
**Step 3 — Install SF:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install -g sf-run
|
npm install -g singularity-forge
|
||||||
```
|
```
|
||||||
|
|
||||||
**Step 4 — Set up your LLM provider:**
|
**Step 4 — Set up your LLM provider:**
|
||||||
|
|
@ -263,7 +263,7 @@ Inside the session, type `/model` to confirm your LLM is connected.
|
||||||
> npm config set prefix '~/.npm-global'
|
> npm config set prefix '~/.npm-global'
|
||||||
> echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.bashrc
|
> echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.bashrc
|
||||||
> source ~/.bashrc
|
> source ~/.bashrc
|
||||||
> npm install -g sf-run
|
> npm install -g singularity-forge
|
||||||
> ```
|
> ```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -279,8 +279,8 @@ Run SF in an isolated sandbox without installing Node.js on your host.
|
||||||
**Step 2 — Clone the SF repo:**
|
**Step 2 — Clone the SF repo:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/singularity-forge/sf-run.git
|
git clone https://github.com/singularity-ng/singularity-forge.git
|
||||||
cd sf-run/docker
|
cd singularity-forge/docker
|
||||||
```
|
```
|
||||||
|
|
||||||
**Step 3 — Create and enter a sandbox:**
|
**Step 3 — Create and enter a sandbox:**
|
||||||
|
|
@ -402,7 +402,7 @@ SF is also available as a VS Code extension. Install from the marketplace (publi
|
||||||
- **Sidebar dashboard** — connection status, model info, token usage
|
- **Sidebar dashboard** — connection status, model info, token usage
|
||||||
- **Full command palette** — start/stop agent, switch models, export sessions
|
- **Full command palette** — start/stop agent, switch models, export sessions
|
||||||
|
|
||||||
The CLI (`sf-run`) must be installed first — the extension connects to it via RPC.
|
The CLI (`singularity-forge`) must be installed first — the extension connects to it via RPC.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -439,7 +439,7 @@ sf sessions
|
||||||
SF checks for updates every 24 hours and prompts at startup. You can also update manually:
|
SF checks for updates every 24 hours and prompts at startup. You can also update manually:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm update -g sf-run
|
npm update -g singularity-forge
|
||||||
```
|
```
|
||||||
|
|
||||||
Or from within a session:
|
Or from within a session:
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,6 @@ After pinning:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node --version # v24.x.x
|
node --version # v24.x.x
|
||||||
npm install -g sf-run
|
npm install -g singularity-forge
|
||||||
sf --version
|
sf --version
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ It checks:
|
||||||
|
|
||||||
### `command not found: sf` after install
|
### `command not found: sf` after install
|
||||||
|
|
||||||
**Symptoms:** `npm install -g sf-run` succeeds but `sf` isn't found.
|
**Symptoms:** `npm install -g singularity-forge` succeeds but `sf` isn't found.
|
||||||
|
|
||||||
**Cause:** npm's global bin directory isn't in your shell's `$PATH`.
|
**Cause:** npm's global bin directory isn't in your shell's `$PATH`.
|
||||||
|
|
||||||
|
|
@ -59,14 +59,14 @@ echo 'export PATH="$(npm prefix -g)/bin:$PATH"' >> ~/.zshrc
|
||||||
source ~/.zshrc
|
source ~/.zshrc
|
||||||
```
|
```
|
||||||
|
|
||||||
**Workaround:** Run `npx sf-run` or `$(npm prefix -g)/bin/sf` directly.
|
**Workaround:** Run `npx singularity-forge` or `$(npm prefix -g)/bin/sf` directly.
|
||||||
|
|
||||||
**Common causes:**
|
**Common causes:**
|
||||||
- **Homebrew Node** — `/opt/homebrew/bin` should be in PATH but sometimes isn't if Homebrew init is missing from your shell profile
|
- **Homebrew Node** — `/opt/homebrew/bin` should be in PATH but sometimes isn't if Homebrew init is missing from your shell profile
|
||||||
- **Version manager (nvm, fnm, mise)** — global bin is version-specific; ensure your version manager initializes in your shell config
|
- **Version manager (nvm, fnm, mise)** — global bin is version-specific; ensure your version manager initializes in your shell config
|
||||||
- **oh-my-zsh** — the `gitfast` plugin aliases `sf` to `git svn dcommit`. Check with `alias sf` and unalias if needed
|
- **oh-my-zsh** — the `gitfast` plugin aliases `sf` to `git svn dcommit`. Check with `alias sf` and unalias if needed
|
||||||
|
|
||||||
### `npm install -g sf-run` fails
|
### `npm install -g singularity-forge` fails
|
||||||
|
|
||||||
**Common causes:**
|
**Common causes:**
|
||||||
- Missing workspace packages — fixed in v2.10.4+
|
- Missing workspace packages — fixed in v2.10.4+
|
||||||
|
|
@ -307,7 +307,7 @@ Doctor rebuilds `STATE.md` from plan and roadmap files on disk and fixes detecte
|
||||||
|
|
||||||
## Getting Help
|
## Getting Help
|
||||||
|
|
||||||
- **GitHub Issues:** [github.com/singularity-forge/sf-run/issues](https://github.com/singularity-forge/sf-run/issues)
|
- **GitHub Issues:** [github.com/singularity-ng/singularity-forge/issues](https://github.com/singularity-ng/singularity-forge/issues)
|
||||||
- **Dashboard:** `Ctrl+Alt+G` or `/sf status` for real-time diagnostics
|
- **Dashboard:** `Ctrl+Alt+G` or `/sf status` for real-time diagnostics
|
||||||
- **Forensics:** `/sf forensics` for structured post-mortem analysis of autonomous mode failures
|
- **Forensics:** `/sf forensics` for structured post-mortem analysis of autonomous mode failures
|
||||||
- **Session logs:** `.sf/activity/` contains JSONL session dumps for crash forensics
|
- **Session logs:** `.sf/activity/` contains JSONL session dumps for crash forensics
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ git --version # 应输出 2.20+
|
||||||
**第 4 步:安装 SF:**
|
**第 4 步:安装 SF:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install -g sf-run
|
npm install -g singularity-forge
|
||||||
```
|
```
|
||||||
|
|
||||||
**第 5 步:设置你的 LLM provider:**
|
**第 5 步:设置你的 LLM provider:**
|
||||||
|
|
@ -116,7 +116,7 @@ git --version # 应输出 2.20+
|
||||||
**第 4 步:安装 SF:**
|
**第 4 步:安装 SF:**
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
npm install -g sf-run
|
npm install -g singularity-forge
|
||||||
```
|
```
|
||||||
|
|
||||||
**第 5 步:设置你的 LLM provider:**
|
**第 5 步:设置你的 LLM provider:**
|
||||||
|
|
@ -220,7 +220,7 @@ git --version # 应输出 2.20+
|
||||||
**第 3 步:安装 SF:**
|
**第 3 步:安装 SF:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install -g sf-run
|
npm install -g singularity-forge
|
||||||
```
|
```
|
||||||
|
|
||||||
**第 4 步:设置你的 LLM provider:**
|
**第 4 步:设置你的 LLM provider:**
|
||||||
|
|
@ -263,7 +263,7 @@ sf --version # 输出已安装版本
|
||||||
> npm config set prefix '~/.npm-global'
|
> npm config set prefix '~/.npm-global'
|
||||||
> echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.bashrc
|
> echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.bashrc
|
||||||
> source ~/.bashrc
|
> source ~/.bashrc
|
||||||
> npm install -g sf-run
|
> npm install -g singularity-forge
|
||||||
> ```
|
> ```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -279,8 +279,8 @@ sf --version # 输出已安装版本
|
||||||
**第 2 步:克隆 SF 仓库:**
|
**第 2 步:克隆 SF 仓库:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/singularity-forge/sf-run.git
|
git clone https://github.com/singularity-ng/singularity-forge.git
|
||||||
cd sf-run/docker
|
cd singularity-forge/docker
|
||||||
```
|
```
|
||||||
|
|
||||||
**第 3 步:创建并进入沙箱:**
|
**第 3 步:创建并进入沙箱:**
|
||||||
|
|
@ -402,7 +402,7 @@ SF 也提供 VS Code 扩展。你可以从扩展市场安装(publisher: FluxLa
|
||||||
- **侧边栏仪表板**:显示连接状态、模型信息、Token 使用量
|
- **侧边栏仪表板**:显示连接状态、模型信息、Token 使用量
|
||||||
- **完整命令面板**:启动 / 停止 agent、切换模型、导出会话
|
- **完整命令面板**:启动 / 停止 agent、切换模型、导出会话
|
||||||
|
|
||||||
CLI(`sf-run`)需要先安装好,扩展会通过 RPC 与其连接。
|
CLI(`singularity-forge`)需要先安装好,扩展会通过 RPC 与其连接。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -439,7 +439,7 @@ sf sessions
|
||||||
SF 每 24 小时检查一次更新,并在启动时提示。你也可以手动更新:
|
SF 每 24 小时检查一次更新,并在启动时提示。你也可以手动更新:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm update -g sf-run
|
npm update -g singularity-forge
|
||||||
```
|
```
|
||||||
|
|
||||||
或者在会话中执行:
|
或者在会话中执行:
|
||||||
|
|
@ -456,7 +456,7 @@ npm update -g sf-run
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| `command not found: sf` | 把 npm 全局 bin 目录加入 PATH(见上面的系统说明) |
|
| `command not found: sf` | 把 npm 全局 bin 目录加入 PATH(见上面的系统说明) |
|
||||||
| `sf` 实际执行了 `git svn dcommit` | oh-my-zsh 冲突,执行 `unalias sf` 或改用 `sf-cli` |
|
| `sf` 实际执行了 `git svn dcommit` | oh-my-zsh 冲突,执行 `unalias sf` 或改用 `sf-cli` |
|
||||||
| `npm install -g sf-run` 权限错误 | 修复 npm prefix(见 Linux 说明)或改用 nvm |
|
| `npm install -g singularity-forge` 权限错误 | 修复 npm prefix(见 Linux 说明)或改用 nvm |
|
||||||
| 无法连接到 LLM | 用 `sf config` 检查 API key,并确认网络可用 |
|
| 无法连接到 LLM | 用 `sf config` 检查 API key,并确认网络可用 |
|
||||||
| `sf` 启动时卡住 | 检查 Node.js 版本:`node --version`(需要 22+) |
|
| `sf` 启动时卡住 | 检查 Node.js 版本:`node --version`(需要 22+) |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,6 @@ brew unpin node@24
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node --version # v24.x.x
|
node --version # v24.x.x
|
||||||
npm install -g sf-run
|
npm install -g singularity-forge
|
||||||
sf --version
|
sf --version
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@
|
||||||
|
|
||||||
### 安装后出现 `command not found: sf`
|
### 安装后出现 `command not found: sf`
|
||||||
|
|
||||||
**症状:** `npm install -g sf-run` 成功,但系统找不到 `sf`。
|
**症状:** `npm install -g singularity-forge` 成功,但系统找不到 `sf`。
|
||||||
|
|
||||||
**原因:** npm 的全局 bin 目录没有加入 shell 的 `$PATH`。
|
**原因:** npm 的全局 bin 目录没有加入 shell 的 `$PATH`。
|
||||||
|
|
||||||
|
|
@ -61,7 +61,7 @@ echo 'export PATH="$(npm prefix -g)/bin:$PATH"' >> ~/.zshrc
|
||||||
source ~/.zshrc
|
source ~/.zshrc
|
||||||
```
|
```
|
||||||
|
|
||||||
**临时方案:** 直接执行 `npx sf-run`,或使用 `$(npm prefix -g)/bin/sf`。
|
**临时方案:** 直接执行 `npx singularity-forge`,或使用 `$(npm prefix -g)/bin/sf`。
|
||||||
|
|
||||||
**常见原因:**
|
**常见原因:**
|
||||||
|
|
||||||
|
|
@ -69,7 +69,7 @@ source ~/.zshrc
|
||||||
- **版本管理器(nvm、fnm、mise)**:全局 bin 路径是按版本区分的,需确保版本管理器正确初始化
|
- **版本管理器(nvm、fnm、mise)**:全局 bin 路径是按版本区分的,需确保版本管理器正确初始化
|
||||||
- **oh-my-zsh**:`gitfast` 插件会把 `sf` alias 到 `git svn dcommit`。可通过 `alias sf` 检查,并在需要时取消 alias
|
- **oh-my-zsh**:`gitfast` 插件会把 `sf` alias 到 `git svn dcommit`。可通过 `alias sf` 检查,并在需要时取消 alias
|
||||||
|
|
||||||
### `npm install -g sf-run` 失败
|
### `npm install -g singularity-forge` 失败
|
||||||
|
|
||||||
**常见原因:**
|
**常见原因:**
|
||||||
|
|
||||||
|
|
@ -324,7 +324,7 @@ Doctor 会从磁盘上的 plan 和 roadmap 文件重建 `STATE.md`,并修复
|
||||||
|
|
||||||
## 获取帮助
|
## 获取帮助
|
||||||
|
|
||||||
- **GitHub Issues:** [github.com/singularity-forge/sf-run/issues](https://github.com/singularity-forge/sf-run/issues)
|
- **GitHub Issues:** [github.com/singularity-ng/singularity-forge/issues](https://github.com/singularity-ng/singularity-forge/issues)
|
||||||
- **Dashboard:** `Ctrl+Alt+G` 或 `/sf status`,查看实时诊断信息
|
- **Dashboard:** `Ctrl+Alt+G` 或 `/sf status`,查看实时诊断信息
|
||||||
- **Forensics:** `/sf forensics`,用于对自动模式失败做结构化事后分析
|
- **Forensics:** `/sf forensics`,用于对自动模式失败做结构化事后分析
|
||||||
- **Session logs:** `.sf/activity/` 中包含用于崩溃取证的 JSONL 会话转储
|
- **Session logs:** `.sf/activity/` 中包含用于崩溃取证的 JSONL 会话转储
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ You can stay hands-on with **step mode** (reviewing each step) or let SF run aut
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install
|
# Install
|
||||||
npm install -g sf-run
|
npm install -g singularity-forge
|
||||||
|
|
||||||
# Launch
|
# Launch
|
||||||
sf
|
sf
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
## Install SF
|
## Install SF
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install -g sf-run
|
npm install -g singularity-forge
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires **Node.js 24.0.0 or later** (24 LTS recommended) and **Git**.
|
Requires **Node.js 24.0.0 or later** (24 LTS recommended) and **Git**.
|
||||||
|
|
@ -57,7 +57,7 @@ The extension provides:
|
||||||
- **Sidebar dashboard** — connection status, model info, token usage, quick actions
|
- **Sidebar dashboard** — connection status, model info, token usage, quick actions
|
||||||
- **Full command palette** — start/stop agent, switch models, export sessions
|
- **Full command palette** — start/stop agent, switch models, export sessions
|
||||||
|
|
||||||
The CLI (`sf-run`) must be installed first — the extension connects to it via RPC.
|
The CLI (`singularity-forge`) must be installed first — the extension connects to it via RPC.
|
||||||
|
|
||||||
## Web Interface
|
## Web Interface
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@
|
||||||
"links": [
|
"links": [
|
||||||
{
|
{
|
||||||
"label": "GitHub",
|
"label": "GitHub",
|
||||||
"href": "https://github.com/singularity-forge/sf-run"
|
"href": "https://github.com/singularity-ng/singularity-forge"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"primary": {
|
"primary": {
|
||||||
|
|
@ -44,7 +44,7 @@
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"socials": {
|
"socials": {
|
||||||
"github": "https://github.com/singularity-forge/sf-run"
|
"github": "https://github.com/singularity-ng/singularity-forge"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ description: "Install SF, configure your LLM provider, and run your first autono
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install -g sf-run
|
npm install -g singularity-forge
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires Node.js 22+ and Git.
|
Requires Node.js 22+ and Git.
|
||||||
|
|
@ -156,7 +156,7 @@ SF is also available as a VS Code extension (publisher: FluxLabs). It provides:
|
||||||
- **Sidebar dashboard** — connection status, model info, token usage, quick actions
|
- **Sidebar dashboard** — connection status, model info, token usage, quick actions
|
||||||
- **Full command palette** — start/stop agent, switch models, export sessions
|
- **Full command palette** — start/stop agent, switch models, export sessions
|
||||||
|
|
||||||
The CLI (`sf-run`) must be installed first — the extension connects to it via RPC.
|
The CLI (`singularity-forge`) must be installed first — the extension connects to it via RPC.
|
||||||
|
|
||||||
## Web interface
|
## Web interface
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ It checks file structure, referential integrity, completion state consistency, g
|
||||||
source ~/.zshrc
|
source ~/.zshrc
|
||||||
```
|
```
|
||||||
|
|
||||||
**Workaround:** `npx sf-run` or `$(npm prefix -g)/bin/sf`
|
**Workaround:** `npx singularity-forge` or `$(npm prefix -g)/bin/sf`
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="Provider errors during auto mode">
|
<Accordion title="Provider errors during auto mode">
|
||||||
|
|
|
||||||
899
package-lock.json
generated
899
package-lock.json
generated
File diff suppressed because it is too large
Load diff
17
package.json
17
package.json
|
|
@ -1,15 +1,15 @@
|
||||||
{
|
{
|
||||||
"name": "singularity-forge",
|
"name": "singularity-forge",
|
||||||
"version": "2.75.2",
|
"version": "2.75.3",
|
||||||
"description": "Singularity Forge runtime core",
|
"description": "Singularity Forge runtime core",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/singularity-ng/singularity-foundry.git"
|
"url": "https://github.com/singularity-ng/singularity-forge.git"
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/singularity-ng/singularity-foundry#readme",
|
"homepage": "https://github.com/singularity-ng/singularity-forge#readme",
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/singularity-ng/singularity-foundry/issues"
|
"url": "https://github.com/singularity-ng/singularity-forge/issues"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|
@ -105,20 +105,21 @@
|
||||||
"release:changelog": "node scripts/generate-changelog.mjs",
|
"release:changelog": "node scripts/generate-changelog.mjs",
|
||||||
"release:bump": "node scripts/bump-version.mjs",
|
"release:bump": "node scripts/bump-version.mjs",
|
||||||
"release:update-changelog": "node scripts/update-changelog.mjs",
|
"release:update-changelog": "node scripts/update-changelog.mjs",
|
||||||
"docker:build-runtime": "docker build --target runtime -t ghcr.io/singularity-forge/sf-run .",
|
"docker:build-runtime": "docker build --target runtime -t ghcr.io/singularity-ng/singularity-forge .",
|
||||||
"docker:build-builder": "docker build --target builder -t ghcr.io/singularity-forge/sf-ci-builder .",
|
"docker:build-builder": "docker build --target builder -t ghcr.io/singularity-forge/sf-ci-builder .",
|
||||||
"prepublishOnly": "npm run sync-pkg-version && npm run sync-platform-versions && node scripts/prepublish-check.mjs && npm run build && npm run typecheck:extensions && npm run validate-pack",
|
"prepublishOnly": "npm run sync-pkg-version && npm run sync-platform-versions && node scripts/prepublish-check.mjs && npm run build && npm run typecheck:extensions && npm run validate-pack",
|
||||||
"test:live-regression": "node --experimental-strip-types tests/live-regression/run.ts"
|
"test:live-regression": "node --experimental-strip-types tests/live-regression/run.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.92.0",
|
"@anthropic-ai/sdk": "^0.93.0",
|
||||||
"@anthropic-ai/vertex-sdk": "^0.14.4",
|
"@anthropic-ai/vertex-sdk": "^0.14.4",
|
||||||
"@aws-sdk/client-bedrock-runtime": "^3.983.0",
|
"@aws-sdk/client-bedrock-runtime": "^3.983.0",
|
||||||
"@clack/prompts": "^1.1.0",
|
"@clack/prompts": "^1.1.0",
|
||||||
|
"@google/gemini-cli-core": "^0.40.1",
|
||||||
"@google/genai": "^1.40.0",
|
"@google/genai": "^1.40.0",
|
||||||
"@mariozechner/jiti": "^2.6.2",
|
"@mariozechner/jiti": "^2.6.2",
|
||||||
"@mistralai/mistralai": "^2.2.1",
|
"@mistralai/mistralai": "^2.2.1",
|
||||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"@octokit/rest": "^22.0.1",
|
"@octokit/rest": "^22.0.1",
|
||||||
"@silvia-odwyer/photon-node": "^0.3.4",
|
"@silvia-odwyer/photon-node": "^0.3.4",
|
||||||
"@sinclair/typebox": "^0.34.49",
|
"@sinclair/typebox": "^0.34.49",
|
||||||
|
|
@ -169,7 +170,7 @@
|
||||||
"vitest": "^4.1.5"
|
"vitest": "^4.1.5"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.83",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.128",
|
||||||
"@singularity-forge/engine-darwin-arm64": ">=2.10.2",
|
"@singularity-forge/engine-darwin-arm64": ">=2.10.2",
|
||||||
"@singularity-forge/engine-darwin-x64": ">=2.10.2",
|
"@singularity-forge/engine-darwin-x64": ">=2.10.2",
|
||||||
"@singularity-forge/engine-linux-arm64-gnu": ">=2.10.2",
|
"@singularity-forge/engine-linux-arm64-gnu": ">=2.10.2",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@singularity-forge/daemon",
|
"name": "@singularity-forge/daemon",
|
||||||
"version": "2.75.0",
|
"version": "2.75.3",
|
||||||
"description": "sf-run daemon — background process for project monitoring and Discord integration",
|
"description": "sf-run daemon — background process for project monitoring and Discord integration",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|
@ -29,8 +29,8 @@
|
||||||
"test": "vitest run packages/daemon/src --root ../.. --config vitest.config.ts"
|
"test": "vitest run packages/daemon/src --root ../.. --config vitest.config.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.92.0",
|
"@anthropic-ai/sdk": "^0.93.0",
|
||||||
"@singularity-forge/rpc-client": "^2.75.0",
|
"@singularity-forge/rpc-client": "^2.75.3",
|
||||||
"discord.js": "^14.25.1",
|
"discord.js": "^14.25.1",
|
||||||
"yaml": "^2.8.0",
|
"yaml": "^2.8.0",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@singularity-forge/native",
|
"name": "@singularity-forge/native",
|
||||||
"version": "2.75.0",
|
"version": "2.75.3",
|
||||||
"description": "Native Rust bindings for sf-run — high-performance native modules via N-API",
|
"description": "Native Rust bindings for sf-run — high-performance native modules via N-API",
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@singularity-forge/pi-agent-core",
|
"name": "@singularity-forge/pi-agent-core",
|
||||||
"version": "2.75.0",
|
"version": "2.75.3",
|
||||||
"description": "General-purpose agent core (vendored from pi-mono)",
|
"description": "General-purpose agent core (vendored from pi-mono)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@singularity-forge/pi-ai",
|
"name": "@singularity-forge/pi-ai",
|
||||||
"version": "2.75.0",
|
"version": "2.75.3",
|
||||||
"description": "Unified LLM API (vendored from pi-mono)",
|
"description": "Unified LLM API (vendored from pi-mono)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|
@ -23,10 +23,10 @@
|
||||||
"build": "tsc -p tsconfig.json"
|
"build": "tsc -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.92.0",
|
"@anthropic-ai/sdk": "^0.93.0",
|
||||||
"@anthropic-ai/vertex-sdk": "^0.14.4",
|
"@anthropic-ai/vertex-sdk": "^0.14.4",
|
||||||
"@aws-sdk/client-bedrock-runtime": "^3.983.0",
|
"@aws-sdk/client-bedrock-runtime": "^3.983.0",
|
||||||
"@google/gemini-cli-core": "0.38.2",
|
"@google/gemini-cli-core": "0.40.1",
|
||||||
"@google/genai": "^1.40.0",
|
"@google/genai": "^1.40.0",
|
||||||
"@mistralai/mistralai": "^2.2.1",
|
"@mistralai/mistralai": "^2.2.1",
|
||||||
"@sinclair/typebox": "^0.34.41",
|
"@sinclair/typebox": "^0.34.41",
|
||||||
|
|
|
||||||
|
|
@ -1117,10 +1117,10 @@ async function generateModels() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Google Cloud Code Assist models (Gemini CLI) — sourced from
|
// Google Cloud Code Assist models — sourced from
|
||||||
// @google/gemini-cli-core's VALID_GEMINI_MODELS so new models ship
|
// @google/gemini-cli-core's VALID_GEMINI_MODELS so new models ship
|
||||||
// automatically on `npm update @google/gemini-cli-core`. cli-core is
|
// automatically on `npm update @google/gemini-cli-core`. cli-core is
|
||||||
// the authoritative list (what the real `gemini` CLI binary supports).
|
// the authoritative list for Code Assist-backed Gemini models.
|
||||||
//
|
//
|
||||||
// We filter out `*-customtools` preview variants — they require a
|
// We filter out `*-customtools` preview variants — they require a
|
||||||
// specific tool protocol that SF's generic adapter doesn't speak.
|
// specific tool protocol that SF's generic adapter doesn't speak.
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ const server = new CodeAssistServer(authClient, projectId);
|
||||||
|
|
||||||
cli-core reads `~/.gemini/oauth_creds.json` (migrated to keychain on newer
|
cli-core reads `~/.gemini/oauth_creds.json` (migrated to keychain on newer
|
||||||
installs), refreshes tokens, writes back. SF's `/login` flow for this provider
|
installs), refreshes tokens, writes back. SF's `/login` flow for this provider
|
||||||
becomes "run the real `gemini` binary first" — exactly what the user asked for.
|
becomes "let cli-core own the login flow" instead of reimplementing Google OAuth in SF.
|
||||||
|
|
||||||
Pros: full integration benefit, SF drops ~80 lines of auth management.
|
Pros: full integration benefit, SF drops ~80 lines of auth management.
|
||||||
Cons: breaks existing SF auth storage path for this provider; users must
|
Cons: breaks existing SF auth storage path for this provider; users must
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
* Google Gemini CLI provider.
|
* Google Gemini CLI provider.
|
||||||
*
|
*
|
||||||
* Delegates auth, project discovery, and the Code Assist transport to
|
* Delegates auth, project discovery, and the Code Assist transport to
|
||||||
* @google/gemini-cli-core — the same library the real `gemini` CLI uses.
|
* @google/gemini-cli-core — the library behind Google's Gemini tooling.
|
||||||
* cli-core reads ~/.gemini/oauth_creds.json itself, refreshes tokens,
|
* cli-core reads ~/.gemini/oauth_creds.json itself when present, refreshes tokens,
|
||||||
* discovers the project (free-tier or whatever's onboarded server-side)
|
* discovers the project (free-tier or whatever's onboarded server-side)
|
||||||
* via setupUser(), and handles all the User-Agent / quota-classification details.
|
* via setupUser(), and handles all the User-Agent / quota-classification details.
|
||||||
* Request retry/fallback stays in the caller so SF can move to the next model.
|
* Request retry/fallback stays in the caller so SF can move to the next model.
|
||||||
|
|
@ -101,9 +101,9 @@ let toolCallCounter = 0;
|
||||||
/**
|
/**
|
||||||
* Build a CodeAssistServer using cli-core's own auth + project discovery.
|
* Build a CodeAssistServer using cli-core's own auth + project discovery.
|
||||||
*
|
*
|
||||||
* - getOauthClient() reads ~/.gemini/oauth_creds.json, refreshes if expired,
|
* - getOauthClient() reads ~/.gemini/oauth_creds.json when present, refreshes if
|
||||||
* and returns an authenticated AuthClient. Triggers the browser OAuth flow
|
* expired, and returns an authenticated AuthClient. cli-core owns any
|
||||||
* on cache miss.
|
* interactive login flow it needs.
|
||||||
* - setupUser() asks the Code Assist API for the project + tier tied to this
|
* - setupUser() asks the Code Assist API for the project + tier tied to this
|
||||||
* identity (free-tier auto-provisioned if needed; otherwise whatever the
|
* identity (free-tier auto-provisioned if needed; otherwise whatever the
|
||||||
* user has been onboarded to server-side).
|
* user has been onboarded to server-side).
|
||||||
|
|
@ -211,8 +211,8 @@ export const streamGoogleGeminiCli: StreamFunction<
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// cli-core handles auth + project discovery. If ~/.gemini/oauth_creds.json
|
// cli-core handles auth + project discovery. SF uses cli-core directly
|
||||||
// is missing the user needs to run the real `gemini` CLI to authenticate.
|
// and does not spawn a separate provider CLI process.
|
||||||
const server = await getCodeAssistServer();
|
const server = await getCodeAssistServer();
|
||||||
let req = buildRequest(model, context, options);
|
let req = buildRequest(model, context, options);
|
||||||
const nextReq = await options?.onPayload?.(req, model);
|
const nextReq = await options?.onPayload?.(req, model);
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@
|
||||||
*
|
*
|
||||||
* Note: Google Cloud Code Assist (google-gemini-cli) is not handled here.
|
* Note: Google Cloud Code Assist (google-gemini-cli) is not handled here.
|
||||||
* The provider delegates to @google/gemini-cli-core, which reads
|
* The provider delegates to @google/gemini-cli-core, which reads
|
||||||
* ~/.gemini/oauth_creds.json directly. Users authenticate via the real
|
* ~/.gemini/oauth_creds.json when present and owns any login flow it needs.
|
||||||
* `gemini` CLI; we just consume the credentials.
|
* SF uses cli-core directly and does not spawn a separate provider CLI process.
|
||||||
*
|
*
|
||||||
* Note: OpenAI Codex (ChatGPT) is not handled here via OAuth flows.
|
* Note: OpenAI Codex (ChatGPT) is not handled here via OAuth flows.
|
||||||
* The real `codex` CLI writes auth state to ~/.codex/auth.json after login.
|
* The real `codex` CLI writes auth state to ~/.codex/auth.json after login.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@singularity-forge/pi-coding-agent",
|
"name": "@singularity-forge/pi-coding-agent",
|
||||||
"version": "2.75.0",
|
"version": "2.75.3",
|
||||||
"description": "Coding agent CLI (vendored from pi-mono)",
|
"description": "Coding agent CLI (vendored from pi-mono)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"piConfig": {
|
"piConfig": {
|
||||||
|
|
|
||||||
|
|
@ -5,20 +5,23 @@ import { parseArgs } from "./args.js";
|
||||||
describe("parseArgs", () => {
|
describe("parseArgs", () => {
|
||||||
it("parses optional-value extension flags with implicit and explicit values", () => {
|
it("parses optional-value extension flags with implicit and explicit values", () => {
|
||||||
const extensionFlags = new Map([
|
const extensionFlags = new Map([
|
||||||
|
["genai-proxy", { type: "string" as const, allowNoValue: true }],
|
||||||
["gemini-cli-proxy", { type: "string" as const, allowNoValue: true }],
|
["gemini-cli-proxy", { type: "string" as const, allowNoValue: true }],
|
||||||
]);
|
]);
|
||||||
const defaultFlagArgs = parseArgs(["--gemini-cli-proxy"], extensionFlags);
|
const defaultFlagArgs = parseArgs(["--genai-proxy"], extensionFlags);
|
||||||
const explicitFlagArgs = parseArgs(
|
const explicitFlagArgs = parseArgs(["--genai-proxy=8080"], extensionFlags);
|
||||||
["--gemini-cli-proxy=8080"],
|
const legacyFlagArgs = parseArgs(
|
||||||
|
["--gemini-cli-proxy=3001"],
|
||||||
extensionFlags,
|
extensionFlags,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
[
|
[
|
||||||
defaultFlagArgs.unknownFlags.get("gemini-cli-proxy"),
|
defaultFlagArgs.unknownFlags.get("genai-proxy"),
|
||||||
explicitFlagArgs.unknownFlags.get("gemini-cli-proxy"),
|
explicitFlagArgs.unknownFlags.get("genai-proxy"),
|
||||||
|
legacyFlagArgs.unknownFlags.get("gemini-cli-proxy"),
|
||||||
],
|
],
|
||||||
[true, "8080"],
|
[true, "8080", "3001"],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -320,7 +320,7 @@ ${chalk.bold("Examples:")}
|
||||||
${APP_NAME} --models claude-sonnet,claude-haiku,gpt-4o
|
${APP_NAME} --models claude-sonnet,claude-haiku,gpt-4o
|
||||||
|
|
||||||
# Limit to a specific provider with glob pattern
|
# Limit to a specific provider with glob pattern
|
||||||
${APP_NAME} --models "github-copilot/*"
|
${APP_NAME} --models "openrouter/*"
|
||||||
|
|
||||||
# Cycle models with fixed thinking levels
|
# Cycle models with fixed thinking levels
|
||||||
${APP_NAME} --models sonnet:high,haiku:low
|
${APP_NAME} --models sonnet:high,haiku:low
|
||||||
|
|
@ -344,10 +344,8 @@ ${chalk.bold("Environment Variables:")}
|
||||||
AZURE_OPENAI_RESOURCE_NAME - Azure OpenAI resource name (alternative to base URL)
|
AZURE_OPENAI_RESOURCE_NAME - Azure OpenAI resource name (alternative to base URL)
|
||||||
AZURE_OPENAI_API_VERSION - Azure OpenAI API version (default: v1)
|
AZURE_OPENAI_API_VERSION - Azure OpenAI API version (default: v1)
|
||||||
AZURE_OPENAI_DEPLOYMENT_NAME_MAP - Azure OpenAI model=deployment map (comma-separated)
|
AZURE_OPENAI_DEPLOYMENT_NAME_MAP - Azure OpenAI model=deployment map (comma-separated)
|
||||||
GEMINI_API_KEY - Google Gemini API key
|
|
||||||
GROQ_API_KEY - Groq API key
|
GROQ_API_KEY - Groq API key
|
||||||
CEREBRAS_API_KEY - Cerebras API key
|
CEREBRAS_API_KEY - Cerebras API key
|
||||||
XAI_API_KEY - xAI Grok API key
|
|
||||||
OPENROUTER_API_KEY - OpenRouter API key
|
OPENROUTER_API_KEY - OpenRouter API key
|
||||||
AI_GATEWAY_API_KEY - Vercel AI Gateway API key
|
AI_GATEWAY_API_KEY - Vercel AI Gateway API key
|
||||||
ZAI_API_KEY - ZAI API key
|
ZAI_API_KEY - ZAI API key
|
||||||
|
|
|
||||||
|
|
@ -79,8 +79,8 @@ function validateNotGoogleOAuthToken(provider: string, key: string): void {
|
||||||
`\n\nIf you're using Google's Gemini CLI, its OAuth tokens are not compatible. ` +
|
`\n\nIf you're using Google's Gemini CLI, its OAuth tokens are not compatible. ` +
|
||||||
`Either:\n` +
|
`Either:\n` +
|
||||||
` 1. Get an API key from https://aistudio.google.com/apikey and set GEMINI_API_KEY\n` +
|
` 1. Get an API key from https://aistudio.google.com/apikey and set GEMINI_API_KEY\n` +
|
||||||
` 2. Authenticate with the real \`gemini\` CLI; the google-gemini-cli ` +
|
` 2. Use the google-gemini-cli provider, which delegates OAuth handling ` +
|
||||||
`provider reads ~/.gemini/oauth_creds.json automatically`,
|
`to @google/gemini-cli-core`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -956,7 +956,7 @@ export class AuthStorage {
|
||||||
new Error(
|
new Error(
|
||||||
`Blocked Google OAuth access token (ya29.*) for provider "${providerId}". ` +
|
`Blocked Google OAuth access token (ya29.*) for provider "${providerId}". ` +
|
||||||
`Use an API key from https://aistudio.google.com/apikey, or authenticate with ` +
|
`Use an API key from https://aistudio.google.com/apikey, or authenticate with ` +
|
||||||
`the real \`gemini\` CLI to use the google-gemini-cli provider.`,
|
`the google-gemini-cli provider, which delegates OAuth handling to @google/gemini-cli-core.`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
@ -996,7 +996,7 @@ export class AuthStorage {
|
||||||
new Error(
|
new Error(
|
||||||
`GEMINI_API_KEY contains a Google OAuth access token (ya29.*), not an API key. ` +
|
`GEMINI_API_KEY contains a Google OAuth access token (ya29.*), not an API key. ` +
|
||||||
`Get an API key from https://aistudio.google.com/apikey, or authenticate with ` +
|
`Get an API key from https://aistudio.google.com/apikey, or authenticate with ` +
|
||||||
`the real \`gemini\` CLI to use the google-gemini-cli provider.`,
|
`the google-gemini-cli provider, which delegates OAuth handling to @google/gemini-cli-core.`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
|
||||||
|
|
@ -347,22 +347,14 @@ describe("ModelRegistry.getModelsForProxy — family priority ordering", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Gemini family: google-gemini-cli before google before google-vertex", () => {
|
it("Gemini family: google-gemini-cli only for bare model routing", () => {
|
||||||
const registry = createRegistry();
|
const registry = createRegistry();
|
||||||
registerNone(registry, "google-vertex", "gemini-2.5-pro");
|
registerNone(registry, "google-vertex", "gemini-2.5-pro");
|
||||||
registerNone(registry, "google", "gemini-2.5-pro");
|
registerNone(registry, "google", "gemini-2.5-pro");
|
||||||
registerNone(registry, "google-gemini-cli", "gemini-2.5-pro");
|
registerNone(registry, "google-gemini-cli", "gemini-2.5-pro");
|
||||||
const result = registry.getModelsForProxy("gemini-2.5-pro");
|
const result = registry.getModelsForProxy("gemini-2.5-pro");
|
||||||
const providers = result.map((m) => m.provider);
|
const providers = result.map((m) => m.provider);
|
||||||
assert.equal(
|
assert.deepEqual(providers, ["google-gemini-cli"]);
|
||||||
providers[0],
|
|
||||||
"google-gemini-cli",
|
|
||||||
"free CLI first for Gemini",
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
providers.indexOf("google") < providers.indexOf("google-vertex"),
|
|
||||||
"paid API before vertex",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("provider not in any family rule falls back to end of list", () => {
|
it("provider not in any family rule falls back to end of list", () => {
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,8 @@ export const PROXY_FAMILY_PRIORITY: ReadonlyArray<{
|
||||||
* honest about which endpoints are "native" vs "via intermediary".
|
* honest about which endpoints are "native" vs "via intermediary".
|
||||||
*/
|
*/
|
||||||
family_failover?: string[];
|
family_failover?: string[];
|
||||||
|
/** Disable generic fallback for families that must stay on one provider. */
|
||||||
|
global_fallback?: boolean;
|
||||||
}> = [
|
}> = [
|
||||||
// MiniMax direct (api.minimax.io) → CN endpoint as its direct pair
|
// MiniMax direct (api.minimax.io) → CN endpoint as its direct pair
|
||||||
{
|
{
|
||||||
|
|
@ -86,36 +88,28 @@ export const PROXY_FAMILY_PRIORITY: ReadonlyArray<{
|
||||||
// or the Token Plan endpoint (token-plan-sgp.xiaomimimo.com). Both served
|
// or the Token Plan endpoint (token-plan-sgp.xiaomimimo.com). Both served
|
||||||
// under the `xiaomi` provider namespace.
|
// under the `xiaomi` provider namespace.
|
||||||
{ match: /^mimo-|^XiaomiMiMo\//i, prefix: "mimo-", providers: ["xiaomi"] },
|
{ match: /^mimo-|^XiaomiMiMo\//i, prefix: "mimo-", providers: ["xiaomi"] },
|
||||||
// Gemini/Gemma: google-gemini-cli (CLI OAuth via ~/.gemini), google
|
// Gemini/Gemma: route bare model IDs through google-gemini-cli only.
|
||||||
// (API key), google-vertex are all FIRST-PARTY Google endpoints.
|
// Direct GenAI and Vertex providers stay explicit provider-qualified routes,
|
||||||
// github-copilot re-serves and is failover only.
|
// but they are hidden from normal SF/TUI selection and default fallback.
|
||||||
{
|
{
|
||||||
match: /^gemini-|^gemma-/i,
|
match: /^gemini-|^gemma-/i,
|
||||||
prefix: "gemini-",
|
prefix: "gemini-",
|
||||||
providers: ["google-gemini-cli", "google", "google-vertex"],
|
providers: ["google-gemini-cli"],
|
||||||
family_failover: ["github-copilot"],
|
global_fallback: false,
|
||||||
},
|
},
|
||||||
// Claude: Anthropic is the ONLY direct provider. github-copilot re-serves
|
// Claude: Anthropic is the default provider. Copilot is disabled.
|
||||||
// Claude via GitHub's platform as failover.
|
|
||||||
{
|
{
|
||||||
match: /^claude-/i,
|
match: /^claude-/i,
|
||||||
prefix: "claude-",
|
prefix: "claude-",
|
||||||
providers: ["anthropic"],
|
providers: ["anthropic"],
|
||||||
family_failover: ["github-copilot"],
|
|
||||||
},
|
},
|
||||||
// GPT / o-series / codex: OpenAI is direct. azure-openai-responses is
|
// GPT / o-series / codex: OpenAI is direct. azure-openai-responses is
|
||||||
// Microsoft's re-serving of OpenAI weights — treated as failover (it is
|
// Microsoft's re-serving of OpenAI weights — treated as failover. Copilot is disabled.
|
||||||
// the same weights via a different legal/contractual relationship).
|
|
||||||
// github-copilot likewise re-serves.
|
|
||||||
{
|
{
|
||||||
match: /^gpt-|^o\d|^codex-/i,
|
match: /^gpt-|^o\d|^codex-/i,
|
||||||
prefix: "gpt-",
|
prefix: "gpt-",
|
||||||
providers: ["openai"],
|
providers: ["openai"],
|
||||||
family_failover: [
|
family_failover: ["azure-openai-responses", "openai-codex"],
|
||||||
"azure-openai-responses",
|
|
||||||
"openai-codex",
|
|
||||||
"github-copilot",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -251,6 +245,10 @@ const OPENCODE_FREE_MODEL_IDS = new Set([
|
||||||
|
|
||||||
const HIDDEN_MODEL_PROVIDERS = new Set([
|
const HIDDEN_MODEL_PROVIDERS = new Set([
|
||||||
"claude-code",
|
"claude-code",
|
||||||
|
"google",
|
||||||
|
"google-vertex",
|
||||||
|
"github-copilot",
|
||||||
|
"xai",
|
||||||
"xiaomi-token-plan-ams",
|
"xiaomi-token-plan-ams",
|
||||||
"xiaomi-token-plan-cn",
|
"xiaomi-token-plan-cn",
|
||||||
"xiaomi-token-plan-sgp",
|
"xiaomi-token-plan-sgp",
|
||||||
|
|
@ -1159,7 +1157,10 @@ export class ModelRegistry {
|
||||||
const familyProviders = overrideEntry?.[1] ?? familyEntry?.providers ?? [];
|
const familyProviders = overrideEntry?.[1] ?? familyEntry?.providers ?? [];
|
||||||
const familyFailover = familyEntry?.family_failover ?? [];
|
const familyFailover = familyEntry?.family_failover ?? [];
|
||||||
const seen = new Set([...familyProviders, ...familyFailover]);
|
const seen = new Set([...familyProviders, ...familyFailover]);
|
||||||
const globalFallback = GLOBAL_PROVIDER_FALLBACK.filter((p) => !seen.has(p));
|
const globalFallback =
|
||||||
|
familyEntry?.global_fallback === false
|
||||||
|
? []
|
||||||
|
: GLOBAL_PROVIDER_FALLBACK.filter((p) => !seen.has(p));
|
||||||
return [...familyProviders, ...familyFailover, ...globalFallback];
|
return [...familyProviders, ...familyFailover, ...globalFallback];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,25 +29,20 @@ const PROXY_FAMILY_PRIORITY: Array<{ match: RegExp; providers: string[] }> = [
|
||||||
{ match: /^glm-/i, providers: ["zai", "opencode", "opencode-go"] },
|
{ match: /^glm-/i, providers: ["zai", "opencode", "opencode-go"] },
|
||||||
// Kimi: kimi-coding direct > opencode aggregators
|
// Kimi: kimi-coding direct > opencode aggregators
|
||||||
{ match: /^kimi-/i, providers: ["kimi-coding", "opencode", "opencode-go"] },
|
{ match: /^kimi-/i, providers: ["kimi-coding", "opencode", "opencode-go"] },
|
||||||
// Gemini/Gemma: google direct > vertex (enterprise) > CLI (OAuth) > copilot
|
// Gemini/Gemma: proxy bare model IDs through cli-core only.
|
||||||
{
|
{
|
||||||
match: /^gemini-|^gemma-/i,
|
match: /^gemini-|^gemma-/i,
|
||||||
providers: [
|
providers: ["google-gemini-cli"],
|
||||||
"google",
|
|
||||||
"google-vertex",
|
|
||||||
"google-gemini-cli",
|
|
||||||
"github-copilot",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
// Claude: anthropic direct > opencode > copilot
|
// Claude: anthropic direct > opencode. Copilot is disabled.
|
||||||
{
|
{
|
||||||
match: /^claude-/i,
|
match: /^claude-/i,
|
||||||
providers: ["anthropic", "opencode", "github-copilot"],
|
providers: ["anthropic", "opencode"],
|
||||||
},
|
},
|
||||||
// GPT/OpenAI: openai direct > azure > copilot
|
// GPT/OpenAI: openai direct > azure. Copilot is disabled.
|
||||||
{
|
{
|
||||||
match: /^gpt-|^o[0-9]|^codex-/i,
|
match: /^gpt-|^o[0-9]|^codex-/i,
|
||||||
providers: ["openai", "azure-openai-responses", "github-copilot"],
|
providers: ["openai", "azure-openai-responses"],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -83,13 +78,7 @@ export class ProxyServer {
|
||||||
|
|
||||||
// 1. Model Listing
|
// 1. Model Listing
|
||||||
app.get(["/v1/models", "/v1beta/models"], async (req, res) => {
|
app.get(["/v1/models", "/v1beta/models"], async (req, res) => {
|
||||||
const providers = [
|
const providers = ["google-gemini-cli", "anthropic", "openai"];
|
||||||
"google",
|
|
||||||
"google-gemini-cli",
|
|
||||||
"google-vertex",
|
|
||||||
"anthropic",
|
|
||||||
"openai",
|
|
||||||
];
|
|
||||||
const allModels = providers.flatMap((p) => getModels(p as any));
|
const allModels = providers.flatMap((p) => getModels(p as any));
|
||||||
|
|
||||||
const formatted = allModels.map((m) => ({
|
const formatted = allModels.map((m) => ({
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@singularity-forge/pi-tui",
|
"name": "@singularity-forge/pi-tui",
|
||||||
"version": "2.75.0",
|
"version": "2.75.3",
|
||||||
"description": "Terminal User Interface library (vendored from pi-mono)",
|
"description": "Terminal User Interface library (vendored from pi-mono)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@singularity-forge/rpc-client",
|
"name": "@singularity-forge/rpc-client",
|
||||||
"version": "2.75.0",
|
"version": "2.75.3",
|
||||||
"description": "Standalone RPC client SDK for sf-run — zero internal dependencies",
|
"description": "Standalone RPC client SDK for sf-run — zero internal dependencies",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "sf",
|
"name": "sf",
|
||||||
"version": "2.75.0",
|
"version": "2.75.3",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.15.0"
|
"node": ">=24.15.0"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -333,7 +333,7 @@ function extractCostFromNdjson(mid) {
|
||||||
|
|
||||||
// Auto-detect the SF loader path — works across npm global, homebrew, and local installs
|
// Auto-detect the SF loader path — works across npm global, homebrew, and local installs
|
||||||
function findSfLoader() {
|
function findSfLoader() {
|
||||||
// 1. Check if we're running from inside the sf-run repo itself
|
// 1. Check if we're running from inside the singularity-forge repo itself
|
||||||
const repoLoader = path.resolve(
|
const repoLoader = path.resolve(
|
||||||
import.meta.dirname,
|
import.meta.dirname,
|
||||||
"..",
|
"..",
|
||||||
|
|
@ -349,7 +349,7 @@ function findSfLoader() {
|
||||||
timeout: 3000,
|
timeout: 3000,
|
||||||
}).trim();
|
}).trim();
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.join(globalRoot, "sf-run", "dist", "loader.js"),
|
path.join(globalRoot, "singularity-forge", "dist", "loader.js"),
|
||||||
path.join(globalRoot, "@sf", "pi", "dist", "loader.js"),
|
path.join(globalRoot, "@sf", "pi", "dist", "loader.js"),
|
||||||
];
|
];
|
||||||
for (const c of candidates) {
|
for (const c of candidates) {
|
||||||
|
|
|
||||||
|
|
@ -180,7 +180,7 @@ try {
|
||||||
console.log(
|
console.log(
|
||||||
"==> Verifying @singularity-forge/* workspace package resolution...",
|
"==> Verifying @singularity-forge/* workspace package resolution...",
|
||||||
);
|
);
|
||||||
const installedRoot = join(installDir, "node_modules", "sf-run");
|
const installedRoot = join(installDir, "node_modules", "singularity-forge");
|
||||||
const criticalPackages = [
|
const criticalPackages = [
|
||||||
{ scope: "@singularity-forge", name: "pi-coding-agent" },
|
{ scope: "@singularity-forge", name: "pi-coding-agent" },
|
||||||
{ scope: "@singularity-forge", name: "rpc-client" },
|
{ scope: "@singularity-forge", name: "rpc-client" },
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ function exitIfManagedResourcesAreNewer(currentAgentDir: string): void {
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
`[sf] ${chalk.yellow("Version mismatch detected")}\n` +
|
`[sf] ${chalk.yellow("Version mismatch detected")}\n` +
|
||||||
`[sf] Synced resources are from ${chalk.bold(`v${managedVersion}`)}, but this \`sf\` binary is ${chalk.dim(`v${currentVersion}`)}.\n` +
|
`[sf] Synced resources are from ${chalk.bold(`v${managedVersion}`)}, but this \`sf\` binary is ${chalk.dim(`v${currentVersion}`)}.\n` +
|
||||||
`[sf] Run ${chalk.bold("npm install -g sf-run@latest")} or ${chalk.bold("sf update")}, then try again.\n`,
|
`[sf] Run ${chalk.bold("npm install -g singularity-forge@latest")} or ${chalk.bold("sf update")}, then try again.\n`,
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1570,7 +1570,7 @@ async function runHeadlessOnce(
|
||||||
error("Failed to start RPC session", {
|
error("Failed to start RPC session", {
|
||||||
operation: "RpcClient.start",
|
operation: "RpcClient.start",
|
||||||
file: cliPath,
|
file: cliPath,
|
||||||
guidance: "Verify SF_BIN_PATH is set or reinstall sf-run",
|
guidance: "Verify SF_BIN_PATH is set or reinstall singularity-forge",
|
||||||
cause: err,
|
cause: err,
|
||||||
}),
|
}),
|
||||||
"[headless]",
|
"[headless]",
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ const SUBCOMMAND_HELP: Record<string, string> = {
|
||||||
"",
|
"",
|
||||||
"Update SF to the latest version.",
|
"Update SF to the latest version.",
|
||||||
"",
|
"",
|
||||||
"Equivalent to: npm install -g sf-run@latest",
|
"Equivalent to: npm install -g singularity-forge@latest",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
|
|
||||||
sessions: [
|
sessions: [
|
||||||
|
|
|
||||||
|
|
@ -360,13 +360,13 @@ if (missingPackages.length > 0) {
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
`\nError: SF installation is broken — missing packages: ${missing}\n\n` +
|
`\nError: SF installation is broken — missing packages: ${missing}\n\n` +
|
||||||
`This is usually caused by one of:\n` +
|
`This is usually caused by one of:\n` +
|
||||||
` • An outdated version installed from npm (run: npm install -g singularity-foundry@latest)\n` +
|
` • An outdated version installed from npm (run: npm install -g singularity-forge@latest)\n` +
|
||||||
` • The packages/ directory was excluded from the installed tarball\n` +
|
` • The packages/ directory was excluded from the installed tarball\n` +
|
||||||
` • A filesystem error prevented linking or copying the workspace packages\n\n` +
|
` • A filesystem error prevented linking or copying the workspace packages\n\n` +
|
||||||
`Fix it by reinstalling:\n\n` +
|
`Fix it by reinstalling:\n\n` +
|
||||||
` npm install -g singularity-foundry@latest\n\n` +
|
` npm install -g singularity-forge@latest\n\n` +
|
||||||
`If the issue persists, please open an issue at:\n` +
|
`If the issue persists, please open an issue at:\n` +
|
||||||
` https://github.com/singularity-ng/singularity-foundry/issues\n`,
|
` https://github.com/singularity-ng/singularity-forge/issues\n`,
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,11 +67,9 @@ const LLM_PROVIDER_IDS = [
|
||||||
"anthropic-vertex",
|
"anthropic-vertex",
|
||||||
"claude-code",
|
"claude-code",
|
||||||
"openai",
|
"openai",
|
||||||
"github-copilot",
|
|
||||||
"openai-codex",
|
"openai-codex",
|
||||||
"google-gemini-cli",
|
"google-gemini-cli",
|
||||||
"groq",
|
"groq",
|
||||||
"xai",
|
|
||||||
"openrouter",
|
"openrouter",
|
||||||
"mistral",
|
"mistral",
|
||||||
"xiaomi",
|
"xiaomi",
|
||||||
|
|
@ -88,7 +86,6 @@ const API_KEY_PREFIXES: Record<string, string[]> = {
|
||||||
|
|
||||||
const OTHER_PROVIDERS = [
|
const OTHER_PROVIDERS = [
|
||||||
{ value: "groq", label: "Groq", hint: "console.groq.com/keys" },
|
{ value: "groq", label: "Groq", hint: "console.groq.com/keys" },
|
||||||
{ value: "xai", label: "xAI (Grok)", hint: "console.x.ai" },
|
|
||||||
{
|
{
|
||||||
value: "openrouter",
|
value: "openrouter",
|
||||||
label: "OpenRouter",
|
label: "OpenRouter",
|
||||||
|
|
@ -382,7 +379,7 @@ async function runLlmStep(
|
||||||
{
|
{
|
||||||
value: "browser",
|
value: "browser",
|
||||||
label: "Sign in with your browser",
|
label: "Sign in with your browser",
|
||||||
hint: "GitHub Copilot, ChatGPT, Google, etc.",
|
hint: "ChatGPT, Google Code Assist, ZAI, etc.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "api-key",
|
value: "api-key",
|
||||||
|
|
@ -440,9 +437,8 @@ async function runLlmStep(
|
||||||
const provider = await p.select({
|
const provider = await p.select({
|
||||||
message: "Choose provider",
|
message: "Choose provider",
|
||||||
options: [
|
options: [
|
||||||
{ value: "github-copilot", label: "GitHub Copilot" },
|
|
||||||
{ value: "openai-codex", label: "ChatGPT Plus/Pro (Codex)" },
|
{ value: "openai-codex", label: "ChatGPT Plus/Pro (Codex)" },
|
||||||
{ value: "google-gemini-cli", label: "Google Gemini CLI" },
|
{ value: "google-gemini-cli", label: "Google Code Assist" },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
if (p.isCancel(provider)) return false;
|
if (p.isCancel(provider)) return false;
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,9 @@ const PI_SETTINGS_PATH = join(homedir(), ".pi", "agent", "settings.json");
|
||||||
const LLM_PROVIDER_IDS = [
|
const LLM_PROVIDER_IDS = [
|
||||||
"anthropic",
|
"anthropic",
|
||||||
"openai",
|
"openai",
|
||||||
"github-copilot",
|
|
||||||
"openai-codex",
|
"openai-codex",
|
||||||
"google-gemini-cli",
|
"google-gemini-cli",
|
||||||
"groq",
|
"groq",
|
||||||
"xai",
|
|
||||||
"openrouter",
|
"openrouter",
|
||||||
"mistral",
|
"mistral",
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"id": "genai-proxy",
|
"id": "genai-proxy",
|
||||||
"name": "GenAI Proxy",
|
"name": "GenAI Proxy",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "OpenAI-compatible proxy for Gemini CLI and GenAI clients",
|
"description": "OpenAI-compatible proxy for GenAI clients",
|
||||||
"tier": "bundled",
|
"tier": "bundled",
|
||||||
"requires": {
|
"requires": {
|
||||||
"platform": ">=2.29.0"
|
"platform": ">=2.29.0"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { createProxyServer } from "./proxy-server.js";
|
import { createProxyServer } from "./proxy-server.js";
|
||||||
|
|
||||||
const PROXY_COMMAND_NAME = "genai-proxy";
|
const PROXY_COMMAND_NAME = "genai-proxy";
|
||||||
const PROXY_FLAG_NAME = "gemini-cli-proxy";
|
const PROXY_FLAG_NAME = "genai-proxy";
|
||||||
|
const LEGACY_PROXY_FLAG_NAME = "gemini-cli-proxy";
|
||||||
const DEFAULT_PROXY_PORT = 3000;
|
const DEFAULT_PROXY_PORT = 3000;
|
||||||
export function installGenaiProxyExtension(api, dependencies) {
|
export function installGenaiProxyExtension(api, dependencies) {
|
||||||
let proxyServer = null;
|
let proxyServer = null;
|
||||||
|
|
@ -20,17 +21,24 @@ export function installGenaiProxyExtension(api, dependencies) {
|
||||||
});
|
});
|
||||||
return proxyServer;
|
return proxyServer;
|
||||||
};
|
};
|
||||||
api.registerFlag(PROXY_FLAG_NAME, {
|
const startProxyFromFlag = async (value, context) => {
|
||||||
description: "Start the Gemini CLI proxy server",
|
|
||||||
type: "string",
|
|
||||||
allowNoValue: true,
|
|
||||||
onStartup: async (value, context) => {
|
|
||||||
const server = ensureProxyServer(context, resolveProxyPort(value));
|
const server = ensureProxyServer(context, resolveProxyPort(value));
|
||||||
await server.start();
|
await server.start();
|
||||||
},
|
};
|
||||||
|
api.registerFlag(PROXY_FLAG_NAME, {
|
||||||
|
description: "Start the GenAI proxy server",
|
||||||
|
type: "string",
|
||||||
|
allowNoValue: true,
|
||||||
|
onStartup: startProxyFromFlag,
|
||||||
|
});
|
||||||
|
api.registerFlag(LEGACY_PROXY_FLAG_NAME, {
|
||||||
|
description: "Legacy alias for --genai-proxy",
|
||||||
|
type: "string",
|
||||||
|
allowNoValue: true,
|
||||||
|
onStartup: startProxyFromFlag,
|
||||||
});
|
});
|
||||||
api.registerCommand(PROXY_COMMAND_NAME, {
|
api.registerCommand(PROXY_COMMAND_NAME, {
|
||||||
description: "Manage the Gemini CLI proxy server",
|
description: "Manage the GenAI proxy server",
|
||||||
handler: async (args, context) => {
|
handler: async (args, context) => {
|
||||||
await handleProxyCommand(
|
await handleProxyCommand(
|
||||||
args ?? "",
|
args ?? "",
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@
|
||||||
* Google Search Extension
|
* Google Search Extension
|
||||||
*
|
*
|
||||||
* Provides a `google_search` tool that performs web searches via Gemini's
|
* Provides a `google_search` tool that performs web searches via Gemini's
|
||||||
* Google Search grounding feature. Uses the user's existing GEMINI_API_KEY or
|
* Google Search grounding feature through @google/gemini-cli-core.
|
||||||
* GOOGLE_GENERATIVE_AI_API_KEY and Google Cloud GenAI credits.
|
|
||||||
*
|
*
|
||||||
* The tool sends queries to Gemini Flash with `googleSearch: {}` enabled.
|
* The tool sends queries to Gemini Flash with `googleSearch: {}` enabled.
|
||||||
* Gemini internally performs Google searches, synthesizes an answer, and
|
* Gemini internally performs Google searches, synthesizes an answer, and
|
||||||
|
|
@ -23,85 +22,54 @@ import {
|
||||||
resolveSearchProvider,
|
resolveSearchProvider,
|
||||||
} from "../search-the-web/provider.js";
|
} from "../search-the-web/provider.js";
|
||||||
|
|
||||||
let client = null;
|
/**
|
||||||
function getGeminiApiKey() {
|
* Build a Code Assist server through @google/gemini-cli-core.
|
||||||
return process.env.GEMINI_API_KEY || process.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
*
|
||||||
}
|
* The OAuth fallback uses cli-core directly instead of carrying SF's own
|
||||||
async function getClient() {
|
* Cloud Code Assist wire client.
|
||||||
if (!client) {
|
*/
|
||||||
const { GoogleGenAI } = await import("@google/genai");
|
async function buildCodeAssistServer(accessToken, projectId) {
|
||||||
client = new GoogleGenAI({ apiKey: getGeminiApiKey() });
|
const [{ CodeAssistServer }, { OAuth2Client }] = await Promise.all([
|
||||||
}
|
import("@google/gemini-cli-core"),
|
||||||
return client;
|
import("google-auth-library"),
|
||||||
|
]);
|
||||||
|
const authClient = new OAuth2Client();
|
||||||
|
authClient.setCredentials({ access_token: accessToken });
|
||||||
|
return new CodeAssistServer(authClient, projectId, { headers: {} });
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Perform a search using OAuth credentials via the Cloud Code Assist API.
|
* Perform a search using OAuth credentials via @google/gemini-cli-core.
|
||||||
* This is used as a fallback when a Gemini API key env var is not set.
|
|
||||||
*/
|
*/
|
||||||
async function searchWithOAuth(query, accessToken, projectId, signal) {
|
async function searchWithOAuth(query, accessToken, projectId, signal) {
|
||||||
const model = process.env.GEMINI_SEARCH_MODEL || "gemini-2.5-flash";
|
const model = process.env.GEMINI_SEARCH_MODEL || "gemini-2.5-flash";
|
||||||
const url = `https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse`;
|
const server = await buildCodeAssistServer(accessToken, projectId);
|
||||||
const GEMINI_CLI_HEADERS = {
|
const promptId = `sf-google-search-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||||
ideType: "IDE_UNSPECIFIED",
|
const chunks = server.generateContentStream(
|
||||||
platform: "PLATFORM_UNSPECIFIED",
|
{
|
||||||
pluginType: "GEMINI",
|
|
||||||
};
|
|
||||||
const executeFetch = async (retries = 3) => {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"User-Agent": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
|
||||||
"X-Goog-Api-Client": "gl-node/22.17.0",
|
|
||||||
"Client-Metadata": JSON.stringify(GEMINI_CLI_HEADERS),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
project: projectId,
|
|
||||||
model,
|
model,
|
||||||
request: {
|
contents: [{ role: "user", parts: [{ text: query }] }],
|
||||||
contents: [{ parts: [{ text: query }] }],
|
config: {
|
||||||
tools: [{ googleSearch: {} }],
|
tools: [{ googleSearch: {} }],
|
||||||
},
|
},
|
||||||
userAgent: "pi-coding-agent",
|
},
|
||||||
}),
|
promptId,
|
||||||
signal,
|
"USER",
|
||||||
});
|
|
||||||
if (
|
|
||||||
!response.ok &&
|
|
||||||
retries > 0 &&
|
|
||||||
(response.status === 429 || response.status >= 500)
|
|
||||||
) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000 * (4 - retries)));
|
|
||||||
return executeFetch(retries - 1);
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
};
|
|
||||||
const response = await executeFetch();
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
throw new Error(
|
|
||||||
`Cloud Code Assist API error (${response.status}): ${errorText}`,
|
|
||||||
);
|
);
|
||||||
|
let answer = "";
|
||||||
|
let grounding;
|
||||||
|
for await (const chunk of chunks) {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new Error("Request was aborted");
|
||||||
|
}
|
||||||
|
const candidate = chunk?.candidates?.[0];
|
||||||
|
const text = candidate?.content?.parts?.find((p) => p.text)?.text;
|
||||||
|
if (text) {
|
||||||
|
answer += text;
|
||||||
|
}
|
||||||
|
if (candidate?.groundingMetadata) {
|
||||||
|
grounding = candidate.groundingMetadata;
|
||||||
}
|
}
|
||||||
// Note: streamGenerateContent returns SSE; for now, we consume all chunks.
|
|
||||||
// For simplicity and to match the previous structure, we'll read to end.
|
|
||||||
const text = await response.text();
|
|
||||||
const jsonLines = text
|
|
||||||
.split("\n")
|
|
||||||
.filter((l) => l.startsWith("data:"))
|
|
||||||
.map((l) => l.slice(5).trim())
|
|
||||||
.filter((l) => l.length > 0);
|
|
||||||
let data;
|
|
||||||
if (jsonLines.length > 0) {
|
|
||||||
// Aggregate chunks if needed, but for now we take the last chunk or assume it's one
|
|
||||||
data = JSON.parse(jsonLines[jsonLines.length - 1]);
|
|
||||||
} else {
|
|
||||||
data = JSON.parse(text);
|
|
||||||
}
|
}
|
||||||
const candidate = data.response?.candidates?.[0];
|
|
||||||
const answer = candidate?.content?.parts?.find((p) => p.text)?.text ?? "";
|
|
||||||
const grounding = candidate?.groundingMetadata;
|
|
||||||
const sources = [];
|
const sources = [];
|
||||||
const seenTitles = new Set();
|
const seenTitles = new Set();
|
||||||
if (grounding?.groundingChunks) {
|
if (grounding?.groundingChunks) {
|
||||||
|
|
@ -230,7 +198,7 @@ export default function (pi) {
|
||||||
"Returns an AI-synthesized answer grounded in Google Search results, plus source URLs. " +
|
"Returns an AI-synthesized answer grounded in Google Search results, plus source URLs. " +
|
||||||
"Use this when you need current information from the web: recent events, documentation, " +
|
"Use this when you need current information from the web: recent events, documentation, " +
|
||||||
"product details, technical references, news, etc. " +
|
"product details, technical references, news, etc. " +
|
||||||
"Requires GEMINI_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY, or Google login. Alternative to Brave-based search tools.",
|
"Requires Google Code Assist OAuth via the google-gemini-cli provider. Alternative to Brave-based search tools.",
|
||||||
promptSnippet:
|
promptSnippet:
|
||||||
"Search the web via Google Search to get current information with sources",
|
"Search the web via Google Search to get current information with sources",
|
||||||
promptGuidelines: [
|
promptGuidelines: [
|
||||||
|
|
@ -257,11 +225,8 @@ export default function (pi) {
|
||||||
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const maxSources = Math.min(Math.max(params.maxSources ?? 5, 1), 10);
|
const maxSources = Math.min(Math.max(params.maxSources ?? 5, 1), 10);
|
||||||
// Check for credentials
|
|
||||||
let oauthToken;
|
let oauthToken;
|
||||||
let projectId;
|
let projectId;
|
||||||
const geminiApiKey = getGeminiApiKey();
|
|
||||||
if (!geminiApiKey) {
|
|
||||||
const oauthRaw =
|
const oauthRaw =
|
||||||
await ctx.modelRegistry.getApiKeyForProvider("google-gemini-cli");
|
await ctx.modelRegistry.getApiKeyForProvider("google-gemini-cli");
|
||||||
if (oauthRaw) {
|
if (oauthRaw) {
|
||||||
|
|
@ -273,8 +238,7 @@ export default function (pi) {
|
||||||
// Fall through to error
|
// Fall through to error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
if (!oauthToken || !projectId) {
|
||||||
if (!geminiApiKey && (!oauthToken || !projectId)) {
|
|
||||||
// No Gemini credentials — try fallback through search-the-web providers
|
// No Gemini credentials — try fallback through search-the-web providers
|
||||||
try {
|
try {
|
||||||
const fallbackResult = await executeFallbackSearch(
|
const fallbackResult = await executeFallbackSearch(
|
||||||
|
|
@ -308,7 +272,7 @@ export default function (pi) {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: "Error: No authentication found for Google Search. Please set GEMINI_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY, or log in via Google.\n\nExample: export GEMINI_API_KEY=your_key or use /login google",
|
text: "Error: No Google Code Assist OAuth found for google_search. Configure the google-gemini-cli provider, or use a non-Google fallback search provider.",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
isError: true,
|
isError: true,
|
||||||
|
|
@ -340,64 +304,12 @@ export default function (pi) {
|
||||||
// Call Gemini with Google Search grounding
|
// Call Gemini with Google Search grounding
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
if (geminiApiKey) {
|
|
||||||
const ai = await getClient();
|
|
||||||
// Add a 30-second timeout to prevent hanging (#1100)
|
|
||||||
const timeoutController = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => timeoutController.abort(), 30_000);
|
|
||||||
const combinedSignal = signal
|
|
||||||
? AbortSignal.any([signal, timeoutController.signal])
|
|
||||||
: timeoutController.signal;
|
|
||||||
let response;
|
|
||||||
try {
|
|
||||||
response = await ai.models.generateContent({
|
|
||||||
model: process.env.GEMINI_SEARCH_MODEL || "gemini-2.5-flash",
|
|
||||||
contents: params.query,
|
|
||||||
config: {
|
|
||||||
tools: [{ googleSearch: {} }],
|
|
||||||
abortSignal: combinedSignal,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
// Extract answer text
|
|
||||||
const answer = response.text ?? "";
|
|
||||||
// Extract grounding metadata
|
|
||||||
const candidate = response.candidates?.[0];
|
|
||||||
const grounding = candidate?.groundingMetadata;
|
|
||||||
// Parse sources from grounding chunks
|
|
||||||
const sources = [];
|
|
||||||
const seenTitles = new Set();
|
|
||||||
if (grounding?.groundingChunks) {
|
|
||||||
for (const chunk of grounding.groundingChunks) {
|
|
||||||
if (chunk.web) {
|
|
||||||
const title = chunk.web.title ?? "Untitled";
|
|
||||||
// Dedupe by title since URIs are redirect URLs that differ per call
|
|
||||||
if (seenTitles.has(title)) continue;
|
|
||||||
seenTitles.add(title);
|
|
||||||
// domain field is not available via Gemini API, use title as fallback
|
|
||||||
// (title is typically the domain name, e.g. "wikipedia.org")
|
|
||||||
const domain = chunk.web.domain ?? title;
|
|
||||||
sources.push({
|
|
||||||
title,
|
|
||||||
uri: chunk.web.uri ?? "",
|
|
||||||
domain,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Extract search queries Gemini actually performed
|
|
||||||
const searchQueries = grounding?.webSearchQueries ?? [];
|
|
||||||
result = { answer, sources, searchQueries, cached: false };
|
|
||||||
} else {
|
|
||||||
result = await searchWithOAuth(
|
result = await searchWithOAuth(
|
||||||
params.query,
|
params.query,
|
||||||
oauthToken,
|
oauthToken,
|
||||||
projectId,
|
projectId,
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
let errorType = "api_error";
|
let errorType = "api_error";
|
||||||
|
|
@ -486,16 +398,14 @@ export default function (pi) {
|
||||||
// ── Session cleanup ─────────────────────────────────────────────────────
|
// ── Session cleanup ─────────────────────────────────────────────────────
|
||||||
pi.on("session_shutdown", async () => {
|
pi.on("session_shutdown", async () => {
|
||||||
resultCache.clear();
|
resultCache.clear();
|
||||||
client = null;
|
|
||||||
});
|
});
|
||||||
// ── Startup notification ─────────────────────────────────────────────────
|
// ── Startup notification ─────────────────────────────────────────────────
|
||||||
pi.on("session_start", async (_event, ctx) => {
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
if (getGeminiApiKey()) return;
|
|
||||||
const hasOAuth =
|
const hasOAuth =
|
||||||
await ctx.modelRegistry.authStorage.hasAuth("google-gemini-cli");
|
await ctx.modelRegistry.authStorage.hasAuth("google-gemini-cli");
|
||||||
if (!hasOAuth) {
|
if (!hasOAuth) {
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(
|
||||||
"Google Search: No authentication set. Log in via Google or set GEMINI_API_KEY / GOOGLE_GENERATIVE_AI_API_KEY to use google_search.",
|
"Google Search: No google-gemini-cli OAuth set. Configure Google Code Assist OAuth to use google_search.",
|
||||||
"warning",
|
"warning",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,6 @@ function loadAuthJson() {
|
||||||
const STATUS_URLS = {
|
const STATUS_URLS = {
|
||||||
anthropic: "https://status.anthropic.com/api/v2/status.json",
|
anthropic: "https://status.anthropic.com/api/v2/status.json",
|
||||||
codex: "https://status.openai.com/api/v2/status.json",
|
codex: "https://status.openai.com/api/v2/status.json",
|
||||||
copilot: "https://www.githubstatus.com/api/v2/status.json",
|
|
||||||
};
|
};
|
||||||
async function fetchProviderStatus(provider) {
|
async function fetchProviderStatus(provider) {
|
||||||
const url = STATUS_URLS[provider];
|
const url = STATUS_URLS[provider];
|
||||||
|
|
@ -200,112 +199,6 @@ async function fetchClaudeUsage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Copilot Usage
|
|
||||||
// ============================================================================
|
|
||||||
function loadCopilotRefreshToken() {
|
|
||||||
// The copilot_internal/user endpoint needs the GitHub OAuth token (ghu_*),
|
|
||||||
// NOT the Copilot session token (tid=*). The refresh token IS the GitHub OAuth token.
|
|
||||||
const data = loadAuthJson();
|
|
||||||
// Use refresh token (GitHub OAuth token ghu_*) for the usage API
|
|
||||||
if (data?.["github-copilot"]?.refresh) return data["github-copilot"].refresh;
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
async function fetchCopilotUsage(_modelRegistry) {
|
|
||||||
const token = loadCopilotRefreshToken();
|
|
||||||
if (!token) {
|
|
||||||
return {
|
|
||||||
provider: "copilot",
|
|
||||||
displayName: "Copilot",
|
|
||||||
windows: [],
|
|
||||||
error: "No token",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const headersBase = {
|
|
||||||
"Editor-Version": "vscode/1.96.2",
|
|
||||||
"User-Agent": "GitHubCopilotChat/0.26.7",
|
|
||||||
"X-Github-Api-Version": "2025-04-01",
|
|
||||||
Accept: "application/json",
|
|
||||||
};
|
|
||||||
const tryFetch = async (authHeader) => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
setTimeout(() => controller.abort(), 5000);
|
|
||||||
const res = await fetch("https://api.github.com/copilot_internal/user", {
|
|
||||||
headers: {
|
|
||||||
...headersBase,
|
|
||||||
Authorization: authHeader,
|
|
||||||
},
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
return res;
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
// Copilot access tokens (from /login github-copilot) expect Bearer. PATs accept "token".
|
|
||||||
// GitHub OAuth token (ghu_*) requires "token" prefix, not Bearer
|
|
||||||
const attempts = [`token ${token}`];
|
|
||||||
let lastStatus;
|
|
||||||
let res;
|
|
||||||
for (const auth of attempts) {
|
|
||||||
res = await tryFetch(auth);
|
|
||||||
lastStatus = res.status;
|
|
||||||
if (res.ok) break;
|
|
||||||
if (res.status === 401 || res.status === 403) continue; // try next scheme
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (!res || !res.ok) {
|
|
||||||
const status = lastStatus ?? 0;
|
|
||||||
return {
|
|
||||||
provider: "copilot",
|
|
||||||
displayName: "Copilot",
|
|
||||||
windows: [],
|
|
||||||
error: `HTTP ${status}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const data = await res.json();
|
|
||||||
const windows = [];
|
|
||||||
// Parse reset date for display
|
|
||||||
const resetDate = data.quota_reset_date_utc
|
|
||||||
? new Date(data.quota_reset_date_utc)
|
|
||||||
: undefined;
|
|
||||||
const resetDesc = resetDate ? formatReset(resetDate) : undefined;
|
|
||||||
// Premium interactions (e.g., Claude, o1 models) - has a cap
|
|
||||||
if (data.quota_snapshots?.premium_interactions) {
|
|
||||||
const pi = data.quota_snapshots.premium_interactions;
|
|
||||||
const remaining = pi.remaining ?? 0;
|
|
||||||
const entitlement = pi.entitlement ?? 0;
|
|
||||||
const usedPercent = Math.max(0, 100 - (pi.percent_remaining || 0));
|
|
||||||
windows.push({
|
|
||||||
label: `Premium`,
|
|
||||||
usedPercent,
|
|
||||||
resetDescription: resetDesc
|
|
||||||
? `${resetDesc} (${remaining}/${entitlement})`
|
|
||||||
: `${remaining}/${entitlement}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Chat quota - often unlimited, only show if limited
|
|
||||||
if (data.quota_snapshots?.chat && !data.quota_snapshots.chat.unlimited) {
|
|
||||||
const chat = data.quota_snapshots.chat;
|
|
||||||
windows.push({
|
|
||||||
label: "Chat",
|
|
||||||
usedPercent: Math.max(0, 100 - (chat.percent_remaining || 0)),
|
|
||||||
resetDescription: resetDesc,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
provider: "copilot",
|
|
||||||
displayName: "Copilot",
|
|
||||||
windows,
|
|
||||||
plan: data.copilot_plan,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
provider: "copilot",
|
|
||||||
displayName: "Copilot",
|
|
||||||
windows: [],
|
|
||||||
error: String(e),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// ============================================================================
|
|
||||||
// Gemini Usage
|
// Gemini Usage
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
async function fetchGeminiUsage(_modelRegistry) {
|
async function fetchGeminiUsage(_modelRegistry) {
|
||||||
|
|
@ -740,13 +633,11 @@ class UsageComponent {
|
||||||
// Fetch usage and status in parallel
|
// Fetch usage and status in parallel
|
||||||
const [
|
const [
|
||||||
claude,
|
claude,
|
||||||
copilot,
|
|
||||||
gemini,
|
gemini,
|
||||||
codex,
|
codex,
|
||||||
kiro,
|
kiro,
|
||||||
zai,
|
zai,
|
||||||
claudeStatus,
|
claudeStatus,
|
||||||
copilotStatus,
|
|
||||||
geminiStatus,
|
geminiStatus,
|
||||||
codexStatus,
|
codexStatus,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
|
|
@ -756,12 +647,6 @@ class UsageComponent {
|
||||||
windows: [],
|
windows: [],
|
||||||
error: "Timeout",
|
error: "Timeout",
|
||||||
}),
|
}),
|
||||||
timeout(fetchCopilotUsage(this.modelRegistry), 6000, {
|
|
||||||
provider: "copilot",
|
|
||||||
displayName: "Copilot",
|
|
||||||
windows: [],
|
|
||||||
error: "Timeout",
|
|
||||||
}),
|
|
||||||
timeout(fetchGeminiUsage(this.modelRegistry), 6000, {
|
timeout(fetchGeminiUsage(this.modelRegistry), 6000, {
|
||||||
provider: "gemini",
|
provider: "gemini",
|
||||||
displayName: "Gemini",
|
displayName: "Gemini",
|
||||||
|
|
@ -789,9 +674,6 @@ class UsageComponent {
|
||||||
timeout(fetchProviderStatus("anthropic"), 3000, {
|
timeout(fetchProviderStatus("anthropic"), 3000, {
|
||||||
indicator: "unknown",
|
indicator: "unknown",
|
||||||
}),
|
}),
|
||||||
timeout(fetchProviderStatus("copilot"), 3000, {
|
|
||||||
indicator: "unknown",
|
|
||||||
}),
|
|
||||||
timeout(fetchGeminiStatus(), 3000, { indicator: "unknown" }),
|
timeout(fetchGeminiStatus(), 3000, { indicator: "unknown" }),
|
||||||
timeout(fetchProviderStatus("codex"), 3000, {
|
timeout(fetchProviderStatus("codex"), 3000, {
|
||||||
indicator: "unknown",
|
indicator: "unknown",
|
||||||
|
|
@ -799,11 +681,10 @@ class UsageComponent {
|
||||||
]);
|
]);
|
||||||
// Attach status to usage
|
// Attach status to usage
|
||||||
claude.status = claudeStatus;
|
claude.status = claudeStatus;
|
||||||
copilot.status = copilotStatus;
|
|
||||||
gemini.status = geminiStatus;
|
gemini.status = geminiStatus;
|
||||||
codex.status = codexStatus;
|
codex.status = codexStatus;
|
||||||
// Filter out providers with no data and no error (not configured)
|
// Filter out providers with no data and no error (not configured)
|
||||||
const allUsages = [claude, copilot, gemini, codex, kiro, zai];
|
const allUsages = [claude, gemini, codex, kiro, zai];
|
||||||
this.usages = allUsages.filter(
|
this.usages = allUsages.filter(
|
||||||
(u) =>
|
(u) =>
|
||||||
u.windows.length > 0 ||
|
u.windows.length > 0 ||
|
||||||
|
|
|
||||||
|
|
@ -889,9 +889,9 @@ export function resolveModelId(modelId, availableModels, currentProvider) {
|
||||||
* Flat-rate providers charge the same per request regardless of model.
|
* Flat-rate providers charge the same per request regardless of model.
|
||||||
* Dynamic routing provides no cost benefit — it only degrades quality (#3453).
|
* Dynamic routing provides no cost benefit — it only degrades quality (#3453).
|
||||||
* Uses case-insensitive matching with alias support to prevent fail-open on
|
* Uses case-insensitive matching with alias support to prevent fail-open on
|
||||||
* provider naming variations (e.g. "copilot" vs "github-copilot").
|
* provider naming variations.
|
||||||
*/
|
*/
|
||||||
const BUILTIN_FLAT_RATE = new Set(["github-copilot", "copilot", "claude-code"]);
|
const BUILTIN_FLAT_RATE = new Set(["claude-code"]);
|
||||||
/**
|
/**
|
||||||
* Check if a provider has flat-rate pricing where model selection provides no cost benefit.
|
* Check if a provider has flat-rate pricing where model selection provides no cost benefit.
|
||||||
* Consults built-in list, auth mode, and user preference list.
|
* Consults built-in list, auth mode, and user preference list.
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,8 @@ import { sfRoot } from "./paths.js";
|
||||||
import { loadPrompt } from "./prompt-loader.js";
|
import { loadPrompt } from "./prompt-loader.js";
|
||||||
import { deriveState } from "./state.js";
|
import { deriveState } from "./state.js";
|
||||||
|
|
||||||
const UPDATE_REGISTRY_URL = "https://registry.npmjs.org/sf-run/latest";
|
const UPDATE_REGISTRY_URL =
|
||||||
|
"https://registry.npmjs.org/singularity-forge/latest";
|
||||||
const UPDATE_FETCH_TIMEOUT_MS = 5000;
|
const UPDATE_FETCH_TIMEOUT_MS = 5000;
|
||||||
function resolveInstallCommand(pkg) {
|
function resolveInstallCommand(pkg) {
|
||||||
if ("bun" in process.versions) return `bun add -g ${pkg}`;
|
if ("bun" in process.versions) return `bun add -g ${pkg}`;
|
||||||
|
|
@ -598,7 +599,7 @@ function compareSemverLocal(a, b) {
|
||||||
}
|
}
|
||||||
export async function handleUpdate(ctx, deps = {}) {
|
export async function handleUpdate(ctx, deps = {}) {
|
||||||
const { execSync } = await import("node:child_process");
|
const { execSync } = await import("node:child_process");
|
||||||
const NPM_PACKAGE = "sf-run";
|
const NPM_PACKAGE = "singularity-forge";
|
||||||
const current = deps.currentVersion ?? process.env.SF_VERSION ?? "0.0.0";
|
const current = deps.currentVersion ?? process.env.SF_VERSION ?? "0.0.0";
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(
|
||||||
`Current version: v${current}\nChecking npm registry...`,
|
`Current version: v${current}\nChecking npm registry...`,
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ function modelToProviderId(model) {
|
||||||
"google-vertex": "google-vertex",
|
"google-vertex": "google-vertex",
|
||||||
anthropic: "anthropic",
|
anthropic: "anthropic",
|
||||||
openai: "openai",
|
openai: "openai",
|
||||||
"github-copilot": "github-copilot",
|
|
||||||
};
|
};
|
||||||
if (prefixMap[prefix]) return prefixMap[prefix];
|
if (prefixMap[prefix]) return prefixMap[prefix];
|
||||||
}
|
}
|
||||||
|
|
@ -50,7 +49,6 @@ function modelToProviderId(model) {
|
||||||
return "openai";
|
return "openai";
|
||||||
if (lower.startsWith("gemini")) return "google";
|
if (lower.startsWith("gemini")) return "google";
|
||||||
if (lower.startsWith("llama") || lower.startsWith("mixtral")) return "groq";
|
if (lower.startsWith("llama") || lower.startsWith("mixtral")) return "groq";
|
||||||
if (lower.startsWith("grok")) return "xai";
|
|
||||||
if (lower.startsWith("mistral") || lower.startsWith("codestral"))
|
if (lower.startsWith("mistral") || lower.startsWith("codestral"))
|
||||||
return "mistral";
|
return "mistral";
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -144,11 +142,11 @@ function resolveKey(providerId) {
|
||||||
/**
|
/**
|
||||||
* Providers that can serve models normally associated with another provider.
|
* Providers that can serve models normally associated with another provider.
|
||||||
* Key = the provider whose models can be served, Value = alternative providers to check.
|
* Key = the provider whose models can be served, Value = alternative providers to check.
|
||||||
* e.g. GitHub Copilot subscriptions can access Claude and GPT models.
|
* Copilot is disabled from SF default routing.
|
||||||
*/
|
*/
|
||||||
const PROVIDER_ROUTES = {
|
const PROVIDER_ROUTES = {
|
||||||
anthropic: ["github-copilot"],
|
anthropic: [],
|
||||||
openai: ["github-copilot", "openai-codex"],
|
openai: ["openai-codex"],
|
||||||
google: ["google-gemini-cli"],
|
google: ["google-gemini-cli"],
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,6 @@ export const PROVIDER_REGISTRY = [
|
||||||
prefixes: ["sk-"],
|
prefixes: ["sk-"],
|
||||||
dashboardUrl: "platform.openai.com/api-keys",
|
dashboardUrl: "platform.openai.com/api-keys",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "github-copilot",
|
|
||||||
label: "GitHub Copilot",
|
|
||||||
category: "llm",
|
|
||||||
envVar: "GITHUB_TOKEN",
|
|
||||||
hasOAuth: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "openai-codex",
|
id: "openai-codex",
|
||||||
label: "ChatGPT Plus/Pro (Codex)",
|
label: "ChatGPT Plus/Pro (Codex)",
|
||||||
|
|
@ -42,18 +35,10 @@ export const PROVIDER_REGISTRY = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "google-gemini-cli",
|
id: "google-gemini-cli",
|
||||||
label: "Google Gemini CLI",
|
label: "Google Code Assist",
|
||||||
category: "llm",
|
category: "llm",
|
||||||
hasOAuth: true,
|
hasOAuth: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "google",
|
|
||||||
label: "Google (Gemini)",
|
|
||||||
category: "llm",
|
|
||||||
envVar: "GEMINI_API_KEY",
|
|
||||||
envAliases: ["GOOGLE_GENERATIVE_AI_API_KEY"],
|
|
||||||
dashboardUrl: "aistudio.google.com/apikey",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "groq",
|
id: "groq",
|
||||||
label: "Groq",
|
label: "Groq",
|
||||||
|
|
@ -61,13 +46,6 @@ export const PROVIDER_REGISTRY = [
|
||||||
envVar: "GROQ_API_KEY",
|
envVar: "GROQ_API_KEY",
|
||||||
dashboardUrl: "console.groq.com",
|
dashboardUrl: "console.groq.com",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "xai",
|
|
||||||
label: "xAI (Grok)",
|
|
||||||
category: "llm",
|
|
||||||
envVar: "XAI_API_KEY",
|
|
||||||
dashboardUrl: "console.x.ai",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "openrouter",
|
id: "openrouter",
|
||||||
label: "OpenRouter",
|
label: "OpenRouter",
|
||||||
|
|
@ -573,10 +551,6 @@ const TEST_ENDPOINTS = {
|
||||||
url: "", // Constructed dynamically with token in URL
|
url: "", // Constructed dynamically with token in URL
|
||||||
headers: () => ({}),
|
headers: () => ({}),
|
||||||
},
|
},
|
||||||
xai: {
|
|
||||||
url: "https://api.x.ai/v1/models",
|
|
||||||
headers: (key) => ({ Authorization: `Bearer ${key}` }),
|
|
||||||
},
|
|
||||||
mistral: {
|
mistral: {
|
||||||
url: "https://api.mistral.ai/v1/models",
|
url: "https://api.mistral.ai/v1/models",
|
||||||
headers: (key) => ({ Authorization: `Bearer ${key}` }),
|
headers: (key) => ({ Authorization: `Bearer ${key}` }),
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,10 @@ const OPENCODE_FREE_MODEL_IDS = new Set([
|
||||||
]);
|
]);
|
||||||
const HIDDEN_MODEL_PROVIDERS = new Set([
|
const HIDDEN_MODEL_PROVIDERS = new Set([
|
||||||
"claude-code",
|
"claude-code",
|
||||||
|
"google",
|
||||||
|
"google-vertex",
|
||||||
|
"github-copilot",
|
||||||
|
"xai",
|
||||||
"xiaomi-token-plan-ams",
|
"xiaomi-token-plan-ams",
|
||||||
"xiaomi-token-plan-cn",
|
"xiaomi-token-plan-cn",
|
||||||
"xiaomi-token-plan-sgp",
|
"xiaomi-token-plan-sgp",
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,9 @@ test("docker/Dockerfile.sandbox exists and uses Node 24 base", () => {
|
||||||
assert.match(content, /FROM node:24/);
|
assert.match(content, /FROM node:24/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("docker/Dockerfile.sandbox installs sf-run globally", () => {
|
test("docker/Dockerfile.sandbox installs singularity-forge globally", () => {
|
||||||
const content = readFile("docker/Dockerfile.sandbox");
|
const content = readFile("docker/Dockerfile.sandbox");
|
||||||
assert.match(content, /npm install -g sf-run/);
|
assert.match(content, /npm install -g singularity-forge/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("docker/Dockerfile.sandbox creates a non-root user", () => {
|
test("docker/Dockerfile.sandbox creates a non-root user", () => {
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,6 @@ function createMockPI() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a mock modelRegistry whose getApiKeyForProvider returns the given
|
|
||||||
* JSON string (matching what the real OAuth provider's getApiKey produces).
|
|
||||||
*/
|
|
||||||
function mockModelRegistry(oauthJson?: string) {
|
function mockModelRegistry(oauthJson?: string) {
|
||||||
return {
|
return {
|
||||||
authStorage: {
|
authStorage: {
|
||||||
|
|
@ -43,82 +39,26 @@ function restoreEnv(name: string, value: string | undefined) {
|
||||||
else process.env[name] = value;
|
else process.env[name] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
test("fix: google-search uses OAuth if GEMINI_API_KEY is missing", async (_t) => {
|
test("google_search_when_no_code_assist_oauth_warns_and_falls_back_or_errors", async () => {
|
||||||
const originalKey = process.env.GEMINI_API_KEY;
|
|
||||||
const originalAlias = process.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
|
||||||
delete process.env.GEMINI_API_KEY;
|
|
||||||
delete process.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
|
||||||
|
|
||||||
const originalFetch = global.fetch;
|
|
||||||
(global as any).fetch = async (url: string, options: any) => {
|
|
||||||
assert.ok(
|
|
||||||
url.includes("cloudcode-pa.googleapis.com"),
|
|
||||||
"Should use Cloud Code Assist endpoint",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
options.headers.Authorization,
|
|
||||||
"Bearer mock-token",
|
|
||||||
"Should use correct bearer token",
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({
|
|
||||||
response: {
|
|
||||||
candidates: [{ content: { parts: [{ text: "Mocked AI Answer" }] } }],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
text: async () =>
|
|
||||||
JSON.stringify({
|
|
||||||
response: {
|
|
||||||
candidates: [
|
|
||||||
{ content: { parts: [{ text: "Mocked AI Answer" }] } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
global.fetch = originalFetch;
|
|
||||||
restoreEnv("GEMINI_API_KEY", originalKey);
|
|
||||||
restoreEnv("GOOGLE_GENERATIVE_AI_API_KEY", originalAlias);
|
|
||||||
});
|
|
||||||
const pi = createMockPI();
|
|
||||||
googleSearchExtension(pi as any);
|
|
||||||
|
|
||||||
const oauthJson = JSON.stringify({
|
|
||||||
token: "mock-token",
|
|
||||||
projectId: "mock-project",
|
|
||||||
});
|
|
||||||
const mockCtx = {
|
|
||||||
ui: { notify() {} },
|
|
||||||
modelRegistry: mockModelRegistry(oauthJson),
|
|
||||||
};
|
|
||||||
|
|
||||||
await pi.fire("session_start", {}, mockCtx);
|
|
||||||
const registeredTool = (pi as any).registeredTool;
|
|
||||||
const result = await registeredTool.execute(
|
|
||||||
"call-1",
|
|
||||||
{ query: "test" },
|
|
||||||
new AbortController().signal,
|
|
||||||
() => {},
|
|
||||||
mockCtx,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(result.isError, undefined);
|
|
||||||
assert.ok(result.content[0].text.includes("Mocked AI Answer"));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("google-search warns if NO authentication is present", async (_t) => {
|
|
||||||
const originalKey = process.env.GEMINI_API_KEY;
|
const originalKey = process.env.GEMINI_API_KEY;
|
||||||
const originalAlias = process.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
const originalAlias = process.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
||||||
|
const originalTavily = process.env.TAVILY_API_KEY;
|
||||||
|
const originalBrave = process.env.BRAVE_API_KEY;
|
||||||
|
const originalMinimax = process.env.MINIMAX_API_KEY;
|
||||||
delete process.env.GEMINI_API_KEY;
|
delete process.env.GEMINI_API_KEY;
|
||||||
delete process.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
delete process.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
||||||
|
process.env.TAVILY_API_KEY = "";
|
||||||
|
delete process.env.BRAVE_API_KEY;
|
||||||
|
delete process.env.MINIMAX_API_KEY;
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
restoreEnv("GEMINI_API_KEY", originalKey);
|
restoreEnv("GEMINI_API_KEY", originalKey);
|
||||||
restoreEnv("GOOGLE_GENERATIVE_AI_API_KEY", originalAlias);
|
restoreEnv("GOOGLE_GENERATIVE_AI_API_KEY", originalAlias);
|
||||||
|
restoreEnv("TAVILY_API_KEY", originalTavily);
|
||||||
|
restoreEnv("BRAVE_API_KEY", originalBrave);
|
||||||
|
restoreEnv("MINIMAX_API_KEY", originalMinimax);
|
||||||
});
|
});
|
||||||
|
|
||||||
const pi = createMockPI();
|
const pi = createMockPI();
|
||||||
googleSearchExtension(pi as any);
|
googleSearchExtension(pi as any);
|
||||||
|
|
||||||
|
|
@ -134,21 +74,21 @@ test("google-search warns if NO authentication is present", async (_t) => {
|
||||||
|
|
||||||
await pi.fire("session_start", {}, mockCtx);
|
await pi.fire("session_start", {}, mockCtx);
|
||||||
assert.equal(notifications.length, 1);
|
assert.equal(notifications.length, 1);
|
||||||
assert.ok(notifications[0].msg.includes("No authentication set"));
|
assert.ok(notifications[0].msg.includes("No google-gemini-cli OAuth set"));
|
||||||
|
|
||||||
const registeredTool = (pi as any).registeredTool;
|
const registeredTool = (pi as any).registeredTool;
|
||||||
const result = await registeredTool.execute(
|
const result = await registeredTool.execute(
|
||||||
"call-2",
|
"call-1",
|
||||||
{ query: "test" },
|
{ query: "test" },
|
||||||
new AbortController().signal,
|
new AbortController().signal,
|
||||||
() => {},
|
() => {},
|
||||||
mockCtx,
|
mockCtx,
|
||||||
);
|
);
|
||||||
assert.equal(result.isError, true);
|
assert.equal(result.isError, true);
|
||||||
assert.ok(result.content[0].text.includes("No authentication found"));
|
assert.ok(result.content[0].text.includes("No Google Code Assist OAuth"));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("google-search uses GEMINI_API_KEY if present (precedence)", async (_t) => {
|
test("google_search_ignores_genai_api_key_env_and_requires_code_assist_oauth", async () => {
|
||||||
const originalKey = process.env.GEMINI_API_KEY;
|
const originalKey = process.env.GEMINI_API_KEY;
|
||||||
const originalAlias = process.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
const originalAlias = process.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
||||||
process.env.GEMINI_API_KEY = "mock-api-key";
|
process.env.GEMINI_API_KEY = "mock-api-key";
|
||||||
|
|
@ -158,42 +98,7 @@ test("google-search uses GEMINI_API_KEY if present (precedence)", async (_t) =>
|
||||||
restoreEnv("GEMINI_API_KEY", originalKey);
|
restoreEnv("GEMINI_API_KEY", originalKey);
|
||||||
restoreEnv("GOOGLE_GENERATIVE_AI_API_KEY", originalAlias);
|
restoreEnv("GOOGLE_GENERATIVE_AI_API_KEY", originalAlias);
|
||||||
});
|
});
|
||||||
const pi = createMockPI();
|
|
||||||
googleSearchExtension(pi as any);
|
|
||||||
|
|
||||||
const notifications: any[] = [];
|
|
||||||
const mockCtx = {
|
|
||||||
ui: {
|
|
||||||
notify(msg: string, level: string) {
|
|
||||||
notifications.push({ msg, level });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
modelRegistry: mockModelRegistry(
|
|
||||||
JSON.stringify({
|
|
||||||
token: "should-not-be-used",
|
|
||||||
projectId: "mock-project",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
await pi.fire("session_start", {}, mockCtx);
|
|
||||||
assert.equal(
|
|
||||||
notifications.length,
|
|
||||||
0,
|
|
||||||
"Should NOT notify if API Key is present",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("google-search accepts GOOGLE_GENERATIVE_AI_API_KEY", async (_t) => {
|
|
||||||
const originalKey = process.env.GEMINI_API_KEY;
|
|
||||||
const originalAlias = process.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
|
||||||
delete process.env.GEMINI_API_KEY;
|
|
||||||
process.env.GOOGLE_GENERATIVE_AI_API_KEY = "mock-alias-key";
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
restoreEnv("GEMINI_API_KEY", originalKey);
|
|
||||||
restoreEnv("GOOGLE_GENERATIVE_AI_API_KEY", originalAlias);
|
|
||||||
});
|
|
||||||
const pi = createMockPI();
|
const pi = createMockPI();
|
||||||
googleSearchExtension(pi as any);
|
googleSearchExtension(pi as any);
|
||||||
|
|
||||||
|
|
@ -208,5 +113,6 @@ test("google-search accepts GOOGLE_GENERATIVE_AI_API_KEY", async (_t) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
await pi.fire("session_start", {}, mockCtx);
|
await pi.fire("session_start", {}, mockCtx);
|
||||||
assert.equal(notifications.length, 0);
|
assert.equal(notifications.length, 1);
|
||||||
|
assert.ok(notifications[0].msg.includes("google-gemini-cli OAuth"));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,61 @@
|
||||||
/**
|
/**
|
||||||
* google-search-oauth-shape.test.ts — Regression test for #2963.
|
* google-search-oauth-shape.test.ts — OAuth fallback must use cli-core.
|
||||||
*
|
*
|
||||||
* The OAuth fallback in google_search manually POSTs to the Cloud Code Assist
|
* The OAuth fallback used to carry its own Cloud Code Assist HTTP client. SF now
|
||||||
* endpoint. The original implementation sent a request body that did not match
|
* delegates that transport to @google/gemini-cli-core, matching the main
|
||||||
* the endpoint's expected contract, causing a 400 INVALID_ARGUMENT response.
|
* google-gemini-cli provider boundary.
|
||||||
*
|
|
||||||
* This test captures the fetch call and asserts that the URL and body conform
|
|
||||||
* to the Cloud Code Assist wire format used by the working provider in
|
|
||||||
* packages/pi-ai/src/providers/google-gemini-cli.ts.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { afterEach, test } from "vitest";
|
import { afterEach, test, vi } from "vitest";
|
||||||
import googleSearchExtension from "../resources/extensions/google-search/index.js";
|
import googleSearchExtension from "../resources/extensions/google-search/index.js";
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
const cliCoreMock = vi.hoisted(() => ({
|
||||||
|
lastRequest: undefined as any,
|
||||||
|
lastPromptId: undefined as string | undefined,
|
||||||
|
lastUserTier: undefined as string | undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@google/gemini-cli-core", () => ({
|
||||||
|
CodeAssistServer: class {
|
||||||
|
async *generateContentStream(
|
||||||
|
request: any,
|
||||||
|
promptId: string,
|
||||||
|
userTier: string,
|
||||||
|
) {
|
||||||
|
cliCoreMock.lastRequest = request;
|
||||||
|
cliCoreMock.lastPromptId = promptId;
|
||||||
|
cliCoreMock.lastUserTier = userTier;
|
||||||
|
yield {
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
parts: [{ text: "Sunny, 85 F in Austin today." }],
|
||||||
|
},
|
||||||
|
groundingMetadata: {
|
||||||
|
groundingChunks: [
|
||||||
|
{
|
||||||
|
web: {
|
||||||
|
title: "weather.com",
|
||||||
|
uri: "https://weather.com/austin",
|
||||||
|
domain: "weather.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webSearchQueries: ["weather today in Austin Texas"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("google-auth-library", () => ({
|
||||||
|
OAuth2Client: class {
|
||||||
|
setCredentials(_credentials: any) {}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
function createMockPI() {
|
function createMockPI() {
|
||||||
const handlers: Array<{ event: string; handler: any }> = [];
|
const handlers: Array<{ event: string; handler: any }> = [];
|
||||||
|
|
@ -50,231 +91,67 @@ function mockModelRegistry(oauthJson?: string) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A valid SSE response body matching the Cloud Code Assist wire format. */
|
async function runOAuthSearch(query: string) {
|
||||||
function makeOkSSEBody() {
|
const originalKey = process.env.GEMINI_API_KEY;
|
||||||
const payload = {
|
const originalAlias = process.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
||||||
response: {
|
delete process.env.GEMINI_API_KEY;
|
||||||
candidates: [
|
delete process.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
||||||
{
|
cliCoreMock.lastRequest = undefined;
|
||||||
content: {
|
cliCoreMock.lastPromptId = undefined;
|
||||||
parts: [{ text: "Sunny, 85 °F in Austin today." }],
|
cliCoreMock.lastUserTier = undefined;
|
||||||
},
|
|
||||||
groundingMetadata: {
|
afterEach(() => {
|
||||||
groundingChunks: [
|
if (originalKey !== undefined) process.env.GEMINI_API_KEY = originalKey;
|
||||||
{
|
else delete process.env.GEMINI_API_KEY;
|
||||||
web: {
|
if (originalAlias !== undefined) {
|
||||||
title: "weather.com",
|
process.env.GOOGLE_GENERATIVE_AI_API_KEY = originalAlias;
|
||||||
uri: "https://weather.com/austin",
|
} else {
|
||||||
domain: "weather.com",
|
delete process.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
||||||
},
|
}
|
||||||
},
|
});
|
||||||
],
|
|
||||||
webSearchQueries: ["weather today in Austin Texas"],
|
const pi = createMockPI();
|
||||||
},
|
googleSearchExtension(pi as any);
|
||||||
},
|
|
||||||
],
|
const ctx = {
|
||||||
},
|
ui: { notify() {} },
|
||||||
|
modelRegistry: mockModelRegistry(
|
||||||
|
JSON.stringify({ token: "tok", projectId: "proj" }),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
return `data: ${JSON.stringify(payload)}\n\n`;
|
|
||||||
|
await pi.fire("session_start", {}, ctx);
|
||||||
|
return pi.registeredTool.execute(
|
||||||
|
"c1",
|
||||||
|
{ query },
|
||||||
|
new AbortController().signal,
|
||||||
|
() => {},
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
test("google_search_oauth_when_api_key_missing_uses_cli_core_request_shape", async () => {
|
||||||
|
const result = await runOAuthSearch("weather tools test");
|
||||||
test("#2963: OAuth fallback URL must include ?alt=sse query parameter", async (_t) => {
|
|
||||||
const originalKey = process.env.GEMINI_API_KEY;
|
|
||||||
delete process.env.GEMINI_API_KEY;
|
|
||||||
const originalFetch = global.fetch;
|
|
||||||
|
|
||||||
let capturedUrl = "";
|
|
||||||
|
|
||||||
(global as any).fetch = async (url: string, _options: any) => {
|
|
||||||
capturedUrl = url;
|
|
||||||
return { ok: true, text: async () => makeOkSSEBody() };
|
|
||||||
};
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
global.fetch = originalFetch;
|
|
||||||
if (originalKey !== undefined) process.env.GEMINI_API_KEY = originalKey;
|
|
||||||
else delete process.env.GEMINI_API_KEY;
|
|
||||||
});
|
|
||||||
|
|
||||||
const pi = createMockPI();
|
|
||||||
googleSearchExtension(pi as any);
|
|
||||||
|
|
||||||
const oauthJson = JSON.stringify({ token: "tok", projectId: "proj" });
|
|
||||||
const ctx = {
|
|
||||||
ui: { notify() {} },
|
|
||||||
modelRegistry: mockModelRegistry(oauthJson),
|
|
||||||
};
|
|
||||||
|
|
||||||
await pi.fire("session_start", {}, ctx);
|
|
||||||
await pi.registeredTool.execute(
|
|
||||||
"c1",
|
|
||||||
{ query: "weather" },
|
|
||||||
new AbortController().signal,
|
|
||||||
() => {},
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
|
|
||||||
|
assert.equal(result.isError, undefined);
|
||||||
|
assert.ok(cliCoreMock.lastPromptId?.startsWith("sf-google-search-"));
|
||||||
|
assert.equal(cliCoreMock.lastUserTier, "USER");
|
||||||
|
assert.equal(cliCoreMock.lastRequest.model, "gemini-2.5-flash");
|
||||||
|
assert.deepEqual(cliCoreMock.lastRequest.contents, [
|
||||||
|
{ role: "user", parts: [{ text: "weather tools test" }] },
|
||||||
|
]);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
capturedUrl.includes("?alt=sse"),
|
cliCoreMock.lastRequest.config.tools.some(
|
||||||
`URL must contain ?alt=sse for SSE parsing to work. Got: ${capturedUrl}`,
|
(tool: any) => tool.googleSearch !== undefined,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("#2963: OAuth fallback body must include userAgent field", async (_t) => {
|
test("google_search_oauth_when_cli_core_streams_grounding_returns_sources", async () => {
|
||||||
const originalKey = process.env.GEMINI_API_KEY;
|
const result = await runOAuthSearch("weather");
|
||||||
delete process.env.GEMINI_API_KEY;
|
|
||||||
const originalFetch = global.fetch;
|
|
||||||
|
|
||||||
let capturedBody: any = null;
|
assert.equal(result.isError, undefined);
|
||||||
|
assert.ok(result.content[0].text.includes("Sunny, 85 F"));
|
||||||
(global as any).fetch = async (_url: string, options: any) => {
|
assert.equal(result.details.sourceCount, 1);
|
||||||
capturedBody = JSON.parse(options.body);
|
assert.equal(result.details.cached, false);
|
||||||
return { ok: true, text: async () => makeOkSSEBody() };
|
|
||||||
};
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
global.fetch = originalFetch;
|
|
||||||
if (originalKey !== undefined) process.env.GEMINI_API_KEY = originalKey;
|
|
||||||
else delete process.env.GEMINI_API_KEY;
|
|
||||||
});
|
|
||||||
|
|
||||||
const pi = createMockPI();
|
|
||||||
googleSearchExtension(pi as any);
|
|
||||||
|
|
||||||
const oauthJson = JSON.stringify({ token: "tok", projectId: "proj" });
|
|
||||||
const ctx = {
|
|
||||||
ui: { notify() {} },
|
|
||||||
modelRegistry: mockModelRegistry(oauthJson),
|
|
||||||
};
|
|
||||||
|
|
||||||
await pi.fire("session_start", {}, ctx);
|
|
||||||
await pi.registeredTool.execute(
|
|
||||||
"c2",
|
|
||||||
{ query: "weather userAgent test" },
|
|
||||||
new AbortController().signal,
|
|
||||||
() => {},
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(capturedBody, "fetch must have been called");
|
|
||||||
assert.equal(
|
|
||||||
typeof capturedBody.userAgent,
|
|
||||||
"string",
|
|
||||||
"Body must include a userAgent field (Cloud Code Assist contract)",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("#2963: OAuth fallback body must contain google_search tool in correct format", async (_t) => {
|
|
||||||
const originalKey = process.env.GEMINI_API_KEY;
|
|
||||||
delete process.env.GEMINI_API_KEY;
|
|
||||||
const originalFetch = global.fetch;
|
|
||||||
|
|
||||||
let capturedBody: any = null;
|
|
||||||
|
|
||||||
(global as any).fetch = async (_url: string, options: any) => {
|
|
||||||
capturedBody = JSON.parse(options.body);
|
|
||||||
return { ok: true, text: async () => makeOkSSEBody() };
|
|
||||||
};
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
global.fetch = originalFetch;
|
|
||||||
if (originalKey !== undefined) process.env.GEMINI_API_KEY = originalKey;
|
|
||||||
else delete process.env.GEMINI_API_KEY;
|
|
||||||
});
|
|
||||||
|
|
||||||
const pi = createMockPI();
|
|
||||||
googleSearchExtension(pi as any);
|
|
||||||
|
|
||||||
const oauthJson = JSON.stringify({ token: "tok", projectId: "proj" });
|
|
||||||
const ctx = {
|
|
||||||
ui: { notify() {} },
|
|
||||||
modelRegistry: mockModelRegistry(oauthJson),
|
|
||||||
};
|
|
||||||
|
|
||||||
await pi.fire("session_start", {}, ctx);
|
|
||||||
await pi.registeredTool.execute(
|
|
||||||
"c3",
|
|
||||||
{ query: "weather tools test" },
|
|
||||||
new AbortController().signal,
|
|
||||||
() => {},
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(capturedBody, "fetch must have been called");
|
|
||||||
const tools = capturedBody.request?.tools;
|
|
||||||
assert.ok(Array.isArray(tools), "request.tools must be an array");
|
|
||||||
assert.ok(
|
|
||||||
tools.some((t: any) => t.googleSearch !== undefined),
|
|
||||||
`tools must contain a googleSearch entry. Got: ${JSON.stringify(tools)}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("#2963: OAuth fallback body has correct top-level structure", async (_t) => {
|
|
||||||
const originalKey = process.env.GEMINI_API_KEY;
|
|
||||||
delete process.env.GEMINI_API_KEY;
|
|
||||||
const originalFetch = global.fetch;
|
|
||||||
|
|
||||||
let capturedBody: any = null;
|
|
||||||
|
|
||||||
(global as any).fetch = async (_url: string, options: any) => {
|
|
||||||
capturedBody = JSON.parse(options.body);
|
|
||||||
return { ok: true, text: async () => makeOkSSEBody() };
|
|
||||||
};
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
global.fetch = originalFetch;
|
|
||||||
if (originalKey !== undefined) process.env.GEMINI_API_KEY = originalKey;
|
|
||||||
else delete process.env.GEMINI_API_KEY;
|
|
||||||
});
|
|
||||||
|
|
||||||
const pi = createMockPI();
|
|
||||||
googleSearchExtension(pi as any);
|
|
||||||
|
|
||||||
const oauthJson = JSON.stringify({ token: "tok", projectId: "proj" });
|
|
||||||
const ctx = {
|
|
||||||
ui: { notify() {} },
|
|
||||||
modelRegistry: mockModelRegistry(oauthJson),
|
|
||||||
};
|
|
||||||
|
|
||||||
await pi.fire("session_start", {}, ctx);
|
|
||||||
await pi.registeredTool.execute(
|
|
||||||
"c4",
|
|
||||||
{ query: "weather structure test" },
|
|
||||||
new AbortController().signal,
|
|
||||||
() => {},
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.ok(capturedBody, "fetch must have been called");
|
|
||||||
|
|
||||||
// Top-level fields required by CloudCodeAssistRequest
|
|
||||||
assert.equal(
|
|
||||||
capturedBody.project,
|
|
||||||
"proj",
|
|
||||||
"project must match the OAuth projectId",
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
typeof capturedBody.model === "string" && capturedBody.model.length > 0,
|
|
||||||
"model must be a non-empty string",
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
capturedBody.request && typeof capturedBody.request === "object",
|
|
||||||
"request must be an object",
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
typeof capturedBody.userAgent === "string",
|
|
||||||
"userAgent must be present",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Nested request fields
|
|
||||||
assert.ok(
|
|
||||||
Array.isArray(capturedBody.request.contents),
|
|
||||||
"request.contents must be an array",
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
Array.isArray(capturedBody.request.tools),
|
|
||||||
"request.tools must be an array",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -386,7 +386,7 @@ test("sf exits early with a clear message when synced resources are newer than t
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
result.stderr,
|
result.stderr,
|
||||||
/npm install -g sf-run@latest|sf update/,
|
/npm install -g singularity-forge@latest|sf update/,
|
||||||
"prints upgrade guidance",
|
"prints upgrade guidance",
|
||||||
);
|
);
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ const ONBOARDING_ENV_KEYS = [
|
||||||
"GOOGLE_CLOUD_LOCATION",
|
"GOOGLE_CLOUD_LOCATION",
|
||||||
"GROQ_API_KEY",
|
"GROQ_API_KEY",
|
||||||
"CEREBRAS_API_KEY",
|
"CEREBRAS_API_KEY",
|
||||||
"XAI_API_KEY",
|
|
||||||
"OPENROUTER_API_KEY",
|
"OPENROUTER_API_KEY",
|
||||||
"AI_GATEWAY_API_KEY",
|
"AI_GATEWAY_API_KEY",
|
||||||
"ZAI_API_KEY",
|
"ZAI_API_KEY",
|
||||||
|
|
@ -392,11 +391,9 @@ test("boot and onboarding routes expose locked required state plus explicitly sk
|
||||||
assert.deepEqual(providerIds, [
|
assert.deepEqual(providerIds, [
|
||||||
"anthropic",
|
"anthropic",
|
||||||
"openai",
|
"openai",
|
||||||
"github-copilot",
|
|
||||||
"openai-codex",
|
"openai-codex",
|
||||||
"google-gemini-cli",
|
"google-gemini-cli",
|
||||||
"groq",
|
"groq",
|
||||||
"xai",
|
|
||||||
"openrouter",
|
"openrouter",
|
||||||
"mistral",
|
"mistral",
|
||||||
]);
|
]);
|
||||||
|
|
@ -419,20 +416,20 @@ test("runtime env-backed auth unlocks boot onboarding state and reports the envi
|
||||||
const fixture = makeWorkspaceFixture();
|
const fixture = makeWorkspaceFixture();
|
||||||
clearOnboardingEnv();
|
clearOnboardingEnv();
|
||||||
const authStorage = AuthStorage.inMemory({});
|
const authStorage = AuthStorage.inMemory({});
|
||||||
const previousGithubToken = process.env.GITHUB_TOKEN;
|
const previousOpenAiKey = process.env.OPENAI_API_KEY;
|
||||||
process.env.GITHUB_TOKEN = "ghu_runtime_env_token";
|
process.env.OPENAI_API_KEY = "sk-runtime-env-token";
|
||||||
configureBridgeFixture(fixture, "sess-env-auth");
|
configureBridgeFixture(fixture, "sess-env-auth");
|
||||||
onboarding.configureOnboardingServiceForTests({
|
onboarding.configureOnboardingServiceForTests({
|
||||||
authStorage,
|
authStorage,
|
||||||
getEnvApiKey: (provider: string) =>
|
getEnvApiKey: (provider: string) =>
|
||||||
provider === "github-copilot" ? process.env.GITHUB_TOKEN : undefined,
|
provider === "openai" ? process.env.OPENAI_API_KEY : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
if (previousGithubToken === undefined) {
|
if (previousOpenAiKey === undefined) {
|
||||||
delete process.env.GITHUB_TOKEN;
|
delete process.env.OPENAI_API_KEY;
|
||||||
} else {
|
} else {
|
||||||
process.env.GITHUB_TOKEN = previousGithubToken;
|
process.env.OPENAI_API_KEY = previousOpenAiKey;
|
||||||
}
|
}
|
||||||
onboarding.resetOnboardingServiceForTests();
|
onboarding.resetOnboardingServiceForTests();
|
||||||
await bridge.resetBridgeServiceForTests();
|
await bridge.resetBridgeServiceForTests();
|
||||||
|
|
@ -451,14 +448,14 @@ test("runtime env-backed auth unlocks boot onboarding state and reports the envi
|
||||||
assert.equal(bootPayload.onboarding.lockReason, null);
|
assert.equal(bootPayload.onboarding.lockReason, null);
|
||||||
assert.equal(bootPayload.onboarding.bridgeAuthRefresh.phase, "idle");
|
assert.equal(bootPayload.onboarding.bridgeAuthRefresh.phase, "idle");
|
||||||
assert.deepEqual(bootPayload.onboarding.required.satisfiedBy, {
|
assert.deepEqual(bootPayload.onboarding.required.satisfiedBy, {
|
||||||
providerId: "github-copilot",
|
providerId: "openai",
|
||||||
source: "environment",
|
source: "environment",
|
||||||
});
|
});
|
||||||
const copilotProvider = bootPayload.onboarding.required.providers.find(
|
const openAiProvider = bootPayload.onboarding.required.providers.find(
|
||||||
(provider: any) => provider.id === "github-copilot",
|
(provider: any) => provider.id === "openai",
|
||||||
);
|
);
|
||||||
assert.equal(copilotProvider.configured, true);
|
assert.equal(openAiProvider.configured, true);
|
||||||
assert.equal(copilotProvider.configuredVia, "environment");
|
assert.equal(openAiProvider.configuredVia, "environment");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("failed API-key validation stays locked, redacts the error, and is reflected in boot state without persisting auth", async (_t) => {
|
test("failed API-key validation stays locked, redacts the error, and is reflected in boot state without persisting auth", async (_t) => {
|
||||||
|
|
@ -767,20 +764,20 @@ test("logout_provider fails clearly for environment-backed auth that the browser
|
||||||
const fixture = makeWorkspaceFixture();
|
const fixture = makeWorkspaceFixture();
|
||||||
clearOnboardingEnv();
|
clearOnboardingEnv();
|
||||||
const authStorage = AuthStorage.inMemory({});
|
const authStorage = AuthStorage.inMemory({});
|
||||||
const previousGithubToken = process.env.GITHUB_TOKEN;
|
const previousOpenAiKey = process.env.OPENAI_API_KEY;
|
||||||
process.env.GITHUB_TOKEN = "ghu_env_only_token";
|
process.env.OPENAI_API_KEY = "sk-env-only-token";
|
||||||
configureBridgeFixture(fixture, "sess-logout-env");
|
configureBridgeFixture(fixture, "sess-logout-env");
|
||||||
onboarding.configureOnboardingServiceForTests({
|
onboarding.configureOnboardingServiceForTests({
|
||||||
authStorage,
|
authStorage,
|
||||||
getEnvApiKey: (provider: string) =>
|
getEnvApiKey: (provider: string) =>
|
||||||
provider === "github-copilot" ? process.env.GITHUB_TOKEN : undefined,
|
provider === "openai" ? process.env.OPENAI_API_KEY : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
if (previousGithubToken === undefined) {
|
if (previousOpenAiKey === undefined) {
|
||||||
delete process.env.GITHUB_TOKEN;
|
delete process.env.OPENAI_API_KEY;
|
||||||
} else {
|
} else {
|
||||||
process.env.GITHUB_TOKEN = previousGithubToken;
|
process.env.OPENAI_API_KEY = previousOpenAiKey;
|
||||||
}
|
}
|
||||||
onboarding.resetOnboardingServiceForTests();
|
onboarding.resetOnboardingServiceForTests();
|
||||||
await bridge.resetBridgeServiceForTests();
|
await bridge.resetBridgeServiceForTests();
|
||||||
|
|
@ -795,7 +792,7 @@ test("logout_provider fails clearly for environment-backed auth that the browser
|
||||||
assert.equal(bootBeforePayload.onboarding.locked, false);
|
assert.equal(bootBeforePayload.onboarding.locked, false);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
bootBeforePayload.onboarding.required.satisfiedBy.providerId,
|
bootBeforePayload.onboarding.required.satisfiedBy.providerId,
|
||||||
"github-copilot",
|
"openai",
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
bootBeforePayload.onboarding.required.satisfiedBy.source,
|
bootBeforePayload.onboarding.required.satisfiedBy.source,
|
||||||
|
|
@ -807,7 +804,7 @@ test("logout_provider fails clearly for environment-backed auth that the browser
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
action: "logout_provider",
|
action: "logout_provider",
|
||||||
providerId: "github-copilot",
|
providerId: "openai",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -821,7 +818,7 @@ test("logout_provider fails clearly for environment-backed auth that the browser
|
||||||
assert.equal(logoutPayload.onboarding.locked, false);
|
assert.equal(logoutPayload.onboarding.locked, false);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
logoutPayload.onboarding.required.satisfiedBy.providerId,
|
logoutPayload.onboarding.required.satisfiedBy.providerId,
|
||||||
"github-copilot",
|
"openai",
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
logoutPayload.onboarding.required.satisfiedBy.source,
|
logoutPayload.onboarding.required.satisfiedBy.source,
|
||||||
|
|
|
||||||
|
|
@ -341,7 +341,7 @@ test("checkForUpdates handles network timeout gracefully", async (_t) => {
|
||||||
|
|
||||||
test("checkForUpdates handles missing version field in response", async (_t) => {
|
test("checkForUpdates handles missing version field in response", async (_t) => {
|
||||||
const tmp = mkdtempSync(join(tmpdir(), "sf-update-"));
|
const tmp = mkdtempSync(join(tmpdir(), "sf-update-"));
|
||||||
const registry = await startMockRegistry({ name: "sf-run" }); // no version field
|
const registry = await startMockRegistry({ name: "singularity-forge" }); // no version field
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await registry.close();
|
await registry.close();
|
||||||
rmSync(tmp, { recursive: true, force: true });
|
rmSync(tmp, { recursive: true, force: true });
|
||||||
|
|
|
||||||
|
|
@ -69,8 +69,8 @@ test("resolveInstallCommand returns bun command when running under Bun (#4145)",
|
||||||
try {
|
try {
|
||||||
(process.versions as Record<string, string | undefined>).bun = "1.0.0";
|
(process.versions as Record<string, string | undefined>).bun = "1.0.0";
|
||||||
assert.equal(
|
assert.equal(
|
||||||
resolveInstallCommand("sf-run@latest"),
|
resolveInstallCommand("singularity-forge@latest"),
|
||||||
"bun add -g sf-run@latest",
|
"bun add -g singularity-forge@latest",
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (orig === undefined) {
|
if (orig === undefined) {
|
||||||
|
|
@ -87,8 +87,8 @@ test("resolveInstallCommand returns npm command when not running under Bun (#414
|
||||||
try {
|
try {
|
||||||
delete (process.versions as Record<string, string | undefined>).bun;
|
delete (process.versions as Record<string, string | undefined>).bun;
|
||||||
assert.equal(
|
assert.equal(
|
||||||
resolveInstallCommand("sf-run@latest"),
|
resolveInstallCommand("singularity-forge@latest"),
|
||||||
"npm install -g sf-run@latest",
|
"npm install -g singularity-forge@latest",
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (orig !== undefined) {
|
if (orig !== undefined) {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import chalk from "chalk";
|
||||||
import { appRoot } from "./app-paths.js";
|
import { appRoot } from "./app-paths.js";
|
||||||
|
|
||||||
const CACHE_FILE = join(appRoot, ".update-check");
|
const CACHE_FILE = join(appRoot, ".update-check");
|
||||||
const NPM_PACKAGE_NAME = "sf-run";
|
export const NPM_PACKAGE_NAME = "singularity-forge";
|
||||||
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
const FETCH_TIMEOUT_MS = 5000;
|
const FETCH_TIMEOUT_MS = 5000;
|
||||||
const DEFAULT_REGISTRY_URL = `https://registry.npmjs.org/${NPM_PACKAGE_NAME}/latest`;
|
const DEFAULT_REGISTRY_URL = `https://registry.npmjs.org/${NPM_PACKAGE_NAME}/latest`;
|
||||||
|
|
@ -87,7 +87,7 @@ export function resolveInstallCommand(pkg: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function printUpdateBanner(current: string, latest: string): void {
|
function printUpdateBanner(current: string, latest: string): void {
|
||||||
const installCmd = resolveInstallCommand("sf-run");
|
const installCmd = resolveInstallCommand(`${NPM_PACKAGE_NAME}@latest`);
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
` ${chalk.yellow("Update available:")} ${chalk.dim(`v${current}`)} → ${chalk.bold(`v${latest}`)}\n` +
|
` ${chalk.yellow("Update available:")} ${chalk.dim(`v${current}`)} → ${chalk.bold(`v${latest}`)}\n` +
|
||||||
` ${chalk.dim("Run")} ${installCmd} ${chalk.dim("or")} /sf update ${chalk.dim("to upgrade")}\n\n`,
|
` ${chalk.dim("Run")} ${installCmd} ${chalk.dim("or")} /sf update ${chalk.dim("to upgrade")}\n\n`,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import {
|
||||||
resolveInstallCommand,
|
resolveInstallCommand,
|
||||||
} from "./update-check.js";
|
} from "./update-check.js";
|
||||||
|
|
||||||
const NPM_PACKAGE = "sf-run";
|
const NPM_PACKAGE = "singularity-forge";
|
||||||
|
|
||||||
export async function runUpdate(): Promise<void> {
|
export async function runUpdate(): Promise<void> {
|
||||||
const current = process.env.SF_VERSION || "0.0.0";
|
const current = process.env.SF_VERSION || "0.0.0";
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
nativeGetCurrentBranch,
|
nativeGetCurrentBranch,
|
||||||
nativeHasChanges,
|
nativeHasChanges,
|
||||||
nativeHasMergeConflicts,
|
nativeHasMergeConflicts,
|
||||||
} from "../resources/extensions/sf/native-git-bridge.ts";
|
} from "../resources/extensions/sf/native-git-bridge.js";
|
||||||
import { resolveBridgeRuntimeConfig } from "./bridge-service.ts";
|
import { resolveBridgeRuntimeConfig } from "./bridge-service.ts";
|
||||||
|
|
||||||
const MAX_CHANGED_FILES = 25;
|
const MAX_CHANGED_FILES = 25;
|
||||||
|
|
|
||||||
|
|
@ -174,12 +174,6 @@ const REQUIRED_PROVIDER_CATALOG: RequiredProviderCatalogEntry[] = [
|
||||||
recommended: true,
|
recommended: true,
|
||||||
},
|
},
|
||||||
{ id: "openai", label: "OpenAI", supportsApiKey: true, supportsOAuth: false },
|
{ id: "openai", label: "OpenAI", supportsApiKey: true, supportsOAuth: false },
|
||||||
{
|
|
||||||
id: "github-copilot",
|
|
||||||
label: "GitHub Copilot",
|
|
||||||
supportsApiKey: false,
|
|
||||||
supportsOAuth: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "openai-codex",
|
id: "openai-codex",
|
||||||
label: "ChatGPT Plus/Pro (Codex Subscription)",
|
label: "ChatGPT Plus/Pro (Codex Subscription)",
|
||||||
|
|
@ -188,17 +182,11 @@ const REQUIRED_PROVIDER_CATALOG: RequiredProviderCatalogEntry[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "google-gemini-cli",
|
id: "google-gemini-cli",
|
||||||
label: "Google Cloud Code Assist (Gemini CLI)",
|
label: "Google Cloud Code Assist",
|
||||||
supportsApiKey: false,
|
supportsApiKey: false,
|
||||||
supportsOAuth: true,
|
supportsOAuth: true,
|
||||||
},
|
},
|
||||||
{ id: "groq", label: "Groq", supportsApiKey: true, supportsOAuth: false },
|
{ id: "groq", label: "Groq", supportsApiKey: true, supportsOAuth: false },
|
||||||
{
|
|
||||||
id: "xai",
|
|
||||||
label: "xAI (Grok)",
|
|
||||||
supportsApiKey: true,
|
|
||||||
supportsOAuth: false,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "openrouter",
|
id: "openrouter",
|
||||||
label: "OpenRouter",
|
label: "OpenRouter",
|
||||||
|
|
@ -454,13 +442,6 @@ async function defaultValidateApiKey(
|
||||||
"https://api.groq.com/openai/v1/models",
|
"https://api.groq.com/openai/v1/models",
|
||||||
apiKey,
|
apiKey,
|
||||||
);
|
);
|
||||||
case "xai":
|
|
||||||
return await validateBearerRequest(
|
|
||||||
fetchImpl,
|
|
||||||
providerId,
|
|
||||||
"https://api.x.ai/v1/models",
|
|
||||||
apiKey,
|
|
||||||
);
|
|
||||||
case "openrouter":
|
case "openrouter":
|
||||||
return await validateBearerRequest(
|
return await validateBearerRequest(
|
||||||
fetchImpl,
|
fetchImpl,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { compareSemver } from "../update-check.ts";
|
import { compareSemver } from "../update-check.ts";
|
||||||
|
|
||||||
const NPM_PACKAGE_NAME = "sf-run";
|
const NPM_PACKAGE_NAME = "singularity-forge";
|
||||||
const REGISTRY_URL = `https://registry.npmjs.org/${NPM_PACKAGE_NAME}/latest`;
|
const REGISTRY_URL = `https://registry.npmjs.org/${NPM_PACKAGE_NAME}/latest`;
|
||||||
const FETCH_TIMEOUT_MS = 5000;
|
const FETCH_TIMEOUT_MS = 5000;
|
||||||
const NPM_COMMAND = process.platform === "win32" ? "npm.cmd" : "npm";
|
const NPM_COMMAND = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||||
|
|
@ -67,7 +67,7 @@ export function getUpdateStatus(): UpdateState {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Triggers an async global npm install of sf-run@latest.
|
* Triggers an async global npm install of singularity-forge@latest.
|
||||||
* Returns `true` if the update was started, `false` if one is already running.
|
* Returns `true` if the update was started, `false` if one is already running.
|
||||||
* The child process runs in the background; poll `getUpdateStatus()` for progress.
|
* The child process runs in the background; poll `getUpdateStatus()` for progress.
|
||||||
*/
|
*/
|
||||||
|
|
@ -78,14 +78,18 @@ export function triggerUpdate(targetVersion?: string): boolean {
|
||||||
|
|
||||||
updateState = { status: "running", targetVersion };
|
updateState = { status: "running", targetVersion };
|
||||||
|
|
||||||
const child = spawn(NPM_COMMAND, ["install", "-g", "sf-run@latest"], {
|
const child = spawn(
|
||||||
|
NPM_COMMAND,
|
||||||
|
["install", "-g", `${NPM_PACKAGE_NAME}@latest`],
|
||||||
|
{
|
||||||
stdio: ["ignore", "ignore", "pipe"],
|
stdio: ["ignore", "ignore", "pipe"],
|
||||||
// Detach so the child process is not killed if the parent exits
|
// Detach so the child process is not killed if the parent exits
|
||||||
detached: false,
|
detached: false,
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
// Avoid shell: true — npm.cmd is directly executable on Windows via spawn.
|
// Avoid shell: true — npm.cmd is directly executable on Windows via spawn.
|
||||||
// Using shell expands the command injection surface unnecessarily.
|
// Using shell expands the command injection surface unnecessarily.
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
const binary = process.env.SF_SMOKE_BINARY || "npx";
|
const defaultBinary = process.execPath;
|
||||||
const args = process.env.SF_SMOKE_BINARY ? ["--help"] : ["sf-run", "--help"];
|
const defaultArgs = [
|
||||||
|
join(import.meta.dirname, "..", "..", "dist", "loader.js"),
|
||||||
|
];
|
||||||
|
const binary = process.env.SF_SMOKE_BINARY || defaultBinary;
|
||||||
|
const args = process.env.SF_SMOKE_BINARY
|
||||||
|
? ["--help"]
|
||||||
|
: [...defaultArgs, "--help"];
|
||||||
|
|
||||||
const output = execFileSync(binary, args, {
|
const output = execFileSync(binary, args, {
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,23 @@ import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
// Skip in non-TTY environments (CI containers) — init requires interactive mode
|
// Skip in non-TTY environments — init enters the interactive setup flow.
|
||||||
if (!process.stdin.isTTY && process.env.CI) {
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
||||||
console.log(" SKIP test-init (no TTY in CI)");
|
console.log(" SKIP test-init (no TTY)");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tmpDir = mkdtempSync(join(tmpdir(), "sf-smoke-init-"));
|
const tmpDir = mkdtempSync(join(tmpdir(), "sf-smoke-init-"));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const binary = process.env.SF_SMOKE_BINARY || "npx";
|
const defaultBinary = process.execPath;
|
||||||
const args = process.env.SF_SMOKE_BINARY ? ["init"] : ["sf-run", "init"];
|
const defaultArgs = [
|
||||||
|
join(import.meta.dirname, "..", "..", "dist", "loader.js"),
|
||||||
|
];
|
||||||
|
const binary = process.env.SF_SMOKE_BINARY || defaultBinary;
|
||||||
|
const args = process.env.SF_SMOKE_BINARY
|
||||||
|
? ["init"]
|
||||||
|
: [...defaultArgs, "init"];
|
||||||
|
|
||||||
execFileSync(binary, args, {
|
execFileSync(binary, args, {
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
const binary = process.env.SF_SMOKE_BINARY || "npx";
|
const defaultBinary = process.execPath;
|
||||||
|
const defaultArgs = [
|
||||||
|
join(import.meta.dirname, "..", "..", "dist", "loader.js"),
|
||||||
|
];
|
||||||
|
const binary = process.env.SF_SMOKE_BINARY || defaultBinary;
|
||||||
const args = process.env.SF_SMOKE_BINARY
|
const args = process.env.SF_SMOKE_BINARY
|
||||||
? ["--version"]
|
? ["--version"]
|
||||||
: ["sf-run", "--version"];
|
: [...defaultArgs, "--version"];
|
||||||
|
|
||||||
const output = execFileSync(binary, args, {
|
const output = execFileSync(binary, args, {
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
|
"tsBuildInfoFile": "dist/.tsbuildinfo/tsconfig.tsbuildinfo",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue