Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b37bc677bd | ||
|
|
6767bef88e | ||
|
|
cd6b2ca034 | ||
|
|
4687f835cb | ||
|
|
1ab982167b | ||
|
|
1c1eeb18f7 | ||
|
|
93348269a8 | ||
|
|
a59c9fbbae |
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the docs Markdown files into a static GitHub Pages site.
|
||||
|
||||
This intentionally avoids themed site generators and template files. Each Markdown
|
||||
file is converted to a minimal standalone HTML page, and relative .md links are
|
||||
rewritten to the generated .html filenames.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path, PurePosixPath
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import markdown
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
DOCS_DIR = ROOT / "docs"
|
||||
SITE_DIR = ROOT / "site"
|
||||
|
||||
MARKDOWN_EXTENSIONS = ["fenced_code", "tables", "toc"]
|
||||
HREF_RE = re.compile(r'href="([^"]+)"')
|
||||
|
||||
|
||||
def output_path(source: Path) -> Path:
|
||||
if source.name == "README.md":
|
||||
return SITE_DIR / "index.html"
|
||||
return SITE_DIR / f"{source.stem}.html"
|
||||
|
||||
|
||||
def page_title(text: str, fallback: str) -> str:
|
||||
for line in text.splitlines():
|
||||
if line.startswith("# "):
|
||||
return line[2:].strip()
|
||||
return fallback
|
||||
|
||||
|
||||
def rewrite_markdown_links(rendered: str) -> str:
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
href = html.unescape(match.group(1))
|
||||
parts = urlsplit(href)
|
||||
if parts.scheme or parts.netloc or not parts.path.endswith(".md"):
|
||||
return match.group(0)
|
||||
|
||||
url_path = PurePosixPath(parts.path)
|
||||
if url_path.name == "README.md":
|
||||
new_path = str(url_path.with_name("index.html"))
|
||||
else:
|
||||
new_path = parts.path[:-3] + ".html"
|
||||
|
||||
new_href = urlunsplit(("", "", new_path, parts.query, parts.fragment))
|
||||
return f'href="{html.escape(new_href, quote=True)}"'
|
||||
|
||||
return HREF_RE.sub(replace, rendered)
|
||||
|
||||
|
||||
def render_page(source: Path) -> str:
|
||||
text = source.read_text(encoding="utf-8")
|
||||
title = page_title(text, "Cassady docs")
|
||||
body = markdown.markdown(
|
||||
text,
|
||||
extensions=MARKDOWN_EXTENSIONS,
|
||||
output_format="html5",
|
||||
)
|
||||
body = rewrite_markdown_links(body)
|
||||
|
||||
return "\n".join(
|
||||
[
|
||||
"<!doctype html>",
|
||||
'<html lang="en">',
|
||||
"<head>",
|
||||
' <meta charset="utf-8">',
|
||||
' <meta name="viewport" content="width=device-width, initial-scale=1">',
|
||||
f" <title>{html.escape(title)}</title>",
|
||||
"</head>",
|
||||
"<body>",
|
||||
body,
|
||||
"</body>",
|
||||
"</html>",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def copy_static_assets() -> None:
|
||||
for item in DOCS_DIR.iterdir():
|
||||
if item.suffix == ".md":
|
||||
continue
|
||||
destination = SITE_DIR / item.name
|
||||
if item.is_dir():
|
||||
shutil.copytree(item, destination)
|
||||
elif item.is_file():
|
||||
shutil.copy2(item, destination)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if SITE_DIR.exists():
|
||||
shutil.rmtree(SITE_DIR)
|
||||
SITE_DIR.mkdir(parents=True)
|
||||
|
||||
for source in sorted(DOCS_DIR.glob("*.md")):
|
||||
output_path(source).write_text(render_page(source), encoding="utf-8")
|
||||
|
||||
copy_static_assets()
|
||||
(SITE_DIR / ".nojekyll").write_text("", encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,67 @@
|
||||
name: Publish Markdown site
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- '.github/scripts/build-pages.py'
|
||||
- '.github/workflows/pages.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- '.github/scripts/build-pages.py'
|
||||
- '.github/workflows/pages.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: github-pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Markdown site
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Install Markdown converter
|
||||
run: python -m pip install Markdown
|
||||
|
||||
- name: Build site
|
||||
run: python .github/scripts/build-pages.py
|
||||
|
||||
- name: Configure Pages
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Upload Pages artifact
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: site
|
||||
|
||||
deploy:
|
||||
name: Deploy to GitHub Pages
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy Pages artifact
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -1,2 +1,3 @@
|
||||
/target/
|
||||
/dist/
|
||||
/site/
|
||||
|
||||
Generated
+1
-1
@@ -226,7 +226,7 @@ checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
|
||||
|
||||
[[package]]
|
||||
name = "cassady"
|
||||
version = "0.2.8"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cassady"
|
||||
version = "0.2.8"
|
||||
version = "0.3.0"
|
||||
edition = "2021"
|
||||
description = "Cassady/Cass minimal terminal coding agent"
|
||||
license = "MIT"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Cassady / Cass
|
||||
|
||||
Cassady (`cass`) is a terminal coding agent written in Rust. It runs an interactive chat in your project, can inspect files, apply exact edits, run shell commands when the active safety mode allows them, and persist sessions for later resume. Cassady currently talks to OpenAI-compatible providers.
|
||||
Cassady (`cass`) is a terminal coding agent written in Rust. It runs an interactive chat in your project, can inspect files, apply exact edits, run shell commands when the active safety mode allows them, and persist sessions for later resume. Cassady talks to OpenAI-compatible providers and a built-in ChatGPT Codex provider preset.
|
||||
|
||||
The project installs two equivalent commands, `cass` and `cassady`; examples use `cass`.
|
||||
|
||||
## Current scope and limitations
|
||||
|
||||
- Provider support is OpenAI-compatible chat/completions APIs only.
|
||||
- Provider support includes OpenAI-compatible chat/completions APIs plus the `ChatGPT Codex` preset for users already signed in to Codex.
|
||||
- The primary interface is an interactive terminal UI.
|
||||
- v0.2.6 adds an experimental Rust embedding API for headless sessions; it is useful for early integrations but not yet a stable long-term library contract.
|
||||
- Config and conversation state live under `~/.cass`.
|
||||
@@ -34,16 +34,17 @@ Start Cassady in a project directory:
|
||||
cass
|
||||
```
|
||||
|
||||
If Cassady cannot resolve a usable provider, model, or API key, it offers to run setup before opening a chat. You can also run setup explicitly:
|
||||
If Cassady cannot resolve a usable provider, model, or API key, it offers to run setup before opening a chat. You can also run setup or provider login explicitly:
|
||||
|
||||
```sh
|
||||
cass login
|
||||
cass setup
|
||||
cass check
|
||||
cass update --check
|
||||
cass
|
||||
```
|
||||
|
||||
The setup wizard lets you choose one or more OpenAI-compatible providers, enter the API key environment-variable name, discover models from `GET /models` when the key is available, or enter a model id manually.
|
||||
The setup wizard lets you choose one or more providers. OpenAI-compatible providers use an API key environment variable and can discover models from `GET /models` when the key is available. `ChatGPT Codex` uses your existing local Codex login (`~/.codex/auth.json`) and the Codex responses endpoint instead of a Cassady API-key environment variable.
|
||||
|
||||
Set your provider key in the shell where you run Cassady. For example, on macOS/Linux:
|
||||
|
||||
@@ -66,6 +67,8 @@ cass [--model MODEL] [--cwd PATH]
|
||||
cass --resume <chat-id>
|
||||
cass --resume
|
||||
cass check
|
||||
cass login
|
||||
cass logout
|
||||
cass setup
|
||||
cass update
|
||||
```
|
||||
@@ -75,6 +78,8 @@ cass update
|
||||
Common in-chat commands:
|
||||
|
||||
- `/branch` or `/restore`: open the branch/restore menu.
|
||||
- `/login`: configure or update provider login settings.
|
||||
- `/logout`: remove saved provider config and associated model entries.
|
||||
- `/model <model>`: switch to a model from `~/.cass/models.json`.
|
||||
- `/new`: create a new chat for the current directory.
|
||||
- `/resume <chat>`: resume a saved chat for the current directory.
|
||||
@@ -118,7 +123,7 @@ Cassady stores user-editable files in `~/.cass`:
|
||||
- `global.md`: optional global instructions added to new chat system prompts when they fit the active request; they cannot override access modes, tool denials, approvals, or workspace boundaries.
|
||||
- `docs/`: bundled documentation installed from the current binary.
|
||||
|
||||
API key references should usually be written as environment variables such as `"$OPENAI_API_KEY"`.
|
||||
API key references should usually be written as environment variables such as `"$OPENAI_API_KEY"`. The `ChatGPT Codex` provider is different: it stores no key in `~/.cass` and reads the bearer token from local Codex auth at check/request time.
|
||||
|
||||
Detailed bundled docs live in this repository under [`docs/`](docs/README.md) and are installed to `~/.cass/docs` at runtime.
|
||||
|
||||
|
||||
+66
@@ -1,5 +1,71 @@
|
||||
# Cassady (Cass) Roadmap
|
||||
|
||||
## v0.3.0 — ChatGPT Codex Provider
|
||||
|
||||
This release focuses on letting users who are already signed in to Codex with a ChatGPT subscription use that account from Cassady. `ChatGPT Codex` becomes a provider preset that calls the Codex responses endpoint and reads its bearer token from local Codex auth instead of an API-key environment variable. See `plans/V0_3_0_CHATGPT_CODEX_PROVIDER_PLAN.md`.
|
||||
|
||||
### Provider Setup
|
||||
|
||||
- [x] **Add a ChatGPT Codex provider preset.** Make `ChatGPT Codex` available from `cass login`, `/login`, and first-run setup as a distinct provider option.
|
||||
- Use provider id `chatgpt-codex` and endpoint `https://chatgpt.com/backend-api/codex/responses`.
|
||||
- Skip the normal API-key environment-variable prompt for this preset.
|
||||
- Prefer the model configured in local Codex config when available, with manual model entry as a fallback.
|
||||
|
||||
- [x] **Read local Codex auth safely.** Resolve the bearer token from `$CODEX_HOME/auth.json` or `~/.codex/auth.json` at check/request time.
|
||||
- Support the local Codex `tokens.access_token` shape without copying the token into `~/.cass`.
|
||||
- Give clear recovery steps when the user has not run `codex login`, signed in to the Codex app, or has an expired/missing token.
|
||||
|
||||
### Provider Runtime
|
||||
|
||||
- [x] **Add a ChatGPT Codex responses client.** Route `chatgpt-codex` providers to the exact Codex responses endpoint instead of the OpenAI-compatible `/chat/completions` path.
|
||||
- Translate Cassady messages, tools, tool calls, and tool outputs to the endpoint's expected responses format.
|
||||
- Stream assistant text, safe reasoning summaries when available, and function-call deltas back into the existing agent loop.
|
||||
|
||||
- [x] **Keep OpenAI-compatible providers unchanged.** Refactor provider dispatch only as much as needed to support the new provider kind.
|
||||
- Existing provider config, setup, model discovery, API-key env vars, `/model`, and `cass check` behavior should continue to work.
|
||||
- Avoid leaking ChatGPT access tokens in errors, logs, transcripts, or config files.
|
||||
|
||||
### Documentation and Validation
|
||||
|
||||
- [x] **Document ChatGPT Codex prerequisites and caveats.** Update README and bundled docs for setup, config examples, `cass check`, troubleshooting, and the distinction between ChatGPT subscription-backed access and API-key providers.
|
||||
- Make clear that Cassady uses an existing Codex login and does not implement its own browser login or token refresh flow in this release.
|
||||
- Note that the ChatGPT backend endpoint may change outside Cassady's control.
|
||||
|
||||
- [x] **Test Codex auth and provider behavior.** Cover Codex auth fixtures, config validation, setup catalog behavior, provider dispatch, streaming response parsing, tool calls, and secret redaction.
|
||||
- Verify `cargo fmt` and `cargo test --locked --all-targets` pass before handoff.
|
||||
|
||||
## v0.2.9 — Provider Login Management
|
||||
|
||||
This release focuses on making provider configuration available from both the shell and an active Cassady chat. Users can add or update OpenAI-compatible provider/model settings with `cass login` or `/login`, and remove saved providers and their associated models with `cass logout` or `/logout`. See `plans/V0_2_9_PROVIDER_LOGIN_MANAGEMENT_PLAN.md`.
|
||||
|
||||
### Login Commands
|
||||
|
||||
- [x] **Add login commands for provider setup.** Make `cass login` and `/login` open the provider setup flow so users can configure providers without remembering that setup is the underlying implementation.
|
||||
- Reuse the existing provider catalog, model discovery, model capability prompts, and safe JSON writes.
|
||||
- Keep `/login` idle-only and reload active provider/model config after the menu closes.
|
||||
|
||||
- [x] **Keep existing setup behavior intact.** Preserve `cass setup` and first-run setup while making login language feel natural for account/provider management.
|
||||
- Direct `cass login` should save configuration and exit rather than unexpectedly starting a chat.
|
||||
- Existing setup validation and missing API key guidance should continue to apply.
|
||||
|
||||
### Logout Commands
|
||||
|
||||
- [x] **Add a safe provider removal menu.** Make `cass logout` and `/logout` let users choose saved providers to remove from `providers.json`.
|
||||
- Remove associated `models.json` entries for selected providers.
|
||||
- Confirm the removal before writing changes.
|
||||
|
||||
- [x] **Repair active defaults after removal.** Ensure `config.json` never points at a provider/model that was just removed.
|
||||
- Select a valid remaining provider/model when possible.
|
||||
- Clear active provider/model defaults when no providers remain so the next startup offers login/setup.
|
||||
|
||||
### Documentation and Validation
|
||||
|
||||
- [x] **Document provider login and logout workflows.** Update command, configuration, and workflow docs with the new shell and in-chat commands.
|
||||
- Clarify that logout removes Cassady provider config, not environment variables or external provider accounts.
|
||||
|
||||
- [x] **Test provider management behavior.** Cover provider/model removal, active default repair, local command parsing, and autocomplete.
|
||||
- Verify `cargo fmt` and `cargo test --locked --all-targets` pass before handoff.
|
||||
|
||||
## v0.2.8 — Conversation Branch and Restore
|
||||
|
||||
This release focuses on making conversation recovery safe and explorable. Pressing `Esc` twice while idle opens a branch/restore menu where users can browse prior user messages, assistant messages, and tool calls, create a new branch from a selected checkpoint, and optionally restore Cassady-tracked file edits without destroying the original conversation. See `plans/V0_2_8_CONVERSATION_BRANCH_RESTORE_PLAN.md`.
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ Cassady tools may list, search, and read this directory. Mutating tools are bloc
|
||||
|
||||
- [Commands](commands.md): CLI forms, global flags, `cass update`, in-chat commands, and keys.
|
||||
- [Configuration](configuration.md): `~/.cass` files, setup, precedence, schema examples, and validation.
|
||||
- [Providers and models](providers.md): built-in OpenAI-compatible providers, custom endpoints, model discovery, and reasoning metadata.
|
||||
- [Providers and models](providers.md): built-in provider presets, custom OpenAI-compatible endpoints, ChatGPT Codex auth, model discovery, and reasoning metadata.
|
||||
- [Access modes and tool safety](access-modes.md): what tools can read, write, edit, and run in each mode.
|
||||
- [Experimental Rust embedding API](embedding.md): import Cassady from Rust, start headless sessions, stream events, and handle approvals.
|
||||
- [Workflows](workflows.md): common ways to inspect code, apply edits, run checks, switch models, and resume chats.
|
||||
|
||||
+21
-5
@@ -8,12 +8,14 @@ Cassady installs two equivalent binaries: `cass` and `cassady`. This page uses `
|
||||
cass [OPTIONS]
|
||||
cassady [OPTIONS]
|
||||
cass check [OPTIONS]
|
||||
cass login [OPTIONS]
|
||||
cass logout [OPTIONS]
|
||||
cass setup [OPTIONS]
|
||||
cass update [OPTIONS]
|
||||
cass --resume [CHAT_ID]
|
||||
```
|
||||
|
||||
Run `cass --help`, `cass check --help`, `cass setup --help`, or `cass update --help` for the help generated by the current binary.
|
||||
Run `cass --help`, `cass check --help`, `cass login --help`, `cass logout --help`, `cass setup --help`, or `cass update --help` for the help generated by the current binary.
|
||||
|
||||
## Startup behavior
|
||||
|
||||
@@ -33,7 +35,7 @@ Resume this chat with: cass --resume <id>
|
||||
|
||||
- `--resume [CHAT_ID]`: resume a chat, or list chats for the current cwd when no id is provided.
|
||||
- `--model MODEL`: use `MODEL` for this session.
|
||||
- `--base-url URL`: override the active provider's OpenAI-compatible base URL for this session.
|
||||
- `--base-url URL`: override the active provider's base URL or endpoint for this session.
|
||||
- `--api-key-env ENV`: read the API key from environment variable `ENV` for this session.
|
||||
- `--cwd PATH`: use `PATH` as the launch cwd and workspace root.
|
||||
- `--readonly`: force read-only mode.
|
||||
@@ -54,13 +56,25 @@ Validates Cassady configuration under `~/.cass`:
|
||||
- duplicate provider/model ids.
|
||||
- model/provider references.
|
||||
- active provider and model resolution.
|
||||
- active API key availability.
|
||||
- active authentication availability.
|
||||
|
||||
Missing API keys for inactive providers are warnings. A missing active API key is an error and `cass check` exits with a non-zero status.
|
||||
Missing API keys for inactive OpenAI-compatible providers are warnings. A missing active API key is an error. For `ChatGPT Codex`, missing or expired local Codex auth is an error when it is active. `cass check` exits with a non-zero status when errors are present.
|
||||
|
||||
### `cass login`
|
||||
|
||||
Runs the provider login/configuration wizard. This is the same provider setup flow used by `cass setup`, framed for adding or updating saved provider access. It can configure multiple providers, discover or manually enter models, update active defaults, and validate the saved files.
|
||||
|
||||
`cass login` edits Cassady files under `~/.cass`; it does not create provider accounts. For `ChatGPT Codex`, sign in with `codex login` or the Codex app first; Cassady then uses local Codex auth instead of storing an API key.
|
||||
|
||||
### `cass logout`
|
||||
|
||||
Opens an interactive menu for removing saved providers from Cassady config. Removing a provider also removes its associated `models.json` entries. If the active provider is removed, Cassady chooses a remaining provider/model when possible. If no providers remain, active defaults are cleared and the next chat startup will offer setup/login again.
|
||||
|
||||
`cass logout` does not delete environment variables, API keys stored elsewhere, or external provider accounts.
|
||||
|
||||
### `cass setup`
|
||||
|
||||
Runs the interactive setup wizard in a terminal. It configures OpenAI-compatible providers, API key environment-variable references, and first models. It updates `config.json`, `providers.json`, and `models.json` while preserving unrelated entries where possible.
|
||||
Runs the interactive setup wizard in a terminal. It configures providers, authentication sources, and first models. OpenAI-compatible providers use API key environment-variable references; `ChatGPT Codex` uses local Codex auth. It updates `config.json`, `providers.json`, and `models.json` while preserving unrelated entries where possible.
|
||||
|
||||
### `cass update`
|
||||
|
||||
@@ -93,6 +107,8 @@ The updater does not invoke `sudo` or administrator prompts. If the install dire
|
||||
Type `/` to open command autocomplete.
|
||||
|
||||
- `/branch` or `/restore`: open the branch/restore menu for the current conversation family.
|
||||
- `/login`: configure or update provider login settings, then reload active provider/model config.
|
||||
- `/logout`: remove saved providers and their associated models, then reload active provider/model config when any remain.
|
||||
- `/model <model>`: switch the model for future turns. Autocomplete lists models from `~/.cass/models.json`.
|
||||
- `/new`: create a new chat for the current directory.
|
||||
- `/resume <chat>`: resume a saved chat from the current directory. Autocomplete lists matching chats.
|
||||
|
||||
+33
-9
@@ -19,13 +19,17 @@ Run:
|
||||
cass setup
|
||||
```
|
||||
|
||||
Cassady also offers setup automatically when `cass` cannot resolve a usable active provider, model, or API key before starting a chat.
|
||||
Cassady also offers setup automatically when `cass` cannot resolve a usable active provider, model, or authentication source before starting a chat.
|
||||
|
||||
For everyday provider management, `cass login` opens the same provider configuration flow with login-oriented wording. Inside an idle chat, `/login` temporarily opens that flow and reloads the active provider/model after it closes.
|
||||
|
||||
To remove saved provider configuration, run `cass logout` or type `/logout` while idle. Logout removes selected providers from `providers.json` and removes their associated entries from `models.json`. It does not remove environment variables, local shell profile exports, or external provider accounts.
|
||||
|
||||
The wizard uses keyboard prompts: `↑`/`↓` moves through choices, `Space` selects providers in the multi-select screen, and `Enter` submits. Text fields use the same prompt style instead of falling back to plain line input.
|
||||
|
||||
The wizard supports configuring multiple OpenAI-compatible providers at once. If more than one provider is configured, setup asks which one should be active first. If the selected API key environment variable is set, Cassady tries to fetch models from `GET {base_url}/models` and lets you choose one. If discovery fails, it offers a retry before falling back to manual model entry. If the API key is not set, setup asks for a model id manually.
|
||||
The wizard supports configuring multiple providers at once. If more than one provider is configured, setup asks which one should be active first. For OpenAI-compatible providers, if the selected API key environment variable is set, Cassady tries to fetch models from `GET {base_url}/models` and lets you choose one. If discovery fails, it offers a retry before falling back to manual model entry. If the API key is not set, setup asks for a model id manually. For `ChatGPT Codex`, setup skips API-key prompts and reads model defaults from local Codex config when available.
|
||||
|
||||
Setup stores API keys as environment-variable references such as `"$OPENAI_API_KEY"` by default. After setup, Cassady writes/updates `config.json`, `providers.json`, and `models.json`, validates them, and starts a chat only when the active API key is available in the current shell.
|
||||
Setup stores API keys as environment-variable references such as `"$OPENAI_API_KEY"` by default for OpenAI-compatible providers. `ChatGPT Codex` stores no API key in `~/.cass`; it reads local Codex auth at check/request time. After setup, Cassady writes/updates `config.json`, `providers.json`, and `models.json`, validates them, and starts a chat only when the active authentication source is available.
|
||||
|
||||
## `config.json`
|
||||
|
||||
@@ -85,14 +89,33 @@ Fields:
|
||||
|
||||
- `id`: required unique provider id.
|
||||
- `name`: optional display name.
|
||||
- `kind`: required provider kind. Currently only `"openai-compatible"` is supported.
|
||||
- `base_url`: required OpenAI-compatible API base URL.
|
||||
- `api_key`: required string. Use either a literal key or an environment-variable reference like `"$OPENAI_API_KEY"`.
|
||||
- `kind`: required provider kind. Supported values are `"openai-compatible"` and `"chatgpt-codex"`.
|
||||
- `base_url`: required API base URL or endpoint. `chatgpt-codex` uses `https://chatgpt.com/backend-api/codex/responses`.
|
||||
- `api_key`: required for `openai-compatible` providers. Use either a literal key or an environment-variable reference like `"$OPENAI_API_KEY"`. Omit it for `chatgpt-codex`; that provider reads local Codex auth instead.
|
||||
- `default_model`: optional model id used when no default model is configured.
|
||||
- `models`: optional list of model ids associated with this provider.
|
||||
|
||||
Only strings that start with `$` are resolved as environment variables. Cassady does not expand partial strings or `${NAME}` syntax.
|
||||
|
||||
`ChatGPT Codex` example:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"id": "chatgpt-codex",
|
||||
"name": "ChatGPT Codex",
|
||||
"kind": "chatgpt-codex",
|
||||
"base_url": "https://chatgpt.com/backend-api/codex/responses",
|
||||
"default_model": "gpt-5.5",
|
||||
"models": ["gpt-5.5"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Run `codex login` or sign in with the Codex app before using this provider. Cassady reads `$CODEX_HOME/auth.json` or `~/.codex/auth.json` and does not store the Codex access token in `~/.cass`.
|
||||
|
||||
## `models.json`
|
||||
|
||||
Example:
|
||||
@@ -154,7 +177,7 @@ Run:
|
||||
cass check
|
||||
```
|
||||
|
||||
This validates JSON syntax, expected schema, duplicate provider/model ids, model/provider references, active provider/model resolution, and API key environment-variable availability. Missing API keys for inactive providers are warnings; a missing active provider API key is an error.
|
||||
This validates JSON syntax, expected schema, duplicate provider/model ids, model/provider references, active provider/model resolution, and authentication availability. Missing API keys for inactive OpenAI-compatible providers are warnings; a missing active provider API key is an error. For `ChatGPT Codex`, missing or expired local Codex auth is an active-provider error.
|
||||
|
||||
When setup is incomplete, `cass check` prints actionable next steps such as:
|
||||
|
||||
@@ -168,7 +191,8 @@ cass
|
||||
|
||||
1. Edit one file at a time.
|
||||
2. Keep provider ids and model provider references in sync.
|
||||
3. Prefer API key env references over literal keys.
|
||||
4. Run `cass check` before starting a chat.
|
||||
3. Prefer API key env references over literal keys for OpenAI-compatible providers; do not paste Codex tokens into Cassady config.
|
||||
4. Prefer `cass login` and `cass logout` for routine provider changes.
|
||||
5. Run `cass check` before starting a chat.
|
||||
|
||||
Invalid JSON, unknown fields, duplicate ids, and missing provider/model links are reported by `cass check` with the file that failed.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
+21
-6
@@ -1,14 +1,15 @@
|
||||
# Providers and models
|
||||
|
||||
Cassady currently supports OpenAI-compatible providers. A provider supplies the base URL and API key; a model entry supplies metadata for one model id used with that provider.
|
||||
Cassady supports OpenAI-compatible providers plus a built-in `ChatGPT Codex` provider preset. A provider supplies the endpoint and authentication source; a model entry supplies metadata for one model id used with that provider.
|
||||
|
||||
## Built-in setup catalog
|
||||
|
||||
The setup wizard offers these provider templates:
|
||||
|
||||
| Provider | Provider id | Base URL | Suggested API key env var |
|
||||
| Provider | Provider id | Base URL / endpoint | Suggested auth source |
|
||||
| --- | --- | --- | --- |
|
||||
| OpenAI | `openai` | `https://api.openai.com/v1` | `OPENAI_API_KEY` |
|
||||
| ChatGPT Codex | `chatgpt-codex` | `https://chatgpt.com/backend-api/codex/responses` | local Codex auth |
|
||||
| xAI | `xai` | `https://api.x.ai/v1` | `XAI_API_KEY` |
|
||||
| Fireworks | `fireworks` | `https://api.fireworks.ai/inference/v1` | `FIREWORKS_API_KEY` |
|
||||
| Groq | `groq` | `https://api.groq.com/openai/v1` | `GROQ_API_KEY` |
|
||||
@@ -21,9 +22,23 @@ The setup wizard offers these provider templates:
|
||||
|
||||
There is also a custom OpenAI-compatible option. Custom setup asks for provider name, provider id, base URL, API key environment variable, and first model id.
|
||||
|
||||
## Login and logout
|
||||
|
||||
Use `cass login` to add or update provider configuration from the shell. Inside an idle chat, `/login` opens the same flow and reloads the active provider/model afterward.
|
||||
|
||||
Use `cass logout` or `/logout` to remove saved provider entries from Cassady config. Logout also removes model metadata entries associated with the removed providers and repairs active defaults when other providers remain. It does not delete environment variables, shell profile exports, API keys stored elsewhere, or external provider accounts.
|
||||
|
||||
## ChatGPT Codex preset
|
||||
|
||||
`ChatGPT Codex` is for users who have already signed in with Codex. Run `codex login` or sign in with the Codex app first, then select `ChatGPT Codex` in `cass login` or `cass setup`.
|
||||
|
||||
Cassady reads the bearer token from `$CODEX_HOME/auth.json` or `~/.codex/auth.json` at check/request time. It does not copy the access token or refresh token into `~/.cass`, and `cass check` redacts secret values. Setup prefers the `model` value from `$CODEX_HOME/config.toml` when present and otherwise offers a default/manual model id.
|
||||
|
||||
This preset uses `kind: "chatgpt-codex"` and posts to `https://chatgpt.com/backend-api/codex/responses`. That ChatGPT backend endpoint and Codex auth file format are outside Cassady's control, so users may need to update Cassady if they change.
|
||||
|
||||
## Model discovery
|
||||
|
||||
When the selected API key environment variable is available, setup tries:
|
||||
For OpenAI-compatible providers, when the selected API key environment variable is available, setup tries:
|
||||
|
||||
```text
|
||||
GET {base_url}/models
|
||||
@@ -40,7 +55,7 @@ A custom provider should expose OpenAI-compatible chat completions behavior at t
|
||||
- optional reasoning fields or reasoning request controls;
|
||||
- optional `/models` discovery during setup.
|
||||
|
||||
Provider protocols that are not OpenAI-compatible are not currently supported.
|
||||
Provider protocols that are not OpenAI-compatible are supported only when Cassady has an explicit provider kind for them, such as `chatgpt-codex`.
|
||||
|
||||
## Provider vs model metadata
|
||||
|
||||
@@ -48,7 +63,7 @@ Provider protocols that are not OpenAI-compatible are not currently supported.
|
||||
|
||||
- provider id;
|
||||
- base URL;
|
||||
- API key reference;
|
||||
- API key reference or local auth source;
|
||||
- optional default model;
|
||||
- optional list of associated model ids.
|
||||
|
||||
@@ -99,4 +114,4 @@ Run:
|
||||
cass check
|
||||
```
|
||||
|
||||
This confirms that the active provider and model resolve and that the active API key environment variable is set. Missing inactive-provider keys are warnings; missing active-provider keys are errors.
|
||||
This confirms that the active provider and model resolve. For OpenAI-compatible providers it checks API key environment variables; missing inactive-provider keys are warnings and missing active-provider keys are errors. For `ChatGPT Codex`, it checks that local Codex auth contains an access token and prints recovery steps if not.
|
||||
|
||||
+22
-5
@@ -1,6 +1,6 @@
|
||||
# Troubleshooting
|
||||
|
||||
Use `cass check` first for configuration problems. It validates files, provider/model references, and API key availability.
|
||||
Use `cass check` first for configuration problems. It validates files, provider/model references, and authentication availability.
|
||||
|
||||
## Missing active API key
|
||||
|
||||
@@ -24,6 +24,22 @@ cass check
|
||||
cass
|
||||
```
|
||||
|
||||
## Missing or expired ChatGPT Codex auth
|
||||
|
||||
Symptom: `cass check` reports `Codex auth` errors, or chat startup says provider authentication is not available for `chatgpt-codex`.
|
||||
|
||||
Likely cause: `ChatGPT Codex` is active but `$CODEX_HOME/auth.json` or `~/.codex/auth.json` is missing, unreadable, lacks `tokens.access_token`, or contains an expired token.
|
||||
|
||||
Fix:
|
||||
|
||||
```sh
|
||||
codex login
|
||||
cass check
|
||||
cass
|
||||
```
|
||||
|
||||
You can also sign in with the Codex app if that is how your local Codex auth is managed. Cassady does not refresh or store ChatGPT/Codex tokens; it reads local Codex auth at check/request time and redacts secret values.
|
||||
|
||||
## Invalid API key reference
|
||||
|
||||
Symptom: the key is not resolved the way you expect.
|
||||
@@ -45,9 +61,10 @@ Likely causes:
|
||||
- wrong `base_url`;
|
||||
- network or proxy problem;
|
||||
- provider outage;
|
||||
- provider requires a different OpenAI-compatible path.
|
||||
- provider requires a different OpenAI-compatible path;
|
||||
- for `ChatGPT Codex`, the private ChatGPT backend endpoint changed or the selected model is unavailable.
|
||||
|
||||
Fix: verify the base URL in `providers.json`, retry setup, or enter the model id manually if only `/models` discovery is failing.
|
||||
Fix: verify the base URL in `providers.json`, retry setup, or enter the model id manually if only `/models` discovery is failing. For `ChatGPT Codex`, verify that `base_url` is `https://chatgpt.com/backend-api/codex/responses`, rerun `codex login`, and try a current Codex model id.
|
||||
|
||||
## `/models` discovery fails
|
||||
|
||||
@@ -75,9 +92,9 @@ Then verify with a small prompt.
|
||||
|
||||
Symptom: the assistant says the provider returned an error.
|
||||
|
||||
Likely cause: provider-side authentication, quota, billing, or rate limit.
|
||||
Likely cause: provider-side authentication, quota, billing, or rate limit. For `ChatGPT Codex`, this can also mean your ChatGPT subscription/account does not have the requested Codex model available or the local Codex token needs to be refreshed by Codex.
|
||||
|
||||
Fix: confirm the API key, provider account status, selected model, and provider dashboard. Cassady forwards provider failures into the chat but cannot resolve account-level issues.
|
||||
Fix: confirm the API key, provider account status, selected model, and provider dashboard. For `ChatGPT Codex`, rerun `codex login` or open Codex to refresh local auth. Cassady forwards provider failures into the chat but cannot resolve account-level issues.
|
||||
|
||||
## Invalid JSON or unknown config fields
|
||||
|
||||
|
||||
+31
-1
@@ -57,6 +57,36 @@ Run the smallest relevant Rust test for this change, then summarize the result.
|
||||
|
||||
When the approval prompt appears, press `y` to approve or `n`/`Esc` to deny. Shell commands run with `sh -c` from the launch cwd and default to a 30-second timeout unless the model requests another timeout.
|
||||
|
||||
## Manage provider login
|
||||
|
||||
Add or update provider configuration from the shell:
|
||||
|
||||
```sh
|
||||
cass login
|
||||
```
|
||||
|
||||
Inside an idle chat:
|
||||
|
||||
```text
|
||||
/login
|
||||
```
|
||||
|
||||
Remove saved provider configuration:
|
||||
|
||||
```sh
|
||||
cass logout
|
||||
```
|
||||
|
||||
Inside an idle chat:
|
||||
|
||||
```text
|
||||
/logout
|
||||
```
|
||||
|
||||
Logout removes selected providers from Cassady's config and removes their associated model entries. It does not delete environment variables, local Codex auth, or external provider accounts.
|
||||
|
||||
For `ChatGPT Codex`, run `codex login` or sign in with the Codex app before `cass login`. Cassady validates `~/.codex/auth.json` and uses that local token source instead of asking for an API-key environment variable.
|
||||
|
||||
## Switch model
|
||||
|
||||
Inside a chat:
|
||||
@@ -138,4 +168,4 @@ Config files live under `~/.cass`, outside a normal project workspace. To inspec
|
||||
cass check
|
||||
```
|
||||
|
||||
Prefer `cass setup` for provider/model changes when possible.
|
||||
For OpenAI-compatible providers this checks API key environment variables. For `ChatGPT Codex` this checks local Codex auth and points you back to `codex login` if the token is missing or expired. Prefer `cass setup` or `cass login` for provider/model changes when possible.
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# v0.2.9 Provider Login Management Implementation Plan
|
||||
|
||||
## Goal
|
||||
|
||||
This release focuses on making provider configuration available from both the shell and an active Cassady chat. Users should be able to run `cass login` or type `/login` to configure one or more OpenAI-compatible providers, and use `cass logout` or `/logout` to remove saved providers and their model entries without hand-editing JSON files.
|
||||
|
||||
Success statement:
|
||||
|
||||
> A user can add, switch, and remove provider/model configuration from Cassady's normal command surfaces, then continue chatting with a valid active provider.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Add `cass login` as an alias-style command for the existing setup wizard.
|
||||
- Add `/login` inside the TUI, available only while idle.
|
||||
- Add `cass logout` with an interactive provider removal menu.
|
||||
- Add `/logout` inside the TUI, available only while idle.
|
||||
- Remove provider definitions and their associated `models.json` entries together.
|
||||
- Update `config.json` active defaults after removal so they do not point at missing providers or models.
|
||||
- Reload active config after login/logout inside the TUI.
|
||||
- Document the new commands in bundled command/config docs.
|
||||
- Add focused unit tests for provider removal and local command parsing/autocomplete.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Browser OAuth or provider-hosted account login flows.
|
||||
- Storing literal API keys from the wizard by default.
|
||||
- Non-OpenAI-compatible provider protocols.
|
||||
- Deleting shell environment variables or secrets outside `~/.cass`.
|
||||
- Publishing, tagging, or preparing release artifacts.
|
||||
|
||||
## Context
|
||||
|
||||
Cassady already has most provider setup primitives:
|
||||
|
||||
- `src/setup.rs` contains the interactive provider/model setup wizard, provider catalog, model discovery, and JSON upsert helpers.
|
||||
- `src/config.rs` owns `config.json`, `providers.json`, `models.json`, active provider/model resolution, and validation.
|
||||
- `src/app.rs` owns top-level CLI dispatch plus in-chat slash command parsing and execution.
|
||||
- `docs/commands.md`, `docs/configuration.md`, and `docs/workflows.md` document the existing `cass setup`, `cass check`, and `/model` behavior.
|
||||
|
||||
The existing setup wizard writes provider connection definitions to `providers.json`, model metadata to `models.json`, and active defaults to `config.json`. The new login command can reuse that flow. Logout needs a new inverse operation that edits all three files consistently.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. Reuse setup behavior instead of creating a second provider configuration path.
|
||||
2. Keep removal explicit and reversible by avoiding broad file deletion and by preserving unrelated providers/models.
|
||||
3. Never remove API keys from the user's shell or keychain; Cassady only edits its own config files.
|
||||
4. Keep in-chat provider management idle-only, because active turns depend on a stable provider config.
|
||||
|
||||
## Design
|
||||
|
||||
### CLI commands
|
||||
|
||||
Add these subcommands:
|
||||
|
||||
```text
|
||||
cass login
|
||||
cass logout
|
||||
```
|
||||
|
||||
`cass login` runs the same interactive wizard as `cass setup`, with text that frames the action as adding or updating provider login configuration. It may start a chat afterward when invoked from an otherwise normal chat startup path only if the existing setup outcome says it should; direct `cass login` should save config and exit.
|
||||
|
||||
`cass logout` opens a multi-select menu of saved providers:
|
||||
|
||||
```text
|
||||
Remove saved providers
|
||||
|
||||
[ ] OpenAI openai · gpt-4.1
|
||||
[ ] Groq groq · llama-3.3-70b-versatile
|
||||
```
|
||||
|
||||
After confirmation, Cassady removes the selected providers from `providers.json` and removes `models.json` entries whose `provider` matches a removed provider id. If the active provider was removed, Cassady selects the first remaining provider and one of its models. If no providers remain, `default_provider`, `default_model`, and `default_reasoning_effort` are cleared so the next `cass` run offers setup.
|
||||
|
||||
### In-chat commands
|
||||
|
||||
Add slash commands:
|
||||
|
||||
```text
|
||||
/login
|
||||
/logout
|
||||
```
|
||||
|
||||
Both commands are idle-only. Because the TUI uses the alternate screen and raw input mode, command execution should temporarily leave the TUI, run the existing menu-driven flow in the normal terminal, reload config, then re-enter the TUI and append a status block.
|
||||
|
||||
After `/login`, reload `Config` from disk and keep the current conversation open. If the active provider/model changed, future turns use the new provider and model. The status block should show the active provider and model.
|
||||
|
||||
After `/logout`, reload `Config` when a provider remains. If no provider remains or config cannot resolve, keep the chat open but append a clear status/error telling the user to run `/login` before sending another turn.
|
||||
|
||||
### Provider/model removal helper
|
||||
|
||||
Add reusable setup/config helpers:
|
||||
|
||||
- `configured_providers(root) -> Vec<ProviderLogoutCandidate>`
|
||||
- `remove_providers(root, provider_ids) -> LogoutResult`
|
||||
|
||||
`LogoutResult` should include removed provider ids, removed model count, remaining provider count, and the new active provider/model when one exists. This keeps CLI output, TUI status, and tests deterministic.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Add the v0.2.9 roadmap entry and this plan.
|
||||
2. Add `Login` and `Logout` CLI variants and dispatch them from `app::run`.
|
||||
3. Refactor setup mode text as needed so `cass login` can share the setup wizard.
|
||||
4. Implement provider removal helpers in `src/setup.rs` using existing config structs.
|
||||
5. Add menu-driven `setup::logout(root)` for CLI and TUI use.
|
||||
6. Add `/login` and `/logout` to local command parsing, autocomplete, and idle command handling.
|
||||
7. Add a small terminal leave/re-enter helper around blocking login/logout menus inside the TUI.
|
||||
8. Update command/config/workflow docs.
|
||||
9. Add focused tests for removal behavior and command parsing/autocomplete.
|
||||
|
||||
## Tests
|
||||
|
||||
- Removing one provider preserves unrelated providers and models.
|
||||
- Removing the active provider chooses a valid remaining provider/model.
|
||||
- Removing all providers clears active defaults.
|
||||
- Removing an unknown provider id is rejected.
|
||||
- `/login` and `/logout` parse only with no arguments.
|
||||
- Command autocomplete lists `/login` and `/logout`.
|
||||
- `cargo fmt` passes.
|
||||
- `cargo test --locked --all-targets` passes when practical.
|
||||
|
||||
## Documentation
|
||||
|
||||
- Update `docs/commands.md` with `cass login`, `cass logout`, `/login`, and `/logout`.
|
||||
- Update `docs/configuration.md` to point users toward login/logout for managed provider edits.
|
||||
- Update `docs/workflows.md` with login/logout examples near model/provider workflows.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `cass login` opens the provider setup wizard and exits after saving direct login changes.
|
||||
- `cass logout` removes selected providers and their models with confirmation.
|
||||
- `/login` and `/logout` work from an idle chat and reload active provider config afterward.
|
||||
- Removing the active provider never leaves `config.json` pointing at a missing provider/model.
|
||||
- Existing `cass setup`, first-run setup, `cass check`, and `/model` behavior still work.
|
||||
- `cargo fmt` and `cargo test --locked --all-targets` pass.
|
||||
@@ -0,0 +1,234 @@
|
||||
# v0.3.0 ChatGPT Codex Provider Implementation Plan
|
||||
|
||||
## Goal
|
||||
|
||||
This release adds a first-class `ChatGPT Codex` provider preset so users who are already signed in to Codex with a ChatGPT subscription can run Cassady without creating a separate API-key environment variable. The preset should call `https://chatgpt.com/backend-api/codex/responses` and resolve its bearer token from the local Codex auth config by default.
|
||||
|
||||
Success statement:
|
||||
|
||||
> A user who has already run `codex login` or signed in through the Codex app can select `ChatGPT Codex` in `cass login`, pass `cass check`, and send Cassady turns through their Codex subscription without copying tokens into Cassady config.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Add `ChatGPT Codex` as a built-in provider preset in the setup/login catalog.
|
||||
- Add a provider kind/client for the ChatGPT Codex responses endpoint rather than forcing it through `/chat/completions` URL construction.
|
||||
- Read the default access token from the local Codex auth file, normally `$CODEX_HOME/auth.json` or `~/.codex/auth.json`.
|
||||
- Support the observed Codex auth shape with `tokens.access_token`, while keeping token values out of Cassady config, logs, check output, and error text.
|
||||
- Prefer the Codex-configured model from `$CODEX_HOME/config.toml` when available, with a safe manual model fallback.
|
||||
- Teach `cass check` to validate that local Codex auth is present and usable for the active `ChatGPT Codex` provider.
|
||||
- Add docs explaining prerequisites, setup flow, token-source behavior, expiration troubleshooting, and the distinction between ChatGPT subscription access and API-key providers.
|
||||
- Add focused tests with temporary Codex-home fixtures and mocked streaming responses.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Implementing Cassady's own browser OAuth/device-login flow for ChatGPT.
|
||||
- Storing or refreshing ChatGPT/Codex tokens in Cassady-owned config files.
|
||||
- Reverse engineering unrelated ChatGPT backend endpoints beyond the requested Codex responses endpoint.
|
||||
- Guaranteeing compatibility if the private ChatGPT backend endpoint or Codex auth file format changes.
|
||||
- Replacing OpenAI-compatible provider support or changing existing provider presets.
|
||||
- Release tagging, packaging, or GitHub release creation.
|
||||
|
||||
## Context or Current State
|
||||
|
||||
Cassady's provider stack is currently centered on OpenAI-compatible chat completions:
|
||||
|
||||
- `src/setup.rs` owns the built-in provider catalog, setup/login prompts, model discovery via `GET {base_url}/models`, and writes to `providers.json`/`models.json`/`config.json`.
|
||||
- `src/config.rs` defines `ProviderDefinition`, validates provider registries, resolves `api_key` values from literals or environment-variable references, and currently accepts only `kind = "openai-compatible"`.
|
||||
- `src/agent.rs` constructs `OpenAiCompatibleProvider` directly from resolved config.
|
||||
- `src/providers/openai_compatible.rs` appends `/chat/completions`, sends OpenAI-compatible chat payloads, and parses OpenAI-compatible streaming deltas.
|
||||
- `docs/providers.md`, `docs/configuration.md`, `docs/commands.md`, `docs/workflows.md`, and `README.md` describe provider setup as API-key/environment-variable based.
|
||||
|
||||
The new preset differs in two important ways:
|
||||
|
||||
1. Authentication should come from Codex's local login state, not from a Cassady environment-variable API key.
|
||||
2. The endpoint is a Codex-specific responses endpoint (`https://chatgpt.com/backend-api/codex/responses`), so Cassady needs an endpoint-specific provider client or a more general provider dispatch layer.
|
||||
|
||||
Local Codex auth is expected to live under Codex home, normally `~/.codex/auth.json`, with a shape like:
|
||||
|
||||
```json
|
||||
{
|
||||
"auth_mode": "chatgpt",
|
||||
"tokens": {
|
||||
"access_token": "...",
|
||||
"refresh_token": "...",
|
||||
"account_id": "..."
|
||||
},
|
||||
"last_refresh": "..."
|
||||
}
|
||||
```
|
||||
|
||||
Cassady should treat this file as sensitive input: read it only when resolving the active provider token, never copy the access token into Cassady-owned JSON, and never print token contents.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Use Codex login state, do not own ChatGPT auth.** Cassady should integrate with an existing Codex login and tell users to run `codex login` or sign in to Codex when auth is missing or expired.
|
||||
2. **Keep provider protocols explicit.** Do not pretend the ChatGPT Codex endpoint is OpenAI-compatible if it needs different URL construction, request shape, or stream parsing.
|
||||
3. **Avoid token leakage.** Token values must not be stored in `~/.cass`, included in transcripts, surfaced in `cass check`, or embedded in test snapshots.
|
||||
4. **Keep existing setup stable.** Existing providers should continue to use environment-variable API keys and `/models` discovery without extra Codex dependencies.
|
||||
5. **Fail with clear recovery steps.** Missing Codex auth should produce actionable messages, not generic provider errors.
|
||||
|
||||
## Design
|
||||
|
||||
### Provider catalog and setup UX
|
||||
|
||||
Add a built-in catalog entry:
|
||||
|
||||
| Provider | Provider id | Kind | Endpoint | Token source |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| ChatGPT Codex | `chatgpt-codex` | `chatgpt-codex` | `https://chatgpt.com/backend-api/codex/responses` | Local Codex auth |
|
||||
|
||||
In `cass login`/`cass setup`, selecting this provider should skip the normal `API key environment variable` prompt and instead show a prerequisite check:
|
||||
|
||||
```text
|
||||
ChatGPT Codex uses your local Codex login.
|
||||
|
||||
✓ Found Codex auth at ~/.codex/auth.json
|
||||
```
|
||||
|
||||
If the file or access token is missing:
|
||||
|
||||
```text
|
||||
ChatGPT Codex needs a local Codex login.
|
||||
Run `codex login` or sign in with the Codex app, then run `cass login` again.
|
||||
```
|
||||
|
||||
Model selection should prefer, in order:
|
||||
|
||||
1. The `model` value from `$CODEX_HOME/config.toml` when present.
|
||||
2. A known default Codex model constant only if the project already has a current default available.
|
||||
3. Manual model id entry.
|
||||
|
||||
Do not call `GET /models` for `chatgpt-codex` unless a verified endpoint is added later; model discovery remains an OpenAI-compatible setup behavior.
|
||||
|
||||
### Provider configuration shape
|
||||
|
||||
Extend provider config without breaking existing files. One possible JSON shape is:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "chatgpt-codex",
|
||||
"name": "ChatGPT Codex",
|
||||
"kind": "chatgpt-codex",
|
||||
"base_url": "https://chatgpt.com/backend-api/codex/responses",
|
||||
"auth": { "type": "codex_local" },
|
||||
"default_model": "gpt-5.5",
|
||||
"models": ["gpt-5.5"]
|
||||
}
|
||||
```
|
||||
|
||||
Implementation may choose an equivalent internal representation, but it should preserve these properties:
|
||||
|
||||
- Existing `api_key` string behavior remains valid for OpenAI-compatible providers.
|
||||
- `chatgpt-codex` providers can omit environment-variable API keys.
|
||||
- `cass check` can distinguish missing local Codex auth from missing API-key env vars.
|
||||
- Serialized config does not contain the Codex access token.
|
||||
|
||||
### Codex auth resolution
|
||||
|
||||
Add a small resolver module, for example `src/codex_auth.rs`, with helpers like:
|
||||
|
||||
- `codex_home() -> PathBuf`: `$CODEX_HOME` when set, otherwise `~/.codex`.
|
||||
- `codex_auth_path() -> PathBuf`: `$CODEX_AUTH_FILE` for tests/overrides when set, otherwise `{codex_home}/auth.json`.
|
||||
- `load_codex_access_token() -> Result<CodexAccessToken>`: parse `tokens.access_token` and return a redacted/display-safe token wrapper.
|
||||
- `check_codex_auth() -> CodexAuthStatus`: report path found, auth mode, access-token presence, optional JWT expiration, and recovery hints.
|
||||
|
||||
If the access token looks like a JWT, parse the `exp` claim without validating the signature so Cassady can warn or fail early when the token is expired. Token refresh itself should stay out of scope unless Codex exposes a stable documented local refresh interface.
|
||||
|
||||
Read the token at request time rather than caching it during setup. This allows a separate Codex process to refresh `auth.json` between Cassady turns.
|
||||
|
||||
### Provider dispatch
|
||||
|
||||
Refactor provider construction so `src/agent.rs` does not instantiate only `OpenAiCompatibleProvider`. A simple first step is an enum:
|
||||
|
||||
```rust
|
||||
enum ProviderClient {
|
||||
OpenAiCompatible(OpenAiCompatibleProvider),
|
||||
ChatGptCodex(ChatGptCodexProvider),
|
||||
}
|
||||
```
|
||||
|
||||
Both variants should expose a common `complete(messages, tools, tx)` async method returning the existing `CompletionResult`. This preserves the current agent loop, tool execution, conversation storage, and TUI behavior.
|
||||
|
||||
### ChatGPT Codex responses client
|
||||
|
||||
Add a new provider module, for example `src/providers/chatgpt_codex.rs`, that:
|
||||
|
||||
- Posts to the exact configured endpoint, defaulting to `https://chatgpt.com/backend-api/codex/responses`.
|
||||
- Sends `Authorization: Bearer <local Codex access token>`.
|
||||
- Includes the active model and converted message/tool context in the endpoint's expected responses format.
|
||||
- Streams assistant text into `AgentEvent::AssistantChunk`.
|
||||
- Streams reasoning summaries into `AgentEvent::ReasoningChunk` only when the endpoint provides a safe reasoning summary field.
|
||||
- Converts function/tool call deltas into Cassady `StoredToolCall` values.
|
||||
- Converts Cassady tool results back into the endpoint's function-call-output input shape on the next turn.
|
||||
- Redacts authentication details from non-success response errors.
|
||||
|
||||
The exact request/stream schema should be verified against the endpoint during implementation and captured in mocked fixtures. If the endpoint rejects a field used by OpenAI-compatible providers, keep the Codex payload minimal rather than adding compatibility shims that risk breaking the flow.
|
||||
|
||||
### Check and troubleshooting behavior
|
||||
|
||||
For active `chatgpt-codex` providers, `cass check` should report status like:
|
||||
|
||||
```text
|
||||
✓ active provider: chatgpt-codex
|
||||
✓ endpoint: https://chatgpt.com/backend-api/codex/responses
|
||||
✓ Codex auth: ~/.codex/auth.json contains an access token
|
||||
```
|
||||
|
||||
Failure should point to recovery:
|
||||
|
||||
```text
|
||||
✗ Codex auth: no access token found in ~/.codex/auth.json
|
||||
Run `codex login` or sign in with the Codex app, then rerun `cass check`.
|
||||
```
|
||||
|
||||
Do not print the token, account id, refresh token, or full auth JSON.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Add the v0.3.0 roadmap entry and this implementation plan.
|
||||
2. Extend provider config types/validation to support `kind = "chatgpt-codex"` and a non-env local Codex auth source while preserving existing OpenAI-compatible files.
|
||||
3. Add `src/codex_auth.rs` for Codex home discovery, auth-file parsing, redacted status reporting, and optional JWT expiration checks.
|
||||
4. Add `ChatGPT Codex` to `src/setup.rs` provider catalog and branch setup behavior so it skips API-key env prompts and `/models` discovery.
|
||||
5. Refactor provider construction in `src/agent.rs` behind a small provider dispatch enum or trait.
|
||||
6. Implement `src/providers/chatgpt_codex.rs` with endpoint-specific request conversion, streaming parsing, tool-call conversion, and redacted errors.
|
||||
7. Update `cass check` so active and inactive provider checks understand Codex-local auth separately from environment-variable API keys.
|
||||
8. Update README and bundled docs for the new preset, prerequisites, config example, troubleshooting, and known endpoint/auth caveats.
|
||||
9. Add unit tests for config parsing/validation, Codex auth fixtures, setup catalog behavior, and provider dispatch.
|
||||
10. Add mocked streaming tests for the ChatGPT Codex client, including text, tool calls, tool outputs, auth failures, and redaction.
|
||||
|
||||
## Tests
|
||||
|
||||
- `providers.json` with existing OpenAI-compatible providers still parses and validates.
|
||||
- A `chatgpt-codex` provider with local Codex auth validates without `api_key`/env-var availability.
|
||||
- Missing `~/.codex/auth.json` produces a clear `cass check` error for an active `chatgpt-codex` provider.
|
||||
- A fixture `auth.json` with `tokens.access_token` resolves a token but redacts it in display and errors.
|
||||
- Expired JWT-like access tokens are detected when possible and produce a recovery hint.
|
||||
- Setup/login catalog includes `ChatGPT Codex` and skips the API-key env-var prompt for that provider.
|
||||
- Model selection uses `$CODEX_HOME/config.toml` `model` when available, with manual fallback.
|
||||
- Provider dispatch selects `ChatGptCodexProvider` only for `kind = "chatgpt-codex"`.
|
||||
- Mocked Codex streaming responses produce assistant chunks and final `CompletionResult.content`.
|
||||
- Mocked Codex function-call streams produce Cassady `StoredToolCall` values and accept subsequent tool output messages.
|
||||
- Provider error messages never include access tokens, refresh tokens, or raw auth JSON.
|
||||
- `cargo fmt` passes.
|
||||
- `cargo test --locked --all-targets` passes when practical.
|
||||
|
||||
## Documentation
|
||||
|
||||
- Update `README.md` setup/provider sections with `ChatGPT Codex` as a subscription-backed option.
|
||||
- Update `docs/providers.md` with the new preset, endpoint, token-source behavior, and private-endpoint caveat.
|
||||
- Update `docs/configuration.md` with the extended provider schema and a safe example that uses local Codex auth.
|
||||
- Update `docs/commands.md` and `docs/workflows.md` for `cass login`, `cass check`, and troubleshooting steps.
|
||||
- Update `docs/troubleshooting.md` with missing/expired Codex auth, unsupported model, and backend endpoint failure guidance.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `cass login` offers `ChatGPT Codex` as a provider preset.
|
||||
- Selecting `ChatGPT Codex` does not ask for an API-key environment variable by default.
|
||||
- Cassady reads the access token from local Codex auth at request/check time and never stores that token under `~/.cass`.
|
||||
- Active `chatgpt-codex` sessions call `https://chatgpt.com/backend-api/codex/responses` instead of appending `/chat/completions`.
|
||||
- Normal OpenAI-compatible providers continue to work unchanged.
|
||||
- `cass check` gives clear success/failure output for local Codex auth without leaking secrets.
|
||||
- README and bundled docs explain the prerequisite of signing in to Codex first.
|
||||
- `cargo fmt` and `cargo test --locked --all-targets` pass.
|
||||
+7
-20
@@ -2,8 +2,8 @@ use crate::access::AccessMode;
|
||||
use crate::config::{Config, ReasoningEffort};
|
||||
use crate::conversation::{now_ts, Conversation, Record, StoredToolCall};
|
||||
use crate::prompt;
|
||||
use crate::providers::openai_compatible::{OpenAiCompatibleProvider, OpenAiCompatibleSettings};
|
||||
use crate::providers::types::ModelMessage;
|
||||
use crate::providers::ProviderClient;
|
||||
use crate::security::PolicyDecision;
|
||||
use crate::tools::{self, ToolContext, ToolRuntimeEvent};
|
||||
use anyhow::Result;
|
||||
@@ -85,34 +85,21 @@ pub async fn run_turn_with_commands(
|
||||
ts: now_ts(),
|
||||
})?;
|
||||
|
||||
let api_key = match settings.config.resolved_api_key() {
|
||||
Ok(api_key) => api_key,
|
||||
let reasoning_effort = settings
|
||||
.reasoning_effort
|
||||
.clamp_for_model(settings.config.model_metadata.as_ref());
|
||||
let provider = match ProviderClient::from_config(&settings.config, reasoning_effort) {
|
||||
Ok(provider) => provider,
|
||||
Err(err) => {
|
||||
append_visible_assistant(
|
||||
&mut conversation,
|
||||
&tx,
|
||||
format!("I couldn't start the turn because the API key is not available: {err}"),
|
||||
format!("I couldn't start the turn because provider authentication is not available: {err}"),
|
||||
)?;
|
||||
let _ = tx.send(AgentEvent::TurnFinished);
|
||||
return Ok(conversation);
|
||||
}
|
||||
};
|
||||
let reasoning_request_format = settings
|
||||
.config
|
||||
.model_metadata
|
||||
.as_ref()
|
||||
.map(|model| model.reasoning.request_format)
|
||||
.unwrap_or_default();
|
||||
let reasoning_effort = settings
|
||||
.reasoning_effort
|
||||
.clamp_for_model(settings.config.model_metadata.as_ref());
|
||||
let provider = OpenAiCompatibleProvider::new(OpenAiCompatibleSettings {
|
||||
model: settings.config.model.clone(),
|
||||
base_url: settings.config.active_provider.base_url.clone(),
|
||||
api_key,
|
||||
reasoning_effort,
|
||||
reasoning_request_format,
|
||||
});
|
||||
|
||||
let docs_dir = settings.config.docs_dir();
|
||||
let tool_ctx = ToolContext {
|
||||
|
||||
+268
-3
@@ -1,5 +1,5 @@
|
||||
use crate::agent::{self, AgentCommand, AgentEvent, AgentSettings};
|
||||
use crate::cli::{self, Command};
|
||||
use crate::cli::{self, Cli, Command};
|
||||
use crate::config::{self, Config, ModelDefinition, ReasoningEffort};
|
||||
use crate::conversation::{self, Conversation, Record};
|
||||
use crate::prompt;
|
||||
@@ -34,6 +34,16 @@ pub async fn run() -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if matches!(cli.command, Some(Command::Login)) {
|
||||
let _ = crate::setup::run(&cli, crate::setup::SetupMode::Login).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if matches!(cli.command, Some(Command::Logout)) {
|
||||
let _ = crate::setup::logout(&config::cass_root())?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if matches!(cli.command, Some(Command::Setup)) {
|
||||
let outcome = crate::setup::run(&cli, crate::setup::SetupMode::Explicit).await?;
|
||||
if !outcome.start_session {
|
||||
@@ -70,7 +80,7 @@ pub async fn run() -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if config.resolved_api_key().is_err() {
|
||||
if config.ensure_provider_auth().is_err() {
|
||||
let outcome = crate::setup::run(&cli, crate::setup::SetupMode::Auto).await?;
|
||||
if !outcome.start_session {
|
||||
return Ok(());
|
||||
@@ -84,7 +94,7 @@ pub async fn run() -> Result<()> {
|
||||
(create_new_conversation(&config, &cwd)?, None)
|
||||
};
|
||||
|
||||
run_tui(config, cwd, conversation, warning).await
|
||||
run_tui(config, cwd, conversation, warning, cli).await
|
||||
}
|
||||
|
||||
fn resolve_cwd(cwd: Option<PathBuf>) -> Result<PathBuf> {
|
||||
@@ -117,6 +127,40 @@ fn list_chats(config: &Config, cwd: &std::path::Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_login_menu_from_tui(
|
||||
terminal: &mut terminal::CassTerminal,
|
||||
cli: &Cli,
|
||||
) -> Result<crate::setup::SetupOutcome> {
|
||||
terminal::suspend(terminal)?;
|
||||
let result = crate::setup::run(cli, crate::setup::SetupMode::Login).await;
|
||||
let resume_result = terminal::resume(terminal);
|
||||
match (result, resume_result) {
|
||||
(Ok(outcome), Ok(())) => Ok(outcome),
|
||||
(Err(err), Ok(())) => Err(err),
|
||||
(Ok(_), Err(err)) => Err(err),
|
||||
(Err(err), Err(resume_err)) => Err(err.context(format!(
|
||||
"also failed to restore the chat screen: {resume_err}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_logout_menu_from_tui(
|
||||
terminal: &mut terminal::CassTerminal,
|
||||
root: &Path,
|
||||
) -> Result<crate::setup::LogoutResult> {
|
||||
terminal::suspend(terminal)?;
|
||||
let result = crate::setup::logout(root);
|
||||
let resume_result = terminal::resume(terminal);
|
||||
match (result, resume_result) {
|
||||
(Ok(outcome), Ok(())) => Ok(outcome),
|
||||
(Err(err), Ok(())) => Err(err),
|
||||
(Ok(_), Err(err)) => Err(err),
|
||||
(Err(err), Err(resume_err)) => Err(err.context(format!(
|
||||
"also failed to restore the chat screen: {resume_err}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn finalize_cancelled_turn(
|
||||
config: &Config,
|
||||
chat_id: &str,
|
||||
@@ -186,6 +230,7 @@ async fn run_tui(
|
||||
cwd: PathBuf,
|
||||
mut conversation: Conversation,
|
||||
warning: Option<String>,
|
||||
cli: Cli,
|
||||
) -> Result<()> {
|
||||
let mut terminal = terminal::enter()?;
|
||||
let mut transcript = Vec::new();
|
||||
@@ -221,6 +266,7 @@ async fn run_tui(
|
||||
let mut chat_id = conversation.id.clone();
|
||||
let mut autofill_selected = 0usize;
|
||||
let mut pending_approval: Option<PendingApproval> = None;
|
||||
let mut provider_ready = true;
|
||||
|
||||
loop {
|
||||
drain_agent_events(
|
||||
@@ -672,6 +718,150 @@ async fn run_tui(
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(LocalCommand::Login) => {
|
||||
if busy {
|
||||
status = "login can be opened when idle".into();
|
||||
} else {
|
||||
input.clear();
|
||||
autofill_selected = 0;
|
||||
match run_login_menu_from_tui(&mut terminal, &cli).await
|
||||
{
|
||||
Ok(_) => match Config::load(&cli) {
|
||||
Ok(updated) => {
|
||||
config = updated;
|
||||
reasoning_effort = config.reasoning_effort;
|
||||
provider_ready = true;
|
||||
let content = format!(
|
||||
"active provider: {}\nactive model: {}",
|
||||
config.provider_id, config.model
|
||||
);
|
||||
transcript.push(TranscriptBlock {
|
||||
kind: TranscriptKind::Status,
|
||||
title: "login".into(),
|
||||
content,
|
||||
});
|
||||
status = "login updated".into();
|
||||
}
|
||||
Err(err) => {
|
||||
provider_ready = false;
|
||||
status = format!(
|
||||
"login saved with issues: {err}"
|
||||
);
|
||||
transcript.push(TranscriptBlock {
|
||||
kind: TranscriptKind::Error,
|
||||
title: "login".into(),
|
||||
content: format!(
|
||||
"Provider config could not be loaded: {err}"
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
status = format!("login cancelled: {err}");
|
||||
transcript.push(TranscriptBlock {
|
||||
kind: TranscriptKind::Error,
|
||||
title: "login".into(),
|
||||
content: err.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if stick_to_bottom {
|
||||
scroll = bottom_scroll(
|
||||
&terminal,
|
||||
&input,
|
||||
&transcript,
|
||||
show_full_tools,
|
||||
show_reasoning,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(LocalCommand::Logout) => {
|
||||
if busy {
|
||||
status = "logout can be opened when idle".into();
|
||||
} else {
|
||||
input.clear();
|
||||
autofill_selected = 0;
|
||||
match run_logout_menu_from_tui(
|
||||
&mut terminal,
|
||||
&config.root,
|
||||
) {
|
||||
Ok(result) => {
|
||||
if result.removed_provider_ids.is_empty() {
|
||||
transcript.push(TranscriptBlock {
|
||||
kind: TranscriptKind::Status,
|
||||
title: "logout".into(),
|
||||
content: "logout cancelled".into(),
|
||||
});
|
||||
status = "logout cancelled".into();
|
||||
} else if result.remaining_provider_count == 0 {
|
||||
provider_ready = false;
|
||||
transcript.push(TranscriptBlock {
|
||||
kind: TranscriptKind::Status,
|
||||
title: "logout".into(),
|
||||
content: format!(
|
||||
"removed providers: {}\nremoved model entries: {}\nno providers remain; run /login before sending another message",
|
||||
result.removed_provider_ids.join(", "),
|
||||
result.removed_model_count
|
||||
),
|
||||
});
|
||||
status = "no provider configured".into();
|
||||
} else {
|
||||
match Config::load(&cli) {
|
||||
Ok(updated) => {
|
||||
config = updated;
|
||||
reasoning_effort =
|
||||
config.reasoning_effort;
|
||||
provider_ready = true;
|
||||
transcript.push(TranscriptBlock {
|
||||
kind: TranscriptKind::Status,
|
||||
title: "logout".into(),
|
||||
content: format!(
|
||||
"removed providers: {}\nremoved model entries: {}\nactive provider: {}\nactive model: {}",
|
||||
result.removed_provider_ids.join(", "),
|
||||
result.removed_model_count,
|
||||
config.provider_id,
|
||||
config.model
|
||||
),
|
||||
});
|
||||
status = "logout updated".into();
|
||||
}
|
||||
Err(err) => {
|
||||
provider_ready = false;
|
||||
status = format!(
|
||||
"logout saved with issues: {err}"
|
||||
);
|
||||
transcript.push(TranscriptBlock {
|
||||
kind: TranscriptKind::Error,
|
||||
title: "logout".into(),
|
||||
content: format!(
|
||||
"Provider config could not be loaded: {err}"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
status = format!("logout cancelled: {err}");
|
||||
transcript.push(TranscriptBlock {
|
||||
kind: TranscriptKind::Error,
|
||||
title: "logout".into(),
|
||||
content: err.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if stick_to_bottom {
|
||||
scroll = bottom_scroll(
|
||||
&terminal,
|
||||
&input,
|
||||
&transcript,
|
||||
show_full_tools,
|
||||
show_reasoning,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(LocalCommand::Status) => {
|
||||
let content = chat_status(
|
||||
&chat_id,
|
||||
@@ -834,6 +1024,24 @@ async fn run_tui(
|
||||
}
|
||||
} else if busy {
|
||||
status = "agent is still running".into();
|
||||
} else if !provider_ready {
|
||||
status = "run /login before sending a message".into();
|
||||
transcript.push(TranscriptBlock {
|
||||
kind: TranscriptKind::Error,
|
||||
title: "provider".into(),
|
||||
content:
|
||||
"No active provider is configured. Run /login before sending another message."
|
||||
.into(),
|
||||
});
|
||||
if stick_to_bottom {
|
||||
scroll = bottom_scroll(
|
||||
&terminal,
|
||||
&input,
|
||||
&transcript,
|
||||
show_full_tools,
|
||||
show_reasoning,
|
||||
)?;
|
||||
}
|
||||
} else {
|
||||
let msg = input.trim_end().to_string();
|
||||
current_turn_start_len = Some(conversation.records.len());
|
||||
@@ -1677,6 +1885,8 @@ fn assistant_content_matches(a: &str, b: &str) -> bool {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum LocalCommand {
|
||||
Branch,
|
||||
Login,
|
||||
Logout,
|
||||
Model(String),
|
||||
New,
|
||||
Resume(String),
|
||||
@@ -1697,6 +1907,18 @@ const COMMANDS: &[CommandSpec] = &[
|
||||
description: "open branch/restore menu",
|
||||
takes_value: false,
|
||||
},
|
||||
CommandSpec {
|
||||
name: "login",
|
||||
usage: "/login",
|
||||
description: "configure provider login settings",
|
||||
takes_value: false,
|
||||
},
|
||||
CommandSpec {
|
||||
name: "logout",
|
||||
usage: "/logout",
|
||||
description: "remove saved providers and models",
|
||||
takes_value: false,
|
||||
},
|
||||
CommandSpec {
|
||||
name: "model",
|
||||
usage: "/model <model>",
|
||||
@@ -1953,6 +2175,18 @@ fn parse_local_command(input: &str) -> std::result::Result<LocalCommand, String>
|
||||
}
|
||||
Ok(LocalCommand::Branch)
|
||||
}
|
||||
"/login" => {
|
||||
if parts.next().is_some() {
|
||||
return Err("usage: /login".into());
|
||||
}
|
||||
Ok(LocalCommand::Login)
|
||||
}
|
||||
"/logout" => {
|
||||
if parts.next().is_some() {
|
||||
return Err("usage: /logout".into());
|
||||
}
|
||||
Ok(LocalCommand::Logout)
|
||||
}
|
||||
"/model" => {
|
||||
let Some(model) = parts.next() else {
|
||||
return Err("usage: /model <model>".into());
|
||||
@@ -2396,12 +2630,43 @@ mod tests {
|
||||
assert!(command_autofill("/new", 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_autofill_lists_login_and_logout_commands() {
|
||||
let menu = command_autofill("/log", 0).unwrap();
|
||||
|
||||
let labels = menu
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| item.label.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(labels, vec!["/login", "/logout"]);
|
||||
assert_eq!(menu.items[0].insert, "/login");
|
||||
assert_eq!(menu.items[1].insert, "/logout");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_local_command_accepts_new_without_args() {
|
||||
assert_eq!(parse_local_command("/new").unwrap(), LocalCommand::New);
|
||||
assert_eq!(parse_local_command("/new extra"), Err("usage: /new".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_local_command_accepts_login_and_logout_without_args() {
|
||||
assert_eq!(parse_local_command("/login").unwrap(), LocalCommand::Login);
|
||||
assert_eq!(
|
||||
parse_local_command("/logout").unwrap(),
|
||||
LocalCommand::Logout
|
||||
);
|
||||
assert_eq!(
|
||||
parse_local_command("/login extra"),
|
||||
Err("usage: /login".into())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_local_command("/logout extra"),
|
||||
Err("usage: /logout".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_turn_repairs_missing_tool_results() {
|
||||
let root = tempdir().unwrap();
|
||||
|
||||
+65
-4
@@ -1,5 +1,9 @@
|
||||
use crate::cli::Cli;
|
||||
use crate::config::{self, ApiKeyReference, Config, ModelsFile, ProvidersFile};
|
||||
use crate::codex_auth;
|
||||
use crate::config::{
|
||||
self, ApiKeyReference, Config, ModelsFile, ProvidersFile, CHATGPT_CODEX_PROVIDER_KIND,
|
||||
DEFAULT_PROVIDER_KIND,
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -72,6 +76,9 @@ impl CheckReport {
|
||||
"cass".into(),
|
||||
];
|
||||
}
|
||||
if error.contains("Codex auth") {
|
||||
return vec!["codex login".into(), "cass check".into(), "cass".into()];
|
||||
}
|
||||
}
|
||||
vec!["cass setup".into()]
|
||||
}
|
||||
@@ -191,29 +198,83 @@ fn check_active_config(report: &mut CheckReport, cfg: &Config, providers: &Provi
|
||||
report
|
||||
.successes
|
||||
.push(format!("active provider: {}", cfg.provider_id));
|
||||
let endpoint_label = if cfg.active_provider.kind == CHATGPT_CODEX_PROVIDER_KIND {
|
||||
"active provider endpoint"
|
||||
} else {
|
||||
"active provider base URL"
|
||||
};
|
||||
report.successes.push(format!(
|
||||
"active provider base URL: {}",
|
||||
"{endpoint_label}: {}",
|
||||
cfg.active_provider.base_url
|
||||
));
|
||||
report
|
||||
.successes
|
||||
.push(format!("active model: {}", cfg.model));
|
||||
|
||||
check_api_key(report, "api key", &cfg.active_provider.api_key, true);
|
||||
check_provider_auth(
|
||||
report,
|
||||
"api key",
|
||||
&cfg.active_provider.kind,
|
||||
&cfg.active_provider.api_key,
|
||||
true,
|
||||
);
|
||||
|
||||
for provider in &providers.providers {
|
||||
if provider.id == cfg.provider_id {
|
||||
continue;
|
||||
}
|
||||
check_api_key(
|
||||
check_provider_auth(
|
||||
report,
|
||||
&format!("provider `{}` api key", provider.id),
|
||||
&provider.kind,
|
||||
&provider.api_key,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn check_provider_auth(
|
||||
report: &mut CheckReport,
|
||||
label: &str,
|
||||
kind: &str,
|
||||
api_key: &str,
|
||||
active: bool,
|
||||
) {
|
||||
match kind {
|
||||
DEFAULT_PROVIDER_KIND => check_api_key(report, label, api_key, active),
|
||||
CHATGPT_CODEX_PROVIDER_KIND => check_codex_auth(report, active),
|
||||
_ if active => report
|
||||
.errors
|
||||
.push(format!("provider kind `{kind}` is unsupported")),
|
||||
_ => report
|
||||
.warnings
|
||||
.push(format!("provider kind `{kind}` is unsupported")),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_codex_auth(report: &mut CheckReport, active: bool) {
|
||||
let status = codex_auth::check_codex_auth();
|
||||
if status.is_usable() {
|
||||
if active {
|
||||
report
|
||||
.successes
|
||||
.push(format!("Codex auth: {}", status.summary()));
|
||||
}
|
||||
} else if active {
|
||||
report.errors.push(format!(
|
||||
"Codex auth: {}. {}",
|
||||
status.summary(),
|
||||
status.recovery_hint()
|
||||
));
|
||||
} else {
|
||||
report.warnings.push(format!(
|
||||
"Codex auth: {}. {}",
|
||||
status.summary(),
|
||||
status.recovery_hint()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn check_api_key(report: &mut CheckReport, label: &str, spec: &str, active: bool) {
|
||||
match config::api_key_reference(spec) {
|
||||
Ok(ApiKeyReference::Env(name)) => match std::env::var(&name) {
|
||||
|
||||
@@ -44,6 +44,10 @@ pub struct Cli {
|
||||
pub enum Command {
|
||||
/// Validate Cass config files.
|
||||
Check,
|
||||
/// Configure or update OpenAI-compatible provider login settings.
|
||||
Login,
|
||||
/// Remove saved providers and their models.
|
||||
Logout,
|
||||
/// Configure an OpenAI-compatible provider and first model.
|
||||
Setup,
|
||||
/// Update Cassady from official GitHub releases.
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CodexAccessToken {
|
||||
value: String,
|
||||
}
|
||||
|
||||
impl CodexAccessToken {
|
||||
pub fn new(value: String) -> Result<Self> {
|
||||
if value.trim().is_empty() {
|
||||
bail!("Codex access token is empty");
|
||||
}
|
||||
Ok(Self { value })
|
||||
}
|
||||
|
||||
pub fn as_secret(&self) -> &str {
|
||||
&self.value
|
||||
}
|
||||
|
||||
pub fn redacted(&self) -> &'static str {
|
||||
"<Codex access token>"
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CodexAccessToken {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.redacted())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CodexAuthStatus {
|
||||
pub path: PathBuf,
|
||||
pub auth_mode: Option<String>,
|
||||
pub has_access_token: bool,
|
||||
pub expires_at: Option<i64>,
|
||||
pub expired: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl CodexAuthStatus {
|
||||
pub fn is_usable(&self) -> bool {
|
||||
self.error.is_none() && self.has_access_token && !self.expired
|
||||
}
|
||||
|
||||
pub fn recovery_hint(&self) -> &'static str {
|
||||
"Run `codex login` or sign in with the Codex app, then rerun `cass check`."
|
||||
}
|
||||
|
||||
pub fn summary(&self) -> String {
|
||||
if self.is_usable() {
|
||||
if let Some(auth_mode) = &self.auth_mode {
|
||||
format!(
|
||||
"{} contains an access token (auth mode: {auth_mode})",
|
||||
pretty_path(&self.path)
|
||||
)
|
||||
} else {
|
||||
format!("{} contains an access token", pretty_path(&self.path))
|
||||
}
|
||||
} else if let Some(error) = &self.error {
|
||||
format!("{}: {error}", pretty_path(&self.path))
|
||||
} else if self.expired {
|
||||
format!(
|
||||
"{} contains an expired access token",
|
||||
pretty_path(&self.path)
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{} does not contain an access token",
|
||||
pretty_path(&self.path)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CodexAuthFile {
|
||||
auth_mode: Option<String>,
|
||||
tokens: Option<CodexTokens>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CodexTokens {
|
||||
access_token: Option<String>,
|
||||
}
|
||||
|
||||
pub fn codex_home() -> PathBuf {
|
||||
std::env::var_os("CODEX_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| dirs::home_dir().map(|home| home.join(".codex")))
|
||||
.unwrap_or_else(|| PathBuf::from(".codex"))
|
||||
}
|
||||
|
||||
pub fn codex_auth_path() -> PathBuf {
|
||||
std::env::var_os("CODEX_AUTH_FILE")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| codex_home().join("auth.json"))
|
||||
}
|
||||
|
||||
pub fn codex_config_path() -> PathBuf {
|
||||
std::env::var_os("CODEX_CONFIG_FILE")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| codex_home().join("config.toml"))
|
||||
}
|
||||
|
||||
pub fn load_codex_access_token() -> Result<CodexAccessToken> {
|
||||
load_codex_access_token_from_path(&codex_auth_path())
|
||||
}
|
||||
|
||||
pub fn load_codex_access_token_from_path(path: &Path) -> Result<CodexAccessToken> {
|
||||
let text = fs::read_to_string(path).with_context(|| {
|
||||
format!(
|
||||
"Codex auth not found at {}; run `codex login` or sign in with the Codex app",
|
||||
pretty_path(path)
|
||||
)
|
||||
})?;
|
||||
let parsed: CodexAuthFile = serde_json::from_str(&text)
|
||||
.with_context(|| format!("parsing Codex auth at {}", pretty_path(path)))?;
|
||||
let token = parsed
|
||||
.tokens
|
||||
.and_then(|tokens| tokens.access_token)
|
||||
.filter(|token| !token.trim().is_empty())
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"no access token found in {}; run `codex login` or sign in with the Codex app",
|
||||
pretty_path(path)
|
||||
)
|
||||
})?;
|
||||
if jwt_is_expired(&token) == Some(true) {
|
||||
bail!(
|
||||
"Codex access token in {} is expired; run `codex login` or sign in with the Codex app",
|
||||
pretty_path(path)
|
||||
);
|
||||
}
|
||||
CodexAccessToken::new(token)
|
||||
}
|
||||
|
||||
pub fn check_codex_auth() -> CodexAuthStatus {
|
||||
check_codex_auth_at(&codex_auth_path())
|
||||
}
|
||||
|
||||
pub fn check_codex_auth_at(path: &Path) -> CodexAuthStatus {
|
||||
let mut status = CodexAuthStatus {
|
||||
path: path.to_path_buf(),
|
||||
auth_mode: None,
|
||||
has_access_token: false,
|
||||
expires_at: None,
|
||||
expired: false,
|
||||
error: None,
|
||||
};
|
||||
|
||||
let text = match fs::read_to_string(path) {
|
||||
Ok(text) => text,
|
||||
Err(err) => {
|
||||
status.error = Some(format!("not readable ({err})"));
|
||||
return status;
|
||||
}
|
||||
};
|
||||
let parsed: CodexAuthFile = match serde_json::from_str(&text) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(err) => {
|
||||
status.error = Some(format!("invalid JSON ({err})"));
|
||||
return status;
|
||||
}
|
||||
};
|
||||
status.auth_mode = parsed.auth_mode;
|
||||
let token = parsed
|
||||
.tokens
|
||||
.and_then(|tokens| tokens.access_token)
|
||||
.filter(|token| !token.trim().is_empty());
|
||||
if let Some(token) = token {
|
||||
status.has_access_token = true;
|
||||
status.expires_at = jwt_expiration(&token);
|
||||
status.expired = jwt_is_expired(&token).unwrap_or(false);
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
pub fn read_codex_default_model() -> Option<String> {
|
||||
read_codex_default_model_from_path(&codex_config_path())
|
||||
}
|
||||
|
||||
pub fn read_codex_default_model_from_path(path: &Path) -> Option<String> {
|
||||
let text = fs::read_to_string(path).ok()?;
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with('#') || !line.starts_with("model") {
|
||||
continue;
|
||||
}
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
if key.trim() != "model" {
|
||||
continue;
|
||||
}
|
||||
let value = value.trim();
|
||||
let value = value
|
||||
.strip_prefix('"')
|
||||
.and_then(|v| v.strip_suffix('"'))
|
||||
.or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
|
||||
.unwrap_or(value)
|
||||
.trim();
|
||||
if !value.is_empty() {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn jwt_is_expired(token: &str) -> Option<bool> {
|
||||
let exp = jwt_expiration(token)?;
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs() as i64;
|
||||
Some(exp <= now)
|
||||
}
|
||||
|
||||
fn jwt_expiration(token: &str) -> Option<i64> {
|
||||
let mut parts = token.split('.');
|
||||
let _header = parts.next()?;
|
||||
let payload = parts.next()?;
|
||||
let bytes = base64_url_decode(payload).ok()?;
|
||||
let json: Value = serde_json::from_slice(&bytes).ok()?;
|
||||
json.get("exp")?.as_i64()
|
||||
}
|
||||
|
||||
fn base64_url_decode(input: &str) -> Result<Vec<u8>, String> {
|
||||
let mut bits = 0u32;
|
||||
let mut bit_count = 0u8;
|
||||
let mut out = Vec::new();
|
||||
for byte in input.bytes() {
|
||||
let value = match byte {
|
||||
b'A'..=b'Z' => byte - b'A',
|
||||
b'a'..=b'z' => byte - b'a' + 26,
|
||||
b'0'..=b'9' => byte - b'0' + 52,
|
||||
b'-' | b'+' => 62,
|
||||
b'_' | b'/' => 63,
|
||||
b'=' => break,
|
||||
_ => return Err("invalid base64 character".into()),
|
||||
} as u32;
|
||||
bits = (bits << 6) | value;
|
||||
bit_count += 6;
|
||||
if bit_count >= 8 {
|
||||
bit_count -= 8;
|
||||
out.push(((bits >> bit_count) & 0xff) as u8);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn pretty_path(path: &Path) -> String {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
if let Ok(rest) = path.strip_prefix(&home) {
|
||||
return format!("~/{}", rest.display());
|
||||
}
|
||||
}
|
||||
path.display().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn reads_access_token_without_displaying_it() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("auth.json");
|
||||
fs::write(
|
||||
&path,
|
||||
r#"{"auth_mode":"chatgpt","tokens":{"access_token":"secret-token"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let token = load_codex_access_token_from_path(&path).unwrap();
|
||||
assert_eq!(token.as_secret(), "secret-token");
|
||||
assert_eq!(token.to_string(), "<Codex access token>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_reports_missing_token_without_secret_fields() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("auth.json");
|
||||
fs::write(
|
||||
&path,
|
||||
r#"{"tokens":{"refresh_token":"refresh-secret","account_id":"acct"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let status = check_codex_auth_at(&path);
|
||||
assert!(!status.is_usable());
|
||||
let summary = status.summary();
|
||||
assert!(!summary.contains("refresh-secret"));
|
||||
assert!(!summary.contains("acct"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_model_from_codex_config() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
fs::write(&path, "model = \"gpt-test\"\n").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
read_codex_default_model_from_path(&path).as_deref(),
|
||||
Some("gpt-test")
|
||||
);
|
||||
}
|
||||
}
|
||||
+32
-5
@@ -9,6 +9,11 @@ use std::path::{Path, PathBuf};
|
||||
pub const DEFAULT_PROVIDER_ID: &str = "fireworks";
|
||||
pub const DEFAULT_PROVIDER_NAME: &str = "Fireworks";
|
||||
pub const DEFAULT_PROVIDER_KIND: &str = "openai-compatible";
|
||||
pub const CHATGPT_CODEX_PROVIDER_ID: &str = "chatgpt-codex";
|
||||
pub const CHATGPT_CODEX_PROVIDER_NAME: &str = "ChatGPT Codex";
|
||||
pub const CHATGPT_CODEX_PROVIDER_KIND: &str = "chatgpt-codex";
|
||||
pub const CHATGPT_CODEX_RESPONSES_URL: &str = "https://chatgpt.com/backend-api/codex/responses";
|
||||
pub const CHATGPT_CODEX_DEFAULT_MODEL: &str = "gpt-5.5";
|
||||
pub const DEFAULT_MODEL: &str = "accounts/fireworks/models/qwen3p7-plus";
|
||||
pub const DEFAULT_BASE_URL: &str = "https://api.fireworks.ai/inference/v1";
|
||||
pub const DEFAULT_API_KEY_ENV: &str = "FIREWORKS_API_KEY";
|
||||
@@ -61,6 +66,7 @@ pub struct ProviderDefinition {
|
||||
pub name: Option<String>,
|
||||
pub kind: String,
|
||||
pub base_url: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub api_key: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_model: Option<String>,
|
||||
@@ -128,7 +134,7 @@ pub struct ResolvedProviderConfig {
|
||||
pub name: Option<String>,
|
||||
pub kind: String,
|
||||
pub base_url: String,
|
||||
/// Either a literal API key or an env-var reference like "$FIREWORKS_API_KEY".
|
||||
/// Either a literal API key, an env-var reference like "$FIREWORKS_API_KEY", or empty for provider kinds that use external local auth.
|
||||
pub api_key: String,
|
||||
pub default_model: Option<String>,
|
||||
pub models: Vec<String>,
|
||||
@@ -429,6 +435,14 @@ impl Config {
|
||||
pub fn resolved_api_key(&self) -> Result<String> {
|
||||
resolve_api_key(&self.active_provider.api_key)
|
||||
}
|
||||
|
||||
pub fn ensure_provider_auth(&self) -> Result<()> {
|
||||
match self.active_provider.kind.as_str() {
|
||||
DEFAULT_PROVIDER_KIND => self.resolved_api_key().map(|_| ()),
|
||||
CHATGPT_CODEX_PROVIDER_KIND => crate::codex_auth::load_codex_access_token().map(|_| ()),
|
||||
kind => bail!("unsupported provider kind `{kind}`"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderDefinition {
|
||||
@@ -535,6 +549,10 @@ pub fn api_key_reference(spec: &str) -> Result<ApiKeyReference> {
|
||||
Ok(ApiKeyReference::Literal)
|
||||
}
|
||||
|
||||
pub fn is_supported_provider_kind(kind: &str) -> bool {
|
||||
matches!(kind, DEFAULT_PROVIDER_KIND | CHATGPT_CODEX_PROVIDER_KIND)
|
||||
}
|
||||
|
||||
pub fn resolve_api_key(spec: &str) -> Result<String> {
|
||||
match api_key_reference(spec)? {
|
||||
ApiKeyReference::Env(name) => {
|
||||
@@ -576,7 +594,7 @@ pub fn validate_registries(
|
||||
"providers.json: provider `{}` kind must not be empty",
|
||||
provider.id
|
||||
));
|
||||
} else if provider.kind != DEFAULT_PROVIDER_KIND {
|
||||
} else if !is_supported_provider_kind(&provider.kind) {
|
||||
out.errors.push(format!(
|
||||
"providers.json: provider `{}` uses unsupported kind `{}`",
|
||||
provider.id, provider.kind
|
||||
@@ -593,9 +611,18 @@ pub fn validate_registries(
|
||||
provider.id
|
||||
));
|
||||
}
|
||||
if let Err(err) = api_key_reference(&provider.api_key) {
|
||||
out.errors.push(format!(
|
||||
"providers.json: provider `{}` has invalid api_key: {err}",
|
||||
if provider.kind == DEFAULT_PROVIDER_KIND {
|
||||
if let Err(err) = api_key_reference(&provider.api_key) {
|
||||
out.errors.push(format!(
|
||||
"providers.json: provider `{}` has invalid api_key: {err}",
|
||||
provider.id
|
||||
));
|
||||
}
|
||||
} else if provider.kind == CHATGPT_CODEX_PROVIDER_KIND
|
||||
&& !provider.api_key.trim().is_empty()
|
||||
{
|
||||
out.warnings.push(format!(
|
||||
"providers.json: provider `{}` ignores api_key because ChatGPT Codex uses local Codex auth",
|
||||
provider.id
|
||||
));
|
||||
}
|
||||
|
||||
+1
-1
@@ -157,7 +157,7 @@ impl SessionBuilder {
|
||||
access_mode: self.access_mode,
|
||||
};
|
||||
let config = Config::load_with_overrides(root, overrides).map_err(Error::config)?;
|
||||
config.resolved_api_key().map_err(Error::config)?;
|
||||
config.ensure_provider_auth().map_err(Error::config)?;
|
||||
let cwd = resolve_cwd(self.cwd).map_err(Error::config)?;
|
||||
let mode = config.default_access_mode;
|
||||
let reasoning_effort = self
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod app;
|
||||
pub mod branch;
|
||||
pub mod check;
|
||||
pub mod cli;
|
||||
pub mod codex_auth;
|
||||
pub mod config;
|
||||
pub mod conversation;
|
||||
pub mod docs;
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
use super::types::{CompletionResult, ModelMessage};
|
||||
use crate::agent::AgentEvent;
|
||||
use crate::codex_auth::load_codex_access_token;
|
||||
use crate::config::{ReasoningEffort, CHATGPT_CODEX_RESPONSES_URL};
|
||||
use crate::conversation::StoredToolCall;
|
||||
use crate::tools::ToolSpec;
|
||||
use anyhow::{bail, Result};
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::Client;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeMap;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatGptCodexProvider {
|
||||
client: Client,
|
||||
model: String,
|
||||
endpoint: String,
|
||||
reasoning_effort: ReasoningEffort,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatGptCodexSettings {
|
||||
pub model: String,
|
||||
pub endpoint: String,
|
||||
pub reasoning_effort: ReasoningEffort,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
struct PartialFunctionCall {
|
||||
call_id: Option<String>,
|
||||
name: Option<String>,
|
||||
arguments: String,
|
||||
}
|
||||
|
||||
impl ChatGptCodexProvider {
|
||||
pub fn new(settings: ChatGptCodexSettings) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
model: settings.model,
|
||||
endpoint: normalize_endpoint(&settings.endpoint),
|
||||
reasoning_effort: settings.reasoning_effort,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn complete(
|
||||
&self,
|
||||
messages: Vec<ModelMessage>,
|
||||
tools: Vec<ToolSpec>,
|
||||
tx: &mpsc::UnboundedSender<AgentEvent>,
|
||||
) -> Result<CompletionResult> {
|
||||
let token = load_codex_access_token()?;
|
||||
let secret = token.as_secret().to_string();
|
||||
let body = responses_body(&self.model, messages, tools, self.reasoning_effort);
|
||||
let resp = self
|
||||
.client
|
||||
.post(&self.endpoint)
|
||||
.bearer_auth(token.as_secret())
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
bail!(
|
||||
"ChatGPT Codex returned {status}: {}",
|
||||
redact_secret(&text, &secret)
|
||||
);
|
||||
}
|
||||
|
||||
let mut state = StreamState::default();
|
||||
let mut buf = String::new();
|
||||
let mut stream = resp.bytes_stream();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk?;
|
||||
let chunk_text = String::from_utf8_lossy(&chunk).replace("\r\n", "\n");
|
||||
buf.push_str(&chunk_text);
|
||||
while let Some(pos) = buf.find("\n\n") {
|
||||
let frame = buf[..pos].to_string();
|
||||
buf = buf[pos + 2..].to_string();
|
||||
process_frame(&frame, &mut state, tx)?;
|
||||
}
|
||||
}
|
||||
if !buf.trim().is_empty() {
|
||||
process_frame(&buf, &mut state, tx)?;
|
||||
}
|
||||
|
||||
Ok(state.finish())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct StreamState {
|
||||
content: String,
|
||||
reasoning: String,
|
||||
reasoning_field: Option<String>,
|
||||
partials: BTreeMap<String, PartialFunctionCall>,
|
||||
}
|
||||
|
||||
impl StreamState {
|
||||
fn finish(self) -> CompletionResult {
|
||||
let tool_calls = self
|
||||
.partials
|
||||
.into_iter()
|
||||
.filter_map(|(key, partial)| {
|
||||
let name = partial.name?;
|
||||
let id = partial.call_id.unwrap_or(key);
|
||||
let arguments = serde_json::from_str(&partial.arguments)
|
||||
.unwrap_or_else(|_| json!({"_raw": partial.arguments}));
|
||||
Some(StoredToolCall {
|
||||
id,
|
||||
name,
|
||||
arguments,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
CompletionResult {
|
||||
content: self.content,
|
||||
reasoning: self.reasoning,
|
||||
reasoning_field: self.reasoning_field,
|
||||
tool_calls,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn responses_body(
|
||||
model: &str,
|
||||
messages: Vec<ModelMessage>,
|
||||
tools: Vec<ToolSpec>,
|
||||
reasoning_effort: ReasoningEffort,
|
||||
) -> Value {
|
||||
let mut instructions = Vec::new();
|
||||
let mut input = Vec::new();
|
||||
for message in messages {
|
||||
match message {
|
||||
ModelMessage::System { content } => instructions.push(content),
|
||||
ModelMessage::User { content } => input.push(json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": content}]
|
||||
})),
|
||||
ModelMessage::Assistant {
|
||||
content,
|
||||
reasoning: _,
|
||||
reasoning_field: _,
|
||||
tool_calls,
|
||||
} => {
|
||||
if !content.is_empty() {
|
||||
input.push(json!({
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": content}]
|
||||
}));
|
||||
}
|
||||
for call in tool_calls {
|
||||
input.push(json!({
|
||||
"type": "function_call",
|
||||
"call_id": call.id,
|
||||
"name": call.name,
|
||||
"arguments": call.arguments.to_string()
|
||||
}));
|
||||
}
|
||||
}
|
||||
ModelMessage::Tool {
|
||||
tool_call_id,
|
||||
name: _,
|
||||
content,
|
||||
} => input.push(json!({
|
||||
"type": "function_call_output",
|
||||
"call_id": tool_call_id,
|
||||
"output": content
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
let mut body = json!({
|
||||
"model": model,
|
||||
"input": input,
|
||||
"tools": tools_to_responses(tools),
|
||||
"stream": true,
|
||||
"store": false
|
||||
});
|
||||
if !instructions.is_empty() {
|
||||
body["instructions"] = Value::String(instructions.join("\n\n"));
|
||||
}
|
||||
if let Some(effort) = reasoning_effort.request_value() {
|
||||
body["reasoning"] = json!({"effort": effort, "summary": "auto"});
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
fn tools_to_responses(tools: Vec<ToolSpec>) -> Vec<Value> {
|
||||
tools
|
||||
.into_iter()
|
||||
.map(|tool| {
|
||||
json!({
|
||||
"type": "function",
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"parameters": tool.parameters
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn process_frame(
|
||||
frame: &str,
|
||||
state: &mut StreamState,
|
||||
tx: &mpsc::UnboundedSender<AgentEvent>,
|
||||
) -> Result<()> {
|
||||
for line in frame.lines() {
|
||||
let line = line.trim();
|
||||
if !line.starts_with("data:") {
|
||||
continue;
|
||||
}
|
||||
let data = line.trim_start_matches("data:").trim();
|
||||
if data == "[DONE]" || data.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let value: Value = serde_json::from_str(data)?;
|
||||
handle_event(&value, state, tx)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_event(
|
||||
value: &Value,
|
||||
state: &mut StreamState,
|
||||
tx: &mpsc::UnboundedSender<AgentEvent>,
|
||||
) -> Result<()> {
|
||||
let event_type = value
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
match event_type {
|
||||
"response.output_text.delta" | "response.message.delta" | "output_text.delta" => {
|
||||
if let Some(delta) = string_field(value, &["delta", "text"]) {
|
||||
push_content(state, tx, delta);
|
||||
}
|
||||
}
|
||||
"response.reasoning_summary_text.delta"
|
||||
| "response.reasoning_text.delta"
|
||||
| "response.reasoning.delta"
|
||||
| "reasoning.delta" => {
|
||||
if let Some(delta) = string_field(value, &["delta", "text"]) {
|
||||
push_reasoning(state, tx, "reasoning_summary", delta);
|
||||
}
|
||||
}
|
||||
"response.function_call_arguments.delta" | "function_call_arguments.delta" => {
|
||||
let key = event_key(value);
|
||||
let partial = state.partials.entry(key).or_default();
|
||||
if let Some(delta) = string_field(value, &["delta", "arguments_delta"]) {
|
||||
partial.arguments.push_str(delta);
|
||||
}
|
||||
}
|
||||
"response.function_call_arguments.done" | "function_call_arguments.done" => {
|
||||
let key = event_key(value);
|
||||
let partial = state.partials.entry(key).or_default();
|
||||
if let Some(arguments) = string_field(value, &["arguments"]) {
|
||||
partial.arguments = arguments.to_string();
|
||||
}
|
||||
if let Some(call_id) = string_field(value, &["call_id", "id"]) {
|
||||
partial.call_id = Some(call_id.to_string());
|
||||
}
|
||||
if let Some(name) = string_field(value, &["name"]) {
|
||||
partial.name = Some(name.to_string());
|
||||
}
|
||||
}
|
||||
"response.output_item.added"
|
||||
| "response.output_item.done"
|
||||
| "output_item.added"
|
||||
| "output_item.done" => {
|
||||
if let Some(item) = value.get("item") {
|
||||
handle_item(item, state, tx, event_type.ends_with("done"));
|
||||
}
|
||||
}
|
||||
"response.completed" | "response.done" => {
|
||||
if state.content.is_empty() {
|
||||
if let Some(response) = value.get("response") {
|
||||
extract_final_response(response, state, tx);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(item) = value.get("item") {
|
||||
handle_item(item, state, tx, false);
|
||||
} else if let Some(delta) = value
|
||||
.get("delta")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|_| event_type.contains("output_text"))
|
||||
{
|
||||
push_content(state, tx, delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_item(
|
||||
item: &Value,
|
||||
state: &mut StreamState,
|
||||
tx: &mpsc::UnboundedSender<AgentEvent>,
|
||||
final_item: bool,
|
||||
) {
|
||||
match item.get("type").and_then(Value::as_str).unwrap_or_default() {
|
||||
"message" => {
|
||||
if final_item && state.content.is_empty() {
|
||||
for content in item
|
||||
.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if matches!(
|
||||
content.get("type").and_then(Value::as_str),
|
||||
Some("output_text")
|
||||
) {
|
||||
if let Some(text) = content.get("text").and_then(Value::as_str) {
|
||||
push_content(state, tx, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"reasoning" => {
|
||||
for summary in item
|
||||
.get("summary")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if let Some(text) = summary.get("text").and_then(Value::as_str) {
|
||||
push_reasoning(state, tx, "reasoning_summary", text);
|
||||
}
|
||||
}
|
||||
}
|
||||
"function_call" => {
|
||||
let key = item
|
||||
.get("call_id")
|
||||
.or_else(|| item.get("id"))
|
||||
.or_else(|| item.get("item_id"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("call")
|
||||
.to_string();
|
||||
let partial = state.partials.entry(key.clone()).or_default();
|
||||
if let Some(call_id) = item
|
||||
.get("call_id")
|
||||
.or_else(|| item.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
partial.call_id = Some(call_id.to_string());
|
||||
}
|
||||
if let Some(name) = item.get("name").and_then(Value::as_str) {
|
||||
partial.name = Some(name.to_string());
|
||||
}
|
||||
if let Some(arguments) = item.get("arguments").and_then(Value::as_str) {
|
||||
partial.arguments = arguments.to_string();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_final_response(
|
||||
response: &Value,
|
||||
state: &mut StreamState,
|
||||
tx: &mpsc::UnboundedSender<AgentEvent>,
|
||||
) {
|
||||
for item in response
|
||||
.get("output")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
handle_item(item, state, tx, true);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_content(state: &mut StreamState, tx: &mpsc::UnboundedSender<AgentEvent>, text: &str) {
|
||||
state.content.push_str(text);
|
||||
let _ = tx.send(AgentEvent::AssistantChunk(text.to_string()));
|
||||
}
|
||||
|
||||
fn push_reasoning(
|
||||
state: &mut StreamState,
|
||||
tx: &mpsc::UnboundedSender<AgentEvent>,
|
||||
field: &'static str,
|
||||
text: &str,
|
||||
) {
|
||||
state
|
||||
.reasoning_field
|
||||
.get_or_insert_with(|| field.to_string());
|
||||
state.reasoning.push_str(text);
|
||||
let _ = tx.send(AgentEvent::ReasoningChunk(text.to_string()));
|
||||
}
|
||||
|
||||
fn string_field<'a>(value: &'a Value, fields: &[&str]) -> Option<&'a str> {
|
||||
fields.iter().find_map(|field| value.get(*field)?.as_str())
|
||||
}
|
||||
|
||||
fn event_key(value: &Value) -> String {
|
||||
string_field(
|
||||
value,
|
||||
&["call_id", "item_id", "output_item_id", "id", "output_index"],
|
||||
)
|
||||
.map(str::to_string)
|
||||
.or_else(|| {
|
||||
value
|
||||
.get("output_index")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|n| n.to_string())
|
||||
})
|
||||
.unwrap_or_else(|| "call".to_string())
|
||||
}
|
||||
|
||||
fn normalize_endpoint(endpoint: &str) -> String {
|
||||
let endpoint = endpoint.trim();
|
||||
if endpoint.is_empty() {
|
||||
CHATGPT_CODEX_RESPONSES_URL.to_string()
|
||||
} else {
|
||||
endpoint.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_secret(text: &str, secret: &str) -> String {
|
||||
if secret.is_empty() {
|
||||
text.to_string()
|
||||
} else {
|
||||
text.replace(secret, "<redacted>")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[test]
|
||||
fn responses_body_uses_function_call_items() {
|
||||
let body = responses_body(
|
||||
"gpt-test",
|
||||
vec![
|
||||
ModelMessage::System {
|
||||
content: "system".into(),
|
||||
},
|
||||
ModelMessage::User {
|
||||
content: "hello".into(),
|
||||
},
|
||||
ModelMessage::Assistant {
|
||||
content: String::new(),
|
||||
reasoning: String::new(),
|
||||
reasoning_field: None,
|
||||
tool_calls: vec![StoredToolCall {
|
||||
id: "call_1".into(),
|
||||
name: "read".into(),
|
||||
arguments: json!({"path":"README.md"}),
|
||||
}],
|
||||
},
|
||||
ModelMessage::Tool {
|
||||
tool_call_id: "call_1".into(),
|
||||
name: "read".into(),
|
||||
content: "ok".into(),
|
||||
},
|
||||
],
|
||||
Vec::new(),
|
||||
ReasoningEffort::Off,
|
||||
);
|
||||
|
||||
assert_eq!(body["model"], "gpt-test");
|
||||
assert_eq!(body["instructions"], "system");
|
||||
assert!(body["input"].as_array().unwrap().iter().any(|item| {
|
||||
item.get("type").and_then(Value::as_str) == Some("function_call_output")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_parser_collects_text_and_function_call() {
|
||||
let (tx, _rx) = mpsc::unbounded_channel();
|
||||
let mut state = StreamState::default();
|
||||
process_frame(
|
||||
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"hi\"}\n\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"function_call\",\"call_id\":\"call_1\",\"name\":\"read\",\"arguments\":\"{\\\"path\\\":\\\"README.md\\\"}\"}}\n\n",
|
||||
&mut state,
|
||||
&tx,
|
||||
)
|
||||
.unwrap();
|
||||
let result = state.finish();
|
||||
|
||||
assert_eq!(result.content, "hi");
|
||||
assert_eq!(result.tool_calls.len(), 1);
|
||||
assert_eq!(result.tool_calls[0].id, "call_1");
|
||||
assert_eq!(result.tool_calls[0].name, "read");
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,62 @@
|
||||
pub mod chatgpt_codex;
|
||||
pub mod openai_compatible;
|
||||
pub mod types;
|
||||
|
||||
use crate::agent::AgentEvent;
|
||||
use crate::config::{Config, ReasoningEffort, CHATGPT_CODEX_PROVIDER_KIND, DEFAULT_PROVIDER_KIND};
|
||||
use crate::providers::chatgpt_codex::{ChatGptCodexProvider, ChatGptCodexSettings};
|
||||
use crate::providers::openai_compatible::{OpenAiCompatibleProvider, OpenAiCompatibleSettings};
|
||||
use crate::providers::types::{CompletionResult, ModelMessage};
|
||||
use crate::tools::ToolSpec;
|
||||
use anyhow::{bail, Result};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ProviderClient {
|
||||
OpenAiCompatible(OpenAiCompatibleProvider),
|
||||
ChatGptCodex(ChatGptCodexProvider),
|
||||
}
|
||||
|
||||
impl ProviderClient {
|
||||
pub fn from_config(config: &Config, reasoning_effort: ReasoningEffort) -> Result<Self> {
|
||||
match config.active_provider.kind.as_str() {
|
||||
DEFAULT_PROVIDER_KIND => {
|
||||
let api_key = config.resolved_api_key()?;
|
||||
let reasoning_request_format = config
|
||||
.model_metadata
|
||||
.as_ref()
|
||||
.map(|model| model.reasoning.request_format)
|
||||
.unwrap_or_default();
|
||||
Ok(Self::OpenAiCompatible(OpenAiCompatibleProvider::new(
|
||||
OpenAiCompatibleSettings {
|
||||
model: config.model.clone(),
|
||||
base_url: config.active_provider.base_url.clone(),
|
||||
api_key,
|
||||
reasoning_effort,
|
||||
reasoning_request_format,
|
||||
},
|
||||
)))
|
||||
}
|
||||
CHATGPT_CODEX_PROVIDER_KIND => Ok(Self::ChatGptCodex(ChatGptCodexProvider::new(
|
||||
ChatGptCodexSettings {
|
||||
model: config.model.clone(),
|
||||
endpoint: config.active_provider.base_url.clone(),
|
||||
reasoning_effort,
|
||||
},
|
||||
))),
|
||||
kind => bail!("unsupported provider kind `{kind}`"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn complete(
|
||||
&self,
|
||||
messages: Vec<ModelMessage>,
|
||||
tools: Vec<ToolSpec>,
|
||||
tx: &mpsc::UnboundedSender<AgentEvent>,
|
||||
) -> Result<CompletionResult> {
|
||||
match self {
|
||||
Self::OpenAiCompatible(provider) => provider.complete(messages, tools, tx).await,
|
||||
Self::ChatGptCodex(provider) => provider.complete(messages, tools, tx).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+390
-27
@@ -1,8 +1,11 @@
|
||||
use crate::check;
|
||||
use crate::cli::Cli;
|
||||
use crate::codex_auth;
|
||||
use crate::config::{
|
||||
self, ConfigFile, ModelDefinition, ModelsFile, ProviderDefinition, ProvidersFile,
|
||||
ReasoningEffort, ReasoningMetadata, ReasoningRequestFormat, DEFAULT_PROVIDER_KIND,
|
||||
ReasoningEffort, ReasoningMetadata, ReasoningRequestFormat, CHATGPT_CODEX_DEFAULT_MODEL,
|
||||
CHATGPT_CODEX_PROVIDER_ID, CHATGPT_CODEX_PROVIDER_KIND, CHATGPT_CODEX_PROVIDER_NAME,
|
||||
CHATGPT_CODEX_RESPONSES_URL, DEFAULT_PROVIDER_KIND,
|
||||
};
|
||||
use crate::menu::{Menu, MenuItem, TextPrompt};
|
||||
use anyhow::{bail, Context, Result};
|
||||
@@ -18,6 +21,7 @@ use std::time::Duration;
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SetupMode {
|
||||
Explicit,
|
||||
Login,
|
||||
Auto,
|
||||
}
|
||||
|
||||
@@ -45,6 +49,23 @@ pub struct SetupSelection {
|
||||
pub supports_reasoning: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderLogoutCandidate {
|
||||
pub id: String,
|
||||
pub name: Option<String>,
|
||||
pub default_model: Option<String>,
|
||||
pub model_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LogoutResult {
|
||||
pub removed_provider_ids: Vec<String>,
|
||||
pub removed_model_count: usize,
|
||||
pub remaining_provider_count: usize,
|
||||
pub active_provider: Option<String>,
|
||||
pub active_model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ModelsResponse {
|
||||
data: Vec<ModelItem>,
|
||||
@@ -57,7 +78,12 @@ struct ModelItem {
|
||||
|
||||
fn print_banner() {
|
||||
println!("Cassady setup");
|
||||
println!("Configure an OpenAI-compatible provider, API key environment variable, and model.");
|
||||
println!("Configure a provider, authentication source, and model.");
|
||||
}
|
||||
|
||||
fn print_login_banner() {
|
||||
println!("Cassady login");
|
||||
println!("Add or update a provider, authentication source, and model.");
|
||||
}
|
||||
|
||||
fn section(title: &str) {
|
||||
@@ -125,6 +151,12 @@ pub fn provider_catalog() -> Vec<ProviderCatalogEntry> {
|
||||
base_url: "https://api.openai.com/v1",
|
||||
api_key_env: "OPENAI_API_KEY",
|
||||
},
|
||||
ProviderCatalogEntry {
|
||||
name: CHATGPT_CODEX_PROVIDER_NAME,
|
||||
id: CHATGPT_CODEX_PROVIDER_ID,
|
||||
base_url: CHATGPT_CODEX_RESPONSES_URL,
|
||||
api_key_env: "",
|
||||
},
|
||||
ProviderCatalogEntry {
|
||||
name: "xAI",
|
||||
id: "xai",
|
||||
@@ -187,10 +219,16 @@ pub async fn run(cli: &Cli, mode: SetupMode) -> Result<SetupOutcome> {
|
||||
fs::create_dir_all(&root).with_context(|| format!("creating {}", root.display()))?;
|
||||
|
||||
if !io::stdin().is_terminal() {
|
||||
bail!("setup is interactive; run `cass setup` in a terminal");
|
||||
match mode {
|
||||
SetupMode::Login => bail!("login is interactive; run `cass login` in a terminal"),
|
||||
_ => bail!("setup is interactive; run `cass setup` in a terminal"),
|
||||
}
|
||||
}
|
||||
|
||||
print_banner();
|
||||
match mode {
|
||||
SetupMode::Login => print_login_banner(),
|
||||
_ => print_banner(),
|
||||
}
|
||||
|
||||
match mode {
|
||||
SetupMode::Explicit => {
|
||||
@@ -206,6 +244,7 @@ pub async fn run(cli: &Cli, mode: SetupMode) -> Result<SetupOutcome> {
|
||||
});
|
||||
}
|
||||
}
|
||||
SetupMode::Login => {}
|
||||
SetupMode::Auto => {
|
||||
println!();
|
||||
hint("Cassady needs this before starting your first chat.");
|
||||
@@ -233,29 +272,129 @@ pub async fn run(cli: &Cli, mode: SetupMode) -> Result<SetupOutcome> {
|
||||
|
||||
let report = check::run(cli)?;
|
||||
if report.has_errors() {
|
||||
if std::env::var(&active_api_key_env).is_err() {
|
||||
section("Setup saved");
|
||||
if !active_api_key_env.is_empty() && std::env::var(&active_api_key_env).is_err() {
|
||||
section(match mode {
|
||||
SetupMode::Login => "Login saved",
|
||||
_ => "Setup saved",
|
||||
});
|
||||
warn("Your active provider API key is not available in this shell.");
|
||||
hint(format!("Set it with: export {active_api_key_env}=..."));
|
||||
hint("Then run: cass");
|
||||
} else {
|
||||
section("Setup saved with issues");
|
||||
section(match mode {
|
||||
SetupMode::Login => "Login saved with issues",
|
||||
_ => "Setup saved with issues",
|
||||
});
|
||||
print!("{}", report.render());
|
||||
hint("Run `cass setup` to try again or edit ~/.cass/config.json manually.");
|
||||
hint(match mode {
|
||||
SetupMode::Login => {
|
||||
"Run `cass login` to try again or edit ~/.cass/config.json manually."
|
||||
}
|
||||
_ => "Run `cass setup` to try again or edit ~/.cass/config.json manually.",
|
||||
});
|
||||
}
|
||||
return Ok(SetupOutcome {
|
||||
start_session: false,
|
||||
});
|
||||
}
|
||||
|
||||
section("Setup complete");
|
||||
section(match mode {
|
||||
SetupMode::Login => "Login complete",
|
||||
_ => "Setup complete",
|
||||
});
|
||||
success("Configuration saved and validated");
|
||||
info("Starting your first Cassady session…");
|
||||
match mode {
|
||||
SetupMode::Login => info("Provider configuration is ready."),
|
||||
_ => info("Starting your first Cassady session…"),
|
||||
}
|
||||
Ok(SetupOutcome {
|
||||
start_session: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn logout(root: &Path) -> Result<LogoutResult> {
|
||||
fs::create_dir_all(root).with_context(|| format!("creating {}", root.display()))?;
|
||||
if !io::stdin().is_terminal() {
|
||||
bail!("logout is interactive; run `cass logout` in a terminal");
|
||||
}
|
||||
let candidates = configured_providers(root)?;
|
||||
if candidates.is_empty() {
|
||||
bail!("no providers are configured; run `cass login` to add one");
|
||||
}
|
||||
|
||||
section("Cassady logout");
|
||||
warn("This removes provider entries from Cassady config only. It does not delete environment variables or provider accounts.");
|
||||
|
||||
let items = candidates
|
||||
.iter()
|
||||
.map(|candidate| {
|
||||
let label = candidate.name.as_deref().unwrap_or(&candidate.id);
|
||||
let model = candidate
|
||||
.default_model
|
||||
.as_deref()
|
||||
.unwrap_or("no default model");
|
||||
MenuItem::with_detail(
|
||||
label.to_string(),
|
||||
format!(
|
||||
"{} · {} · {} model{}",
|
||||
candidate.id,
|
||||
model,
|
||||
candidate.model_count,
|
||||
if candidate.model_count == 1 { "" } else { "s" }
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let selected =
|
||||
Menu::new("Remove saved providers", items).select_many(&BTreeSet::new(), true)?;
|
||||
let provider_ids = selected
|
||||
.into_iter()
|
||||
.map(|idx| candidates[idx].id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let label = provider_ids.join(", ");
|
||||
if !ask_yes_no(
|
||||
&format!("Remove {label} and associated model entries?"),
|
||||
false,
|
||||
)? {
|
||||
println!("Logout cancelled.");
|
||||
return Ok(LogoutResult {
|
||||
removed_provider_ids: Vec::new(),
|
||||
removed_model_count: 0,
|
||||
remaining_provider_count: candidates.len(),
|
||||
active_provider: None,
|
||||
active_model: None,
|
||||
});
|
||||
}
|
||||
|
||||
let result = remove_providers(root, &provider_ids)?;
|
||||
if result.removed_provider_ids.is_empty() {
|
||||
println!("No providers removed.");
|
||||
} else {
|
||||
success(format!(
|
||||
"Removed {} provider{} and {} model entr{}",
|
||||
result.removed_provider_ids.len(),
|
||||
if result.removed_provider_ids.len() == 1 {
|
||||
""
|
||||
} else {
|
||||
"s"
|
||||
},
|
||||
result.removed_model_count,
|
||||
if result.removed_model_count == 1 {
|
||||
"y"
|
||||
} else {
|
||||
"ies"
|
||||
}
|
||||
));
|
||||
if let Some(provider) = &result.active_provider {
|
||||
let model = result.active_model.as_deref().unwrap_or("no default model");
|
||||
info(format!("Active provider is now {provider} ({model})"));
|
||||
} else {
|
||||
warn("No providers remain. Run `cass login` before starting a chat.");
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ChosenProvider {
|
||||
name: String,
|
||||
@@ -344,24 +483,39 @@ async fn configure_provider(
|
||||
key_value("id", &provider.id);
|
||||
key_value("endpoint", &provider.base_url);
|
||||
|
||||
let api_key_env = ask_default("API key environment variable", &provider.api_key_env)?;
|
||||
if !looks_like_env_var(&api_key_env) {
|
||||
warn(format!(
|
||||
"`{api_key_env}` is an unusual environment variable name. Continuing."
|
||||
));
|
||||
}
|
||||
let api_key = match std::env::var(&api_key_env) {
|
||||
Ok(value) if !value.is_empty() => {
|
||||
success(format!("{api_key_env} is set"));
|
||||
Some(value)
|
||||
let (api_key_env, api_key) = if is_chatgpt_codex_provider(&provider.id) {
|
||||
info(
|
||||
"ChatGPT Codex uses your local Codex login instead of an API-key environment variable.",
|
||||
);
|
||||
let status = codex_auth::check_codex_auth();
|
||||
if status.is_usable() {
|
||||
success(format!("Codex auth: {}", status.summary()));
|
||||
} else {
|
||||
warn(format!("Codex auth: {}", status.summary()));
|
||||
hint(status.recovery_hint());
|
||||
}
|
||||
_ => {
|
||||
(String::new(), None)
|
||||
} else {
|
||||
let api_key_env = ask_default("API key environment variable", &provider.api_key_env)?;
|
||||
if !looks_like_env_var(&api_key_env) {
|
||||
warn(format!(
|
||||
"{api_key_env} is not set in this shell. Setup can still be saved."
|
||||
"`{api_key_env}` is an unusual environment variable name. Continuing."
|
||||
));
|
||||
hint(format!("Later, run: export {api_key_env}=..."));
|
||||
None
|
||||
}
|
||||
let api_key = match std::env::var(&api_key_env) {
|
||||
Ok(value) if !value.is_empty() => {
|
||||
success(format!("{api_key_env} is set"));
|
||||
Some(value)
|
||||
}
|
||||
_ => {
|
||||
warn(format!(
|
||||
"{api_key_env} is not set in this shell. Setup can still be saved."
|
||||
));
|
||||
hint(format!("Later, run: export {api_key_env}=..."));
|
||||
None
|
||||
}
|
||||
};
|
||||
(api_key_env, api_key)
|
||||
};
|
||||
|
||||
let model_id = choose_model(&provider, api_key.as_deref()).await?;
|
||||
@@ -424,6 +578,15 @@ fn choose_active_provider(selections: &[SetupSelection]) -> Result<usize> {
|
||||
async fn choose_model(provider: &ChosenProvider, api_key: Option<&str>) -> Result<String> {
|
||||
section("Model");
|
||||
|
||||
if is_chatgpt_codex_provider(&provider.id) {
|
||||
if let Some(model) = codex_auth::read_codex_default_model() {
|
||||
success(format!("Found Codex default model: {model}"));
|
||||
return ask_default("Model id", &model);
|
||||
}
|
||||
warn("Could not find a model in local Codex config. Using a safe default; edit it if needed.");
|
||||
return ask_default("Model id", CHATGPT_CODEX_DEFAULT_MODEL);
|
||||
}
|
||||
|
||||
let Some(api_key) = api_key else {
|
||||
warn("Model discovery was skipped because the API key is not available in this shell.");
|
||||
hint("Enter the model id manually now; Cassady will use it after the key is exported.");
|
||||
@@ -538,7 +701,9 @@ pub fn apply_setups(root: &Path, selections: &[SetupSelection], active_index: us
|
||||
for selection in selections {
|
||||
validate_provider_id(&selection.provider_id)?;
|
||||
validate_base_url(&selection.base_url)?;
|
||||
if selection.api_key_env.trim().is_empty() {
|
||||
if !is_chatgpt_codex_provider(&selection.provider_id)
|
||||
&& selection.api_key_env.trim().is_empty()
|
||||
{
|
||||
bail!("API key environment variable must not be empty");
|
||||
}
|
||||
if selection.model_id.trim().is_empty() {
|
||||
@@ -565,13 +730,207 @@ pub fn apply_setups(root: &Path, selections: &[SetupSelection], active_index: us
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn configured_providers(root: &Path) -> Result<Vec<ProviderLogoutCandidate>> {
|
||||
let providers = load_providers_or_empty(root)?;
|
||||
let models = load_models_or_empty(root)?;
|
||||
Ok(providers
|
||||
.providers
|
||||
.into_iter()
|
||||
.map(|provider| {
|
||||
let model_count = models
|
||||
.models
|
||||
.iter()
|
||||
.filter(|model| model.provider == provider.id)
|
||||
.count();
|
||||
ProviderLogoutCandidate {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
default_model: provider.default_model,
|
||||
model_count,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn remove_providers(root: &Path, provider_ids: &[String]) -> Result<LogoutResult> {
|
||||
if provider_ids.is_empty() {
|
||||
bail!("select at least one provider to remove");
|
||||
}
|
||||
let selected: BTreeSet<String> = provider_ids
|
||||
.iter()
|
||||
.map(|id| id.trim().to_string())
|
||||
.collect();
|
||||
if selected.iter().any(|id| id.is_empty()) {
|
||||
bail!("provider id must not be empty");
|
||||
}
|
||||
|
||||
fs::create_dir_all(root).with_context(|| format!("creating {}", root.display()))?;
|
||||
let mut config_file = load_config_or_default(root)?;
|
||||
let mut providers = load_providers_or_empty(root)?;
|
||||
let mut models = load_models_or_empty(root)?;
|
||||
|
||||
let existing: BTreeSet<String> = providers
|
||||
.providers
|
||||
.iter()
|
||||
.map(|provider| provider.id.clone())
|
||||
.collect();
|
||||
for id in &selected {
|
||||
if !existing.contains(id) {
|
||||
bail!("provider `{id}` is not configured");
|
||||
}
|
||||
}
|
||||
|
||||
let removed_provider_ids = providers
|
||||
.providers
|
||||
.iter()
|
||||
.filter(|provider| selected.contains(&provider.id))
|
||||
.map(|provider| provider.id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
providers
|
||||
.providers
|
||||
.retain(|provider| !selected.contains(&provider.id));
|
||||
|
||||
let before_models = models.models.len();
|
||||
models
|
||||
.models
|
||||
.retain(|model| !selected.contains(&model.provider));
|
||||
let removed_model_count = before_models - models.models.len();
|
||||
|
||||
repair_active_defaults(&mut config_file, &providers, &models);
|
||||
|
||||
write_json_pretty(&config::providers_path(root), &providers)?;
|
||||
write_json_pretty(&config::models_path(root), &models)?;
|
||||
write_json_pretty(&config::config_path(root), &config_file)?;
|
||||
|
||||
Ok(LogoutResult {
|
||||
removed_provider_ids,
|
||||
removed_model_count,
|
||||
remaining_provider_count: providers.providers.len(),
|
||||
active_provider: config_file.default_provider,
|
||||
active_model: config_file.default_model,
|
||||
})
|
||||
}
|
||||
|
||||
fn repair_active_defaults(
|
||||
config_file: &mut ConfigFile,
|
||||
providers: &ProvidersFile,
|
||||
models: &ModelsFile,
|
||||
) {
|
||||
if providers.providers.is_empty() {
|
||||
config_file.default_provider = None;
|
||||
config_file.default_model = None;
|
||||
config_file.default_reasoning_effort = None;
|
||||
return;
|
||||
}
|
||||
|
||||
let current_provider = config_file
|
||||
.default_provider
|
||||
.as_ref()
|
||||
.filter(|id| {
|
||||
providers
|
||||
.providers
|
||||
.iter()
|
||||
.any(|provider| provider.id == **id)
|
||||
})
|
||||
.cloned();
|
||||
let provider_id =
|
||||
current_provider.unwrap_or_else(|| choose_provider_with_model(providers, models));
|
||||
let model = config_file
|
||||
.default_model
|
||||
.as_ref()
|
||||
.filter(|model| model_belongs_to_provider(models, &provider_id, model))
|
||||
.cloned()
|
||||
.or_else(|| default_model_for_provider(providers, models, &provider_id));
|
||||
|
||||
config_file.default_provider = Some(provider_id);
|
||||
config_file.default_model = model;
|
||||
if let Some(effort) = config_file.default_reasoning_effort {
|
||||
if let Some(model_id) = config_file.default_model.as_deref() {
|
||||
let model = models.models.iter().find(|model| {
|
||||
model.provider == config_file.default_provider.as_deref().unwrap_or_default()
|
||||
&& model.id == model_id
|
||||
});
|
||||
config_file.default_reasoning_effort = Some(effort.clamp_for_model(model));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn choose_provider_with_model(providers: &ProvidersFile, models: &ModelsFile) -> String {
|
||||
providers
|
||||
.providers
|
||||
.iter()
|
||||
.find(|provider| {
|
||||
provider
|
||||
.default_model
|
||||
.as_ref()
|
||||
.is_some_and(|model| model_belongs_to_provider(models, &provider.id, model))
|
||||
|| provider
|
||||
.models
|
||||
.iter()
|
||||
.any(|model| model_belongs_to_provider(models, &provider.id, model))
|
||||
|| models
|
||||
.models
|
||||
.iter()
|
||||
.any(|model| model.provider == provider.id)
|
||||
})
|
||||
.or_else(|| providers.providers.first())
|
||||
.map(|provider| provider.id.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn default_model_for_provider(
|
||||
providers: &ProvidersFile,
|
||||
models: &ModelsFile,
|
||||
provider_id: &str,
|
||||
) -> Option<String> {
|
||||
let provider = providers
|
||||
.providers
|
||||
.iter()
|
||||
.find(|provider| provider.id == provider_id)?;
|
||||
provider
|
||||
.default_model
|
||||
.as_ref()
|
||||
.filter(|model| model_belongs_to_provider(models, provider_id, model))
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
provider
|
||||
.models
|
||||
.iter()
|
||||
.find(|model| model_belongs_to_provider(models, provider_id, model))
|
||||
.cloned()
|
||||
})
|
||||
.or_else(|| {
|
||||
models
|
||||
.models
|
||||
.iter()
|
||||
.find(|model| model.provider == provider_id)
|
||||
.map(|model| model.id.clone())
|
||||
})
|
||||
}
|
||||
|
||||
fn model_belongs_to_provider(models: &ModelsFile, provider_id: &str, model_id: &str) -> bool {
|
||||
models
|
||||
.models
|
||||
.iter()
|
||||
.any(|model| model.provider == provider_id && model.id == model_id)
|
||||
}
|
||||
|
||||
fn upsert_provider(providers: &mut ProvidersFile, selection: &SetupSelection) {
|
||||
let is_codex = is_chatgpt_codex_provider(&selection.provider_id);
|
||||
let new_entry = ProviderDefinition {
|
||||
id: selection.provider_id.clone(),
|
||||
name: Some(selection.provider_name.clone()),
|
||||
kind: DEFAULT_PROVIDER_KIND.to_string(),
|
||||
kind: if is_codex {
|
||||
CHATGPT_CODEX_PROVIDER_KIND.to_string()
|
||||
} else {
|
||||
DEFAULT_PROVIDER_KIND.to_string()
|
||||
},
|
||||
base_url: selection.base_url.clone(),
|
||||
api_key: format!("${}", selection.api_key_env),
|
||||
api_key: if is_codex {
|
||||
String::new()
|
||||
} else {
|
||||
format!("${}", selection.api_key_env)
|
||||
},
|
||||
default_model: Some(selection.model_id.clone()),
|
||||
models: vec![selection.model_id.clone()],
|
||||
};
|
||||
@@ -664,6 +1023,10 @@ fn validate_base_url(base_url: &str) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_chatgpt_codex_provider(id: &str) -> bool {
|
||||
id == CHATGPT_CODEX_PROVIDER_ID
|
||||
}
|
||||
|
||||
fn looks_like_env_var(value: &str) -> bool {
|
||||
let mut chars = value.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
|
||||
@@ -27,3 +27,26 @@ pub fn leave(mut terminal: CassTerminal) -> Result<()> {
|
||||
terminal.show_cursor()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn suspend(terminal: &mut CassTerminal) -> Result<()> {
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn resume(terminal: &mut CassTerminal) -> Result<()> {
|
||||
enable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
EnterAlternateScreen,
|
||||
EnableMouseCapture
|
||||
)?;
|
||||
terminal.clear()?;
|
||||
terminal.hide_cursor()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -127,6 +127,37 @@ fn reasoning_defaults_to_supported_medium_for_model_metadata() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_accepts_chatgpt_codex_without_api_key() {
|
||||
let providers = ProvidersFile {
|
||||
providers: vec![ProviderDefinition {
|
||||
id: config::CHATGPT_CODEX_PROVIDER_ID.into(),
|
||||
name: Some(config::CHATGPT_CODEX_PROVIDER_NAME.into()),
|
||||
kind: config::CHATGPT_CODEX_PROVIDER_KIND.into(),
|
||||
base_url: config::CHATGPT_CODEX_RESPONSES_URL.into(),
|
||||
api_key: String::new(),
|
||||
default_model: Some(config::CHATGPT_CODEX_DEFAULT_MODEL.into()),
|
||||
models: vec![config::CHATGPT_CODEX_DEFAULT_MODEL.into()],
|
||||
}],
|
||||
};
|
||||
let models = ModelsFile {
|
||||
models: vec![config::ModelDefinition {
|
||||
id: config::CHATGPT_CODEX_DEFAULT_MODEL.into(),
|
||||
provider: config::CHATGPT_CODEX_PROVIDER_ID.into(),
|
||||
display_name: None,
|
||||
context_length: None,
|
||||
max_output_tokens: None,
|
||||
supports_tools: true,
|
||||
supports_streaming: true,
|
||||
reasoning: Default::default(),
|
||||
}],
|
||||
};
|
||||
|
||||
let summary = config::validate_registries(None, &providers, &models);
|
||||
|
||||
assert!(summary.errors.is_empty(), "{:?}", summary.errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_rejects_duplicate_provider_ids() {
|
||||
let providers = ProvidersFile {
|
||||
|
||||
+169
-1
@@ -5,7 +5,7 @@ use wiremock::matchers::{header, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
#[test]
|
||||
fn provider_catalog_contains_expected_openai_compatible_providers() {
|
||||
fn provider_catalog_contains_expected_providers() {
|
||||
let catalog = setup::provider_catalog();
|
||||
let ids: Vec<_> = catalog.iter().map(|entry| entry.id).collect();
|
||||
|
||||
@@ -13,6 +13,7 @@ fn provider_catalog_contains_expected_openai_compatible_providers() {
|
||||
ids,
|
||||
vec![
|
||||
"openai",
|
||||
"chatgpt-codex",
|
||||
"xai",
|
||||
"fireworks",
|
||||
"groq",
|
||||
@@ -148,6 +149,33 @@ fn apply_setup_upserts_selected_provider_model_and_preserves_unrelated_entries()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_setup_writes_chatgpt_codex_without_api_key() {
|
||||
let root = tempdir().unwrap();
|
||||
|
||||
setup::apply_setup(
|
||||
root.path(),
|
||||
&SetupSelection {
|
||||
provider_id: config::CHATGPT_CODEX_PROVIDER_ID.into(),
|
||||
provider_name: config::CHATGPT_CODEX_PROVIDER_NAME.into(),
|
||||
base_url: config::CHATGPT_CODEX_RESPONSES_URL.into(),
|
||||
api_key_env: String::new(),
|
||||
model_id: config::CHATGPT_CODEX_DEFAULT_MODEL.into(),
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let providers: ProvidersFile =
|
||||
serde_json::from_str(&std::fs::read_to_string(root.path().join("providers.json")).unwrap())
|
||||
.unwrap();
|
||||
let provider = &providers.providers[0];
|
||||
assert_eq!(provider.id, config::CHATGPT_CODEX_PROVIDER_ID);
|
||||
assert_eq!(provider.kind, config::CHATGPT_CODEX_PROVIDER_KIND);
|
||||
assert!(provider.api_key.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_setups_writes_multiple_providers_and_active_choice() {
|
||||
let root = tempdir().unwrap();
|
||||
@@ -197,6 +225,146 @@ fn apply_setups_writes_multiple_providers_and_active_choice() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_providers_removes_models_and_preserves_active_provider() {
|
||||
let root = tempdir().unwrap();
|
||||
let selections = vec![
|
||||
SetupSelection {
|
||||
provider_id: "openai".into(),
|
||||
provider_name: "OpenAI".into(),
|
||||
base_url: "https://api.openai.com/v1".into(),
|
||||
api_key_env: "OPENAI_API_KEY".into(),
|
||||
model_id: "gpt-4.1".into(),
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
SetupSelection {
|
||||
provider_id: "groq".into(),
|
||||
provider_name: "Groq".into(),
|
||||
base_url: "https://api.groq.com/openai/v1".into(),
|
||||
api_key_env: "GROQ_API_KEY".into(),
|
||||
model_id: "llama-3.3-70b-versatile".into(),
|
||||
supports_tools: true,
|
||||
supports_reasoning: false,
|
||||
},
|
||||
];
|
||||
setup::apply_setups(root.path(), &selections, 0).unwrap();
|
||||
|
||||
let result = setup::remove_providers(root.path(), &["groq".to_string()]).unwrap();
|
||||
|
||||
assert_eq!(result.removed_provider_ids, vec!["groq"]);
|
||||
assert_eq!(result.removed_model_count, 1);
|
||||
assert_eq!(result.remaining_provider_count, 1);
|
||||
assert_eq!(result.active_provider.as_deref(), Some("openai"));
|
||||
assert_eq!(result.active_model.as_deref(), Some("gpt-4.1"));
|
||||
|
||||
let providers: ProvidersFile =
|
||||
serde_json::from_str(&std::fs::read_to_string(root.path().join("providers.json")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(providers.providers.len(), 1);
|
||||
assert_eq!(providers.providers[0].id, "openai");
|
||||
|
||||
let models: ModelsFile =
|
||||
serde_json::from_str(&std::fs::read_to_string(root.path().join("models.json")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(models.models.len(), 1);
|
||||
assert_eq!(models.models[0].provider, "openai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_active_provider_selects_remaining_provider_and_model() {
|
||||
let root = tempdir().unwrap();
|
||||
let selections = vec![
|
||||
SetupSelection {
|
||||
provider_id: "openai".into(),
|
||||
provider_name: "OpenAI".into(),
|
||||
base_url: "https://api.openai.com/v1".into(),
|
||||
api_key_env: "OPENAI_API_KEY".into(),
|
||||
model_id: "gpt-4.1".into(),
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
SetupSelection {
|
||||
provider_id: "groq".into(),
|
||||
provider_name: "Groq".into(),
|
||||
base_url: "https://api.groq.com/openai/v1".into(),
|
||||
api_key_env: "GROQ_API_KEY".into(),
|
||||
model_id: "llama-3.3-70b-versatile".into(),
|
||||
supports_tools: true,
|
||||
supports_reasoning: false,
|
||||
},
|
||||
];
|
||||
setup::apply_setups(root.path(), &selections, 1).unwrap();
|
||||
|
||||
let result = setup::remove_providers(root.path(), &["groq".to_string()]).unwrap();
|
||||
|
||||
assert_eq!(result.active_provider.as_deref(), Some("openai"));
|
||||
assert_eq!(result.active_model.as_deref(), Some("gpt-4.1"));
|
||||
let config: ConfigFile =
|
||||
serde_json::from_str(&std::fs::read_to_string(root.path().join("config.json")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(config.default_provider.as_deref(), Some("openai"));
|
||||
assert_eq!(config.default_model.as_deref(), Some("gpt-4.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_all_providers_clears_active_defaults() {
|
||||
let root = tempdir().unwrap();
|
||||
setup::apply_setup(
|
||||
root.path(),
|
||||
&SetupSelection {
|
||||
provider_id: "openai".into(),
|
||||
provider_name: "OpenAI".into(),
|
||||
base_url: "https://api.openai.com/v1".into(),
|
||||
api_key_env: "OPENAI_API_KEY".into(),
|
||||
model_id: "gpt-4.1".into(),
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = setup::remove_providers(root.path(), &["openai".to_string()]).unwrap();
|
||||
|
||||
assert_eq!(result.remaining_provider_count, 0);
|
||||
assert!(result.active_provider.is_none());
|
||||
assert!(result.active_model.is_none());
|
||||
|
||||
let config: ConfigFile =
|
||||
serde_json::from_str(&std::fs::read_to_string(root.path().join("config.json")).unwrap())
|
||||
.unwrap();
|
||||
assert!(config.default_provider.is_none());
|
||||
assert!(config.default_model.is_none());
|
||||
|
||||
let models: ModelsFile =
|
||||
serde_json::from_str(&std::fs::read_to_string(root.path().join("models.json")).unwrap())
|
||||
.unwrap();
|
||||
assert!(models.models.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_unknown_provider_fails() {
|
||||
let root = tempdir().unwrap();
|
||||
setup::apply_setup(
|
||||
root.path(),
|
||||
&SetupSelection {
|
||||
provider_id: "openai".into(),
|
||||
provider_name: "OpenAI".into(),
|
||||
base_url: "https://api.openai.com/v1".into(),
|
||||
api_key_env: "OPENAI_API_KEY".into(),
|
||||
model_id: "gpt-4.1".into(),
|
||||
supports_tools: true,
|
||||
supports_reasoning: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = setup::remove_providers(root.path(), &["missing".to_string()]).unwrap_err();
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("provider `missing` is not configured"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_initial_setup_detects_empty_and_default_only_roots() {
|
||||
let root = tempdir().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user