fix: harden widget and provider auth handling
This commit is contained in:
parent
3c84bd2fed
commit
b1a7749763
24 changed files with 621 additions and 96 deletions
|
|
@ -1,29 +1,236 @@
|
|||
# CLI Agent Code Survey — 2026-05-07
|
||||
|
||||
We compared Forge-relevant CLI agent implementations to pull workflow and autonomy patterns into SF planning.
|
||||
This record compares the local coding-agent checkouts under `/home/mhugo/code/`
|
||||
against Forge. It is planning evidence, not an instruction to copy another
|
||||
product's architecture.
|
||||
|
||||
## What was checked
|
||||
## Product Boundary
|
||||
|
||||
- `claude-code`
|
||||
Forge remains the product, and UOK remains the internal execution kernel.
|
||||
External CLIs are reference implementations used to sharpen Forge, not
|
||||
destination architectures.
|
||||
|
||||
Hard boundary: Forge must stay an MCP client only. Do not add, restore, or plan
|
||||
an SF MCP server. External control belongs in daemon, RPC, and headless
|
||||
interfaces.
|
||||
|
||||
## Local Checkouts Inspected
|
||||
|
||||
Primary references:
|
||||
|
||||
- `singularity-forge`
|
||||
- `codex`
|
||||
- `gemini-cli`
|
||||
- `opencode`
|
||||
- `claude-code`
|
||||
- `ace-coder`
|
||||
|
||||
Additional coder references:
|
||||
|
||||
- `aider`
|
||||
- `goose`
|
||||
- `qwen-code`
|
||||
- `crush`
|
||||
- `Agentless`
|
||||
- `RA.Aid`
|
||||
- `plandex`
|
||||
- agentless-style repos: `Agentless`, `open-codex`, `RA.Aid`, `letta-code`, `neovate-code`, `amazon-q-developer-cli`
|
||||
- `ace-coder` for curator, memory, and autonomy patterns
|
||||
- `goose`
|
||||
- `gemini-cli`
|
||||
- `qwen-code`
|
||||
- `opencode`
|
||||
- `crush`
|
||||
- `amazon-q-developer-cli`
|
||||
- `open-codex`
|
||||
- `letta-code`
|
||||
- `neovate-code`
|
||||
|
||||
## Where the code lives
|
||||
The local `claude-code` checkout is a leaked-source/sourcemap research mirror,
|
||||
not a clean upstream dependency. Treat it as ergonomics evidence only.
|
||||
|
||||
All reference checkouts are local under `/home/mhugo/code/`.
|
||||
## Comparison Matrix
|
||||
|
||||
## Takeaways
|
||||
| Reference | Strongest Fit For Forge | Borrow | Avoid |
|
||||
|---|---|---|---|
|
||||
| `plandex` | Large task planning and review workflow | Cumulative diff sandbox, plan versioning, explicit chat-to-plan-to-apply flow, context indexing for big repos | Server/product coupling and any cloud-hosting assumptions |
|
||||
| `codex` | Execution hardening and protocol boundaries | Typed non-interactive event stream, sandbox permission profiles, app-server protocol shape, Rust crate boundaries, config schema rigor, plugin/skill manager discipline | Treating MCP server code as a Forge product direction |
|
||||
| `claude-code` | Interactive ergonomics | Permission UX, command discoverability, plugin surfaces, subagent UX, memory/context commands, MCP client config flows | Copying leaked-source implementation or making it an upstream dependency |
|
||||
| `ace-coder` | Owned multi-repo governance | Reviewer roles, hard quality gates, skill/subagent routing policy, explicit MCP-client contract style | Collapsing ACE and Forge into one product surface |
|
||||
| `aider` | Tight edit loop, repo maps, and benchmark culture | Token-budgeted repo-map ranking, reproducible benchmark reports with model, edit format, commit hash, dirty state, pass rates, malformed output counts | Early auto-commit posture before validation and commit gates |
|
||||
| `Agentless` | Bug-fix eval pipeline | Localization, candidate repair, regression-test selection, reproduction-test generation, validation-based patch reranking | SWE-bench-specific harness assumptions |
|
||||
| `RA.Aid` | Stage boundaries and trajectory records | Explicit research/planning/implementation phases, research-only mode, durable session/tool trajectory records | Broad autonomous shell posture and external Aider outsourcing |
|
||||
| `goose` | Desktop/CLI/API distribution and diagnostics | Provider breadth, diagnostics/reporting, API embedding, extension packaging, MCP client lifecycle patterns | Built-in/re-exported MCP servers or broad general-agent scope |
|
||||
| `gemini-cli` | Release/test/docs automation | Release channels, generated settings schema/docs, behavioral eval incubation, sandbox integration tests, perf/memory baselines, GitHub Action workflows | Provider-specific product assumptions or unstable evals as hard CI gates |
|
||||
| `qwen-code` | Claude-like terminal workflow | Skills/subagents, forked subagent design, trust-gated workspace config, terminal-capture regressions, flexible provider config | OAuth/provider policy coupling or ungated project-local config |
|
||||
| `opencode` | Mode split and schema boundary | Read-only `plan` mode vs full-access `build` mode, client/server framing, LSP opt-in, project-local commands/tools, schema-first domain boundary | Bun-specific implementation style for Forge |
|
||||
| `crush` | SQLite state, hooks, permissions, TUI | SQLite migrations/query discipline, hook engine, permission layering, session DB, tool markdown descriptions, LSP, pub/sub, MCP client status UX | Replacing Forge's TypeScript extension architecture with Go |
|
||||
| `letta-code` | Long-lived memory-agent UX | Memory lifecycle, skill learning, approval recovery tests, channel/remote control ideas, MCP OAuth/connect UX | Treating memory as unstructured product magic instead of DB-backed state |
|
||||
| `neovate-code` | Design-doc and terminal UX iteration | Small design records, queued-message designs, subagent design notes, command/terminal UX records | Pulling in provider-specific branding or immature UX churn |
|
||||
| `amazon-q-developer-cli` | Rust auth/security reference | Auth/security/workspace patterns and Rust CLI lessons where applicable | Product direction; local README says the open source project is no longer actively maintained |
|
||||
| `open-codex` | Older/forked approval-mode comparison | Approval-mode vocabulary and provider abstraction history | Fork-specific Chat Completions direction as a primary architecture |
|
||||
|
||||
- `plandex` is the closest workflow match for SF planning.
|
||||
- `claude-code`, `aider`, `codex`, and `gemini-cli` are the best ergonomics references.
|
||||
- `ace-coder` is our own codebase and the long-term direction is convergence between Forge and ACE.
|
||||
- The code survey is done; future planning can rely on the local checkouts instead of rescanning remote repos.
|
||||
## Forge Already Has
|
||||
|
||||
- DB-backed workflow state and project-local planning artifacts.
|
||||
- Headless/RPC surfaces for automation.
|
||||
- UOK safety and recovery concepts.
|
||||
- Extension loading and bundled tool surfaces.
|
||||
- Purpose-first TDD and PDD field contracts.
|
||||
- Provider abstraction through `pi-ai`.
|
||||
|
||||
Those are the center of gravity. Borrowed patterns should strengthen these
|
||||
surfaces instead of adding parallel state systems.
|
||||
|
||||
## Gaps Worth Pulling Into The Roadmap
|
||||
|
||||
1. **Execution and permission hardening**
|
||||
- Use Codex and Crush as the references.
|
||||
- Target Forge surfaces: `exec-sandbox`, production mutation approval,
|
||||
command permissions, headless/RPC mutation gates, DB-recorded tool-call
|
||||
evidence, and permission profiles that specify filesystem, network,
|
||||
`.git`, metadata, writable-root, and denied-path behavior.
|
||||
|
||||
2. **Plan/build mode separation**
|
||||
- Use OpenCode, Plandex, and Qwen Code as the references.
|
||||
- Target Forge surfaces: explicit read-only planning mode, full-access build
|
||||
mode, and clearer mode transitions in auto/headless.
|
||||
|
||||
3. **Typed headless event stream**
|
||||
- Use Codex and OpenCode as the references.
|
||||
- Target Forge surfaces: stable machine-readable events such as
|
||||
`thread.started`, `turn.started`, `turn.completed`, `turn.failed`,
|
||||
`item.started`, `item.updated`, and `item.completed`, with typed payloads
|
||||
for commands, patches, MCP calls, web/context lookups, todos, and UOK
|
||||
evidence.
|
||||
|
||||
4. **Reviewable cumulative diffs**
|
||||
- Use Plandex and Aider as the references.
|
||||
- Target Forge surfaces: cumulative patch review, apply/reject/revise
|
||||
workflow, conflict analysis before apply/rewind, and commit metadata tied
|
||||
to model, prompt, dirty state, and evidence.
|
||||
|
||||
5. **Eval and bug-fix pipeline**
|
||||
- Use Aider and Agentless as the references.
|
||||
- Target Forge surfaces: reproducible eval reports, localization -> repair
|
||||
-> validation cases, candidate patch sampling, reproduction-test
|
||||
generation, and validation-based failure reranking.
|
||||
|
||||
6. **Memory lifecycle and recovery**
|
||||
- Use Letta Code and ACE as references, while keeping Forge DB-first.
|
||||
- Target Forge surfaces: durable memory extraction, turn recovery policy,
|
||||
approval recovery, stale-state reconciliation, typed memory records, and
|
||||
per-tool trajectory records for auto-mode postmortems.
|
||||
|
||||
7. **Terminal UX and command discoverability**
|
||||
- Use Claude Code, Crush, OpenCode, and Neovate as references.
|
||||
- Target Forge surfaces: command catalog, permission prompts, status line,
|
||||
queued-message behavior, and compact TUI/headless diagnostics.
|
||||
|
||||
8. **Config and schema generation**
|
||||
- Use Gemini CLI, Codex, Qwen Code, and Crush as references.
|
||||
- Target Forge surfaces: typed settings, generated docs, environment schema,
|
||||
DB migrations, and strict versioned JSON projections when JSON is only a
|
||||
compatibility/export format.
|
||||
|
||||
9. **MCP client lifecycle**
|
||||
- Use Crush, Amazon Q, Claude Code, Letta Code, and Neovate as references.
|
||||
- Target Forge surfaces: explicit client states (`disabled`, `starting`,
|
||||
`connected`, `error`), reconnect behavior, scoped project/global/managed
|
||||
config, atomic config writes, tool namespacing such as
|
||||
`mcp__server__tool`, schema cleanup, resource list/read commands, OAuth
|
||||
connect UX, status counts, and evidence logging.
|
||||
- Stop rule: do not implement any SF MCP server, MCP worker backend, or
|
||||
bundled/re-exported MCP server.
|
||||
|
||||
## Priority Order
|
||||
|
||||
P0:
|
||||
|
||||
- Keep Forge MCP-client-only; reject any MCP-server plan.
|
||||
- Harden command/tool execution policy and mutation gates.
|
||||
- Add typed headless event DTOs for auto/headless consumers.
|
||||
- Make DB-backed state the structured source of truth for planner/runtime
|
||||
records, with JSON/Markdown only as projections, imports, exports, or
|
||||
promoted human docs.
|
||||
- Add trust gating for project-local config, hooks, tools, `.env`, and automatic
|
||||
memory loading before expanding those surfaces.
|
||||
|
||||
P1:
|
||||
|
||||
- Add explicit plan/build mode semantics.
|
||||
- Add cumulative diff review and evidence metadata.
|
||||
- Expand UOK evals with Agentless-style localization/repair/validation cases.
|
||||
- Add MCP client state/status/config hardening without adding any MCP server.
|
||||
|
||||
P2:
|
||||
|
||||
- Improve terminal command discovery and permission UX.
|
||||
- Generate settings/environment docs from typed schemas.
|
||||
- Compare memory lifecycle/recovery against Letta and ACE.
|
||||
|
||||
## Evidence Pointers
|
||||
|
||||
The follow-up subagent pass inspected these concrete local paths:
|
||||
|
||||
- `aider/aider/repomap.py`, `aider/aider/coders/base_coder.py`,
|
||||
`aider/aider/linter.py`, and `aider/benchmark/README.md`.
|
||||
- `Agentless/agentless/fl/localize.py`,
|
||||
`Agentless/agentless/repair/rerank.py`,
|
||||
`Agentless/agentless/test/generate_reproduction_tests.py`.
|
||||
- `RA.Aid/ra_aid/agents/`, `RA.Aid/ra_aid/tools/programmer.py`,
|
||||
`RA.Aid/ra_aid/database/models.py`.
|
||||
- `plandex/app/cli/lib/apply.go`, `plandex/app/cli/lib/rewind.go`,
|
||||
`plandex/app/server/db/diff_helpers.go`.
|
||||
- `codex/codex-rs/exec/src/exec_events.rs`,
|
||||
`codex/codex-rs/linux-sandbox/README.md`,
|
||||
`codex/codex-rs/linux-sandbox/src/linux_run_main.rs`.
|
||||
- `gemini-cli/evals/README.md`, `gemini-cli/perf-tests/README.md`,
|
||||
`gemini-cli/memory-tests/`.
|
||||
- `qwen-code/docs/users/configuration/trusted-folders.md`,
|
||||
`qwen-code/docs/design/fork-subagent/fork-subagent-design.md`,
|
||||
`qwen-code/integration-tests/terminal-capture/`.
|
||||
- `opencode/.opencode/`, `opencode/specs/v2/session.md`,
|
||||
`opencode/packages/opencode/specs/effect/schema.md`,
|
||||
`opencode/packages/opencode/src/session/schema.ts`.
|
||||
- `crush/internal/db/`, `crush/internal/hooks/`,
|
||||
`crush/internal/permission/`, `crush/internal/agent/tools/mcp/`.
|
||||
- `claude-code/src/services/mcp/`, `claude-code/src/commands/mcp/`,
|
||||
`claude-code/src/tools/ListMcpResourcesTool/`,
|
||||
`claude-code/src/tools/ReadMcpResourceTool/`.
|
||||
- `letta-code/src/cli/components/McpConnectFlow.tsx`,
|
||||
`letta-code/src/cli/helpers/mcpOauth.ts`,
|
||||
`letta-code/src/agent/approval-recovery.ts`.
|
||||
- `neovate-code/src/mcp.ts`, `neovate-code/src/commands/mcp.ts`,
|
||||
`neovate-code/src/slash-commands/builtin/mcp.tsx`.
|
||||
- `amazon-q-developer-cli/crates/agent/src/agent/mcp/`,
|
||||
`amazon-q-developer-cli/crates/chat-cli/src/cli/mcp.rs`.
|
||||
- `ace-coder/docs/MCP_SERVER.md`,
|
||||
`ace-coder/docs/plans/2026-04-05-mcp-daemon-refactor.md`,
|
||||
`ace-coder/python/ai_dev/mcp/`.
|
||||
|
||||
## Context7 Cross-Check
|
||||
|
||||
Context7 was used after the local-source pass as a secondary check for indexed
|
||||
public references. Local source remains the evidence of record because it is the
|
||||
snapshot available on this machine.
|
||||
|
||||
- `/openai/codex` confirmed the relevant Codex public patterns: interactive and
|
||||
non-interactive CLI modes, `app-server`, `AGENTS.md` project guidance,
|
||||
approval policy, and sandbox modes (`read-only`, `workspace-write`,
|
||||
`danger-full-access`) with writable roots and network controls.
|
||||
- `/plandex-ai/plandex` confirmed the relevant Plandex public patterns:
|
||||
semi/full autonomy levels, smart context loading, cumulative diff review
|
||||
sandbox, review/apply/debug workflow, and large multi-file task focus.
|
||||
- `/ai-christianson/ra.aid` confirmed the relevant RA.Aid public patterns:
|
||||
research-only and research-and-plan-only modes, the research -> planning ->
|
||||
implementation workflow, logging/cost visibility, and the risky
|
||||
`--cowboy-mode` shell approval bypass that Forge should not copy.
|
||||
- Context7 also resolves these remaining comparison targets for later deeper
|
||||
checks:
|
||||
- Aider: `/websites/aider_chat` and `/aider-ai/aider`.
|
||||
- Qwen Code: `/qwenlm/qwen-code`,
|
||||
`/websites/qwenlm_github_io_qwen-code-docs`, and
|
||||
`/websites/qwenlm_github_io_qwen-code-docs_en`.
|
||||
- OpenCode: `/anomalyco/opencode`.
|
||||
|
||||
## Resulting Direction
|
||||
|
||||
Forge should absorb proven patterns into UOK and the existing DB-first runtime:
|
||||
structured state, explicit modes, stronger permissions, reproducible evidence,
|
||||
and better review UX. The goal is not feature parity with every coder. The goal
|
||||
is a purpose-to-software compiler whose autonomy is inspectable, recoverable,
|
||||
and safe enough to run repeatedly.
|
||||
|
|
|
|||
|
|
@ -7,5 +7,5 @@ Repo-memory audits, decision ledgers, context-gardening notes, and records-keepe
|
|||
| Date | Note | Summary |
|
||||
|------|------|---------|
|
||||
| 2026-05-01 | [repo-vcs and notifications](./2026-05-01-repo-vcs-and-notifications.md) | repo-vcs skill landed; notification specs drafted; JSDoc annotations added; placeholder docs filled |
|
||||
| 2026-05-07 | [cli agent code survey](./2026-05-07-cli-agent-code-survey.md) | compared local CLI agent checkouts; Plandex is the workflow analog; ACE is owned code and future convergence target |
|
||||
| 2026-05-07 | [cli agent code survey](./2026-05-07-cli-agent-code-survey.md) | compared local CLI agent checkouts plus Context7 cross-checks; priority pulls are execution permissions, typed headless events, DB-first state, trust gating, cumulative diffs, eval pipelines, and MCP-client-only lifecycle hardening |
|
||||
| 2026-05-07 | [strategy alignment](./2026-05-07-strategy-alignment.md) | aligned top-level docs and roadmap framing around Forge as product, UOK as kernel, and external CLIs as sharpening inputs |
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ Step-by-step setup instructions for every LLM provider SF supports. If you ran t
|
|||
|----------|-------------|-------------|-------------|
|
||||
| Anthropic | API key | `ANTHROPIC_API_KEY` | — |
|
||||
| OpenAI | API key | `OPENAI_API_KEY` | — |
|
||||
| Google Gemini | API key | `GEMINI_API_KEY` | — |
|
||||
| Google Gemini | Gemini CLI Core auth | — | `~/.gemini/oauth_creds.json` |
|
||||
| OpenRouter | API key | `OPENROUTER_API_KEY` | Optional `models.json` |
|
||||
| Groq | API key | `GROQ_API_KEY` | — |
|
||||
| xAI | API key | `XAI_API_KEY` | — |
|
||||
|
|
@ -85,11 +85,15 @@ Or run `sf config` and choose "Paste an API key" then "OpenAI".
|
|||
|
||||
### Google Gemini
|
||||
|
||||
SF routes Gemini-family models through `google-gemini-cli` / Gemini CLI Core.
|
||||
Authenticate there once and let SF reuse the stored auth state.
|
||||
|
||||
```bash
|
||||
export GEMINI_API_KEY="..."
|
||||
gemini login
|
||||
```
|
||||
|
||||
**Get a key:** [aistudio.google.com/app/apikey](https://aistudio.google.com/app/apikey)
|
||||
SF intentionally ignores ambient `GEMINI_API_KEY` and
|
||||
`GOOGLE_GENERATIVE_AI_API_KEY` values for Forge runtime selection.
|
||||
|
||||
### OpenRouter
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ Step-by-step setup instructions for every LLM provider SF supports. If you ran t
|
|||
|----------|-------------|---------------------|
|
||||
| Anthropic | OAuth or API key | `ANTHROPIC_API_KEY` |
|
||||
| OpenAI | API key | `OPENAI_API_KEY` |
|
||||
| Google Gemini | API key | `GEMINI_API_KEY` or `GOOGLE_GENERATIVE_AI_API_KEY` |
|
||||
| Google Gemini | Gemini CLI Core auth | `~/.gemini/oauth_creds.json` |
|
||||
| OpenRouter | API key | `OPENROUTER_API_KEY` |
|
||||
| Groq | API key | `GROQ_API_KEY` |
|
||||
| xAI (Grok) | API key | `XAI_API_KEY` |
|
||||
|
|
@ -52,12 +52,15 @@ Or run `sf config` and choose "Paste an API key" then "OpenAI".
|
|||
|
||||
### Google Gemini
|
||||
|
||||
Authenticate Gemini CLI Core once and let SF reuse that state:
|
||||
|
||||
```bash
|
||||
export GEMINI_API_KEY="..."
|
||||
# or
|
||||
export GOOGLE_GENERATIVE_AI_API_KEY="..."
|
||||
gemini login
|
||||
```
|
||||
|
||||
SF intentionally ignores `GEMINI_API_KEY` and `GOOGLE_GENERATIVE_AI_API_KEY`
|
||||
for Forge runtime selection.
|
||||
|
||||
### OpenRouter
|
||||
|
||||
OpenRouter aggregates 200+ models from multiple providers behind a single API key.
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@
|
|||
|----------|----------|
|
||||
| `ANTHROPIC_API_KEY` | Anthropic (Claude) |
|
||||
| `OPENAI_API_KEY` | OpenAI |
|
||||
| `GEMINI_API_KEY` | Google Gemini |
|
||||
| `GOOGLE_GENERATIVE_AI_API_KEY` | Google Gemini (models.dev / AI SDK alias) |
|
||||
| `GEMINI_API_KEY` | Google Gemini (ignored by Forge runtime; Gemini CLI Core auth is used instead) |
|
||||
| `GOOGLE_GENERATIVE_AI_API_KEY` | Google Gemini alias (ignored by Forge runtime) |
|
||||
| `OPENROUTER_API_KEY` | OpenRouter |
|
||||
| `GROQ_API_KEY` | Groq |
|
||||
| `XAI_API_KEY` | xAI (Grok) |
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { describe, it } from "vitest";
|
|||
import { getEnvApiKey } from "./env-api-keys.js";
|
||||
|
||||
describe("getEnvApiKey", () => {
|
||||
it("uses GEMINI_API_KEY for google when present", () => {
|
||||
it("ignores GEMINI_API_KEY for google when present", () => {
|
||||
const savedGemini = process.env.GEMINI_API_KEY;
|
||||
const savedGoogleGenerative = process.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
||||
|
||||
|
|
@ -11,7 +11,8 @@ describe("getEnvApiKey", () => {
|
|||
process.env.GOOGLE_GENERATIVE_AI_API_KEY = "google-generative-key";
|
||||
|
||||
try {
|
||||
assert.equal(getEnvApiKey("google"), "gemini-key");
|
||||
assert.equal(getEnvApiKey("google"), undefined);
|
||||
assert.equal(getEnvApiKey("google-gemini-cli"), undefined);
|
||||
} finally {
|
||||
if (savedGemini === undefined) delete process.env.GEMINI_API_KEY;
|
||||
else process.env.GEMINI_API_KEY = savedGemini;
|
||||
|
|
@ -21,7 +22,7 @@ describe("getEnvApiKey", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("accepts models.dev GOOGLE_GENERATIVE_AI_API_KEY for google", () => {
|
||||
it("ignores GOOGLE_GENERATIVE_AI_API_KEY for google", () => {
|
||||
const savedGemini = process.env.GEMINI_API_KEY;
|
||||
const savedGoogleGenerative = process.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
||||
|
||||
|
|
@ -29,7 +30,7 @@ describe("getEnvApiKey", () => {
|
|||
process.env.GOOGLE_GENERATIVE_AI_API_KEY = "google-generative-key";
|
||||
|
||||
try {
|
||||
assert.equal(getEnvApiKey("google"), "google-generative-key");
|
||||
assert.equal(getEnvApiKey("google"), undefined);
|
||||
} finally {
|
||||
if (savedGemini === undefined) delete process.env.GEMINI_API_KEY;
|
||||
else process.env.GEMINI_API_KEY = savedGemini;
|
||||
|
|
|
|||
|
|
@ -73,6 +73,13 @@ function hasVertexAdcCredentials(): boolean {
|
|||
export function getEnvApiKey(provider: KnownProvider): string | undefined;
|
||||
export function getEnvApiKey(provider: string): string | undefined;
|
||||
export function getEnvApiKey(provider: any): string | undefined {
|
||||
// Forge routes Gemini-family models through google-gemini-cli, which owns
|
||||
// auth via Gemini CLI Core state. Intentionally ignore Google API-key env vars
|
||||
// here so ambient GEMINI_API_KEY values do not change provider selection.
|
||||
if (provider === "google" || provider === "google-gemini-cli") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Fall back to environment variables
|
||||
if (provider === "github-copilot") {
|
||||
return (
|
||||
|
|
@ -154,7 +161,6 @@ export function getEnvApiKey(provider: any): string | undefined {
|
|||
const envMap: Record<string, string | string[]> = {
|
||||
openai: "OPENAI_API_KEY",
|
||||
"azure-openai-responses": "AZURE_OPENAI_API_KEY",
|
||||
google: ["GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
|
||||
groq: "GROQ_API_KEY",
|
||||
cerebras: "CEREBRAS_API_KEY",
|
||||
xai: "XAI_API_KEY",
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ function hasVertexAdcCredentials(): boolean {
|
|||
export function getEnvApiKey(provider: KnownProvider): string | undefined;
|
||||
export function getEnvApiKey(provider: string): string | undefined;
|
||||
export function getEnvApiKey(provider: string): string | undefined {
|
||||
if (provider === "google" || provider === "google-gemini-cli") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (provider === "github-copilot") {
|
||||
return (
|
||||
process.env.COPILOT_GITHUB_TOKEN ||
|
||||
|
|
@ -89,7 +93,6 @@ export function getEnvApiKey(provider: string): string | undefined {
|
|||
const envMap: Record<string, string | string[]> = {
|
||||
openai: "OPENAI_API_KEY",
|
||||
"azure-openai-responses": "AZURE_OPENAI_API_KEY",
|
||||
google: ["GEMINI_API_KEY", "GOOGLE_GENERATIVE_AI_API_KEY"],
|
||||
groq: "GROQ_API_KEY",
|
||||
cerebras: "CEREBRAS_API_KEY",
|
||||
xai: "XAI_API_KEY",
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ const GOOGLE_API_KEY_PROVIDERS = new Set(["google"]);
|
|||
* Google's OAuth2 token endpoint and are not valid as AI Studio API keys.
|
||||
*
|
||||
* Users who installed Google's Gemini CLI may have these tokens and
|
||||
* mistakenly set them as GEMINI_API_KEY.
|
||||
* mistakenly expose them via environment variables.
|
||||
*/
|
||||
export function isGoogleOAuthToken(key: string): boolean {
|
||||
return key.startsWith("ya29.");
|
||||
|
|
@ -77,10 +77,8 @@ function validateNotGoogleOAuthToken(provider: string, key: string): void {
|
|||
`The provided key for "${provider}" appears to be a Google OAuth access token (ya29.*), ` +
|
||||
`not a valid API key. Google AI Studio requires an API key starting with "AIza...". ` +
|
||||
`\n\nIf you're using Google's Gemini CLI, its OAuth tokens are not compatible. ` +
|
||||
`Either:\n` +
|
||||
` 1. Get an API key from https://aistudio.google.com/apikey and set GEMINI_API_KEY\n` +
|
||||
` 2. Use the google-gemini-cli provider, which delegates OAuth handling ` +
|
||||
`to @google/gemini-cli-core`,
|
||||
`Use the google-gemini-cli provider, which delegates OAuth handling ` +
|
||||
`to @google/gemini-cli-core.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -984,7 +982,8 @@ export class AuthStorage {
|
|||
// All credentials backed off or unresolvable - fall through to env/fallback
|
||||
}
|
||||
|
||||
// Fall back to environment variable
|
||||
// Fall back to environment variable. Gemini-family providers intentionally
|
||||
// ignore ambient GEMINI_API_KEY values via getEnvApiKey().
|
||||
const envKey = getEnvApiKey(providerId);
|
||||
if (envKey) {
|
||||
// Block Google OAuth tokens from environment variables (e.g., GEMINI_API_KEY=ya29.*)
|
||||
|
|
@ -995,7 +994,7 @@ export class AuthStorage {
|
|||
this.recordError(
|
||||
new Error(
|
||||
`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 ` +
|
||||
`Authenticate with ` +
|
||||
`the google-gemini-cli provider, which delegates OAuth handling to @google/gemini-cli-core.`,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { getApiProvider } from "@singularity-forge/pi-ai";
|
|||
import { describe, it } from "vitest";
|
||||
import { AuthStorage, type AuthStorageData } from "./auth-storage.js";
|
||||
import { ModelRegistry } from "./model-registry.js";
|
||||
import { type Settings, SettingsManager } from "./settings-manager.js";
|
||||
|
||||
function createRegistry(
|
||||
hasAuthFn?: (provider: string) => boolean,
|
||||
|
|
@ -30,6 +31,17 @@ function createInMemoryRegistry(data: AuthStorageData = {}): ModelRegistry {
|
|||
return new ModelRegistry(AuthStorage.inMemory(data), undefined);
|
||||
}
|
||||
|
||||
function createRegistryWithSettings(
|
||||
settings: Partial<Settings>,
|
||||
data: AuthStorageData = {},
|
||||
): ModelRegistry {
|
||||
return new ModelRegistry(
|
||||
AuthStorage.inMemory(data),
|
||||
undefined,
|
||||
SettingsManager.inMemory(settings),
|
||||
);
|
||||
}
|
||||
|
||||
function createProviderModel(
|
||||
id: string,
|
||||
api?: string,
|
||||
|
|
@ -624,6 +636,35 @@ describe("ModelRegistry authMode — getApiKey", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("ModelRegistry availability — disabled config", () => {
|
||||
it("excludes disabled providers from available models", () => {
|
||||
const registry = createRegistryWithSettings(
|
||||
{ disabledProviders: ["anthropic"] },
|
||||
{ anthropic: { type: "api_key", key: "sk-ant" } },
|
||||
);
|
||||
|
||||
assert.equal(registry.isProviderRequestReady("anthropic"), false);
|
||||
assert.equal(
|
||||
findModel(registry, "anthropic", "claude-sonnet-4.5"),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("excludes disabled models while keeping the provider available", () => {
|
||||
const registry = createRegistryWithSettings(
|
||||
{ disabledModels: ["anthropic/claude-sonnet-4.5"] },
|
||||
{ anthropic: { type: "api_key", key: "sk-ant" } },
|
||||
);
|
||||
|
||||
assert.equal(registry.isProviderRequestReady("anthropic"), true);
|
||||
assert.equal(
|
||||
findModel(registry, "anthropic", "claude-sonnet-4.5"),
|
||||
undefined,
|
||||
);
|
||||
assert.ok(findModel(registry, "anthropic", "claude-3-7-sonnet-20250219"));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── streamSimple apiKey stripping ────────────────────────────────────────────
|
||||
|
||||
describe("ModelRegistry authMode — streamSimple apiKey boundary", () => {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import {
|
|||
getDiscoveryAdapter,
|
||||
} from "./model-discovery.js";
|
||||
import { resolveConfigValue, resolveHeaders } from "./resolve-config-value.js";
|
||||
import type { SettingsManager } from "./settings-manager.js";
|
||||
|
||||
const Ajv = (AjvModule as any).default || AjvModule;
|
||||
const ajv = new Ajv();
|
||||
|
|
@ -481,6 +482,7 @@ export class ModelRegistry {
|
|||
getAgentDir(),
|
||||
"models.json",
|
||||
),
|
||||
private readonly settingsManager?: SettingsManager,
|
||||
discoveryCache?: ModelDiscoveryCache,
|
||||
) {
|
||||
this.discoveryCache = discoveryCache ?? new ModelDiscoveryCache();
|
||||
|
|
@ -860,8 +862,9 @@ export class ModelRegistry {
|
|||
*/
|
||||
getAvailable(providerModelAllow?: ProviderModelAllowList): Model<Api>[] {
|
||||
return this.filterProviderModelAllow(
|
||||
this.getAllWithDiscovered().filter((m) =>
|
||||
this.isProviderRequestReady(m.provider),
|
||||
this.getAllWithDiscovered().filter(
|
||||
(m) =>
|
||||
this.isModelEnabled(m) && this.isProviderRequestReady(m.provider),
|
||||
),
|
||||
providerModelAllow,
|
||||
);
|
||||
|
|
@ -885,6 +888,7 @@ export class ModelRegistry {
|
|||
* Whether a provider can be used for requests/fallback without hard auth gating.
|
||||
*/
|
||||
isProviderRequestReady(provider: string): boolean {
|
||||
if (this.settingsManager?.isProviderDisabled(provider)) return false;
|
||||
const config = this.registeredProviders.get(provider);
|
||||
if (config?.isReady) return config.isReady();
|
||||
const authMode = this.getProviderAuthMode(provider);
|
||||
|
|
@ -897,10 +901,20 @@ export class ModelRegistry {
|
|||
*/
|
||||
find(provider: string, modelId: string): Model<Api> | undefined {
|
||||
return this.filterProviderModelAllow(
|
||||
this.models.filter((m) => m.provider === provider && m.id === modelId),
|
||||
this.models.filter(
|
||||
(m) =>
|
||||
m.provider === provider && m.id === modelId && this.isModelEnabled(m),
|
||||
),
|
||||
)[0];
|
||||
}
|
||||
|
||||
isModelEnabled(model: Pick<Model<Api>, "provider" | "id">): boolean {
|
||||
if (this.settingsManager?.isProviderDisabled(model.provider)) return false;
|
||||
if (this.settingsManager?.isModelDisabled(model.provider, model.id))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API key for a model.
|
||||
* Returns undefined for externalCli/none providers (no key needed).
|
||||
|
|
|
|||
|
|
@ -114,4 +114,28 @@ describe("SettingsManager — global-only security settings", () => {
|
|||
// Normal fields: project overrides global
|
||||
assert.equal(sm.getQuietStartup(), true);
|
||||
});
|
||||
|
||||
it("toggles disabled providers in scoped settings", () => {
|
||||
const sm = SettingsManager.inMemory();
|
||||
assert.equal(sm.isProviderDisabled("google-gemini-cli"), false);
|
||||
assert.equal(sm.toggleProviderDisabled("google-gemini-cli"), true);
|
||||
assert.equal(sm.isProviderDisabled("google-gemini-cli"), true);
|
||||
assert.equal(sm.toggleProviderDisabled("google-gemini-cli"), false);
|
||||
assert.equal(sm.isProviderDisabled("google-gemini-cli"), false);
|
||||
});
|
||||
|
||||
it("toggles disabled models in scoped settings", () => {
|
||||
const sm = SettingsManager.inMemory();
|
||||
assert.equal(sm.isModelDisabled("anthropic", "claude-sonnet-4.5"), false);
|
||||
assert.equal(
|
||||
sm.toggleModelDisabled("anthropic", "claude-sonnet-4.5"),
|
||||
true,
|
||||
);
|
||||
assert.equal(sm.isModelDisabled("anthropic", "claude-sonnet-4.5"), true);
|
||||
assert.equal(
|
||||
sm.toggleModelDisabled("anthropic", "claude-sonnet-4.5"),
|
||||
false,
|
||||
);
|
||||
assert.equal(sm.isModelDisabled("anthropic", "claude-sonnet-4.5"), false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -100,6 +100,13 @@ export interface ProxySettings {
|
|||
providerPriority?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export type ProviderEnvAuthMode = "auto" | "on" | "off";
|
||||
|
||||
export interface ProviderEnvAuthSettings {
|
||||
default?: ProviderEnvAuthMode;
|
||||
providers?: Record<string, ProviderEnvAuthMode>;
|
||||
}
|
||||
|
||||
export type TransportSetting = Transport;
|
||||
|
||||
/**
|
||||
|
|
@ -174,6 +181,9 @@ export interface Settings {
|
|||
allowedCommandPrefixes?: string[]; // Override built-in SAFE_COMMAND_PREFIXES for !command resolution (global-only — ignored in project settings)
|
||||
fetchAllowedUrls?: string[]; // Hostnames exempted from SSRF blocklist in fetch_page (global-only — ignored in project settings)
|
||||
proxy?: ProxySettings;
|
||||
disabledProviders?: string[]; // Provider IDs hidden from normal model availability and selection
|
||||
disabledModels?: string[]; // provider/model IDs hidden from normal model availability and selection
|
||||
providerEnvAuth?: ProviderEnvAuthSettings; // Per-provider policy for environment-based auth detection
|
||||
}
|
||||
|
||||
/** Settings keys that are only respected from global config — project settings cannot override these. */
|
||||
|
|
@ -939,6 +949,69 @@ export class SettingsManager {
|
|||
this.setGlobalSetting("quietStartup", quiet);
|
||||
}
|
||||
|
||||
getDisabledProviders(): string[] {
|
||||
return [...(this.settings.disabledProviders ?? [])];
|
||||
}
|
||||
|
||||
isProviderDisabled(provider: string): boolean {
|
||||
return this.getDisabledProviders().includes(provider);
|
||||
}
|
||||
|
||||
setDisabledProviders(providers: string[]): void {
|
||||
this.setScopedSetting("disabledProviders", [...new Set(providers)].sort());
|
||||
}
|
||||
|
||||
toggleProviderDisabled(provider: string): boolean {
|
||||
const next = new Set(this.getDisabledProviders());
|
||||
if (next.has(provider)) {
|
||||
next.delete(provider);
|
||||
this.setDisabledProviders([...next]);
|
||||
return false;
|
||||
}
|
||||
next.add(provider);
|
||||
this.setDisabledProviders([...next]);
|
||||
return true;
|
||||
}
|
||||
|
||||
getDisabledModels(): string[] {
|
||||
return [...(this.settings.disabledModels ?? [])];
|
||||
}
|
||||
|
||||
isModelDisabled(provider: string, modelId: string): boolean {
|
||||
return this.getDisabledModels().includes(`${provider}/${modelId}`);
|
||||
}
|
||||
|
||||
setDisabledModels(models: string[]): void {
|
||||
this.setScopedSetting("disabledModels", [...new Set(models)].sort());
|
||||
}
|
||||
|
||||
toggleModelDisabled(provider: string, modelId: string): boolean {
|
||||
const modelKey = `${provider}/${modelId}`;
|
||||
const next = new Set(this.getDisabledModels());
|
||||
if (next.has(modelKey)) {
|
||||
next.delete(modelKey);
|
||||
this.setDisabledModels([...next]);
|
||||
return false;
|
||||
}
|
||||
next.add(modelKey);
|
||||
this.setDisabledModels([...next]);
|
||||
return true;
|
||||
}
|
||||
|
||||
getProviderEnvAuthMode(provider: string): ProviderEnvAuthMode {
|
||||
return (
|
||||
this.settings.providerEnvAuth?.providers?.[provider] ??
|
||||
this.settings.providerEnvAuth?.default ??
|
||||
"auto"
|
||||
);
|
||||
}
|
||||
|
||||
setProviderEnvAuthMode(provider: string, mode: ProviderEnvAuthMode): void {
|
||||
const current = structuredClone(this.settings.providerEnvAuth ?? {});
|
||||
current.providers = { ...(current.providers ?? {}), [provider]: mode };
|
||||
this.setScopedSetting("providerEnvAuth", current);
|
||||
}
|
||||
|
||||
getShellCommandPrefix(): string | undefined {
|
||||
return this.settings.shellCommandPrefix;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -490,7 +490,11 @@ export async function main(args: string[]) {
|
|||
const settingsManager = SettingsManager.create(cwd, agentDir);
|
||||
reportSettingsErrors(settingsManager, "startup");
|
||||
const authStorage = AuthStorage.create();
|
||||
const modelRegistry = new ModelRegistry(authStorage, getModelsPath());
|
||||
const modelRegistry = new ModelRegistry(
|
||||
authStorage,
|
||||
getModelsPath(),
|
||||
settingsManager,
|
||||
);
|
||||
|
||||
// Offline mode validation / auto-detection
|
||||
if (offlineMode) {
|
||||
|
|
@ -740,7 +744,9 @@ export async function main(args: string[]) {
|
|||
if (!isInteractive && !session.model) {
|
||||
console.error(chalk.red("No models available."));
|
||||
console.error(chalk.yellow("\nSet an API key environment variable:"));
|
||||
console.error(" ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc.");
|
||||
console.error(
|
||||
" ANTHROPIC_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY, etc.",
|
||||
);
|
||||
console.error(chalk.yellow(`\nOr create ${getModelsPath()}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
|||
private modelRegistry: ModelRegistry;
|
||||
private onSelectCallback: (model: Model<any>) => void;
|
||||
private onCancelCallback: () => void;
|
||||
private onConfigChange?: () => void;
|
||||
private errorMessage?: string;
|
||||
private tui: TUI;
|
||||
private scopedModels: ReadonlyArray<ScopedModelItem>;
|
||||
|
|
@ -111,6 +112,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
|||
scopedModels: ReadonlyArray<ScopedModelItem>,
|
||||
onSelect: (model: Model<any>) => void,
|
||||
onCancel: () => void,
|
||||
onConfigChange?: () => void,
|
||||
initialSearchInput?: string,
|
||||
) {
|
||||
super();
|
||||
|
|
@ -128,6 +130,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
|||
this.scope = hasReadyScopedModel ? "scoped" : "all";
|
||||
this.onSelectCallback = onSelect;
|
||||
this.onCancelCallback = onCancel;
|
||||
this.onConfigChange = onConfigChange;
|
||||
|
||||
// Add top border
|
||||
this.addChild(new DynamicBorder());
|
||||
|
|
@ -141,7 +144,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
|||
this.addChild(this.scopeHintText);
|
||||
} else {
|
||||
const hintText =
|
||||
"Only showing models with configured API keys (see README for details)";
|
||||
"Browse all models; disabled or unready entries cannot be selected";
|
||||
this.addChild(new Text(theme.fg("warning", hintText), 0, 0));
|
||||
}
|
||||
this.addChild(new Spacer(1));
|
||||
|
|
@ -203,8 +206,8 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
|||
|
||||
// Load available models (built-in models still work even if models.json failed)
|
||||
try {
|
||||
const availableModels = this.modelRegistry.getAvailable();
|
||||
models = availableModels.map((model: Model<any>) => ({
|
||||
const allModels = this.modelRegistry.getAll();
|
||||
models = allModels.map((model: Model<any>) => ({
|
||||
provider: model.provider,
|
||||
id: model.id,
|
||||
model,
|
||||
|
|
@ -222,18 +225,12 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
|||
}
|
||||
|
||||
this.allModels = this.sortModelsWithinProvider(models);
|
||||
// Scoped models must also be filtered by provider readiness so users
|
||||
// can't pick a scoped model whose provider has no API key / OAuth.
|
||||
this.scopedModelItems = this.sortModelsWithinProvider(
|
||||
this.scopedModels
|
||||
.filter((scoped) =>
|
||||
this.modelRegistry.isProviderRequestReady(scoped.model.provider),
|
||||
)
|
||||
.map((scoped) => ({
|
||||
provider: scoped.model.provider,
|
||||
id: scoped.model.id,
|
||||
model: scoped.model,
|
||||
})),
|
||||
this.scopedModels.map((scoped) => ({
|
||||
provider: scoped.model.provider,
|
||||
id: scoped.model.id,
|
||||
model: scoped.model,
|
||||
})),
|
||||
);
|
||||
this.activeModels =
|
||||
this.scope === "scoped" ? this.scopedModelItems : this.allModels;
|
||||
|
|
@ -348,7 +345,11 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
|||
}
|
||||
|
||||
private getScopeHintText(): string {
|
||||
return keyHint("tab", "scope") + theme.fg("muted", " (all/scoped)");
|
||||
return (
|
||||
keyHint("tab", "scope") +
|
||||
theme.fg("muted", " (all/scoped) ") +
|
||||
keyHint("d", "disable")
|
||||
);
|
||||
}
|
||||
|
||||
private setScope(scope: ModelScope): void {
|
||||
|
|
@ -433,6 +434,8 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
|||
|
||||
const isSelected = i === this.selectedFlatIndex;
|
||||
const isCurrent = modelsAreEqual(this.currentModel, item.model);
|
||||
const statusBadge = this.modelStatusBadge(item.model);
|
||||
const selectable = this.isModelSelectable(item.model);
|
||||
|
||||
const ctx = formatTokenCount(item.model.contextWindow);
|
||||
const ctxBadge = theme.fg("muted", `${ctx}`);
|
||||
|
|
@ -445,9 +448,13 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
|||
let line: string;
|
||||
if (isSelected) {
|
||||
const prefix = theme.fg("accent", "→ ");
|
||||
line = `${prefix}${theme.fg("accent", item.id)} ${ctxBadge} ${providerBadge}${checkmark}`;
|
||||
const modelText = selectable
|
||||
? theme.fg("accent", item.id)
|
||||
: theme.fg("muted", item.id);
|
||||
line = `${prefix}${modelText} ${ctxBadge} ${providerBadge}${statusBadge}${checkmark}`;
|
||||
} else {
|
||||
line = ` ${item.id} ${ctxBadge} ${providerBadge}${checkmark}`;
|
||||
const modelText = selectable ? item.id : theme.fg("muted", item.id);
|
||||
line = ` ${modelText} ${ctxBadge} ${providerBadge}${statusBadge}${checkmark}`;
|
||||
}
|
||||
|
||||
this.listContainer.addChild(new Text(line, 0, 0));
|
||||
|
|
@ -511,21 +518,28 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
|||
if (row.kind === "header") {
|
||||
// Provider group header — always unselectable
|
||||
const providerLabel = theme.fg(
|
||||
"borderAccent",
|
||||
i === this.selectedGroupIndex ? "accent" : "borderAccent",
|
||||
providerDisplayName(row.provider),
|
||||
);
|
||||
const count = theme.fg("muted", ` (${row.count})`);
|
||||
const disabledBadge = this.settingsManager.isProviderDisabled(
|
||||
row.provider,
|
||||
)
|
||||
? theme.fg("warning", " [disabled]")
|
||||
: "";
|
||||
// Add blank line before header if not the very first visible row
|
||||
if (i > startIndex) {
|
||||
this.listContainer.addChild(new Text("", 0, 0));
|
||||
}
|
||||
this.listContainer.addChild(
|
||||
new Text(` ${providerLabel}${count}`, 0, 0),
|
||||
new Text(` ${providerLabel}${count}${disabledBadge}`, 0, 0),
|
||||
);
|
||||
} else {
|
||||
// Model row
|
||||
const isSelected = i === this.selectedGroupIndex;
|
||||
const isCurrent = modelsAreEqual(this.currentModel, row.item.model);
|
||||
const statusBadge = this.modelStatusBadge(row.item.model);
|
||||
const selectable = this.isModelSelectable(row.item.model);
|
||||
|
||||
const ctx = formatTokenCount(row.item.model.contextWindow);
|
||||
const ctxBadge = theme.fg("muted", ` ${ctx}`);
|
||||
|
|
@ -533,9 +547,15 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
|||
|
||||
let line: string;
|
||||
if (isSelected) {
|
||||
line = ` ${theme.fg("accent", "→")} ${theme.fg("accent", row.item.id)}${ctxBadge}${checkmark}`;
|
||||
const modelText = selectable
|
||||
? theme.fg("accent", row.item.id)
|
||||
: theme.fg("muted", row.item.id);
|
||||
line = ` ${theme.fg("accent", "→")} ${modelText}${ctxBadge}${statusBadge}${checkmark}`;
|
||||
} else {
|
||||
line = ` ${row.item.id}${ctxBadge}${checkmark}`;
|
||||
const modelText = selectable
|
||||
? row.item.id
|
||||
: theme.fg("muted", row.item.id);
|
||||
line = ` ${modelText}${ctxBadge}${statusBadge}${checkmark}`;
|
||||
}
|
||||
|
||||
this.listContainer.addChild(new Text(line, 0, 0));
|
||||
|
|
@ -571,6 +591,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
|||
m.name,
|
||||
`ctx: ${formatTokenCount(m.contextWindow)}`,
|
||||
`out: ${formatTokenCount(m.maxTokens)}`,
|
||||
this.modelStatusText(m),
|
||||
m.reasoning ? "thinking" : "",
|
||||
m.input.includes("image") ? "vision" : "",
|
||||
]
|
||||
|
|
@ -593,6 +614,11 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
|||
return;
|
||||
}
|
||||
|
||||
if (keyData === "d" || keyData === "D") {
|
||||
this.handleDisableToggle();
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigation keys
|
||||
if (kb.matches(keyData, "selectUp")) {
|
||||
this.moveUp();
|
||||
|
|
@ -659,14 +685,6 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
|||
let next = this.selectedGroupIndex - 1;
|
||||
// Wrap
|
||||
if (next < 0) next = this.groupedRows.length - 1;
|
||||
// Skip headers
|
||||
while (next > 0 && this.groupedRows[next]?.kind === "header") {
|
||||
next--;
|
||||
}
|
||||
// If landed on header at 0, wrap to bottom
|
||||
if (this.groupedRows[next]?.kind === "header") {
|
||||
next = this.groupedRows.length - 1;
|
||||
}
|
||||
this.selectedGroupIndex = next;
|
||||
this.updateList();
|
||||
}
|
||||
|
|
@ -687,27 +705,75 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
|||
let next = this.selectedGroupIndex + 1;
|
||||
// Wrap
|
||||
if (next >= this.groupedRows.length) next = 0;
|
||||
// Skip headers
|
||||
while (
|
||||
next < this.groupedRows.length - 1 &&
|
||||
this.groupedRows[next]?.kind === "header"
|
||||
) {
|
||||
next++;
|
||||
}
|
||||
// If landed on header at end, wrap to first model
|
||||
if (this.groupedRows[next]?.kind === "header") {
|
||||
next = this.modelRowIndices[0] ?? 0;
|
||||
}
|
||||
this.selectedGroupIndex = next;
|
||||
this.updateList();
|
||||
}
|
||||
|
||||
private handleSelect(model: Model<any>): void {
|
||||
if (!this.isModelSelectable(model)) return;
|
||||
// Save as new default
|
||||
this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
|
||||
this.onSelectCallback(model);
|
||||
}
|
||||
|
||||
private isModelSelectable(model: Model<any>): boolean {
|
||||
return (
|
||||
this.modelRegistry.isModelEnabled(model) &&
|
||||
this.modelRegistry.isProviderRequestReady(model.provider)
|
||||
);
|
||||
}
|
||||
|
||||
private modelStatusText(model: Model<any>): string {
|
||||
if (this.settingsManager.isProviderDisabled(model.provider)) {
|
||||
return "provider disabled";
|
||||
}
|
||||
if (this.settingsManager.isModelDisabled(model.provider, model.id)) {
|
||||
return "model disabled";
|
||||
}
|
||||
if (!this.modelRegistry.isProviderRequestReady(model.provider)) {
|
||||
return "auth unavailable";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private modelStatusBadge(model: Model<any>): string {
|
||||
const status = this.modelStatusText(model);
|
||||
return status ? theme.fg("warning", ` [${status}]`) : "";
|
||||
}
|
||||
|
||||
private handleDisableToggle(): void {
|
||||
if (this.isSearching) {
|
||||
const item = this.filteredModels[this.selectedFlatIndex];
|
||||
if (!item) return;
|
||||
this.settingsManager.toggleModelDisabled(item.provider, item.id);
|
||||
} else {
|
||||
const row = this.groupedRows[this.selectedGroupIndex];
|
||||
if (!row) return;
|
||||
if (row.kind === "header") {
|
||||
this.settingsManager.toggleProviderDisabled(row.provider);
|
||||
} else {
|
||||
this.settingsManager.toggleModelDisabled(
|
||||
row.item.provider,
|
||||
row.item.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.onConfigChange?.();
|
||||
this.modelRegistry.refresh();
|
||||
void this.loadModels().then(() => {
|
||||
if (this.isSearching) {
|
||||
this.filterModels(this.searchInput.getValue());
|
||||
} else {
|
||||
this.buildGroupedRows();
|
||||
if (this.selectedGroupIndex >= this.groupedRows.length) {
|
||||
this.selectedGroupIndex = Math.max(0, this.groupedRows.length - 1);
|
||||
}
|
||||
this.updateList();
|
||||
}
|
||||
this.tui.requestRender();
|
||||
});
|
||||
}
|
||||
|
||||
getSearchInput(): Input {
|
||||
return this.searchInput;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3491,6 +3491,10 @@ export class InteractiveMode {
|
|||
done();
|
||||
this.ui.requestRender();
|
||||
},
|
||||
() => {
|
||||
void this.updateAvailableProviderCount();
|
||||
this.footer.invalidate();
|
||||
},
|
||||
initialSearchInput,
|
||||
);
|
||||
return { component: selector, focus: selector };
|
||||
|
|
|
|||
12
src/cli.ts
12
src/cli.ts
|
|
@ -628,18 +628,22 @@ const authStorage = AuthStorage.create(authFilePath);
|
|||
markStartup("AuthStorage.create");
|
||||
loadStoredEnvKeys(authStorage);
|
||||
migratePiCredentials(authStorage);
|
||||
const settingsManager = SettingsManager.create(process.cwd(), agentDir);
|
||||
applySecurityOverrides(settingsManager);
|
||||
markStartup("SettingsManager.create");
|
||||
|
||||
// Resolve models.json path with fallback to ~/.pi/agent/models.json
|
||||
const { resolveModelsJsonPath } = await import("./models-resolver.js");
|
||||
const modelsJsonPath = resolveModelsJsonPath();
|
||||
|
||||
const modelRegistry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
const modelRegistry = new ModelRegistry(
|
||||
authStorage,
|
||||
modelsJsonPath,
|
||||
settingsManager,
|
||||
);
|
||||
markStartup("ModelRegistry");
|
||||
await warmDiscoveryBackedProviders(modelRegistry);
|
||||
markStartup("ModelRegistry.discovery");
|
||||
const settingsManager = SettingsManager.create(process.cwd(), agentDir);
|
||||
applySecurityOverrides(settingsManager);
|
||||
markStartup("SettingsManager.create");
|
||||
|
||||
// Run onboarding wizard on first launch (no LLM provider configured)
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import { getMilestoneSlices, getSliceTasks, isDbAvailable } from "./sf-db.js";
|
|||
import { formattedShortcutPair } from "./shortcut-defs.js";
|
||||
import { parseUnitId } from "./unit-id.js";
|
||||
import { readUokDiagnostics } from "./uok/diagnostic-synthesis.js";
|
||||
import { safeSetWidget } from "./widget-safe.js";
|
||||
import { logWarning } from "./workflow-logger.js";
|
||||
import { getCurrentBranch } from "./worktree.js";
|
||||
import { getActiveWorktreeName } from "./worktree-command.js";
|
||||
|
|
@ -602,7 +603,7 @@ export function updateProgressWidget(
|
|||
refreshLastCommit(accessors.getBasePath());
|
||||
// Cache the effective service tier at widget creation time (reads preferences)
|
||||
const effectiveServiceTier = getEffectiveServiceTier();
|
||||
ctx.ui.setWidget("sf-progress", (tui, theme) => {
|
||||
safeSetWidget(ctx, "sf-progress", (tui, theme) => {
|
||||
let cachedLines;
|
||||
let cachedWidth;
|
||||
let cachedRtkLabel;
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ import {
|
|||
reconcileDurableCompleteUnitRuntimeRecords,
|
||||
reconcileStaleCompleteSliceRecords,
|
||||
} from "./uok/unit-runtime.js";
|
||||
import { safeSetWidget } from "./widget-safe.js";
|
||||
import { logError, logWarning } from "./workflow-logger.js";
|
||||
|
||||
import {
|
||||
|
|
@ -1068,7 +1069,7 @@ export async function bootstrapAutoSession(
|
|||
ctx.ui.setFooter(hideFooter);
|
||||
// Hide sf-health during AUTO — sf-progress is the single source of truth
|
||||
// for last-commit / cost / health signal while auto is running.
|
||||
ctx.ui.setWidget("sf-health", undefined);
|
||||
safeSetWidget(ctx, "sf-health", undefined);
|
||||
const modeLabel = s.stepMode ? "Step-mode" : "Auto-mode";
|
||||
const pendingCount = (state.registry ?? []).filter(
|
||||
(m) => m.status !== "complete" && m.status !== "parked",
|
||||
|
|
|
|||
|
|
@ -178,6 +178,7 @@ import {
|
|||
recordUokKernelTermination,
|
||||
runAutoLoopWithUok,
|
||||
} from "./uok/kernel.js";
|
||||
import { safeSetWidget } from "./widget-safe.js";
|
||||
import { logWarning, setLogBasePath } from "./workflow-logger.js";
|
||||
import {
|
||||
autoCommitCurrentBranch,
|
||||
|
|
@ -722,7 +723,7 @@ function cleanupAfterLoopExit(ctx) {
|
|||
// visible so the user still has a resumable auto-mode signal on screen.
|
||||
if (!s.paused) {
|
||||
ctx.ui.setStatus("sf-auto", undefined);
|
||||
ctx.ui.setWidget("sf-progress", undefined);
|
||||
safeSetWidget(ctx, "sf-progress", undefined);
|
||||
ctx.ui.setFooter(undefined);
|
||||
initHealthWidget(ctx);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
nativeLastCommitEpoch,
|
||||
} from "./native-git-bridge.js";
|
||||
import { loadEffectiveSFPreferences } from "./preferences.js";
|
||||
import { safeSetWidget } from "./widget-safe.js";
|
||||
|
||||
// ── Data loader ────────────────────────────────────────────────────────────────
|
||||
function loadHealthWidgetData(basePath) {
|
||||
|
|
@ -104,12 +105,13 @@ export function initHealthWidget(ctx) {
|
|||
|
||||
// String-array fallback — used in RPC mode (factory is a no-op there).
|
||||
// The factory call below overwrites this when the host supports factories.
|
||||
ctx.ui.setWidget("sf-health", buildHealthLines(initialData), {
|
||||
safeSetWidget(ctx, "sf-health", buildHealthLines(initialData), {
|
||||
placement: "belowEditor",
|
||||
});
|
||||
|
||||
// Factory-based widget for TUI mode — replaces the string-array above.
|
||||
ctx.ui.setWidget(
|
||||
safeSetWidget(
|
||||
ctx,
|
||||
"sf-health",
|
||||
(_tui, _theme) => {
|
||||
let data = initialData;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
onNotificationStoreChange,
|
||||
} from "./notification-store.js";
|
||||
import { formattedShortcutPair } from "./shortcut-defs.js";
|
||||
import { safeSetWidget } from "./widget-safe.js";
|
||||
// ─── Pure rendering ─────────────────────────────────────────────────────
|
||||
/**
|
||||
* Build the notification widget UI lines. Returns empty array if no unread
|
||||
|
|
@ -31,12 +32,13 @@ export function initNotificationWidget(ctx) {
|
|||
|
||||
// String-array fallback for RPC mode.
|
||||
// The factory call below overwrites this when the host supports factories.
|
||||
ctx.ui.setWidget("sf-notifications", buildNotificationWidgetLines(), {
|
||||
safeSetWidget(ctx, "sf-notifications", buildNotificationWidgetLines(), {
|
||||
placement: "belowEditor",
|
||||
});
|
||||
|
||||
// Factory-based widget for TUI mode
|
||||
ctx.ui.setWidget(
|
||||
safeSetWidget(
|
||||
ctx,
|
||||
"sf-notifications",
|
||||
(_tui, _theme) => {
|
||||
let cachedLines;
|
||||
|
|
|
|||
36
src/resources/extensions/sf/tests/widget-safe.test.mjs
Normal file
36
src/resources/extensions/sf/tests/widget-safe.test.mjs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { safeSetWidget } from "../widget-safe.js";
|
||||
|
||||
describe("safeSetWidget", () => {
|
||||
it("safeSetWidget_when_ui_missing_returns_false", () => {
|
||||
expect(safeSetWidget({}, "sf-health", ["ready"])).toBe(false);
|
||||
expect(safeSetWidget({ ui: {} }, "sf-health", ["ready"])).toBe(false);
|
||||
});
|
||||
|
||||
it("safeSetWidget_when_setWidget_throws_returns_false", () => {
|
||||
const ctx = {
|
||||
ui: {
|
||||
setWidget() {
|
||||
throw new TypeError("host.setExtensionWidget is not a function");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(safeSetWidget(ctx, "sf-health", ["ready"])).toBe(false);
|
||||
});
|
||||
|
||||
it("safeSetWidget_when_setWidget_exists_calls_it_and_returns_true", () => {
|
||||
const setWidget = vi.fn();
|
||||
const ctx = { ui: { setWidget } };
|
||||
|
||||
expect(
|
||||
safeSetWidget(ctx, "sf-health", ["ready"], {
|
||||
placement: "belowEditor",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(setWidget).toHaveBeenCalledWith("sf-health", ["ready"], {
|
||||
placement: "belowEditor",
|
||||
});
|
||||
});
|
||||
});
|
||||
27
src/resources/extensions/sf/widget-safe.js
Normal file
27
src/resources/extensions/sf/widget-safe.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* widget-safe.js - tolerant widget registration for mixed SF runtimes.
|
||||
*
|
||||
* Purpose: keep bundled SF extensions loadable when the installed host is older
|
||||
* than the source extension and does not expose widget APIs yet.
|
||||
*
|
||||
* Consumer: ambient SF widgets that are useful in TUI hosts but optional in
|
||||
* headless, RPC, or stale extension hosts.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Set or clear a widget when the active host supports widgets.
|
||||
*
|
||||
* Purpose: prevent optional UI registration from aborting extension startup
|
||||
* when ctx.ui.setWidget is missing or throws in a partially upgraded runtime.
|
||||
*
|
||||
* Consumer: SF health, notification, and auto-progress widgets.
|
||||
*/
|
||||
export function safeSetWidget(ctx, key, content, options) {
|
||||
try {
|
||||
if (typeof ctx?.ui?.setWidget !== "function") return false;
|
||||
ctx.ui.setWidget(key, content, options);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue