mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-03 05:19:21 +00:00
Merge pull request #1033 from kaitranntt/dev
feat(release): promote dev to main
This commit is contained in:
213 files changed
+13012
-2079
No files matched your search
@@ -34,6 +34,7 @@ package-lock.json
|
||||
|
||||
.claude/active-plan
|
||||
.claude/agent-memory/
|
||||
.claude/plans-registry.json*
|
||||
|
||||
# Logs directory
|
||||
logs/
|
||||
|
||||
@@ -50,6 +50,40 @@ ccs glm
|
||||
ccs ollama
|
||||
```
|
||||
|
||||
## OpenAI-Compatible Routing
|
||||
|
||||
CCS can now bridge Claude Code into OpenAI-compatible providers through a local
|
||||
Anthropic-compatible proxy instead of requiring a native Anthropic upstream.
|
||||
|
||||
```bash
|
||||
ccs api create --preset hf
|
||||
ccs hf
|
||||
```
|
||||
|
||||
Need to manage the proxy manually?
|
||||
|
||||
```bash
|
||||
ccs proxy start hf
|
||||
eval "$(ccs proxy activate)"
|
||||
```
|
||||
|
||||
The proxy also supports request-time `profile:model` selectors, scenario-based
|
||||
model routing through `proxy.routing`, and explicit activation helpers such as
|
||||
`ccs proxy activate --fish`.
|
||||
|
||||
Guide: [OpenAI-Compatible Provider Routing](./docs/openai-compatible-providers.md)
|
||||
|
||||
### Related Project: claude-code-router
|
||||
|
||||
[claude-code-router](https://github.com/musistudio/claude-code-router) is an
|
||||
excellent standalone tool for routing Claude Code requests to OpenAI-compatible
|
||||
providers. CCS's local proxy and SSE transformation work was directly informed
|
||||
by CCR's transformer architecture.
|
||||
|
||||
Use CCR when you want a standalone router without CCS profile management.
|
||||
Use CCS when you want the routing flow integrated with CCS profiles, runtime
|
||||
bridges, and the existing `ccs` command surface.
|
||||
|
||||
Need the full setup path instead of the short version?
|
||||
|
||||
| Need | Start here |
|
||||
@@ -93,8 +127,10 @@ Deep dive:
|
||||

|
||||
|
||||
CCS can provision first-class local tools like WebSearch and image analysis for
|
||||
third-party launches instead of leaving you to wire them by hand. Deep dive:
|
||||
[WebSearch](https://docs.ccs.kaitran.ca/features/ai/websearch).
|
||||
third-party launches instead of leaving you to wire them by hand. Browser
|
||||
automation now has a first-class setup path as well. Deep dive:
|
||||
[WebSearch](https://docs.ccs.kaitran.ca/features/ai/websearch) |
|
||||
[Browser Automation](./docs/browser-automation.md).
|
||||
|
||||
## Docs Matrix
|
||||
|
||||
@@ -110,6 +146,7 @@ reference material.
|
||||
| Compare OAuth providers, Claude accounts, and API profiles | [Provider Overview](https://docs.ccs.kaitran.ca/providers/concepts/overview) |
|
||||
| Learn the dashboard structure and feature pages | [Dashboard Overview](https://docs.ccs.kaitran.ca/features/dashboard/overview) |
|
||||
| Configure profiles, paths, and environment variables | [Configuration](https://docs.ccs.kaitran.ca/getting-started/configuration) |
|
||||
| Understand browser attach vs Codex browser tooling | [Browser Automation](./docs/browser-automation.md) |
|
||||
| Keep OpenCode aligned with your live CCS setup | [OpenCode Sync Plugin](https://docs.ccs.kaitran.ca/features/workflow/opencode-sync) |
|
||||
| Browse every command and flag | [CLI Commands](https://docs.ccs.kaitran.ca/reference/cli-commands) |
|
||||
| Recover from install, auth, or provider failures | [Troubleshooting](https://docs.ccs.kaitran.ca/reference/troubleshooting) |
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/codebuddy",
|
||||
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
|
||||
"ANTHROPIC_MODEL": "auto",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-5.1",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "kimi-k2.5",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "deepseek-v3-2-volc"
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/codex",
|
||||
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
|
||||
"ANTHROPIC_MODEL": "gpt-5-codex",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "gpt-5-codex",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5-codex",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-5-codex-mini"
|
||||
"ANTHROPIC_MODEL": "gpt-5.4",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "gpt-5.4",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5.4",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-5.4-mini"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/cursor",
|
||||
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
|
||||
"ANTHROPIC_MODEL": "composer-2",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-4-sonnet",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-4-sonnet",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "cursor-small"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/gitlab",
|
||||
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
|
||||
"ANTHROPIC_MODEL": "gitlab-duo",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "duo-chat-opus-4-6",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "duo-chat-sonnet-4-6",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "duo-chat-haiku-4-5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/kilo",
|
||||
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
|
||||
"ANTHROPIC_MODEL": "kilo/auto",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "kilo/auto",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "kilo/auto",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "kilo/auto"
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,8 @@ environment:
|
||||
CCS_DASHBOARD_PASSWORD_HASH: "<bcrypt-hash>"
|
||||
```
|
||||
|
||||
Running `ccs config auth setup` on the outer host shell updates that machine's own `~/.ccs`, not the Docker volume mounted into `ccs-cliproxy`. For the integrated stack, configure auth inside the container or provide the auth env vars in Compose.
|
||||
|
||||
Generate a bcrypt hash:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
# Browser Automation
|
||||
|
||||
Last Updated: 2026-04-16
|
||||
|
||||
CCS provides browser automation through two separate runtime paths:
|
||||
|
||||
- **Claude Browser Attach**: reuses a running Chrome/Chromium session through the CCS-managed local `ccs-browser` MCP runtime
|
||||
- **Codex Browser Tools**: injects Playwright MCP tooling into Codex-target launches
|
||||
|
||||
These are related, but they are not the same implementation and they do not promise a shared browser session.
|
||||
|
||||
## How Browser Automation Works
|
||||
|
||||
### Claude Browser Attach
|
||||
|
||||
Claude-target CCS launches can provision a managed local MCP server named `ccs-browser`.
|
||||
That path is designed for workflows where you want Claude to interact with a browser session
|
||||
that already has useful authenticated state.
|
||||
|
||||
Claude Browser Attach requires a browser launched in attach mode with remote debugging
|
||||
enabled. A recent Chrome update alone is not sufficient.
|
||||
|
||||
### Codex Browser Tools
|
||||
|
||||
Codex-target CCS launches use a separate managed path: CCS injects Playwright MCP overrides
|
||||
for the `ccs_browser` runtime config entry.
|
||||
|
||||
This is configured from the same Browser settings surface, but it is distinct from Claude
|
||||
Browser Attach.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Via Dashboard
|
||||
|
||||
Open `ccs config` -> `Settings` -> `Browser`.
|
||||
|
||||
The Browser screen exposes two sections:
|
||||
|
||||
- **Claude Browser Attach**
|
||||
- enable/disable the Claude attach lane
|
||||
- choose the Chrome user-data directory
|
||||
- set the expected DevTools port
|
||||
- review readiness and next-step guidance
|
||||
- copy a generated browser launch command
|
||||
- **Codex Browser Tools**
|
||||
- enable/disable CCS-managed browser tooling for Codex-target launches
|
||||
- review whether the detected Codex build supports managed browser overrides
|
||||
|
||||
### Via CLI
|
||||
|
||||
```bash
|
||||
ccs help browser
|
||||
ccs browser status
|
||||
ccs browser doctor
|
||||
```
|
||||
|
||||
Use `ccs browser status` for the current state and `ccs browser doctor` for actionable
|
||||
troubleshooting guidance.
|
||||
|
||||
### Via Config File
|
||||
|
||||
Edit `~/.ccs/config.yaml`:
|
||||
|
||||
```yaml
|
||||
browser:
|
||||
claude:
|
||||
enabled: false
|
||||
user_data_dir: "~/.ccs/browser/chrome-user-data"
|
||||
devtools_port: 9222
|
||||
codex:
|
||||
enabled: true
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `claude.user_data_dir` is a **Chrome user-data directory**, not a display-name browser profile
|
||||
- `claude.devtools_port` is the expected remote debugging port for attach mode
|
||||
- `codex.enabled` controls whether CCS injects browser tooling into Codex-target launches
|
||||
|
||||
## Environment Variable Overrides
|
||||
|
||||
CCS still supports environment-variable overrides for backward compatibility.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `CCS_BROWSER_USER_DATA_DIR` | Preferred override for Claude Browser Attach user-data dir |
|
||||
| `CCS_BROWSER_PROFILE_DIR` | Legacy alias for the same attach directory |
|
||||
| `CCS_BROWSER_DEVTOOLS_PORT` | Explicit DevTools port override |
|
||||
|
||||
If an override is active, Browser status surfaces should report that the current session is being
|
||||
managed externally by environment variables.
|
||||
|
||||
Override precedence is:
|
||||
|
||||
1. `CCS_BROWSER_USER_DATA_DIR`
|
||||
2. `CCS_BROWSER_PROFILE_DIR`
|
||||
3. the persisted `browser.claude.user_data_dir` config value
|
||||
|
||||
Config-backed Browser Attach always passes an explicit DevTools port to the runtime, even when the
|
||||
effective value is the default `9222`. Metadata-based port discovery is preserved only for the
|
||||
legacy `CCS_BROWSER_PROFILE_DIR` flow when `CCS_BROWSER_DEVTOOLS_PORT` is not set.
|
||||
|
||||
## Managed Runtime Files
|
||||
|
||||
- `~/.claude.json` -> CCS manages `mcpServers.ccs-browser` for Claude Browser Attach
|
||||
- `~/.ccs/mcp/ccs-browser-server.cjs` -> local Claude Browser Attach MCP runtime
|
||||
- `Codex runtime config overrides` -> CCS manages the `ccs_browser` MCP entry for Codex-target launches
|
||||
|
||||
Do not treat the generic Codex MCP editor as the primary browser setup path. CCS-managed browser
|
||||
entries should be configured from `Settings -> Browser`.
|
||||
|
||||
## Launching Chrome For Claude Attach
|
||||
|
||||
Claude Browser Attach needs a browser launched with remote debugging.
|
||||
|
||||
Typical examples:
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
open -na "Google Chrome" --args --remote-debugging-port=9222 --user-data-dir="$HOME/.ccs/browser/chrome-user-data"
|
||||
|
||||
# Linux
|
||||
google-chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.ccs/browser/chrome-user-data"
|
||||
|
||||
# Windows
|
||||
chrome.exe --remote-debugging-port=9222 --user-data-dir="%USERPROFILE%\\.ccs\\browser\\chrome-user-data"
|
||||
```
|
||||
|
||||
Using a dedicated CCS browser data dir is recommended. It avoids profile-locking issues and keeps
|
||||
automation state separate from your daily browser profile.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Browser status says Claude Browser Attach is disabled
|
||||
|
||||
Enable Claude Browser Attach in `Settings -> Browser` or via the browser config block in
|
||||
`~/.ccs/config.yaml`.
|
||||
|
||||
### Browser status says the path is missing
|
||||
|
||||
The configured Chrome user-data directory does not exist yet.
|
||||
|
||||
1. Create the directory or use the generated launch command
|
||||
2. Start Chrome in attach mode with `--remote-debugging-port`
|
||||
3. Rerun `ccs browser doctor`
|
||||
|
||||
### Browser status says no running browser session was found
|
||||
|
||||
CCS could not find usable DevTools attach metadata for the configured user-data directory.
|
||||
|
||||
1. Make sure Chrome was started with `--remote-debugging-port=<port>`
|
||||
2. Make sure it is using the same `user_data_dir` configured in CCS
|
||||
3. Rerun `ccs browser doctor`
|
||||
|
||||
### Browser status says the DevTools endpoint is unreachable
|
||||
|
||||
CCS found attach metadata, but the endpoint did not answer successfully.
|
||||
|
||||
1. Restart the attach browser session
|
||||
2. Confirm the expected port matches the real remote debugging port
|
||||
3. Rerun `ccs browser status`
|
||||
|
||||
### Codex Browser Tools are unavailable
|
||||
|
||||
Codex browser tooling depends on a Codex build that supports `--config` overrides.
|
||||
|
||||
If CCS reports `unsupported_build`, upgrade Codex and rerun `ccs browser status`.
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Browser automation may operate inside authenticated browser sessions
|
||||
- Prefer a dedicated automation user-data dir instead of your everyday browser profile
|
||||
- Do not commit browser paths, secrets, or generated session state to version control
|
||||
- Treat `~/.ccs/config.yaml`, `~/.claude.json`, and the browser user-data directory as local machine state
|
||||
+32
-18
@@ -1,17 +1,20 @@
|
||||
# Cursor IDE Integration
|
||||
|
||||
This guide covers the current CCS-owned Cursor runtime, including auth import, local daemon lifecycle, live probe checks, and dashboard controls.
|
||||
This guide covers the deprecated CCS-owned Cursor IDE bridge, including auth import, local daemon lifecycle, live probe checks, and dashboard controls.
|
||||
|
||||
`ccs cursor` now belongs to the CLIProxy-backed Cursor provider path.
|
||||
Use `ccs legacy cursor` for the deprecated local bridge documented here.
|
||||
|
||||
## What It Provides
|
||||
|
||||
- OpenAI-compatible local endpoint powered by Cursor credentials.
|
||||
- Anthropic-compatible local endpoint at `/v1/messages` for Claude-native clients.
|
||||
- Cursor model list and chat completions via the local CCS daemon.
|
||||
- Dedicated dashboard page: `ccs config` -> `Cursor IDE`.
|
||||
- Dedicated dashboard page: `ccs config` -> `Deprecated` -> `Cursor IDE (Legacy)`.
|
||||
|
||||
## What This Runtime Actually Does
|
||||
|
||||
`ccs cursor` does not launch Cursor IDE itself.
|
||||
`ccs legacy cursor` does not launch Cursor IDE itself.
|
||||
|
||||
The current workflow is:
|
||||
1. import Cursor credentials from local SQLite or manual input
|
||||
@@ -32,7 +35,7 @@ Treat this as a CCS-managed Cursor bridge, not a generic CLIProxy-backed provide
|
||||
### 1) Enable integration
|
||||
|
||||
```bash
|
||||
ccs cursor enable
|
||||
ccs legacy cursor enable
|
||||
```
|
||||
|
||||
### 2) Import credentials
|
||||
@@ -40,25 +43,25 @@ ccs cursor enable
|
||||
Auto-detect from Cursor local SQLite state:
|
||||
|
||||
```bash
|
||||
ccs cursor auth
|
||||
ccs legacy cursor auth
|
||||
```
|
||||
|
||||
Manual fallback:
|
||||
|
||||
```bash
|
||||
ccs cursor auth --manual --token <token> --machine-id <machine-id>
|
||||
ccs legacy cursor auth --manual --token <token> --machine-id <machine-id>
|
||||
```
|
||||
|
||||
### 3) Start daemon
|
||||
|
||||
```bash
|
||||
ccs cursor start
|
||||
ccs legacy cursor start
|
||||
```
|
||||
|
||||
### 4) Run a live probe
|
||||
|
||||
```bash
|
||||
ccs cursor probe
|
||||
ccs legacy cursor probe
|
||||
```
|
||||
|
||||
Use this to verify that the current build can complete one real authenticated request through the local daemon.
|
||||
@@ -66,26 +69,37 @@ Use this to verify that the current build can complete one real authenticated re
|
||||
### 5) Run Cursor-backed Claude
|
||||
|
||||
```bash
|
||||
ccs cursor "explain this repo"
|
||||
ccs legacy cursor "explain this repo"
|
||||
```
|
||||
|
||||
### 6) Verify status
|
||||
|
||||
```bash
|
||||
ccs cursor status
|
||||
ccs legacy cursor status
|
||||
```
|
||||
|
||||
Use `ccs cursor` with bare or normal Claude args to run through the local Cursor proxy.
|
||||
Use `ccs legacy cursor` with bare or normal Claude args to run through the local Cursor proxy.
|
||||
The admin namespace remains available for setup and inspection:
|
||||
|
||||
```bash
|
||||
ccs cursor help
|
||||
ccs legacy cursor help
|
||||
```
|
||||
|
||||
### 7) Stop daemon
|
||||
|
||||
```bash
|
||||
ccs cursor stop
|
||||
ccs legacy cursor stop
|
||||
```
|
||||
|
||||
## Supported Cursor Provider Path
|
||||
|
||||
For the supported CLIProxy-backed Cursor provider, use:
|
||||
|
||||
```bash
|
||||
ccs cursor --auth
|
||||
ccs cursor --accounts
|
||||
ccs cursor --config
|
||||
ccs cursor "task"
|
||||
```
|
||||
|
||||
## Runtime Defaults
|
||||
@@ -96,7 +110,7 @@ ccs cursor stop
|
||||
- Model list resolution: authenticated live fetch when available, with cached/default fallback.
|
||||
- Request model validation: if a requested model is not present in the available Cursor model catalog, daemon falls back to the resolved default model.
|
||||
- Daemon API surface: `POST /v1/chat/completions`, `POST /v1/messages`, and `GET /v1/models`.
|
||||
- Live verification: `ccs cursor probe` or `POST /api/cursor/probe`
|
||||
- Live verification: `ccs legacy cursor probe` or `POST /api/cursor/probe`
|
||||
|
||||
These values are managed in unified config and can be updated from CLI or dashboard.
|
||||
|
||||
@@ -108,7 +122,7 @@ Open dashboard:
|
||||
ccs config
|
||||
```
|
||||
|
||||
Then navigate to `Cursor IDE` in the sidebar.
|
||||
Then navigate to `Cursor IDE (Legacy)` in the `Deprecated` section.
|
||||
|
||||
Available controls:
|
||||
|
||||
@@ -131,9 +145,9 @@ When raw settings include a local `ANTHROPIC_BASE_URL` port override, CCS synchr
|
||||
|
||||
### `Not authenticated` or `expired` in `ccs cursor status`
|
||||
|
||||
- Re-run `ccs cursor auth` (or manual auth command).
|
||||
- Re-run `ccs legacy cursor auth` (or manual auth command).
|
||||
|
||||
### `ccs cursor probe` fails even though status is green
|
||||
### `ccs legacy cursor probe` fails even though status is green
|
||||
|
||||
- `status` proves local config/auth/daemon readiness only.
|
||||
- `probe` proves the live runtime path.
|
||||
@@ -148,4 +162,4 @@ When raw settings include a local `ANTHROPIC_BASE_URL` port override, CCS synchr
|
||||
### Daemon fails to start
|
||||
|
||||
- Check if port `20129` is in use.
|
||||
- Change port in dashboard config tab, then retry `ccs cursor start`.
|
||||
- Change port in dashboard config tab, then retry `ccs legacy cursor start`.
|
||||
@@ -12,6 +12,12 @@ Authentication is **disabled by default** for backward compatibility. Use the CL
|
||||
|
||||
CCS does **not** ship a default dashboard username or password. When someone opens the dashboard from a non-loopback/IP address before auth is enabled, the UI now shows a setup state instead of an ambiguous login form. The host owner must run `ccs config auth setup`, or the user should switch back to the localhost URL if they are on the same machine.
|
||||
|
||||
Docker note: the integrated `ccs docker` stack stores its config inside the running container volume, not in the outer shell's `~/.ccs`. For Docker deployments, run auth setup inside the container:
|
||||
|
||||
```bash
|
||||
docker exec -it ccs-cliproxy ccs config auth setup
|
||||
```
|
||||
|
||||
When auth stays disabled, CCS now applies a localhost-only fallback on sensitive management endpoints. Remote devices can still open the dashboard UI when you intentionally bind it beyond loopback, but write-capable routes such as AI Provider management and CLIProxy auth/status helpers reject non-loopback requests until you enable dashboard auth.
|
||||
|
||||
## Account Context Modes (Related Feature)
|
||||
@@ -193,6 +199,8 @@ dashboard_auth:
|
||||
|
||||
Run `ccs config auth setup` to configure credentials.
|
||||
|
||||
If you are using the integrated Docker stack, run that command inside `ccs-cliproxy`. Running it on the outer host shell updates a different config directory and will not unlock the running dashboard.
|
||||
|
||||
### Forgot password
|
||||
|
||||
Run `ccs config auth setup` again to set a new password.
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
# OpenAI-Compatible Provider Routing
|
||||
|
||||
CCS can route Claude Code traffic through a local Anthropic-compatible proxy when
|
||||
your API profile points at an OpenAI-compatible chat completions endpoint.
|
||||
|
||||
This is useful for providers such as:
|
||||
|
||||
- Hugging Face Inference Providers
|
||||
- OpenRouter
|
||||
- Ollama
|
||||
- llama.cpp servers
|
||||
- OpenAI-compatible self-hosted gateways
|
||||
|
||||
## Related Project: claude-code-router
|
||||
|
||||
[claude-code-router](https://github.com/musistudio/claude-code-router) is the
|
||||
main external reference that informed this CCS work. Their Anthropic/OpenAI
|
||||
transformer design helped shape the routing approach here.
|
||||
|
||||
When to use CCR:
|
||||
|
||||
- you want a standalone router without CCS profile integration
|
||||
- you do not need CCS account/runtime management around the request flow
|
||||
|
||||
When to use CCS:
|
||||
|
||||
- you already use CCS API profiles or runtime bridges
|
||||
- you want the proxy flow available through `ccs <profile>` and `ccs proxy ...`
|
||||
- you want the routing behavior documented and tested inside the CCS workflow
|
||||
|
||||
## What CCS Does
|
||||
|
||||
When you launch a compatible settings profile with the Claude target, CCS now:
|
||||
|
||||
1. Starts a local proxy on `127.0.0.1`
|
||||
2. Accepts Anthropic `/v1/messages` traffic from Claude Code
|
||||
3. Translates requests into OpenAI chat-completions format
|
||||
4. Forwards them to your configured upstream provider
|
||||
5. Translates streaming responses back into Anthropic SSE
|
||||
|
||||
You do not need to rewrite your profile by hand each time.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Create or reuse an API profile that points at an OpenAI-compatible endpoint:
|
||||
|
||||
```bash
|
||||
ccs api create --preset hf
|
||||
```
|
||||
|
||||
Then you can use the profile directly:
|
||||
|
||||
```bash
|
||||
ccs hf
|
||||
```
|
||||
|
||||
CCS detects that the profile is OpenAI-compatible and auto-routes Claude Code
|
||||
through the local proxy.
|
||||
|
||||
## Manual Proxy Lifecycle
|
||||
|
||||
If you want to manage the proxy explicitly:
|
||||
|
||||
```bash
|
||||
ccs proxy start hf
|
||||
eval "$(ccs proxy activate)"
|
||||
ccs proxy status
|
||||
ccs proxy stop
|
||||
```
|
||||
|
||||
Useful variants:
|
||||
|
||||
```bash
|
||||
ccs proxy start hf --host 127.0.0.1
|
||||
ccs proxy activate --fish
|
||||
```
|
||||
|
||||
`ccs proxy activate` now prints the full local runtime contract:
|
||||
|
||||
- `ANTHROPIC_BASE_URL`
|
||||
- `ANTHROPIC_AUTH_TOKEN`
|
||||
- `ANTHROPIC_MODEL` plus tier defaults when present
|
||||
- `DISABLE_TELEMETRY`
|
||||
- `DISABLE_COST_WARNINGS`
|
||||
- `API_TIMEOUT_MS`
|
||||
- `NO_PROXY`
|
||||
|
||||
## One Active Proxy Profile
|
||||
|
||||
The current runtime is a single local proxy daemon.
|
||||
|
||||
- Reusing the same OpenAI-compatible profile is supported
|
||||
- Starting a different OpenAI-compatible profile while one proxy is already
|
||||
running is rejected instead of silently replacing the active upstream
|
||||
|
||||
This is intentional to avoid breaking an in-flight Claude session by swapping
|
||||
its upstream provider out from under it.
|
||||
|
||||
## Request-Time Routing
|
||||
|
||||
The proxy is no longer limited to the startup profile's default model.
|
||||
|
||||
Supported request-time selectors:
|
||||
|
||||
- `profile:model`
|
||||
Example: `deepseek:deepseek-reasoner`
|
||||
- `profile`
|
||||
Example: `openrouter`
|
||||
- plain model ids
|
||||
Example: `deepseek-chat`
|
||||
|
||||
Plain model ids use exact string equality against the configured profile model
|
||||
slots (`model`, `opusModel`, `sonnetModel`, `haikuModel`). CCS does not apply
|
||||
fuzzy matching or prefix matching here. If no exact match is found, the request
|
||||
stays on the active profile with the requested model id unchanged.
|
||||
|
||||
Routing behavior:
|
||||
|
||||
1. `profile:model` wins immediately.
|
||||
2. Scenario routing may override the active profile when configured.
|
||||
3. Plain model ids are matched against the configured OpenAI-compatible
|
||||
profiles before falling back to the active profile.
|
||||
|
||||
This means a Claude session launched through one compatible profile can still
|
||||
request another compatible profile/model when the proxy can resolve it safely.
|
||||
|
||||
## Scenario Routing
|
||||
|
||||
Scenario routing is now supported through `proxy.routing` in your CCS config.
|
||||
|
||||
Example `~/.ccs/config.yaml`:
|
||||
|
||||
```yaml
|
||||
proxy:
|
||||
routing:
|
||||
default: "deepseek:deepseek-chat"
|
||||
background: "ollama:qwen2.5-coder:0.5b"
|
||||
think: "deepseek:deepseek-reasoner"
|
||||
longContext: "openrouter:google/gemini-2.5-pro"
|
||||
longContextThreshold: 60000
|
||||
webSearch: "openrouter:perplexity/sonar-pro"
|
||||
```
|
||||
|
||||
Current scenario detection:
|
||||
|
||||
- `background`: requested model contains `haiku`
|
||||
- `think`: Anthropic `thinking` is enabled
|
||||
- `longContext`: estimated request tokens exceed `longContextThreshold`
|
||||
- `webSearch`: tool list includes `web_search`
|
||||
- `default`: fallback selector when the above do not apply
|
||||
|
||||
Routing decisions are logged through CCS structured logs.
|
||||
|
||||
`longContextThreshold` uses an intentionally approximate token estimate based on
|
||||
message characters, tool payload size, and a `chars / 4` heuristic. Tune the
|
||||
threshold conservatively if your routing decision needs a sharper cutoff near
|
||||
the boundary.
|
||||
|
||||
## How Profile Detection Works
|
||||
|
||||
CCS keeps these profiles in the normal API/settings-profile flow.
|
||||
|
||||
Anthropic-compatible endpoints such as:
|
||||
|
||||
- `https://api.anthropic.com`
|
||||
- `https://api.z.ai/api/anthropic`
|
||||
- `https://api.deepseek.com/anthropic`
|
||||
|
||||
continue to launch directly.
|
||||
|
||||
OpenAI-compatible endpoints such as:
|
||||
|
||||
- `https://router.huggingface.co/v1`
|
||||
- `https://api.openai.com/v1`
|
||||
- `http://localhost:11434`
|
||||
|
||||
are routed through the local proxy for Claude-target launches.
|
||||
|
||||
## Provider Setup
|
||||
|
||||
### DeepSeek
|
||||
|
||||
Use a settings profile whose env looks like:
|
||||
|
||||
```json
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/v1",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-...",
|
||||
"ANTHROPIC_MODEL": "deepseek-chat",
|
||||
"CCS_DROID_PROVIDER": "generic-chat-completion-api"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Typical override target:
|
||||
|
||||
- `deepseek:deepseek-reasoner`
|
||||
|
||||
### OpenRouter
|
||||
|
||||
```json
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api/v1",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-or-...",
|
||||
"ANTHROPIC_MODEL": "openai/gpt-4.1-mini",
|
||||
"CCS_DROID_PROVIDER": "generic-chat-completion-api"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Useful when you want:
|
||||
|
||||
- model fan-out behind one provider profile
|
||||
- long-context or web-search scenario targets
|
||||
|
||||
### Ollama / Local Gateways
|
||||
|
||||
```json
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "http://127.0.0.1:11434",
|
||||
"ANTHROPIC_AUTH_TOKEN": "ollama",
|
||||
"ANTHROPIC_MODEL": "qwen3-coder",
|
||||
"CCS_DROID_PROVIDER": "generic-chat-completion-api"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For self-signed HTTPS gateways, add `CCS_OPENAI_PROXY_INSECURE=1`.
|
||||
|
||||
### DashScope / Qwen Compatible Mode
|
||||
|
||||
DashScope's compatible endpoint works even when older settings files still
|
||||
carry a stale Anthropic-style provider hint:
|
||||
|
||||
```json
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://dashscope-us.aliyuncs.com/compatible-mode/v1",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-...",
|
||||
"ANTHROPIC_MODEL": "qwen3.6-plus",
|
||||
"CCS_DROID_PROVIDER": "anthropic"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
CCS now infers the OpenAI-compatible route from the base URL and does not let
|
||||
that stale provider hint block proxy routing.
|
||||
|
||||
## Self-Signed TLS
|
||||
|
||||
If your upstream gateway uses a self-signed or privately issued certificate,
|
||||
set this in the profile settings JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"env": {
|
||||
"CCS_OPENAI_PROXY_INSECURE": "1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
That flag is respected by both:
|
||||
|
||||
- `ccs <profile>` auto-routing
|
||||
- `ccs proxy start <profile>`
|
||||
|
||||
## Supported Runtime Paths
|
||||
|
||||
- `ccs <profile>` with Claude target: auto-starts the local proxy when needed
|
||||
- `ccs proxy start <profile>`: starts the proxy explicitly
|
||||
- `GET /`: proxy info and bound profile details
|
||||
- `GET /health`: proxy liveness check
|
||||
- `GET /v1/models`: local view of the configured model mapping
|
||||
- `POST /v1/messages`: Anthropic-compatible request entrypoint
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Missing or invalid local proxy token
|
||||
|
||||
- Re-run `eval "$(ccs proxy activate)"`
|
||||
- Check `ccs proxy status` and confirm the expected profile is running
|
||||
|
||||
### Self-signed or private CA upstream
|
||||
|
||||
- Add `CCS_OPENAI_PROXY_INSECURE=1` to the profile settings
|
||||
- Restart the proxy after changing the setting
|
||||
|
||||
### Port conflict on `3456`
|
||||
|
||||
- Start with a fixed port: `ccs proxy start hf --port 3457`
|
||||
- Re-run `ccs proxy activate` after changing the port
|
||||
|
||||
### Provider returns `429` or empty upstream output
|
||||
|
||||
- CCS now preserves upstream rate-limit errors and retry headers
|
||||
- Empty or malformed provider JSON is returned as Anthropic-style `api_error`
|
||||
|
||||
### Requests route to the wrong model/profile
|
||||
|
||||
- Use an explicit selector such as `profile:model`
|
||||
- Review `proxy.routing` if scenario routing is enabled
|
||||
- Check CCS structured logs in `~/.ccs/logs/current.jsonl` for routing decisions
|
||||
|
||||
## Validation
|
||||
|
||||
The shipped coverage includes:
|
||||
|
||||
- unit tests for OpenAI-compatible profile detection
|
||||
- unit tests for Anthropic -> OpenAI request translation
|
||||
- unit tests for request-time profile/model routing and scenario routing
|
||||
- unit tests for multi-line SSE parsing
|
||||
- integration tests for `/v1/messages` request/response translation
|
||||
- integration tests for rate limits, empty upstream responses, timeout handling,
|
||||
thinking/tool-call chunk streaming, and request-time routing
|
||||
- integration tests for daemon lifecycle and `/health` / `/v1/models`
|
||||
- e2e tests for `ccs proxy` lifecycle
|
||||
- e2e tests for `ccs <profile>` auto-routing through a mock upstream
|
||||
|
||||
Focused verification command:
|
||||
|
||||
```bash
|
||||
bun test tests/e2e/proxy-command.e2e.test.ts tests/integration/proxy/request-routing.test.ts --coverage
|
||||
```
|
||||
|
||||
Pre-merge gate:
|
||||
|
||||
```bash
|
||||
bun run validate
|
||||
```
|
||||
@@ -1,6 +1,6 @@
|
||||
# CCS Project Roadmap
|
||||
|
||||
Last Updated: 2026-04-10
|
||||
Last Updated: 2026-04-14
|
||||
|
||||
Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans.
|
||||
|
||||
@@ -41,6 +41,10 @@ All major modularization work is complete. The codebase evolved from monolithic
|
||||
|
||||
### Recent Fixes
|
||||
|
||||
- **2026-04-16**: **#1030** Browser automation is now a first-class CCS surface instead of an env-only/runtime-only feature. CCS adds `ccs help browser`, `ccs browser status`, and `ccs browser doctor`; a dedicated `Settings -> Browser` dashboard tab for Claude Browser Attach and Codex Browser Tools; a new `browser` section in `~/.ccs/config.yaml`; explicit readiness/next-step messaging for attach-mode Chrome sessions; and Codex UI guidance that marks the managed `ccs_browser` entry as CCS-owned and redirects browser setup away from the generic MCP editor.
|
||||
- **2026-04-15**: **#969** Local CLIProxy bootstrap no longer depends on live GitHub reachability during normal dashboard and runtime startup. CCS now skips hidden auto-update lookups on standard CLIProxy bootstrap paths, fails fast with explicit `ccs cliproxy install` guidance when a service start needs a binary that is not installed locally, and keeps `ccs config` able to open the dashboard in limited mode instead of stalling behind blocked release downloads.
|
||||
- **2026-04-15**: **#1010** Remote dashboard auth guidance now explains the Docker boundary explicitly. The readonly banner, remote login/setup card, and dashboard-auth docs now tell users that integrated Docker deployments keep config inside the running `ccs-cliproxy` container volume, so `ccs config auth setup` must run there rather than in the outer host shell.
|
||||
- **2026-04-14**: **#991** CCS now auto-routes Claude-target settings profiles that use OpenAI-compatible endpoints through a local Anthropic-compatible proxy instead of sending raw Anthropic `/v1/messages` traffic directly to chat-completions backends. The `ccs proxy` command now supports `start`, `status`, `activate`, and `stop` with explicit host binding, shell-aware activation helpers, and a fuller local runtime env contract. The proxy surface now exposes `GET /`, `/health`, `/v1/models`, and `/v1/messages`, logs routing decisions into CCS structured logs, supports Anthropic image blocks plus request-time `profile:model` overrides, and adds config-driven scenario routing (`background`, `think`, `longContext`, `webSearch`) on top of the compatible-profile path. Coverage now includes request routing, rate-limit/timeout/empty-upstream failures, chunked tool-call streaming, and disconnect cleanup alongside the existing unit, integration, and e2e suites.
|
||||
- **2026-04-10**: **#765** `/providers` now includes a first-class Hugging Face preset for API Profiles. CCS exposes Hugging Face Inference Providers through the existing OpenAI-compatible profile flow with the official router endpoint `https://router.huggingface.co/v1`, a short `hf` default profile name, and `hf` preset alias support for both the dashboard chooser and `ccs api create --preset hf`.
|
||||
- **2026-04-10**: **#944** Image Analysis auth readiness no longer collapses to native Read when merged runtime-status dependency overrides include a missing initializer value. CCS now preserves default dependency functions when override entries are `undefined`, still reads token-backed auth status directly in the local readiness path, and includes regression coverage for the missing-initializer case that previously surfaced as `deps.initializeAccounts is not a function`.
|
||||
- **2026-04-10**: **#945** CCS now normalizes Gemini CLI and Antigravity tier signals around an explicit `free / pro / ultra / unknown` model, preserves raw tier ids such as `g1-pro-tier`, enriches Gemini quota responses with provider entitlement evidence, classifies `MODEL_CAPACITY_EXHAUSTED` separately from auth/entitlement failures, fixes the Antigravity CLI quota table so live quota-derived tiers no longer collapse back to stale `unknown`, adds Gemini tier ids to CLI quota output, extends Gemini Flash Lite grouping to cover `gemini-3.1-flash-lite-preview`, and allows Gemini account surfaces to render the same tier badge semantics as Antigravity.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "7.71.0",
|
||||
"version": "7.71.0-dev.13",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
phase: 1
|
||||
title: "CLI Routing & Namespacing"
|
||||
status: complete
|
||||
effort: "6h"
|
||||
---
|
||||
|
||||
# Phase 1: CLI Routing & Namespacing
|
||||
|
||||
## Context Links
|
||||
|
||||
- `plan.md`
|
||||
- `src/ccs.ts`
|
||||
- `src/auth/profile-detector.ts`
|
||||
- `src/cursor/constants.ts`
|
||||
- `src/commands/root-command-router.ts`
|
||||
- `src/commands/command-catalog.ts`
|
||||
- `src/commands/help-command.ts`
|
||||
- `src/commands/cursor-command.ts`
|
||||
- `src/commands/cursor-command-display.ts`
|
||||
- `src/types/profile.ts`
|
||||
- `src/config/reserved-names.ts`
|
||||
|
||||
## Overview
|
||||
|
||||
- Priority: P1
|
||||
- Owner scope: CLI entry, help, profile detection, command naming
|
||||
- Goal: make `cursor` provider-first and move the deprecated bridge under `legacy cursor`
|
||||
|
||||
## Key Insights
|
||||
|
||||
- The current collision is structural, not cosmetic. `ccs cursor` means "legacy bridge" in `src/ccs.ts` and `src/auth/profile-detector.ts`, but `cursor` is also listed as a built-in CLIProxy provider.
|
||||
- `shouldUseCursorCliproxyShortcut()` is only a heuristic escape hatch. It does not fix bare `ccs cursor`, quoted prompts, or help routing.
|
||||
- Help is currently inconsistent: provider help exists generically, but `cursor` is excluded and routed to bridge help instead.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Reserve `cursor` for CLIProxy runtime and CLIProxy admin flags.
|
||||
- Introduce explicit legacy syntax: `ccs legacy cursor ...`.
|
||||
- Keep a release-N alias for old legacy admin subcommands only.
|
||||
- Rename internal bridge-only profile typing from ambiguous `cursor` to explicit `legacy-cursor`.
|
||||
- Keep file ownership isolated to CLI/router/help files in this phase.
|
||||
|
||||
## Data Flow
|
||||
|
||||
- Provider path:
|
||||
`argv -> root command resolution -> provider shortcut/help path -> ProfileDetector(type=cliproxy, provider=cursor) -> CLIProxy runtime`
|
||||
- Legacy path:
|
||||
`argv -> legacy root command -> legacy cursor subrouter -> ProfileDetector(type=legacy-cursor) or direct handler -> local bridge runtime`
|
||||
- Deprecated alias path, release N only:
|
||||
`argv=ccs cursor auth|status|... -> alias shim -> warning -> dispatch to legacy cursor handler`
|
||||
|
||||
## Architecture
|
||||
|
||||
- Add a new root command namespace: `ccs legacy`.
|
||||
- Add nested routing under `legacy` with `cursor` as the first migrated leaf. Do not overload `cursor` itself any longer.
|
||||
- Remove provider exceptions for `cursor` from the generic provider help/routing logic. `ccs cursor --help` should now use provider shortcut help.
|
||||
- Convert bridge-only type checks from `profileInfo.type === 'cursor'` to `profileInfo.type === 'legacy-cursor'`.
|
||||
- Keep `ccs cursor help` only as a release-N compatibility shim that prints:
|
||||
- `Use "ccs cursor --help" for CLIProxy Cursor`
|
||||
- `Use "ccs legacy cursor help" for the deprecated bridge`
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Modify:
|
||||
- `src/ccs.ts`
|
||||
- `src/auth/profile-detector.ts`
|
||||
- `src/cursor/constants.ts`
|
||||
- `src/commands/root-command-router.ts`
|
||||
- `src/commands/command-catalog.ts`
|
||||
- `src/commands/help-command.ts`
|
||||
- `src/commands/cursor-command.ts`
|
||||
- `src/commands/cursor-command-display.ts`
|
||||
- `src/types/profile.ts`
|
||||
- `src/config/reserved-names.ts`
|
||||
- `src/shared/claude-extension-setup.ts`
|
||||
- `src/targets/target-runtime-compatibility.ts`
|
||||
- Create:
|
||||
- `src/commands/legacy-command.ts` or `src/commands/legacy/index.ts`
|
||||
- `src/commands/legacy/cursor-command.ts` if the team wants physical separation immediately
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Add the `legacy` root command route and its help surface.
|
||||
2. Flip `src/ccs.ts` so `cursor` goes through normal CLIProxy provider routing; remove the special-case that gives the bridge ownership of the name.
|
||||
3. Replace the `shouldUseCursorCliproxyShortcut()` hack with provider-first dispatch plus a compatibility alias table for the old legacy subcommands.
|
||||
4. Update `ProfileDetector` priority order so `cursor` resolves as `cliproxy`, while `legacy cursor` resolves as `legacy-cursor`.
|
||||
5. Rename bridge-only help text, summaries, and status text to say "legacy Cursor bridge" explicitly.
|
||||
6. Audit all `profileType === 'cursor'` checks and convert only the bridge-specific ones to `legacy-cursor`.
|
||||
|
||||
## Todo List
|
||||
|
||||
- [x] Add `legacy cursor` routing
|
||||
- [x] Make `ccs cursor` provider-first for bare, prompt, and `--help` usage
|
||||
- [x] Add deprecated alias forwarding for old admin subcommands
|
||||
- [x] Rename internal bridge profile path to `legacy-cursor`
|
||||
- [x] Update provider help, completion, and command catalog summaries
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- `ccs cursor "task"` resolves to CLIProxy Cursor.
|
||||
- `ccs legacy cursor "task"` resolves to the old bridge.
|
||||
- `ccs cursor --help` shows provider shortcut help.
|
||||
- `ccs cursor auth` still works in release N, but prints an exact replacement warning.
|
||||
- No CLI path depends on `shouldUseCursorCliproxyShortcut()` to disambiguate runtime meaning.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
- High likelihood / high impact: users with scripts calling `ccs cursor "task"` will hit the provider path immediately.
|
||||
Mitigation: call this out in release notes, keep admin aliases, add explicit warning when legacy files/config are detected and the user invokes `ccs cursor` with no flags.
|
||||
- Medium likelihood / medium impact: bridge-only type renames may break target compatibility checks or extension setup.
|
||||
Mitigation: grep audit every `profileType === 'cursor'` branch before tests.
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
- Re-enable the old `cursor` special-case in `src/ccs.ts` and `ProfileDetector`.
|
||||
- Keep the new `legacy` namespace in place even if dormant; it is additive and safe to leave.
|
||||
- Do not roll back migrated files in this phase; routing rollback alone is enough.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- No auth material moves in this phase.
|
||||
- Preserve existing `CCS_HOME`-aware path resolution. Do not introduce `os.homedir()` shortcuts while adding the new namespace.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Phase 2 depends on the new command contract from this phase.
|
||||
@@ -0,0 +1,140 @@
|
||||
---
|
||||
phase: 2
|
||||
title: "Storage & API Boundaries"
|
||||
status: partial
|
||||
effort: "6h"
|
||||
---
|
||||
|
||||
# Phase 2: Storage & API Boundaries
|
||||
|
||||
## Context Links
|
||||
|
||||
- `plan.md`
|
||||
- `src/config/unified-config-types.ts`
|
||||
- `src/config/unified-config-loader.ts`
|
||||
- `src/cursor/cursor-auth.ts`
|
||||
- `src/cursor/cursor-daemon-pid.ts`
|
||||
- `src/cliproxy/config/path-resolver.ts`
|
||||
- `src/cliproxy/config/env-builder.ts`
|
||||
- `src/web-server/routes/index.ts`
|
||||
- `src/web-server/routes/cursor-routes.ts`
|
||||
- `src/web-server/routes/cursor-settings-routes.ts`
|
||||
- `src/web-server/routes/cliproxy-stats-routes.ts`
|
||||
- `src/api/services/profile-lifecycle-service.ts`
|
||||
|
||||
## Overview
|
||||
|
||||
- Priority: P1
|
||||
- Owner scope: config schema, path resolution, backend APIs, migration readers
|
||||
- Goal: make legacy bridge storage explicit and guarantee CLIProxy Cursor never writes the legacy raw settings file
|
||||
|
||||
## Key Insights
|
||||
|
||||
- Top-level `config.cursor` is bridge-only configuration today and must move.
|
||||
- The legacy bridge owns `~/.ccs/cursor.settings.json`, `~/.ccs/cursor/credentials.json`, and `~/.ccs/cursor/daemon.pid`.
|
||||
- CLIProxy provider settings currently resolve through generic provider settings helpers and can still collide with the legacy file for provider `cursor`.
|
||||
- `~/.ccs/cursor.settings.json` is historically documented as legacy-owned, so it is unsafe to auto-import it into provider storage by default.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Canonical legacy config key: `legacy.cursor`
|
||||
- Canonical legacy files:
|
||||
- `~/.ccs/legacy/cursor.settings.json`
|
||||
- `~/.ccs/legacy/cursor/credentials.json`
|
||||
- `~/.ccs/legacy/cursor/daemon.pid`
|
||||
- Canonical provider file for CLIProxy Cursor only:
|
||||
- `~/.ccs/cliproxy/cursor.settings.json`
|
||||
- Canonical legacy API namespace:
|
||||
- `/api/legacy/cursor/*`
|
||||
- Compatibility reads:
|
||||
- read old `config.cursor`
|
||||
- read old `~/.ccs/cursor.settings.json`
|
||||
- read old `~/.ccs/cursor/*`
|
||||
- Compatibility writes:
|
||||
- write only the new `legacy.*` and `cliproxy/*` paths
|
||||
|
||||
## Data Flow
|
||||
|
||||
- Legacy config:
|
||||
`load config -> prefer legacy.cursor -> fallback config.cursor -> normalize -> write legacy.cursor only`
|
||||
- Legacy raw settings:
|
||||
`load /api/legacy/cursor/settings/raw -> prefer ~/.ccs/legacy/cursor.settings.json -> fallback ~/.ccs/cursor.settings.json -> write new legacy path`
|
||||
- Provider settings:
|
||||
`CLIProxy env builder/stats updater -> read ~/.ccs/cliproxy/cursor.settings.json -> if absent use defaults -> never read/write ~/.ccs/cursor.settings.json`
|
||||
|
||||
## Architecture
|
||||
|
||||
- Add a `legacy` section to unified config types and loader. Keep old `cursor` as read-only migration input during the compatibility window.
|
||||
- Move legacy bridge filesystem helpers under a `legacy/cursor` path prefix.
|
||||
- Split API routing:
|
||||
- new canonical mount: `/api/legacy/cursor`
|
||||
- release-N alias: `/api/cursor` -> same handlers + deprecation header
|
||||
- Special-case CLIProxy provider settings for `cursor` only in the provider path resolver. Do not expand this migration to every provider in this issue.
|
||||
- Treat existing `~/.ccs/cursor.settings.json` as legacy-owned. Do not auto-copy it into provider storage unless a future explicit provider migration is added.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Modify:
|
||||
- `src/config/unified-config-types.ts`
|
||||
- `src/config/unified-config-loader.ts`
|
||||
- `src/cursor/cursor-auth.ts`
|
||||
- `src/cursor/cursor-daemon-pid.ts`
|
||||
- `src/cliproxy/config/path-resolver.ts`
|
||||
- `src/cliproxy/config/env-builder.ts`
|
||||
- `src/web-server/routes/index.ts`
|
||||
- `src/web-server/routes/cursor-routes.ts`
|
||||
- `src/web-server/routes/cursor-settings-routes.ts`
|
||||
- `src/web-server/routes/cliproxy-stats-routes.ts`
|
||||
- `src/api/services/profile-lifecycle-service.ts`
|
||||
- Create:
|
||||
- `src/web-server/routes/legacy-cursor-routes.ts`
|
||||
- `src/web-server/routes/legacy-cursor-settings-routes.ts`
|
||||
- `src/config/migrations/cursor-legacy-migration.ts` if migration logic should stay out of the loader
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Extend config types and loader to support `legacy.cursor`, with `legacy.cursor` taking precedence over old `cursor`.
|
||||
2. Update legacy bridge credential and pid helpers to use `~/.ccs/legacy/cursor/`.
|
||||
3. Update the raw settings route to use `~/.ccs/legacy/cursor.settings.json` as canonical and old root path as read fallback only.
|
||||
4. Move legacy API mounts to `/api/legacy/cursor/*` and keep `/api/cursor/*` as a warned alias for release N.
|
||||
5. Change CLIProxy Cursor provider settings resolution to `~/.ccs/cliproxy/cursor.settings.json`.
|
||||
6. Update orphan detection and cleanup logic so old `cursor.settings.json` is treated as a migration target, not a permanent provider-owned file.
|
||||
|
||||
## Todo List
|
||||
|
||||
- [ ] Add `legacy.cursor` config schema and loader precedence
|
||||
- [ ] Move bridge credentials/pid/raw settings under `~/.ccs/legacy/`
|
||||
- [x] Add canonical `/api/legacy/cursor/*` routes
|
||||
- [x] Keep release-N `/api/cursor/*` alias
|
||||
- [x] Isolate CLIProxy Cursor settings away from `~/.ccs/cursor.settings.json`
|
||||
- [ ] Update cleanup/orphan handling
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Saving legacy bridge settings writes only to `legacy.cursor` and `~/.ccs/legacy/*`.
|
||||
- CLIProxy Cursor model/env updates write only to `~/.ccs/cliproxy/cursor.settings.json`.
|
||||
- Existing legacy users can still read old config/files during the compatibility window.
|
||||
- No backend route that serves the provider path references `~/.ccs/cursor.settings.json`.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
- High likelihood / high impact: old `~/.ccs/cursor.settings.json` contents are ambiguous between bridge and provider expectations.
|
||||
Mitigation: treat the file as legacy-owned and do not auto-import it into provider storage.
|
||||
- Medium likelihood / medium impact: route aliasing may mask which API is canonical.
|
||||
Mitigation: add explicit response headers or payload flags marking `/api/cursor/*` as deprecated.
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
- Keep read fallback from old paths even if the canonical write path changes back.
|
||||
- If the new legacy API namespace causes regressions, remount `/api/cursor/*` as canonical temporarily and keep the new namespace dormant.
|
||||
- Do not delete old files during release N; cleanup stays opt-in until release N+2.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Preserve `0600` for migrated credentials and `0700` for directories.
|
||||
- Use atomic temp-file writes exactly as current routes do.
|
||||
- Never copy provider tokens into the legacy namespace or legacy tokens into provider storage automatically.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Phase 3 depends on the canonical API and path names from this phase.
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
---
|
||||
phase: 3
|
||||
title: "Dashboard & Deprecation UX"
|
||||
status: partial
|
||||
effort: "4h"
|
||||
---
|
||||
|
||||
# Phase 3: Dashboard & Deprecation UX
|
||||
|
||||
## Context Links
|
||||
|
||||
- `plan.md`
|
||||
- `ui/src/App.tsx`
|
||||
- `ui/src/components/layout/app-sidebar.tsx`
|
||||
- `ui/src/pages/cursor.tsx`
|
||||
- `ui/src/hooks/use-cursor.ts`
|
||||
- `ui/src/lib/i18n.ts`
|
||||
- `src/web-server/routes/index.ts`
|
||||
- `src/commands/cursor-command-display.ts`
|
||||
|
||||
## Overview
|
||||
|
||||
- Priority: P1
|
||||
- Owner scope: dashboard route ownership, labels, user-facing deprecation messaging
|
||||
- Goal: align dashboard semantics with CLI semantics so `/cursor` means provider and legacy UI is clearly marked and isolated
|
||||
|
||||
## Key Insights
|
||||
|
||||
- The current dashboard already admits the bridge is deprecated, but the route `/cursor` still belongs to it.
|
||||
- The page includes direct navigation to CLIProxy Cursor, which means the UX already wants a split; the route layer just has not caught up.
|
||||
- Keeping `/cursor` for legacy while CLI uses `cursor` for provider would create the same ambiguity in a different surface.
|
||||
|
||||
## Requirements
|
||||
|
||||
- `/cursor` must become the provider-owned dashboard surface.
|
||||
- The legacy bridge page must move to `/legacy/cursor`.
|
||||
- Legacy bridge API hooks must move to `/api/legacy/cursor/*`.
|
||||
- The deprecated UX must contain exact replacements, not generic warnings.
|
||||
- Sidebar grouping must reflect support level:
|
||||
- provider view under provider/cliproxy navigation
|
||||
- legacy bridge under deprecated navigation
|
||||
|
||||
## Data Flow
|
||||
|
||||
- Provider dashboard:
|
||||
`browser /cursor -> provider view or redirect wrapper -> /cliproxy?provider=cursor -> existing CLIProxy provider APIs`
|
||||
- Legacy dashboard:
|
||||
`browser /legacy/cursor -> legacy bridge page -> useLegacyCursor hook -> /api/legacy/cursor/*`
|
||||
- Compatibility API path, release N only:
|
||||
`old UI/tests -> /api/cursor/* -> alias handler -> same legacy payload + deprecation signal`
|
||||
|
||||
## Architecture
|
||||
|
||||
- Keep provider UI DRY by making `/cursor` a thin redirect or preselected wrapper around the existing CLIProxy provider page instead of building a second Cursor-provider page.
|
||||
- Move the current `ui/src/pages/cursor.tsx` implementation to a new `legacy-cursor` page and rename its hook to `useLegacyCursor`.
|
||||
- Change nav labels from generic "Cursor IDE" to explicit "Cursor Bridge (Legacy)" in the deprecated section.
|
||||
- Update CLI and dashboard warnings to show both paths side-by-side:
|
||||
- `ccs cursor --auth` / `/cursor`
|
||||
- `ccs legacy cursor auth` / `/legacy/cursor`
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Modify:
|
||||
- `ui/src/App.tsx`
|
||||
- `ui/src/components/layout/app-sidebar.tsx`
|
||||
- `ui/src/lib/i18n.ts`
|
||||
- `src/commands/cursor-command-display.ts`
|
||||
- Move or rename:
|
||||
- `ui/src/pages/cursor.tsx` -> `ui/src/pages/legacy-cursor.tsx`
|
||||
- `ui/src/hooks/use-cursor.ts` -> `ui/src/hooks/use-legacy-cursor.ts`
|
||||
- Create:
|
||||
- `ui/src/pages/cursor-provider-redirect.tsx` if a wrapper is preferred over direct router config
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Move the legacy page and hook to `legacy-*` names and update all imports.
|
||||
2. Reassign `/cursor` to the provider path and add `/legacy/cursor` for the bridge page.
|
||||
3. Update sidebar grouping and labels so the provider path is no longer listed under Deprecated.
|
||||
4. Replace vague deprecated copy with concrete migration copy:
|
||||
- old command
|
||||
- new command
|
||||
- old route
|
||||
- new route
|
||||
5. Keep the legacy page banner persistent until release N+2, not dismissible per session.
|
||||
|
||||
## Todo List
|
||||
|
||||
- [ ] Move legacy page/hook module names to `legacy-*`
|
||||
- [x] Reassign `/cursor` and add `/legacy/cursor`
|
||||
- [x] Update deprecated nav group and labels
|
||||
- [x] Rewrite key banners, button copy, and path labels with exact replacements
|
||||
- [x] Keep provider and legacy links visible from both surfaces during release N
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Opening `/cursor` lands on the CLIProxy Cursor provider surface.
|
||||
- Opening `/legacy/cursor` lands on the bridge page with a persistent deprecation banner.
|
||||
- No dashboard component serving the provider route uses the legacy API hook.
|
||||
- Every warning banner shows the exact before/after command and route.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
- Medium likelihood / medium impact: users with bookmarked `/cursor` expect the legacy page.
|
||||
Mitigation: provider page shows a top-level "Looking for the old bridge?" callout linking to `/legacy/cursor`.
|
||||
- Low likelihood / medium impact: UI rename churn breaks lazy imports or tests.
|
||||
Mitigation: do route and hook rename in one phase and leave compatibility API alias in place until tests pass.
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
- Point `/cursor` back to the legacy page if the provider redirect breaks.
|
||||
- Keep `/legacy/cursor` additive; it does not block rollback.
|
||||
- Do not remove the deprecation banner on rollback; it still communicates future intent.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- No auth secrets should be exposed in UI copy or route params.
|
||||
- Keep manual auth dialogs scoped to the legacy page only. Provider auth remains in CLIProxy flows.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Phase 4 owns test rewrites, docs updates, and release gating for these UI changes.
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
phase: 4
|
||||
title: "Tests Docs & Rollout"
|
||||
status: complete
|
||||
effort: "4h"
|
||||
---
|
||||
|
||||
# Phase 4: Tests Docs & Rollout
|
||||
|
||||
## Context Links
|
||||
|
||||
- `plan.md`
|
||||
- `docs/cursor-integration.md`
|
||||
- `README.md`
|
||||
- `docs/system-architecture/provider-flows.md`
|
||||
- `docs/system-architecture/index.md`
|
||||
- `tests/unit/cursor/cursor-shortcut-routing.test.ts`
|
||||
- `tests/unit/web-server/cursor-settings-routes.test.ts`
|
||||
- `tests/unit/web-server/cursor-routes.test.ts`
|
||||
- `ui/tests/unit/hooks/use-cursor.test.tsx`
|
||||
- `ui/tests/unit/ui/pages/cursor-page.test.tsx`
|
||||
|
||||
## Overview
|
||||
|
||||
- Priority: P1
|
||||
- Owner scope: compatibility rollout, validation, docs/help updates, release notes
|
||||
- Goal: ship the namespace split without surprising existing bridge users or leaving docs/help inconsistent
|
||||
|
||||
## Key Insights
|
||||
|
||||
- This change has one intentional breaking behavior: positional `ccs cursor` stops being the legacy bridge.
|
||||
- Everything else can use a compatibility window: admin subcommands, API aliases, old config reads, old file-path reads.
|
||||
- Tests must lock both meanings so the ambiguity does not regress later.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Document exact before/after commands and routes.
|
||||
- Add a concrete migration path for three user groups:
|
||||
- legacy bridge users
|
||||
- CLIProxy Cursor users
|
||||
- dashboard bookmark users
|
||||
- Define removal windows for aliases and old path fallbacks.
|
||||
- Run repo quality gates after implementation:
|
||||
- root: `bun run format && bun run lint:fix && bun run validate && bun run validate:ci-parity`
|
||||
- UI: `cd ui && bun run format && bun run lint:fix && bun run validate`
|
||||
|
||||
## Test Matrix
|
||||
|
||||
- Unit:
|
||||
- provider-first cursor routing
|
||||
- legacy alias forwarding
|
||||
- `legacy.cursor` loader precedence
|
||||
- path resolvers for legacy vs provider files
|
||||
- deprecation help text snapshots
|
||||
- Integration:
|
||||
- `ccs cursor "task"` -> provider
|
||||
- `ccs legacy cursor "task"` -> bridge
|
||||
- `/api/legacy/cursor/*` canonical behavior
|
||||
- `/api/cursor/*` alias behavior during release N
|
||||
- UI:
|
||||
- `/cursor` route ownership
|
||||
- `/legacy/cursor` banner and actions
|
||||
- hook path changes and raw settings save targets
|
||||
- Manual release validation:
|
||||
- migrate old config/files in a temp `CCS_HOME`
|
||||
- verify provider path never writes `~/.ccs/cursor.settings.json`
|
||||
|
||||
## User Migration Plan
|
||||
|
||||
1. Legacy bridge users:
|
||||
- replace `ccs cursor ...` with `ccs legacy cursor ...`
|
||||
- run `ccs legacy cursor status`
|
||||
- update scripts and dashboard bookmarks to `/legacy/cursor`
|
||||
2. CLIProxy Cursor users:
|
||||
- keep using `ccs cursor ...`
|
||||
- if provider-specific settings are needed, re-save them under the new provider-owned path instead of relying on `~/.ccs/cursor.settings.json`
|
||||
3. Mixed/unclear state:
|
||||
- `ccs migrate` should move `config.cursor` and legacy files into the new legacy namespace
|
||||
- do not auto-copy the old raw settings file into provider storage
|
||||
|
||||
## Deprecation UX Plan
|
||||
|
||||
- CLI warning text, release N:
|
||||
- `ccs cursor auth` is deprecated. Use `ccs legacy cursor auth` for the old bridge or `ccs cursor --auth` for CLIProxy Cursor.
|
||||
- Dashboard banner:
|
||||
- visible on `/legacy/cursor`
|
||||
- provider route links back to legacy route with "Looking for the old bridge?"
|
||||
- Docs banner:
|
||||
- top callout in `docs/cursor-integration.md` pointing users to CLIProxy Cursor as the supported path
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Modify tests:
|
||||
- `tests/unit/cursor/cursor-shortcut-routing.test.ts`
|
||||
- `tests/unit/web-server/cursor-settings-routes.test.ts`
|
||||
- `tests/unit/web-server/cursor-routes.test.ts`
|
||||
- `ui/tests/unit/hooks/use-cursor.test.tsx`
|
||||
- `ui/tests/unit/ui/pages/cursor-page.test.tsx`
|
||||
- Modify docs:
|
||||
- `docs/cursor-integration.md`
|
||||
- `README.md` if root command examples mention Cursor
|
||||
- `docs/system-architecture/provider-flows.md`
|
||||
- `docs/system-architecture/index.md`
|
||||
- CLI help snapshots or generated references if present
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Rewrite tests around the new command contract and route ownership before removing aliases in later releases.
|
||||
2. Update docs/help text in the same PR as code changes so the new syntax ships atomically.
|
||||
3. Add migration notes to changelog/release notes with a bold callout that `ccs cursor "task"` now means CLIProxy Cursor.
|
||||
4. Keep a removal checklist for release N+1 and N+2 in the plan or roadmap so the compatibility window does not become permanent.
|
||||
|
||||
## Todo List
|
||||
|
||||
- [x] Update unit, integration, and selected UI tests
|
||||
- [x] Update docs and CLI help text
|
||||
- [x] Add migration note and deprecation wording
|
||||
- [x] Run root and UI quality gates
|
||||
- [x] Record alias-removal follow-up for N+1 and old-path-removal follow-up for N+2
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Test suite covers both provider and legacy cursor paths explicitly.
|
||||
- Docs and help text match the shipped command contract exactly.
|
||||
- Release notes include the migration table and deprecation window.
|
||||
- Quality gates pass in both root and `ui/`.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
- High likelihood / medium impact: docs or tests lag behind the command flip and users keep invoking the wrong surface.
|
||||
Mitigation: block merge until help text, docs, and tests all match the new contract.
|
||||
- Medium likelihood / medium impact: compatibility shims never get removed.
|
||||
Mitigation: create follow-up issues or roadmap entries for N+1 and N+2 removal work before merge.
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
- If rollout messaging is incomplete, revert the command flip before removing aliases.
|
||||
- If only docs/help are wrong, fix docs first and keep aliases until corrected.
|
||||
- Old-path readers stay in place through N+1, so rollback does not strand migrated users.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Use temp `CCS_HOME` in tests and manual verification. Never touch the real `~/.ccs`.
|
||||
- Sanitize any migration logs or warnings so they mention paths, not token contents.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Implementation is complete when all four phases land together; do not ship phase 1 without phases 2-4.
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: "Separate legacy Cursor bridge from CLIProxy Cursor provider"
|
||||
description: "Reserve `cursor` for the CLIProxy provider, move the reverse-engineered bridge under `legacy`, and split storage/UI with a staged migration."
|
||||
status: in_progress
|
||||
priority: P1
|
||||
effort: 2d
|
||||
branch: kai/feat/1016-missing-provider-integration
|
||||
tags: [cursor, cliproxy, migration, dashboard, deprecation]
|
||||
created: 2026-04-15
|
||||
blockedBy: []
|
||||
blocks: []
|
||||
---
|
||||
|
||||
# Separate legacy Cursor bridge from CLIProxy Cursor provider
|
||||
|
||||
## Goal
|
||||
|
||||
Make `cursor` mean one thing everywhere: the CLIProxy-backed provider. Move the deprecated local bridge to `legacy`, stop provider writes to `~/.ccs/cursor.settings.json`, and ship a low-risk migration window.
|
||||
|
||||
## Current Collision Points
|
||||
|
||||
- `src/ccs.ts` hardcodes `cursor` as a legacy command/profile, then reclaims only `--auth|--logout|--config|--accounts` for CLIProxy.
|
||||
- `src/auth/profile-detector.ts` resolves `cursor` to the legacy runtime before CLIProxy provider detection.
|
||||
- `src/commands/command-catalog.ts` and `src/commands/help-command.ts` advertise `cursor` as both bridge and provider.
|
||||
- `src/config/unified-config-types.ts` + `src/config/unified-config-loader.ts` store bridge config under top-level `cursor`.
|
||||
- `src/cliproxy/config/path-resolver.ts`, `src/cliproxy/config/env-builder.ts`, and `src/web-server/routes/cliproxy-stats-routes.ts` still use provider settings paths that collide with the legacy raw file.
|
||||
- `src/web-server/routes/cursor-*.ts`, `ui/src/pages/cursor.tsx`, `ui/src/hooks/use-cursor.ts`, `ui/src/App.tsx`, and `ui/src/components/layout/app-sidebar.tsx` dedicate `/cursor` and `/api/cursor/*` to the legacy bridge.
|
||||
- `docs/cursor-integration.md` documents `ccs cursor` as the bridge even though CLIProxy already exposes a `cursor` provider shortcut.
|
||||
|
||||
## Command Contract
|
||||
|
||||
Before:
|
||||
```text
|
||||
ccs cursor -> legacy bridge runtime
|
||||
ccs cursor "task" -> legacy bridge runtime
|
||||
ccs cursor auth|status|... -> legacy bridge admin
|
||||
ccs cursor --auth|--config -> CLIProxy Cursor shortcut
|
||||
```
|
||||
|
||||
After release N:
|
||||
```text
|
||||
ccs cursor -> CLIProxy Cursor runtime
|
||||
ccs cursor "task" -> CLIProxy Cursor runtime
|
||||
ccs cursor --auth|--config -> CLIProxy Cursor admin
|
||||
ccs legacy cursor -> legacy bridge runtime
|
||||
ccs legacy cursor "task" -> legacy bridge runtime
|
||||
ccs legacy cursor auth|... -> legacy bridge admin
|
||||
```
|
||||
|
||||
Compatibility window, release N only:
|
||||
- `ccs cursor auth|status|probe|models|start|stop|enable|disable|help` forwards to `ccs legacy cursor ...` with a deprecation warning.
|
||||
- Bare and positional `ccs cursor` switch immediately to the provider path; no silent legacy fallback.
|
||||
|
||||
## Phase Plan
|
||||
|
||||
| Phase | Scope | Output |
|
||||
| --- | --- | --- |
|
||||
| 1 | [CLI Routing & Namespacing](./phase-01-cli-routing-namespacing.md) | Provider-first `cursor`, explicit `legacy cursor`, updated help/catalog/type names |
|
||||
| 2 | [Storage & API Boundaries](./phase-02-storage-api-boundaries.md) | `legacy.cursor` config, split file paths, `/api/legacy/cursor/*`, provider path isolation |
|
||||
| 3 | [Dashboard & Deprecation UX](./phase-03-dashboard-deprecation-ux.md) | `/cursor` -> provider view, `/legacy/cursor` -> bridge view, clear migration UX |
|
||||
| 4 | [Tests Docs & Rollout](./phase-04-tests-docs-rollout.md) | Compatibility plan, migration steps, test matrix, docs updates, rollback gates |
|
||||
|
||||
## Rollout Sequence
|
||||
|
||||
1. Release N: add new legacy namespace, flip `ccs cursor` to provider, keep old admin subcommands and `/api/cursor/*` as warned aliases, and split provider settings away from `~/.ccs/cursor.settings.json`.
|
||||
2. Release N+1: move the remaining legacy backend/config namespaces fully under `legacy.cursor`, keep old file-path fallback and `/api/cursor/*` alias for one more release.
|
||||
3. Release N+2: remove old `config.cursor` and root-level `~/.ccs/cursor*` fallback reads, delete stale alias docs/help, and let cleanup/migrate remove leftovers.
|
||||
|
||||
## Current Implementation Status
|
||||
|
||||
- Completed in this branch:
|
||||
- `ccs cursor` is provider-first for runtime and `--help`
|
||||
- `ccs legacy cursor` works as the explicit legacy bridge namespace
|
||||
- old legacy admin subcommands under `ccs cursor ...` forward with deprecation warnings
|
||||
- CLIProxy Cursor settings no longer collide with `~/.ccs/cursor.settings.json`
|
||||
- `/cursor` redirects to the provider surface while `/legacy/cursor` serves the deprecated bridge page
|
||||
- `/api/legacy/cursor/*` is mounted and the legacy page uses that namespace
|
||||
- docs, completion, and core regression tests were updated
|
||||
- Intentionally deferred follow-up:
|
||||
- move top-level `config.cursor` to `legacy.cursor`
|
||||
- move legacy credentials/pid/raw settings fully under `~/.ccs/legacy/cursor/*`
|
||||
- rename `use-cursor` and `CursorPage` modules to explicit `legacy-*`
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- `cursor` is provider-owned in CLI help, routing, dashboard nav, and docs.
|
||||
- Legacy bridge is reachable only through `legacy cursor` and `legacy.cursor` storage.
|
||||
- CLIProxy Cursor never reads or writes `~/.ccs/cursor.settings.json`.
|
||||
- Existing legacy users have an explicit migration path, warning UX, and rollback-safe compatibility window.
|
||||
|
||||
## Docs Impact
|
||||
|
||||
Major. CLI reference, Cursor docs, dashboard tour, provider docs, and migration notes all change in the same release.
|
||||
@@ -26,6 +26,7 @@ import { getCcsDir } from '../utils/config-manager';
|
||||
import { getProfileLookupCandidates, isLegacyProfileAlias } from '../utils/profile-compat';
|
||||
import type { CLIProxyProvider } from '../cliproxy/types';
|
||||
import { CLIPROXY_PROVIDER_IDS, isCLIProxyProvider } from '../cliproxy/provider-capabilities';
|
||||
import { LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants';
|
||||
import { normalizeCopilotModelId } from '../copilot/copilot-model-normalizer';
|
||||
import type { TargetType } from '../targets/target-adapter';
|
||||
import type { ProfileType } from '../types/profile';
|
||||
@@ -253,9 +254,8 @@ class ProfileDetector {
|
||||
* Detect profile type and return routing information
|
||||
*
|
||||
* Priority order:
|
||||
* 0. Hardcoded CLIProxy profiles (gemini, codex, agy, qwen)
|
||||
* 0.5. Copilot profile (if enabled in config)
|
||||
* 0.75. Cursor profile (if enabled in config)
|
||||
* 0. Hardcoded special runtime profiles (copilot, cursor)
|
||||
* 0.5. Hardcoded CLIProxy profiles (gemini, codex, agy, qwen, ...)
|
||||
* 1. Unified config profiles (if config.yaml exists or CCS_UNIFIED_CONFIG=1)
|
||||
* 2. User-defined CLIProxy variants (config.cliproxy section) [legacy]
|
||||
* 3. Settings-based profiles (config.profiles section) [legacy]
|
||||
@@ -267,16 +267,7 @@ class ProfileDetector {
|
||||
return this.resolveDefaultProfile();
|
||||
}
|
||||
|
||||
// Priority 0: Check CLIProxy profiles (gemini, codex, agy, qwen) - OAuth-based, zero config
|
||||
if (isCLIProxyProvider(profileName)) {
|
||||
return {
|
||||
type: 'cliproxy',
|
||||
name: profileName,
|
||||
provider: profileName,
|
||||
};
|
||||
}
|
||||
|
||||
// Priority 0.5: Check Copilot profile - GitHub Copilot subscription via copilot-api
|
||||
// Priority 0: Check Copilot profile - GitHub Copilot subscription via copilot-api
|
||||
if (profileName === 'copilot') {
|
||||
const unifiedConfig = this.readUnifiedConfig();
|
||||
const copilotConfig = unifiedConfig?.copilot;
|
||||
@@ -306,17 +297,17 @@ class ProfileDetector {
|
||||
};
|
||||
}
|
||||
|
||||
// Priority 0.75: Check Cursor profile - local Cursor daemon runtime
|
||||
if (profileName === 'cursor') {
|
||||
// Priority 0.25: Check explicit legacy Cursor bridge profile.
|
||||
if (profileName === LEGACY_CURSOR_PROFILE_NAME) {
|
||||
const cursorConfig = getCursorConfig();
|
||||
|
||||
if (!cursorConfig?.enabled) {
|
||||
const error = new Error(
|
||||
'Cursor profile is not enabled.\n\n' +
|
||||
'Legacy Cursor profile is not enabled.\n\n' +
|
||||
'To enable Cursor integration:\n' +
|
||||
' 1. Run: ccs cursor enable\n' +
|
||||
' 2. Import auth: ccs cursor auth\n' +
|
||||
' 3. Start daemon: ccs cursor start\n\n' +
|
||||
' 1. Run: ccs legacy cursor enable\n' +
|
||||
' 2. Import auth: ccs legacy cursor auth\n' +
|
||||
' 3. Start daemon: ccs legacy cursor start\n\n' +
|
||||
'Or manually edit ~/.ccs/config.yaml:\n' +
|
||||
' cursor:\n' +
|
||||
' enabled: true'
|
||||
@@ -329,11 +320,20 @@ class ProfileDetector {
|
||||
|
||||
return {
|
||||
type: 'cursor',
|
||||
name: 'cursor',
|
||||
name: LEGACY_CURSOR_PROFILE_NAME,
|
||||
cursorConfig,
|
||||
};
|
||||
}
|
||||
|
||||
// Priority 0.5: Check CLIProxy profiles (gemini, codex, agy, qwen, ...)
|
||||
if (isCLIProxyProvider(profileName)) {
|
||||
return {
|
||||
type: 'cliproxy',
|
||||
name: profileName,
|
||||
provider: profileName,
|
||||
};
|
||||
}
|
||||
|
||||
// Priority 1: Try unified config if available
|
||||
const unifiedConfig = this.readUnifiedConfig();
|
||||
if (unifiedConfig) {
|
||||
|
||||
+129
-20
@@ -39,11 +39,15 @@ import {
|
||||
import {
|
||||
appendBrowserToolArgs,
|
||||
ensureBrowserMcpOrThrow,
|
||||
getEffectiveClaudeBrowserAttachConfig,
|
||||
resolveBrowserRuntimeEnv,
|
||||
resolveConfiguredBrowserProfileDir,
|
||||
syncBrowserMcpToConfigDir,
|
||||
} from './utils/browser';
|
||||
import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader';
|
||||
import {
|
||||
getBrowserConfig,
|
||||
getGlobalEnvConfig,
|
||||
getOfficialChannelsConfig,
|
||||
} from './config/unified-config-loader';
|
||||
import {
|
||||
ensureProfileHooks as ensureImageAnalyzerHooks,
|
||||
removeImageAnalysisProfileHook,
|
||||
@@ -65,7 +69,8 @@ import {
|
||||
resolveOfficialChannelsLaunchPlan,
|
||||
} from './channels/official-channels-runtime';
|
||||
import { getOfficialChannelReadiness } from './channels/official-channels-store';
|
||||
import { isCursorSubcommandToken } from './cursor/constants';
|
||||
import { isCursorSubcommandToken, LEGACY_CURSOR_PROFILE_NAME } from './cursor/constants';
|
||||
import { isCLIProxyProvider } from './cliproxy/provider-capabilities';
|
||||
|
||||
// Import centralized error handling
|
||||
import { handleError, runCleanup } from './errors';
|
||||
@@ -77,6 +82,7 @@ import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils
|
||||
import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning';
|
||||
import { createLogger } from './services/logging';
|
||||
import { buildCodexBrowserMcpOverrides } from './utils/browser-codex-overrides';
|
||||
import type { ProfileDetectionResult } from './auth/profile-detector';
|
||||
|
||||
// Import target adapter system
|
||||
import {
|
||||
@@ -97,6 +103,11 @@ import {
|
||||
} from './targets/droid-reasoning-runtime';
|
||||
import { DroidCommandRouterError, routeDroidCommandArgs } from './targets/droid-command-router';
|
||||
import { resolveCliproxyBridgeMetadata } from './api/services/cliproxy-profile-bridge';
|
||||
import {
|
||||
buildOpenAICompatProxyEnv,
|
||||
resolveOpenAICompatProfileConfig,
|
||||
startOpenAICompatProxy,
|
||||
} from './proxy';
|
||||
|
||||
// Version and Update check utilities
|
||||
import { getVersion } from './utils/version';
|
||||
@@ -128,7 +139,7 @@ const CODEX_NATIVE_PASSTHROUGH_FLAGS = new Set(['--help', '-h', '--version', '-v
|
||||
function resolveCodexRuntimeConfigOverrides(
|
||||
target: ReturnType<typeof resolveTargetType>
|
||||
): string[] {
|
||||
if (target !== 'codex') {
|
||||
if (target !== 'codex' || !getBrowserConfig().codex.enabled) {
|
||||
return [];
|
||||
}
|
||||
return buildCodexBrowserMcpOverrides();
|
||||
@@ -147,6 +158,26 @@ function detectProfile(args: string[]): DetectedProfile {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLegacyCursorArgs(args: string[]): string[] {
|
||||
if (args[0] === 'legacy' && args[1] === 'cursor') {
|
||||
return [LEGACY_CURSOR_PROFILE_NAME, ...args.slice(2)];
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function printCursorLegacySubcommandDeprecation(subcommand: string): void {
|
||||
console.error(
|
||||
info(`\`ccs cursor ${subcommand}\` is deprecated for the legacy Cursor IDE bridge.`)
|
||||
);
|
||||
console.error(
|
||||
info(
|
||||
`Use \`ccs legacy cursor ${subcommand}\` for the old bridge, or \`ccs cursor --auth|--accounts|--config\` for the CLIProxy provider.`
|
||||
)
|
||||
);
|
||||
console.error('');
|
||||
}
|
||||
|
||||
function resolveRuntimeReasoningFlags(
|
||||
args: string[],
|
||||
envThinkingValue: string | undefined
|
||||
@@ -341,7 +372,7 @@ async function main(): Promise<void> {
|
||||
registerTarget(new CodexAdapter());
|
||||
const cliLogger = createLogger('cli');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
let args = process.argv.slice(2);
|
||||
const isCompletionCommand = args[0] === '__complete';
|
||||
|
||||
// Initialize UI colors early to ensure consistent colored output
|
||||
@@ -417,6 +448,8 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
args = normalizeLegacyCursorArgs(args);
|
||||
|
||||
cliLogger.info('command.start', 'CLI invocation started', {
|
||||
command: args[0] || 'default',
|
||||
argCount: args.length,
|
||||
@@ -484,6 +517,17 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof firstArg === 'string' &&
|
||||
isCLIProxyProvider(firstArg) &&
|
||||
args.length > 1 &&
|
||||
(args.includes('--help') || args.includes('-h'))
|
||||
) {
|
||||
const { showProviderShortcutHelp } = await import('./commands/help-command');
|
||||
await showProviderShortcutHelp(firstArg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Special case: copilot command (GitHub Copilot integration)
|
||||
// Route known subcommands to command handler, keep all other args as profile passthrough.
|
||||
if (firstArg === 'copilot' && args.length > 1) {
|
||||
@@ -497,9 +541,8 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Special case: cursor command (Cursor local proxy integration)
|
||||
// Route known admin subcommands to the command handler, keep all other args as profile passthrough.
|
||||
if (firstArg === 'cursor' && args.length > 1) {
|
||||
// Special case: explicit legacy Cursor bridge namespace.
|
||||
if (firstArg === LEGACY_CURSOR_PROFILE_NAME && args.length > 1) {
|
||||
const { handleCursorCommand } = await import('./commands/cursor-command');
|
||||
const cursorToken = args[1];
|
||||
|
||||
@@ -509,6 +552,19 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Compatibility shim: old `ccs cursor <subcommand>` still forwards to the legacy bridge
|
||||
// for one migration window, but bare/positional `ccs cursor` now belongs to CLIProxy.
|
||||
if (firstArg === 'cursor' && args.length > 1) {
|
||||
const { handleCursorCommand } = await import('./commands/cursor-command');
|
||||
const cursorToken = args[1];
|
||||
|
||||
if (isCursorSubcommandToken(cursorToken) && cursorToken !== '--help' && cursorToken !== '-h') {
|
||||
printCursorLegacySubcommandDeprecation(cursorToken);
|
||||
const exitCode = await handleCursorCommand(args.slice(1));
|
||||
process.exit(exitCode);
|
||||
}
|
||||
}
|
||||
|
||||
// First-time install: offer setup wizard for interactive users
|
||||
// Check independently of recovery status (user may have empty config.yaml)
|
||||
// Skip if headless, CI, or non-TTY environment
|
||||
@@ -538,7 +594,7 @@ async function main(): Promise<void> {
|
||||
// Detect profile (strip --target flags before profile detection)
|
||||
const cleanArgs = stripTargetFlag(args);
|
||||
const { profile, remainingArgs } = detectProfile(cleanArgs);
|
||||
const profileInfo = detector.detectProfileType(profile);
|
||||
const profileInfo: ProfileDetectionResult = detector.detectProfileType(profile);
|
||||
let resolvedTarget: ReturnType<typeof resolveTargetType>;
|
||||
try {
|
||||
resolvedTarget = resolveTargetType(
|
||||
@@ -1002,13 +1058,13 @@ async function main(): Promise<void> {
|
||||
const imageAnalysisMcpReady =
|
||||
resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true;
|
||||
let browserRuntimeEnv: TargetCredentials['browserRuntimeEnv'];
|
||||
const browserProfileDir =
|
||||
const browserAttachConfig =
|
||||
resolvedTarget === 'claude'
|
||||
? resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR)
|
||||
? getEffectiveClaudeBrowserAttachConfig(getBrowserConfig())
|
||||
: undefined;
|
||||
if (resolvedTarget === 'claude') {
|
||||
ensureWebSearchMcpOrThrow();
|
||||
if (browserProfileDir) {
|
||||
if (browserAttachConfig?.enabled) {
|
||||
ensureBrowserMcpOrThrow();
|
||||
}
|
||||
}
|
||||
@@ -1035,7 +1091,7 @@ async function main(): Promise<void> {
|
||||
syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir);
|
||||
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
|
||||
if (
|
||||
browserProfileDir &&
|
||||
browserAttachConfig?.enabled &&
|
||||
inheritedClaudeConfigDir &&
|
||||
!syncBrowserMcpToConfigDir(inheritedClaudeConfigDir)
|
||||
) {
|
||||
@@ -1245,10 +1301,13 @@ async function main(): Promise<void> {
|
||||
|
||||
// Explicitly inject effective settings env vars so stale ANTHROPIC_*
|
||||
// values from prior sessions cannot leak into the active profile.
|
||||
if (browserProfileDir) {
|
||||
if (browserAttachConfig?.enabled) {
|
||||
browserRuntimeEnv = {
|
||||
...(await resolveBrowserRuntimeEnv({
|
||||
profileDir: browserProfileDir,
|
||||
profileDir: browserAttachConfig.userDataDir,
|
||||
devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort
|
||||
? String(browserAttachConfig.devtoolsPort)
|
||||
: undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -1304,6 +1363,53 @@ async function main(): Promise<void> {
|
||||
const browserArgs = browserRuntimeEnv
|
||||
? appendBrowserToolArgs(imageAnalysisArgs)
|
||||
: imageAnalysisArgs;
|
||||
const openAICompatProfile = resolveOpenAICompatProfileConfig(
|
||||
profileInfo.name,
|
||||
expandedSettingsPath,
|
||||
settingsEnv
|
||||
);
|
||||
if (openAICompatProfile) {
|
||||
const proxyStart = await startOpenAICompatProxy(openAICompatProfile, {
|
||||
insecure: openAICompatProfile.insecure,
|
||||
});
|
||||
if (!proxyStart.success) {
|
||||
console.error(fail(proxyStart.error || 'Failed to start local OpenAI-compatible proxy'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.error(
|
||||
info(
|
||||
`Using local OpenAI-compatible proxy for "${profileInfo.name}" on port ${proxyStart.port}`
|
||||
)
|
||||
);
|
||||
|
||||
const proxyEnv = {
|
||||
...envVars,
|
||||
...buildOpenAICompatProxyEnv(
|
||||
openAICompatProfile,
|
||||
proxyStart.port,
|
||||
proxyStart.authToken || '',
|
||||
inheritedClaudeConfigDir
|
||||
),
|
||||
};
|
||||
delete proxyEnv.ANTHROPIC_API_KEY;
|
||||
|
||||
const launchArgs = [
|
||||
'--settings',
|
||||
expandedSettingsPath,
|
||||
...appendThirdPartyWebSearchToolArgs(browserArgs),
|
||||
];
|
||||
const traceEnv = createWebSearchTraceContext({
|
||||
launcher: 'ccs.settings-profile.proxy',
|
||||
args: launchArgs,
|
||||
profile: profileInfo.name,
|
||||
profileType: profileInfo.type,
|
||||
settingsPath: expandedSettingsPath,
|
||||
});
|
||||
|
||||
execClaude(claudeCli, launchArgs, { ...proxyEnv, ...traceEnv });
|
||||
return;
|
||||
}
|
||||
const launchArgs = [
|
||||
'--settings',
|
||||
expandedSettingsPath,
|
||||
@@ -1366,17 +1472,20 @@ async function main(): Promise<void> {
|
||||
CCS_IMAGE_ANALYSIS_SKIP: '1',
|
||||
};
|
||||
let browserRuntimeEnv: TargetCredentials['browserRuntimeEnv'];
|
||||
const browserProfileDir =
|
||||
const browserAttachConfig =
|
||||
resolvedTarget === 'claude'
|
||||
? resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR)
|
||||
? getEffectiveClaudeBrowserAttachConfig(getBrowserConfig())
|
||||
: undefined;
|
||||
|
||||
if (resolvedTarget === 'claude') {
|
||||
if (browserProfileDir) {
|
||||
if (browserAttachConfig?.enabled) {
|
||||
ensureBrowserMcpOrThrow();
|
||||
browserRuntimeEnv = {
|
||||
...(await resolveBrowserRuntimeEnv({
|
||||
profileDir: browserProfileDir,
|
||||
profileDir: browserAttachConfig.userDataDir,
|
||||
devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort
|
||||
? String(browserAttachConfig.devtoolsPort)
|
||||
: undefined,
|
||||
})),
|
||||
};
|
||||
Object.assign(envVars, browserRuntimeEnv);
|
||||
@@ -1396,7 +1505,7 @@ async function main(): Promise<void> {
|
||||
if (defaultContinuityInheritance.claudeConfigDir) {
|
||||
envVars.CLAUDE_CONFIG_DIR = defaultContinuityInheritance.claudeConfigDir;
|
||||
if (
|
||||
browserProfileDir &&
|
||||
browserAttachConfig?.enabled &&
|
||||
!syncBrowserMcpToConfigDir(defaultContinuityInheritance.claudeConfigDir)
|
||||
) {
|
||||
throw new Error(
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { CLIProxyProvider } from '../types';
|
||||
|
||||
const DUPLICATE_EMAIL_ACCOUNT_PROVIDERS = new Set<string>(['codex']);
|
||||
const FREE_PLAN_PARTS = new Set(['free']);
|
||||
const PERSONAL_PLAN_PARTS = new Set(['plus', 'pro']);
|
||||
const BUSINESS_PLAN_PARTS = new Set(['team']);
|
||||
|
||||
// Keep variant parsing aligned with ui/src/lib/account-identity.ts. The UI copy is
|
||||
// separate because the browser bundle cannot import this server module directly.
|
||||
@@ -49,6 +52,20 @@ function formatVariantPart(value: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function formatWorkspaceLabel(parts: string[]): string | null {
|
||||
const workspaceId = parts.find((part) => /^[a-f0-9]{8}$/i.test(part));
|
||||
if (workspaceId) {
|
||||
return `Workspace ${workspaceId.toLowerCase()}`;
|
||||
}
|
||||
|
||||
return parts.map(formatVariantPart).filter(Boolean).join(' · ') || null;
|
||||
}
|
||||
|
||||
function formatAudienceDetail(parts: string[]): string | null {
|
||||
const label = parts.map(formatVariantPart).filter(Boolean).join(' · ');
|
||||
return label || null;
|
||||
}
|
||||
|
||||
export function supportsDuplicateEmailAccounts(provider: CLIProxyProvider | string): boolean {
|
||||
return DUPLICATE_EMAIL_ACCOUNT_PROVIDERS.has(normalizeProvider(provider));
|
||||
}
|
||||
@@ -136,8 +153,16 @@ export function formatAccountVariantLabel(accountId: string, email?: string): st
|
||||
}
|
||||
|
||||
const suffix = parts[parts.length - 1]?.toLowerCase();
|
||||
if (suffix && ['team', 'free', 'plus', 'pro'].includes(suffix)) {
|
||||
return [formatVariantPart(suffix), ...parts.slice(0, -1).map(formatVariantPart)]
|
||||
if (suffix && BUSINESS_PLAN_PARTS.has(suffix)) {
|
||||
return ['Business', formatWorkspaceLabel(parts.slice(0, -1))].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
if (suffix && FREE_PLAN_PARTS.has(suffix)) {
|
||||
return ['Free', formatAudienceDetail(parts.slice(0, -1))].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
if (suffix && PERSONAL_PLAN_PARTS.has(suffix)) {
|
||||
return ['Personal', formatVariantPart(suffix), formatAudienceDetail(parts.slice(0, -1))]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
@@ -155,6 +155,10 @@ export function toKiroManagementMethod(method: KiroAuthMethod): 'aws' | 'google'
|
||||
* - Qwen: Device Code Flow (polling-based, NO callback port needed)
|
||||
* - GHCP: Device Code Flow (polling-based, NO callback port needed)
|
||||
* - Kimi: Device Code Flow (polling-based, NO callback port needed)
|
||||
* - Cursor: Device-style browser polling (NO callback port needed)
|
||||
* - GitLab: Authorization Code Flow with callback server on port 17171
|
||||
* - CodeBuddy: Device-style browser polling (NO callback port needed)
|
||||
* - Kilo: Device Code Flow (polling-based, NO callback port needed)
|
||||
*/
|
||||
export const OAUTH_CALLBACK_PORTS: Partial<Record<CLIProxyProvider, number>> =
|
||||
CLIPROXY_PROVIDER_IDS.reduce(
|
||||
@@ -274,6 +278,34 @@ export const OAUTH_CONFIGS: Record<CLIProxyProvider, ProviderOAuthConfig> = {
|
||||
scopes: ['api'],
|
||||
authFlag: '--kimi-login',
|
||||
},
|
||||
cursor: {
|
||||
provider: 'cursor',
|
||||
displayName: 'Cursor',
|
||||
authUrl: 'https://cursor.com/loginDeepControl',
|
||||
scopes: [],
|
||||
authFlag: '--cursor-login',
|
||||
},
|
||||
gitlab: {
|
||||
provider: 'gitlab',
|
||||
displayName: 'GitLab Duo',
|
||||
authUrl: 'https://gitlab.com/oauth/authorize',
|
||||
scopes: ['api', 'read_user'],
|
||||
authFlag: '--gitlab-login',
|
||||
},
|
||||
codebuddy: {
|
||||
provider: 'codebuddy',
|
||||
displayName: 'CodeBuddy (Tencent)',
|
||||
authUrl: 'https://copilot.tencent.com/v2/plugin/auth/state',
|
||||
scopes: [],
|
||||
authFlag: '--codebuddy-login',
|
||||
},
|
||||
kilo: {
|
||||
provider: 'kilo',
|
||||
displayName: 'Kilo AI',
|
||||
authUrl: 'https://api.kilo.ai/api/device-auth/codes',
|
||||
scopes: [],
|
||||
authFlag: '--kilo-login',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -373,6 +405,12 @@ export interface OAuthOptions {
|
||||
noIncognito?: boolean;
|
||||
/** If true, skip OAuth and import token from Kiro IDE directly (Kiro only) */
|
||||
import?: boolean;
|
||||
/** GitLab auth mode override. */
|
||||
gitlabAuthMode?: 'oauth' | 'pat';
|
||||
/** GitLab self-hosted base URL override. */
|
||||
gitlabBaseUrl?: string;
|
||||
/** GitLab personal access token for PAT login. */
|
||||
gitlabPersonalAccessToken?: string;
|
||||
/** Enable paste-callback mode: show auth URL and prompt for callback paste */
|
||||
pasteCallback?: boolean;
|
||||
/** If true, use port-forwarding mode (skip interactive prompt in headless) */
|
||||
|
||||
@@ -1,437 +0,0 @@
|
||||
/**
|
||||
* Gemini Token Refresh
|
||||
*
|
||||
* Handles proactive token validation and refresh for Gemini OAuth tokens.
|
||||
* Prevents UND_ERR_SOCKET errors by ensuring tokens are valid before use.
|
||||
*
|
||||
* Token sources (priority order):
|
||||
* 1. CLIProxy auth dir (~/.ccs/cliproxy/auth/) - CCS-managed tokens
|
||||
* 2. Standard Gemini CLI (~/.gemini/oauth_creds.json) - backward compatibility
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { getProviderAuthDir } from '../config-generator';
|
||||
import { getDefaultAccount, getProviderAccounts } from '../account-manager';
|
||||
import { isTokenFileForProvider } from './token-manager';
|
||||
|
||||
/** Google OAuth token endpoint */
|
||||
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||
|
||||
/** Refresh tokens 5 minutes before expiry */
|
||||
const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000;
|
||||
|
||||
const GEMINI_CLIENT_ID_ENV_KEYS = ['CCS_GEMINI_OAUTH_CLIENT_ID', 'OPENCLAW_GEMINI_OAUTH_CLIENT_ID'];
|
||||
const GEMINI_CLIENT_SECRET_ENV_KEYS = [
|
||||
'CCS_GEMINI_OAUTH_CLIENT_SECRET',
|
||||
'OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET',
|
||||
];
|
||||
|
||||
/** Gemini oauth_creds.json structure */
|
||||
interface GeminiOAuthCreds {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expiry_date?: number; // Unix timestamp in milliseconds
|
||||
scope?: string;
|
||||
token_type?: string;
|
||||
id_token?: string;
|
||||
client_id?: string;
|
||||
client_secret?: string;
|
||||
token_uri?: string;
|
||||
}
|
||||
|
||||
/** Gemini credentials with source path for write-back */
|
||||
interface GeminiCredsWithSource {
|
||||
creds: GeminiOAuthCreds;
|
||||
sourcePath: string;
|
||||
}
|
||||
|
||||
/** CLIProxyAPI Gemini token structure (from GeminiTokenStorage Go struct) */
|
||||
interface CliproxyGeminiToken {
|
||||
token: {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expiry?: number; // Unix timestamp in milliseconds
|
||||
client_id?: string;
|
||||
client_secret?: string;
|
||||
token_uri?: string;
|
||||
};
|
||||
project_id: string;
|
||||
email: string;
|
||||
type: 'gemini';
|
||||
}
|
||||
|
||||
/** Token refresh response from Google */
|
||||
interface TokenRefreshResponse {
|
||||
access_token?: string;
|
||||
expires_in?: number;
|
||||
token_type?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
}
|
||||
|
||||
interface GoogleOAuthClientCredentials {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
tokenUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path to Gemini OAuth credentials file
|
||||
*/
|
||||
export function getGeminiOAuthPath(): string {
|
||||
return path.join(os.homedir(), '.gemini', 'oauth_creds.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Map CLIProxyAPI token format to internal GeminiOAuthCreds format
|
||||
*/
|
||||
function mapCliproxyToGeminiCreds(cliproxy: CliproxyGeminiToken): GeminiOAuthCreds {
|
||||
return {
|
||||
access_token: cliproxy.token.access_token,
|
||||
refresh_token: cliproxy.token.refresh_token,
|
||||
expiry_date: cliproxy.token.expiry,
|
||||
token_type: 'Bearer',
|
||||
client_id: cliproxy.token.client_id,
|
||||
client_secret: cliproxy.token.client_secret,
|
||||
token_uri: cliproxy.token.token_uri,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate CLIProxyAPI token structure has required fields
|
||||
*/
|
||||
function isValidCliproxyToken(data: unknown): data is CliproxyGeminiToken {
|
||||
if (typeof data !== 'object' || data === null) return false;
|
||||
const obj = data as Record<string, unknown>;
|
||||
if (obj.type !== 'gemini') return false;
|
||||
if (typeof obj.token !== 'object' || obj.token === null) return false;
|
||||
const token = obj.token as Record<string, unknown>;
|
||||
return typeof token.access_token === 'string';
|
||||
}
|
||||
|
||||
function getFirstEnvValue(keys: readonly string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = process.env[key]?.trim();
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveGeminiRefreshCredentials(creds: GeminiOAuthCreds): {
|
||||
credentials?: GoogleOAuthClientCredentials;
|
||||
error?: string;
|
||||
} {
|
||||
const clientId = creds.client_id?.trim() || getFirstEnvValue(GEMINI_CLIENT_ID_ENV_KEYS);
|
||||
const clientSecret =
|
||||
creds.client_secret?.trim() || getFirstEnvValue(GEMINI_CLIENT_SECRET_ENV_KEYS);
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
return {
|
||||
error:
|
||||
'Gemini token refresh unavailable: missing OAuth client credentials in the token file. ' +
|
||||
'Re-authenticate with CLIProxy or set CCS_GEMINI_OAUTH_CLIENT_ID and CCS_GEMINI_OAUTH_CLIENT_SECRET.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
credentials: {
|
||||
clientId,
|
||||
clientSecret,
|
||||
tokenUrl: creds.token_uri?.trim() || GOOGLE_TOKEN_URL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Gemini token from CLIProxy auth directory
|
||||
* Returns credentials with source path, or null if no valid token found
|
||||
*/
|
||||
function readCliproxyGeminiCreds(accountId?: string): GeminiCredsWithSource | null {
|
||||
const authDir = getProviderAuthDir('gemini');
|
||||
if (!fs.existsSync(authDir)) return null;
|
||||
|
||||
let tokenPath: string | null = null;
|
||||
const normalizedAccountId = accountId?.trim();
|
||||
const accounts = getProviderAccounts('gemini');
|
||||
|
||||
// Account-specific refresh path (used by background worker)
|
||||
if (normalizedAccountId) {
|
||||
const targetAccount = accounts.find((account) => account.id === normalizedAccountId);
|
||||
if (!targetAccount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
tokenPath = path.join(authDir, targetAccount.tokenFile);
|
||||
}
|
||||
|
||||
if (!normalizedAccountId) {
|
||||
// Try to find default account's token file
|
||||
const defaultAccount = getDefaultAccount('gemini');
|
||||
if (defaultAccount) {
|
||||
tokenPath = path.join(authDir, defaultAccount.tokenFile);
|
||||
if (!fs.existsSync(tokenPath)) tokenPath = null;
|
||||
}
|
||||
|
||||
// Fallback: find any gemini account token file
|
||||
if (!tokenPath && accounts.length > 0) {
|
||||
tokenPath = path.join(authDir, accounts[0].tokenFile);
|
||||
if (!fs.existsSync(tokenPath)) tokenPath = null;
|
||||
}
|
||||
|
||||
// Last fallback: scan directory for gemini token files
|
||||
if (!tokenPath) {
|
||||
try {
|
||||
const files = fs.readdirSync(authDir).filter((f) => f.endsWith('.json'));
|
||||
for (const file of files) {
|
||||
const filePath = path.join(authDir, file);
|
||||
if (file.startsWith('gemini-') || isTokenFileForProvider(filePath, 'gemini')) {
|
||||
tokenPath = filePath;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory read failed - continue to return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!tokenPath) return null;
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(tokenPath, 'utf8');
|
||||
const data: unknown = JSON.parse(content);
|
||||
|
||||
// Validate CLIProxyAPI format with proper type checking
|
||||
if (isValidCliproxyToken(data)) {
|
||||
return {
|
||||
creds: mapCliproxyToGeminiCreds(data),
|
||||
sourcePath: tokenPath,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Gemini OAuth credentials
|
||||
* Priority: CLIProxy auth dir first, then ~/.gemini/oauth_creds.json
|
||||
* Returns credentials with source path for correct write-back
|
||||
*/
|
||||
function readGeminiCreds(accountId?: string): GeminiCredsWithSource | null {
|
||||
// 1. Try CLIProxy auth directory first (CCS-managed tokens)
|
||||
const cliproxyResult = readCliproxyGeminiCreds(accountId);
|
||||
if (cliproxyResult) {
|
||||
return cliproxyResult;
|
||||
}
|
||||
|
||||
// Account-scoped refresh is only supported for CLIProxy account files.
|
||||
// Do not fall back to ~/.gemini for a specific accountId.
|
||||
if (accountId?.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. Fall back to standard Gemini CLI location
|
||||
const oauthPath = getGeminiOAuthPath();
|
||||
if (!fs.existsSync(oauthPath)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const content = fs.readFileSync(oauthPath, 'utf8');
|
||||
return {
|
||||
creds: JSON.parse(content) as GeminiOAuthCreds,
|
||||
sourcePath: oauthPath,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write updated credentials to CLIProxy token file
|
||||
* Preserves existing fields (email, project_id), only updates token subfields
|
||||
*/
|
||||
function writeCliproxyGeminiCreds(tokenPath: string, creds: GeminiOAuthCreds): string | undefined {
|
||||
try {
|
||||
const existing = JSON.parse(fs.readFileSync(tokenPath, 'utf8'));
|
||||
const updated = {
|
||||
...existing,
|
||||
token: {
|
||||
...existing.token,
|
||||
access_token: creds.access_token,
|
||||
refresh_token: creds.refresh_token,
|
||||
expiry: creds.expiry_date,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(tokenPath, JSON.stringify(updated, null, 2), { mode: 0o600 });
|
||||
return undefined;
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : 'Failed to write credentials';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write Gemini OAuth credentials
|
||||
* Writes back to the specified source location (CLIProxy or ~/.gemini)
|
||||
* @param creds - The credentials to write
|
||||
* @param sourcePath - The path where credentials were originally read from
|
||||
* @returns error message if write failed, undefined on success
|
||||
*/
|
||||
function writeGeminiCreds(creds: GeminiOAuthCreds, sourcePath: string): string | undefined {
|
||||
const geminiOAuthPath = getGeminiOAuthPath();
|
||||
|
||||
// If source is not the standard Gemini path, write to CLIProxy format
|
||||
if (sourcePath !== geminiOAuthPath) {
|
||||
return writeCliproxyGeminiCreds(sourcePath, creds);
|
||||
}
|
||||
|
||||
// Otherwise write to standard Gemini CLI location
|
||||
const dir = path.dirname(geminiOAuthPath);
|
||||
try {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
fs.writeFileSync(geminiOAuthPath, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
||||
return undefined;
|
||||
} catch (err) {
|
||||
return err instanceof Error ? err.message : 'Failed to write credentials';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Gemini token is expired or expiring soon
|
||||
*/
|
||||
export function isGeminiTokenExpiringSoon(accountId?: string): boolean {
|
||||
const result = readGeminiCreds(accountId);
|
||||
if (!result || !result.creds.access_token) {
|
||||
return true; // No token = needs auth
|
||||
}
|
||||
if (!result.creds.expiry_date) {
|
||||
return false; // No expiry info = assume valid
|
||||
}
|
||||
const expiresIn = result.creds.expiry_date - Date.now();
|
||||
return expiresIn < REFRESH_LEAD_TIME_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Gemini access token using refresh_token
|
||||
* @param accountId Optional account ID for account-scoped refresh
|
||||
* @returns Result with success status, optional error, and expiry time
|
||||
*/
|
||||
export async function refreshGeminiToken(accountId?: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
expiresAt?: number;
|
||||
}> {
|
||||
const result = readGeminiCreds(accountId);
|
||||
if (!result || !result.creds.refresh_token) {
|
||||
return { success: false, error: 'No refresh token available' };
|
||||
}
|
||||
|
||||
const { creds, sourcePath } = result;
|
||||
const resolvedCredentials = resolveGeminiRefreshCredentials(creds);
|
||||
if (!resolvedCredentials.credentials) {
|
||||
return { success: false, error: resolvedCredentials.error };
|
||||
}
|
||||
|
||||
const { clientId, clientSecret, tokenUrl } = resolvedCredentials.credentials;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
try {
|
||||
const response = await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: creds.refresh_token as string, // Already validated above
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const data = (await response.json()) as TokenRefreshResponse;
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
return {
|
||||
success: false,
|
||||
error: data.error_description || data.error || `OAuth error: ${response.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!data.access_token) {
|
||||
return { success: false, error: 'No access_token in response' };
|
||||
}
|
||||
|
||||
// Update credentials file with new token
|
||||
const expiresAt = Date.now() + (data.expires_in ?? 3600) * 1000;
|
||||
const updatedCreds: GeminiOAuthCreds = {
|
||||
...creds,
|
||||
access_token: data.access_token,
|
||||
expiry_date: expiresAt,
|
||||
};
|
||||
const writeError = writeGeminiCreds(updatedCreds, sourcePath);
|
||||
if (writeError) {
|
||||
return { success: false, error: `Token refreshed but failed to save: ${writeError}` };
|
||||
}
|
||||
|
||||
return { success: true, expiresAt };
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
return { success: false, error: 'Token refresh timeout' };
|
||||
}
|
||||
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure Gemini token is valid, refreshing if needed
|
||||
* @param verbose Log progress if true
|
||||
* @param accountId Optional account ID for account-scoped refresh
|
||||
* @returns true if token is valid (or was refreshed), false if refresh failed
|
||||
*/
|
||||
export async function ensureGeminiTokenValid(
|
||||
verbose = false,
|
||||
accountId?: string
|
||||
): Promise<{
|
||||
valid: boolean;
|
||||
refreshed: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
const result = readGeminiCreds(accountId);
|
||||
if (!result || !result.creds.access_token) {
|
||||
return { valid: false, refreshed: false, error: 'No Gemini credentials found' };
|
||||
}
|
||||
|
||||
if (!isGeminiTokenExpiringSoon(accountId)) {
|
||||
return { valid: true, refreshed: false };
|
||||
}
|
||||
|
||||
// Token is expired or expiring soon - try to refresh
|
||||
if (verbose) {
|
||||
console.log('[i] Gemini token expired or expiring soon, refreshing...');
|
||||
}
|
||||
|
||||
const refreshResult = await refreshGeminiToken(accountId);
|
||||
if (refreshResult.success) {
|
||||
if (verbose) {
|
||||
console.log('[OK] Gemini token refreshed successfully');
|
||||
}
|
||||
return { valid: true, refreshed: true };
|
||||
}
|
||||
|
||||
return { valid: false, refreshed: false, error: refreshResult.error };
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
const GITLAB_PAT_ERROR_DETAIL_MAX_LENGTH = 500;
|
||||
const GITLAB_PAT_ERROR_DETAIL_TRUNCATION_SUFFIX = '...[truncated]';
|
||||
const HTML_ERROR_RESPONSE_OMITTED = '[HTML error response omitted]';
|
||||
|
||||
function sanitizeGitLabPatErrorDetail(
|
||||
detail: string | undefined,
|
||||
submittedToken?: string
|
||||
): string | undefined {
|
||||
const trimmed = detail?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (/^<!doctype html/i.test(trimmed) || /^<html/i.test(trimmed) || /^<[^>]+>/.test(trimmed)) {
|
||||
return HTML_ERROR_RESPONSE_OMITTED;
|
||||
}
|
||||
|
||||
let sanitized = trimmed.replace(
|
||||
/"(access[_-]?token|refresh[_-]?token|authorization|cookie|set-cookie|api[_-]?key|session[_-]?token|token|personal_access_token)"\s*:\s*"[^"]*"/gi,
|
||||
'"$1":"[redacted]"'
|
||||
);
|
||||
|
||||
if (submittedToken) {
|
||||
sanitized = sanitized.split(submittedToken).join('[redacted]');
|
||||
}
|
||||
|
||||
sanitized = sanitized
|
||||
.replace(/glpat-[A-Za-z0-9._-]+/gi, '[redacted]')
|
||||
.replace(/Bearer\s+[A-Za-z0-9._-]+/g, 'Bearer [redacted]')
|
||||
.replace(/\s+/g, ' ');
|
||||
|
||||
if (sanitized.length > GITLAB_PAT_ERROR_DETAIL_MAX_LENGTH) {
|
||||
sanitized = `${sanitized.slice(
|
||||
0,
|
||||
GITLAB_PAT_ERROR_DETAIL_MAX_LENGTH - GITLAB_PAT_ERROR_DETAIL_TRUNCATION_SUFFIX.length
|
||||
)}${GITLAB_PAT_ERROR_DETAIL_TRUNCATION_SUFFIX}`;
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function parseGitLabPatAuthResponse(
|
||||
responseOk: boolean,
|
||||
responseStatus: number,
|
||||
responseBody: string,
|
||||
submittedToken?: string
|
||||
):
|
||||
| { ok: true; payload: Record<string, unknown> }
|
||||
| { ok: false; payload: Record<string, unknown>; errorMessage: string } {
|
||||
const trimmedBody = responseBody.trim();
|
||||
let payload: Record<string, unknown> = {};
|
||||
|
||||
if (trimmedBody) {
|
||||
try {
|
||||
payload = JSON.parse(trimmedBody) as Record<string, unknown>;
|
||||
} catch {
|
||||
payload = {
|
||||
error:
|
||||
sanitizeGitLabPatErrorDetail(trimmedBody, submittedToken) ||
|
||||
`GitLab PAT login failed with status ${responseStatus}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const payloadError =
|
||||
typeof payload.error === 'string'
|
||||
? sanitizeGitLabPatErrorDetail(payload.error, submittedToken)
|
||||
: undefined;
|
||||
const fallbackError =
|
||||
sanitizeGitLabPatErrorDetail(trimmedBody, submittedToken) ||
|
||||
`GitLab PAT login failed with status ${responseStatus}`;
|
||||
|
||||
if (!responseOk) {
|
||||
return {
|
||||
ok: false,
|
||||
payload,
|
||||
errorMessage: payloadError || fallbackError,
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.status !== 'ok') {
|
||||
return {
|
||||
ok: false,
|
||||
payload,
|
||||
errorMessage: payloadError || fallbackError,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, payload };
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export async function tryKiroImport(tokenDir: string, verbose = false): Promise<
|
||||
|
||||
try {
|
||||
log('Ensuring CLIProxy binary is available...');
|
||||
const binaryPath = await ensureCLIProxyBinary(verbose);
|
||||
const binaryPath = await ensureCLIProxyBinary(verbose, { skipAutoUpdate: true });
|
||||
const configPath = generateConfig('kiro');
|
||||
|
||||
log(`Binary: ${binaryPath}`);
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
} from './token-manager';
|
||||
import { executeOAuthProcess } from './oauth-process';
|
||||
import { importKiroToken } from './kiro-import';
|
||||
import { parseGitLabPatAuthResponse } from './gitlab-pat-response';
|
||||
import {
|
||||
getProxyTarget,
|
||||
buildProxyUrl,
|
||||
@@ -69,6 +70,7 @@ import {
|
||||
warnPossible403Ban,
|
||||
} from '../account-safety';
|
||||
import { ensureCliAntigravityResponsibility } from '../antigravity-responsibility';
|
||||
import { InteractivePrompt } from '../../utils/prompt';
|
||||
|
||||
interface PasteCallbackStartData {
|
||||
url?: string;
|
||||
@@ -83,9 +85,12 @@ const POLLED_AUTH_LOCAL_TOKEN_GRACE_MS = 15 * 1000;
|
||||
export async function requestPasteCallbackStart(
|
||||
provider: CLIProxyProvider,
|
||||
target: ProxyTarget,
|
||||
options?: { kiroMethod?: OAuthOptions['kiroMethod'] }
|
||||
options?: {
|
||||
kiroMethod?: OAuthOptions['kiroMethod'];
|
||||
gitlabBaseUrl?: OAuthOptions['gitlabBaseUrl'];
|
||||
}
|
||||
): Promise<PasteCallbackStartData> {
|
||||
const startPath = getPasteCallbackStartPath(provider, {
|
||||
let startPath = getPasteCallbackStartPath(provider, {
|
||||
kiroMethod: options?.kiroMethod,
|
||||
});
|
||||
if (!startPath) {
|
||||
@@ -93,6 +98,11 @@ export async function requestPasteCallbackStart(
|
||||
`Paste-callback start is not available for ${provider} with the selected method`
|
||||
);
|
||||
}
|
||||
const normalizedGitLabBaseUrl =
|
||||
provider === 'gitlab' ? normalizeGitLabBaseUrl(options?.gitlabBaseUrl) : undefined;
|
||||
if (normalizedGitLabBaseUrl) {
|
||||
startPath += `&base_url=${encodeURIComponent(normalizedGitLabBaseUrl)}`;
|
||||
}
|
||||
const response = await fetch(buildProxyUrl(target, startPath), {
|
||||
headers: buildManagementHeaders(target),
|
||||
});
|
||||
@@ -142,6 +152,49 @@ function parseAuthUrlState(url: string | null | undefined): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeGitLabBaseUrl(baseUrl: string | undefined): string | undefined {
|
||||
const normalized = baseUrl?.trim();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(normalized);
|
||||
} catch {
|
||||
throw new Error('GitLab URL must be a valid http:// or https:// URL');
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error('GitLab URL must use http:// or https://');
|
||||
}
|
||||
|
||||
parsed.hash = '';
|
||||
parsed.search = '';
|
||||
parsed.username = '';
|
||||
parsed.password = '';
|
||||
|
||||
const normalizedPath = parsed.pathname.replace(/\/+$/, '');
|
||||
return normalizedPath ? `${parsed.origin}${normalizedPath}` : parsed.origin;
|
||||
}
|
||||
|
||||
export async function promptGitLabPersonalAccessToken(): Promise<string | null> {
|
||||
try {
|
||||
const token = (await InteractivePrompt.password('GitLab Personal Access Token')).trim();
|
||||
return token.length > 0 ? token : null;
|
||||
} catch (error) {
|
||||
if ((error as Error).message.includes('TTY')) {
|
||||
console.log(
|
||||
fail(
|
||||
'GitLab Personal Access Token prompt requires an interactive TTY. Set the token explicitly or use Browser OAuth.'
|
||||
)
|
||||
);
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function findNewTokenSnapshotForManualAuth(
|
||||
provider: CLIProxyProvider,
|
||||
tokenDir: string,
|
||||
@@ -375,7 +428,7 @@ async function prepareBinary(
|
||||
showStep(1, 4, 'progress', 'Preparing CLIProxy binary...');
|
||||
|
||||
try {
|
||||
const binaryPath = await ensureCLIProxyBinary(verbose);
|
||||
const binaryPath = await ensureCLIProxyBinary(verbose, { skipAutoUpdate: true });
|
||||
process.stdout.write('\x1b[1A\x1b[2K');
|
||||
showStep(1, 4, 'ok', 'CLIProxy binary ready');
|
||||
|
||||
@@ -458,7 +511,10 @@ async function handlePasteCallbackMode(
|
||||
tokenDir: string,
|
||||
nickname?: string,
|
||||
expectedAccountId?: string,
|
||||
options?: { kiroMethod?: OAuthOptions['kiroMethod'] }
|
||||
options?: {
|
||||
kiroMethod?: OAuthOptions['kiroMethod'];
|
||||
gitlabBaseUrl?: OAuthOptions['gitlabBaseUrl'];
|
||||
}
|
||||
): Promise<AccountInfo | null> {
|
||||
// Resolve CLIProxyAPI target (local or remote based on config)
|
||||
const target = getProxyTarget();
|
||||
@@ -647,6 +703,91 @@ async function handlePasteCallbackMode(
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGitLabPatLogin(
|
||||
provider: CLIProxyProvider,
|
||||
oauthConfig: ProviderOAuthConfig,
|
||||
verbose: boolean,
|
||||
tokenDir: string,
|
||||
nickname?: string,
|
||||
expectedAccountId?: string,
|
||||
options?: {
|
||||
gitlabBaseUrl?: OAuthOptions['gitlabBaseUrl'];
|
||||
gitlabPersonalAccessToken?: OAuthOptions['gitlabPersonalAccessToken'];
|
||||
}
|
||||
): Promise<AccountInfo | null> {
|
||||
const target = getProxyTarget();
|
||||
const baseUrl = normalizeGitLabBaseUrl(options?.gitlabBaseUrl);
|
||||
const knownTokenFiles = listProviderTokenSnapshots(provider, tokenDir);
|
||||
const suppliedToken = options?.gitlabPersonalAccessToken?.trim();
|
||||
const personalAccessToken =
|
||||
suppliedToken || process.env['GITLAB_PERSONAL_ACCESS_TOKEN']?.trim() || undefined;
|
||||
|
||||
let token = personalAccessToken;
|
||||
if (!token) {
|
||||
console.log('');
|
||||
console.log(info(`Starting ${oauthConfig.displayName} PAT login...`));
|
||||
console.log('Paste a Personal Access Token with api and read_user scopes.');
|
||||
token = (await promptGitLabPersonalAccessToken()) || undefined;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
console.log(info('Cancelled'));
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await fetch(buildProxyUrl(target, '/v0/management/gitlab-auth-url'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...buildManagementHeaders(target),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...(baseUrl ? { base_url: baseUrl } : {}),
|
||||
personal_access_token: token,
|
||||
}),
|
||||
});
|
||||
|
||||
const responseBody = await response.text();
|
||||
const parsedResponse = parseGitLabPatAuthResponse(
|
||||
response.ok,
|
||||
response.status,
|
||||
responseBody,
|
||||
token
|
||||
);
|
||||
|
||||
if (!parsedResponse.ok) {
|
||||
console.log(fail(parsedResponse.errorMessage));
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenSnapshot = findNewTokenSnapshotForAuthAttempt(
|
||||
provider,
|
||||
tokenDir,
|
||||
knownTokenFiles,
|
||||
expectedAccountId
|
||||
);
|
||||
if (!tokenSnapshot) {
|
||||
console.log(fail('GitLab PAT login completed, but CCS could not find the saved token file.'));
|
||||
return null;
|
||||
}
|
||||
|
||||
const account = registerAccountFromToken(
|
||||
provider,
|
||||
tokenDir,
|
||||
nickname,
|
||||
verbose,
|
||||
expectedAccountId || tokenSnapshot.file
|
||||
);
|
||||
|
||||
if (!account) {
|
||||
console.log(fail('Authenticated GitLab token could not be registered as a CCS account.'));
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(ok('Authentication successful!'));
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger OAuth flow for provider
|
||||
* Auto-detects headless environment and uses --no-browser flag accordingly
|
||||
@@ -666,6 +807,17 @@ export async function triggerOAuth(
|
||||
provider === 'kiro' ? normalizeKiroAuthMethod(options.kiroMethod) : DEFAULT_KIRO_AUTH_METHOD;
|
||||
const resolvedKiroIDCFlow =
|
||||
provider === 'kiro' ? normalizeKiroIDCFlow(options.kiroIDCFlow) : DEFAULT_KIRO_IDC_FLOW;
|
||||
const resolvedGitLabAuthMode =
|
||||
provider === 'gitlab' && options.gitlabAuthMode === 'pat' ? 'pat' : 'oauth';
|
||||
let resolvedGitLabBaseUrl: string | undefined;
|
||||
if (provider === 'gitlab') {
|
||||
try {
|
||||
resolvedGitLabBaseUrl = normalizeGitLabBaseUrl(options.gitlabBaseUrl);
|
||||
} catch (error) {
|
||||
console.log(fail((error as Error).message));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === 'agy') {
|
||||
if (fromUI && !acceptAgyRisk) {
|
||||
@@ -744,6 +896,14 @@ export async function triggerOAuth(
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === 'gitlab' && resolvedGitLabBaseUrl && !selectedPasteCallback) {
|
||||
selectedPasteCallback = true;
|
||||
console.log('');
|
||||
console.log(
|
||||
info('GitLab custom base URL selected. Switching to paste-callback mode for OAuth.')
|
||||
);
|
||||
}
|
||||
|
||||
const useSelectedKiroLocalPasteCallback =
|
||||
selectedPasteCallback &&
|
||||
provider === 'kiro' &&
|
||||
@@ -765,6 +925,22 @@ export async function triggerOAuth(
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === 'gitlab' && resolvedGitLabAuthMode === 'pat') {
|
||||
const tokenDir = getProviderTokenDir(provider);
|
||||
return handleGitLabPatLogin(
|
||||
provider,
|
||||
oauthConfig,
|
||||
verbose,
|
||||
tokenDir,
|
||||
nickname,
|
||||
existingNameMatch?.id,
|
||||
{
|
||||
gitlabBaseUrl: resolvedGitLabBaseUrl,
|
||||
gitlabPersonalAccessToken: options.gitlabPersonalAccessToken,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedPasteCallback && !useSelectedKiroDirectCliFlow) {
|
||||
const tokenDir = getProviderTokenDir(provider);
|
||||
return handlePasteCallbackMode(
|
||||
@@ -774,7 +950,10 @@ export async function triggerOAuth(
|
||||
tokenDir,
|
||||
nickname,
|
||||
existingNameMatch?.id,
|
||||
{ kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined }
|
||||
{
|
||||
kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined,
|
||||
gitlabBaseUrl: provider === 'gitlab' ? resolvedGitLabBaseUrl : undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
* Exports refresh functions for each OAuth provider.
|
||||
*
|
||||
* Refresh responsibility:
|
||||
* - CCS-managed: gemini (CCS refreshes tokens directly via Google OAuth)
|
||||
* - CLIProxy-delegated: codex, agy, kiro, ghcp, qwen, iflow, kimi
|
||||
* - CLIProxy-delegated: gemini, codex, agy, kiro, ghcp, qwen, iflow, kimi
|
||||
* (CLIProxyAPIPlus handles refresh automatically in background)
|
||||
* - Not implemented: claude
|
||||
*/
|
||||
@@ -16,7 +15,6 @@ import {
|
||||
getTokenRefreshOwnership,
|
||||
isRefreshDelegatedToCLIProxy,
|
||||
} from '../../provider-capabilities';
|
||||
import { refreshGeminiToken } from '../gemini-token-refresh';
|
||||
|
||||
/** Token refresh result */
|
||||
export interface ProviderRefreshResult {
|
||||
@@ -66,19 +64,18 @@ export async function refreshToken(
|
||||
};
|
||||
}
|
||||
|
||||
if (provider === 'gemini') {
|
||||
return await refreshGeminiTokenWrapper(normalizedAccountId);
|
||||
}
|
||||
|
||||
const ownership = getTokenRefreshOwnership(provider);
|
||||
switch (ownership) {
|
||||
case 'cliproxy':
|
||||
// CLIProxyAPIPlus handles refresh for these providers automatically.
|
||||
// No action needed from CCS — report success with delegated flag.
|
||||
return { success: true, delegated: true };
|
||||
case 'unsupported':
|
||||
case 'ccs':
|
||||
// Non-gemini CCS-owned refresh paths are not implemented yet.
|
||||
return {
|
||||
success: false,
|
||||
error: `Token refresh not yet implemented for ${provider}`,
|
||||
};
|
||||
case 'unsupported':
|
||||
return {
|
||||
success: false,
|
||||
error: `Token refresh not yet implemented for ${provider}`,
|
||||
@@ -87,23 +84,3 @@ export async function refreshToken(
|
||||
return assertNever(ownership);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for Gemini token refresh
|
||||
* Converts gemini-token-refresh.ts format to provider-refreshers format
|
||||
*/
|
||||
async function refreshGeminiTokenWrapper(accountId: string): Promise<ProviderRefreshResult> {
|
||||
const result = await refreshGeminiToken(accountId);
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
expiresAt: result.expiresAt,
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { getProviderAuthDir } from '../config-generator';
|
||||
import { getProviderAccounts, getDefaultAccount } from '../account-manager';
|
||||
import { deleteTokenFile, extractAccountIdFromTokenFile } from '../accounts/token-file-ops';
|
||||
import { buildEmailBackedAccountId } from '../accounts/email-account-identity';
|
||||
import { getTokenRefreshOwnership } from '../provider-capabilities';
|
||||
import {
|
||||
AuthStatus,
|
||||
PROVIDER_AUTH_PREFIXES,
|
||||
@@ -507,14 +508,16 @@ export function displayAuthStatus(): void {
|
||||
*/
|
||||
export async function ensureTokenValid(
|
||||
provider: CLIProxyProvider,
|
||||
verbose = false
|
||||
_verbose = false
|
||||
): Promise<{ valid: boolean; refreshed: boolean; error?: string }> {
|
||||
if (provider === 'gemini') {
|
||||
const { ensureGeminiTokenValid } = await import('./gemini-token-refresh');
|
||||
return ensureGeminiTokenValid(verbose);
|
||||
if (getTokenRefreshOwnership(provider) === 'ccs') {
|
||||
return {
|
||||
valid: false,
|
||||
refreshed: false,
|
||||
error: `CCS-managed token validation is not available for ${provider}`,
|
||||
};
|
||||
}
|
||||
|
||||
// For CLIProxy-delegated providers, token refresh is handled by CLIProxyAPIPlus.
|
||||
// CCS only verifies the token file exists (authentication state).
|
||||
// Runtime-managed providers refresh upstream. CCS only verifies auth material exists locally.
|
||||
return { valid: isAuthenticated(provider), refreshed: false };
|
||||
}
|
||||
@@ -62,6 +62,8 @@ function createDefaultConfig(backend: CLIProxyBackend = DEFAULT_BACKEND): Binary
|
||||
maxRetries: 3,
|
||||
verbose: false,
|
||||
forceVersion: false,
|
||||
skipAutoUpdate: false,
|
||||
allowInstall: true,
|
||||
backend, // Pass backend for installer to use correct download URL
|
||||
};
|
||||
}
|
||||
@@ -115,8 +117,16 @@ export class BinaryManager {
|
||||
}
|
||||
}
|
||||
|
||||
export interface EnsureCLIProxyBinaryOptions {
|
||||
allowInstall?: boolean;
|
||||
skipAutoUpdate?: boolean;
|
||||
}
|
||||
|
||||
/** Convenience function respecting version pin */
|
||||
export async function ensureCLIProxyBinary(verbose = false): Promise<string> {
|
||||
export async function ensureCLIProxyBinary(
|
||||
verbose = false,
|
||||
options: EnsureCLIProxyBinaryOptions = {}
|
||||
): Promise<string> {
|
||||
const backend = getConfiguredBackend();
|
||||
|
||||
// Migrate old shared pin to backend-specific location (one-time migration)
|
||||
@@ -130,11 +140,20 @@ export async function ensureCLIProxyBinary(verbose = false): Promise<string> {
|
||||
version: pinnedVersion,
|
||||
verbose,
|
||||
forceVersion: true,
|
||||
skipAutoUpdate: options.skipAutoUpdate ?? false,
|
||||
allowInstall: options.allowInstall ?? true,
|
||||
},
|
||||
backend
|
||||
).ensureBinary();
|
||||
}
|
||||
return new BinaryManager({ verbose }, backend).ensureBinary();
|
||||
return new BinaryManager(
|
||||
{
|
||||
verbose,
|
||||
skipAutoUpdate: options.skipAutoUpdate ?? false,
|
||||
allowInstall: options.allowInstall ?? true,
|
||||
},
|
||||
backend
|
||||
).ensureBinary();
|
||||
}
|
||||
|
||||
/** Check if CLIProxyAPI binary is installed */
|
||||
|
||||
@@ -26,6 +26,10 @@ function log(message: string, verbose: boolean): void {
|
||||
if (verbose) console.error(`[cliproxy] ${message}`);
|
||||
}
|
||||
|
||||
function getBackendLabel(backend: CLIProxyBackend): string {
|
||||
return backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if version is above max stable (known unstable)
|
||||
*/
|
||||
@@ -52,7 +56,7 @@ function clampToMaxStable(version: string | undefined, verbose: boolean): string
|
||||
/** Handle auto-update when binary exists */
|
||||
async function handleAutoUpdate(config: BinaryManagerConfig, verbose: boolean): Promise<void> {
|
||||
const backend: CLIProxyBackend = config.backend ?? DEFAULT_BACKEND;
|
||||
const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
|
||||
const backendLabel = getBackendLabel(backend);
|
||||
const updateResult = await checkForUpdates(config.binPath, config.version, verbose, backend);
|
||||
const currentVersion = updateResult.currentVersion;
|
||||
const latestVersion = updateResult.latestVersion;
|
||||
@@ -112,6 +116,11 @@ export async function ensureBinary(config: BinaryManagerConfig): Promise<string>
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
if (config.skipAutoUpdate) {
|
||||
log('Runtime bootstrap mode: skipping auto-update check', verbose);
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
try {
|
||||
await handleAutoUpdate(config, verbose);
|
||||
} catch (error) {
|
||||
@@ -125,6 +134,13 @@ export async function ensureBinary(config: BinaryManagerConfig): Promise<string>
|
||||
// Binary missing - download
|
||||
log('Binary not found, downloading...', verbose);
|
||||
|
||||
if (!config.allowInstall) {
|
||||
throw new Error(
|
||||
`${getBackendLabel(backend)} binary is not installed locally. ` +
|
||||
'Run "ccs cliproxy install" when you have network access.'
|
||||
);
|
||||
}
|
||||
|
||||
if (!config.forceVersion) {
|
||||
try {
|
||||
const latestVersion = await fetchLatestVersion(verbose, backend);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getDefaultAccount } from './account-manager';
|
||||
import { getProviderCatalog } from './model-catalog';
|
||||
import { normalizeModelIdForProvider } from './model-id-normalizer';
|
||||
import { fetchCodexQuota } from './quota-fetcher-codex';
|
||||
import { getCachedQuota, setCachedQuota } from './quota-response-cache';
|
||||
import type { CodexQuotaResult } from './quota-types';
|
||||
@@ -7,8 +8,8 @@ import { info, warn } from '../utils/ui';
|
||||
|
||||
export type CodexPlanType = CodexQuotaResult['planType'];
|
||||
|
||||
const FREE_SAFE_DEFAULT_MODEL = 'gpt-5-codex';
|
||||
const FREE_SAFE_FAST_MODEL = 'gpt-5-codex-mini';
|
||||
const FREE_SAFE_DEFAULT_MODEL = 'gpt-5.4';
|
||||
const FREE_SAFE_FAST_MODEL = 'gpt-5.4-mini';
|
||||
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
|
||||
const CODEX_PAREN_SUFFIX_REGEX = /\((xhigh|high|medium)\)$/i;
|
||||
const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i;
|
||||
@@ -19,7 +20,6 @@ const KNOWN_CODEX_MODELS = new Set(
|
||||
const FREE_PLAN_FALLBACKS = new Map<string, string>([
|
||||
['gpt-5.3-codex', FREE_SAFE_DEFAULT_MODEL],
|
||||
['gpt-5.3-codex-spark', FREE_SAFE_FAST_MODEL],
|
||||
['gpt-5.4', FREE_SAFE_DEFAULT_MODEL],
|
||||
]);
|
||||
|
||||
export interface CodexRuntimeFallbackModelMap {
|
||||
@@ -52,13 +52,13 @@ function isKnownCodexModel(model: string): boolean {
|
||||
}
|
||||
|
||||
export function normalizeCodexModelId(model: string): string {
|
||||
return model
|
||||
const stripped = model
|
||||
.trim()
|
||||
.replace(EXTENDED_CONTEXT_SUFFIX_REGEX, '')
|
||||
.replace(CODEX_PAREN_SUFFIX_REGEX, '')
|
||||
.replace(CODEX_EFFORT_SUFFIX_REGEX, '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
.trim();
|
||||
return normalizeModelIdForProvider(stripped, 'codex').trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function getDefaultCodexModel(): string {
|
||||
|
||||
@@ -19,7 +19,10 @@ import {
|
||||
normalizeProtocol,
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
} from './port-manager';
|
||||
import { getProviderSettingsPath } from './path-resolver';
|
||||
import {
|
||||
getLegacyProviderSettingsPath,
|
||||
migrateLegacyProviderSettingsIfNeeded,
|
||||
} from './path-resolver';
|
||||
import {
|
||||
canonicalizeModelIdForProvider,
|
||||
MODEL_ENV_VAR_KEYS,
|
||||
@@ -49,6 +52,15 @@ const REQUIRED_PROVIDER_ENV_KEYS = [
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
] as const;
|
||||
const CURSOR_LEGACY_ENV_OVERRIDE_KEYS = new Set([
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'ANTHROPIC_API_KEY',
|
||||
]);
|
||||
|
||||
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stripCodexEffortSuffix(modelId: string): string {
|
||||
return modelId.replace(CODEX_EFFORT_SUFFIX_REGEX, '');
|
||||
@@ -285,6 +297,63 @@ export function getClaudeEnvVars(
|
||||
return normalizeModelEnvVarsForProvider(mergedEnv, provider);
|
||||
}
|
||||
|
||||
function buildCursorProviderSettingsFromLegacy(
|
||||
legacySettings: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const defaultEnv = getClaudeEnvVars('cursor');
|
||||
const legacyEnvSource = legacySettings.env;
|
||||
const legacyEnv = isObjectRecord(legacyEnvSource) ? legacyEnvSource : {};
|
||||
const migratedEnv: NodeJS.ProcessEnv = { ...defaultEnv };
|
||||
|
||||
for (const [key, value] of Object.entries(legacyEnv)) {
|
||||
if (typeof value !== 'string' || CURSOR_LEGACY_ENV_OVERRIDE_KEYS.has(key)) {
|
||||
continue;
|
||||
}
|
||||
migratedEnv[key] = value;
|
||||
}
|
||||
|
||||
delete migratedEnv.ANTHROPIC_API_KEY;
|
||||
|
||||
return {
|
||||
...legacySettings,
|
||||
env: normalizeModelEnvVarsForProvider(migratedEnv, 'cursor'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the provider settings path, migrating legacy Cursor provider settings into
|
||||
* the dedicated cliproxy/providers namespace on first access.
|
||||
*/
|
||||
export function resolveProviderSettingsPath(provider: CLIProxyProvider): string {
|
||||
const settingsPath = migrateLegacyProviderSettingsIfNeeded(provider);
|
||||
if (provider !== 'cursor' || fs.existsSync(settingsPath)) {
|
||||
return settingsPath;
|
||||
}
|
||||
|
||||
const legacySettingsPath = getLegacyProviderSettingsPath(provider);
|
||||
if (!fs.existsSync(legacySettingsPath)) {
|
||||
return settingsPath;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(legacySettingsPath, 'utf-8')) as unknown;
|
||||
if (!isObjectRecord(parsed)) {
|
||||
return settingsPath;
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(buildCursorProviderSettingsFromLegacy(parsed), null, 2) + '\n',
|
||||
{ mode: 0o600 }
|
||||
);
|
||||
} catch {
|
||||
// Best-effort migration only. Callers will fall back to defaults if the legacy file is invalid.
|
||||
}
|
||||
|
||||
return settingsPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get global env vars to inject into all third-party profiles.
|
||||
* Returns empty object if disabled.
|
||||
@@ -465,7 +534,7 @@ export function getEffectiveEnvVars(
|
||||
}
|
||||
|
||||
// Priority 2: Default provider settings file
|
||||
const settingsPath = getProviderSettingsPath(provider);
|
||||
const settingsPath = resolveProviderSettingsPath(provider);
|
||||
|
||||
// Check for user override file
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
@@ -505,7 +574,7 @@ export function getEffectiveEnvVars(
|
||||
* Called during installation/first run
|
||||
*/
|
||||
export function ensureProviderSettings(provider: CLIProxyProvider): void {
|
||||
const settingsPath = getProviderSettingsPath(provider);
|
||||
const settingsPath = resolveProviderSettingsPath(provider);
|
||||
const defaultEnv = getClaudeEnvVars(provider);
|
||||
|
||||
const writeSettings = (settings: Record<string, unknown>): void => {
|
||||
@@ -663,7 +732,7 @@ export function getRemoteEnvVars(
|
||||
|
||||
// Priority 2: Default provider settings file (~/.ccs/{provider}.settings.json)
|
||||
if (Object.keys(userEnvVars).length === 0) {
|
||||
const settingsPath = getProviderSettingsPath(provider);
|
||||
const settingsPath = resolveProviderSettingsPath(provider);
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(settingsPath, 'utf-8');
|
||||
|
||||
@@ -16,6 +16,13 @@ export function getCliproxyDir(): string {
|
||||
return path.join(getCcsDir(), 'cliproxy');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CLIProxy provider settings directory.
|
||||
*/
|
||||
export function getCliproxyProvidersDir(): string {
|
||||
return path.join(getCliproxyDir(), 'providers');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CLIProxy writable directory for logs and runtime files.
|
||||
* This directory is set as WRITABLE_PATH env var when spawning CLIProxy.
|
||||
@@ -75,5 +82,40 @@ export function getBinDir(): string {
|
||||
* Example: ~/.ccs/gemini.settings.json
|
||||
*/
|
||||
export function getProviderSettingsPath(provider: CLIProxyProvider): string {
|
||||
if (provider === 'cursor') {
|
||||
return path.join(getCliproxyProvidersDir(), `${provider}.settings.json`);
|
||||
}
|
||||
|
||||
return getLegacyProviderSettingsPath(provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CLIProxy provider settings path in the dedicated cliproxy/providers namespace.
|
||||
* Used only for providers that must not collide with legacy top-level settings files.
|
||||
*/
|
||||
export function getDedicatedProviderSettingsPath(provider: CLIProxyProvider): string {
|
||||
return path.join(getCliproxyProvidersDir(), `${provider}.settings.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get legacy provider settings file path in ~/.ccs root.
|
||||
* This is kept for compatibility reads/migration of older provider settings.
|
||||
*/
|
||||
export function getLegacyProviderSettingsPath(provider: CLIProxyProvider): string {
|
||||
return path.join(getCcsDir(), `${provider}.settings.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective provider settings path.
|
||||
*
|
||||
* Cursor uses a dedicated cliproxy/providers namespace so it does not collide
|
||||
* with the deprecated Cursor IDE bridge raw settings file.
|
||||
*/
|
||||
export function migrateLegacyProviderSettingsIfNeeded(provider: CLIProxyProvider): string {
|
||||
if (provider !== 'cursor') {
|
||||
return getProviderSettingsPath(provider);
|
||||
}
|
||||
|
||||
const targetPath = getDedicatedProviderSettingsPath(provider);
|
||||
return targetPath;
|
||||
}
|
||||
@@ -66,11 +66,15 @@ import {
|
||||
import {
|
||||
appendBrowserToolArgs,
|
||||
ensureBrowserMcpOrThrow,
|
||||
getEffectiveClaudeBrowserAttachConfig,
|
||||
resolveBrowserRuntimeEnv,
|
||||
resolveConfiguredBrowserProfileDir,
|
||||
syncBrowserMcpToConfigDir,
|
||||
} from '../../utils/browser';
|
||||
import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unified-config-loader';
|
||||
import {
|
||||
getBrowserConfig,
|
||||
loadOrCreateUnifiedConfig,
|
||||
getThinkingConfig,
|
||||
} from '../../config/unified-config-loader';
|
||||
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
|
||||
import {
|
||||
isKiroAuthMethod,
|
||||
@@ -152,6 +156,14 @@ export function readOptionValue(
|
||||
return { present: true, value: next.trim(), missingValue: false };
|
||||
}
|
||||
|
||||
export function hasGitLabTokenLoginFlag(args: string[]): boolean {
|
||||
return args.includes('--gitlab-token-login') || args.includes('--token-login');
|
||||
}
|
||||
|
||||
function getGitLabTokenLoginFlagName(args: string[]): '--gitlab-token-login' | '--token-login' {
|
||||
return args.includes('--gitlab-token-login') ? '--gitlab-token-login' : '--token-login';
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute Claude CLI with CLIProxy (main entry point)
|
||||
*
|
||||
@@ -252,8 +264,8 @@ export async function execClaudeWithCLIProxy(
|
||||
// Setup first-class CCS WebSearch runtime
|
||||
ensureWebSearchMcpOrThrow();
|
||||
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
|
||||
const browserProfileDir = resolveConfiguredBrowserProfileDir(process.env.CCS_BROWSER_PROFILE_DIR);
|
||||
if (browserProfileDir) {
|
||||
const browserAttachConfig = getEffectiveClaudeBrowserAttachConfig(getBrowserConfig());
|
||||
if (browserAttachConfig.enabled) {
|
||||
ensureBrowserMcpOrThrow();
|
||||
}
|
||||
displayWebSearchStatus();
|
||||
@@ -321,7 +333,7 @@ export async function execClaudeWithCLIProxy(
|
||||
spinner.start();
|
||||
|
||||
try {
|
||||
binaryPath = await ensureCLIProxyBinary(verbose);
|
||||
binaryPath = await ensureCLIProxyBinary(verbose, { skipAutoUpdate: true });
|
||||
spinner.succeed('CLIProxy binary ready');
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to prepare CLIProxy');
|
||||
@@ -353,6 +365,7 @@ export async function execClaudeWithCLIProxy(
|
||||
const addAccount = argsWithoutProxy.includes('--add');
|
||||
const showAccounts = argsWithoutProxy.includes('--accounts');
|
||||
const forceImport = argsWithoutProxy.includes('--import');
|
||||
const gitlabTokenLogin = hasGitLabTokenLoginFlag(argsWithoutProxy);
|
||||
const acceptAgyRisk = hasAntigravityRiskAcceptanceFlag(argsWithoutProxy);
|
||||
|
||||
const incognitoFlag = argsWithoutProxy.includes('--incognito');
|
||||
@@ -444,6 +457,16 @@ export async function execClaudeWithCLIProxy(
|
||||
kiroIDCFlow = normalizeKiroIDCFlow(normalized);
|
||||
}
|
||||
|
||||
let gitlabBaseUrl: string | undefined;
|
||||
const gitlabBaseUrlValue = readOptionValue(argsWithoutProxy, '--gitlab-url');
|
||||
if (gitlabBaseUrlValue.present && gitlabBaseUrlValue.value) {
|
||||
gitlabBaseUrl = gitlabBaseUrlValue.value.trim();
|
||||
} else if (gitlabBaseUrlValue.present) {
|
||||
console.error(fail('--gitlab-url requires a value'));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (kiroAuthMethod && provider !== 'kiro' && !compositeProviders.includes('kiro')) {
|
||||
console.error(fail('--kiro-auth-method is only valid for ccs kiro'));
|
||||
process.exitCode = 1;
|
||||
@@ -491,6 +514,15 @@ export async function execClaudeWithCLIProxy(
|
||||
return;
|
||||
}
|
||||
|
||||
if ((gitlabTokenLogin || gitlabBaseUrl) && provider !== 'gitlab') {
|
||||
const flagName = gitlabTokenLogin
|
||||
? getGitLabTokenLoginFlagName(argsWithoutProxy)
|
||||
: '--gitlab-url';
|
||||
console.error(fail(`${flagName} is only valid for ccs gitlab`));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse --thinking / --effort flags (aliases; first occurrence wins)
|
||||
const thinkingParse = parseThinkingOverride(argsWithoutProxy);
|
||||
if (thinkingParse.error) {
|
||||
@@ -730,6 +762,8 @@ export async function execClaudeWithCLIProxy(
|
||||
...(kiroIDCStartUrl && p === 'kiro' ? { kiroIDCStartUrl } : {}),
|
||||
...(kiroIDCRegion && p === 'kiro' ? { kiroIDCRegion } : {}),
|
||||
...(kiroIDCFlow && p === 'kiro' ? { kiroIDCFlow } : {}),
|
||||
...(gitlabTokenLogin && p === 'gitlab' ? { gitlabAuthMode: 'pat' as const } : {}),
|
||||
...(gitlabBaseUrl && p === 'gitlab' ? { gitlabBaseUrl } : {}),
|
||||
...(forceHeadless ? { headless: true } : {}),
|
||||
...(setNickname ? { nickname: setNickname } : {}),
|
||||
...(noIncognito ? { noIncognito: true } : {}),
|
||||
@@ -775,6 +809,8 @@ export async function execClaudeWithCLIProxy(
|
||||
...(kiroIDCStartUrl ? { kiroIDCStartUrl } : {}),
|
||||
...(kiroIDCRegion ? { kiroIDCRegion } : {}),
|
||||
...(kiroIDCFlow ? { kiroIDCFlow } : {}),
|
||||
...(gitlabTokenLogin ? { gitlabAuthMode: 'pat' as const } : {}),
|
||||
...(gitlabBaseUrl ? { gitlabBaseUrl } : {}),
|
||||
...(forceHeadless ? { headless: true } : {}),
|
||||
...(setNickname ? { nickname: setNickname } : {}),
|
||||
...(noIncognito ? { noIncognito: true } : {}),
|
||||
@@ -1018,7 +1054,7 @@ export async function execClaudeWithCLIProxy(
|
||||
|
||||
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
|
||||
if (
|
||||
browserProfileDir &&
|
||||
browserAttachConfig.enabled &&
|
||||
inheritedClaudeConfigDir &&
|
||||
!syncBrowserMcpToConfigDir(inheritedClaudeConfigDir)
|
||||
) {
|
||||
@@ -1121,10 +1157,13 @@ export async function execClaudeWithCLIProxy(
|
||||
}
|
||||
|
||||
// 11. Build final environment with all proxy chains
|
||||
const browserRuntimeEnv = browserProfileDir
|
||||
const browserRuntimeEnv = browserAttachConfig.enabled
|
||||
? {
|
||||
...(await resolveBrowserRuntimeEnv({
|
||||
profileDir: browserProfileDir,
|
||||
profileDir: browserAttachConfig.userDataDir,
|
||||
devtoolsPort: browserAttachConfig.hasExplicitDevtoolsPort
|
||||
? String(browserAttachConfig.devtoolsPort)
|
||||
: undefined,
|
||||
})),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
@@ -186,56 +186,12 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
codex: {
|
||||
provider: 'codex',
|
||||
displayName: 'Copilot Codex',
|
||||
defaultModel: 'gpt-5-codex',
|
||||
defaultModel: 'gpt-5.4',
|
||||
models: [
|
||||
{
|
||||
id: 'gpt-5-codex',
|
||||
name: 'GPT-5 Codex',
|
||||
description: 'Cross-plan safe Codex default',
|
||||
thinking: {
|
||||
type: 'levels',
|
||||
levels: ['low', 'medium', 'high'],
|
||||
maxLevel: 'high',
|
||||
dynamicAllowed: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gpt-5-codex-mini',
|
||||
name: 'GPT-5 Codex Mini',
|
||||
description: 'Faster and cheaper Codex option',
|
||||
thinking: {
|
||||
type: 'levels',
|
||||
levels: ['low', 'medium', 'high'],
|
||||
maxLevel: 'high',
|
||||
dynamicAllowed: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gpt-5-mini',
|
||||
name: 'GPT-5 Mini',
|
||||
description: 'Legacy mini model ID kept for backwards compatibility',
|
||||
thinking: {
|
||||
type: 'levels',
|
||||
levels: ['low', 'medium', 'high'],
|
||||
maxLevel: 'high',
|
||||
dynamicAllowed: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.1-codex-mini',
|
||||
name: 'GPT-5.1 Codex Mini',
|
||||
description: 'Legacy fast Codex mini model',
|
||||
thinking: {
|
||||
type: 'levels',
|
||||
levels: ['low', 'medium', 'high'],
|
||||
maxLevel: 'high',
|
||||
dynamicAllowed: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.1-codex-max',
|
||||
name: 'GPT-5.1 Codex Max',
|
||||
description: 'Higher-effort Codex model with xhigh support',
|
||||
id: 'gpt-5.4',
|
||||
name: 'GPT-5.4',
|
||||
description: 'Recommended Codex default for most coding and agentic tasks',
|
||||
thinking: {
|
||||
type: 'levels',
|
||||
levels: ['low', 'medium', 'high', 'xhigh'],
|
||||
@@ -244,13 +200,13 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.2-codex',
|
||||
name: 'GPT-5.2 Codex',
|
||||
description: 'Cross-plan Codex model with xhigh support',
|
||||
id: 'gpt-5.4-mini',
|
||||
name: 'GPT-5.4 Mini',
|
||||
description: 'Fast, lower-cost Codex option for lighter tasks and haiku-tier routing',
|
||||
thinking: {
|
||||
type: 'levels',
|
||||
levels: ['low', 'medium', 'high', 'xhigh'],
|
||||
maxLevel: 'xhigh',
|
||||
levels: ['low', 'medium', 'high'],
|
||||
maxLevel: 'high',
|
||||
dynamicAllowed: false,
|
||||
},
|
||||
},
|
||||
@@ -258,7 +214,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
id: 'gpt-5.3-codex',
|
||||
name: 'GPT-5.3 Codex',
|
||||
tier: 'pro',
|
||||
description: 'Paid Codex plans only',
|
||||
description: 'Previous flagship coding model whose capabilities now power GPT-5.4',
|
||||
thinking: {
|
||||
type: 'levels',
|
||||
levels: ['low', 'medium', 'high', 'xhigh'],
|
||||
@@ -270,7 +226,8 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
id: 'gpt-5.3-codex-spark',
|
||||
name: 'GPT-5.3 Codex Spark',
|
||||
tier: 'pro',
|
||||
description: 'Paid Codex plans only, ultra-fast coding model',
|
||||
description:
|
||||
'Research preview model for ChatGPT Pro subscribers, optimized for near-instant coding iteration',
|
||||
thinking: {
|
||||
type: 'levels',
|
||||
levels: ['low', 'medium', 'high', 'xhigh'],
|
||||
@@ -279,10 +236,9 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.4',
|
||||
name: 'GPT-5.4',
|
||||
tier: 'pro',
|
||||
description: 'Paid Codex plans only, latest GPT-5 family model',
|
||||
id: 'gpt-5.2',
|
||||
name: 'GPT-5.2',
|
||||
description: 'Previous general-purpose Codex model',
|
||||
thinking: {
|
||||
type: 'levels',
|
||||
levels: ['low', 'medium', 'high', 'xhigh'],
|
||||
|
||||
@@ -9,7 +9,7 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import { InteractivePrompt } from '../utils/prompt';
|
||||
import { getProviderCatalog, supportsModelConfig, ModelEntry } from './model-catalog';
|
||||
import { getProviderSettingsPath, getClaudeEnvVars } from './config-generator';
|
||||
import { getClaudeEnvVars, resolveProviderSettingsPath } from './config-generator';
|
||||
import { CLIProxyProvider } from './types';
|
||||
import { initUI, color, bold, dim, ok, info, header } from '../utils/ui';
|
||||
import { getCcsDir } from '../utils/config-manager';
|
||||
@@ -31,7 +31,7 @@ function canonicalizeModelForProvider(provider: CLIProxyProvider, model: string)
|
||||
* Check if provider has user settings configured
|
||||
*/
|
||||
export function hasUserSettings(provider: CLIProxyProvider): boolean {
|
||||
const settingsPath = getProviderSettingsPath(provider);
|
||||
const settingsPath = resolveProviderSettingsPath(provider);
|
||||
return fs.existsSync(settingsPath);
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export function getCurrentModel(
|
||||
): string | undefined {
|
||||
const settingsPath = customSettingsPath
|
||||
? customSettingsPath.replace(/^~/, os.homedir())
|
||||
: getProviderSettingsPath(provider);
|
||||
: resolveProviderSettingsPath(provider);
|
||||
if (!fs.existsSync(settingsPath)) return undefined;
|
||||
|
||||
try {
|
||||
@@ -116,7 +116,7 @@ export async function configureProviderModel(
|
||||
// Use custom settings path for CLIProxy variants, otherwise use default provider path
|
||||
const settingsPath = customSettingsPath
|
||||
? customSettingsPath.replace(/^~/, os.homedir())
|
||||
: getProviderSettingsPath(provider);
|
||||
: resolveProviderSettingsPath(provider);
|
||||
|
||||
// Skip if already configured with a model (unless --config flag).
|
||||
// A settings file can exist without model env keys (e.g., hook-only writes).
|
||||
@@ -249,7 +249,7 @@ export async function showCurrentConfig(provider: CLIProxyProvider): Promise<voi
|
||||
await initUI();
|
||||
|
||||
const currentModel = getCurrentModel(provider);
|
||||
const settingsPath = getProviderSettingsPath(provider);
|
||||
const settingsPath = resolveProviderSettingsPath(provider);
|
||||
const normalizedCurrentModel = currentModel
|
||||
? canonicalizeModelForProvider(provider, currentModel)
|
||||
: undefined;
|
||||
|
||||
@@ -27,6 +27,16 @@ const DENIED_ANTIGRAVITY_SONNET_45_REGEX =
|
||||
/claude-sonnet-4(?:[.-])5(?:-thinking)?(?=(?:$|[^a-z0-9]))/gi;
|
||||
const CANONICAL_ANTIGRAVITY_OPUS_46_MODEL = 'claude-opus-4-6-thinking';
|
||||
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
|
||||
const CODEX_LEGACY_MODEL_ALIASES: Readonly<Record<string, string>> = Object.freeze({
|
||||
'gpt-5-codex': 'gpt-5.4',
|
||||
'gpt-5-codex-mini': 'gpt-5.4-mini',
|
||||
'gpt-5-mini': 'gpt-5.4-mini',
|
||||
'gpt-5.1-codex': 'gpt-5.2',
|
||||
'gpt-5.1-codex-mini': 'gpt-5.4-mini',
|
||||
'gpt-5.1-codex-max': 'gpt-5.4',
|
||||
'gpt-5.2-codex': 'gpt-5.2',
|
||||
'gpt-5.2-codex-mini': 'gpt-5.4-mini',
|
||||
});
|
||||
const IFLOW_LEGACY_MODEL_ALIASES: Readonly<Record<string, string>> = Object.freeze({
|
||||
'iflow-default': 'qwen3-coder-plus',
|
||||
'kimi-k2.5': 'kimi-k2',
|
||||
@@ -92,6 +102,17 @@ export function stripCodexEffortSuffix(model: string): string {
|
||||
return model.replace(CODEX_EFFORT_SUFFIX_REGEX, '');
|
||||
}
|
||||
|
||||
/** Normalize legacy Codex aliases to the current public Codex model IDs. */
|
||||
export function normalizeCodexLegacyModelAliases(model: string): string {
|
||||
const trimmed = trimModelId(model);
|
||||
const { baseModel, suffix } = splitBaseModelAndSuffix(trimmed);
|
||||
const replacement = CODEX_LEGACY_MODEL_ALIASES[baseModel.trim().toLowerCase()];
|
||||
if (!replacement) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${replacement}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize known legacy iFlow model aliases to current upstream model IDs.
|
||||
* Preserves suffixes such as (budget) and [1m].
|
||||
@@ -186,6 +207,9 @@ export function normalizeModelIdForProvider(model: string, provider: ProviderLik
|
||||
if (isIFlowProvider(provider)) {
|
||||
return normalizeIFlowLegacyModelAliases(trimmedModel);
|
||||
}
|
||||
if (isCodexProvider(provider)) {
|
||||
return normalizeCodexLegacyModelAliases(trimmedModel);
|
||||
}
|
||||
if (!isAntigravityProvider(provider)) return trimmedModel;
|
||||
const normalizedDottedVersion = normalizeClaudeDottedMajorMinor(trimmedModel);
|
||||
return normalizeDeprecatedAntigravityModelAliases(normalizedDottedVersion);
|
||||
|
||||
@@ -33,7 +33,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
|
||||
callbackPort: 8085,
|
||||
callbackProviderName: 'gemini',
|
||||
authUrlProviderName: 'gemini-cli',
|
||||
refreshOwnership: 'ccs',
|
||||
refreshOwnership: 'cliproxy',
|
||||
authFilePrefixes: ['gemini-', 'google-'],
|
||||
tokenTypeValues: ['gemini'],
|
||||
aliases: ['gemini-cli'],
|
||||
@@ -134,6 +134,54 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
|
||||
tokenTypeValues: ['kimi'],
|
||||
aliases: ['moonshot'],
|
||||
},
|
||||
cursor: {
|
||||
displayName: 'Cursor',
|
||||
description: 'Cursor browser-authenticated provider',
|
||||
oauthFlow: 'device_code',
|
||||
callbackPort: null,
|
||||
callbackProviderName: 'cursor',
|
||||
authUrlProviderName: 'cursor',
|
||||
refreshOwnership: 'cliproxy',
|
||||
authFilePrefixes: ['cursor.', 'cursor-'],
|
||||
tokenTypeValues: ['cursor'],
|
||||
aliases: [],
|
||||
},
|
||||
gitlab: {
|
||||
displayName: 'GitLab Duo',
|
||||
description: 'GitLab Duo with OAuth or PAT auth',
|
||||
oauthFlow: 'authorization_code',
|
||||
callbackPort: 17171,
|
||||
callbackProviderName: 'gitlab',
|
||||
authUrlProviderName: 'gitlab',
|
||||
refreshOwnership: 'cliproxy',
|
||||
authFilePrefixes: ['gitlab-'],
|
||||
tokenTypeValues: ['gitlab'],
|
||||
aliases: ['gitlab-duo'],
|
||||
},
|
||||
codebuddy: {
|
||||
displayName: 'CodeBuddy (Tencent)',
|
||||
description: 'Tencent CodeBuddy AI assistant',
|
||||
oauthFlow: 'device_code',
|
||||
callbackPort: null,
|
||||
callbackProviderName: 'codebuddy',
|
||||
authUrlProviderName: 'codebuddy',
|
||||
refreshOwnership: 'cliproxy',
|
||||
authFilePrefixes: ['codebuddy-'],
|
||||
tokenTypeValues: ['codebuddy'],
|
||||
aliases: ['tencent'],
|
||||
},
|
||||
kilo: {
|
||||
displayName: 'Kilo AI',
|
||||
description: 'Kilo AI coding assistant',
|
||||
oauthFlow: 'device_code',
|
||||
callbackPort: null,
|
||||
callbackProviderName: 'kilo',
|
||||
authUrlProviderName: 'kilo',
|
||||
refreshOwnership: 'unsupported',
|
||||
authFilePrefixes: ['kilo-'],
|
||||
tokenTypeValues: ['kilo'],
|
||||
aliases: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const CLIPROXY_PROVIDER_IDS = Object.freeze(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Claude Quota Response Normalization Helpers
|
||||
*
|
||||
* Parses Anthropic policy limits payload into normalized windows and core usage summary.
|
||||
* Parses Anthropic policy-limits or OAuth-usage payloads into normalized windows and core usage summary.
|
||||
*/
|
||||
|
||||
import type { ClaudeCoreUsageSummary, ClaudeQuotaWindow } from './quota-types';
|
||||
@@ -74,7 +74,14 @@ function getClaudeWindowLabel(rateLimitType: string): string {
|
||||
case 'overage':
|
||||
return 'Extra usage';
|
||||
default:
|
||||
return rateLimitType || 'Unknown limit';
|
||||
if (!rateLimitType) return 'Unknown limit';
|
||||
return rateLimitType
|
||||
.split(/[_-]+/g)
|
||||
.filter((part) => part.length > 0)
|
||||
.map((part) =>
|
||||
/^\d+$/.test(part) ? part : `${part.charAt(0).toUpperCase()}${part.slice(1)}`
|
||||
)
|
||||
.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,8 +192,17 @@ function normalizeRestriction(
|
||||
|
||||
/**
|
||||
* Parse raw policy limits response into normalized windows.
|
||||
* Supports both array and object-map `restrictions` shapes.
|
||||
* Supports both policy-limits `restrictions` payloads and OAuth usage payloads
|
||||
* keyed by window name (`five_hour`, `seven_day`, `seven_day_sonnet`, ...).
|
||||
*/
|
||||
function isClaudeOAuthUsageWindowCandidate(key: string, raw: Record<string, unknown>): boolean {
|
||||
if (key === 'extra_usage') return false;
|
||||
if (asNumber(raw['utilization']) === null) return false;
|
||||
|
||||
const resetAt = raw['resetsAt'] ?? raw['resets_at'] ?? raw['resetAt'] ?? raw['reset_at'] ?? null;
|
||||
return resetAt !== null && resetAt !== undefined;
|
||||
}
|
||||
|
||||
export function buildClaudeQuotaWindows(payload: Record<string, unknown>): ClaudeQuotaWindow[] {
|
||||
const rawRestrictions = payload['restrictions'];
|
||||
const windows: ClaudeQuotaWindow[] = [];
|
||||
@@ -206,9 +222,19 @@ export function buildClaudeQuotaWindows(payload: Record<string, unknown>): Claud
|
||||
if (window) windows.push(window);
|
||||
}
|
||||
} else if (toObject(payload)) {
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
const raw = toObject(value);
|
||||
if (!raw) continue;
|
||||
if (!isClaudeOAuthUsageWindowCandidate(key, raw)) continue;
|
||||
const window = normalizeRestriction(raw, key);
|
||||
if (window) windows.push(window);
|
||||
}
|
||||
|
||||
// Some responses may contain a single restriction object directly.
|
||||
const direct = normalizeRestriction(payload);
|
||||
if (direct) windows.push(direct);
|
||||
if (windows.length === 0) {
|
||||
const direct = normalizeRestriction(payload);
|
||||
if (direct) windows.push(direct);
|
||||
}
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Quota Fetcher for Claude (Anthropic) Accounts
|
||||
*
|
||||
* Fetches policy limits from Claude API and normalizes 5h + weekly windows.
|
||||
* Fetches OAuth usage windows from Claude API and normalizes 5h + weekly windows.
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
@@ -17,11 +17,10 @@ import {
|
||||
|
||||
export { buildClaudeQuotaWindows, buildClaudeCoreUsageSummary };
|
||||
|
||||
export const CLAUDE_POLICY_LIMITS_URL = 'https://api.anthropic.com/api/claude_code/policy_limits';
|
||||
export const CLAUDE_OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
|
||||
const CLAUDE_QUOTA_TIMEOUT_MS = 10000;
|
||||
const CLAUDE_QUOTA_MAX_ATTEMPTS = 2;
|
||||
const CLAUDE_USER_AGENT = 'ccs-cli/claude-quota';
|
||||
const CLAUDE_OAUTH_UNSUPPORTED_MESSAGE = 'oauth authentication is currently not supported';
|
||||
const CLAUDE_OAUTH_BETA_HEADER = 'oauth-2025-04-20';
|
||||
|
||||
interface ClaudeAuthData {
|
||||
accessToken: string;
|
||||
@@ -193,16 +192,6 @@ function buildEmptyResult(
|
||||
};
|
||||
}
|
||||
|
||||
function buildPolicyUnavailableResult(accountId: string): ClaudeQuotaResult {
|
||||
return {
|
||||
success: true,
|
||||
windows: [],
|
||||
coreUsage: { fiveHour: null, weekly: null },
|
||||
lastUpdated: Date.now(),
|
||||
accountId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch quota for a single Claude account.
|
||||
*/
|
||||
@@ -230,46 +219,43 @@ export async function fetchClaudeQuota(
|
||||
const timeoutId = setTimeout(() => controller.abort(), CLAUDE_QUOTA_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(CLAUDE_POLICY_LIMITS_URL, {
|
||||
const response = await fetch(CLAUDE_OAUTH_USAGE_URL, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Authorization: `Bearer ${authData.accessToken}`,
|
||||
Accept: 'application/json',
|
||||
'User-Agent': CLAUDE_USER_AGENT,
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-beta': CLAUDE_OAUTH_BETA_HEADER,
|
||||
},
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
if (verbose) {
|
||||
console.error(`[i] Claude policy limits status: ${response.status} (attempt ${attempt})`);
|
||||
console.error(`[i] Claude OAuth usage status: ${response.status} (attempt ${attempt})`);
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
const errorMessage = await readResponseErrorMessage(response);
|
||||
if (errorMessage && errorMessage.toLowerCase().includes(CLAUDE_OAUTH_UNSUPPORTED_MESSAGE)) {
|
||||
if (verbose) {
|
||||
console.error(
|
||||
'[i] Claude policy limits endpoint does not support OAuth tokens; treating quota as unavailable'
|
||||
);
|
||||
}
|
||||
return buildPolicyUnavailableResult(accountId);
|
||||
}
|
||||
|
||||
return buildEmptyResult('Authentication required for policy limits', accountId, true);
|
||||
return buildEmptyResult(
|
||||
errorMessage || 'Authentication required for Claude OAuth usage',
|
||||
accountId,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
// Some accounts may not expose policy limits; treat as unavailable but successful.
|
||||
return buildPolicyUnavailableResult(accountId);
|
||||
return buildEmptyResult('Claude OAuth usage endpoint not found', accountId);
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
return buildEmptyResult('Not authorized for policy limits', accountId);
|
||||
return buildEmptyResult('Not authorized for Claude OAuth usage', accountId);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
lastError = `Policy limits API error: ${response.status}`;
|
||||
lastError =
|
||||
(await readResponseErrorMessage(response)) ||
|
||||
`Claude OAuth usage API error: ${response.status}`;
|
||||
if (
|
||||
attempt < CLAUDE_QUOTA_MAX_ATTEMPTS &&
|
||||
(response.status === 429 || response.status >= 500)
|
||||
@@ -283,11 +269,11 @@ export async function fetchClaudeQuota(
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
return buildEmptyResult('Invalid policy limits format', accountId);
|
||||
return buildEmptyResult('Invalid Claude OAuth usage format', accountId);
|
||||
}
|
||||
|
||||
if (!toObject(payload)) {
|
||||
return buildEmptyResult('Invalid policy limits format', accountId);
|
||||
return buildEmptyResult('Invalid Claude OAuth usage format', accountId);
|
||||
}
|
||||
|
||||
const windows = buildClaudeQuotaWindows(payload as Record<string, unknown>);
|
||||
@@ -304,7 +290,7 @@ export async function fetchClaudeQuota(
|
||||
clearTimeout(timeoutId);
|
||||
lastError =
|
||||
error instanceof Error && error.name === 'AbortError'
|
||||
? 'Policy limits request timeout'
|
||||
? 'Claude OAuth usage request timeout'
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: 'Unknown error';
|
||||
@@ -313,7 +299,7 @@ export async function fetchClaudeQuota(
|
||||
const errorDetails =
|
||||
error instanceof Error ? (error.stack ?? error.message) : JSON.stringify(error);
|
||||
console.error(
|
||||
`[!] Claude policy limits failed (attempt ${attempt}): ${lastError}${errorDetails ? `\n${errorDetails}` : ''}`
|
||||
`[!] Claude OAuth usage failed (attempt ${attempt}): ${lastError}${errorDetails ? `\n${errorDetails}` : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -624,11 +624,12 @@ export async function fetchCodexQuota(
|
||||
|
||||
// Extract plan type
|
||||
const planTypeRaw = data.plan_type || data.planType;
|
||||
let planType: 'free' | 'plus' | 'team' | null = null;
|
||||
let planType: 'free' | 'plus' | 'pro' | 'team' | null = null;
|
||||
if (planTypeRaw) {
|
||||
const normalized = planTypeRaw.toLowerCase();
|
||||
if (normalized === 'free') planType = 'free';
|
||||
else if (normalized === 'plus') planType = 'plus';
|
||||
else if (normalized === 'pro') planType = 'pro';
|
||||
else if (normalized === 'team') planType = 'team';
|
||||
}
|
||||
|
||||
|
||||
@@ -10,11 +10,12 @@ import * as path from 'node:path';
|
||||
import { getAuthDir } from './config-generator';
|
||||
import { getProviderAccounts, getPausedDir, setAccountTier } from './account-manager';
|
||||
import { getTokenExpiryTimestamp, sanitizeEmail, isTokenExpired } from './auth-utils';
|
||||
import { refreshGeminiToken } from './auth/gemini-token-refresh';
|
||||
import {
|
||||
buildGeminiCliBucketsFromParsedBuckets,
|
||||
type GeminiCliParsedBucket,
|
||||
} from './gemini-cli-quota-normalizer';
|
||||
import { mapExternalProviderName } from './provider-capabilities';
|
||||
import { buildManagementHeaders, buildProxyUrl, getProxyTarget } from './proxy-target-resolver';
|
||||
import type { GeminiCliQuotaResult, GeminiCliBucket } from './quota-types';
|
||||
import {
|
||||
buildProviderEntitlementEvidence,
|
||||
@@ -32,6 +33,8 @@ const GEMINI_CLI_CODE_ASSIST_URL = `${GEMINI_CLI_API_BASE}/${GEMINI_CLI_API_VERS
|
||||
const GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH = 320;
|
||||
const GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX = '...[truncated]';
|
||||
const GEMINI_CLI_G1_CREDIT_TYPE = 'GOOGLE_ONE_AI';
|
||||
const MANAGEMENT_API_TIMEOUT_MS = 5000;
|
||||
const SECONDARY_REQUEST_TIMEOUT_MS = 2000;
|
||||
|
||||
/** Auth data extracted from Gemini CLI auth file */
|
||||
interface GeminiCliAuthData {
|
||||
@@ -93,6 +96,44 @@ interface GeminiCliSupplementaryInfo {
|
||||
normalizedTier: 'free' | 'pro' | 'ultra' | 'unknown';
|
||||
}
|
||||
|
||||
interface ManagementAuthFile {
|
||||
auth_index?: string | number;
|
||||
provider?: string;
|
||||
type?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface ManagementApiCallResponse {
|
||||
status_code?: number;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
interface ManagedResponse {
|
||||
status: number;
|
||||
bodyText: string;
|
||||
json: unknown;
|
||||
viaManagement: boolean;
|
||||
}
|
||||
|
||||
interface ManagedGeminiAuthContext {
|
||||
authIndexLookupPromise?: Promise<ManagedGeminiAuthLookupResult>;
|
||||
}
|
||||
|
||||
interface ManagedGeminiAuthLookupResult {
|
||||
authIndex: string | number | null;
|
||||
unavailable: boolean;
|
||||
}
|
||||
|
||||
interface ManagedGeminiRequestResult {
|
||||
response: ManagedResponse | null;
|
||||
unavailable: boolean;
|
||||
}
|
||||
|
||||
function getRemainingTimeoutMs(deadlineMs: number): number {
|
||||
return Math.max(1, deadlineMs - Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract project ID from account field
|
||||
* Input: "user@example.com (cloudaicompanion-abc-123)"
|
||||
@@ -167,6 +208,244 @@ function isGeminiAuthFile(filename: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function safeParseJson(bodyText: string): unknown {
|
||||
try {
|
||||
return JSON.parse(bodyText);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readManagedResponse(
|
||||
response: Response,
|
||||
viaManagement: boolean
|
||||
): Promise<ManagedResponse> {
|
||||
const bodyText = await response.text();
|
||||
return {
|
||||
status: response.status,
|
||||
bodyText,
|
||||
json: safeParseJson(bodyText),
|
||||
viaManagement,
|
||||
};
|
||||
}
|
||||
|
||||
function isGeminiAuthFileForAccount(file: ManagementAuthFile, accountId: string): boolean {
|
||||
const rawProvider = normalizeStringValue(file.provider ?? file.type);
|
||||
if (!rawProvider || mapExternalProviderName(rawProvider) !== 'gemini') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const email = normalizeStringValue(file.email);
|
||||
const normalizedAccountId = accountId.trim().toLowerCase();
|
||||
if (email?.toLowerCase() === normalizedAccountId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedName = normalizeStringValue(file.name);
|
||||
if (!normalizedName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedFileName = normalizedName.toLowerCase();
|
||||
const sanitizedAccount = sanitizeEmail(accountId).toLowerCase();
|
||||
return (
|
||||
normalizedFileName === `gemini-${sanitizedAccount}.json` ||
|
||||
normalizedFileName.startsWith(`${normalizedAccountId}-gen-lang-client-`) ||
|
||||
normalizedFileName.includes(sanitizedAccount)
|
||||
);
|
||||
}
|
||||
|
||||
async function findManagedGeminiAuthIndex(
|
||||
accountId: string,
|
||||
timeoutMs: number
|
||||
): Promise<ManagedGeminiAuthLookupResult> {
|
||||
const target = getProxyTarget();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(buildProxyUrl(target, '/v0/management/auth-files'), {
|
||||
signal: controller.signal,
|
||||
headers: buildManagementHeaders(target),
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return { authIndex: null, unavailable: true };
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { files?: ManagementAuthFile[] };
|
||||
const match = data.files?.find((file) => isGeminiAuthFileForAccount(file, accountId));
|
||||
return { authIndex: match?.auth_index ?? null, unavailable: false };
|
||||
} catch {
|
||||
clearTimeout(timeoutId);
|
||||
return { authIndex: null, unavailable: true };
|
||||
}
|
||||
}
|
||||
|
||||
async function getManagedGeminiAuthIndex(
|
||||
accountId: string,
|
||||
timeoutMs: number,
|
||||
context?: ManagedGeminiAuthContext
|
||||
): Promise<ManagedGeminiAuthLookupResult> {
|
||||
if (!context) {
|
||||
return await findManagedGeminiAuthIndex(accountId, timeoutMs);
|
||||
}
|
||||
|
||||
context.authIndexLookupPromise ??= findManagedGeminiAuthIndex(accountId, timeoutMs);
|
||||
return await context.authIndexLookupPromise;
|
||||
}
|
||||
|
||||
class GeminiManagedAuthUnavailableError extends Error {
|
||||
constructor() {
|
||||
super('CLIProxy managed Gemini auth is temporarily unavailable');
|
||||
this.name = 'GeminiManagedAuthUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
async function performManagedGeminiRequest(
|
||||
accountId: string,
|
||||
url: string,
|
||||
body: string,
|
||||
timeoutMs: number,
|
||||
authContext?: ManagedGeminiAuthContext
|
||||
): Promise<ManagedGeminiRequestResult> {
|
||||
const deadlineMs = Date.now() + timeoutMs;
|
||||
const lookupResult = await getManagedGeminiAuthIndex(
|
||||
accountId,
|
||||
getRemainingTimeoutMs(deadlineMs),
|
||||
authContext
|
||||
);
|
||||
if (lookupResult.unavailable) {
|
||||
return { response: null, unavailable: true };
|
||||
}
|
||||
|
||||
const authIndex = lookupResult.authIndex;
|
||||
if (authIndex === null || authIndex === undefined) {
|
||||
return { response: null, unavailable: false };
|
||||
}
|
||||
|
||||
const target = getProxyTarget();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), getRemainingTimeoutMs(deadlineMs));
|
||||
|
||||
try {
|
||||
const response = await fetch(buildProxyUrl(target, '/v0/management/api-call'), {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: buildManagementHeaders(target, {
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
auth_index: authIndex,
|
||||
method: 'POST',
|
||||
url,
|
||||
header: {
|
||||
Authorization: 'Bearer $TOKEN$',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: body,
|
||||
}),
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return { response: null, unavailable: true };
|
||||
}
|
||||
|
||||
const apiResponse = (await response.json()) as ManagementApiCallResponse;
|
||||
const bodyText = typeof apiResponse.body === 'string' ? apiResponse.body : '';
|
||||
return {
|
||||
response: {
|
||||
status: typeof apiResponse.status_code === 'number' ? apiResponse.status_code : 500,
|
||||
bodyText,
|
||||
json: safeParseJson(bodyText),
|
||||
viaManagement: true,
|
||||
},
|
||||
unavailable: false,
|
||||
};
|
||||
} catch {
|
||||
clearTimeout(timeoutId);
|
||||
return { response: null, unavailable: true };
|
||||
}
|
||||
}
|
||||
|
||||
async function performGeminiCliRequest(
|
||||
accountId: string,
|
||||
accessToken: string,
|
||||
url: string,
|
||||
body: string,
|
||||
preferManagement = false,
|
||||
authContext?: ManagedGeminiAuthContext
|
||||
): Promise<ManagedResponse> {
|
||||
let managementAttempted = false;
|
||||
let managementUnavailable = false;
|
||||
|
||||
if (preferManagement) {
|
||||
managementAttempted = true;
|
||||
const managedResult = await performManagedGeminiRequest(
|
||||
accountId,
|
||||
url,
|
||||
body,
|
||||
MANAGEMENT_API_TIMEOUT_MS,
|
||||
authContext
|
||||
);
|
||||
managementUnavailable = managedResult.unavailable;
|
||||
if (managedResult.response) {
|
||||
return managedResult.response;
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
managementAttempted ? SECONDARY_REQUEST_TIMEOUT_MS : MANAGEMENT_API_TIMEOUT_MS
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const directResult = await readManagedResponse(response, false);
|
||||
if (directResult.status !== 401) {
|
||||
return directResult;
|
||||
}
|
||||
|
||||
if (managementAttempted) {
|
||||
if (managementUnavailable) {
|
||||
throw new GeminiManagedAuthUnavailableError();
|
||||
}
|
||||
return directResult;
|
||||
}
|
||||
|
||||
const managedResult = await performManagedGeminiRequest(
|
||||
accountId,
|
||||
url,
|
||||
body,
|
||||
SECONDARY_REQUEST_TIMEOUT_MS,
|
||||
authContext
|
||||
);
|
||||
if (managedResult.response) {
|
||||
return managedResult.response;
|
||||
}
|
||||
if (managedResult.unavailable) {
|
||||
throw new GeminiManagedAuthUnavailableError();
|
||||
}
|
||||
return directResult;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read auth data from Gemini CLI auth file
|
||||
* Supports multiple file naming conventions and JSON structures
|
||||
@@ -308,42 +587,43 @@ function resolveGeminiCliCreditBalance(payload: GeminiCliCodeAssistResponse | nu
|
||||
}
|
||||
|
||||
async function fetchGeminiCliSupplementary(
|
||||
accountId: string,
|
||||
accessToken: string,
|
||||
projectId: string,
|
||||
verbose: boolean
|
||||
verbose: boolean,
|
||||
authContext?: ManagedGeminiAuthContext
|
||||
): Promise<GeminiCliSupplementaryInfo> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
const requestBody = JSON.stringify({
|
||||
cloudaicompanionProject: projectId,
|
||||
metadata: {
|
||||
ideType: 'IDE_UNSPECIFIED',
|
||||
platform: 'PLATFORM_UNSPECIFIED',
|
||||
pluginType: 'GEMINI',
|
||||
duetProject: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(GEMINI_CLI_CODE_ASSIST_URL, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
cloudaicompanionProject: projectId,
|
||||
metadata: {
|
||||
ideType: 'IDE_UNSPECIFIED',
|
||||
platform: 'PLATFORM_UNSPECIFIED',
|
||||
pluginType: 'GEMINI',
|
||||
duetProject: projectId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const response = await performGeminiCliRequest(
|
||||
accountId,
|
||||
accessToken,
|
||||
GEMINI_CLI_CODE_ASSIST_URL,
|
||||
requestBody,
|
||||
false,
|
||||
authContext
|
||||
);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status !== 200) {
|
||||
if (verbose) {
|
||||
console.error(`[i] Gemini CLI supplementary metadata unavailable: HTTP ${response.status}`);
|
||||
const source = response.viaManagement ? 'managed' : 'direct';
|
||||
console.error(
|
||||
`[i] Gemini CLI supplementary metadata unavailable via ${source}: HTTP ${response.status}`
|
||||
);
|
||||
}
|
||||
return { tierLabel: null, tierId: null, creditBalance: null, normalizedTier: 'unknown' };
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as GeminiCliCodeAssistResponse;
|
||||
const payload = response.json as GeminiCliCodeAssistResponse | null;
|
||||
return {
|
||||
tierLabel: resolveGeminiCliTierLabel(payload),
|
||||
tierId: resolveGeminiCliTierId(payload),
|
||||
@@ -351,7 +631,6 @@ async function fetchGeminiCliSupplementary(
|
||||
normalizedTier: normalizeProviderTierId(resolveGeminiCliTierId(payload)),
|
||||
};
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
if (verbose) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error(`[i] Gemini CLI supplementary metadata skipped: ${message}`);
|
||||
@@ -680,41 +959,42 @@ async function fetchWithAuthData(
|
||||
});
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
const authContext: ManagedGeminiAuthContext = {};
|
||||
const supplementaryPromise = fetchGeminiCliSupplementary(
|
||||
accountId,
|
||||
authData.accessToken,
|
||||
authData.projectId,
|
||||
verbose
|
||||
verbose,
|
||||
authContext
|
||||
);
|
||||
const requestBody = JSON.stringify({ project: authData.projectId });
|
||||
|
||||
try {
|
||||
const response = await fetch(GEMINI_CLI_QUOTA_URL, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Authorization: `Bearer ${authData.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ project: authData.projectId }),
|
||||
});
|
||||
const response = await performGeminiCliRequest(
|
||||
accountId,
|
||||
authData.accessToken,
|
||||
GEMINI_CLI_QUOTA_URL,
|
||||
requestBody,
|
||||
authData.isExpired,
|
||||
authContext
|
||||
);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
if (verbose) {
|
||||
const source = response.viaManagement ? 'managed' : 'direct';
|
||||
console.error(`[i] Gemini CLI API status via ${source}: ${response.status}`);
|
||||
}
|
||||
|
||||
if (verbose) console.error(`[i] Gemini CLI API status: ${response.status}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const bodyText = await response.text();
|
||||
if (response.status !== 200) {
|
||||
return buildGeminiCliHttpFailureResult(
|
||||
accountId,
|
||||
authData.projectId,
|
||||
response.status,
|
||||
bodyText
|
||||
response.bodyText
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as GeminiCliQuotaResponse;
|
||||
const rawBuckets = data.buckets || [];
|
||||
const data = response.json as GeminiCliQuotaResponse | null;
|
||||
const rawBuckets = data?.buckets || [];
|
||||
const buckets = buildGeminiCliBuckets(rawBuckets);
|
||||
const supplementary = await supplementaryPromise;
|
||||
|
||||
@@ -744,7 +1024,16 @@ async function fetchWithAuthData(
|
||||
accountId,
|
||||
};
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
if (err instanceof GeminiManagedAuthUnavailableError) {
|
||||
return buildGeminiCliFailureResult(accountId, authData.projectId, {
|
||||
error: 'Gemini delegated auth refresh is temporarily unavailable',
|
||||
errorCode: 'managed_auth_unavailable',
|
||||
errorDetail: err.message,
|
||||
actionHint: 'Retry later. CLIProxy management could not refresh this Gemini account.',
|
||||
retryable: true,
|
||||
});
|
||||
}
|
||||
|
||||
const errorMsg =
|
||||
err instanceof Error && err.name === 'AbortError'
|
||||
? 'Request timeout'
|
||||
@@ -778,7 +1067,7 @@ export async function fetchGeminiCliQuota(
|
||||
): Promise<GeminiCliQuotaResult> {
|
||||
if (verbose) console.error(`[i] Fetching Gemini CLI quota for ${accountId}...`);
|
||||
|
||||
let authData = readGeminiCliAuthData(accountId);
|
||||
const authData = readGeminiCliAuthData(accountId);
|
||||
if (!authData) {
|
||||
const error = 'Auth file not found for Gemini account';
|
||||
if (verbose) console.error(`[!] Error: ${error}`);
|
||||
@@ -790,62 +1079,15 @@ export async function fetchGeminiCliQuota(
|
||||
});
|
||||
}
|
||||
|
||||
// Proactive refresh: refresh if expired OR expiring within 5 minutes
|
||||
const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000;
|
||||
const expiresAt = getTokenExpiryTimestamp(authData.expiresAt);
|
||||
const shouldRefresh =
|
||||
authData.isExpired || expiresAt === null || expiresAt - Date.now() < REFRESH_LEAD_TIME_MS;
|
||||
let refreshedBeforeQuotaFetch = false;
|
||||
|
||||
if (shouldRefresh) {
|
||||
if (verbose)
|
||||
console.error(
|
||||
authData.isExpired
|
||||
? '[i] Token expired, refreshing...'
|
||||
: '[i] Token expiring soon, proactive refresh...'
|
||||
);
|
||||
const refreshResult = await refreshGeminiToken(accountId);
|
||||
|
||||
if (refreshResult.success) {
|
||||
refreshedBeforeQuotaFetch = true;
|
||||
if (verbose) console.error('[i] Token refreshed successfully');
|
||||
// Re-read auth data after successful refresh
|
||||
const refreshedAuthData = readGeminiCliAuthData(accountId);
|
||||
if (refreshedAuthData) {
|
||||
authData = refreshedAuthData;
|
||||
}
|
||||
} else if (authData.isExpired) {
|
||||
// Only fail if token is actually expired (not just expiring soon)
|
||||
const error = refreshResult.error || 'Token refresh failed';
|
||||
if (verbose) console.error(`[!] Refresh failed: ${error}`);
|
||||
return buildGeminiCliFailureResult(accountId, authData.projectId, {
|
||||
error,
|
||||
errorCode: 'reauth_required',
|
||||
errorDetail: error,
|
||||
actionHint: 'Run ccs gemini --auth to reconnect this account.',
|
||||
needsReauth: true,
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
// If proactive refresh fails but token isn't expired yet, continue with existing token
|
||||
if (authData.isExpired && verbose) {
|
||||
const expiresAt = getTokenExpiryTimestamp(authData.expiresAt);
|
||||
const expiryLabel = expiresAt ? new Date(expiresAt).toISOString() : 'unknown';
|
||||
console.error(
|
||||
`[i] Gemini access token is expired (${expiryLabel}); quota requests will defer to managed auth when available.`
|
||||
);
|
||||
}
|
||||
|
||||
// First attempt with current token
|
||||
const result = await fetchWithAuthData(authData, accountId, verbose);
|
||||
|
||||
// Retry once with an account-scoped refresh when the quota endpoint rejects auth.
|
||||
if (result.needsReauth && !refreshedBeforeQuotaFetch) {
|
||||
if (verbose) console.error('[i] Got 401, attempting refresh and retry...');
|
||||
const refreshResult = await refreshGeminiToken(accountId);
|
||||
if (refreshResult.success) {
|
||||
const refreshedAuthData = readGeminiCliAuthData(accountId);
|
||||
if (refreshedAuthData) {
|
||||
return await fetchWithAuthData(refreshedAuthData, accountId, verbose);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return await fetchWithAuthData(authData, accountId, verbose);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -74,8 +74,8 @@ export interface CodexQuotaResult extends QuotaErrorMetadata {
|
||||
windows: CodexQuotaWindow[];
|
||||
/** Explicit core usage windows (5h + weekly) for easier reset display */
|
||||
coreUsage?: CodexCoreUsageSummary;
|
||||
/** Plan type: free, plus, team, or null if unknown */
|
||||
planType: 'free' | 'plus' | 'team' | null;
|
||||
/** Plan type: free, plus, pro, team, or null if unknown */
|
||||
planType: 'free' | 'plus' | 'pro' | 'team' | null;
|
||||
/** Timestamp of fetch */
|
||||
lastUpdated: number;
|
||||
/** Error message if fetch failed */
|
||||
|
||||
@@ -216,7 +216,10 @@ export async function ensureCliproxyService(
|
||||
// 1. Ensure binary exists
|
||||
let binaryPath: string;
|
||||
try {
|
||||
binaryPath = await ensureCLIProxyBinary(verbose);
|
||||
binaryPath = await ensureCLIProxyBinary(verbose, {
|
||||
allowInstall: false,
|
||||
skipAutoUpdate: true,
|
||||
});
|
||||
log(`Binary ready: ${binaryPath}`);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
|
||||
+23
-4
@@ -50,6 +50,10 @@ export interface BinaryManagerConfig {
|
||||
verbose: boolean;
|
||||
/** Force specific version (skip auto-upgrade to latest) */
|
||||
forceVersion: boolean;
|
||||
/** Skip background update checks on runtime bootstrap paths */
|
||||
skipAutoUpdate: boolean;
|
||||
/** Allow downloading/installing the binary when it is missing */
|
||||
allowInstall: boolean;
|
||||
/** Backend variant (original vs plus) */
|
||||
backend?: CLIProxyBackend;
|
||||
}
|
||||
@@ -122,6 +126,10 @@ export interface DownloadResult {
|
||||
* - ghcp: GitHub Copilot via Device Code (OAuth through CLIProxyAPIPlus)
|
||||
* - claude: Claude (Anthropic) via OAuth
|
||||
* - kimi: Kimi (Moonshot AI) via Device Code OAuth
|
||||
* - cursor: Cursor via PKCE browser polling
|
||||
* - gitlab: GitLab Duo via OAuth or PAT
|
||||
* - codebuddy: Tencent CodeBuddy via browser polling
|
||||
* - kilo: Kilo AI via device flow
|
||||
*/
|
||||
export type CLIProxyProvider =
|
||||
| 'gemini'
|
||||
@@ -132,12 +140,16 @@ export type CLIProxyProvider =
|
||||
| 'kiro'
|
||||
| 'ghcp'
|
||||
| 'claude'
|
||||
| 'kimi';
|
||||
| 'kimi'
|
||||
| 'cursor'
|
||||
| 'gitlab'
|
||||
| 'codebuddy'
|
||||
| 'kilo';
|
||||
|
||||
/**
|
||||
* CLIProxy backend selection
|
||||
* - original: CLIProxyAPI (no Kiro/ghcp support)
|
||||
* - plus: CLIProxyAPIPlus (Kiro/ghcp support, default)
|
||||
* - original: CLIProxyAPI (legacy provider subset)
|
||||
* - plus: CLIProxyAPIPlus (expanded provider support, default)
|
||||
*/
|
||||
export type CLIProxyBackend = 'original' | 'plus';
|
||||
|
||||
@@ -149,7 +161,14 @@ export type CliproxyRoutingStrategy = 'round-robin' | 'fill-first';
|
||||
/**
|
||||
* Providers that require CLIProxyAPIPlus backend
|
||||
*/
|
||||
export const PLUS_ONLY_PROVIDERS: CLIProxyProvider[] = ['kiro', 'ghcp'];
|
||||
export const PLUS_ONLY_PROVIDERS: CLIProxyProvider[] = [
|
||||
'kiro',
|
||||
'ghcp',
|
||||
'cursor',
|
||||
'gitlab',
|
||||
'codebuddy',
|
||||
'kilo',
|
||||
];
|
||||
|
||||
/**
|
||||
* CLIProxy config.yaml structure (minimal)
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { getBrowserStatus, type BrowserStatusPayload } from '../utils/browser';
|
||||
import { getNodePlatformKey } from '../utils/browser/platform';
|
||||
import { color, dim, header, initUI, subheader } from '../utils/ui';
|
||||
|
||||
type HelpWriter = (line: string) => void;
|
||||
|
||||
function summarizeBrowserHealth(status: BrowserStatusPayload): {
|
||||
label: 'ready' | 'partial' | 'action required';
|
||||
exitCode: 0 | 1;
|
||||
} {
|
||||
const claudeNeedsAttention = status.claude.enabled && status.claude.state !== 'ready';
|
||||
if (claudeNeedsAttention) {
|
||||
return { label: 'action required', exitCode: 1 };
|
||||
}
|
||||
|
||||
if (status.codex.enabled && status.codex.state !== 'enabled') {
|
||||
return { label: 'partial', exitCode: 0 };
|
||||
}
|
||||
|
||||
return { label: 'ready', exitCode: 0 };
|
||||
}
|
||||
|
||||
function writeCommandTable(writeLine: HelpWriter): void {
|
||||
writeLine(subheader('Commands'));
|
||||
writeLine(
|
||||
` ${color('ccs browser status', 'command')} Show Claude attach and Codex browser readiness`
|
||||
);
|
||||
writeLine(
|
||||
` ${color('ccs browser doctor', 'command')} Explain what is missing and how to fix it`
|
||||
);
|
||||
writeLine('');
|
||||
}
|
||||
|
||||
function writeIntro(writeLine: HelpWriter): void {
|
||||
writeLine(' Claude Browser Attach reuses a local Chrome session for Claude-target launches.');
|
||||
writeLine(
|
||||
' Codex Browser Tools inject managed Playwright MCP overrides into Codex-target launches.'
|
||||
);
|
||||
writeLine('');
|
||||
}
|
||||
|
||||
function writeClaudeStatus(
|
||||
status: BrowserStatusPayload['claude'],
|
||||
writeLine: HelpWriter,
|
||||
includeLaunchGuidance: boolean
|
||||
): void {
|
||||
writeLine(subheader('Claude Browser Attach'));
|
||||
writeLine(` State: ${status.state}`);
|
||||
writeLine(` Enabled: ${status.enabled ? 'yes' : 'no'}`);
|
||||
writeLine(` Source: ${status.source}${status.overrideActive ? ' (env override active)' : ''}`);
|
||||
writeLine(` User data dir: ${status.effectiveUserDataDir}`);
|
||||
writeLine(` DevTools port: ${status.devtoolsPort}`);
|
||||
writeLine(` Managed MCP: ${status.managedMcpServerName}`);
|
||||
writeLine(` Managed path: ${status.managedMcpServerPath}`);
|
||||
if (status.runtimeEnv?.CCS_BROWSER_DEVTOOLS_HTTP_URL) {
|
||||
writeLine(` DevTools endpoint: ${status.runtimeEnv.CCS_BROWSER_DEVTOOLS_HTTP_URL}`);
|
||||
}
|
||||
writeLine(` Detail: ${status.detail}`);
|
||||
writeLine(` Next step: ${status.nextStep}`);
|
||||
if (includeLaunchGuidance && status.enabled && status.state !== 'ready') {
|
||||
const platform = getNodePlatformKey();
|
||||
writeLine(` Launch command (${platform}): ${status.launchCommands[platform]}`);
|
||||
}
|
||||
writeLine('');
|
||||
}
|
||||
|
||||
function writeCodexStatus(status: BrowserStatusPayload['codex'], writeLine: HelpWriter): void {
|
||||
writeLine(subheader('Codex Browser Tools'));
|
||||
writeLine(` State: ${status.state}`);
|
||||
writeLine(` Enabled: ${status.enabled ? 'yes' : 'no'}`);
|
||||
writeLine(` Managed server: ${status.serverName}`);
|
||||
writeLine(` Supports overrides: ${status.supportsConfigOverrides ? 'yes' : 'no'}`);
|
||||
writeLine(` Codex binary: ${status.binaryPath || 'not detected'}`);
|
||||
if (status.version) {
|
||||
writeLine(` Codex version: ${status.version}`);
|
||||
}
|
||||
writeLine(` Detail: ${status.detail}`);
|
||||
writeLine(` Next step: ${status.nextStep}`);
|
||||
writeLine('');
|
||||
}
|
||||
|
||||
export async function showBrowserHelp(writeLine: HelpWriter = console.log): Promise<void> {
|
||||
await initUI();
|
||||
writeLine(header('CCS Browser Help'));
|
||||
writeLine('');
|
||||
writeIntro(writeLine);
|
||||
writeLine(subheader('Usage'));
|
||||
writeLine(` ${color('ccs browser <status|doctor>', 'command')}`);
|
||||
writeLine(` ${color('ccs help browser', 'command')}`);
|
||||
writeLine('');
|
||||
writeCommandTable(writeLine);
|
||||
writeLine(subheader('What Each Lane Does'));
|
||||
writeLine(' Claude Browser Attach expects a Chrome user-data dir and remote debugging port.');
|
||||
writeLine(' Codex Browser Tools depend on a Codex build that supports --config overrides.');
|
||||
writeLine('');
|
||||
writeLine(subheader('Examples'));
|
||||
writeLine(` ${color('ccs browser status', 'command')} ${dim('# Quick readiness snapshot')}`);
|
||||
writeLine(
|
||||
` ${color('ccs browser doctor', 'command')} ${dim('# Detailed troubleshooting output')}`
|
||||
);
|
||||
writeLine(
|
||||
` ${color('ccs config', 'command')} ${dim('# Open Settings > Browser in the dashboard')}`
|
||||
);
|
||||
writeLine('');
|
||||
}
|
||||
|
||||
export async function handleBrowserCommand(
|
||||
args: string[],
|
||||
writeLine: HelpWriter = console.log
|
||||
): Promise<void> {
|
||||
const subcommand = args[0];
|
||||
if (!subcommand || subcommand === '--help' || subcommand === '-h' || subcommand === 'help') {
|
||||
await showBrowserHelp(writeLine);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand !== 'status' && subcommand !== 'doctor') {
|
||||
await initUI();
|
||||
writeLine(color(`Unknown browser subcommand: ${subcommand}`, 'error'));
|
||||
writeLine('');
|
||||
writeLine(` ${dim('Supported subcommands: status, doctor')}`);
|
||||
writeLine('');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
await initUI();
|
||||
const status = await getBrowserStatus();
|
||||
|
||||
writeLine(header(`ccs browser ${subcommand}`));
|
||||
writeLine('');
|
||||
writeIntro(writeLine);
|
||||
|
||||
if (subcommand === 'doctor') {
|
||||
const summary = summarizeBrowserHealth(status);
|
||||
writeLine(subheader('Overall'));
|
||||
writeLine(` Claude Browser Attach: ${status.claude.title}`);
|
||||
writeLine(` Codex Browser Tools: ${status.codex.title}`);
|
||||
writeLine(` Result: ${summary.label}`);
|
||||
writeLine('');
|
||||
}
|
||||
|
||||
writeClaudeStatus(status.claude, writeLine, subcommand === 'doctor');
|
||||
writeCodexStatus(status.codex, writeLine);
|
||||
|
||||
if (subcommand === 'doctor') {
|
||||
process.exitCode = summarizeBrowserHealth(status).exitCode;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import { COPILOT_SUBCOMMANDS } from '../copilot/constants';
|
||||
import { CURSOR_SUBCOMMANDS } from '../cursor/constants';
|
||||
import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities';
|
||||
|
||||
export type HelpTopicName = 'profiles' | 'providers' | 'kiro' | 'completion' | 'targets';
|
||||
export type HelpTopicName =
|
||||
| 'profiles'
|
||||
| 'providers'
|
||||
| 'kiro'
|
||||
| 'browser'
|
||||
| 'completion'
|
||||
| 'targets';
|
||||
|
||||
export interface HelpTopicEntry {
|
||||
name: HelpTopicName;
|
||||
@@ -26,6 +31,7 @@ export const ROOT_HELP_TOPICS: readonly HelpTopicEntry[] = [
|
||||
{ name: 'profiles', summary: 'Account profiles, API profiles, and CLIProxy variants' },
|
||||
{ name: 'providers', summary: 'Built-in OAuth providers and runtime shortcuts' },
|
||||
{ name: 'kiro', summary: 'Kiro auth methods, IDC flags, and callback guidance' },
|
||||
{ name: 'browser', summary: 'Claude Browser Attach and Codex Browser Tools guidance' },
|
||||
{ name: 'completion', summary: 'Shell completion install, refresh, and testing' },
|
||||
{ name: 'targets', summary: 'Claude, Droid, and Codex target routing' },
|
||||
] as const;
|
||||
@@ -105,7 +111,19 @@ export const ROOT_COMMAND_CATALOG: readonly RootCommandEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'cursor',
|
||||
summary: 'Run or manage the Cursor bridge',
|
||||
summary: 'Run Cursor via CLIProxy or manage Cursor provider auth',
|
||||
group: 'runtime',
|
||||
visibility: 'public',
|
||||
},
|
||||
{
|
||||
name: 'proxy',
|
||||
summary: 'Start or inspect the OpenAI-compatible local proxy',
|
||||
group: 'runtime',
|
||||
visibility: 'public',
|
||||
},
|
||||
{
|
||||
name: 'browser',
|
||||
summary: 'Inspect Claude Browser Attach and Codex Browser Tools readiness',
|
||||
group: 'runtime',
|
||||
visibility: 'public',
|
||||
},
|
||||
@@ -183,6 +201,10 @@ export const BUILTIN_PROVIDER_SHORTCUTS: readonly ShortcutEntry[] = CLIPROXY_PRO
|
||||
ghcp: 'GitHub Copilot via CLIProxy OAuth',
|
||||
claude: 'Claude via CLIProxy OAuth',
|
||||
kimi: 'Kimi via CLIProxy OAuth',
|
||||
cursor: 'Cursor via CLIProxy OAuth',
|
||||
gitlab: 'GitLab Duo via CLIProxy OAuth',
|
||||
codebuddy: 'CodeBuddy via CLIProxy OAuth',
|
||||
kilo: 'Kilo AI via CLIProxy OAuth',
|
||||
}[name] || 'CLIProxy OAuth provider',
|
||||
})
|
||||
);
|
||||
@@ -252,6 +274,7 @@ export const CLIPROXY_SUBCOMMANDS = [
|
||||
] as const;
|
||||
export const CONFIG_SUBCOMMANDS = ['auth', 'channels', 'image-analysis', 'thinking'] as const;
|
||||
export const DOCKER_SUBCOMMANDS = ['up', 'down', 'status', 'update', 'logs', 'config'] as const;
|
||||
export const PROXY_SUBCOMMANDS = ['start', 'stop', 'status', 'activate'] as const;
|
||||
export const TOKENS_FLAGS = [
|
||||
'--show',
|
||||
'--api-key',
|
||||
@@ -297,6 +320,7 @@ export const COMMAND_FLAG_SUGGESTIONS: Readonly<Record<string, readonly string[]
|
||||
config: ['--help', '-h', '--port', '-p', '--host', '-H', '--dev'],
|
||||
cursor: ['--help', '-h'],
|
||||
doctor: ['--fix', '-f', '--help', '-h'],
|
||||
browser: ['status', 'doctor', '--help', '-h'],
|
||||
docker: ['--help', '-h', '--host'],
|
||||
env: ['--format', '--shell', '--ide', '--help', '-h'],
|
||||
migrate: MIGRATE_FLAGS,
|
||||
@@ -304,7 +328,14 @@ export const COMMAND_FLAG_SUGGESTIONS: Readonly<Record<string, readonly string[]
|
||||
update: ['--force', '--beta', '--dev', '--help', '-h'],
|
||||
};
|
||||
|
||||
export const CURSOR_COMPLETION_SUBCOMMANDS = [...CURSOR_SUBCOMMANDS] as const;
|
||||
export const CURSOR_COMPLETION_SUBCOMMANDS = [
|
||||
'--auth',
|
||||
'--accounts',
|
||||
'--config',
|
||||
'--logout',
|
||||
'--help',
|
||||
'-h',
|
||||
] as const;
|
||||
export const COPILOT_COMPLETION_SUBCOMMANDS = [...COPILOT_SUBCOMMANDS, 'help'] as const;
|
||||
|
||||
export function getPublicRootCommands(): readonly RootCommandEntry[] {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ROOT_COMMAND_FLAGS,
|
||||
ROOT_HELP_TOPICS,
|
||||
TOKENS_FLAGS,
|
||||
PROXY_SUBCOMMANDS,
|
||||
PROVIDER_FLAGS,
|
||||
uniqueStrings,
|
||||
getPublicRootCommandTokens,
|
||||
@@ -189,6 +190,13 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg
|
||||
return completeSubcommands([], COMMAND_FLAG_SUGGESTIONS.docker);
|
||||
case 'cursor':
|
||||
return completeSubcommands(CURSOR_COMPLETION_SUBCOMMANDS);
|
||||
case 'proxy':
|
||||
if (lastToken === '--shell')
|
||||
return completeSubcommands(['auto', 'bash', 'zsh', 'fish', 'powershell']);
|
||||
return completeSubcommands(
|
||||
[...PROXY_SUBCOMMANDS],
|
||||
['--port', '--shell', '--insecure', '--help', '-h']
|
||||
);
|
||||
case 'copilot':
|
||||
return completeSubcommands(COPILOT_COMPLETION_SUBCOMMANDS);
|
||||
case 'env':
|
||||
|
||||
@@ -4,6 +4,9 @@ import type { CursorConfig } from '../config/unified-config-types';
|
||||
import { getCcsDirDisplay } from '../utils/config-manager';
|
||||
import { color } from '../utils/ui';
|
||||
|
||||
const LEGACY_CURSOR_COMMAND = 'ccs legacy cursor';
|
||||
const CLIPROXY_CURSOR_COMMAND = 'ccs cursor';
|
||||
|
||||
function printLines(lines: string[]): void {
|
||||
for (const line of lines) {
|
||||
console.log(line);
|
||||
@@ -12,37 +15,46 @@ function printLines(lines: string[]): void {
|
||||
|
||||
export function renderCursorHelp(): number {
|
||||
printLines([
|
||||
'Cursor IDE Integration',
|
||||
'Legacy Cursor Compatibility',
|
||||
'',
|
||||
'Usage: ccs cursor <subcommand>',
|
||||
'Deprecated: `ccs cursor` now belongs to the CLIProxy Cursor provider.',
|
||||
`Supported auth path: ${CLIPROXY_CURSOR_COMMAND} --auth`,
|
||||
'Supported dashboard path: ccs config -> CLIProxy -> Cursor',
|
||||
'',
|
||||
'Subcommands:',
|
||||
' auth Import Cursor IDE authentication token',
|
||||
' status Show integration, authentication, and daemon status',
|
||||
`Usage: ${LEGACY_CURSOR_COMMAND} <subcommand>`,
|
||||
'',
|
||||
'Subcommands (deprecated compatibility for the local reverse-engineered bridge):',
|
||||
' auth Import Cursor IDE authentication token (deprecated)',
|
||||
' status Show legacy integration, authentication, and daemon status',
|
||||
' probe Run a live authenticated runtime probe',
|
||||
' models List available models',
|
||||
' start Start cursor daemon',
|
||||
' stop Stop cursor daemon',
|
||||
' enable Enable cursor integration in unified config',
|
||||
' disable Disable cursor integration in unified config',
|
||||
' start Start local cursor daemon',
|
||||
' stop Stop local cursor daemon',
|
||||
' enable Enable legacy cursor integration in unified config',
|
||||
' disable Disable legacy cursor integration in unified config',
|
||||
' help Show this help message',
|
||||
'',
|
||||
'Runtime entry:',
|
||||
' ccs cursor [claude args] # Run Claude via the local Cursor proxy',
|
||||
'Supported CLIProxy path:',
|
||||
` ${CLIPROXY_CURSOR_COMMAND} --auth # Authenticate Cursor via CLIProxy`,
|
||||
` ${CLIPROXY_CURSOR_COMMAND} --accounts # Manage CLIProxy Cursor accounts`,
|
||||
` ${CLIPROXY_CURSOR_COMMAND} --config # Open CLIProxy Cursor settings`,
|
||||
'',
|
||||
'Auth options:',
|
||||
' ccs cursor auth # Auto-detect from Cursor SQLite',
|
||||
' ccs cursor auth --manual --token <t> --machine-id <id>',
|
||||
'Legacy runtime entry (deprecated compatibility):',
|
||||
` ${LEGACY_CURSOR_COMMAND} [claude args] # Run Claude via the local Cursor bridge`,
|
||||
'',
|
||||
'Quick start:',
|
||||
' 1. ccs cursor enable # Enable integration',
|
||||
' 2. ccs cursor auth # Import Cursor IDE token',
|
||||
' 3. ccs cursor start # Start daemon',
|
||||
' 4. ccs cursor probe # Verify live runtime health',
|
||||
' 5. ccs cursor "task" # Run Claude through Cursor',
|
||||
' 6. ccs cursor status # Inspect auth/daemon wiring',
|
||||
'Legacy auth options:',
|
||||
` ${LEGACY_CURSOR_COMMAND} auth # Auto-detect from Cursor SQLite (deprecated)`,
|
||||
` ${LEGACY_CURSOR_COMMAND} auth --manual --token <t> --machine-id <id>`,
|
||||
'',
|
||||
'Or use the web UI: ccs config -> Cursor page',
|
||||
'Legacy bridge quick start:',
|
||||
` 1. ${LEGACY_CURSOR_COMMAND} enable # Deprecated compatibility: enable local bridge`,
|
||||
` 2. ${LEGACY_CURSOR_COMMAND} auth # Deprecated compatibility: import Cursor IDE token`,
|
||||
` 3. ${LEGACY_CURSOR_COMMAND} start # Start local daemon`,
|
||||
` 4. ${LEGACY_CURSOR_COMMAND} probe # Verify live runtime health`,
|
||||
` 5. ${LEGACY_CURSOR_COMMAND} "task" # Run Claude through the local bridge`,
|
||||
` 6. ${LEGACY_CURSOR_COMMAND} status # Inspect auth/daemon wiring`,
|
||||
'',
|
||||
'Web UI: ccs config -> Deprecated -> Cursor IDE',
|
||||
'',
|
||||
]);
|
||||
|
||||
@@ -100,10 +112,13 @@ export function renderCursorStatus(
|
||||
console.log('');
|
||||
console.log('Client setup:');
|
||||
console.log(` Raw settings: ${dirDisplay}/cursor.settings.json`);
|
||||
console.log(' Runtime entry: ccs cursor [claude args]');
|
||||
console.log(' Live probe: ccs cursor probe');
|
||||
console.log(' Status command: ccs cursor status');
|
||||
console.log(' Help command: ccs cursor help');
|
||||
console.log(
|
||||
` Runtime entry: ${LEGACY_CURSOR_COMMAND} [claude args] (deprecated compatibility)`
|
||||
);
|
||||
console.log(` Supported auth: ${CLIPROXY_CURSOR_COMMAND} --auth`);
|
||||
console.log(` Live probe: ${LEGACY_CURSOR_COMMAND} probe`);
|
||||
console.log(` Status command: ${LEGACY_CURSOR_COMMAND} status`);
|
||||
console.log(` Help command: ${LEGACY_CURSOR_COMMAND} help`);
|
||||
|
||||
if (isReady) {
|
||||
return;
|
||||
@@ -113,15 +128,16 @@ export function renderCursorStatus(
|
||||
|
||||
console.log('Next steps:');
|
||||
if (!cursorConfig.enabled) {
|
||||
console.log(' - Enable: ccs cursor enable');
|
||||
console.log(` - Enable: ${LEGACY_CURSOR_COMMAND} enable`);
|
||||
}
|
||||
if (!authStatus.authenticated || authStatus.expired) {
|
||||
console.log(' - Auth: ccs cursor auth');
|
||||
console.log(` - Supported: ${CLIPROXY_CURSOR_COMMAND} --auth`);
|
||||
console.log(` - Legacy auth: ${LEGACY_CURSOR_COMMAND} auth`);
|
||||
}
|
||||
if (!daemonStatus.running) {
|
||||
console.log(' - Start: ccs cursor start');
|
||||
console.log(` - Start: ${LEGACY_CURSOR_COMMAND} start`);
|
||||
}
|
||||
console.log(' - Help: ccs cursor help');
|
||||
console.log(` - Help: ${LEGACY_CURSOR_COMMAND} help`);
|
||||
}
|
||||
|
||||
export function renderCursorModels(models: CursorModel[], defaultModel: string): void {
|
||||
|
||||
@@ -26,6 +26,18 @@ import {
|
||||
} from './cursor-command-display';
|
||||
import { ok, fail, info } from '../utils/ui';
|
||||
|
||||
const LEGACY_CURSOR_COMMAND = 'ccs legacy cursor';
|
||||
const CLIPROXY_CURSOR_COMMAND = 'ccs cursor';
|
||||
|
||||
function printLegacyCursorDeprecationNotice(): void {
|
||||
console.log(
|
||||
info(
|
||||
`Deprecated compatibility path. \`${CLIPROXY_CURSOR_COMMAND}\` now belongs to the CLIProxy Cursor provider; use \`${LEGACY_CURSOR_COMMAND}\` for the old bridge.`
|
||||
)
|
||||
);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle cursor subcommand.
|
||||
*/
|
||||
@@ -114,6 +126,7 @@ function printAutoDetectFailure(result: {
|
||||
* Handle auth subcommand.
|
||||
*/
|
||||
async function handleAuth(args: string[]): Promise<number> {
|
||||
printLegacyCursorDeprecationNotice();
|
||||
const manual = args.includes('--manual');
|
||||
|
||||
if (manual) {
|
||||
@@ -125,7 +138,7 @@ async function handleAuth(args: string[]): Promise<number> {
|
||||
if (!accessToken || !machineId) {
|
||||
console.error(
|
||||
fail(
|
||||
'Manual auth requires both token and machine ID.\n\nExample:\n ccs cursor auth --manual --token <token> --machine-id <machine-id>'
|
||||
`Manual auth requires both token and machine ID.\n\nExample:\n ${LEGACY_CURSOR_COMMAND} auth --manual --token <token> --machine-id <machine-id>`
|
||||
)
|
||||
);
|
||||
return 1;
|
||||
@@ -146,8 +159,9 @@ async function handleAuth(args: string[]): Promise<number> {
|
||||
console.log(ok('Cursor credentials imported (manual mode)'));
|
||||
console.log('');
|
||||
console.log('Next steps:');
|
||||
console.log(' 1. Enable integration: ccs cursor enable');
|
||||
console.log(' 2. Start daemon: ccs cursor start');
|
||||
console.log(` 0. Preferred auth: ${CLIPROXY_CURSOR_COMMAND} --auth`);
|
||||
console.log(` 1. Enable integration: ${LEGACY_CURSOR_COMMAND} enable`);
|
||||
console.log(` 2. Start daemon: ${LEGACY_CURSOR_COMMAND} start`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -168,9 +182,10 @@ async function handleAuth(args: string[]): Promise<number> {
|
||||
console.log(ok('Auto-detected Cursor credentials'));
|
||||
console.log('');
|
||||
console.log('Next steps:');
|
||||
console.log(' 1. Enable integration: ccs cursor enable');
|
||||
console.log(' 2. Start daemon: ccs cursor start');
|
||||
console.log(' 3. Check status: ccs cursor status');
|
||||
console.log(` 0. Preferred auth: ${CLIPROXY_CURSOR_COMMAND} --auth`);
|
||||
console.log(` 1. Enable integration: ${LEGACY_CURSOR_COMMAND} enable`);
|
||||
console.log(` 2. Start daemon: ${LEGACY_CURSOR_COMMAND} start`);
|
||||
console.log(` 3. Check status: ${LEGACY_CURSOR_COMMAND} status`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -178,13 +193,14 @@ async function handleAuth(args: string[]): Promise<number> {
|
||||
printAutoDetectFailure(autoResult);
|
||||
console.log('');
|
||||
console.log('Manual fallback:');
|
||||
console.log(' ccs cursor auth --manual --token <token> --machine-id <machine-id>');
|
||||
console.log(` ${LEGACY_CURSOR_COMMAND} auth --manual --token <token> --machine-id <machine-id>`);
|
||||
console.log('');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
async function handleStatus(): Promise<number> {
|
||||
printLegacyCursorDeprecationNotice();
|
||||
const cursorConfig = getCursorConfig();
|
||||
const authStatus = checkAuthStatus();
|
||||
const daemonStatus = await getDaemonStatus(cursorConfig.port);
|
||||
@@ -193,6 +209,7 @@ async function handleStatus(): Promise<number> {
|
||||
}
|
||||
|
||||
async function handleProbe(): Promise<number> {
|
||||
printLegacyCursorDeprecationNotice();
|
||||
const cursorConfig = getCursorConfig();
|
||||
const result = await probeCursorRuntime(cursorConfig);
|
||||
renderCursorProbe(result);
|
||||
@@ -200,6 +217,7 @@ async function handleProbe(): Promise<number> {
|
||||
}
|
||||
|
||||
async function handleModels(): Promise<number> {
|
||||
printLegacyCursorDeprecationNotice();
|
||||
const cursorConfig = getCursorConfig();
|
||||
const models = await getAvailableModels(cursorConfig.port);
|
||||
const defaultModel = getDefaultModel();
|
||||
@@ -211,20 +229,21 @@ async function handleModels(): Promise<number> {
|
||||
* Handle start subcommand.
|
||||
*/
|
||||
async function handleStart(): Promise<number> {
|
||||
printLegacyCursorDeprecationNotice();
|
||||
const cursorConfig = getCursorConfig();
|
||||
|
||||
if (!cursorConfig.enabled) {
|
||||
console.error(fail('Cursor integration is disabled. Run: ccs cursor enable'));
|
||||
console.error(fail(`Cursor integration is disabled. Run: ${LEGACY_CURSOR_COMMAND} enable`));
|
||||
return 1;
|
||||
}
|
||||
|
||||
const authStatus = checkAuthStatus();
|
||||
if (!authStatus.authenticated) {
|
||||
console.error(fail('Not authenticated. Run: ccs cursor auth'));
|
||||
console.error(fail(`Not authenticated. Run: ${LEGACY_CURSOR_COMMAND} auth`));
|
||||
return 1;
|
||||
}
|
||||
if (authStatus.expired) {
|
||||
console.error(fail('Credentials expired. Run: ccs cursor auth'));
|
||||
console.error(fail(`Credentials expired. Run: ${LEGACY_CURSOR_COMMAND} auth`));
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -248,6 +267,7 @@ async function handleStart(): Promise<number> {
|
||||
* Handle stop subcommand.
|
||||
*/
|
||||
async function handleStop(): Promise<number> {
|
||||
printLegacyCursorDeprecationNotice();
|
||||
console.log(info('Stopping cursor daemon...'));
|
||||
|
||||
const result = await stopDaemon();
|
||||
@@ -265,6 +285,7 @@ async function handleStop(): Promise<number> {
|
||||
* Handle enable subcommand.
|
||||
*/
|
||||
async function handleEnable(): Promise<number> {
|
||||
printLegacyCursorDeprecationNotice();
|
||||
mutateUnifiedConfig((config) => {
|
||||
if (!config.cursor) {
|
||||
config.cursor = { ...DEFAULT_CURSOR_CONFIG };
|
||||
@@ -276,9 +297,10 @@ async function handleEnable(): Promise<number> {
|
||||
console.log(ok('Cursor integration enabled'));
|
||||
console.log('');
|
||||
console.log('Next steps:');
|
||||
console.log(' 1. Authenticate: ccs cursor auth');
|
||||
console.log(' 2. Start daemon: ccs cursor start');
|
||||
console.log(' 3. Check status: ccs cursor status');
|
||||
console.log(` 0. Preferred auth: ${CLIPROXY_CURSOR_COMMAND} --auth`);
|
||||
console.log(` 1. Authenticate: ${LEGACY_CURSOR_COMMAND} auth`);
|
||||
console.log(` 2. Start daemon: ${LEGACY_CURSOR_COMMAND} start`);
|
||||
console.log(` 3. Check status: ${LEGACY_CURSOR_COMMAND} status`);
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -287,6 +309,7 @@ async function handleEnable(): Promise<number> {
|
||||
* Handle disable subcommand.
|
||||
*/
|
||||
async function handleDisable(): Promise<number> {
|
||||
printLegacyCursorDeprecationNotice();
|
||||
mutateUnifiedConfig((config) => {
|
||||
if (config.cursor) {
|
||||
config.cursor.enabled = false;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import packageJson from '../../package.json';
|
||||
import type { CLIProxyProvider } from '../cliproxy';
|
||||
import { color, dim, header, initUI, subheader } from '../utils/ui';
|
||||
import {
|
||||
BUILTIN_PROVIDER_SHORTCUTS,
|
||||
@@ -82,10 +83,80 @@ async function showProvidersHelp(writeLine: HelpWriter): Promise<void> {
|
||||
],
|
||||
writeLine
|
||||
);
|
||||
writeCommandTable(
|
||||
'GitLab Duo Flags',
|
||||
[
|
||||
{
|
||||
name: 'ccs gitlab --auth --gitlab-token-login',
|
||||
summary: 'Authenticate with a GitLab Personal Access Token',
|
||||
},
|
||||
{
|
||||
name: 'ccs gitlab --auth --token-login',
|
||||
summary: 'Legacy alias for GitLab PAT login (still supported)',
|
||||
},
|
||||
{
|
||||
name: 'ccs gitlab --auth --gitlab-url <url>',
|
||||
summary: 'Use a self-hosted GitLab base URL during OAuth or PAT auth',
|
||||
},
|
||||
],
|
||||
writeLine
|
||||
);
|
||||
writeLine(` ${dim('Deep help: ccs cliproxy --help | ccs api --help')}`);
|
||||
writeLine('');
|
||||
}
|
||||
|
||||
export async function showProviderShortcutHelp(
|
||||
provider: CLIProxyProvider,
|
||||
writeLine: HelpWriter = console.log
|
||||
): Promise<void> {
|
||||
if (provider === 'kiro') {
|
||||
await showKiroHelp(writeLine);
|
||||
return;
|
||||
}
|
||||
|
||||
await initUI();
|
||||
|
||||
const providerEntry = BUILTIN_PROVIDER_SHORTCUTS.find((entry) => entry.name === provider);
|
||||
writeLine(header(`CCS ${provider} Shortcut Help`));
|
||||
writeLine('');
|
||||
writeLine(` ${providerEntry?.summary || 'CLIProxy OAuth provider shortcut'}.`);
|
||||
writeLine('');
|
||||
writeCommandTable(
|
||||
'Common Commands',
|
||||
[
|
||||
{ name: `ccs ${provider} --auth`, summary: 'Authenticate the provider account via CLIProxy' },
|
||||
{ name: `ccs ${provider} --accounts`, summary: 'List or manage stored CLIProxy accounts' },
|
||||
{ name: `ccs ${provider} --config`, summary: 'Open the provider config flow' },
|
||||
{ name: `ccs ${provider} "task"`, summary: 'Run Claude through this provider shortcut' },
|
||||
],
|
||||
writeLine
|
||||
);
|
||||
|
||||
if (provider === 'gitlab') {
|
||||
writeCommandTable(
|
||||
'GitLab Duo Flags',
|
||||
[
|
||||
{
|
||||
name: '--gitlab-token-login',
|
||||
summary: 'Use a GitLab Personal Access Token instead of browser OAuth',
|
||||
},
|
||||
{
|
||||
name: '--token-login',
|
||||
summary: 'Legacy alias for `--gitlab-token-login`',
|
||||
},
|
||||
{
|
||||
name: '--gitlab-url <url>',
|
||||
summary: 'Target a self-hosted GitLab base URL',
|
||||
},
|
||||
],
|
||||
writeLine
|
||||
);
|
||||
}
|
||||
|
||||
writeLine(` ${dim('See also: ccs help providers | ccs cliproxy --help')}`);
|
||||
writeLine('');
|
||||
}
|
||||
|
||||
async function showKiroHelp(writeLine: HelpWriter): Promise<void> {
|
||||
await initUI();
|
||||
writeLine(header('CCS Kiro Help'));
|
||||
@@ -178,7 +249,7 @@ export async function handleHelpCommand(writeLine: HelpWriter = console.log): Pr
|
||||
|
||||
writeLine(header(`CCS CLI v${packageJson.version}`));
|
||||
writeLine('');
|
||||
writeLine(' Claude profile switching, provider routing, and compatible runtime bridges.');
|
||||
writeLine(' Claude profile switching, provider routing, runtime bridges, and browser tooling.');
|
||||
writeLine('');
|
||||
|
||||
writeLine(subheader('Usage'));
|
||||
@@ -208,6 +279,7 @@ export async function handleHelpCommand(writeLine: HelpWriter = console.log): Pr
|
||||
[
|
||||
{ name: 'ccs help profiles', summary: getTopicSummary('profiles') },
|
||||
{ name: 'ccs help providers', summary: getTopicSummary('providers') },
|
||||
{ name: 'ccs help browser', summary: getTopicSummary('browser') },
|
||||
{ name: 'ccs help completion', summary: getTopicSummary('completion') },
|
||||
{ name: 'ccs help targets', summary: getTopicSummary('targets') },
|
||||
{ name: 'ccs api --help', summary: 'Deep help for API profile lifecycle commands' },
|
||||
@@ -215,6 +287,7 @@ export async function handleHelpCommand(writeLine: HelpWriter = console.log): Pr
|
||||
name: 'ccs cliproxy --help',
|
||||
summary: 'Deep help for variants, routing, quota, and lifecycle',
|
||||
},
|
||||
{ name: 'ccs proxy --help', summary: 'Deep help for the OpenAI-compatible local proxy' },
|
||||
{ name: 'ccs docker --help', summary: 'Deep help for Docker deployment commands' },
|
||||
{ name: 'ccs cursor --help', summary: 'Deep help for Cursor runtime/admin commands' },
|
||||
{ name: 'ccs copilot --help', summary: 'Deep help for GitHub Copilot commands' },
|
||||
@@ -256,6 +329,10 @@ export async function handleHelpRoute(
|
||||
await showTargetsHelp(writeLine);
|
||||
return;
|
||||
}
|
||||
if (topic === 'browser') {
|
||||
await (await import('./browser-command')).showBrowserHelp(writeLine);
|
||||
return;
|
||||
}
|
||||
if (topic === 'completion') {
|
||||
const { showShellCompletionHelp } = await import('./shell-completion-command');
|
||||
showShellCompletionHelp(writeLine);
|
||||
@@ -270,11 +347,13 @@ export async function handleHelpRoute(
|
||||
await new AuthCommands().showHelp();
|
||||
},
|
||||
cleanup: async () => (await import('./cleanup-command')).handleCleanupCommand(['--help']),
|
||||
browser: async () => (await import('./browser-command')).showBrowserHelp(writeLine),
|
||||
cliproxy: async () => (await import('./cliproxy/help-subcommand')).showHelp(),
|
||||
copilot: async () =>
|
||||
process.exit(await (await import('./copilot-command')).handleCopilotCommand(['--help'])),
|
||||
cursor: async () =>
|
||||
process.exit(await (await import('./cursor-command')).handleCursorCommand(['--help'])),
|
||||
cursor: async () => await showProviderShortcutHelp('cursor', writeLine),
|
||||
proxy: async () =>
|
||||
process.exit(await (await import('./proxy-command')).handleProxyCommand(['--help'])),
|
||||
docker: async () => (await import('./docker/help-subcommand')).showHelp(),
|
||||
migrate: async () => (await import('./migrate-command')).printMigrateHelp(),
|
||||
setup: async () => (await import('./setup-command')).handleSetupCommand(['--help']),
|
||||
|
||||
@@ -13,6 +13,7 @@ export { handleDockerCommand } from './docker-command';
|
||||
export { handleHelpCommand } from './help-command';
|
||||
export { handleInstallCommand } from './install-command';
|
||||
export { handleMigrateCommand } from './migrate-command';
|
||||
export { handleProxyCommand } from './proxy-command';
|
||||
export { handleShellCompletionCommand } from './shell-completion-command';
|
||||
export { handleSyncCommand } from './sync-command';
|
||||
export { handleUpdateCommand } from './update-command';
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { detectShell, formatExportLine } from './env-command';
|
||||
import { getSettingsPath, loadSettings } from '../utils/config-manager';
|
||||
import { expandPath } from '../utils/helpers';
|
||||
import { fail, info, ok } from '../utils/ui';
|
||||
import {
|
||||
buildOpenAICompatProxyEnv,
|
||||
getOpenAICompatProxyStatus,
|
||||
resolveOpenAICompatProfileConfig,
|
||||
startOpenAICompatProxy,
|
||||
stopOpenAICompatProxy,
|
||||
} from '../proxy';
|
||||
|
||||
function parseOptionValue(args: string[], key: string): string | undefined {
|
||||
const exactIndex = args.findIndex((arg) => arg === key);
|
||||
if (exactIndex !== -1 && args[exactIndex + 1]) {
|
||||
return args[exactIndex + 1];
|
||||
}
|
||||
|
||||
const prefix = `${key}=`;
|
||||
const withEquals = args.find((arg) => arg.startsWith(prefix));
|
||||
return withEquals ? withEquals.slice(prefix.length) : undefined;
|
||||
}
|
||||
|
||||
function showHelp(): number {
|
||||
console.log('OpenAI-Compatible Proxy');
|
||||
console.log('');
|
||||
console.log('Usage: ccs proxy <start|stop|status|activate> [profile] [options]');
|
||||
console.log('');
|
||||
console.log('Commands:');
|
||||
console.log(
|
||||
' start <profile> Start the local proxy for an OpenAI-compatible settings profile'
|
||||
);
|
||||
console.log(' stop Stop the running proxy');
|
||||
console.log(' status Show daemon status and active profile');
|
||||
console.log(' activate Print shell exports for the running proxy');
|
||||
console.log('');
|
||||
console.log('Options:');
|
||||
console.log(' --port <n> Override the local proxy port (default: 3456)');
|
||||
console.log(' --host <addr> Bind the proxy server to a specific host (default: 127.0.0.1)');
|
||||
console.log(' --shell <name> activate only: auto|bash|zsh|fish|powershell');
|
||||
console.log(' --fish activate only: shorthand for --shell fish');
|
||||
console.log(' --insecure Disable upstream TLS verification');
|
||||
console.log('');
|
||||
console.log('Examples:');
|
||||
console.log(' ccs proxy start hf');
|
||||
console.log(' eval "$(ccs proxy activate)"');
|
||||
console.log(' ccs proxy activate --fish');
|
||||
console.log(' ccs proxy status');
|
||||
console.log(' ccs proxy stop');
|
||||
console.log('');
|
||||
return 0;
|
||||
}
|
||||
|
||||
function resolveProfile(profileName: string) {
|
||||
const settingsPath = expandPath(getSettingsPath(profileName));
|
||||
const settings = loadSettings(settingsPath);
|
||||
const profile = resolveOpenAICompatProfileConfig(profileName, settingsPath, settings.env || {});
|
||||
if (!profile) {
|
||||
throw new Error(`Profile "${profileName}" is not configured for an OpenAI-compatible endpoint`);
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
async function handleStart(args: string[]): Promise<number> {
|
||||
const profileName = args.find((arg) => !arg.startsWith('-'));
|
||||
if (!profileName) {
|
||||
console.error(
|
||||
fail('Usage: ccs proxy start <profile> [--port <n>] [--host <addr>] [--insecure]')
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const portValue = parseOptionValue(args, '--port');
|
||||
const host = parseOptionValue(args, '--host');
|
||||
const port = portValue ? Number.parseInt(portValue, 10) || 3456 : undefined;
|
||||
let profile;
|
||||
try {
|
||||
profile = resolveProfile(profileName);
|
||||
} catch (error) {
|
||||
console.error(fail((error as Error).message));
|
||||
return 1;
|
||||
}
|
||||
|
||||
const result = await startOpenAICompatProxy(profile, {
|
||||
...(port ? { port } : {}),
|
||||
...(host ? { host } : {}),
|
||||
insecure: args.includes('--insecure'),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error(fail(result.error || 'Failed to start proxy'));
|
||||
return 1;
|
||||
}
|
||||
|
||||
console.log(
|
||||
result.alreadyRunning
|
||||
? info(`Proxy already running on port ${result.port}`)
|
||||
: ok(`Proxy started on port ${result.port}`)
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function handleStatus(): Promise<number> {
|
||||
const status = await getOpenAICompatProxyStatus();
|
||||
if (!status.running) {
|
||||
console.log(info('Proxy is not running'));
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log(ok(`Proxy running on port ${status.port}`));
|
||||
if (status.host) {
|
||||
console.log(` Host: ${status.host}`);
|
||||
console.log(` Local URL: http://${status.host}:${status.port}`);
|
||||
}
|
||||
console.log(` Profile: ${status.profileName}`);
|
||||
console.log(` Base URL: ${status.baseUrl}`);
|
||||
if (status.model) {
|
||||
console.log(` Model: ${status.model}`);
|
||||
}
|
||||
if (status.pid) {
|
||||
console.log(` PID: ${status.pid}`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function handleActivate(args: string[]): Promise<number> {
|
||||
const status = await getOpenAICompatProxyStatus();
|
||||
if (!status.running || !status.profileName || !status.port || !status.authToken) {
|
||||
console.error(fail('Proxy is not running. Start it with: ccs proxy start <profile>'));
|
||||
return 1;
|
||||
}
|
||||
|
||||
const shell = detectShell(args.includes('--fish') ? 'fish' : parseOptionValue(args, '--shell'));
|
||||
let profile;
|
||||
try {
|
||||
profile = resolveProfile(status.profileName);
|
||||
} catch (error) {
|
||||
console.error(fail((error as Error).message));
|
||||
return 1;
|
||||
}
|
||||
|
||||
const env = buildOpenAICompatProxyEnv(
|
||||
profile,
|
||||
status.port,
|
||||
status.authToken,
|
||||
undefined,
|
||||
status.host || '127.0.0.1'
|
||||
);
|
||||
Object.entries(env).forEach(([key, value]) => {
|
||||
console.log(formatExportLine(shell, key, value));
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function handleProxyCommand(args: string[]): Promise<number> {
|
||||
const subcommand = args[0];
|
||||
switch (subcommand) {
|
||||
case undefined:
|
||||
case 'help':
|
||||
case '--help':
|
||||
case '-h':
|
||||
return showHelp();
|
||||
case 'start':
|
||||
return handleStart(args.slice(1));
|
||||
case 'stop': {
|
||||
const result = await stopOpenAICompatProxy();
|
||||
if (!result.success) {
|
||||
console.error(fail(result.error || 'Failed to stop proxy'));
|
||||
return 1;
|
||||
}
|
||||
console.log(ok('Proxy stopped'));
|
||||
return 0;
|
||||
}
|
||||
case 'status':
|
||||
return handleStatus();
|
||||
case 'activate':
|
||||
return handleActivate(args.slice(1));
|
||||
default:
|
||||
console.error(fail(`Unknown proxy subcommand: ${subcommand}`));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,13 @@ export const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [
|
||||
await handleSyncCommand();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'browser',
|
||||
handle: async (args) => {
|
||||
const { handleBrowserCommand } = await import('./browser-command');
|
||||
await handleBrowserCommand(args);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'cleanup',
|
||||
aliases: ['--cleanup'],
|
||||
@@ -136,6 +143,13 @@ export const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [
|
||||
await handleCliproxyCommand(args);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'proxy',
|
||||
handle: async (args) => {
|
||||
const { handleProxyCommand } = await import('./proxy-command');
|
||||
process.exit(await handleProxyCommand(args));
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'docker',
|
||||
handle: async (args) => {
|
||||
|
||||
@@ -9,14 +9,24 @@ export const RESERVED_PROFILE_NAMES = [
|
||||
'agy',
|
||||
'qwen',
|
||||
'iflow',
|
||||
'kiro',
|
||||
'ghcp',
|
||||
'claude',
|
||||
'kimi',
|
||||
'gitlab',
|
||||
'codebuddy',
|
||||
'kilo',
|
||||
// Copilot API (GitHub Copilot proxy)
|
||||
'copilot',
|
||||
// Cursor IDE (Cursor proxy daemon)
|
||||
'cursor',
|
||||
'legacy-cursor',
|
||||
'legacy',
|
||||
// CLI commands and special names
|
||||
'default',
|
||||
'config',
|
||||
'cliproxy',
|
||||
'proxy',
|
||||
] as const;
|
||||
|
||||
export type ReservedProfileName = (typeof RESERVED_PROFILE_NAMES)[number];
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
DEFAULT_THINKING_CONFIG,
|
||||
DEFAULT_OFFICIAL_CHANNELS_CONFIG,
|
||||
DEFAULT_DASHBOARD_AUTH_CONFIG,
|
||||
DEFAULT_BROWSER_CONFIG,
|
||||
DEFAULT_IMAGE_ANALYSIS_CONFIG,
|
||||
DEFAULT_LOGGING_CONFIG,
|
||||
} from './unified-config-types';
|
||||
@@ -34,6 +35,7 @@ import type {
|
||||
OfficialChannelsConfig,
|
||||
OfficialChannelId,
|
||||
DashboardAuthConfig,
|
||||
BrowserConfig,
|
||||
ImageAnalysisConfig,
|
||||
LoggingConfig,
|
||||
CursorConfig,
|
||||
@@ -46,6 +48,7 @@ import {
|
||||
normalizeOfficialChannelIds,
|
||||
resolveLegacyDiscordSelection,
|
||||
} from '../channels/official-channels-runtime';
|
||||
import { getRecommendedBrowserUserDataDir } from '../utils/browser/browser-settings';
|
||||
import { canonicalizeImageAnalysisConfig } from '../utils/hooks/image-analysis-backend-resolver';
|
||||
import { normalizeSearxngBaseUrl } from '../utils/websearch/types';
|
||||
|
||||
@@ -54,6 +57,32 @@ const CONFIG_JSON = 'config.json';
|
||||
const CONFIG_LOCK = 'config.yaml.lock';
|
||||
const LOCK_STALE_MS = 5000; // Lock is stale after 5 seconds
|
||||
|
||||
function normalizeBrowserDevtoolsPort(value: number | undefined): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return DEFAULT_BROWSER_CONFIG.claude.devtools_port;
|
||||
}
|
||||
|
||||
const port = Math.floor(value as number);
|
||||
if (port < 1 || port > 65535) {
|
||||
return DEFAULT_BROWSER_CONFIG.claude.devtools_port;
|
||||
}
|
||||
|
||||
return port;
|
||||
}
|
||||
|
||||
function canonicalizeBrowserConfig(config?: BrowserConfig): BrowserConfig {
|
||||
return {
|
||||
claude: {
|
||||
enabled: config?.claude?.enabled ?? DEFAULT_BROWSER_CONFIG.claude.enabled,
|
||||
user_data_dir: config?.claude?.user_data_dir?.trim() || getRecommendedBrowserUserDataDir(),
|
||||
devtools_port: normalizeBrowserDevtoolsPort(config?.claude?.devtools_port),
|
||||
},
|
||||
codex: {
|
||||
enabled: config?.codex?.enabled ?? DEFAULT_BROWSER_CONFIG.codex.enabled,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path to unified config.yaml
|
||||
*/
|
||||
@@ -384,6 +413,18 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
|
||||
: defaults.cliproxy.routing?.strategy,
|
||||
},
|
||||
},
|
||||
proxy: {
|
||||
routing: {
|
||||
default: partial.proxy?.routing?.default ?? defaults.proxy?.routing?.default,
|
||||
background: partial.proxy?.routing?.background ?? defaults.proxy?.routing?.background,
|
||||
think: partial.proxy?.routing?.think ?? defaults.proxy?.routing?.think,
|
||||
longContext: partial.proxy?.routing?.longContext ?? defaults.proxy?.routing?.longContext,
|
||||
webSearch: partial.proxy?.routing?.webSearch ?? defaults.proxy?.routing?.webSearch,
|
||||
longContextThreshold:
|
||||
partial.proxy?.routing?.longContextThreshold ??
|
||||
defaults.proxy?.routing?.longContextThreshold,
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
enabled: partial.logging?.enabled ?? DEFAULT_LOGGING_CONFIG.enabled,
|
||||
level: partial.logging?.level ?? DEFAULT_LOGGING_CONFIG.level,
|
||||
@@ -580,6 +621,7 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
|
||||
partial.dashboard_auth?.session_timeout_hours ??
|
||||
DEFAULT_DASHBOARD_AUTH_CONFIG.session_timeout_hours,
|
||||
},
|
||||
browser: canonicalizeBrowserConfig(partial.browser),
|
||||
// Image analysis config - enabled by default for CLIProxy providers
|
||||
image_analysis: canonicalizeImageAnalysisConfig({
|
||||
enabled: partial.image_analysis?.enabled ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.enabled,
|
||||
@@ -674,6 +716,17 @@ function generateYamlWithComments(config: UnifiedConfig): string {
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
if (config.proxy?.routing) {
|
||||
lines.push('# ----------------------------------------------------------------------------');
|
||||
lines.push('# Proxy Routing: OpenAI-compatible local proxy model selection rules');
|
||||
lines.push('# Use profile:model selectors to force a target profile and upstream model.');
|
||||
lines.push('# ----------------------------------------------------------------------------');
|
||||
lines.push(
|
||||
yaml.dump({ proxy: config.proxy }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim()
|
||||
);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (config.logging) {
|
||||
lines.push('# ----------------------------------------------------------------------------');
|
||||
lines.push('# Logging: CCS-owned structured runtime logs');
|
||||
@@ -893,6 +946,23 @@ function generateYamlWithComments(config: UnifiedConfig): string {
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Browser automation section
|
||||
if (config.browser) {
|
||||
lines.push('# ----------------------------------------------------------------------------');
|
||||
lines.push('# Browser Automation: Claude browser attach and Codex browser tooling');
|
||||
lines.push('# Claude attach reuses a running Chrome/Chromium session with remote debugging.');
|
||||
lines.push('# Codex tooling controls whether CCS injects Playwright MCP overrides.');
|
||||
lines.push('#');
|
||||
lines.push('# claude.user_data_dir should point at the Chrome user-data directory for the');
|
||||
lines.push('# dedicated attach session. claude.devtools_port is the expected debugging port.');
|
||||
lines.push('# Configure via: Settings > Browser or `ccs browser ...`.');
|
||||
lines.push('# ----------------------------------------------------------------------------');
|
||||
lines.push(
|
||||
yaml.dump({ browser: config.browser }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim()
|
||||
);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Image analysis section
|
||||
if (config.image_analysis) {
|
||||
lines.push('# ----------------------------------------------------------------------------');
|
||||
@@ -1311,6 +1381,15 @@ export function getDashboardAuthConfig(): DashboardAuthConfig {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get browser automation configuration.
|
||||
* Returns canonicalized defaults if not configured.
|
||||
*/
|
||||
export function getBrowserConfig(): BrowserConfig {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
return canonicalizeBrowserConfig(config.browser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image_analysis configuration.
|
||||
* Returns defaults if not configured.
|
||||
|
||||
@@ -498,6 +498,19 @@ export interface ProxyLocalConfig {
|
||||
auto_start: boolean;
|
||||
}
|
||||
|
||||
export interface OpenAICompatProxyRoutingConfig {
|
||||
default?: string;
|
||||
background?: string;
|
||||
think?: string;
|
||||
longContext?: string;
|
||||
webSearch?: string;
|
||||
longContextThreshold?: number;
|
||||
}
|
||||
|
||||
export interface OpenAICompatProxyConfig {
|
||||
routing?: OpenAICompatProxyRoutingConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLIProxy server configuration section.
|
||||
* Controls whether CCS uses local or remote CLIProxyAPI instance.
|
||||
@@ -799,6 +812,40 @@ export const DEFAULT_DASHBOARD_AUTH_CONFIG: DashboardAuthConfig = {
|
||||
session_timeout_hours: 24,
|
||||
};
|
||||
|
||||
/**
|
||||
* Browser automation configuration.
|
||||
* Controls Claude browser attach and Codex browser tooling.
|
||||
*/
|
||||
export interface BrowserClaudeConfig {
|
||||
/** Enable Claude browser attach (default: false) */
|
||||
enabled: boolean;
|
||||
/** Chrome user-data directory used for attach mode */
|
||||
user_data_dir: string;
|
||||
/** DevTools port used for attach mode (default: 9222) */
|
||||
devtools_port: number;
|
||||
}
|
||||
|
||||
export interface BrowserCodexConfig {
|
||||
/** Enable Codex browser tooling injection (default: true) */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface BrowserConfig {
|
||||
claude: BrowserClaudeConfig;
|
||||
codex: BrowserCodexConfig;
|
||||
}
|
||||
|
||||
export const DEFAULT_BROWSER_CONFIG: BrowserConfig = {
|
||||
claude: {
|
||||
enabled: false,
|
||||
user_data_dir: '',
|
||||
devtools_port: 9222,
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Image analysis configuration.
|
||||
* Routes image/PDF files through CLIProxy for vision analysis.
|
||||
@@ -856,6 +903,8 @@ export interface UnifiedConfig {
|
||||
profiles: Record<string, ProfileConfig>;
|
||||
/** CLIProxy configuration */
|
||||
cliproxy: CLIProxyConfig;
|
||||
/** OpenAI-compatible local proxy configuration */
|
||||
proxy?: OpenAICompatProxyConfig;
|
||||
/** CCS-owned structured logging configuration */
|
||||
logging?: LoggingConfig;
|
||||
/** User preferences */
|
||||
@@ -880,6 +929,8 @@ export interface UnifiedConfig {
|
||||
channels?: OfficialChannelsConfig;
|
||||
/** Dashboard authentication configuration (optional) */
|
||||
dashboard_auth?: DashboardAuthConfig;
|
||||
/** Browser automation configuration */
|
||||
browser?: BrowserConfig;
|
||||
/** Image analysis configuration (vision via CLIProxy) */
|
||||
image_analysis?: ImageAnalysisConfig;
|
||||
}
|
||||
@@ -934,6 +985,12 @@ export const DEFAULT_CLIPROXY_SERVER_CONFIG: CliproxyServerConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
export const DEFAULT_OPENAI_COMPAT_PROXY_CONFIG: OpenAICompatProxyConfig = {
|
||||
routing: {
|
||||
longContextThreshold: 60_000,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an empty unified config with defaults.
|
||||
*/
|
||||
@@ -958,6 +1015,11 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
|
||||
strategy: 'round-robin',
|
||||
},
|
||||
},
|
||||
proxy: {
|
||||
routing: {
|
||||
...DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.routing,
|
||||
},
|
||||
},
|
||||
logging: { ...DEFAULT_LOGGING_CONFIG },
|
||||
preferences: {
|
||||
theme: 'system',
|
||||
@@ -1015,6 +1077,10 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
|
||||
thinking: { ...DEFAULT_THINKING_CONFIG },
|
||||
channels: { ...DEFAULT_OFFICIAL_CHANNELS_CONFIG },
|
||||
dashboard_auth: { ...DEFAULT_DASHBOARD_AUTH_CONFIG },
|
||||
browser: {
|
||||
claude: { ...DEFAULT_BROWSER_CONFIG.claude },
|
||||
codex: { ...DEFAULT_BROWSER_CONFIG.codex },
|
||||
},
|
||||
image_analysis: { ...DEFAULT_IMAGE_ANALYSIS_CONFIG },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const LEGACY_CURSOR_PROFILE_NAME = 'legacy-cursor';
|
||||
|
||||
export const CURSOR_SUBCOMMANDS = [
|
||||
'auth',
|
||||
'status',
|
||||
@@ -12,8 +14,35 @@ export const CURSOR_SUBCOMMANDS = [
|
||||
'-h',
|
||||
] as const;
|
||||
|
||||
export const CURSOR_CLIPROXY_SHORTCUT_FLAGS = new Set([
|
||||
'--auth',
|
||||
'--logout',
|
||||
'--config',
|
||||
'--accounts',
|
||||
]);
|
||||
|
||||
export function isCursorSubcommandToken(token?: string): boolean {
|
||||
return (
|
||||
Boolean(token) && CURSOR_SUBCOMMANDS.includes(token as (typeof CURSOR_SUBCOMMANDS)[number])
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldUseCursorCliproxyShortcut(args: string[]): boolean {
|
||||
if (args[0] !== 'cursor') {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const token of args.slice(1)) {
|
||||
if (token === '--') {
|
||||
break;
|
||||
}
|
||||
if (CURSOR_CLIPROXY_SHORTCUT_FLAGS.has(token)) {
|
||||
return true;
|
||||
}
|
||||
if (!token.startsWith('-')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,249 +1,4 @@
|
||||
import { DeltaAccumulator } from '../glmt/delta-accumulator';
|
||||
import { GlmtTransformer } from '../glmt/glmt-transformer';
|
||||
import { SSEParser } from '../glmt/sse-parser';
|
||||
import type { OpenAIResponse, SSEEvent } from '../glmt/pipeline';
|
||||
|
||||
const JSON_TRANSLATION_ERROR_MESSAGE = 'Failed to translate Cursor JSON response';
|
||||
const STREAM_TRANSLATION_ERROR_MESSAGE = 'Failed to translate Cursor SSE response';
|
||||
type ResponseHeaders = Headers | Record<string, string> | Array<[string, string]>;
|
||||
|
||||
interface AnthropicErrorPayload {
|
||||
type: 'error';
|
||||
error: {
|
||||
type: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
function createAnthropicErrorPayload(type: string, message: string): AnthropicErrorPayload {
|
||||
return {
|
||||
type: 'error',
|
||||
error: {
|
||||
type,
|
||||
message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatErrorForLog(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
|
||||
function logTranslationError(context: string, error: unknown): void {
|
||||
console.error(`[cursor-anthropic-response] ${context}: ${formatErrorForLog(error)}`);
|
||||
}
|
||||
|
||||
export function createAnthropicErrorResponse(
|
||||
status: number,
|
||||
type: string,
|
||||
message: string,
|
||||
headers?: ResponseHeaders
|
||||
): Response {
|
||||
const responseHeaders = new Headers(headers);
|
||||
responseHeaders.set('Content-Type', 'application/json');
|
||||
responseHeaders.delete('Content-Length');
|
||||
|
||||
return new Response(JSON.stringify(createAnthropicErrorPayload(type, message)), {
|
||||
status,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
function formatSseEvent(event: string, data: unknown): string {
|
||||
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
}
|
||||
|
||||
function hasTranslatableChoices(value: unknown): value is OpenAIResponse {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { choices } = value as OpenAIResponse;
|
||||
if (!Array.isArray(choices) || choices.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const firstChoice = choices[0];
|
||||
if (typeof firstChoice !== 'object' || firstChoice === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = (firstChoice as { message?: unknown }).message;
|
||||
return typeof message === 'object' && message !== null;
|
||||
}
|
||||
|
||||
function isSyntheticTransformationFallback(value: unknown): boolean {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
typeof (value as { id?: unknown }).id === 'string' &&
|
||||
(value as { id: string }).id.startsWith('msg_error_')
|
||||
);
|
||||
}
|
||||
|
||||
async function createAnthropicErrorProxyResponse(response: Response): Promise<Response> {
|
||||
const headers = new Headers(response.headers);
|
||||
headers.delete('Content-Type');
|
||||
headers.delete('Content-Length');
|
||||
|
||||
let type =
|
||||
response.status === 401
|
||||
? 'authentication_error'
|
||||
: response.status === 429
|
||||
? 'rate_limit_error'
|
||||
: response.status >= 400 && response.status < 500
|
||||
? 'invalid_request_error'
|
||||
: 'api_error';
|
||||
let message = `Cursor request failed with status ${response.status}`;
|
||||
|
||||
try {
|
||||
const contentType = (response.headers.get('content-type') || '').toLowerCase();
|
||||
if (contentType.includes('application/json')) {
|
||||
const payload = (await response.json()) as {
|
||||
error?: { type?: string; message?: string };
|
||||
message?: string;
|
||||
};
|
||||
|
||||
if (typeof payload?.error?.type === 'string' && payload.error.type.trim().length > 0) {
|
||||
type = payload.error.type;
|
||||
}
|
||||
|
||||
if (typeof payload?.error?.message === 'string' && payload.error.message.trim().length > 0) {
|
||||
message = payload.error.message;
|
||||
} else if (typeof payload?.message === 'string' && payload.message.trim().length > 0) {
|
||||
message = payload.message;
|
||||
}
|
||||
} else {
|
||||
const text = (await response.text()).trim();
|
||||
if (text.length > 0) {
|
||||
message = text;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logTranslationError('Failed to parse Cursor error response', error);
|
||||
}
|
||||
|
||||
return createAnthropicErrorResponse(response.status, type, message, headers);
|
||||
}
|
||||
|
||||
async function createAnthropicJsonResponse(response: Response): Promise<Response> {
|
||||
try {
|
||||
const openAiResponse = await response.json();
|
||||
if (!hasTranslatableChoices(openAiResponse)) {
|
||||
return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE);
|
||||
}
|
||||
|
||||
const anthropicResponse = new GlmtTransformer().transformResponse(openAiResponse);
|
||||
if (isSyntheticTransformationFallback(anthropicResponse)) {
|
||||
logTranslationError(
|
||||
'Cursor JSON translation produced synthetic fallback response',
|
||||
anthropicResponse
|
||||
);
|
||||
return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(anthropicResponse), {
|
||||
status: response.status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
logTranslationError('Cursor JSON translation failed', error);
|
||||
return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
function createAnthropicStreamingResponse(response: Response): Response {
|
||||
const body = response.body;
|
||||
if (!body) {
|
||||
return createAnthropicErrorResponse(
|
||||
502,
|
||||
'api_error',
|
||||
'Cursor stream ended before a response body was available'
|
||||
);
|
||||
}
|
||||
|
||||
const parser = new SSEParser({ throwOnMalformedJson: true });
|
||||
const transformer = new GlmtTransformer();
|
||||
const accumulator = new DeltaAccumulator({});
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const readable = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const reader = body.getReader();
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const events = parser.parse(Buffer.from(value));
|
||||
events.forEach((event) => {
|
||||
const anthropicEvents = transformer.transformDelta(event as SSEEvent, accumulator);
|
||||
anthropicEvents.forEach((anthropicEvent) => {
|
||||
controller.enqueue(
|
||||
encoder.encode(formatSseEvent(anthropicEvent.event, anthropicEvent.data))
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (!accumulator.isFinalized() && accumulator.isMessageStarted()) {
|
||||
transformer.finalizeDelta(accumulator).forEach((anthropicEvent) => {
|
||||
controller.enqueue(
|
||||
encoder.encode(formatSseEvent(anthropicEvent.event, anthropicEvent.data))
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logTranslationError('Cursor SSE translation failed', error);
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
formatSseEvent(
|
||||
'error',
|
||||
createAnthropicErrorPayload('api_error', STREAM_TRANSLATION_ERROR_MESSAGE)
|
||||
)
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(readable, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createAnthropicProxyResponse(response: Response): Promise<Response> {
|
||||
if (!response.ok) {
|
||||
return createAnthropicErrorProxyResponse(response);
|
||||
}
|
||||
|
||||
const contentType = (response.headers.get('content-type') || '').toLowerCase();
|
||||
const isEventStream =
|
||||
contentType === 'text/event-stream' || contentType.startsWith('text/event-stream;');
|
||||
|
||||
return isEventStream
|
||||
? createAnthropicStreamingResponse(response)
|
||||
: createAnthropicJsonResponse(response);
|
||||
}
|
||||
export {
|
||||
createAnthropicErrorResponse,
|
||||
createAnthropicProxyResponse,
|
||||
} from '../proxy/transformers/sse-stream-transformer';
|
||||
@@ -248,7 +248,7 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser
|
||||
|
||||
const authStatus = checkAuthStatus();
|
||||
if (!authStatus.authenticated || !authStatus.credentials) {
|
||||
const message = 'Cursor credentials not found. Run `ccs cursor auth` first.';
|
||||
const message = 'Cursor credentials not found. Run `ccs legacy cursor auth` first.';
|
||||
if (isAnthropicRoute) {
|
||||
await pipeWebResponseToNode(
|
||||
createAnthropicErrorResponse(401, 'authentication_error', message),
|
||||
@@ -266,7 +266,7 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser
|
||||
}
|
||||
|
||||
if (authStatus.expired) {
|
||||
const message = 'Cursor credentials expired. Run `ccs cursor auth` again.';
|
||||
const message = 'Cursor credentials expired. Run `ccs legacy cursor auth` again.';
|
||||
if (isAnthropicRoute) {
|
||||
await pipeWebResponseToNode(
|
||||
createAnthropicErrorResponse(401, 'authentication_error', message),
|
||||
|
||||
@@ -97,7 +97,7 @@ export async function executeCursorProfile(
|
||||
if (!config.enabled) {
|
||||
console.error(fail('Cursor integration is not enabled.'));
|
||||
console.error('');
|
||||
console.error('Enable it first: ccs cursor enable');
|
||||
console.error('Enable it first: ccs legacy cursor enable');
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -105,13 +105,13 @@ export async function executeCursorProfile(
|
||||
if (!authStatus.authenticated) {
|
||||
console.error(fail('Cursor credentials not found.'));
|
||||
console.error('');
|
||||
console.error('Authenticate first: ccs cursor auth');
|
||||
console.error('Authenticate first: ccs legacy cursor auth');
|
||||
return 1;
|
||||
}
|
||||
if (authStatus.expired) {
|
||||
console.error(fail('Cursor credentials have expired.'));
|
||||
console.error('');
|
||||
console.error('Refresh them with: ccs cursor auth');
|
||||
console.error('Refresh them with: ccs legacy cursor auth');
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ export async function executeCursorProfile(
|
||||
console.error(fail('Cursor daemon is not running.'));
|
||||
console.error('');
|
||||
console.error('Start the daemon:');
|
||||
console.error(' ccs cursor start');
|
||||
console.error(' ccs legacy cursor start');
|
||||
console.error('Or enable auto_start in the Cursor config section.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ export async function probeCursorRuntime(config: CursorConfig): Promise<CursorPr
|
||||
stage: 'auth',
|
||||
status: 401,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
message: 'Cursor credentials not found. Run `ccs cursor auth` first.',
|
||||
message: 'Cursor credentials not found. Run `ccs legacy cursor auth` first.',
|
||||
error_type: 'authentication_error',
|
||||
};
|
||||
}
|
||||
@@ -131,7 +131,7 @@ export async function probeCursorRuntime(config: CursorConfig): Promise<CursorPr
|
||||
stage: 'auth',
|
||||
status: 401,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
message: 'Cursor credentials expired. Run `ccs cursor auth` again.',
|
||||
message: 'Cursor credentials expired. Run `ccs legacy cursor auth` again.',
|
||||
error_type: 'authentication_error',
|
||||
};
|
||||
}
|
||||
@@ -168,7 +168,7 @@ export async function probeCursorRuntime(config: CursorConfig): Promise<CursorPr
|
||||
status: 503,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
message:
|
||||
'Cursor daemon is not running. Start it with `ccs cursor start` or enable auto_start.',
|
||||
'Cursor daemon is not running. Start it with `ccs legacy cursor start` or enable auto_start.',
|
||||
error_type: 'daemon_not_running',
|
||||
};
|
||||
}
|
||||
|
||||
+56
-46
@@ -49,63 +49,73 @@ export class SSEParser {
|
||||
* @returns Array of parsed events
|
||||
*/
|
||||
parse(chunk: Buffer | string): SSEEvent[] {
|
||||
this.buffer += chunk.toString();
|
||||
this.buffer += chunk.toString().replace(/\r\n?/g, '\n');
|
||||
|
||||
// C-01 Fix: Prevent unbounded buffer growth (DoS protection)
|
||||
if (this.buffer.length > this.maxBufferSize) {
|
||||
throw new Error(`SSE buffer exceeded ${this.maxBufferSize} bytes (DoS protection)`);
|
||||
}
|
||||
|
||||
const lines = this.buffer.split('\n');
|
||||
|
||||
// Keep incomplete line in buffer
|
||||
this.buffer = lines.pop() || '';
|
||||
|
||||
const events: SSEEvent[] = [];
|
||||
let currentEvent: SSEEvent = { event: 'message', data: '' };
|
||||
const segments = this.buffer.split('\n\n');
|
||||
this.buffer = segments.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
currentEvent.event = line.substring(7).trim();
|
||||
} else if (line.startsWith('data: ')) {
|
||||
const data = line.substring(6);
|
||||
for (const segment of segments) {
|
||||
const lines = segment.split('\n');
|
||||
const currentEvent: SSEEvent = { event: 'message', data: '' };
|
||||
const dataLines: string[] = [];
|
||||
|
||||
if (data === '[DONE]') {
|
||||
this.eventCount++;
|
||||
events.push({
|
||||
event: 'done',
|
||||
data: null,
|
||||
index: this.eventCount,
|
||||
});
|
||||
currentEvent = { event: 'message', data: '' };
|
||||
} else {
|
||||
try {
|
||||
currentEvent.data = JSON.parse(data);
|
||||
this.eventCount++;
|
||||
currentEvent.index = this.eventCount;
|
||||
events.push({ ...currentEvent });
|
||||
currentEvent = { event: 'message', data: '' };
|
||||
} catch (e) {
|
||||
// H-01 Fix: Log parse errors for debugging
|
||||
if (typeof console !== 'undefined' && console.error) {
|
||||
console.error(
|
||||
'[SSEParser] Malformed JSON event:',
|
||||
(e as Error).message,
|
||||
'Data:',
|
||||
data.substring(0, 100)
|
||||
);
|
||||
}
|
||||
if (this.throwOnMalformedJson) {
|
||||
throw new Error(`Malformed SSE JSON event: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trimEnd();
|
||||
if (!line || line.startsWith(':')) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('event: ')) {
|
||||
currentEvent.event = line.substring(7).trim();
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.substring(5).trimStart());
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('id: ')) {
|
||||
currentEvent.id = line.substring(4).trim();
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('retry: ')) {
|
||||
currentEvent.retry = parseInt(line.substring(7), 10);
|
||||
}
|
||||
}
|
||||
|
||||
const data = dataLines.join('\n');
|
||||
if (!data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (data === '[DONE]') {
|
||||
this.eventCount++;
|
||||
events.push({ event: 'done', data: null, index: this.eventCount });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
currentEvent.data = JSON.parse(data);
|
||||
this.eventCount++;
|
||||
currentEvent.index = this.eventCount;
|
||||
events.push({ ...currentEvent });
|
||||
} catch (e) {
|
||||
if (typeof console !== 'undefined' && console.error) {
|
||||
console.error(
|
||||
'[SSEParser] Malformed JSON event:',
|
||||
(e as Error).message,
|
||||
'Data:',
|
||||
data.substring(0, 100)
|
||||
);
|
||||
}
|
||||
if (this.throwOnMalformedJson) {
|
||||
throw new Error(`Malformed SSE JSON event: ${(e as Error).message}`);
|
||||
}
|
||||
} else if (line.startsWith('id: ')) {
|
||||
currentEvent.id = line.substring(4).trim();
|
||||
} else if (line.startsWith('retry: ')) {
|
||||
currentEvent.retry = parseInt(line.substring(7), 10);
|
||||
}
|
||||
// Empty lines separate events (already handled by JSON parsing)
|
||||
}
|
||||
|
||||
return events;
|
||||
|
||||
@@ -936,6 +936,10 @@ class SharedManager {
|
||||
}
|
||||
|
||||
for (const [name, value] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (!this.isMarketplaceRegistryEntry(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
merged[name] = normalizePluginMetadataValue(value, targetConfigDir).normalized;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -947,22 +951,20 @@ class SharedManager {
|
||||
|
||||
const discoveredEntries = this.discoverMarketplaceEntries(targetConfigDir);
|
||||
|
||||
for (const [name, value] of Object.entries(discoveredEntries)) {
|
||||
const existing = merged[name];
|
||||
if (existing && typeof existing === 'object' && !Array.isArray(existing)) {
|
||||
merged[name] = {
|
||||
...(existing as Record<string, unknown>),
|
||||
installLocation: value.installLocation,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
merged[name] = value;
|
||||
}
|
||||
|
||||
// Keep only registry entries that have a physical directory, and update their
|
||||
// installLocation. Entries only on disk (no registry record) are excluded —
|
||||
// they lack required schema fields that Claude Code enforces.
|
||||
for (const name of Object.keys(merged)) {
|
||||
const entry = merged[name];
|
||||
if (!(name in discoveredEntries)) {
|
||||
delete merged[name];
|
||||
} else if (this.isMarketplaceRegistryEntry(entry)) {
|
||||
merged[name] = {
|
||||
...entry,
|
||||
installLocation: discoveredEntries[name].installLocation,
|
||||
};
|
||||
} else {
|
||||
delete merged[name];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -984,6 +986,11 @@ class SharedManager {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip hidden dirs and Claude Code rename-dance leftovers (.staging/.bak).
|
||||
if (this.isTransientMarketplaceDirectory(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
discovered[entry.name] = {
|
||||
installLocation: path.join(targetConfigDir, 'plugins', 'marketplaces', entry.name),
|
||||
};
|
||||
@@ -992,6 +999,14 @@ class SharedManager {
|
||||
return discovered;
|
||||
}
|
||||
|
||||
private isTransientMarketplaceDirectory(name: string): boolean {
|
||||
return name.startsWith('.') || name.endsWith('.staging') || name.endsWith('.bak');
|
||||
}
|
||||
|
||||
private isMarketplaceRegistryEntry(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
private writePluginMetadataFile(
|
||||
registryPath: string,
|
||||
content: string,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from './profile-router';
|
||||
export * from './proxy-daemon';
|
||||
export * from './proxy-daemon-paths';
|
||||
export * from './proxy-env';
|
||||
export * from './upstream-url';
|
||||
export * from './transformers/request-transformer';
|
||||
export * from './transformers/sse-stream-transformer';
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
inferDroidProviderFromBaseUrl,
|
||||
resolveDroidProvider,
|
||||
type DroidProvider,
|
||||
} from '../targets/droid-provider';
|
||||
|
||||
export interface OpenAICompatProfileConfig {
|
||||
profileName: string;
|
||||
settingsPath: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
provider: DroidProvider;
|
||||
insecure?: boolean;
|
||||
model?: string;
|
||||
opusModel?: string;
|
||||
sonnetModel?: string;
|
||||
haikuModel?: string;
|
||||
}
|
||||
|
||||
export interface OpenAICompatProfileEnv {
|
||||
ANTHROPIC_BASE_URL?: string;
|
||||
ANTHROPIC_AUTH_TOKEN?: string;
|
||||
ANTHROPIC_API_KEY?: string;
|
||||
ANTHROPIC_MODEL?: string;
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL?: string;
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL?: string;
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL?: string;
|
||||
ANTHROPIC_SMALL_FAST_MODEL?: string;
|
||||
CCS_DROID_PROVIDER?: string;
|
||||
CCS_OPENAI_PROXY_INSECURE?: string;
|
||||
}
|
||||
|
||||
export function isOpenAICompatProvider(provider: DroidProvider | null): provider is DroidProvider {
|
||||
return provider === 'openai' || provider === 'generic-chat-completion-api';
|
||||
}
|
||||
|
||||
export function resolveOpenAICompatProfileConfig(
|
||||
profileName: string,
|
||||
settingsPath: string,
|
||||
env: OpenAICompatProfileEnv
|
||||
): OpenAICompatProfileConfig | null {
|
||||
const baseUrl = env.ANTHROPIC_BASE_URL?.trim() || '';
|
||||
const apiKey = env.ANTHROPIC_AUTH_TOKEN?.trim() || env.ANTHROPIC_API_KEY?.trim() || '';
|
||||
if (!baseUrl || !apiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const providerFromUrl = inferDroidProviderFromBaseUrl(baseUrl);
|
||||
const provider = isOpenAICompatProvider(providerFromUrl)
|
||||
? providerFromUrl
|
||||
: resolveDroidProvider({
|
||||
provider: env.CCS_DROID_PROVIDER,
|
||||
baseUrl,
|
||||
model: env.ANTHROPIC_MODEL,
|
||||
});
|
||||
if (!isOpenAICompatProvider(provider)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
profileName,
|
||||
settingsPath,
|
||||
baseUrl,
|
||||
apiKey,
|
||||
provider,
|
||||
insecure:
|
||||
env.CCS_OPENAI_PROXY_INSECURE === '1' ||
|
||||
env.CCS_OPENAI_PROXY_INSECURE?.toLowerCase() === 'true',
|
||||
model: env.ANTHROPIC_MODEL?.trim() || undefined,
|
||||
opusModel: env.ANTHROPIC_DEFAULT_OPUS_MODEL?.trim() || undefined,
|
||||
sonnetModel: env.ANTHROPIC_DEFAULT_SONNET_MODEL?.trim() || undefined,
|
||||
haikuModel:
|
||||
env.ANTHROPIC_DEFAULT_HAIKU_MODEL?.trim() ||
|
||||
env.ANTHROPIC_SMALL_FAST_MODEL?.trim() ||
|
||||
undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { loadSettings } from '../utils/config-manager';
|
||||
import { resolveOpenAICompatProfileConfig } from './profile-router';
|
||||
import { startOpenAICompatProxyServer } from './server/proxy-server';
|
||||
|
||||
interface RuntimeOptions {
|
||||
port: number;
|
||||
host: string;
|
||||
profileName: string;
|
||||
settingsPath: string;
|
||||
authToken: string;
|
||||
insecure: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): RuntimeOptions {
|
||||
let port = 3456;
|
||||
let host = '127.0.0.1';
|
||||
let profileName = '';
|
||||
let settingsPath = '';
|
||||
let authToken = '';
|
||||
let insecure = false;
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--port' && argv[i + 1]) {
|
||||
port = Number.parseInt(argv[++i] || '', 10) || port;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--host' && argv[i + 1]) {
|
||||
host = argv[++i] || host;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--profile' && argv[i + 1]) {
|
||||
profileName = argv[++i] || '';
|
||||
continue;
|
||||
}
|
||||
if (arg === '--settings-path' && argv[i + 1]) {
|
||||
settingsPath = argv[++i] || '';
|
||||
continue;
|
||||
}
|
||||
if (arg === '--auth-token' && argv[i + 1]) {
|
||||
authToken = argv[++i] || '';
|
||||
continue;
|
||||
}
|
||||
if (arg === '--insecure') {
|
||||
insecure = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { port, host, profileName, settingsPath, authToken, insecure };
|
||||
}
|
||||
|
||||
function startRuntime(options: RuntimeOptions): void {
|
||||
if (!options.authToken.trim()) {
|
||||
throw new Error('Missing local proxy auth token');
|
||||
}
|
||||
|
||||
const settings = loadSettings(options.settingsPath);
|
||||
const profile = resolveOpenAICompatProfileConfig(
|
||||
options.profileName,
|
||||
options.settingsPath,
|
||||
settings.env || {}
|
||||
);
|
||||
if (!profile) {
|
||||
throw new Error(
|
||||
`Profile "${options.profileName}" is not an OpenAI-compatible settings profile`
|
||||
);
|
||||
}
|
||||
|
||||
const server = startOpenAICompatProxyServer({
|
||||
profile,
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
authToken: options.authToken,
|
||||
insecure: options.insecure,
|
||||
});
|
||||
server.once('error', (error) => {
|
||||
console.error((error as Error).message);
|
||||
process.exit(1);
|
||||
});
|
||||
const shutdown = () => server.close();
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
startRuntime(parseArgs(process.argv.slice(2)));
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as path from 'path';
|
||||
import { getCcsDir } from '../utils/config-manager';
|
||||
|
||||
export const OPENAI_COMPAT_PROXY_DEFAULT_PORT = 3456;
|
||||
export const OPENAI_COMPAT_PROXY_SERVICE_NAME = 'ccs-openai-compat-proxy';
|
||||
|
||||
export function getOpenAICompatProxyDir(): string {
|
||||
return path.join(getCcsDir(), 'proxy');
|
||||
}
|
||||
|
||||
export function getOpenAICompatProxyPidPath(): string {
|
||||
return path.join(getOpenAICompatProxyDir(), 'daemon.pid');
|
||||
}
|
||||
|
||||
export function getOpenAICompatProxySessionPath(): string {
|
||||
return path.join(getOpenAICompatProxyDir(), 'session.json');
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
getOpenAICompatProxyDir,
|
||||
getOpenAICompatProxyPidPath,
|
||||
getOpenAICompatProxySessionPath,
|
||||
} from './proxy-daemon-paths';
|
||||
|
||||
export interface OpenAICompatProxySession {
|
||||
profileName: string;
|
||||
settingsPath: string;
|
||||
host: string;
|
||||
port: number;
|
||||
baseUrl: string;
|
||||
authToken: string;
|
||||
model?: string;
|
||||
insecure?: boolean;
|
||||
}
|
||||
|
||||
function ensureProxyDir(): void {
|
||||
fs.mkdirSync(getOpenAICompatProxyDir(), { recursive: true });
|
||||
}
|
||||
|
||||
export function getOpenAICompatProxyPid(): number | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(getOpenAICompatProxyPidPath(), 'utf8').trim();
|
||||
const pid = Number.parseInt(raw, 10);
|
||||
return Number.isInteger(pid) ? pid : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeOpenAICompatProxyPid(pid: number): void {
|
||||
ensureProxyDir();
|
||||
fs.writeFileSync(getOpenAICompatProxyPidPath(), String(pid), 'utf8');
|
||||
}
|
||||
|
||||
export function removeOpenAICompatProxyPid(): void {
|
||||
try {
|
||||
fs.unlinkSync(getOpenAICompatProxyPidPath());
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
export function readOpenAICompatProxySession(): OpenAICompatProxySession | null {
|
||||
try {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(getOpenAICompatProxySessionPath(), 'utf8')
|
||||
) as OpenAICompatProxySession;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeOpenAICompatProxySession(session: OpenAICompatProxySession): void {
|
||||
ensureProxyDir();
|
||||
fs.writeFileSync(
|
||||
getOpenAICompatProxySessionPath(),
|
||||
JSON.stringify(session, null, 2) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
export function removeOpenAICompatProxySession(): void {
|
||||
try {
|
||||
fs.unlinkSync(getOpenAICompatProxySessionPath());
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveOpenAICompatProxyEntrypointCandidates(): string[] {
|
||||
const jsEntry = path.join(__dirname, 'proxy-daemon-entry.js');
|
||||
const tsEntry = path.join(__dirname, 'proxy-daemon-entry.ts');
|
||||
const isBunRuntime = process.execPath.toLowerCase().includes('bun');
|
||||
const runningFromDist = __filename.endsWith('.js');
|
||||
if (runningFromDist) {
|
||||
return [jsEntry];
|
||||
}
|
||||
return isBunRuntime ? [tsEntry, jsEntry] : [jsEntry];
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import * as crypto from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as http from 'http';
|
||||
import * as net from 'net';
|
||||
import * as lockfile from 'proper-lockfile';
|
||||
import { verifyProcessOwnership } from '../cursor/daemon-process-ownership';
|
||||
import type { OpenAICompatProfileConfig } from './profile-router';
|
||||
import {
|
||||
OPENAI_COMPAT_PROXY_DEFAULT_PORT,
|
||||
OPENAI_COMPAT_PROXY_SERVICE_NAME,
|
||||
getOpenAICompatProxyDir,
|
||||
} from './proxy-daemon-paths';
|
||||
import {
|
||||
getOpenAICompatProxyPid,
|
||||
readOpenAICompatProxySession,
|
||||
removeOpenAICompatProxyPid,
|
||||
removeOpenAICompatProxySession,
|
||||
resolveOpenAICompatProxyEntrypointCandidates,
|
||||
type OpenAICompatProxySession,
|
||||
writeOpenAICompatProxyPid,
|
||||
writeOpenAICompatProxySession,
|
||||
} from './proxy-daemon-state';
|
||||
|
||||
export interface OpenAICompatProxyStatus extends Partial<OpenAICompatProxySession> {
|
||||
running: boolean;
|
||||
pid?: number;
|
||||
}
|
||||
|
||||
export interface StartOpenAICompatProxyResult {
|
||||
success: boolean;
|
||||
alreadyRunning?: boolean;
|
||||
authToken?: string;
|
||||
pid?: number;
|
||||
port: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function generateProxyAuthToken(): string {
|
||||
return crypto.randomBytes(24).toString('hex');
|
||||
}
|
||||
|
||||
async function withOpenAICompatProxyLock<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const proxyDir = getOpenAICompatProxyDir();
|
||||
await fs.promises.mkdir(proxyDir, { recursive: true });
|
||||
|
||||
let release: (() => Promise<void>) | undefined;
|
||||
try {
|
||||
release = await lockfile.lock(proxyDir, {
|
||||
stale: 10000,
|
||||
retries: { retries: 20, minTimeout: 50, maxTimeout: 250 },
|
||||
realpath: false,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to lock OpenAI-compatible proxy directory (${proxyDir}): ${(error as Error).message}`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
if (release) {
|
||||
try {
|
||||
await release();
|
||||
} catch {
|
||||
// Best-effort release.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function isPortOccupied(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.createConnection({ host: '127.0.0.1', port });
|
||||
const finish = (occupied: boolean) => {
|
||||
socket.removeAllListeners();
|
||||
socket.destroy();
|
||||
resolve(occupied);
|
||||
};
|
||||
|
||||
socket.once('connect', () => finish(true));
|
||||
socket.once('error', () => finish(false));
|
||||
socket.setTimeout(500, () => {
|
||||
finish(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function findOpenAICompatProxyPort(): Promise<number> {
|
||||
for (
|
||||
let candidate = OPENAI_COMPAT_PROXY_DEFAULT_PORT;
|
||||
candidate <= OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10;
|
||||
candidate += 1
|
||||
) {
|
||||
if (!(await isPortOccupied(candidate))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function resolveDaemonEntrypoint(): Promise<string | null> {
|
||||
for (const candidate of resolveOpenAICompatProxyEntrypointCandidates()) {
|
||||
try {
|
||||
await fs.promises.access(candidate, fs.constants.R_OK);
|
||||
return candidate;
|
||||
} catch {
|
||||
// Try next candidate.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function isOpenAICompatProxyRunning(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.request(
|
||||
{ hostname: '127.0.0.1', port, path: '/health', method: 'GET', timeout: 3000 },
|
||||
(res) => {
|
||||
let body = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk) => (body += chunk));
|
||||
res.on('end', () => {
|
||||
if (res.statusCode !== 200) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(body) as { service?: string };
|
||||
resolve(payload.service === OPENAI_COMPAT_PROXY_SERVICE_NAME);
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on('error', () => resolve(false));
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOpenAICompatProxyStatus(): Promise<OpenAICompatProxyStatus> {
|
||||
const session = readOpenAICompatProxySession();
|
||||
const port = session?.port ?? OPENAI_COMPAT_PROXY_DEFAULT_PORT;
|
||||
const running = await isOpenAICompatProxyRunning(port);
|
||||
return {
|
||||
running,
|
||||
pid: running ? getOpenAICompatProxyPid() || undefined : undefined,
|
||||
...session,
|
||||
};
|
||||
}
|
||||
|
||||
async function stopOpenAICompatProxyUnlocked(): Promise<{ success: boolean; error?: string }> {
|
||||
const pid = getOpenAICompatProxyPid();
|
||||
if (!pid) {
|
||||
removeOpenAICompatProxySession();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const ownership = verifyProcessOwnership(
|
||||
pid,
|
||||
(commandLine) =>
|
||||
commandLine.includes('--ccs-openai-proxy-daemon') &&
|
||||
commandLine.includes('proxy-daemon-entry')
|
||||
);
|
||||
|
||||
if (ownership === 'not-owned') {
|
||||
removeOpenAICompatProxyPid();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (ownership === 'unknown') {
|
||||
return {
|
||||
success: false,
|
||||
error: `Refusing to stop PID ${pid}: unable to verify daemon ownership`,
|
||||
};
|
||||
}
|
||||
|
||||
if (ownership === 'not-running') {
|
||||
removeOpenAICompatProxyPid();
|
||||
removeOpenAICompatProxySession();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
let attempts = 0;
|
||||
while (attempts < 10) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
attempts += 1;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (attempts >= 10) {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {
|
||||
// Already exited.
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err.code !== 'ESRCH') {
|
||||
return { success: false, error: `Failed to stop daemon: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
removeOpenAICompatProxyPid();
|
||||
removeOpenAICompatProxySession();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function stopOpenAICompatProxy(): Promise<{ success: boolean; error?: string }> {
|
||||
return withOpenAICompatProxyLock(() => stopOpenAICompatProxyUnlocked());
|
||||
}
|
||||
|
||||
export async function startOpenAICompatProxy(
|
||||
profile: OpenAICompatProfileConfig,
|
||||
options: { port?: number; host?: string; insecure?: boolean } = {}
|
||||
): Promise<StartOpenAICompatProxyResult> {
|
||||
return withOpenAICompatProxyLock(async () => {
|
||||
const status = await getOpenAICompatProxyStatus();
|
||||
const host = options.host?.trim() || status.host || '127.0.0.1';
|
||||
const port =
|
||||
typeof options.port === 'number'
|
||||
? options.port
|
||||
: status.running && status.profileName === profile.profileName && status.port
|
||||
? status.port
|
||||
: await findOpenAICompatProxyPort();
|
||||
if (port === 0) {
|
||||
return {
|
||||
success: false,
|
||||
port: OPENAI_COMPAT_PROXY_DEFAULT_PORT,
|
||||
error: `No free proxy port found in range ${OPENAI_COMPAT_PROXY_DEFAULT_PORT}-${OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10}`,
|
||||
};
|
||||
}
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
return { success: false, port, error: `Invalid port: ${port}` };
|
||||
}
|
||||
if (
|
||||
status.running &&
|
||||
status.profileName === profile.profileName &&
|
||||
status.port === port &&
|
||||
(status.host || '127.0.0.1') === host
|
||||
) {
|
||||
return {
|
||||
success: true,
|
||||
alreadyRunning: true,
|
||||
pid: status.pid,
|
||||
port,
|
||||
authToken: status.authToken,
|
||||
};
|
||||
}
|
||||
if (status.running) {
|
||||
if (status.profileName !== profile.profileName) {
|
||||
return {
|
||||
success: false,
|
||||
port,
|
||||
error: `Proxy already running for profile "${status.profileName}" on port ${status.port}. Stop it before starting a different profile.`,
|
||||
};
|
||||
}
|
||||
|
||||
const stopped = await stopOpenAICompatProxyUnlocked();
|
||||
if (!stopped.success) {
|
||||
return {
|
||||
success: false,
|
||||
port,
|
||||
error: stopped.error || 'Failed to restart the running proxy',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const daemonEntry = await resolveDaemonEntrypoint();
|
||||
if (!daemonEntry) {
|
||||
return {
|
||||
success: false,
|
||||
port,
|
||||
error: 'OpenAI proxy daemon entrypoint not found. Run `bun run build` and retry.',
|
||||
};
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
const authToken = generateProxyAuthToken();
|
||||
|
||||
const finish = (result: StartOpenAICompatProxyResult) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
if (!result.success) {
|
||||
removeOpenAICompatProxyPid();
|
||||
removeOpenAICompatProxySession();
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const proc: ChildProcess = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
daemonEntry,
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
host,
|
||||
'--profile',
|
||||
profile.profileName,
|
||||
'--settings-path',
|
||||
profile.settingsPath,
|
||||
'--auth-token',
|
||||
authToken,
|
||||
...(options.insecure ? ['--insecure'] : []),
|
||||
'--ccs-openai-proxy-daemon',
|
||||
],
|
||||
{ stdio: 'ignore', detached: true }
|
||||
);
|
||||
|
||||
proc.unref();
|
||||
if (proc.pid) {
|
||||
writeOpenAICompatProxyPid(proc.pid);
|
||||
}
|
||||
writeOpenAICompatProxySession({
|
||||
profileName: profile.profileName,
|
||||
settingsPath: profile.settingsPath,
|
||||
host,
|
||||
port,
|
||||
baseUrl: profile.baseUrl,
|
||||
authToken,
|
||||
model: profile.model,
|
||||
insecure: options.insecure,
|
||||
});
|
||||
|
||||
let attempts = 0;
|
||||
const poll = async () => {
|
||||
attempts += 1;
|
||||
if (await isOpenAICompatProxyRunning(port)) {
|
||||
finish({ success: true, pid: proc.pid, port, authToken });
|
||||
return;
|
||||
}
|
||||
if (attempts >= 30) {
|
||||
finish({
|
||||
success: false,
|
||||
port,
|
||||
error: `Proxy daemon did not start within 30 seconds on port ${port}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
timeout = setTimeout(poll, 1000);
|
||||
};
|
||||
|
||||
timeout = setTimeout(poll, 1000);
|
||||
proc.on('error', (error) => {
|
||||
finish({ success: false, port, error: error.message });
|
||||
});
|
||||
proc.on('exit', (code, signal) => {
|
||||
if (code === 0) {
|
||||
finish({
|
||||
success: false,
|
||||
port,
|
||||
error: 'Proxy daemon exited before becoming healthy',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (code !== null) {
|
||||
finish({
|
||||
success: false,
|
||||
port,
|
||||
error: `Proxy daemon exited with code ${code}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
finish({
|
||||
success: false,
|
||||
port,
|
||||
error: `Proxy daemon was killed by signal ${signal}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { OpenAICompatProfileConfig } from './profile-router';
|
||||
|
||||
export function buildOpenAICompatProxyEnv(
|
||||
profile: OpenAICompatProfileConfig,
|
||||
port: number,
|
||||
authToken: string,
|
||||
claudeConfigDir?: string,
|
||||
host = '127.0.0.1'
|
||||
): Record<string, string> {
|
||||
const localBaseUrl = `http://${host}:${port}`;
|
||||
return {
|
||||
ANTHROPIC_BASE_URL: localBaseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: authToken,
|
||||
DISABLE_TELEMETRY: '1',
|
||||
DISABLE_COST_WARNINGS: '1',
|
||||
API_TIMEOUT_MS: '600000',
|
||||
NO_PROXY: host === '127.0.0.1' ? '127.0.0.1,localhost' : `${host},127.0.0.1,localhost`,
|
||||
...(profile.model ? { ANTHROPIC_MODEL: profile.model } : {}),
|
||||
...(profile.opusModel ? { ANTHROPIC_DEFAULT_OPUS_MODEL: profile.opusModel } : {}),
|
||||
...(profile.sonnetModel ? { ANTHROPIC_DEFAULT_SONNET_MODEL: profile.sonnetModel } : {}),
|
||||
...(profile.haikuModel
|
||||
? {
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: profile.haikuModel,
|
||||
ANTHROPIC_SMALL_FAST_MODEL: profile.haikuModel,
|
||||
}
|
||||
: {}),
|
||||
...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { loadConfigSafe, loadSettings } from '../utils/config-manager';
|
||||
import { expandPath } from '../utils/helpers';
|
||||
import type { ProxyOpenAIRequest } from './transformers/request-transformer';
|
||||
import {
|
||||
loadOpenAICompatProxyRoutingConfig,
|
||||
type OpenAICompatProxyRoutingConfig,
|
||||
} from './routing-config';
|
||||
import { resolveOpenAICompatProfileConfig, type OpenAICompatProfileConfig } from './profile-router';
|
||||
|
||||
export type ProxyRoutingScenario = 'default' | 'background' | 'think' | 'longContext' | 'webSearch';
|
||||
|
||||
export interface ProxyRequestRoute {
|
||||
profile: OpenAICompatProfileConfig;
|
||||
model?: string;
|
||||
scenario?: ProxyRoutingScenario;
|
||||
estimatedTokens: number;
|
||||
source:
|
||||
| 'explicit-profile'
|
||||
| 'scenario'
|
||||
| 'profile-model-match'
|
||||
| 'profile-name'
|
||||
| 'request-model'
|
||||
| 'active-default';
|
||||
}
|
||||
|
||||
function loadOpenAICompatProfiles(
|
||||
activeProfile: OpenAICompatProfileConfig
|
||||
): OpenAICompatProfileConfig[] {
|
||||
const config = loadConfigSafe();
|
||||
const profiles = [activeProfile];
|
||||
|
||||
for (const [profileName, settingsPath] of Object.entries(config.profiles)) {
|
||||
if (profileName === activeProfile.profileName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const expandedPath = expandPath(settingsPath);
|
||||
const settings = loadSettings(expandedPath);
|
||||
const profile = resolveOpenAICompatProfileConfig(
|
||||
profileName,
|
||||
expandedPath,
|
||||
settings.env || {}
|
||||
);
|
||||
if (profile) {
|
||||
profiles.push(profile);
|
||||
}
|
||||
} catch {
|
||||
// Ignore invalid profiles while routing a live request.
|
||||
}
|
||||
}
|
||||
|
||||
return profiles;
|
||||
}
|
||||
|
||||
function resolveSelectorTarget(
|
||||
selector: string,
|
||||
activeProfile: OpenAICompatProfileConfig,
|
||||
profiles: OpenAICompatProfileConfig[]
|
||||
): { profile: OpenAICompatProfileConfig; model?: string; explicitProfile: boolean } | null {
|
||||
const trimmed = selector.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const colonIndex = trimmed.indexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
const profileName = trimmed.slice(0, colonIndex).trim();
|
||||
const profile = profiles.find((candidate) => candidate.profileName === profileName);
|
||||
if (profile) {
|
||||
const model = trimmed.slice(colonIndex + 1).trim() || profile.model;
|
||||
return { profile, model, explicitProfile: true };
|
||||
}
|
||||
}
|
||||
|
||||
const namedProfile = profiles.find((candidate) => candidate.profileName === trimmed);
|
||||
if (namedProfile) {
|
||||
return { profile: namedProfile, model: namedProfile.model, explicitProfile: false };
|
||||
}
|
||||
|
||||
return {
|
||||
profile: activeProfile,
|
||||
model: trimmed,
|
||||
explicitProfile: false,
|
||||
};
|
||||
}
|
||||
|
||||
function profileSupportsModel(profile: OpenAICompatProfileConfig, model: string): boolean {
|
||||
return [profile.model, profile.opusModel, profile.sonnetModel, profile.haikuModel].some(
|
||||
(candidate) => typeof candidate === 'string' && candidate === model
|
||||
);
|
||||
}
|
||||
|
||||
function estimateTokens(request: ProxyOpenAIRequest): number {
|
||||
let characters = 0;
|
||||
for (const message of request.messages) {
|
||||
if (typeof message.content === 'string') {
|
||||
characters += message.content.length;
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(message.content)) {
|
||||
for (const part of message.content) {
|
||||
characters += part.type === 'text' ? part.text.length : part.image_url.url.length;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(message.tool_calls)) {
|
||||
for (const toolCall of message.tool_calls) {
|
||||
characters += toolCall.function.name.length + toolCall.function.arguments.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(request.tools)) {
|
||||
characters += JSON.stringify(request.tools).length;
|
||||
}
|
||||
|
||||
return Math.max(1, Math.ceil(characters / 4));
|
||||
}
|
||||
|
||||
function detectScenario(
|
||||
request: ProxyOpenAIRequest,
|
||||
requestedModel: string | undefined,
|
||||
routing: OpenAICompatProxyRoutingConfig
|
||||
): { scenario?: ProxyRoutingScenario; selector?: string; estimatedTokens: number } {
|
||||
const estimatedTokens = estimateTokens(request);
|
||||
const hasWebSearchTool =
|
||||
request.tools?.some((tool) => tool.function.name === 'web_search') === true;
|
||||
const thinkingEnabled =
|
||||
request.reasoning?.enabled === true || typeof request.reasoning_effort === 'string';
|
||||
const modelId = requestedModel || '';
|
||||
const longContextThreshold = routing.longContextThreshold ?? 60_000;
|
||||
|
||||
if (hasWebSearchTool && routing.webSearch) {
|
||||
return { scenario: 'webSearch', selector: routing.webSearch, estimatedTokens };
|
||||
}
|
||||
if (thinkingEnabled && routing.think) {
|
||||
return { scenario: 'think', selector: routing.think, estimatedTokens };
|
||||
}
|
||||
if (estimatedTokens > longContextThreshold && routing.longContext) {
|
||||
return { scenario: 'longContext', selector: routing.longContext, estimatedTokens };
|
||||
}
|
||||
if (modelId.toLowerCase().includes('haiku') && routing.background) {
|
||||
return { scenario: 'background', selector: routing.background, estimatedTokens };
|
||||
}
|
||||
if (!requestedModel && routing.default) {
|
||||
return { scenario: 'default', selector: routing.default, estimatedTokens };
|
||||
}
|
||||
|
||||
return { estimatedTokens };
|
||||
}
|
||||
|
||||
export function resolveProxyRequestRoute(
|
||||
activeProfile: OpenAICompatProfileConfig,
|
||||
request: ProxyOpenAIRequest
|
||||
): ProxyRequestRoute {
|
||||
const profiles = loadOpenAICompatProfiles(activeProfile);
|
||||
const requestedModel = request.model?.trim() || undefined;
|
||||
const explicitTarget = requestedModel
|
||||
? resolveSelectorTarget(requestedModel, activeProfile, profiles)
|
||||
: null;
|
||||
const routing = loadOpenAICompatProxyRoutingConfig();
|
||||
|
||||
if (explicitTarget?.explicitProfile) {
|
||||
return {
|
||||
profile: explicitTarget.profile,
|
||||
model: explicitTarget.model,
|
||||
estimatedTokens: estimateTokens(request),
|
||||
source: 'explicit-profile',
|
||||
};
|
||||
}
|
||||
|
||||
const scenario = detectScenario(request, requestedModel, routing);
|
||||
if (scenario.selector) {
|
||||
const scenarioTarget = resolveSelectorTarget(scenario.selector, activeProfile, profiles);
|
||||
if (scenarioTarget) {
|
||||
return {
|
||||
profile: scenarioTarget.profile,
|
||||
model: scenarioTarget.model,
|
||||
scenario: scenario.scenario,
|
||||
estimatedTokens: scenario.estimatedTokens,
|
||||
source: 'scenario',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (explicitTarget && explicitTarget.profile.profileName !== activeProfile.profileName) {
|
||||
return {
|
||||
profile: explicitTarget.profile,
|
||||
model: explicitTarget.model,
|
||||
estimatedTokens: scenario.estimatedTokens,
|
||||
source: 'profile-name',
|
||||
};
|
||||
}
|
||||
|
||||
if (requestedModel) {
|
||||
const matchedProfile = profiles.find((profile) =>
|
||||
profileSupportsModel(profile, requestedModel)
|
||||
);
|
||||
if (matchedProfile) {
|
||||
return {
|
||||
profile: matchedProfile,
|
||||
model: requestedModel,
|
||||
estimatedTokens: scenario.estimatedTokens,
|
||||
source: 'profile-model-match',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
profile: activeProfile,
|
||||
model: requestedModel,
|
||||
estimatedTokens: scenario.estimatedTokens,
|
||||
source: 'request-model',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
profile: activeProfile,
|
||||
model: activeProfile.model,
|
||||
estimatedTokens: scenario.estimatedTokens,
|
||||
source: 'active-default',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import * as fs from 'fs';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { getActiveConfigPath, getConfigPath } from '../utils/config-manager';
|
||||
|
||||
export interface OpenAICompatProxyRoutingConfig {
|
||||
default?: string;
|
||||
background?: string;
|
||||
think?: string;
|
||||
longContext?: string;
|
||||
webSearch?: string;
|
||||
longContextThreshold?: number;
|
||||
}
|
||||
|
||||
function readRawConfigObject(configPath: string): Record<string, unknown> | null {
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(configPath, 'utf8');
|
||||
const parsed =
|
||||
configPath.endsWith('.yaml') || configPath.endsWith('.yml') ? yaml.load(raw) : JSON.parse(raw);
|
||||
return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function normalizeRoutingConfig(value: unknown): OpenAICompatProxyRoutingConfig {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const config = value as Record<string, unknown>;
|
||||
return {
|
||||
default:
|
||||
typeof config.default === 'string' && config.default.trim()
|
||||
? config.default.trim()
|
||||
: undefined,
|
||||
background:
|
||||
typeof config.background === 'string' && config.background.trim()
|
||||
? config.background.trim()
|
||||
: undefined,
|
||||
think:
|
||||
typeof config.think === 'string' && config.think.trim() ? config.think.trim() : undefined,
|
||||
longContext:
|
||||
typeof config.longContext === 'string' && config.longContext.trim()
|
||||
? config.longContext.trim()
|
||||
: undefined,
|
||||
webSearch:
|
||||
typeof config.webSearch === 'string' && config.webSearch.trim()
|
||||
? config.webSearch.trim()
|
||||
: undefined,
|
||||
longContextThreshold:
|
||||
typeof config.longContextThreshold === 'number' &&
|
||||
Number.isFinite(config.longContextThreshold)
|
||||
? config.longContextThreshold
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadOpenAICompatProxyRoutingConfig(): OpenAICompatProxyRoutingConfig {
|
||||
const activePath = getActiveConfigPath();
|
||||
const rawConfig = readRawConfigObject(activePath);
|
||||
if (rawConfig?.proxy && typeof rawConfig.proxy === 'object') {
|
||||
return normalizeRoutingConfig((rawConfig.proxy as Record<string, unknown>).routing);
|
||||
}
|
||||
|
||||
const legacyPath = getConfigPath();
|
||||
if (legacyPath !== activePath) {
|
||||
const legacyConfig = readRawConfigObject(legacyPath);
|
||||
if (legacyConfig?.proxy && typeof legacyConfig.proxy === 'object') {
|
||||
return normalizeRoutingConfig((legacyConfig.proxy as Record<string, unknown>).routing);
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import * as http from 'http';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
const MAX_BODY_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
export function writeJson(res: http.ServerResponse, statusCode: number, payload: unknown): void {
|
||||
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export function readJsonBody(req: http.IncomingMessage): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
let settled = false;
|
||||
|
||||
const resolveOnce = (payload: unknown) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(payload);
|
||||
}
|
||||
};
|
||||
|
||||
const rejectOnce = (error: Error) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
total += chunk.length;
|
||||
if (total > MAX_BODY_SIZE) {
|
||||
req.pause();
|
||||
rejectOnce(new Error('Request body too large (max 10MB)'));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
req.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
||||
if (!raw) {
|
||||
resolveOnce({});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
resolveOnce(JSON.parse(raw));
|
||||
} catch {
|
||||
rejectOnce(new Error('Invalid JSON in request body'));
|
||||
}
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
rejectOnce(error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function pipeWebResponseToNode(
|
||||
response: Response,
|
||||
res: http.ServerResponse
|
||||
): Promise<void> {
|
||||
res.statusCode = response.status;
|
||||
response.headers.forEach((value, key) => {
|
||||
res.setHeader(key, value);
|
||||
});
|
||||
|
||||
if (!response.body) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeStream = Readable.fromWeb(response.body as unknown as ReadableStream<Uint8Array>);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
nodeStream.on('error', reject);
|
||||
nodeStream.on('end', resolve);
|
||||
nodeStream.pipe(res);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import * as http from 'http';
|
||||
import type { Dispatcher } from 'undici';
|
||||
import type { OpenAICompatProfileConfig } from '../profile-router';
|
||||
import { resolveProxyRequestRoute } from '../request-router';
|
||||
import { ProxyRequestTransformer } from '../transformers/request-transformer';
|
||||
import { ProxySseStreamTransformer } from '../transformers/sse-stream-transformer';
|
||||
import { resolveOpenAIChatCompletionsUrl } from '../upstream-url';
|
||||
import { createLogger } from '../../services/logging';
|
||||
import { pipeWebResponseToNode, readJsonBody, writeJson } from './http-helpers';
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 600_000;
|
||||
const logger = createLogger('proxy:openai-compat:messages');
|
||||
|
||||
class ProxyInputError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ProxyInputError';
|
||||
}
|
||||
}
|
||||
|
||||
function buildUpstreamHeaders(profile: OpenAICompatProfileConfig): Record<string, string> {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${profile.apiKey}`,
|
||||
'User-Agent': 'CCS-OpenAI-Compat-Proxy/1.0',
|
||||
};
|
||||
}
|
||||
|
||||
function buildUpstreamRequest(
|
||||
profile: OpenAICompatProfileConfig,
|
||||
rawBody: unknown
|
||||
): { body: string; route: ReturnType<typeof resolveProxyRequestRoute> } {
|
||||
let transformed;
|
||||
try {
|
||||
const transformer = new ProxyRequestTransformer();
|
||||
transformed = transformer.transform(rawBody);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Invalid Anthropic request';
|
||||
throw new ProxyInputError(message);
|
||||
}
|
||||
const route = resolveProxyRequestRoute(profile, transformed);
|
||||
const body = {
|
||||
...transformed,
|
||||
model: route.model || route.profile.model,
|
||||
stream: transformed.stream === true,
|
||||
};
|
||||
return { body: JSON.stringify(body), route };
|
||||
}
|
||||
|
||||
export function extractIncomingProxyToken(headers: http.IncomingHttpHeaders): string | null {
|
||||
const xApiKey = headers['x-api-key'];
|
||||
if (typeof xApiKey === 'string' && xApiKey.trim().length > 0) {
|
||||
return xApiKey.trim();
|
||||
}
|
||||
|
||||
const anthropicApiKey = headers['anthropic-api-key'];
|
||||
if (typeof anthropicApiKey === 'string' && anthropicApiKey.trim().length > 0) {
|
||||
return anthropicApiKey.trim();
|
||||
}
|
||||
|
||||
const authHeader = headers.authorization;
|
||||
if (typeof authHeader === 'string' && authHeader.trim().length > 0) {
|
||||
const trimmed = authHeader.trim();
|
||||
const bearerPrefix = 'Bearer ';
|
||||
return trimmed.startsWith(bearerPrefix) ? trimmed.slice(bearerPrefix.length).trim() : trimmed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateIncomingProxyAuth(
|
||||
headers: http.IncomingHttpHeaders,
|
||||
expectedToken: string
|
||||
): boolean {
|
||||
return extractIncomingProxyToken(headers) === expectedToken;
|
||||
}
|
||||
|
||||
function buildFetchInit(
|
||||
profile: OpenAICompatProfileConfig,
|
||||
body: string,
|
||||
signal: AbortSignal,
|
||||
insecureDispatcher?: Dispatcher
|
||||
): RequestInit {
|
||||
const init: RequestInit = {
|
||||
method: 'POST',
|
||||
headers: buildUpstreamHeaders(profile),
|
||||
body,
|
||||
signal,
|
||||
};
|
||||
|
||||
if (insecureDispatcher) {
|
||||
(init as Record<string, unknown>).dispatcher = insecureDispatcher;
|
||||
}
|
||||
|
||||
return init;
|
||||
}
|
||||
|
||||
function getRequestTimeoutMs(): number {
|
||||
const rawValue = process.env.CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS;
|
||||
if (!rawValue) {
|
||||
return REQUEST_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(rawValue, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : REQUEST_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
function formatTimeoutDuration(timeoutMs: number): string {
|
||||
return timeoutMs % 1000 === 0 ? `${timeoutMs / 1000} seconds` : `${timeoutMs}ms`;
|
||||
}
|
||||
|
||||
function registerOnceListener(
|
||||
emitter: NodeJS.EventEmitter | null | undefined,
|
||||
event: string,
|
||||
handler: () => void
|
||||
): () => void {
|
||||
if (!emitter) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
emitter.once(event, handler);
|
||||
return () => {
|
||||
emitter.removeListener(event, handler);
|
||||
};
|
||||
}
|
||||
|
||||
export function attachDisconnectAbortHandlers(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
controller: AbortController,
|
||||
onDisconnect: (source: string) => void
|
||||
): () => void {
|
||||
const abortOnDisconnect = (source: string) => {
|
||||
if (!controller.signal.aborted && !res.writableEnded) {
|
||||
onDisconnect(source);
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupFns = [
|
||||
registerOnceListener(req, 'aborted', () => abortOnDisconnect('req.aborted')),
|
||||
registerOnceListener(req, 'close', () => abortOnDisconnect('req.close')),
|
||||
registerOnceListener(req.socket, 'close', () => abortOnDisconnect('req.socket.close')),
|
||||
registerOnceListener(res, 'close', () => abortOnDisconnect('res.close')),
|
||||
registerOnceListener(res.socket, 'close', () => abortOnDisconnect('res.socket.close')),
|
||||
];
|
||||
|
||||
const disconnectPoll = setInterval(() => {
|
||||
if (
|
||||
req.destroyed ||
|
||||
res.destroyed ||
|
||||
req.socket?.destroyed === true ||
|
||||
res.socket?.destroyed === true
|
||||
) {
|
||||
abortOnDisconnect('poll.destroyed');
|
||||
}
|
||||
}, 50);
|
||||
|
||||
return () => {
|
||||
clearInterval(disconnectPoll);
|
||||
for (const cleanup of cleanupFns) {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleProxyMessagesRequest(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
profile: OpenAICompatProfileConfig,
|
||||
expectedAuthToken: string,
|
||||
insecureDispatcher?: Dispatcher
|
||||
): Promise<void> {
|
||||
const transformer = new ProxySseStreamTransformer();
|
||||
|
||||
if (!validateIncomingProxyAuth(req.headers, expectedAuthToken)) {
|
||||
logger.warn('auth.invalid', 'Rejected proxy message request with invalid auth token', {
|
||||
remoteAddress: req.socket.remoteAddress || null,
|
||||
});
|
||||
await pipeWebResponseToNode(
|
||||
transformer.error(401, 'authentication_error', 'Missing or invalid local proxy token'),
|
||||
res
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let timeoutMs = REQUEST_TIMEOUT_MS;
|
||||
try {
|
||||
const rawBody = await readJsonBody(req);
|
||||
const upstream = buildUpstreamRequest(profile, rawBody);
|
||||
logger.info('request.forward', 'Forwarding Anthropic request to OpenAI-compatible upstream', {
|
||||
profileName: upstream.route.profile.profileName,
|
||||
provider: upstream.route.profile.provider,
|
||||
baseUrl: upstream.route.profile.baseUrl,
|
||||
model: upstream.route.model || upstream.route.profile.model || null,
|
||||
routeSource: upstream.route.source,
|
||||
scenario: upstream.route.scenario || null,
|
||||
estimatedTokens: upstream.route.estimatedTokens,
|
||||
});
|
||||
const controller = new AbortController();
|
||||
timeoutMs = getRequestTimeoutMs();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const cleanupDisconnectHandlers = attachDisconnectAbortHandlers(
|
||||
req,
|
||||
res,
|
||||
controller,
|
||||
(source) => {
|
||||
logger.info(
|
||||
'request.disconnect',
|
||||
'Aborting upstream request after local client disconnect',
|
||||
{
|
||||
profileName: profile.profileName,
|
||||
source,
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
const upstreamResponse = await fetch(
|
||||
resolveOpenAIChatCompletionsUrl(upstream.route.profile.baseUrl),
|
||||
buildFetchInit(upstream.route.profile, upstream.body, controller.signal, insecureDispatcher)
|
||||
);
|
||||
logger.info('response.received', 'Received upstream response', {
|
||||
profileName: profile.profileName,
|
||||
routedProfileName: upstream.route.profile.profileName,
|
||||
status: upstreamResponse.status,
|
||||
});
|
||||
const response = await transformer.transform(upstreamResponse);
|
||||
await pipeWebResponseToNode(response, res);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
cleanupDisconnectHandlers();
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown proxy error';
|
||||
logger.error('request.failed', 'Proxy message request failed', {
|
||||
profileName: profile.profileName,
|
||||
error: message,
|
||||
abort: error instanceof Error && error.name === 'AbortError',
|
||||
});
|
||||
const status =
|
||||
error instanceof Error && error.name === 'AbortError'
|
||||
? 502
|
||||
: error instanceof ProxyInputError
|
||||
? 400
|
||||
: message.includes('Request body too large')
|
||||
? 413
|
||||
: message.includes('Invalid JSON')
|
||||
? 400
|
||||
: 502;
|
||||
const type = status >= 500 ? 'api_error' : 'invalid_request_error';
|
||||
await pipeWebResponseToNode(
|
||||
transformer.error(
|
||||
status,
|
||||
type,
|
||||
error instanceof Error && error.name === 'AbortError'
|
||||
? `The upstream provider did not respond within ${formatTimeoutDuration(timeoutMs)}`
|
||||
: message
|
||||
),
|
||||
res
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleProxyModelsRequest(
|
||||
res: http.ServerResponse,
|
||||
profile: OpenAICompatProfileConfig
|
||||
): void {
|
||||
const data = [profile.model, profile.opusModel, profile.sonnetModel, profile.haikuModel]
|
||||
.filter((value): value is string => typeof value === 'string' && value.length > 0)
|
||||
.map((id) => ({
|
||||
id,
|
||||
object: 'model',
|
||||
created: 0,
|
||||
owned_by: profile.provider,
|
||||
}));
|
||||
|
||||
writeJson(res, 200, { object: 'list', data });
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import * as http from 'http';
|
||||
import { Agent } from 'undici';
|
||||
import type { OpenAICompatProfileConfig } from '../profile-router';
|
||||
import { OPENAI_COMPAT_PROXY_SERVICE_NAME } from '../proxy-daemon-paths';
|
||||
import { createLogger } from '../../services/logging';
|
||||
import {
|
||||
handleProxyMessagesRequest,
|
||||
handleProxyModelsRequest,
|
||||
validateIncomingProxyAuth,
|
||||
} from './messages-route';
|
||||
import { writeJson } from './http-helpers';
|
||||
|
||||
export interface OpenAICompatProxyServerOptions {
|
||||
profile: OpenAICompatProfileConfig;
|
||||
host?: string;
|
||||
port: number;
|
||||
authToken: string;
|
||||
insecure?: boolean;
|
||||
}
|
||||
|
||||
export function startOpenAICompatProxyServer(options: OpenAICompatProxyServerOptions): http.Server {
|
||||
const host = options.host?.trim() || '127.0.0.1';
|
||||
const logger = createLogger('proxy:openai-compat', {
|
||||
profileName: options.profile.profileName,
|
||||
host,
|
||||
port: options.port,
|
||||
});
|
||||
const insecureDispatcher = options.insecure
|
||||
? new Agent({ connect: { rejectUnauthorized: false } })
|
||||
: undefined;
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const method = req.method || 'GET';
|
||||
const requestUrl = req.url || '/';
|
||||
const parsedUrl = new URL(requestUrl, 'http://127.0.0.1');
|
||||
const pathname =
|
||||
parsedUrl.pathname.length > 1 ? parsedUrl.pathname.replace(/\/+$/, '') : parsedUrl.pathname;
|
||||
|
||||
if (method === 'GET' && pathname === '/health') {
|
||||
writeJson(res, 200, {
|
||||
ok: true,
|
||||
service: OPENAI_COMPAT_PROXY_SERVICE_NAME,
|
||||
host,
|
||||
profile: options.profile.profileName,
|
||||
port: options.port,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'GET' && pathname === '/') {
|
||||
writeJson(res, 200, {
|
||||
ok: true,
|
||||
service: OPENAI_COMPAT_PROXY_SERVICE_NAME,
|
||||
bind: {
|
||||
host,
|
||||
port: options.port,
|
||||
},
|
||||
profile: {
|
||||
name: options.profile.profileName,
|
||||
provider: options.profile.provider,
|
||||
model: options.profile.model || null,
|
||||
},
|
||||
endpoints: ['/health', '/v1/messages', '/v1/models'],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'GET' && pathname === '/v1/models') {
|
||||
if (!validateIncomingProxyAuth(req.headers, options.authToken)) {
|
||||
writeJson(res, 401, {
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'authentication_error',
|
||||
message: 'Missing or invalid local proxy token',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
handleProxyModelsRequest(res, options.profile);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'POST' && pathname === '/v1/messages') {
|
||||
await handleProxyMessagesRequest(
|
||||
req,
|
||||
res,
|
||||
options.profile,
|
||||
options.authToken,
|
||||
insecureDispatcher
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn('http.not_found', 'Rejected unknown proxy route', {
|
||||
method,
|
||||
pathname,
|
||||
});
|
||||
writeJson(res, 404, { error: 'Not found' });
|
||||
});
|
||||
|
||||
logger.info('server.start', 'OpenAI-compatible proxy server listening', {
|
||||
baseUrl: `http://${host}:${options.port}`,
|
||||
});
|
||||
server.on('close', () => {
|
||||
logger.info('server.stop', 'OpenAI-compatible proxy server stopped');
|
||||
void insecureDispatcher?.close();
|
||||
});
|
||||
|
||||
server.listen(options.port, host);
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
interface AnthropicThinking {
|
||||
type?: 'enabled' | 'disabled' | string;
|
||||
budget_tokens?: number;
|
||||
}
|
||||
|
||||
interface AnthropicTextBlock {
|
||||
type: 'text';
|
||||
text?: string;
|
||||
}
|
||||
|
||||
interface AnthropicImageBlock {
|
||||
type: 'image';
|
||||
source?: {
|
||||
type?: string;
|
||||
media_type?: string;
|
||||
data?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AnthropicToolUseBlock {
|
||||
type: 'tool_use';
|
||||
id?: string;
|
||||
name?: string;
|
||||
input?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface AnthropicToolResultBlock {
|
||||
type: 'tool_result';
|
||||
tool_use_id?: string;
|
||||
content?: unknown;
|
||||
}
|
||||
|
||||
type AnthropicContentBlock =
|
||||
| AnthropicTextBlock
|
||||
| AnthropicImageBlock
|
||||
| AnthropicToolUseBlock
|
||||
| AnthropicToolResultBlock
|
||||
| { type: string; [key: string]: unknown };
|
||||
|
||||
interface AnthropicMessage {
|
||||
role?: 'user' | 'assistant' | string;
|
||||
content?: string | AnthropicContentBlock[];
|
||||
}
|
||||
|
||||
interface AnthropicProxyRequestShape {
|
||||
model?: unknown;
|
||||
system?: unknown;
|
||||
messages?: unknown;
|
||||
max_tokens?: unknown;
|
||||
temperature?: unknown;
|
||||
top_p?: unknown;
|
||||
stop_sequences?: unknown;
|
||||
metadata?: unknown;
|
||||
tools?: unknown;
|
||||
stream?: unknown;
|
||||
thinking?: AnthropicThinking;
|
||||
}
|
||||
|
||||
interface OpenAITextPart {
|
||||
type: 'text';
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface OpenAIImagePart {
|
||||
type: 'image_url';
|
||||
image_url: {
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
type OpenAIContentPart = OpenAITextPart | OpenAIImagePart;
|
||||
|
||||
interface OpenAIMessage {
|
||||
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||
content: string | OpenAIContentPart[] | null;
|
||||
tool_call_id?: string;
|
||||
tool_calls?: Array<{
|
||||
id: string;
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ProxyOpenAIRequest {
|
||||
model?: string;
|
||||
stream: boolean;
|
||||
reasoning_effort?: string;
|
||||
reasoning?: {
|
||||
enabled: boolean;
|
||||
effort: string;
|
||||
};
|
||||
tools?: Array<{
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters: Record<string, unknown>;
|
||||
};
|
||||
}>;
|
||||
messages: OpenAIMessage[];
|
||||
max_tokens?: number;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
stop?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const TOOL_RESULT_SERIALIZATION_FALLBACK = '[unserializable content]';
|
||||
const TOOL_USE_ARGUMENTS_FALLBACK = '{}';
|
||||
|
||||
function assertObject(value: unknown, label: string): Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
throw new Error(`${label} must be an object`);
|
||||
}
|
||||
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = value.filter(
|
||||
(entry): entry is string => typeof entry === 'string' && entry.length > 0
|
||||
);
|
||||
return result.length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
function asMetadata(value: unknown): Record<string, unknown> | undefined {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function safeJsonStringify(value: unknown, fallback: string): string {
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
return typeof serialized === 'string' ? serialized : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function flattenTextContent(content: unknown, label: string): string {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
throw new Error(`${label} must be a string or content block array`);
|
||||
}
|
||||
|
||||
return content
|
||||
.map((block, index) => {
|
||||
const parsed = assertObject(block, `${label}[${index}]`);
|
||||
if (parsed.type !== 'text') {
|
||||
throw new Error(`${label}[${index}].type "${String(parsed.type)}" is not supported`);
|
||||
}
|
||||
return typeof parsed.text === 'string' ? parsed.text : '';
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function toToolResultContent(content: unknown, label: string): string {
|
||||
if (content === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return flattenTextContent(content, label);
|
||||
}
|
||||
return safeJsonStringify(content, TOOL_RESULT_SERIALIZATION_FALLBACK);
|
||||
}
|
||||
|
||||
function createFallbackToolId(messageIndex: number, blockIndex: number): string {
|
||||
return `toolu_proxy_fallback_${messageIndex}_${blockIndex}`;
|
||||
}
|
||||
|
||||
function toImagePart(block: AnthropicImageBlock, label: string): OpenAIImagePart {
|
||||
const source = block.source;
|
||||
if (!source || source.type !== 'base64' || !source.media_type || !source.data) {
|
||||
throw new Error(`${label}.source must be a base64 image payload`);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: `data:${source.media_type};base64,${source.data}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isImageBlock(block: AnthropicContentBlock): block is AnthropicImageBlock {
|
||||
return block.type === 'image';
|
||||
}
|
||||
|
||||
function isToolUseBlock(block: AnthropicContentBlock): block is AnthropicToolUseBlock {
|
||||
return block.type === 'tool_use';
|
||||
}
|
||||
|
||||
function isToolResultBlock(block: AnthropicContentBlock): block is AnthropicToolResultBlock {
|
||||
return block.type === 'tool_result';
|
||||
}
|
||||
|
||||
function flushUserContent(messages: OpenAIMessage[], parts: OpenAIContentPart[]): void {
|
||||
if (parts.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onlyText = parts.every((part) => part.type === 'text');
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: onlyText ? parts.map((part) => (part as OpenAITextPart).text).join('\n') : [...parts],
|
||||
});
|
||||
parts.length = 0;
|
||||
}
|
||||
|
||||
function transformTools(value: unknown): ProxyOpenAIRequest['tools'] {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const tools = value
|
||||
.filter(
|
||||
(entry): entry is { name?: unknown; description?: unknown; input_schema?: unknown } =>
|
||||
typeof entry === 'object' && entry !== null
|
||||
)
|
||||
.map((entry) => ({
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: typeof entry.name === 'string' ? entry.name : 'tool',
|
||||
...(typeof entry.description === 'string' ? { description: entry.description } : {}),
|
||||
parameters:
|
||||
typeof entry.input_schema === 'object' && entry.input_schema !== null
|
||||
? (entry.input_schema as Record<string, unknown>)
|
||||
: { type: 'object', properties: {} },
|
||||
},
|
||||
}));
|
||||
|
||||
return tools.length > 0 ? tools : undefined;
|
||||
}
|
||||
|
||||
function mapThinkingToReasoning(
|
||||
thinking: AnthropicThinking | undefined
|
||||
): Pick<ProxyOpenAIRequest, 'reasoning' | 'reasoning_effort'> {
|
||||
if (!thinking || thinking.type === 'disabled') {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (thinking.type !== 'enabled') {
|
||||
throw new Error('thinking.type must be "enabled" or "disabled"');
|
||||
}
|
||||
|
||||
const effort =
|
||||
typeof thinking.budget_tokens === 'number' && thinking.budget_tokens >= 8192
|
||||
? 'high'
|
||||
: 'medium';
|
||||
|
||||
return {
|
||||
reasoning_effort: effort,
|
||||
reasoning: {
|
||||
enabled: true,
|
||||
effort,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function transformMessages(messagesValue: unknown): OpenAIMessage[] {
|
||||
if (!Array.isArray(messagesValue)) {
|
||||
throw new Error('messages must be an array');
|
||||
}
|
||||
|
||||
const translatedMessages: OpenAIMessage[] = [];
|
||||
|
||||
messagesValue.forEach((message, messageIndex) => {
|
||||
const parsedMessage = assertObject(message, `messages[${messageIndex}]`) as AnthropicMessage;
|
||||
const role = parsedMessage.role;
|
||||
if (role !== 'user' && role !== 'assistant') {
|
||||
throw new Error(`messages[${messageIndex}].role must be "user" or "assistant"`);
|
||||
}
|
||||
|
||||
const content = parsedMessage.content;
|
||||
if (typeof content === 'string') {
|
||||
translatedMessages.push({ role, content });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
throw new Error(`messages[${messageIndex}].content must be a string or array`);
|
||||
}
|
||||
|
||||
const userParts: OpenAIContentPart[] = [];
|
||||
const assistantTextParts: string[] = [];
|
||||
const toolCalls: NonNullable<OpenAIMessage['tool_calls']> = [];
|
||||
let sawToolResult = false;
|
||||
|
||||
content.forEach((block, blockIndex) => {
|
||||
const parsed = assertObject(
|
||||
block,
|
||||
`messages[${messageIndex}].content[${blockIndex}]`
|
||||
) as AnthropicContentBlock;
|
||||
|
||||
if (parsed.type === 'text') {
|
||||
const text = typeof parsed.text === 'string' ? parsed.text : '';
|
||||
if (role === 'user') {
|
||||
userParts.push({ type: 'text', text });
|
||||
} else {
|
||||
assistantTextParts.push(text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isImageBlock(parsed)) {
|
||||
if (role !== 'user') {
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].content[${blockIndex}] image requires user role`
|
||||
);
|
||||
}
|
||||
userParts.push(toImagePart(parsed, `messages[${messageIndex}].content[${blockIndex}]`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isToolUseBlock(parsed)) {
|
||||
if (role !== 'assistant') {
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].content[${blockIndex}] tool_use requires assistant role`
|
||||
);
|
||||
}
|
||||
toolCalls.push({
|
||||
id:
|
||||
typeof parsed.id === 'string' && parsed.id.length > 0
|
||||
? parsed.id
|
||||
: createFallbackToolId(messageIndex, blockIndex),
|
||||
type: 'function',
|
||||
function: {
|
||||
name: typeof parsed.name === 'string' ? parsed.name : 'tool',
|
||||
arguments: safeJsonStringify(parsed.input ?? {}, TOOL_USE_ARGUMENTS_FALLBACK),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isToolResultBlock(parsed)) {
|
||||
if (role !== 'user') {
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].content[${blockIndex}] tool_result requires user role`
|
||||
);
|
||||
}
|
||||
if (typeof parsed.tool_use_id !== 'string' || parsed.tool_use_id.trim().length === 0) {
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].content[${blockIndex}].tool_use_id must be a non-empty string`
|
||||
);
|
||||
}
|
||||
sawToolResult = true;
|
||||
flushUserContent(translatedMessages, userParts);
|
||||
translatedMessages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: parsed.tool_use_id,
|
||||
content: toToolResultContent(
|
||||
parsed.content,
|
||||
`messages[${messageIndex}].content[${blockIndex}].content`
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].content[${blockIndex}].type "${String(parsed.type)}" is not supported`
|
||||
);
|
||||
});
|
||||
|
||||
if (role === 'assistant') {
|
||||
translatedMessages.push({
|
||||
role: 'assistant',
|
||||
content: assistantTextParts.join('\n'),
|
||||
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (userParts.length > 0 || !sawToolResult) {
|
||||
flushUserContent(translatedMessages, userParts);
|
||||
}
|
||||
});
|
||||
|
||||
return translatedMessages;
|
||||
}
|
||||
|
||||
export class ProxyRequestTransformer {
|
||||
transform(raw: unknown): ProxyOpenAIRequest {
|
||||
const source = assertObject(raw || {}, 'request') as AnthropicProxyRequestShape;
|
||||
const messages = transformMessages(source.messages);
|
||||
const system = source.system;
|
||||
const allMessages =
|
||||
system !== undefined
|
||||
? [
|
||||
{ role: 'system', content: flattenTextContent(system, 'system') } as OpenAIMessage,
|
||||
...messages,
|
||||
]
|
||||
: messages;
|
||||
|
||||
return {
|
||||
model:
|
||||
typeof source.model === 'string' && source.model.trim().length > 0
|
||||
? source.model.trim()
|
||||
: undefined,
|
||||
stream: source.stream === true,
|
||||
messages: allMessages,
|
||||
max_tokens: asNumber(source.max_tokens),
|
||||
temperature: asNumber(source.temperature),
|
||||
top_p: asNumber(source.top_p),
|
||||
stop: asStringArray(source.stop_sequences),
|
||||
metadata: asMetadata(source.metadata),
|
||||
tools: transformTools(source.tools),
|
||||
...mapThinkingToReasoning(source.thinking),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { DeltaAccumulator } from '../../glmt/delta-accumulator';
|
||||
import { GlmtTransformer } from '../../glmt/glmt-transformer';
|
||||
import { SSEParser } from '../../glmt/sse-parser';
|
||||
import type { OpenAIResponse, SSEEvent } from '../../glmt/pipeline';
|
||||
|
||||
const JSON_TRANSLATION_ERROR_MESSAGE = 'Failed to translate OpenAI-compatible JSON response';
|
||||
const STREAM_TRANSLATION_ERROR_MESSAGE = 'Failed to translate OpenAI-compatible SSE response';
|
||||
|
||||
type ResponseHeaders = Headers | Record<string, string> | Array<[string, string]>;
|
||||
|
||||
interface AnthropicErrorPayload {
|
||||
type: 'error';
|
||||
error: {
|
||||
type: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
function createAnthropicErrorPayload(type: string, message: string): AnthropicErrorPayload {
|
||||
return {
|
||||
type: 'error',
|
||||
error: {
|
||||
type,
|
||||
message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatErrorForLog(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
|
||||
function logTranslationError(context: string, error: unknown): void {
|
||||
console.error(`[proxy-sse-transformer] ${context}: ${formatErrorForLog(error)}`);
|
||||
}
|
||||
|
||||
export function createAnthropicErrorResponse(
|
||||
status: number,
|
||||
type: string,
|
||||
message: string,
|
||||
headers?: ResponseHeaders
|
||||
): Response {
|
||||
const responseHeaders = new Headers(headers);
|
||||
responseHeaders.set('Content-Type', 'application/json');
|
||||
responseHeaders.delete('Content-Length');
|
||||
|
||||
return new Response(JSON.stringify(createAnthropicErrorPayload(type, message)), {
|
||||
status,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
function formatSseEvent(event: string, data: unknown): string {
|
||||
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
}
|
||||
|
||||
function hasTranslatableChoices(value: unknown): value is OpenAIResponse {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { choices } = value as OpenAIResponse;
|
||||
if (!Array.isArray(choices) || choices.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const firstChoice = choices[0];
|
||||
if (typeof firstChoice !== 'object' || firstChoice === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = (firstChoice as { message?: unknown }).message;
|
||||
return typeof message === 'object' && message !== null;
|
||||
}
|
||||
|
||||
function isSyntheticTransformationFallback(value: unknown): boolean {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
typeof (value as { id?: unknown }).id === 'string' &&
|
||||
(value as { id: string }).id.startsWith('msg_error_')
|
||||
);
|
||||
}
|
||||
|
||||
async function createAnthropicErrorProxyResponse(response: Response): Promise<Response> {
|
||||
const headers = new Headers(response.headers);
|
||||
headers.delete('Content-Type');
|
||||
headers.delete('Content-Length');
|
||||
|
||||
let type =
|
||||
response.status === 401
|
||||
? 'authentication_error'
|
||||
: response.status === 429
|
||||
? 'rate_limit_error'
|
||||
: response.status >= 400 && response.status < 500
|
||||
? 'invalid_request_error'
|
||||
: 'api_error';
|
||||
let message = `Upstream request failed with status ${response.status}`;
|
||||
|
||||
try {
|
||||
const contentType = (response.headers.get('content-type') || '').toLowerCase();
|
||||
if (contentType.includes('application/json')) {
|
||||
const payload = (await response.json()) as {
|
||||
error?: { type?: string; message?: string };
|
||||
message?: string;
|
||||
};
|
||||
|
||||
if (typeof payload?.error?.type === 'string' && payload.error.type.trim().length > 0) {
|
||||
type = payload.error.type;
|
||||
}
|
||||
|
||||
if (typeof payload?.error?.message === 'string' && payload.error.message.trim().length > 0) {
|
||||
message = payload.error.message;
|
||||
} else if (typeof payload?.message === 'string' && payload.message.trim().length > 0) {
|
||||
message = payload.message;
|
||||
}
|
||||
} else {
|
||||
const text = (await response.text()).trim();
|
||||
if (text.length > 0) {
|
||||
message = text;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logTranslationError('Failed to parse upstream error response', error);
|
||||
}
|
||||
|
||||
return createAnthropicErrorResponse(response.status, type, message, headers);
|
||||
}
|
||||
|
||||
async function createAnthropicJsonResponse(response: Response): Promise<Response> {
|
||||
try {
|
||||
const openAIResponse = await response.json();
|
||||
if (!hasTranslatableChoices(openAIResponse)) {
|
||||
return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE);
|
||||
}
|
||||
|
||||
const anthropicResponse = new GlmtTransformer().transformResponse(openAIResponse);
|
||||
if (isSyntheticTransformationFallback(anthropicResponse)) {
|
||||
logTranslationError(
|
||||
'OpenAI-compatible JSON translation produced synthetic fallback response',
|
||||
anthropicResponse
|
||||
);
|
||||
return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(anthropicResponse), {
|
||||
status: response.status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
logTranslationError('OpenAI-compatible JSON translation failed', error);
|
||||
return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
function createAnthropicStreamingResponse(response: Response): Response {
|
||||
const body = response.body;
|
||||
if (!body) {
|
||||
return createAnthropicErrorResponse(
|
||||
502,
|
||||
'api_error',
|
||||
'Upstream stream ended before a response body was available'
|
||||
);
|
||||
}
|
||||
|
||||
const parser = new SSEParser({ throwOnMalformedJson: true });
|
||||
const transformer = new GlmtTransformer();
|
||||
const accumulator = new DeltaAccumulator({});
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const readable = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const reader = body.getReader();
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const events = parser.parse(Buffer.from(value));
|
||||
for (const event of events) {
|
||||
const anthropicEvents = transformer.transformDelta(event as SSEEvent, accumulator);
|
||||
for (const anthropicEvent of anthropicEvents) {
|
||||
controller.enqueue(
|
||||
encoder.encode(formatSseEvent(anthropicEvent.event, anthropicEvent.data))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!accumulator.isFinalized() && accumulator.isMessageStarted()) {
|
||||
for (const anthropicEvent of transformer.finalizeDelta(accumulator)) {
|
||||
controller.enqueue(
|
||||
encoder.encode(formatSseEvent(anthropicEvent.event, anthropicEvent.data))
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logTranslationError('OpenAI-compatible SSE translation failed', error);
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
formatSseEvent(
|
||||
'error',
|
||||
createAnthropicErrorPayload('api_error', STREAM_TRANSLATION_ERROR_MESSAGE)
|
||||
)
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(readable, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createAnthropicProxyResponse(response: Response): Promise<Response> {
|
||||
if (!response.ok) {
|
||||
return createAnthropicErrorProxyResponse(response);
|
||||
}
|
||||
|
||||
const contentType = (response.headers.get('content-type') || '').toLowerCase();
|
||||
const isEventStream =
|
||||
contentType === 'text/event-stream' || contentType.startsWith('text/event-stream;');
|
||||
|
||||
return isEventStream
|
||||
? createAnthropicStreamingResponse(response)
|
||||
: createAnthropicJsonResponse(response);
|
||||
}
|
||||
|
||||
export class ProxySseStreamTransformer {
|
||||
async transform(response: Response): Promise<Response> {
|
||||
return createAnthropicProxyResponse(response);
|
||||
}
|
||||
|
||||
error(status: number, type: string, message: string): Response {
|
||||
return createAnthropicErrorResponse(status, type, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
function normalizePathname(pathname: string): string {
|
||||
const trimmed = pathname.replace(/\/+$/, '');
|
||||
return trimmed || '';
|
||||
}
|
||||
|
||||
function ensureSupportedProtocol(parsed: URL): void {
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error(`Unsupported upstream protocol: ${parsed.protocol}`);
|
||||
}
|
||||
}
|
||||
|
||||
function buildResolvedUrl(baseUrl: string, suffix: string): string {
|
||||
const parsed = new URL(baseUrl);
|
||||
ensureSupportedProtocol(parsed);
|
||||
|
||||
const pathname = normalizePathname(parsed.pathname);
|
||||
if (pathname.endsWith(suffix)) {
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
if (pathname.endsWith('/v1') || pathname.endsWith('/api')) {
|
||||
parsed.pathname = `${pathname}${suffix.startsWith('/') ? suffix : `/${suffix}`}`;
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
parsed.pathname = pathname ? `${pathname}/v1${suffix}` : `/v1${suffix}`;
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
export function resolveOpenAIChatCompletionsUrl(baseUrl: string): string {
|
||||
return buildResolvedUrl(baseUrl, '/chat/completions');
|
||||
}
|
||||
|
||||
export function resolveOpenAIModelsUrl(baseUrl: string): string {
|
||||
return buildResolvedUrl(baseUrl, '/models');
|
||||
}
|
||||
@@ -121,7 +121,7 @@ export function listClaudeExtensionProfiles(): ClaudeExtensionProfileOption[] {
|
||||
'default',
|
||||
...all.accounts,
|
||||
...all.settings,
|
||||
...all.cliproxy,
|
||||
...all.cliproxy.filter((profileName) => profileName !== 'cursor'),
|
||||
...all.cliproxyVariants,
|
||||
];
|
||||
const deduped = [...new Set(orderedNames)];
|
||||
|
||||
@@ -10,17 +10,26 @@ const CODEX_CONFIG_OVERRIDE_PROBE_ARGS = ['-c', 'model="gpt-5"', '--version'];
|
||||
function buildWindowsCodexCandidates(matches: string[]): string[] {
|
||||
const shellCandidates = matches.filter((entry) => /\.(exe|cmd|bat|ps1)$/i.test(entry));
|
||||
const bareCandidates = matches.filter((entry) => !/\.(exe|cmd|bat|ps1)$/i.test(entry));
|
||||
const prioritized: string[] = [];
|
||||
|
||||
for (const entry of shellCandidates) {
|
||||
if (/\.(cmd|bat)$/i.test(entry)) {
|
||||
prioritized.push(entry.replace(/\.(cmd|bat)$/i, '.ps1'));
|
||||
const prioritized = shellCandidates.map((entry) => {
|
||||
if (!/\.ps1$/i.test(entry)) {
|
||||
return entry;
|
||||
}
|
||||
prioritized.push(entry);
|
||||
}
|
||||
|
||||
prioritized.push(...bareCandidates);
|
||||
return [...new Set(prioritized)];
|
||||
for (const preferredExtension of ['.cmd', '.bat', '.exe']) {
|
||||
const siblingCandidate = entry.replace(/\.ps1$/i, preferredExtension);
|
||||
try {
|
||||
if (fs.statSync(siblingCandidate).isFile()) {
|
||||
return siblingCandidate;
|
||||
}
|
||||
} catch {
|
||||
// Ignore missing sibling wrappers and keep the original PowerShell path.
|
||||
}
|
||||
}
|
||||
|
||||
return entry;
|
||||
});
|
||||
|
||||
return [...new Set([...prioritized, ...bareCandidates])];
|
||||
}
|
||||
|
||||
function runCodexProbe(codexPath: string, args: string[]): string | undefined {
|
||||
|
||||
@@ -86,8 +86,10 @@ export function inferDroidProviderFromBaseUrl(
|
||||
host.includes('api.deepinfra.com') ||
|
||||
host.includes('api.fireworks.ai') ||
|
||||
host.includes('inference.baseten.co') ||
|
||||
host.includes('dashscope') ||
|
||||
host.includes('huggingface.co') ||
|
||||
host.includes('ollama.com') ||
|
||||
pathname.includes('/compatible-mode') ||
|
||||
pathname.includes('/openai') ||
|
||||
pathname.includes('/chat/completions')
|
||||
) {
|
||||
|
||||
@@ -40,6 +40,19 @@ export interface CLIProxyVariantsConfig {
|
||||
[profileName: string]: CLIProxyVariantConfig;
|
||||
}
|
||||
|
||||
export interface OpenAICompatProxyRoutingConfig {
|
||||
default?: string;
|
||||
background?: string;
|
||||
think?: string;
|
||||
longContext?: string;
|
||||
webSearch?: string;
|
||||
longContextThreshold?: number;
|
||||
}
|
||||
|
||||
export interface OpenAICompatProxyConfig {
|
||||
routing?: OpenAICompatProxyRoutingConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main CCS configuration
|
||||
* Located at: ~/.ccs/config.json
|
||||
@@ -51,6 +64,8 @@ export interface Config {
|
||||
profile_targets?: Record<string, TargetType>;
|
||||
/** User-defined CLIProxy profile variants (optional) */
|
||||
cliproxy?: CLIProxyVariantsConfig;
|
||||
/** OpenAI-compatible local proxy configuration (optional) */
|
||||
proxy?: OpenAICompatProxyConfig;
|
||||
/** Legacy continuity inheritance mapping (profile -> source account) */
|
||||
continuity_inherit_from_account?: Record<string, string>;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ export type {
|
||||
ProfilesRegistry,
|
||||
CLIProxyVariantConfig,
|
||||
CLIProxyVariantsConfig,
|
||||
OpenAICompatProxyConfig,
|
||||
OpenAICompatProxyRoutingConfig,
|
||||
} from './config';
|
||||
export { isConfig, isSettings } from './config';
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import * as path from 'path';
|
||||
import type { BrowserConfig } from '../../config/unified-config-types';
|
||||
import { getCcsDir } from '../config-manager';
|
||||
import { expandPath } from '../helpers';
|
||||
|
||||
export type BrowserOverrideSource = 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR';
|
||||
|
||||
export interface EffectiveClaudeBrowserAttachConfig {
|
||||
enabled: boolean;
|
||||
source: 'config' | BrowserOverrideSource;
|
||||
overrideActive: boolean;
|
||||
userDataDir: string;
|
||||
devtoolsPort: number;
|
||||
hasExplicitDevtoolsPort: boolean;
|
||||
}
|
||||
|
||||
export function getRecommendedBrowserUserDataDir(): string {
|
||||
return path.join(getCcsDir(), 'browser', 'chrome-user-data');
|
||||
}
|
||||
|
||||
export function resolveBrowserUserDataDir(value?: string): string | undefined {
|
||||
return value?.trim() ? expandPath(value) : undefined;
|
||||
}
|
||||
|
||||
export function getBrowserAttachOverride(env: NodeJS.ProcessEnv = process.env): {
|
||||
userDataDir?: string;
|
||||
devtoolsPort?: number;
|
||||
source?: BrowserOverrideSource;
|
||||
} {
|
||||
const explicitUserDataDir = resolveBrowserUserDataDir(env.CCS_BROWSER_USER_DATA_DIR);
|
||||
if (explicitUserDataDir) {
|
||||
return {
|
||||
userDataDir: explicitUserDataDir,
|
||||
devtoolsPort: parseDevtoolsPort(env.CCS_BROWSER_DEVTOOLS_PORT),
|
||||
source: 'CCS_BROWSER_USER_DATA_DIR',
|
||||
};
|
||||
}
|
||||
|
||||
const legacyProfileDir = resolveBrowserUserDataDir(env.CCS_BROWSER_PROFILE_DIR);
|
||||
if (legacyProfileDir) {
|
||||
return {
|
||||
userDataDir: legacyProfileDir,
|
||||
devtoolsPort: parseDevtoolsPort(env.CCS_BROWSER_DEVTOOLS_PORT),
|
||||
source: 'CCS_BROWSER_PROFILE_DIR',
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export function getEffectiveClaudeBrowserAttachConfig(
|
||||
config: BrowserConfig,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): EffectiveClaudeBrowserAttachConfig {
|
||||
const override = getBrowserAttachOverride(env);
|
||||
const configUserDataDir =
|
||||
resolveBrowserUserDataDir(config.claude.user_data_dir) ?? getRecommendedBrowserUserDataDir();
|
||||
const configPort = normalizeDevtoolsPort(config.claude.devtools_port);
|
||||
|
||||
if (override.userDataDir) {
|
||||
return {
|
||||
enabled: true,
|
||||
source: override.source as BrowserOverrideSource,
|
||||
overrideActive: true,
|
||||
userDataDir: override.userDataDir,
|
||||
devtoolsPort: override.devtoolsPort ?? configPort,
|
||||
hasExplicitDevtoolsPort: override.devtoolsPort !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: config.claude.enabled,
|
||||
source: 'config',
|
||||
overrideActive: false,
|
||||
userDataDir: configUserDataDir,
|
||||
devtoolsPort: configPort,
|
||||
// Config-backed browser attach always keeps an explicit port so launches
|
||||
// stay aligned with Settings > Browser, even when the effective value is
|
||||
// the default 9222.
|
||||
hasExplicitDevtoolsPort: true,
|
||||
};
|
||||
}
|
||||
|
||||
function parseDevtoolsPort(value?: string): number | undefined {
|
||||
if (!value?.trim() || !/^\d+$/.test(value.trim())) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return normalizeDevtoolsPort(Number.parseInt(value.trim(), 10));
|
||||
}
|
||||
|
||||
function normalizeDevtoolsPort(value: number | undefined): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 9222;
|
||||
}
|
||||
|
||||
const port = Math.floor(value as number);
|
||||
if (port < 1 || port > 65535) {
|
||||
return 9222;
|
||||
}
|
||||
|
||||
return port;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { getBrowserConfig } from '../../config/unified-config-loader';
|
||||
import { getCodexBinaryInfo } from '../../targets/codex-detector';
|
||||
import { type BrowserRuntimeEnv, resolveBrowserRuntimeEnv } from './chrome-reuse';
|
||||
import { getBrowserMcpServerName, getBrowserMcpServerPath } from './mcp-installer';
|
||||
import { getNodePlatformKey } from './platform';
|
||||
import {
|
||||
getEffectiveClaudeBrowserAttachConfig,
|
||||
getRecommendedBrowserUserDataDir,
|
||||
} from './browser-settings';
|
||||
|
||||
export interface BrowserLaunchCommands {
|
||||
darwin: string;
|
||||
linux: string;
|
||||
win32: string;
|
||||
}
|
||||
|
||||
export interface ClaudeBrowserStatus {
|
||||
enabled: boolean;
|
||||
source: 'config' | 'CCS_BROWSER_USER_DATA_DIR' | 'CCS_BROWSER_PROFILE_DIR';
|
||||
overrideActive: boolean;
|
||||
state: 'disabled' | 'path_missing' | 'browser_not_running' | 'endpoint_unreachable' | 'ready';
|
||||
title: string;
|
||||
detail: string;
|
||||
nextStep: string;
|
||||
effectiveUserDataDir: string;
|
||||
recommendedUserDataDir: string;
|
||||
devtoolsPort: number;
|
||||
managedMcpServerName: string;
|
||||
managedMcpServerPath: string;
|
||||
launchCommands: BrowserLaunchCommands;
|
||||
runtimeEnv?: BrowserRuntimeEnv;
|
||||
}
|
||||
|
||||
export interface CodexBrowserStatus {
|
||||
enabled: boolean;
|
||||
state: 'disabled' | 'enabled' | 'unsupported_build';
|
||||
title: string;
|
||||
detail: string;
|
||||
nextStep: string;
|
||||
serverName: string;
|
||||
supportsConfigOverrides: boolean;
|
||||
binaryPath: string | null;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface BrowserStatusPayload {
|
||||
claude: ClaudeBrowserStatus;
|
||||
codex: CodexBrowserStatus;
|
||||
}
|
||||
|
||||
export async function getBrowserStatus(): Promise<BrowserStatusPayload> {
|
||||
const browserConfig = getBrowserConfig();
|
||||
return {
|
||||
claude: await buildClaudeBrowserStatus(browserConfig),
|
||||
codex: buildCodexBrowserStatus(browserConfig),
|
||||
};
|
||||
}
|
||||
|
||||
async function buildClaudeBrowserStatus(
|
||||
browserConfig = getBrowserConfig()
|
||||
): Promise<ClaudeBrowserStatus> {
|
||||
const effective = getEffectiveClaudeBrowserAttachConfig(browserConfig);
|
||||
const launchCommands = buildLaunchCommands(effective.userDataDir, effective.devtoolsPort);
|
||||
const base: Omit<ClaudeBrowserStatus, 'state' | 'title' | 'detail' | 'nextStep'> = {
|
||||
enabled: effective.enabled,
|
||||
source: effective.source,
|
||||
overrideActive: effective.overrideActive,
|
||||
effectiveUserDataDir: effective.userDataDir,
|
||||
recommendedUserDataDir: getRecommendedBrowserUserDataDir(),
|
||||
devtoolsPort: effective.devtoolsPort,
|
||||
managedMcpServerName: getBrowserMcpServerName(),
|
||||
managedMcpServerPath: getBrowserMcpServerPath(),
|
||||
launchCommands,
|
||||
};
|
||||
|
||||
if (!effective.enabled) {
|
||||
return {
|
||||
...base,
|
||||
state: 'disabled',
|
||||
title: 'Claude Browser Attach is disabled.',
|
||||
detail:
|
||||
'CCS will not provision the managed browser MCP runtime for Claude launches until this lane is enabled.',
|
||||
nextStep:
|
||||
'Enable Claude Browser Attach in Settings > Browser or in ~/.ccs/config.yaml, then rerun `ccs browser doctor`.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const runtimeEnv = await resolveBrowserRuntimeEnv({
|
||||
profileDir: effective.userDataDir,
|
||||
devtoolsPort: effective.hasExplicitDevtoolsPort ? String(effective.devtoolsPort) : undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
...base,
|
||||
state: 'ready',
|
||||
title: 'Claude Browser Attach is ready.',
|
||||
detail:
|
||||
'CCS can reach the configured Chrome DevTools endpoint for the current attach session.',
|
||||
nextStep: 'Launch a Claude-target CCS session to use the managed browser MCP runtime.',
|
||||
runtimeEnv,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = (error as Error).message;
|
||||
if (message.includes('Chrome profile directory is invalid')) {
|
||||
return {
|
||||
...base,
|
||||
state: 'path_missing',
|
||||
title: 'Claude Browser Attach path is missing.',
|
||||
detail: message,
|
||||
nextStep: `Create or choose a Chrome user-data directory, then launch Chrome with attach mode enabled. Example: ${launchCommands[getNodePlatformKey()]}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (message.includes('Chrome reuse metadata')) {
|
||||
return {
|
||||
...base,
|
||||
state: 'browser_not_running',
|
||||
title: 'Claude Browser Attach could not find a running browser session.',
|
||||
detail: message,
|
||||
nextStep: `Start Chrome with remote debugging and the configured user-data dir. Example: ${launchCommands[getNodePlatformKey()]}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
state: 'endpoint_unreachable',
|
||||
title: 'Claude Browser Attach could not reach the DevTools endpoint.',
|
||||
detail: message,
|
||||
nextStep: `Restart the attach browser session or confirm the configured port. Example: ${launchCommands[getNodePlatformKey()]}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildCodexBrowserStatus(browserConfig = getBrowserConfig()): CodexBrowserStatus {
|
||||
if (!browserConfig.codex.enabled) {
|
||||
return {
|
||||
enabled: false,
|
||||
state: 'disabled',
|
||||
title: 'Codex Browser Tools are disabled.',
|
||||
detail: 'CCS will not inject Playwright MCP browser tooling into Codex-target launches.',
|
||||
nextStep:
|
||||
'Enable Codex Browser Tools in Settings > Browser to restore the managed Codex browser path.',
|
||||
serverName: 'ccs_browser',
|
||||
supportsConfigOverrides: false,
|
||||
binaryPath: null,
|
||||
};
|
||||
}
|
||||
|
||||
const binaryInfo = getCodexBinaryInfo({ includeVersion: true, includeFeatures: true });
|
||||
const supportsConfigOverrides = Boolean(binaryInfo?.features?.includes('config-overrides'));
|
||||
if (!binaryInfo || !supportsConfigOverrides) {
|
||||
return {
|
||||
enabled: true,
|
||||
state: 'unsupported_build',
|
||||
title: 'Codex Browser Tools need a Codex build with --config override support.',
|
||||
detail: binaryInfo
|
||||
? `Detected Codex at ${binaryInfo.path}, but it does not advertise --config overrides.`
|
||||
: 'No Codex binary was detected, so CCS cannot confirm managed browser override support.',
|
||||
nextStep: 'Install or upgrade Codex, then rerun browser status/doctor.',
|
||||
serverName: 'ccs_browser',
|
||||
supportsConfigOverrides,
|
||||
binaryPath: binaryInfo?.path ?? null,
|
||||
version: binaryInfo?.version,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
state: 'enabled',
|
||||
title: 'Codex Browser Tools are enabled.',
|
||||
detail: 'CCS can inject the managed Playwright MCP overrides into Codex-target launches.',
|
||||
nextStep: 'Use a Codex-target CCS launch to access browser tools.',
|
||||
serverName: 'ccs_browser',
|
||||
supportsConfigOverrides,
|
||||
binaryPath: binaryInfo.path,
|
||||
version: binaryInfo.version,
|
||||
};
|
||||
}
|
||||
|
||||
function buildLaunchCommands(userDataDir: string, devtoolsPort: number): BrowserLaunchCommands {
|
||||
const quotedPath = JSON.stringify(userDataDir);
|
||||
return {
|
||||
darwin: `open -na "Google Chrome" --args --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`,
|
||||
linux: `google-chrome --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`,
|
||||
win32: `chrome.exe --remote-debugging-port=${devtoolsPort} --user-data-dir=${quotedPath}`,
|
||||
};
|
||||
}
|
||||
@@ -17,9 +17,22 @@ export {
|
||||
|
||||
export { appendBrowserToolArgs } from './claude-tool-args';
|
||||
|
||||
export {
|
||||
getRecommendedBrowserUserDataDir,
|
||||
getBrowserAttachOverride,
|
||||
getEffectiveClaudeBrowserAttachConfig,
|
||||
} from './browser-settings';
|
||||
|
||||
export {
|
||||
resolveBrowserRuntimeEnv,
|
||||
resolveDefaultChromeUserDataDir,
|
||||
resolveConfiguredBrowserProfileDir,
|
||||
} from './chrome-reuse';
|
||||
export type { BrowserReuseOptions, BrowserRuntimeEnv } from './chrome-reuse';
|
||||
|
||||
export { getBrowserStatus } from './browser-status';
|
||||
export type {
|
||||
BrowserStatusPayload,
|
||||
ClaudeBrowserStatus,
|
||||
CodexBrowserStatus,
|
||||
} from './browser-status';
|
||||
@@ -0,0 +1,9 @@
|
||||
export type BrowserPlatformKey = 'darwin' | 'linux' | 'win32';
|
||||
|
||||
export function getNodePlatformKey(
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): BrowserPlatformKey {
|
||||
if (platform === 'darwin') return 'darwin';
|
||||
if (platform === 'win32') return 'win32';
|
||||
return 'linux';
|
||||
}
|
||||
@@ -396,7 +396,7 @@ function resolveBackend(
|
||||
};
|
||||
}
|
||||
|
||||
if (profileType === 'cursor' || profileName === 'cursor') {
|
||||
if (profileType === 'cursor') {
|
||||
return {
|
||||
backendId: null,
|
||||
backendDisplayName: null,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { getBrowserConfig, mutateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import { getBrowserStatus } from '../../utils/browser';
|
||||
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
|
||||
|
||||
const router = Router();
|
||||
const BROWSER_LOCAL_ACCESS_ERROR =
|
||||
'Browser endpoints require localhost access when dashboard auth is disabled.';
|
||||
|
||||
interface BrowserRouteBody {
|
||||
claude?: {
|
||||
enabled?: boolean;
|
||||
userDataDir?: string;
|
||||
devtoolsPort?: number;
|
||||
};
|
||||
codex?: {
|
||||
enabled?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function isValidDevtoolsPort(value: number): boolean {
|
||||
return Number.isInteger(value) && value >= 1 && value <= 65535;
|
||||
}
|
||||
|
||||
router.use((req: Request, res: Response, next) => {
|
||||
if (requireLocalAccessWhenAuthDisabled(req, res, BROWSER_LOCAL_ACCESS_ERROR)) {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const config = getBrowserConfig();
|
||||
const status = await getBrowserStatus();
|
||||
res.json({
|
||||
config: toBrowserRouteConfig(config),
|
||||
status,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/status', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
res.json(await getBrowserStatus());
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/', async (req: Request, res: Response): Promise<void> => {
|
||||
if (
|
||||
req.body === null ||
|
||||
req.body === undefined ||
|
||||
typeof req.body !== 'object' ||
|
||||
Array.isArray(req.body)
|
||||
) {
|
||||
res.status(400).json({ error: 'Invalid request body. Must be an object.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { claude, codex } = req.body as BrowserRouteBody;
|
||||
if (claude && (typeof claude !== 'object' || Array.isArray(claude))) {
|
||||
res.status(400).json({ error: 'Invalid value for claude. Must be an object.' });
|
||||
return;
|
||||
}
|
||||
if (codex && (typeof codex !== 'object' || Array.isArray(codex))) {
|
||||
res.status(400).json({ error: 'Invalid value for codex. Must be an object.' });
|
||||
return;
|
||||
}
|
||||
if (claude?.enabled !== undefined && typeof claude.enabled !== 'boolean') {
|
||||
res.status(400).json({ error: 'Invalid value for claude.enabled. Must be a boolean.' });
|
||||
return;
|
||||
}
|
||||
if (claude?.userDataDir !== undefined && typeof claude.userDataDir !== 'string') {
|
||||
res.status(400).json({ error: 'Invalid value for claude.userDataDir. Must be a string.' });
|
||||
return;
|
||||
}
|
||||
if (
|
||||
claude?.devtoolsPort !== undefined &&
|
||||
(typeof claude.devtoolsPort !== 'number' || !isValidDevtoolsPort(claude.devtoolsPort))
|
||||
) {
|
||||
res.status(400).json({
|
||||
error: 'Invalid value for claude.devtoolsPort. Must be an integer between 1 and 65535.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (codex?.enabled !== undefined && typeof codex.enabled !== 'boolean') {
|
||||
res.status(400).json({ error: 'Invalid value for codex.enabled. Must be a boolean.' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const current = getBrowserConfig();
|
||||
const nextClaudeUserDataDir =
|
||||
claude?.userDataDir === undefined ? current.claude.user_data_dir : claude.userDataDir.trim();
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.browser = {
|
||||
claude: {
|
||||
enabled: claude?.enabled ?? current.claude.enabled,
|
||||
user_data_dir: nextClaudeUserDataDir,
|
||||
devtools_port: claude?.devtoolsPort ?? current.claude.devtools_port,
|
||||
},
|
||||
codex: {
|
||||
enabled: codex?.enabled ?? current.codex.enabled,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const config = getBrowserConfig();
|
||||
const status = await getBrowserStatus();
|
||||
res.json({
|
||||
success: true,
|
||||
browser: {
|
||||
config: toBrowserRouteConfig(config),
|
||||
status,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
function toBrowserRouteConfig(config: ReturnType<typeof getBrowserConfig>) {
|
||||
return {
|
||||
claude: {
|
||||
enabled: config.claude.enabled,
|
||||
userDataDir: config.claude.user_data_dir,
|
||||
devtoolsPort: config.claude.devtools_port,
|
||||
},
|
||||
codex: {
|
||||
enabled: config.codex.enabled,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from '../../cliproxy/proxy-target-resolver';
|
||||
import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher';
|
||||
import { ensureManagedModelPrefixes } from '../../cliproxy/managed-model-prefixes';
|
||||
import { invalidateQuotaCache } from '../../cliproxy/quota-response-cache';
|
||||
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import { tryKiroImport } from '../../cliproxy/auth/kiro-import';
|
||||
import {
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
listProviderTokenSnapshots,
|
||||
registerAccountFromToken,
|
||||
} from '../../cliproxy/auth/token-manager';
|
||||
import { parseGitLabPatAuthResponse } from '../../cliproxy/auth/gitlab-pat-response';
|
||||
import {
|
||||
CLIPROXY_CALLBACK_PROVIDER_MAP,
|
||||
CLIPROXY_AUTH_URL_PROVIDER_MAP,
|
||||
@@ -190,6 +192,13 @@ function shouldKeepWaitingForLocalToken(
|
||||
);
|
||||
}
|
||||
|
||||
function invalidateQuotaForRegisteredAccount(account: {
|
||||
provider: CLIProxyProvider;
|
||||
id: string;
|
||||
}): void {
|
||||
invalidateQuotaCache(account.provider, account.id);
|
||||
}
|
||||
|
||||
function parseKiroMethod(raw: unknown): { method: KiroAuthMethod; invalid: boolean } {
|
||||
if (raw === undefined || raw === null) {
|
||||
return { method: normalizeKiroAuthMethod(), invalid: false };
|
||||
@@ -221,6 +230,20 @@ function parseKiroIDCFlow(raw: unknown): { flow: KiroIDCFlow; invalid: boolean }
|
||||
return { flow: normalizeKiroIDCFlow(normalized), invalid: false };
|
||||
}
|
||||
|
||||
function parseGitLabAuthMode(raw: unknown): { mode: 'oauth' | 'pat'; invalid: boolean } {
|
||||
if (raw === undefined || raw === null || raw === '') {
|
||||
return { mode: 'oauth', invalid: false };
|
||||
}
|
||||
if (typeof raw !== 'string') {
|
||||
return { mode: 'oauth', invalid: true };
|
||||
}
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
if (normalized === 'oauth' || normalized === 'pat') {
|
||||
return { mode: normalized, invalid: false };
|
||||
}
|
||||
return { mode: 'oauth', invalid: true };
|
||||
}
|
||||
|
||||
export function getKiroStartIDCValidationError(options: {
|
||||
kiroMethod: KiroAuthMethod;
|
||||
kiroIDCStartUrl?: string;
|
||||
@@ -595,6 +618,13 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
||||
const kiroIDCRegion =
|
||||
typeof requestBody.kiroIDCRegion === 'string' ? requestBody.kiroIDCRegion.trim() : undefined;
|
||||
const kiroIDCFlowRaw = requestBody.kiroIDCFlow;
|
||||
const gitlabAuthModeRaw = requestBody.gitlabAuthMode;
|
||||
const gitlabBaseUrl =
|
||||
typeof requestBody.gitlabBaseUrl === 'string' ? requestBody.gitlabBaseUrl.trim() : undefined;
|
||||
const gitlabPersonalAccessToken =
|
||||
typeof requestBody.gitlabPersonalAccessToken === 'string'
|
||||
? requestBody.gitlabPersonalAccessToken.trim()
|
||||
: undefined;
|
||||
const riskAcknowledgement = requestBody.riskAcknowledgement;
|
||||
const target = getProxyTarget();
|
||||
if (target.isRemote) {
|
||||
@@ -605,6 +635,8 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
||||
const nickname = nicknameRaw?.trim();
|
||||
const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw);
|
||||
const { flow: kiroIDCFlow, invalid: invalidKiroIDCFlow } = parseKiroIDCFlow(kiroIDCFlowRaw);
|
||||
const { mode: gitlabAuthMode, invalid: invalidGitLabAuthMode } =
|
||||
parseGitLabAuthMode(gitlabAuthModeRaw);
|
||||
|
||||
// Validate provider
|
||||
if (!validProviders.includes(provider as CLIProxyProvider)) {
|
||||
@@ -620,6 +652,14 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === 'gitlab' && invalidGitLabAuthMode) {
|
||||
res.status(400).json({
|
||||
error: 'Invalid gitlabAuthMode. Supported: oauth, pat',
|
||||
code: 'INVALID_GITLAB_AUTH_MODE',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === 'kiro') {
|
||||
const kiroIDCValidationError = getKiroStartIDCValidationError({
|
||||
kiroMethod,
|
||||
@@ -643,6 +683,92 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === 'gitlab' && gitlabAuthMode === 'pat') {
|
||||
if (!gitlabPersonalAccessToken) {
|
||||
res.status(400).json({
|
||||
error: 'gitlabPersonalAccessToken is required when gitlabAuthMode=pat',
|
||||
code: 'MISSING_GITLAB_PAT',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const localProvider = provider as CLIProxyProvider;
|
||||
const knownTokenFiles = listProviderTokenSnapshots(localProvider);
|
||||
const response = await fetch(buildProxyUrl(target, '/v0/management/gitlab-auth-url'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...buildManagementHeaders(target),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...(gitlabBaseUrl ? { base_url: gitlabBaseUrl } : {}),
|
||||
personal_access_token: gitlabPersonalAccessToken,
|
||||
}),
|
||||
});
|
||||
|
||||
const responseBody = await response.text();
|
||||
const parsedResponse = parseGitLabPatAuthResponse(
|
||||
response.ok,
|
||||
response.status,
|
||||
responseBody,
|
||||
gitlabPersonalAccessToken
|
||||
);
|
||||
if (!parsedResponse.ok) {
|
||||
res.status(response.ok ? 400 : response.status).json({
|
||||
error: parsedResponse.errorMessage || 'GitLab PAT authentication failed',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenSnapshot = findNewTokenSnapshot(
|
||||
listProviderTokenSnapshots(localProvider),
|
||||
knownTokenFiles
|
||||
);
|
||||
if (!tokenSnapshot) {
|
||||
res.status(409).json({
|
||||
error: 'GitLab PAT authentication completed, but CCS could not find the saved token.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const account = registerAccountFromToken(
|
||||
localProvider,
|
||||
getProviderTokenDir(localProvider),
|
||||
nickname,
|
||||
false,
|
||||
tokenSnapshot.file
|
||||
);
|
||||
if (!account) {
|
||||
res.status(409).json({
|
||||
error: 'GitLab PAT authentication succeeded, but account registration failed.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureManagedModelPrefixes([account.provider]);
|
||||
} catch {
|
||||
// Keep auth success path non-fatal when prefix repair cannot run.
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
account: {
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
nickname: account.nickname,
|
||||
provider: account.provider,
|
||||
isDefault: account.isDefault,
|
||||
},
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
respondInternalError(res, error, 'Failed to start GitLab PAT flow.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const existingAccounts = getProviderAccounts(provider as CLIProxyProvider);
|
||||
const nicknameError = getStartAuthNicknameError(
|
||||
provider as CLIProxyProvider,
|
||||
@@ -673,6 +799,8 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
||||
kiroIDCStartUrl: provider === 'kiro' ? kiroIDCStartUrl : undefined,
|
||||
kiroIDCRegion: provider === 'kiro' ? kiroIDCRegion : undefined,
|
||||
kiroIDCFlow: provider === 'kiro' && kiroMethod === 'idc' ? kiroIDCFlow : undefined,
|
||||
gitlabAuthMode: provider === 'gitlab' ? gitlabAuthMode : undefined,
|
||||
gitlabBaseUrl: provider === 'gitlab' ? gitlabBaseUrl : undefined,
|
||||
fromUI: true, // Enable project selection prompt in UI
|
||||
noIncognito, // Kiro: use normal browser if enabled
|
||||
});
|
||||
@@ -829,9 +957,14 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
|
||||
req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>) : {};
|
||||
const nicknameRaw = typeof requestBody.nickname === 'string' ? requestBody.nickname : undefined;
|
||||
const kiroMethodRaw = requestBody.kiroMethod;
|
||||
const gitlabAuthModeRaw = requestBody.gitlabAuthMode;
|
||||
const gitlabBaseUrl =
|
||||
typeof requestBody.gitlabBaseUrl === 'string' ? requestBody.gitlabBaseUrl.trim() : undefined;
|
||||
const riskAcknowledgement = requestBody.riskAcknowledgement;
|
||||
const nickname = nicknameRaw?.trim();
|
||||
const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw);
|
||||
const { mode: gitlabAuthMode, invalid: invalidGitLabAuthMode } =
|
||||
parseGitLabAuthMode(gitlabAuthModeRaw);
|
||||
|
||||
// Check remote mode
|
||||
const target = getProxyTarget();
|
||||
@@ -854,6 +987,22 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === 'gitlab' && invalidGitLabAuthMode) {
|
||||
res.status(400).json({
|
||||
error: 'Invalid gitlabAuthMode. Supported: oauth, pat',
|
||||
code: 'INVALID_GITLAB_AUTH_MODE',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === 'gitlab' && gitlabAuthMode === 'pat') {
|
||||
res.status(400).json({
|
||||
error: 'GitLab PAT login must use /api/cliproxy/auth/gitlab/start',
|
||||
code: 'GITLAB_PAT_REQUIRES_START',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === 'agy' && !isAntigravityResponsibilityBypassEnabled()) {
|
||||
const validation = validateAntigravityRiskAcknowledgement(riskAcknowledgement);
|
||||
if (!validation.valid) {
|
||||
@@ -892,11 +1041,18 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
|
||||
provider === 'kiro' && kiroManagementMethod
|
||||
? `&method=${encodeURIComponent(kiroManagementMethod)}`
|
||||
: '';
|
||||
const gitlabQuery =
|
||||
provider === 'gitlab' && gitlabBaseUrl
|
||||
? `&base_url=${encodeURIComponent(gitlabBaseUrl)}`
|
||||
: '';
|
||||
|
||||
// Call CLIProxyAPI to start OAuth and get auth URL
|
||||
// CLIProxyAPI management routes are under /v0/management prefix
|
||||
const response = await fetch(
|
||||
buildProxyUrl(target, `/v0/management/${authUrlProvider}-auth-url?is_webui=true${kiroQuery}`),
|
||||
buildProxyUrl(
|
||||
target,
|
||||
`/v0/management/${authUrlProvider}-auth-url?is_webui=true${kiroQuery}${gitlabQuery}`
|
||||
),
|
||||
{ headers: buildManagementHeaders(target) }
|
||||
);
|
||||
|
||||
@@ -1022,6 +1178,7 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise<voi
|
||||
} catch {
|
||||
// Keep manual callback success path non-fatal when prefix repair cannot run.
|
||||
}
|
||||
invalidateQuotaForRegisteredAccount(account);
|
||||
res.json({
|
||||
status: 'ok',
|
||||
account: {
|
||||
@@ -1173,6 +1330,7 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
|
||||
// Keep manual callback success path non-fatal when prefix repair cannot run.
|
||||
}
|
||||
}
|
||||
invalidateQuotaForRegisteredAccount(account);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -1205,6 +1363,8 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
|
||||
return;
|
||||
}
|
||||
|
||||
invalidateQuotaForRegisteredAccount(account);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
account: {
|
||||
|
||||
@@ -673,6 +673,10 @@ router.put('/', (req: Request, res: Response): void => {
|
||||
currentConfig.cliproxy = mergeCliproxyConfig(currentConfig, config);
|
||||
}
|
||||
|
||||
if (config.proxy !== undefined) {
|
||||
currentConfig.proxy = config.proxy;
|
||||
}
|
||||
|
||||
if (config.preferences !== undefined) {
|
||||
currentConfig.preferences = config.preferences;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import settingsRoutes from './settings-routes';
|
||||
import channelsRoutes from './channels-routes';
|
||||
import websearchRoutes from './websearch-routes';
|
||||
import imageAnalysisRoutes from './image-analysis-routes';
|
||||
import browserRoutes from './browser-routes';
|
||||
import cliproxyAuthRoutes from './cliproxy-auth-routes';
|
||||
import cliproxyStatsRoutes from './cliproxy-stats-routes';
|
||||
import cliproxyRoutingRoutes from './cliproxy-routing-routes';
|
||||
@@ -97,6 +98,7 @@ apiRoutes.use('/cliproxy/openai-compat', providerRoutes);
|
||||
|
||||
// ==================== WebSearch ====================
|
||||
apiRoutes.use('/websearch', websearchRoutes);
|
||||
apiRoutes.use('/browser', browserRoutes);
|
||||
apiRoutes.use('/image-analysis', imageAnalysisRoutes);
|
||||
|
||||
// ==================== Copilot ====================
|
||||
@@ -104,6 +106,7 @@ apiRoutes.use('/copilot', copilotRoutes);
|
||||
|
||||
// ==================== Cursor ====================
|
||||
apiRoutes.use('/cursor', cursorRoutes);
|
||||
apiRoutes.use('/legacy/cursor', cursorRoutes);
|
||||
|
||||
// ==================== Droid ====================
|
||||
apiRoutes.use('/droid', droidRoutes);
|
||||
|
||||
@@ -30,6 +30,7 @@ import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middlewar
|
||||
import type { Settings } from '../../types/config';
|
||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
|
||||
import { resolveProviderSettingsPath } from '../../cliproxy/config/env-builder';
|
||||
import { expandPath } from '../../utils/helpers';
|
||||
import {
|
||||
canonicalizeModelIdForProvider,
|
||||
@@ -101,6 +102,17 @@ function resolveSettingsPath(profileOrVariant: string): string {
|
||||
const ccsDir = getCcsDir();
|
||||
const resolvedCcsDir = path.resolve(ccsDir);
|
||||
|
||||
const directProvider = mapExternalProviderName(profileOrVariant);
|
||||
if (directProvider) {
|
||||
if (profileOrVariant !== directProvider) {
|
||||
return resolvePathWithin(
|
||||
resolvedCcsDir,
|
||||
path.join(resolvedCcsDir, `${profileOrVariant}.settings.json`)
|
||||
);
|
||||
}
|
||||
return path.resolve(resolveProviderSettingsPath(directProvider));
|
||||
}
|
||||
|
||||
// Check if this is a variant
|
||||
const variants = listVariants();
|
||||
const variant = variants[profileOrVariant];
|
||||
|
||||
@@ -114,6 +114,7 @@ const COMPATIBLE_CLI_DOCS_REGISTRY: Record<string, CompatibleCliDocsRegistryEntr
|
||||
'CLI --profile selects a named [profiles.<name>] overlay on top of base config',
|
||||
'CCS-backed Codex launches may apply transient -c overrides and CCS_CODEX_API_KEY',
|
||||
'Official docs treat model_providers, mcp_servers, features, and project trust as schema-backed config surfaces',
|
||||
'CCS-managed browser tooling for Codex should be configured from Settings > Browser, not by editing the ccs_browser MCP entry directly',
|
||||
],
|
||||
links: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as http from 'http';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
import getPort from 'get-port';
|
||||
|
||||
const DIST_ENTRY = path.join(process.cwd(), 'dist', 'ccs.js');
|
||||
|
||||
let originalCcsHome: string | undefined;
|
||||
let tempDir: string;
|
||||
let upstreamServer: http.Server;
|
||||
let upstreamBody: unknown;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-routing-e2e-'));
|
||||
upstreamBody = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
upstreamServer?.close();
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
spawnSync(process.execPath, [DIST_ENTRY, 'proxy', 'stop'], {
|
||||
env: { ...process.env, CCS_HOME: tempDir },
|
||||
});
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function startMockUpstream(port: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
upstreamServer = http.createServer(async (req, res) => {
|
||||
let body = '';
|
||||
for await (const chunk of req) {
|
||||
body += chunk.toString();
|
||||
}
|
||||
upstreamBody = JSON.parse(body);
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
||||
res.write(
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"}}]}\n\n'
|
||||
);
|
||||
res.write(
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"search","arguments":"{\\"q\\":\\"docs\\"}"}}]}}]}\n\n'
|
||||
);
|
||||
res.write(
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":9,"completion_tokens":4}}\n\n'
|
||||
);
|
||||
res.end('data: [DONE]\n\n');
|
||||
});
|
||||
upstreamServer.listen(port, '127.0.0.1', () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
function runCli(args: string[], env: Record<string, string>): Promise<{ code: number | null; stdout: string; stderr: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [DIST_ENTRY, ...args], {
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
resolve({ code, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
const result = spawnSync(process.execPath, ['run', 'build'], {
|
||||
encoding: 'utf8',
|
||||
env: process.env,
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
describe('openai provider routing e2e', () => {
|
||||
it('routes a settings profile through the local proxy into an OpenAI-compatible upstream', async () => {
|
||||
const upstreamPort = await getPort();
|
||||
await startMockUpstream(upstreamPort);
|
||||
|
||||
const ccsDir = path.join(tempDir, '.ccs');
|
||||
const binDir = path.join(tempDir, 'bin');
|
||||
const outputPath = path.join(tempDir, 'claude-output.json');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
|
||||
const settingsPath = path.join(ccsDir, 'hf.settings.json');
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.json'),
|
||||
JSON.stringify({ profiles: { hf: settingsPath } }, null, 2),
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${upstreamPort}`,
|
||||
ANTHROPIC_AUTH_TOKEN: 'hf_token',
|
||||
ANTHROPIC_MODEL: 'hf-model',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(binDir, 'claude'),
|
||||
`#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
(async () => {
|
||||
const response = await fetch(\`\${process.env.ANTHROPIC_BASE_URL}/v1/messages\`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
'x-api-key': process.env.ANTHROPIC_AUTH_TOKEN,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: process.env.ANTHROPIC_MODEL,
|
||||
stream: true,
|
||||
tools: [{ name: 'search', description: 'Search docs', input_schema: { type: 'object' } }],
|
||||
messages: [{ role: 'user', content: 'Find docs' }],
|
||||
}),
|
||||
});
|
||||
const text = await response.text();
|
||||
fs.writeFileSync(process.env.CCS_E2E_OUTPUT, JSON.stringify({
|
||||
status: response.status,
|
||||
baseUrl: process.env.ANTHROPIC_BASE_URL,
|
||||
authToken: process.env.ANTHROPIC_AUTH_TOKEN,
|
||||
text
|
||||
}, null, 2));
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
`,
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
|
||||
const result = await runCli(['hf'], {
|
||||
...process.env,
|
||||
CCS_HOME: tempDir,
|
||||
CCS_E2E_OUTPUT: outputPath,
|
||||
PATH: `${binDir}:${process.env.PATH || ''}`,
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
const payload = JSON.parse(fs.readFileSync(outputPath, 'utf8')) as {
|
||||
status: number;
|
||||
baseUrl: string;
|
||||
authToken: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
expect(payload.status).toBe(200);
|
||||
expect(payload.baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
||||
expect(payload.authToken).toMatch(/^[a-f0-9]{48}$/);
|
||||
expect(payload.text).toContain('event: message_start');
|
||||
expect(payload.text).toContain('tool_use');
|
||||
expect(payload.text).toContain('message_stop');
|
||||
|
||||
const parsedUpstream = upstreamBody as {
|
||||
messages?: Array<{ role: string; content: string }>;
|
||||
tools?: Array<{ type: string }>;
|
||||
};
|
||||
expect(parsedUpstream.messages?.[0]).toEqual({ role: 'user', content: 'Find docs' });
|
||||
expect(parsedUpstream.tools?.[0]?.type).toBe('function');
|
||||
}, 35000);
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
import getPort from 'get-port';
|
||||
|
||||
const DIST_ENTRY = path.join(process.cwd(), 'dist', 'ccs.js');
|
||||
|
||||
let originalCcsHome: string | undefined;
|
||||
let tempDir: string;
|
||||
|
||||
function runCli(args: string[], extraEnv: Record<string, string> = {}) {
|
||||
return spawnSync(process.execPath, [DIST_ENTRY, ...args], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_HOME: tempDir,
|
||||
...extraEnv,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
const result = spawnSync(process.execPath, ['run', 'build'], {
|
||||
encoding: 'utf8',
|
||||
env: process.env,
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-e2e-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
runCli(['proxy', 'stop']);
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('proxy command e2e', () => {
|
||||
it('starts, reports status, activates, and stops via the built CLI', async () => {
|
||||
const port = await getPort();
|
||||
const ccsDir = path.join(tempDir, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
const settingsPath = path.join(ccsDir, 'hf.settings.json');
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.json'),
|
||||
JSON.stringify({ profiles: { hf: settingsPath } }, null, 2),
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const started = runCli(['proxy', 'start', 'hf', '--port', String(port), '--host', '127.0.0.1']);
|
||||
expect(started.status).toBe(0);
|
||||
|
||||
const status = runCli(['proxy', 'status']);
|
||||
expect(status.stdout).toContain(`Proxy running on port ${port}`);
|
||||
expect(status.stdout).toContain('Host: 127.0.0.1');
|
||||
expect(status.stdout).toContain('Profile: hf');
|
||||
|
||||
const activate = runCli(['proxy', 'activate', '--shell', 'bash']);
|
||||
expect(activate.stdout).toContain(`export ANTHROPIC_BASE_URL='http://127.0.0.1:${port}'`);
|
||||
expect(activate.stdout).toMatch(/export ANTHROPIC_AUTH_TOKEN='[a-f0-9]{48}'/);
|
||||
expect(activate.stdout).toContain("export DISABLE_TELEMETRY='1'");
|
||||
expect(activate.stdout).toContain("export DISABLE_COST_WARNINGS='1'");
|
||||
expect(activate.stdout).toContain("export API_TIMEOUT_MS='600000'");
|
||||
expect(activate.stdout).toContain("export NO_PROXY='127.0.0.1,localhost'");
|
||||
|
||||
const activateFish = runCli(['proxy', 'activate', '--fish']);
|
||||
expect(activateFish.stdout).toContain(`set -gx ANTHROPIC_BASE_URL 'http://127.0.0.1:${port}'`);
|
||||
|
||||
const health = await fetch(`http://127.0.0.1:${port}/health`);
|
||||
expect(health.status).toBe(200);
|
||||
|
||||
const info = await fetch(`http://127.0.0.1:${port}/`);
|
||||
expect(info.status).toBe(200);
|
||||
await expect(info.json()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
service: 'ccs-openai-compat-proxy',
|
||||
bind: {
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
},
|
||||
profile: {
|
||||
name: 'hf',
|
||||
},
|
||||
});
|
||||
|
||||
const stopped = runCli(['proxy', 'stop']);
|
||||
expect(stopped.status).toBe(0);
|
||||
}, 35000);
|
||||
});
|
||||
@@ -74,7 +74,7 @@ describe('cursor daemon lifecycle smoke', () => {
|
||||
};
|
||||
expect(anthropicBody.type).toBe('error');
|
||||
expect(anthropicBody.error?.type).toBe('authentication_error');
|
||||
expect(anthropicBody.error?.message).toContain('Run `ccs cursor auth` first');
|
||||
expect(anthropicBody.error?.message).toContain('Run `ccs legacy cursor auth` first');
|
||||
|
||||
const stopResult = await stopDaemon();
|
||||
expect(stopResult.success).toBe(true);
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import getPort from 'get-port';
|
||||
import {
|
||||
getOpenAICompatProxyStatus,
|
||||
startOpenAICompatProxy,
|
||||
stopOpenAICompatProxy,
|
||||
} from '../../../src/proxy/proxy-daemon';
|
||||
import { resolveOpenAICompatProfileConfig } from '../../../src/proxy/profile-router';
|
||||
|
||||
let originalCcsHome: string | undefined;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-openai-proxy-'));
|
||||
process.env.CCS_HOME = tempDir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await stopOpenAICompatProxy();
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('openai proxy daemon lifecycle', () => {
|
||||
it('starts, reports status, serves health/models, and stops', async () => {
|
||||
const port = await getPort();
|
||||
const settingsPath = path.join(tempDir, 'hf.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const profile = resolveOpenAICompatProfileConfig('hf', settingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
});
|
||||
if (!profile) {
|
||||
throw new Error('Expected an OpenAI-compatible profile');
|
||||
}
|
||||
|
||||
const started = await startOpenAICompatProxy(profile, { port });
|
||||
expect(started.success).toBe(true);
|
||||
expect(started.authToken).toBeTruthy();
|
||||
|
||||
const status = await getOpenAICompatProxyStatus();
|
||||
expect(status.running).toBe(true);
|
||||
expect(status.profileName).toBe('hf');
|
||||
expect(status.authToken).toBe(started.authToken);
|
||||
|
||||
const health = await fetch(`http://127.0.0.1:${port}/health`);
|
||||
expect(health.status).toBe(200);
|
||||
|
||||
const models = (await (
|
||||
await fetch(`http://127.0.0.1:${port}/v1/models`, {
|
||||
headers: { 'x-api-key': started.authToken! },
|
||||
})
|
||||
).json()) as { data?: Array<{ id: string }> };
|
||||
expect(models.data?.map((entry) => entry.id)).toEqual(['qwen3-coder']);
|
||||
|
||||
const stopped = await stopOpenAICompatProxy();
|
||||
expect(stopped.success).toBe(true);
|
||||
expect((await getOpenAICompatProxyStatus()).running).toBe(false);
|
||||
}, 35000);
|
||||
|
||||
it('refuses to replace a running proxy for a different profile', async () => {
|
||||
const firstPort = await getPort();
|
||||
const firstSettingsPath = path.join(tempDir, 'hf.settings.json');
|
||||
fs.writeFileSync(
|
||||
firstSettingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
const firstProfile = resolveOpenAICompatProfileConfig('hf', firstSettingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
});
|
||||
if (!firstProfile) {
|
||||
throw new Error('Expected first OpenAI-compatible profile');
|
||||
}
|
||||
|
||||
const firstStart = await startOpenAICompatProxy(firstProfile, { port: firstPort });
|
||||
expect(firstStart.success).toBe(true);
|
||||
|
||||
const secondProfile = resolveOpenAICompatProfileConfig('openai', path.join(tempDir, 'openai.settings.json'), {
|
||||
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-openai',
|
||||
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||
});
|
||||
if (!secondProfile) {
|
||||
throw new Error('Expected second OpenAI-compatible profile');
|
||||
}
|
||||
|
||||
const secondStart = await startOpenAICompatProxy(secondProfile, { port: await getPort() });
|
||||
expect(secondStart.success).toBe(false);
|
||||
expect(secondStart.error).toContain('Proxy already running for profile "hf"');
|
||||
|
||||
const health = await fetch(`http://127.0.0.1:${firstPort}/health`);
|
||||
expect(health.status).toBe(200);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 213 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user