mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 12:21:20 +00:00
Merge pull request #175 from kaitranntt/dev
feat(cliproxy): add remote CLIProxy routing support
This commit is contained in:
@@ -82,22 +82,31 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Get version from package.json (updated by semantic-release)
|
||||
# Get version from package.json
|
||||
VERSION=$(jq -r '.version' package.json)
|
||||
|
||||
# Find commits since last release
|
||||
LAST_RELEASE=$(git log --oneline --grep="chore(release):" -n 2 | tail -1 | cut -d' ' -f1)
|
||||
RANGE="${LAST_RELEASE:-HEAD~20}..HEAD"
|
||||
# Get commits ONLY since last dev tag (not all release commits)
|
||||
# This prevents re-tagging issues from older releases
|
||||
LAST_DEV_TAG=$(git tag -l "v*-dev.*" --sort=-v:refname | head -1 || echo "")
|
||||
|
||||
if [ -n "$LAST_DEV_TAG" ]; then
|
||||
# Commits between last dev tag and current (excluding release commit)
|
||||
RANGE="${LAST_DEV_TAG}..HEAD~1"
|
||||
else
|
||||
# First dev release - check commits since last stable tag
|
||||
STABLE_TAG=$(git tag -l "v[0-9]*.[0-9]*.[0-9]" --merged origin/main --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1 || echo "")
|
||||
RANGE="${STABLE_TAG:-HEAD~10}..HEAD~1"
|
||||
fi
|
||||
|
||||
echo "Checking commits in range: $RANGE"
|
||||
|
||||
# Extract issue numbers
|
||||
ISSUES=$(git log $RANGE --pretty=format:"%s %b" | \
|
||||
# Extract issue numbers from commit messages
|
||||
ISSUES=$(git log $RANGE --pretty=format:"%s %b" 2>/dev/null | \
|
||||
grep -oE "(Fixes|Closes|Resolves|Refs?) #[0-9]+" | \
|
||||
grep -oE "#[0-9]+" | sort -u || true)
|
||||
|
||||
if [[ -z "$ISSUES" ]]; then
|
||||
echo "No linked issues found"
|
||||
echo "No linked issues found in range"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -110,13 +119,16 @@ jobs:
|
||||
for ISSUE in $ISSUES; do
|
||||
NUM=${ISSUE#\#}
|
||||
|
||||
# Skip if already tagged
|
||||
if gh issue view "$NUM" --repo "${{ github.repository }}" --json labels --jq '.labels[].name' | grep -q "released-dev"; then
|
||||
echo "Issue #$NUM already tagged, skipping"
|
||||
# Skip if already has ANY release label (prevents duplicate comments)
|
||||
RELEASE_LABELS=$(gh issue view "$NUM" --repo "${{ github.repository }}" --json labels --jq '[.labels[].name | select(startswith("released"))] | length' 2>/dev/null || echo "0")
|
||||
if [[ "$RELEASE_LABELS" -gt 0 ]]; then
|
||||
echo "Issue #$NUM already released, skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Tagging issue #$NUM"
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body "[i] Available in \`$VERSION\`. Update: \`ccs update --dev\`" || true
|
||||
gh issue edit "$NUM" --add-label "released-dev" --repo "${{ github.repository }}" || true
|
||||
# Remove pending-release (transition), add released-dev
|
||||
gh issue edit "$NUM" --remove-label "pending-release" --add-label "released-dev" --repo "${{ github.repository }}" 2>/dev/null || \
|
||||
gh issue edit "$NUM" --add-label "released-dev" --repo "${{ github.repository }}" || true
|
||||
gh issue comment "$NUM" --repo "${{ github.repository }}" --body "[bot] Available in \`$VERSION\`. Install: \`ccs update --dev\`" || true
|
||||
done
|
||||
|
||||
@@ -73,3 +73,27 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
node scripts/send-discord-release.cjs production "$DISCORD_WEBHOOK_URL"
|
||||
|
||||
- name: Cleanup stale labels on released issues
|
||||
if: success() && steps.release.outputs.released == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# semantic-release already adds "released" label via .releaserc.cjs
|
||||
# This step removes transitional labels from issues now in stable
|
||||
|
||||
# Find issues with both "released" and "released-dev" labels
|
||||
ISSUES=$(gh issue list --label "released" --label "released-dev" --state all --json number --jq '.[].number' 2>/dev/null || echo "")
|
||||
|
||||
for NUM in $ISSUES; do
|
||||
echo "Cleaning up labels on issue #$NUM"
|
||||
gh issue edit "$NUM" --remove-label "released-dev" --remove-label "pending-release" --repo "${{ github.repository }}" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# Also clean pending-release from any issues with released label
|
||||
PENDING=$(gh issue list --label "released" --label "pending-release" --state all --json number --jq '.[].number' 2>/dev/null || echo "")
|
||||
|
||||
for NUM in $PENDING; do
|
||||
echo "Removing pending-release from issue #$NUM"
|
||||
gh issue edit "$NUM" --remove-label "pending-release" --repo "${{ github.repository }}" 2>/dev/null || true
|
||||
done
|
||||
|
||||
@@ -255,6 +255,37 @@ See [docs/websearch.md](./docs/websearch.md) for detailed configuration and trou
|
||||
|
||||
<br>
|
||||
|
||||
## Remote CLIProxy
|
||||
|
||||
CCS v7.x supports connecting to remote CLIProxyAPI instances, enabling:
|
||||
- **Team sharing**: One CLIProxyAPI server for multiple developers
|
||||
- **Cost optimization**: Centralized API key management
|
||||
- **Network isolation**: Keep API credentials on a secure server
|
||||
|
||||
### Quick Setup
|
||||
|
||||
Configure via dashboard (**Settings > CLIProxy Server**) or CLI flags:
|
||||
|
||||
```bash
|
||||
ccs gemini --proxy-host 192.168.1.100 --proxy-port 8317
|
||||
ccs codex --proxy-host proxy.example.com --proxy-protocol https
|
||||
```
|
||||
|
||||
### CLI Flags
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--proxy-host` | Remote proxy hostname or IP |
|
||||
| `--proxy-port` | Remote proxy port (default: 8317 for HTTP, 443 for HTTPS) |
|
||||
| `--proxy-protocol` | `http` or `https` (default: http) |
|
||||
| `--proxy-auth-token` | Bearer token for authentication |
|
||||
| `--local-proxy` | Force local mode, ignore remote config |
|
||||
| `--remote-only` | Fail if remote unreachable (no fallback) |
|
||||
|
||||
See [Remote Proxy documentation](https://docs.ccs.kaitran.ca/features/remote-proxy) for detailed setup.
|
||||
|
||||
<br>
|
||||
|
||||
## Documentation
|
||||
|
||||
| Topic | Link |
|
||||
|
||||
@@ -580,6 +580,26 @@ interface Config { ... }
|
||||
export interface Config { ... }
|
||||
```
|
||||
|
||||
### 5. Config Priority Pattern
|
||||
|
||||
When resolving configuration from multiple sources, follow this priority order:
|
||||
|
||||
```typescript
|
||||
// proxy-config-resolver.ts pattern
|
||||
// Priority: CLI flags > Environment variables > config.yaml > defaults
|
||||
|
||||
const resolved = {
|
||||
...DEFAULT_CONFIG, // 4. Defaults (lowest)
|
||||
...yamlConfig, // 3. config.yaml
|
||||
...envConfig, // 2. Environment variables
|
||||
...cliFlags, // 1. CLI flags (highest)
|
||||
};
|
||||
```
|
||||
|
||||
This pattern is used in:
|
||||
- `src/cliproxy/proxy-config-resolver.ts` - Remote proxy config
|
||||
- `src/config/unified-config-loader.ts` - Main config loading
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Last Updated: 2025-12-21
|
||||
|
||||
Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure).
|
||||
Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure) and v7.1 Remote CLIProxy feature.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
@@ -79,6 +79,9 @@ src/
|
||||
│ ├── service-manager.ts # Background service
|
||||
│ ├── proxy-detector.ts # Running proxy detection
|
||||
│ ├── startup-lock.ts # Race condition prevention
|
||||
│ ├── remote-proxy-client.ts # Remote proxy health checks (NEW v7.1)
|
||||
│ ├── proxy-config-resolver.ts # CLI/env/config merging (NEW v7.1)
|
||||
│ ├── types.ts # ResolvedProxyConfig for local/remote modes
|
||||
│ └── [more files...]
|
||||
│
|
||||
├── copilot/ # GitHub Copilot integration
|
||||
@@ -161,6 +164,7 @@ src/
|
||||
| Auth | `auth/`, `cliproxy/auth/` | Authentication across providers |
|
||||
| Config | `config/`, `types/` | Configuration & type definitions |
|
||||
| Providers | `cliproxy/`, `copilot/`, `glmt/` | Provider integrations |
|
||||
| Remote Proxy | `cliproxy/remote-*.ts`, `proxy-config-resolver.ts` | Remote CLIProxy support (v7.1) |
|
||||
| Services | `web-server/`, `api/` | HTTP server, API services |
|
||||
| Utilities | `utils/`, `management/` | Helpers, diagnostics |
|
||||
|
||||
@@ -462,12 +466,14 @@ tests/
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Total Tests | 497 |
|
||||
| Passing | 497 |
|
||||
| Skipped | 2 |
|
||||
| Failed | 0 |
|
||||
| CLI Tests | 539 |
|
||||
| UI Tests | 99 |
|
||||
| Total Tests | 638 |
|
||||
| Passing | 612 |
|
||||
| Skipped | 6 |
|
||||
| Failed | 0 (CLI), 26 (UI - jsdom setup) |
|
||||
| Coverage Threshold | 90% |
|
||||
| Test Files | 29 |
|
||||
| Test Files | 38 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ Last Updated: 2025-12-21
|
||||
|
||||
**Tagline**: The universal AI profile manager for Claude Code
|
||||
|
||||
**Description**: CLI wrapper enabling seamless switching between multiple Claude accounts and alternative AI providers (GLM, Gemini, Codex, OpenRouter) with a React-based dashboard for configuration management.
|
||||
**Description**: CLI wrapper enabling seamless switching between multiple Claude accounts and alternative AI providers (GLM, Gemini, Codex, OpenRouter) with a React-based dashboard for configuration management. Supports both local and remote CLIProxyAPI instances.
|
||||
|
||||
**Current Version**: v7.x (OpenRouter integration added)
|
||||
**Current Version**: v7.1.x (Remote CLIProxy routing, OpenRouter integration)
|
||||
|
||||
---
|
||||
|
||||
@@ -90,6 +90,14 @@ CCS provides:
|
||||
- Support Gemini CLI, OpenCode, Grok providers
|
||||
- Graceful fallback chain
|
||||
|
||||
### FR-008: Remote CLIProxy Support
|
||||
- Connect to remote CLIProxyAPI instances
|
||||
- CLI flags for proxy configuration (--proxy-host, --proxy-port, etc.)
|
||||
- Environment variable configuration (CCS_PROXY_HOST, etc.)
|
||||
- Fallback to local proxy when remote unreachable
|
||||
- Protocol-based default ports (443 for HTTPS, 8317 for HTTP)
|
||||
- Dashboard UI for remote server configuration and testing
|
||||
|
||||
---
|
||||
|
||||
## Non-Functional Requirements
|
||||
@@ -167,7 +175,7 @@ CCS provides:
|
||||
| Startup time | < 100ms | Achieved |
|
||||
| Dashboard load | < 2s | Achieved |
|
||||
| Error rate | < 1% | Achieved |
|
||||
| Test coverage | > 90% | 90% (497 tests) |
|
||||
| Test coverage | > 90% | 90% (539 CLI + 99 UI tests) |
|
||||
| File size compliance | 100% < 200 lines | 95% |
|
||||
|
||||
---
|
||||
@@ -191,7 +199,16 @@ CCS provides:
|
||||
- [x] Settings page modularization (20 files)
|
||||
- [x] Analytics page modularization (8 files)
|
||||
- [x] Auth monitor modularization (8 files)
|
||||
- [x] Comprehensive test infrastructure (497 tests)
|
||||
- [x] Comprehensive test infrastructure (539 CLI + 99 UI tests)
|
||||
|
||||
### v7.1 Release (Complete)
|
||||
- [x] Remote CLIProxy routing support
|
||||
- [x] CLI flags for remote proxy (--proxy-host, --proxy-port, etc.)
|
||||
- [x] Environment variables for proxy config (CCS_PROXY_*)
|
||||
- [x] Dashboard remote proxy configuration UI
|
||||
- [x] Connection testing with latency display
|
||||
- [x] Fallback to local when remote unreachable
|
||||
- [x] Protocol-based default ports (HTTPS:443, HTTP:8317)
|
||||
|
||||
### v8.0 Release (Planned - Q1 2026)
|
||||
- [ ] Multiple CLIProxyAPI instances (load balancing, failover)
|
||||
|
||||
+12
-9
@@ -17,16 +17,18 @@ All major modularization work is complete. The codebase evolved from monolithic
|
||||
| 3 | CLIProxy | `src/cliproxy/` with auth/, binary/, services/ subdirs |
|
||||
| 4 | Utils/Errors | `src/utils/ui/`, `src/errors/`, `src/management/` |
|
||||
| 5 | UI Components | 5 monster files split into modular dirs (54+ modules) |
|
||||
| 6 | Settings Page | `pages/settings/` (1,781→20 files) |
|
||||
| 7 | Analytics Page | `pages/analytics/` (420→8 files) |
|
||||
| 8 | Auth Monitor | `monitoring/auth-monitor/` (465→8 files) |
|
||||
| 9 | Test Infrastructure | 99 UI tests + 497 CLI tests, 90% coverage |
|
||||
| 6 | Settings Page | `pages/settings/` (1,781->20 files) |
|
||||
| 7 | Analytics Page | `pages/analytics/` (420->8 files) |
|
||||
| 8 | Auth Monitor | `monitoring/auth-monitor/` (465->8 files) |
|
||||
| 9 | Test Infrastructure | 99 UI tests + 539 CLI tests, 90% coverage |
|
||||
| 10 | Remote CLIProxy | `proxy-config-resolver.ts`, `remote-proxy-client.ts` |
|
||||
|
||||
**Metrics Achieved**:
|
||||
- Files >500 lines: 12 → 5 (-58%)
|
||||
- UI files >200 lines: 28 → 8 (-71%)
|
||||
- Barrel exports: 5 → 39 (+680%)
|
||||
- Test coverage: 0% → 90%
|
||||
- Files >500 lines: 12 -> 5 (-58%)
|
||||
- UI files >200 lines: 28 -> 8 (-71%)
|
||||
- Barrel exports: 5 -> 39 (+680%)
|
||||
- Test coverage: 0% -> 90%
|
||||
- Total tests: 638 (539 CLI + 99 UI)
|
||||
|
||||
---
|
||||
|
||||
@@ -59,7 +61,7 @@ All major modularization work is complete. The codebase evolved from monolithic
|
||||
|
||||
| Issue | Title | Type | Status |
|
||||
|-------|-------|------|--------|
|
||||
| #142 | Configure with available CLIProxyAPI | enhancement | **IN PROGRESS** |
|
||||
| #142 | Configure with available CLIProxyAPI | enhancement | **COMPLETE** (v7.1) |
|
||||
| #157 | Support for Kiro auth from CLIProxyAPIPlus | enhancement | - |
|
||||
| #123 | Add More Models | enhancement | Ongoing |
|
||||
| #114 | OpenCode Zen Free model + Auto Rotation API Key | enhancement | - |
|
||||
@@ -157,6 +159,7 @@ worktrees:
|
||||
| Milestone | Status | Target |
|
||||
|-----------|--------|--------|
|
||||
| Modularization (Phases 1-9) | COMPLETE | - |
|
||||
| Remote CLIProxy Support (#142) | COMPLETE | v7.1 |
|
||||
| Critical Bug Fixes (#158, #155, #124) | PLANNED | Q1 2026 |
|
||||
| Multiple CLIProxyAPI Instances | PLANNED | Q1 2026 |
|
||||
| Git Worktree Support | PLANNED | Q1 2026 |
|
||||
|
||||
@@ -13,6 +13,8 @@ CCS is a CLI wrapper that enables seamless switching between multiple Claude acc
|
||||
1. **CLI Application** (`src/`) - Node.js TypeScript CLI
|
||||
2. **Dashboard UI** (`ui/`) - React web application served by Express
|
||||
|
||||
CCS v7.1 adds support for both **local** and **remote** CLIProxyAPI instances.
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| CCS System |
|
||||
@@ -30,10 +32,10 @@ CCS is a CLI wrapper that enables seamless switching between multiple Claude acc
|
||||
| +------------------+ +-----------------+ | Gemini/etc) | |
|
||||
| | +----------------+ |
|
||||
| v |
|
||||
| +-----------------+ |
|
||||
| | CLIProxyAPI | |
|
||||
| | (Binary) | |
|
||||
| +-----------------+ |
|
||||
| +---------------------+ |
|
||||
| | CLIProxyAPI | |
|
||||
| | (Local or Remote) | |
|
||||
| +---------------------+ |
|
||||
| |
|
||||
+===========================================================================+
|
||||
```
|
||||
@@ -350,6 +352,65 @@ CCS is a CLI wrapper that enables seamless switching between multiple Claude acc
|
||||
+------------------+
|
||||
```
|
||||
|
||||
### Remote CLIProxy Flow (v7.1)
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| Remote CLIProxy Architecture |
|
||||
+===========================================================================+
|
||||
|
||||
Config Resolution (proxy-config-resolver.ts)
|
||||
|
|
||||
+---> Priority: CLI flags > ENV vars > config.yaml > defaults
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| ResolvedProxyConfig |
|
||||
| mode: local|remote |
|
||||
+------------------+
|
||||
|
|
||||
+---> [mode = local] ---> Spawn local CLIProxyAPI binary
|
||||
| |
|
||||
| v
|
||||
| localhost:8317
|
||||
|
|
||||
+---> [mode = remote] ---> Connect to remote server
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| Health Check | remote-proxy-client.ts
|
||||
| /v1/models | 2s timeout
|
||||
+------------------+
|
||||
|
|
||||
+---> [reachable] ---> Use remote
|
||||
| |
|
||||
| v
|
||||
| protocol://host:port
|
||||
|
|
||||
+---> [unreachable] ---> Fallback decision
|
||||
|
|
||||
+-----------------------------+
|
||||
|
|
||||
+---> [fallbackEnabled] ---> Start local
|
||||
|
|
||||
+---> [remoteOnly] ---> Fail with error
|
||||
|
||||
CLI Flags:
|
||||
--proxy-host <host> Remote hostname/IP
|
||||
--proxy-port <port> Port (default: 8317 HTTP, 443 HTTPS)
|
||||
--proxy-protocol <proto> http or https
|
||||
--proxy-auth-token <token> Bearer authentication
|
||||
--local-proxy Force local mode
|
||||
--remote-only Fail if remote unreachable
|
||||
|
||||
Environment Variables:
|
||||
CCS_PROXY_HOST Remote hostname
|
||||
CCS_PROXY_PORT Remote port
|
||||
CCS_PROXY_PROTOCOL Protocol (http/https)
|
||||
CCS_PROXY_AUTH_TOKEN Auth token
|
||||
CCS_PROXY_FALLBACK_ENABLED Enable fallback (true/false)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration Architecture
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "7.1.1",
|
||||
"version": "7.1.1-dev.4",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
import { registerSession, unregisterSession, cleanupOrphanedSessions } from './session-tracker';
|
||||
import { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from './proxy-detector';
|
||||
import { withStartupLock } from './startup-lock';
|
||||
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
||||
|
||||
/** Default executor configuration */
|
||||
const DEFAULT_CONFIG: ExecutorConfig = {
|
||||
@@ -126,7 +127,25 @@ export async function execClaudeWithCLIProxy(
|
||||
|
||||
// 0. Resolve proxy configuration (CLI > ENV > config.yaml > defaults)
|
||||
// This filters proxy flags from args and returns resolved config
|
||||
const { config: proxyConfig, remainingArgs: argsWithoutProxy } = resolveProxyConfig(args);
|
||||
const unifiedConfig = loadOrCreateUnifiedConfig();
|
||||
const cliproxyServerConfig = unifiedConfig.cliproxy_server;
|
||||
const { config: proxyConfig, remainingArgs: argsWithoutProxy } = resolveProxyConfig(args, {
|
||||
remote: cliproxyServerConfig?.remote
|
||||
? {
|
||||
enabled: cliproxyServerConfig.remote.enabled,
|
||||
host: cliproxyServerConfig.remote.host,
|
||||
port: cliproxyServerConfig.remote.port,
|
||||
protocol: cliproxyServerConfig.remote.protocol,
|
||||
auth_token: cliproxyServerConfig.remote.auth_token,
|
||||
}
|
||||
: undefined,
|
||||
local: cliproxyServerConfig?.local
|
||||
? {
|
||||
port: cliproxyServerConfig.local.port,
|
||||
auto_start: cliproxyServerConfig.local.auto_start,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// Use resolved port from proxy config (overrides ExecutorConfig)
|
||||
if (proxyConfig.port !== CLIPROXY_DEFAULT_PORT) {
|
||||
@@ -516,14 +535,29 @@ export async function execClaudeWithCLIProxy(
|
||||
|
||||
// 7. Execute Claude CLI with proxied environment
|
||||
// Use remote or local env vars based on mode
|
||||
// When remote is configured (even if using local), pass config for URL rewriting
|
||||
const remoteRewriteConfig =
|
||||
proxyConfig.mode === 'remote' && proxyConfig.host
|
||||
? {
|
||||
host: proxyConfig.host,
|
||||
port: proxyConfig.port,
|
||||
protocol: proxyConfig.protocol,
|
||||
authToken: proxyConfig.authToken,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const envVars = useRemoteProxy
|
||||
? getRemoteEnvVars(provider, {
|
||||
host: proxyConfig.host ?? 'localhost',
|
||||
port: proxyConfig.port,
|
||||
protocol: proxyConfig.protocol,
|
||||
authToken: proxyConfig.authToken,
|
||||
})
|
||||
: getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath);
|
||||
? getRemoteEnvVars(
|
||||
provider,
|
||||
{
|
||||
host: proxyConfig.host ?? 'localhost',
|
||||
port: proxyConfig.port,
|
||||
protocol: proxyConfig.protocol,
|
||||
authToken: proxyConfig.authToken,
|
||||
},
|
||||
cfg.customSettingsPath
|
||||
)
|
||||
: getEffectiveEnvVars(provider, cfg.port, cfg.customSettingsPath, remoteRewriteConfig);
|
||||
const webSearchEnv = getWebSearchHookEnv();
|
||||
const env = {
|
||||
...process.env,
|
||||
|
||||
@@ -392,6 +392,48 @@ function getGlobalEnvVars(): Record<string, string> {
|
||||
return globalEnvConfig.env;
|
||||
}
|
||||
|
||||
/** Remote proxy configuration for URL rewriting */
|
||||
interface RemoteProxyRewriteConfig {
|
||||
host: string;
|
||||
port?: number;
|
||||
protocol: 'http' | 'https';
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite localhost URLs to remote server URLs.
|
||||
* Handles various localhost patterns: 127.0.0.1, localhost, 0.0.0.0
|
||||
*/
|
||||
function rewriteLocalhostUrls(
|
||||
envVars: NodeJS.ProcessEnv,
|
||||
provider: CLIProxyProvider,
|
||||
remoteConfig: RemoteProxyRewriteConfig
|
||||
): NodeJS.ProcessEnv {
|
||||
const result = { ...envVars };
|
||||
const baseUrl = result.ANTHROPIC_BASE_URL;
|
||||
|
||||
if (!baseUrl) return result;
|
||||
|
||||
// Check if URL points to localhost (127.0.0.1, localhost, 0.0.0.0)
|
||||
const localhostPattern = /^https?:\/\/(127\.0\.0\.1|localhost|0\.0\.0\.0)(:\d+)?/i;
|
||||
if (!localhostPattern.test(baseUrl)) return result;
|
||||
|
||||
// Build remote URL with smart port handling
|
||||
const defaultPort = remoteConfig.protocol === 'https' ? 443 : 80;
|
||||
const effectivePort = remoteConfig.port ?? defaultPort;
|
||||
const portSuffix = effectivePort === defaultPort ? '' : `:${effectivePort}`;
|
||||
const remoteBaseUrl = `${remoteConfig.protocol}://${remoteConfig.host}${portSuffix}/api/provider/${provider}`;
|
||||
|
||||
result.ANTHROPIC_BASE_URL = remoteBaseUrl;
|
||||
|
||||
// Update auth token if provided
|
||||
if (remoteConfig.authToken) {
|
||||
result.ANTHROPIC_AUTH_TOKEN = remoteConfig.authToken;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective environment variables for provider
|
||||
*
|
||||
@@ -402,15 +444,20 @@ function getGlobalEnvVars(): Record<string, string> {
|
||||
*
|
||||
* All results are merged with global_env vars (telemetry/reporting disables).
|
||||
* User takes full responsibility for custom settings.
|
||||
*
|
||||
* If remoteRewriteConfig is provided, localhost URLs are rewritten to remote server.
|
||||
*/
|
||||
export function getEffectiveEnvVars(
|
||||
provider: CLIProxyProvider,
|
||||
port: number = CLIPROXY_DEFAULT_PORT,
|
||||
customSettingsPath?: string
|
||||
customSettingsPath?: string,
|
||||
remoteRewriteConfig?: RemoteProxyRewriteConfig
|
||||
): NodeJS.ProcessEnv {
|
||||
// Get global env vars (DISABLE_TELEMETRY, etc.)
|
||||
const globalEnv = getGlobalEnvVars();
|
||||
|
||||
let envVars: NodeJS.ProcessEnv;
|
||||
|
||||
// Priority 1: Custom settings path (for user-defined variants)
|
||||
if (customSettingsPath) {
|
||||
const expandedPath = customSettingsPath.replace(/^~/, require('os').homedir());
|
||||
@@ -421,7 +468,12 @@ export function getEffectiveEnvVars(
|
||||
|
||||
if (settings.env && typeof settings.env === 'object') {
|
||||
// Custom variant settings found - merge with global env
|
||||
return { ...globalEnv, ...settings.env };
|
||||
envVars = { ...globalEnv, ...settings.env };
|
||||
// Apply remote rewrite if configured
|
||||
if (remoteRewriteConfig) {
|
||||
envVars = rewriteLocalhostUrls(envVars, provider, remoteRewriteConfig);
|
||||
}
|
||||
return envVars;
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON - fall through to provider defaults
|
||||
@@ -443,7 +495,12 @@ export function getEffectiveEnvVars(
|
||||
|
||||
if (settings.env && typeof settings.env === 'object') {
|
||||
// User override found - merge with global env
|
||||
return { ...globalEnv, ...settings.env };
|
||||
envVars = { ...globalEnv, ...settings.env };
|
||||
// Apply remote rewrite if configured
|
||||
if (remoteRewriteConfig) {
|
||||
envVars = rewriteLocalhostUrls(envVars, provider, remoteRewriteConfig);
|
||||
}
|
||||
return envVars;
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON or structure - fall through to defaults
|
||||
@@ -483,48 +540,89 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void {
|
||||
/**
|
||||
* Get environment variables for remote proxy mode.
|
||||
* Uses the remote proxy's provider endpoint as the base URL.
|
||||
* Respects user model settings from custom settings path or provider settings file.
|
||||
*
|
||||
* @param provider CLIProxy provider (gemini, codex, agy, qwen, iflow)
|
||||
* @param remoteConfig Remote proxy connection details
|
||||
* @param customSettingsPath Optional path to user's custom settings file
|
||||
* @returns Environment variables for Claude CLI
|
||||
*/
|
||||
export function getRemoteEnvVars(
|
||||
provider: CLIProxyProvider,
|
||||
remoteConfig: { host: string; port?: number; protocol: 'http' | 'https'; authToken?: string }
|
||||
remoteConfig: { host: string; port?: number; protocol: 'http' | 'https'; authToken?: string },
|
||||
customSettingsPath?: string
|
||||
): Record<string, string> {
|
||||
// Build URL with smart port handling - omit if using protocol default
|
||||
const defaultPort = remoteConfig.protocol === 'https' ? 443 : 80;
|
||||
const effectivePort = remoteConfig.port ?? defaultPort;
|
||||
const portSuffix = effectivePort === defaultPort ? '' : `:${effectivePort}`;
|
||||
const baseUrl = `${remoteConfig.protocol}://${remoteConfig.host}${portSuffix}/api/provider/${provider}`;
|
||||
const models = getModelMapping(provider);
|
||||
|
||||
// Get global env vars (DISABLE_TELEMETRY, etc.)
|
||||
const globalEnv = getGlobalEnvVars();
|
||||
|
||||
// Get additional env vars from base config (ANTHROPIC_MAX_TOKENS, etc.)
|
||||
const baseEnvVars = getEnvVarsFromConfig(provider);
|
||||
// Load user settings with priority: custom path > user settings file > base config
|
||||
let userEnvVars: Record<string, string> = {};
|
||||
|
||||
// Filter out core env vars from base config to avoid conflicts
|
||||
const {
|
||||
ANTHROPIC_BASE_URL: _baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: _authToken,
|
||||
ANTHROPIC_MODEL: _model,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: _opusModel,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: _sonnetModel,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: _haikuModel,
|
||||
...additionalEnvVars
|
||||
} = baseEnvVars;
|
||||
// Priority 1: Custom settings path (for user-defined variants)
|
||||
if (customSettingsPath) {
|
||||
const expandedPath = customSettingsPath.replace(/^~/, require('os').homedir());
|
||||
if (fs.existsSync(expandedPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(expandedPath, 'utf-8');
|
||||
const settings: ProviderSettings = JSON.parse(content);
|
||||
if (settings.env && typeof settings.env === 'object') {
|
||||
userEnvVars = settings.env as Record<string, string>;
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON - fall through to provider defaults
|
||||
console.warn(warn(`Invalid settings file: ${customSettingsPath}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Default provider settings file (~/.ccs/{provider}.settings.json)
|
||||
if (Object.keys(userEnvVars).length === 0) {
|
||||
const settingsPath = getProviderSettingsPath(provider);
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(settingsPath, 'utf-8');
|
||||
const settings: ProviderSettings = JSON.parse(content);
|
||||
if (settings.env && typeof settings.env === 'object') {
|
||||
userEnvVars = settings.env as Record<string, string>;
|
||||
}
|
||||
} catch {
|
||||
// Invalid JSON - fall through to base config
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: Base config defaults
|
||||
if (Object.keys(userEnvVars).length === 0) {
|
||||
const models = getModelMapping(provider);
|
||||
const baseEnvVars = getEnvVarsFromConfig(provider);
|
||||
// Filter out URL/auth from base config (we'll set those from remote config)
|
||||
const {
|
||||
ANTHROPIC_BASE_URL: _baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: _authToken,
|
||||
...additionalEnvVars
|
||||
} = baseEnvVars;
|
||||
userEnvVars = {
|
||||
...additionalEnvVars,
|
||||
ANTHROPIC_MODEL: models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel,
|
||||
};
|
||||
}
|
||||
|
||||
// Build final env: global + user settings + remote URL/auth override
|
||||
const env: Record<string, string> = {
|
||||
...globalEnv,
|
||||
...additionalEnvVars,
|
||||
...userEnvVars,
|
||||
// Always override URL and auth token with remote config
|
||||
ANTHROPIC_BASE_URL: baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: remoteConfig.authToken || CCS_INTERNAL_API_KEY,
|
||||
ANTHROPIC_MODEL: models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel,
|
||||
};
|
||||
|
||||
return env;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Proxy Target Resolver
|
||||
*
|
||||
* Determines whether CLIProxyAPI requests should go to local or remote
|
||||
* based on unified config. Used by stats-fetcher, auth-routes, and UI.
|
||||
*/
|
||||
|
||||
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
||||
import type { CliproxyServerConfig } from '../config/unified-config-types';
|
||||
|
||||
/** Default CLIProxyAPI port */
|
||||
const DEFAULT_CLIPROXY_PORT = 8317;
|
||||
|
||||
/** Resolved proxy target for making requests */
|
||||
export interface ProxyTarget {
|
||||
/** Target hostname or IP */
|
||||
host: string;
|
||||
/** Target port */
|
||||
port: number;
|
||||
/** Protocol (http/https) */
|
||||
protocol: 'http' | 'https';
|
||||
/** Optional auth token - only send header if defined and non-empty */
|
||||
authToken?: string;
|
||||
/** True if targeting remote server, false if local */
|
||||
isRemote: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load cliproxy_server configuration from unified config.
|
||||
* Returns undefined if not configured.
|
||||
*/
|
||||
function loadCliproxyServerConfig(): CliproxyServerConfig | undefined {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
return config.cliproxy_server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current CLIProxyAPI target based on unified config.
|
||||
* Returns remote server config if enabled, otherwise localhost.
|
||||
*/
|
||||
export function getProxyTarget(): ProxyTarget {
|
||||
const config = loadCliproxyServerConfig();
|
||||
|
||||
if (config?.remote?.enabled && config.remote?.host) {
|
||||
const protocol = config.remote.protocol ?? 'http';
|
||||
// Default port based on protocol if not specified
|
||||
const defaultPort = protocol === 'https' ? 443 : 80;
|
||||
const port = config.remote.port ?? defaultPort;
|
||||
|
||||
return {
|
||||
host: config.remote.host,
|
||||
port,
|
||||
protocol,
|
||||
authToken: config.remote.auth_token || undefined, // Empty string -> undefined
|
||||
isRemote: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
host: '127.0.0.1',
|
||||
port: config?.local?.port ?? DEFAULT_CLIPROXY_PORT,
|
||||
protocol: 'http',
|
||||
isRemote: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build URL for proxy endpoint
|
||||
* @param target Resolved proxy target
|
||||
* @param path Endpoint path (e.g., '/v0/management/usage')
|
||||
*/
|
||||
export function buildProxyUrl(target: ProxyTarget, path: string): string {
|
||||
// Normalize path to ensure leading slash
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${target.protocol}://${target.host}:${target.port}${normalizedPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build request headers for proxy requests
|
||||
* Handles optional auth token - only adds Authorization header if token is set.
|
||||
*
|
||||
* @param target Resolved proxy target
|
||||
* @param additionalHeaders Extra headers to merge
|
||||
*/
|
||||
export function buildProxyHeaders(
|
||||
target: ProxyTarget,
|
||||
additionalHeaders: Record<string, string> = {}
|
||||
): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
...additionalHeaders,
|
||||
};
|
||||
|
||||
// Only add auth header if token is configured
|
||||
if (target.authToken) {
|
||||
headers['Authorization'] = `Bearer ${target.authToken}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Remote Auth Fetcher
|
||||
* Fetches and transforms auth data from remote CLIProxyAPI.
|
||||
*/
|
||||
|
||||
import {
|
||||
getProxyTarget,
|
||||
buildProxyUrl,
|
||||
buildProxyHeaders,
|
||||
ProxyTarget,
|
||||
} from './proxy-target-resolver';
|
||||
|
||||
/** Timeout for remote fetch requests (ms) */
|
||||
const REMOTE_FETCH_TIMEOUT_MS = 5000;
|
||||
|
||||
/** Remote auth file from CLIProxyAPI /v0/management/auth-files */
|
||||
interface RemoteAuthFile {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
provider: string;
|
||||
email?: string;
|
||||
status: 'active' | 'disabled' | 'unavailable';
|
||||
source: 'file' | 'memory';
|
||||
}
|
||||
|
||||
/** Account info for UI display */
|
||||
export interface RemoteAccountInfo {
|
||||
id: string;
|
||||
email: string;
|
||||
isDefault: boolean;
|
||||
status: 'active' | 'disabled' | 'unavailable';
|
||||
}
|
||||
|
||||
/** Auth status for a provider (UI format) */
|
||||
export interface RemoteAuthStatus {
|
||||
provider: string;
|
||||
displayName: string;
|
||||
authenticated: boolean;
|
||||
tokenFiles: number;
|
||||
accounts: RemoteAccountInfo[];
|
||||
defaultAccount: string | null;
|
||||
source: 'remote';
|
||||
}
|
||||
|
||||
/** Map CLIProxyAPI provider names to CCS internal names */
|
||||
const PROVIDER_MAP: Record<string, string> = {
|
||||
gemini: 'gemini',
|
||||
'gemini-cli': 'gemini', // CLIProxyAPI uses 'gemini-cli' for Gemini CLI auth
|
||||
antigravity: 'agy',
|
||||
codex: 'codex',
|
||||
qwen: 'qwen',
|
||||
iflow: 'iflow',
|
||||
};
|
||||
|
||||
/** Display names for providers */
|
||||
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
||||
gemini: 'Google Gemini',
|
||||
agy: 'AntiGravity',
|
||||
codex: 'Codex',
|
||||
qwen: 'Qwen',
|
||||
iflow: 'iFlow',
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch auth status from remote CLIProxyAPI
|
||||
* @throws Error if remote is unreachable or returns error
|
||||
*/
|
||||
export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise<RemoteAuthStatus[]> {
|
||||
const proxyTarget = target ?? getProxyTarget();
|
||||
|
||||
if (!proxyTarget.isRemote) {
|
||||
throw new Error('fetchRemoteAuthStatus called but remote mode not enabled');
|
||||
}
|
||||
|
||||
const url = buildProxyUrl(proxyTarget, '/v0/management/auth-files');
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), REMOTE_FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: buildProxyHeaders(proxyTarget),
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new Error('Authentication failed - check auth token in settings');
|
||||
}
|
||||
throw new Error(`Remote returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: unknown = await response.json();
|
||||
|
||||
// Validate response structure
|
||||
if (!data || typeof data !== 'object' || !('files' in data) || !Array.isArray(data.files)) {
|
||||
throw new Error('Invalid response format from remote auth endpoint');
|
||||
}
|
||||
|
||||
return transformRemoteAuthFiles(data.files as RemoteAuthFile[]);
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error('Remote proxy connection timed out');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform CLIProxyAPI auth files to CCS AuthStatus format
|
||||
* @param files Array of auth files from remote API
|
||||
*/
|
||||
function transformRemoteAuthFiles(files: RemoteAuthFile[]): RemoteAuthStatus[] {
|
||||
const byProvider = new Map<string, RemoteAuthFile[]>();
|
||||
|
||||
for (const file of files) {
|
||||
const provider = PROVIDER_MAP[file.provider.toLowerCase()];
|
||||
if (!provider) {
|
||||
// Unknown provider, skip (could add logging in debug mode)
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = byProvider.get(provider);
|
||||
if (existing) {
|
||||
existing.push(file);
|
||||
} else {
|
||||
byProvider.set(provider, [file]);
|
||||
}
|
||||
}
|
||||
|
||||
const result: RemoteAuthStatus[] = [];
|
||||
|
||||
for (const [provider, providerFiles] of byProvider) {
|
||||
const activeFiles = providerFiles.filter((f) => f.status === 'active');
|
||||
const accounts: RemoteAccountInfo[] = providerFiles.map((f, idx) => ({
|
||||
id: f.id,
|
||||
email: f.email || f.name || 'Unknown',
|
||||
isDefault: idx === 0,
|
||||
status: f.status,
|
||||
}));
|
||||
|
||||
result.push({
|
||||
provider,
|
||||
displayName: PROVIDER_DISPLAY_NAMES[provider] || provider,
|
||||
authenticated: activeFiles.length > 0,
|
||||
tokenFiles: providerFiles.length,
|
||||
accounts,
|
||||
defaultAccount: accounts.find((a) => a.isDefault)?.id || null,
|
||||
source: 'remote',
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
* Requires usage-statistics-enabled: true in config.yaml.
|
||||
*/
|
||||
|
||||
import { CCS_CONTROL_PANEL_SECRET, CLIPROXY_DEFAULT_PORT } from './config-generator';
|
||||
import { CCS_CONTROL_PANEL_SECRET } from './config-generator';
|
||||
import { getProxyTarget, buildProxyUrl, buildProxyHeaders } from './proxy-target-resolver';
|
||||
|
||||
/** Per-account usage statistics */
|
||||
export interface AccountUsageStats {
|
||||
@@ -95,19 +96,27 @@ interface UsageApiResponse {
|
||||
* @param port CLIProxyAPI port (default: 8317)
|
||||
* @returns Stats object or null if unavailable
|
||||
*/
|
||||
export async function fetchCliproxyStats(
|
||||
port: number = CLIPROXY_DEFAULT_PORT
|
||||
): Promise<CliproxyStats | null> {
|
||||
export async function fetchCliproxyStats(port?: number): Promise<CliproxyStats | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 3000); // 3s timeout
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v0/management/usage`, {
|
||||
// Dynamic target resolution
|
||||
const target = getProxyTarget();
|
||||
// Allow port override for local testing only
|
||||
if (port !== undefined && !target.isRemote) {
|
||||
target.port = port;
|
||||
}
|
||||
const url = buildProxyUrl(target, '/v0/management/usage');
|
||||
|
||||
// For management endpoints, use CCS control panel secret for local, remote auth for remote
|
||||
const headers = target.isRemote
|
||||
? buildProxyHeaders(target)
|
||||
: { Accept: 'application/json', Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}` };
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}`,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
@@ -222,20 +231,27 @@ export interface CliproxyModelsResponse {
|
||||
* @param port CLIProxyAPI port (default: 8317)
|
||||
* @returns Categorized models or null if unavailable
|
||||
*/
|
||||
export async function fetchCliproxyModels(
|
||||
port: number = CLIPROXY_DEFAULT_PORT
|
||||
): Promise<CliproxyModelsResponse | null> {
|
||||
export async function fetchCliproxyModels(port?: number): Promise<CliproxyModelsResponse | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/models`, {
|
||||
// Dynamic target resolution
|
||||
const target = getProxyTarget();
|
||||
// Allow port override for local testing only
|
||||
if (port !== undefined && !target.isRemote) {
|
||||
target.port = port;
|
||||
}
|
||||
const url = buildProxyUrl(target, '/v1/models');
|
||||
|
||||
// For /v1 endpoints: use remote auth token for remote, ccs-internal-managed for local
|
||||
const headers = target.isRemote
|
||||
? buildProxyHeaders(target)
|
||||
: { Accept: 'application/json', Authorization: 'Bearer ccs-internal-managed' };
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
// Use the internal API key for /v1 endpoints
|
||||
Authorization: 'Bearer ccs-internal-managed',
|
||||
},
|
||||
headers,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
@@ -293,19 +309,27 @@ interface ErrorLogsApiResponse {
|
||||
* @param port CLIProxyAPI port (default: 8317)
|
||||
* @returns Array of error log metadata or null if unavailable
|
||||
*/
|
||||
export async function fetchCliproxyErrorLogs(
|
||||
port: number = CLIPROXY_DEFAULT_PORT
|
||||
): Promise<CliproxyErrorLog[] | null> {
|
||||
export async function fetchCliproxyErrorLogs(port?: number): Promise<CliproxyErrorLog[] | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v0/management/request-error-logs`, {
|
||||
// Dynamic target resolution
|
||||
const target = getProxyTarget();
|
||||
// Allow port override for local testing only
|
||||
if (port !== undefined && !target.isRemote) {
|
||||
target.port = port;
|
||||
}
|
||||
const url = buildProxyUrl(target, '/v0/management/request-error-logs');
|
||||
|
||||
// For management endpoints, use CCS control panel secret for local, remote auth for remote
|
||||
const headers = target.isRemote
|
||||
? buildProxyHeaders(target)
|
||||
: { Accept: 'application/json', Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}` };
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}`,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
@@ -329,22 +353,33 @@ export async function fetchCliproxyErrorLogs(
|
||||
*/
|
||||
export async function fetchCliproxyErrorLogContent(
|
||||
name: string,
|
||||
port: number = CLIPROXY_DEFAULT_PORT
|
||||
port?: number
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${port}/v0/management/request-error-logs/${encodeURIComponent(name)}`,
|
||||
{
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}`,
|
||||
},
|
||||
}
|
||||
// Dynamic target resolution
|
||||
const target = getProxyTarget();
|
||||
// Allow port override for local testing only
|
||||
if (port !== undefined && !target.isRemote) {
|
||||
target.port = port;
|
||||
}
|
||||
const url = buildProxyUrl(
|
||||
target,
|
||||
`/v0/management/request-error-logs/${encodeURIComponent(name)}`
|
||||
);
|
||||
|
||||
// For management endpoints, use CCS control panel secret for local, remote auth for remote
|
||||
const headers = target.isRemote
|
||||
? buildProxyHeaders(target)
|
||||
: { Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}` };
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -362,13 +397,21 @@ export async function fetchCliproxyErrorLogContent(
|
||||
* @param port CLIProxyAPI port (default: 8317)
|
||||
* @returns true if proxy is running
|
||||
*/
|
||||
export async function isCliproxyRunning(port: number = CLIPROXY_DEFAULT_PORT): Promise<boolean> {
|
||||
export async function isCliproxyRunning(port?: number): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 1000); // 1s timeout
|
||||
|
||||
// Use root endpoint - CLIProxyAPI returns server info at /
|
||||
const response = await fetch(`http://127.0.0.1:${port}/`, {
|
||||
// Dynamic target resolution
|
||||
const target = getProxyTarget();
|
||||
// Allow port override for local testing only
|
||||
if (port !== undefined && !target.isRemote) {
|
||||
target.port = port;
|
||||
}
|
||||
const url = buildProxyUrl(target, '/');
|
||||
|
||||
// Health check - no auth needed for root endpoint
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Account Routes - CRUD operations for Claude accounts (profiles.json)
|
||||
*
|
||||
* Separated from profile-routes.ts to avoid dual-mounting conflicts.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir } from '../../utils/config-manager';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/accounts - List accounts from profiles.json
|
||||
*/
|
||||
router.get('/', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const profilesPath = path.join(getCcsDir(), 'profiles.json');
|
||||
|
||||
if (!fs.existsSync(profilesPath)) {
|
||||
res.json({ accounts: [], default: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(profilesPath, 'utf8'));
|
||||
const accounts = Object.entries(data.profiles || {}).map(([name, meta]) => {
|
||||
const metadata = meta as Record<string, unknown>;
|
||||
return {
|
||||
name,
|
||||
...metadata,
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ accounts, default: data.default || null });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/accounts/default - Set default account
|
||||
*/
|
||||
router.post('/default', (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { name } = req.body;
|
||||
|
||||
if (!name) {
|
||||
res.status(400).json({ error: 'Missing required field: name' });
|
||||
return;
|
||||
}
|
||||
|
||||
const profilesPath = path.join(getCcsDir(), 'profiles.json');
|
||||
|
||||
const data = fs.existsSync(profilesPath)
|
||||
? JSON.parse(fs.readFileSync(profilesPath, 'utf8'))
|
||||
: { profiles: {} };
|
||||
|
||||
data.default = name;
|
||||
fs.writeFileSync(profilesPath, JSON.stringify(data, null, 2) + '\n');
|
||||
|
||||
res.json({ default: name });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
removeAccount as removeAccountFn,
|
||||
touchAccount,
|
||||
} from '../../cliproxy/account-manager';
|
||||
import { getProxyTarget } from '../../cliproxy/proxy-target-resolver';
|
||||
import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher';
|
||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||
|
||||
const router = Router();
|
||||
@@ -32,57 +34,79 @@ const validProviders: CLIProxyProvider[] = ['gemini', 'codex', 'agy', 'qwen', 'i
|
||||
* GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles
|
||||
* Also fetches CLIProxyAPI stats to update lastUsedAt for active providers
|
||||
*/
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
// Initialize accounts from existing tokens on first request
|
||||
initializeAccounts();
|
||||
router.get('/', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Check if remote mode is enabled
|
||||
const target = getProxyTarget();
|
||||
if (target.isRemote) {
|
||||
const authStatus = await fetchRemoteAuthStatus(target);
|
||||
res.json({ authStatus, source: 'remote' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch CLIProxyAPI usage stats to determine active providers
|
||||
const stats = await fetchCliproxyStats();
|
||||
// Local mode: Initialize accounts from existing tokens on first request
|
||||
initializeAccounts();
|
||||
|
||||
// Map CLIProxyAPI provider names to our internal provider names
|
||||
const statsProviderMap: Record<string, CLIProxyProvider> = {
|
||||
gemini: 'gemini',
|
||||
antigravity: 'agy',
|
||||
codex: 'codex',
|
||||
qwen: 'qwen',
|
||||
iflow: 'iflow',
|
||||
};
|
||||
// Fetch CLIProxyAPI usage stats to determine active providers
|
||||
const stats = await fetchCliproxyStats();
|
||||
|
||||
// Update lastUsedAt for providers with recent activity
|
||||
if (stats?.requestsByProvider) {
|
||||
for (const [statsProvider, requestCount] of Object.entries(stats.requestsByProvider)) {
|
||||
if (requestCount > 0) {
|
||||
const provider = statsProviderMap[statsProvider.toLowerCase()];
|
||||
if (provider) {
|
||||
// Touch the default account for this provider (or all accounts)
|
||||
const accounts = getProviderAccounts(provider);
|
||||
for (const account of accounts) {
|
||||
// Only touch if this is the default account (most likely being used)
|
||||
if (account.isDefault) {
|
||||
touchAccount(provider, account.id);
|
||||
// Map CLIProxyAPI provider names to our internal provider names
|
||||
const statsProviderMap: Record<string, CLIProxyProvider> = {
|
||||
gemini: 'gemini',
|
||||
antigravity: 'agy',
|
||||
codex: 'codex',
|
||||
qwen: 'qwen',
|
||||
iflow: 'iflow',
|
||||
};
|
||||
|
||||
// Update lastUsedAt for providers with recent activity
|
||||
if (stats?.requestsByProvider) {
|
||||
for (const [statsProvider, requestCount] of Object.entries(stats.requestsByProvider)) {
|
||||
if (requestCount > 0) {
|
||||
const provider = statsProviderMap[statsProvider.toLowerCase()];
|
||||
if (provider) {
|
||||
// Touch the default account for this provider (or all accounts)
|
||||
const accounts = getProviderAccounts(provider);
|
||||
for (const account of accounts) {
|
||||
// Only touch if this is the default account (most likely being used)
|
||||
if (account.isDefault) {
|
||||
touchAccount(provider, account.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const statuses = getAllAuthStatus();
|
||||
|
||||
const authStatus = statuses.map((status) => {
|
||||
const oauthConfig = getOAuthConfig(status.provider);
|
||||
return {
|
||||
provider: status.provider,
|
||||
displayName: oauthConfig.displayName,
|
||||
authenticated: status.authenticated,
|
||||
lastAuth: status.lastAuth?.toISOString() || null,
|
||||
tokenFiles: status.tokenFiles.length,
|
||||
accounts: status.accounts,
|
||||
defaultAccount: status.defaultAccount,
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ authStatus });
|
||||
} catch (error) {
|
||||
// Return appropriate error for remote vs local mode
|
||||
const target = getProxyTarget();
|
||||
if (target.isRemote) {
|
||||
res.status(503).json({
|
||||
error: (error as Error).message,
|
||||
authStatus: [],
|
||||
source: 'remote',
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
const statuses = getAllAuthStatus();
|
||||
|
||||
const authStatus = statuses.map((status) => {
|
||||
const oauthConfig = getOAuthConfig(status.provider);
|
||||
return {
|
||||
provider: status.provider,
|
||||
displayName: oauthConfig.displayName,
|
||||
authenticated: status.authenticated,
|
||||
lastAuth: status.lastAuth?.toISOString() || null,
|
||||
tokenFiles: status.tokenFiles.length,
|
||||
accounts: status.accounts,
|
||||
defaultAccount: status.defaultAccount,
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ authStatus });
|
||||
});
|
||||
|
||||
// ==================== Account Management ====================
|
||||
@@ -90,12 +114,41 @@ router.get('/', async (_req: Request, res: Response) => {
|
||||
/**
|
||||
* GET /api/cliproxy/accounts - Get all accounts across all providers
|
||||
*/
|
||||
router.get('/accounts', (_req: Request, res: Response) => {
|
||||
// Initialize accounts from existing tokens
|
||||
initializeAccounts();
|
||||
router.get('/accounts', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Check if remote mode is enabled
|
||||
const target = getProxyTarget();
|
||||
if (target.isRemote) {
|
||||
const authStatus = await fetchRemoteAuthStatus(target);
|
||||
// Transform RemoteAuthStatus[] to account summary format
|
||||
const accounts = authStatus.flatMap((status) =>
|
||||
status.accounts.map((acc) => ({
|
||||
provider: status.provider,
|
||||
...acc,
|
||||
}))
|
||||
);
|
||||
res.json({ accounts, source: 'remote' });
|
||||
return;
|
||||
}
|
||||
|
||||
const accounts = getAllAccountsSummary();
|
||||
res.json({ accounts });
|
||||
// Local mode: Initialize accounts from existing tokens
|
||||
initializeAccounts();
|
||||
|
||||
const accounts = getAllAccountsSummary();
|
||||
res.json({ accounts });
|
||||
} catch (error) {
|
||||
const target = getProxyTarget();
|
||||
if (target.isRemote) {
|
||||
res.status(503).json({
|
||||
error: (error as Error).message,
|
||||
accounts: [],
|
||||
source: 'remote',
|
||||
});
|
||||
} else {
|
||||
const message = error instanceof Error ? error.message : 'Failed to list accounts';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -110,14 +163,28 @@ router.get('/accounts/:provider', (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const accounts = getProviderAccounts(provider as CLIProxyProvider);
|
||||
res.json({ provider, accounts });
|
||||
try {
|
||||
const accounts = getProviderAccounts(provider as CLIProxyProvider);
|
||||
res.json({ provider, accounts });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get provider accounts';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/cliproxy/accounts/:provider/default - Set default account for provider
|
||||
*/
|
||||
router.post('/accounts/:provider/default', (req: Request, res: Response): void => {
|
||||
// Check if remote mode is enabled - account management not available
|
||||
const target = getProxyTarget();
|
||||
if (target.isRemote) {
|
||||
res.status(501).json({
|
||||
error: 'Account management not available in remote mode',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { provider } = req.params;
|
||||
const { accountId } = req.body;
|
||||
|
||||
@@ -132,12 +199,19 @@ router.post('/accounts/:provider/default', (req: Request, res: Response): void =
|
||||
return;
|
||||
}
|
||||
|
||||
const success = setDefaultAccountFn(provider as CLIProxyProvider, accountId);
|
||||
try {
|
||||
const success = setDefaultAccountFn(provider as CLIProxyProvider, accountId);
|
||||
|
||||
if (success) {
|
||||
res.json({ provider, defaultAccount: accountId });
|
||||
} else {
|
||||
res.status(404).json({ error: 'Account not found' });
|
||||
if (success) {
|
||||
res.json({ provider, defaultAccount: accountId });
|
||||
} else {
|
||||
res
|
||||
.status(404)
|
||||
.json({ error: `Account '${accountId}' not found for provider '${provider}'` });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to set default account';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -145,6 +219,15 @@ router.post('/accounts/:provider/default', (req: Request, res: Response): void =
|
||||
* DELETE /api/cliproxy/accounts/:provider/:accountId - Remove an account
|
||||
*/
|
||||
router.delete('/accounts/:provider/:accountId', (req: Request, res: Response): void => {
|
||||
// Check if remote mode is enabled - account management not available
|
||||
const target = getProxyTarget();
|
||||
if (target.isRemote) {
|
||||
res.status(501).json({
|
||||
error: 'Account management not available in remote mode',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { provider, accountId } = req.params;
|
||||
|
||||
// Validate provider
|
||||
@@ -153,12 +236,19 @@ router.delete('/accounts/:provider/:accountId', (req: Request, res: Response): v
|
||||
return;
|
||||
}
|
||||
|
||||
const success = removeAccountFn(provider as CLIProxyProvider, accountId);
|
||||
try {
|
||||
const success = removeAccountFn(provider as CLIProxyProvider, accountId);
|
||||
|
||||
if (success) {
|
||||
res.json({ provider, accountId, deleted: true });
|
||||
} else {
|
||||
res.status(404).json({ error: 'Account not found' });
|
||||
if (success) {
|
||||
res.json({ provider, accountId, deleted: true });
|
||||
} else {
|
||||
res
|
||||
.status(404)
|
||||
.json({ error: `Account '${accountId}' not found for provider '${provider}'` });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to remove account';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
fetchCliproxyStats,
|
||||
@@ -11,7 +12,11 @@ import {
|
||||
fetchCliproxyErrorLogs,
|
||||
fetchCliproxyErrorLogContent,
|
||||
} from '../../cliproxy/stats-fetcher';
|
||||
import { getCliproxyWritablePath } from '../../cliproxy/config-generator';
|
||||
import {
|
||||
getCliproxyWritablePath,
|
||||
getConfigPath,
|
||||
getAuthDir,
|
||||
} from '../../cliproxy/config-generator';
|
||||
import { getProxyStatus as getProxyProcessStatus, stopProxy } from '../../cliproxy/session-tracker';
|
||||
import { ensureCliproxyService } from '../../cliproxy/service-manager';
|
||||
import { checkCliproxyUpdate } from '../../cliproxy/binary-manager';
|
||||
@@ -19,10 +24,9 @@ import { checkCliproxyUpdate } from '../../cliproxy/binary-manager';
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/stats - Get CLIProxyAPI usage statistics
|
||||
* Returns: CliproxyStats or error if proxy not running
|
||||
* Shared handler for stats/usage endpoint
|
||||
*/
|
||||
router.get('/stats', async (_req: Request, res: Response): Promise<void> => {
|
||||
const handleStatsRequest = async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Check if proxy is running first
|
||||
const running = await isCliproxyRunning();
|
||||
@@ -48,7 +52,18 @@ router.get('/stats', async (_req: Request, res: Response): Promise<void> => {
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/stats - Get CLIProxyAPI usage statistics
|
||||
* Returns: CliproxyStats or error if proxy not running
|
||||
*/
|
||||
router.get('/stats', handleStatsRequest);
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/usage - Alias for /stats (frontend compatibility)
|
||||
*/
|
||||
router.get('/usage', handleStatsRequest);
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/status - Check CLIProxyAPI running status
|
||||
@@ -249,4 +264,170 @@ router.get('/error-logs/:name', async (req: Request, res: Response): Promise<voi
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== Config File ====================
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/config.yaml - Get CLIProxy YAML config content
|
||||
* Returns: plain text YAML content
|
||||
*/
|
||||
router.get('/config.yaml', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const configPath = getConfigPath();
|
||||
if (!fs.existsSync(configPath)) {
|
||||
res.status(404).json({ error: 'Config file not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
res.type('text/yaml').send(content);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/cliproxy/config.yaml - Save CLIProxy YAML config content
|
||||
* Body: { content: string }
|
||||
* Returns: { success: true, path: string }
|
||||
*/
|
||||
router.put('/config.yaml', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { content } = req.body;
|
||||
|
||||
if (typeof content !== 'string') {
|
||||
res.status(400).json({ error: 'Missing required field: content' });
|
||||
return;
|
||||
}
|
||||
|
||||
const configPath = getConfigPath();
|
||||
|
||||
// Ensure parent directory exists
|
||||
const configDir = path.dirname(configPath);
|
||||
if (!fs.existsSync(configDir)) {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write atomically
|
||||
const tempPath = configPath + '.tmp';
|
||||
fs.writeFileSync(tempPath, content);
|
||||
fs.renameSync(tempPath, configPath);
|
||||
|
||||
res.json({ success: true, path: configPath });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== Auth Files ====================
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/auth-files - List auth files in auth directory
|
||||
* Returns: { files: Array<{ name, size, mtime }> }
|
||||
*/
|
||||
router.get('/auth-files', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const authDir = getAuthDir();
|
||||
|
||||
if (!fs.existsSync(authDir)) {
|
||||
res.json({ files: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(authDir, { withFileTypes: true });
|
||||
const files = entries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => {
|
||||
const filePath = path.join(authDir, entry.name);
|
||||
const stat = fs.statSync(filePath);
|
||||
return {
|
||||
name: entry.name,
|
||||
size: stat.size,
|
||||
mtime: stat.mtime.getTime(),
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ files, directory: authDir });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/auth-files/download - Download auth file content
|
||||
* Query: ?name=filename
|
||||
* Returns: file content as octet-stream
|
||||
*/
|
||||
router.get('/auth-files/download', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { name } = req.query;
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
res.status(400).json({ error: 'Missing required query parameter: name' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate filename - prevent path traversal
|
||||
if (name.includes('..') || name.includes('/') || name.includes('\\')) {
|
||||
res.status(400).json({ error: 'Invalid filename' });
|
||||
return;
|
||||
}
|
||||
|
||||
const authDir = getAuthDir();
|
||||
const filePath = path.join(authDir, name);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
res.status(404).json({ error: 'Auth file not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${name}"`);
|
||||
res.type('application/octet-stream').send(content);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== Model Updates ====================
|
||||
|
||||
/**
|
||||
* PUT /api/cliproxy/models/:provider - Update model for a provider
|
||||
* Body: { model: string }
|
||||
* Returns: { success: true, provider, model }
|
||||
*/
|
||||
router.put('/models/:provider', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { provider } = req.params;
|
||||
const { model } = req.body;
|
||||
|
||||
if (!model || typeof model !== 'string') {
|
||||
res.status(400).json({ error: 'Missing required field: model' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the settings file for this provider
|
||||
const ccsDir = getCliproxyWritablePath();
|
||||
const settingsPath = path.join(ccsDir, `${provider}.settings.json`);
|
||||
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
res.status(404).json({ error: `Settings file not found for provider: ${provider}` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Read and update settings
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
settings.env = settings.env || {};
|
||||
settings.env.ANTHROPIC_MODEL = model;
|
||||
|
||||
// Write atomically
|
||||
const tempPath = settingsPath + '.tmp';
|
||||
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
|
||||
fs.renameSync(tempPath, settingsPath);
|
||||
|
||||
res.json({ success: true, provider, model });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -24,12 +24,16 @@ const router = Router();
|
||||
/**
|
||||
* GET /api/config/format - Return current config format and migration status
|
||||
*/
|
||||
router.get('/format', (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
format: getConfigFormat(),
|
||||
migrationNeeded: needsMigration(),
|
||||
backups: getBackupDirectories(),
|
||||
});
|
||||
router.get('/format', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
res.json({
|
||||
format: getConfigFormat(),
|
||||
migrationNeeded: needsMigration(),
|
||||
backups: getBackupDirectories(),
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -89,25 +93,33 @@ router.put('/', (req: Request, res: Response): void => {
|
||||
/**
|
||||
* POST /api/config/migrate - Trigger migration from JSON to YAML
|
||||
*/
|
||||
router.post('/migrate', async (req: Request, res: Response) => {
|
||||
const dryRun = req.query.dryRun === 'true';
|
||||
const result = await migrate(dryRun);
|
||||
res.json(result);
|
||||
router.post('/migrate', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const dryRun = req.query.dryRun === 'true';
|
||||
const result = await migrate(dryRun);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/config/rollback - Rollback migration to JSON format
|
||||
*/
|
||||
router.post('/rollback', async (req: Request, res: Response): Promise<void> => {
|
||||
const { backupPath } = req.body;
|
||||
try {
|
||||
const { backupPath } = req.body;
|
||||
|
||||
if (!backupPath || typeof backupPath !== 'string') {
|
||||
res.status(400).json({ error: 'Missing required field: backupPath' });
|
||||
return;
|
||||
if (!backupPath || typeof backupPath !== 'string') {
|
||||
res.status(400).json({ error: 'Missing required field: backupPath' });
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await rollback(backupPath);
|
||||
res.json({ success });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
|
||||
const success = await rollback(backupPath);
|
||||
res.json({ success });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -10,22 +10,30 @@ const router = Router();
|
||||
/**
|
||||
* GET /api/health - Run health checks
|
||||
*/
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
const report = await runHealthChecks();
|
||||
res.json(report);
|
||||
router.get('/', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const report = await runHealthChecks();
|
||||
res.json(report);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/health/fix/:checkId - Fix a health issue
|
||||
*/
|
||||
router.post('/fix/:checkId', (req: Request, res: Response): void => {
|
||||
const { checkId } = req.params;
|
||||
const result = fixHealthIssue(checkId);
|
||||
try {
|
||||
const { checkId } = req.params;
|
||||
const result = fixHealthIssue(checkId);
|
||||
|
||||
if (result.success) {
|
||||
res.json({ success: true, message: result.message });
|
||||
} else {
|
||||
res.status(400).json({ success: false, message: result.message });
|
||||
if (result.success) {
|
||||
res.json({ success: true, message: result.message });
|
||||
} else {
|
||||
res.status(400).json({ success: false, message: result.message });
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Router } from 'express';
|
||||
|
||||
// Import domain routers
|
||||
import profileRoutes from './profile-routes';
|
||||
import accountRoutes from './account-routes';
|
||||
import configRoutes from './config-routes';
|
||||
import healthRoutes from './health-routes';
|
||||
import providerRoutes from './provider-routes';
|
||||
@@ -28,7 +29,7 @@ export const apiRoutes = Router();
|
||||
// Profile CRUD, settings management, presets, accounts
|
||||
apiRoutes.use('/profiles', profileRoutes);
|
||||
apiRoutes.use('/settings', settingsRoutes);
|
||||
apiRoutes.use('/accounts', profileRoutes);
|
||||
apiRoutes.use('/accounts', accountRoutes);
|
||||
|
||||
// ==================== Unified Config ====================
|
||||
// Config format, migration
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
/**
|
||||
* Profile Routes - CRUD operations for user profiles and accounts
|
||||
* Profile Routes - CRUD operations for user profiles
|
||||
*
|
||||
* Uses unified config (config.yaml) when available, falls back to legacy (config.json).
|
||||
* Note: Account routes have been moved to account-routes.ts
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir } from '../../utils/config-manager';
|
||||
import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names';
|
||||
import { createApiProfile, removeApiProfile } from '../../api/services/profile-writer';
|
||||
import { apiProfileExists, listApiProfiles } from '../../api/services/profile-reader';
|
||||
@@ -20,15 +18,19 @@ const router = Router();
|
||||
/**
|
||||
* GET /api/profiles - List all profiles
|
||||
*/
|
||||
router.get('/', (_req: Request, res: Response) => {
|
||||
const result = listApiProfiles();
|
||||
// Map isConfigured -> configured for UI compatibility
|
||||
const profiles = result.profiles.map((p) => ({
|
||||
name: p.name,
|
||||
settingsPath: p.settingsPath,
|
||||
configured: p.isConfigured,
|
||||
}));
|
||||
res.json({ profiles });
|
||||
router.get('/', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const result = listApiProfiles();
|
||||
// Map isConfigured -> configured for UI compatibility
|
||||
const profiles = result.profiles.map((p) => ({
|
||||
name: p.name,
|
||||
settingsPath: p.settingsPath,
|
||||
configured: p.isConfigured,
|
||||
}));
|
||||
res.json({ profiles });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -117,52 +119,4 @@ router.delete('/:name', (req: Request, res: Response): void => {
|
||||
res.json({ name, deleted: true });
|
||||
});
|
||||
|
||||
// ==================== Accounts ====================
|
||||
|
||||
/**
|
||||
* GET /api/accounts - List accounts from profiles.json
|
||||
*/
|
||||
router.get('/accounts', (_req: Request, res: Response): void => {
|
||||
const profilesPath = path.join(getCcsDir(), 'profiles.json');
|
||||
|
||||
if (!fs.existsSync(profilesPath)) {
|
||||
res.json({ accounts: [], default: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(profilesPath, 'utf8'));
|
||||
const accounts = Object.entries(data.profiles || {}).map(([name, meta]) => {
|
||||
const metadata = meta as Record<string, unknown>;
|
||||
return {
|
||||
name,
|
||||
...metadata,
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ accounts, default: data.default || null });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/accounts/default - Set default account
|
||||
*/
|
||||
router.post('/accounts/default', (req: Request, res: Response): void => {
|
||||
const { name } = req.body;
|
||||
|
||||
if (!name) {
|
||||
res.status(400).json({ error: 'Missing required field: name' });
|
||||
return;
|
||||
}
|
||||
|
||||
const profilesPath = path.join(getCcsDir(), 'profiles.json');
|
||||
|
||||
const data = fs.existsSync(profilesPath)
|
||||
? JSON.parse(fs.readFileSync(profilesPath, 'utf8'))
|
||||
: { profiles: {} };
|
||||
|
||||
data.default = name;
|
||||
fs.writeFileSync(profilesPath, JSON.stringify(data, null, 2) + '\n');
|
||||
|
||||
res.json({ default: name });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -32,103 +32,115 @@ function maskApiKeys(settings: Settings): Settings {
|
||||
* GET /api/settings/:profile - Get settings with masked API keys
|
||||
*/
|
||||
router.get('/:profile', (req: Request, res: Response): void => {
|
||||
const { profile } = req.params;
|
||||
const ccsDir = getCcsDir();
|
||||
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
|
||||
try {
|
||||
const { profile } = req.params;
|
||||
const ccsDir = getCcsDir();
|
||||
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
|
||||
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
res.status(404).json({ error: 'Settings not found' });
|
||||
return;
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
res.status(404).json({ error: 'Settings not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const stat = fs.statSync(settingsPath);
|
||||
const settings = loadSettings(settingsPath);
|
||||
const masked = maskApiKeys(settings);
|
||||
|
||||
res.json({
|
||||
profile,
|
||||
settings: masked,
|
||||
mtime: stat.mtime.getTime(),
|
||||
path: settingsPath,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
|
||||
const stat = fs.statSync(settingsPath);
|
||||
const settings = loadSettings(settingsPath);
|
||||
const masked = maskApiKeys(settings);
|
||||
|
||||
res.json({
|
||||
profile,
|
||||
settings: masked,
|
||||
mtime: stat.mtime.getTime(),
|
||||
path: settingsPath,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/settings/:profile/raw - Get full settings (for editing)
|
||||
*/
|
||||
router.get('/:profile/raw', (req: Request, res: Response): void => {
|
||||
const { profile } = req.params;
|
||||
const ccsDir = getCcsDir();
|
||||
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
|
||||
try {
|
||||
const { profile } = req.params;
|
||||
const ccsDir = getCcsDir();
|
||||
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
|
||||
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
res.status(404).json({ error: 'Settings not found' });
|
||||
return;
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
res.status(404).json({ error: 'Settings not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const stat = fs.statSync(settingsPath);
|
||||
const settings = loadSettings(settingsPath);
|
||||
|
||||
res.json({
|
||||
profile,
|
||||
settings,
|
||||
mtime: stat.mtime.getTime(),
|
||||
path: settingsPath,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
|
||||
const stat = fs.statSync(settingsPath);
|
||||
const settings = loadSettings(settingsPath);
|
||||
|
||||
res.json({
|
||||
profile,
|
||||
settings,
|
||||
mtime: stat.mtime.getTime(),
|
||||
path: settingsPath,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/settings/:profile - Update settings with conflict detection and backup
|
||||
*/
|
||||
router.put('/:profile', (req: Request, res: Response): void => {
|
||||
const { profile } = req.params;
|
||||
const { settings, expectedMtime } = req.body;
|
||||
const ccsDir = getCcsDir();
|
||||
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
|
||||
try {
|
||||
const { profile } = req.params;
|
||||
const { settings, expectedMtime } = req.body;
|
||||
const ccsDir = getCcsDir();
|
||||
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
|
||||
|
||||
const fileExists = fs.existsSync(settingsPath);
|
||||
const fileExists = fs.existsSync(settingsPath);
|
||||
|
||||
// Only check conflict if file exists and expectedMtime was provided
|
||||
if (fileExists && expectedMtime) {
|
||||
const stat = fs.statSync(settingsPath);
|
||||
if (stat.mtime.getTime() !== expectedMtime) {
|
||||
res.status(409).json({
|
||||
error: 'File modified externally',
|
||||
currentMtime: stat.mtime.getTime(),
|
||||
});
|
||||
return;
|
||||
// Only check conflict if file exists and expectedMtime was provided
|
||||
if (fileExists && expectedMtime) {
|
||||
const stat = fs.statSync(settingsPath);
|
||||
if (stat.mtime.getTime() !== expectedMtime) {
|
||||
res.status(409).json({
|
||||
error: 'File modified externally',
|
||||
currentMtime: stat.mtime.getTime(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create backup only if file exists
|
||||
let backupPath: string | undefined;
|
||||
if (fileExists) {
|
||||
const backupDir = path.join(ccsDir, 'backups');
|
||||
if (!fs.existsSync(backupDir)) {
|
||||
fs.mkdirSync(backupDir, { recursive: true });
|
||||
// Create backup only if file exists
|
||||
let backupPath: string | undefined;
|
||||
if (fileExists) {
|
||||
const backupDir = path.join(ccsDir, 'backups');
|
||||
if (!fs.existsSync(backupDir)) {
|
||||
fs.mkdirSync(backupDir, { recursive: true });
|
||||
}
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
backupPath = path.join(backupDir, `${profile}.${timestamp}.settings.json`);
|
||||
fs.copyFileSync(settingsPath, backupPath);
|
||||
}
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
backupPath = path.join(backupDir, `${profile}.${timestamp}.settings.json`);
|
||||
fs.copyFileSync(settingsPath, backupPath);
|
||||
|
||||
// Ensure directory exists for new files
|
||||
if (!fileExists) {
|
||||
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
||||
}
|
||||
|
||||
// Write new settings atomically
|
||||
const tempPath = settingsPath + '.tmp';
|
||||
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
|
||||
fs.renameSync(tempPath, settingsPath);
|
||||
|
||||
const newStat = fs.statSync(settingsPath);
|
||||
res.json({
|
||||
profile,
|
||||
mtime: newStat.mtime.getTime(),
|
||||
backupPath,
|
||||
created: !fileExists,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
|
||||
// Ensure directory exists for new files
|
||||
if (!fileExists) {
|
||||
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
||||
}
|
||||
|
||||
// Write new settings atomically
|
||||
const tempPath = settingsPath + '.tmp';
|
||||
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
|
||||
fs.renameSync(tempPath, settingsPath);
|
||||
|
||||
const newStat = fs.statSync(settingsPath);
|
||||
res.json({
|
||||
profile,
|
||||
mtime: newStat.mtime.getTime(),
|
||||
backupPath,
|
||||
created: !fileExists,
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Presets ====================
|
||||
@@ -137,85 +149,97 @@ router.put('/:profile', (req: Request, res: Response): void => {
|
||||
* GET /api/settings/:profile/presets - Get saved presets for a provider
|
||||
*/
|
||||
router.get('/:profile/presets', (req: Request, res: Response): void => {
|
||||
const { profile } = req.params;
|
||||
const ccsDir = getCcsDir();
|
||||
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
|
||||
try {
|
||||
const { profile } = req.params;
|
||||
const ccsDir = getCcsDir();
|
||||
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
|
||||
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
res.json({ presets: [] });
|
||||
return;
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
res.json({ presets: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = loadSettings(settingsPath);
|
||||
res.json({ presets: settings.presets || [] });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
|
||||
const settings = loadSettings(settingsPath);
|
||||
res.json({ presets: settings.presets || [] });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/settings/:profile/presets - Create a new preset
|
||||
*/
|
||||
router.post('/:profile/presets', (req: Request, res: Response): void => {
|
||||
const { profile } = req.params;
|
||||
const { name, default: defaultModel, opus, sonnet, haiku } = req.body;
|
||||
try {
|
||||
const { profile } = req.params;
|
||||
const { name, default: defaultModel, opus, sonnet, haiku } = req.body;
|
||||
|
||||
if (!name || !defaultModel) {
|
||||
res.status(400).json({ error: 'Missing required fields: name, default' });
|
||||
return;
|
||||
if (!name || !defaultModel) {
|
||||
res.status(400).json({ error: 'Missing required fields: name, default' });
|
||||
return;
|
||||
}
|
||||
|
||||
const ccsDir = getCcsDir();
|
||||
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
|
||||
|
||||
// Create settings file if it doesn't exist
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {}, presets: [] }, null, 2) + '\n');
|
||||
}
|
||||
|
||||
const settings = loadSettings(settingsPath);
|
||||
settings.presets = settings.presets || [];
|
||||
|
||||
// Check for duplicate name
|
||||
if (settings.presets.some((p) => p.name === name)) {
|
||||
res.status(409).json({ error: 'Preset with this name already exists' });
|
||||
return;
|
||||
}
|
||||
|
||||
const preset = {
|
||||
name,
|
||||
default: defaultModel,
|
||||
opus: opus || defaultModel,
|
||||
sonnet: sonnet || defaultModel,
|
||||
haiku: haiku || defaultModel,
|
||||
};
|
||||
|
||||
settings.presets.push(preset);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
||||
|
||||
res.status(201).json({ preset });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
|
||||
const ccsDir = getCcsDir();
|
||||
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
|
||||
|
||||
// Create settings file if it doesn't exist
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {}, presets: [] }, null, 2) + '\n');
|
||||
}
|
||||
|
||||
const settings = loadSettings(settingsPath);
|
||||
settings.presets = settings.presets || [];
|
||||
|
||||
// Check for duplicate name
|
||||
if (settings.presets.some((p) => p.name === name)) {
|
||||
res.status(409).json({ error: 'Preset with this name already exists' });
|
||||
return;
|
||||
}
|
||||
|
||||
const preset = {
|
||||
name,
|
||||
default: defaultModel,
|
||||
opus: opus || defaultModel,
|
||||
sonnet: sonnet || defaultModel,
|
||||
haiku: haiku || defaultModel,
|
||||
};
|
||||
|
||||
settings.presets.push(preset);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
||||
|
||||
res.status(201).json({ preset });
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/settings/:profile/presets/:name - Delete a preset
|
||||
*/
|
||||
router.delete('/:profile/presets/:name', (req: Request, res: Response): void => {
|
||||
const { profile, name } = req.params;
|
||||
const ccsDir = getCcsDir();
|
||||
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
|
||||
try {
|
||||
const { profile, name } = req.params;
|
||||
const ccsDir = getCcsDir();
|
||||
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
|
||||
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
res.status(404).json({ error: 'Settings not found' });
|
||||
return;
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
res.status(404).json({ error: 'Settings not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = loadSettings(settingsPath);
|
||||
if (!settings.presets || !settings.presets.some((p) => p.name === name)) {
|
||||
res.status(404).json({ error: 'Preset not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
settings.presets = settings.presets.filter((p) => p.name !== name);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
|
||||
const settings = loadSettings(settingsPath);
|
||||
if (!settings.presets || !settings.presets.some((p) => p.name === name)) {
|
||||
res.status(404).json({ error: 'Preset not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
settings.presets = settings.presets.filter((p) => p.name !== name);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -78,82 +78,90 @@ router.post('/', (req: Request, res: Response): void => {
|
||||
* PUT /api/cliproxy/:name - Update cliproxy variant
|
||||
*/
|
||||
router.put('/:name', (req: Request, res: Response): void => {
|
||||
const { name } = req.params;
|
||||
const { provider, account, model } = req.body;
|
||||
try {
|
||||
const { name } = req.params;
|
||||
const { provider, account, model } = req.body;
|
||||
|
||||
const config = readConfigSafe();
|
||||
const config = readConfigSafe();
|
||||
|
||||
if (!config.cliproxy?.[name]) {
|
||||
res.status(404).json({ error: 'Variant not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const variant = config.cliproxy[name];
|
||||
|
||||
// Update fields if provided
|
||||
if (provider) {
|
||||
variant.provider = provider;
|
||||
}
|
||||
if (account !== undefined) {
|
||||
if (account) {
|
||||
variant.account = account;
|
||||
} else {
|
||||
delete variant.account; // Remove account to use default
|
||||
if (!config.cliproxy?.[name]) {
|
||||
res.status(404).json({ error: 'Variant not found' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Update model in settings file if provided
|
||||
if (model !== undefined) {
|
||||
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
const settings = loadSettings(settingsPath);
|
||||
if (model) {
|
||||
settings.env = settings.env || {};
|
||||
settings.env.ANTHROPIC_MODEL = model;
|
||||
} else if (settings.env) {
|
||||
delete settings.env.ANTHROPIC_MODEL;
|
||||
const variant = config.cliproxy[name];
|
||||
|
||||
// Update fields if provided
|
||||
if (provider) {
|
||||
variant.provider = provider;
|
||||
}
|
||||
if (account !== undefined) {
|
||||
if (account) {
|
||||
variant.account = account;
|
||||
} else {
|
||||
delete variant.account; // Remove account to use default
|
||||
}
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
||||
}
|
||||
|
||||
// Update model in settings file if provided
|
||||
if (model !== undefined) {
|
||||
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
const settings = loadSettings(settingsPath);
|
||||
if (model) {
|
||||
settings.env = settings.env || {};
|
||||
settings.env.ANTHROPIC_MODEL = model;
|
||||
} else if (settings.env) {
|
||||
delete settings.env.ANTHROPIC_MODEL;
|
||||
}
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
writeConfig(config);
|
||||
|
||||
res.json({
|
||||
name,
|
||||
provider: variant.provider,
|
||||
account: variant.account || 'default',
|
||||
settings: variant.settings,
|
||||
updated: true,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
|
||||
writeConfig(config);
|
||||
|
||||
res.json({
|
||||
name,
|
||||
provider: variant.provider,
|
||||
account: variant.account || 'default',
|
||||
settings: variant.settings,
|
||||
updated: true,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/cliproxy/:name - Delete cliproxy variant
|
||||
*/
|
||||
router.delete('/:name', (req: Request, res: Response): void => {
|
||||
const { name } = req.params;
|
||||
try {
|
||||
const { name } = req.params;
|
||||
|
||||
const config = readConfigSafe();
|
||||
const config = readConfigSafe();
|
||||
|
||||
if (!config.cliproxy?.[name]) {
|
||||
res.status(404).json({ error: 'Variant not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Never delete settings files for reserved provider names (safety guard)
|
||||
if (!isReservedName(name)) {
|
||||
// Only delete settings file for non-reserved variant names
|
||||
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
fs.unlinkSync(settingsPath);
|
||||
if (!config.cliproxy?.[name]) {
|
||||
res.status(404).json({ error: 'Variant not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Never delete settings files for reserved provider names (safety guard)
|
||||
if (!isReservedName(name)) {
|
||||
// Only delete settings file for non-reserved variant names
|
||||
const settingsPath = path.join(getCcsDir(), `${name}.settings.json`);
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
fs.unlinkSync(settingsPath);
|
||||
}
|
||||
}
|
||||
|
||||
delete config.cliproxy[name];
|
||||
writeConfig(config);
|
||||
|
||||
res.json({ name, deleted: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
|
||||
delete config.cliproxy[name];
|
||||
writeConfig(config);
|
||||
|
||||
res.json({ name, deleted: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
*
|
||||
* Embeds the CLIProxy management.html with auto-authentication.
|
||||
* Uses postMessage to inject credentials into the iframe.
|
||||
* Supports both local and remote CLIProxy server connections.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { RefreshCw, AlertCircle, Key, X, Gauge } from 'lucide-react';
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { RefreshCw, AlertCircle, Key, X, Gauge, Globe } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api-client';
|
||||
import type { CliproxyServerConfig } from '@/lib/api-client';
|
||||
|
||||
/** CLIProxyAPI default port */
|
||||
const CLIPROXY_DEFAULT_PORT = 8317;
|
||||
@@ -25,30 +29,94 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [showLoginHint, setShowLoginHint] = useState(true);
|
||||
|
||||
const managementUrl = `http://localhost:${port}/management.html`;
|
||||
// Fetch cliproxy_server config for remote/local mode detection
|
||||
const { data: cliproxyConfig, error: configError } = useQuery<CliproxyServerConfig>({
|
||||
queryKey: ['cliproxy-server-config'],
|
||||
queryFn: () => api.cliproxyServer.get(),
|
||||
staleTime: 30000, // 30 seconds
|
||||
});
|
||||
|
||||
// Log config fetch errors (fallback to local mode on error)
|
||||
useEffect(() => {
|
||||
if (configError) {
|
||||
console.warn('[ControlPanelEmbed] Config fetch failed, using local mode:', configError);
|
||||
}
|
||||
}, [configError]);
|
||||
|
||||
// Calculate URLs and settings based on remote or local mode
|
||||
const { managementUrl, checkUrl, authToken, isRemote, displayHost } = useMemo(() => {
|
||||
const remote = cliproxyConfig?.remote;
|
||||
|
||||
if (remote?.enabled && remote?.host) {
|
||||
const protocol = remote.protocol || 'http';
|
||||
// Use port from config, or default based on protocol (443 for https, 80 for http)
|
||||
const remotePort = remote.port || (protocol === 'https' ? 443 : 80);
|
||||
// Only include port in URL if it's non-standard
|
||||
const portSuffix =
|
||||
(protocol === 'https' && remotePort === 443) || (protocol === 'http' && remotePort === 80)
|
||||
? ''
|
||||
: `:${remotePort}`;
|
||||
const baseUrl = `${protocol}://${remote.host}${portSuffix}`;
|
||||
|
||||
return {
|
||||
managementUrl: `${baseUrl}/management.html`,
|
||||
checkUrl: `${baseUrl}/`,
|
||||
authToken: remote.auth_token || undefined,
|
||||
isRemote: true,
|
||||
displayHost: `${remote.host}${portSuffix}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Local mode
|
||||
return {
|
||||
managementUrl: `http://localhost:${port}/management.html`,
|
||||
checkUrl: `http://localhost:${port}/`,
|
||||
authToken: CCS_CONTROL_PANEL_SECRET,
|
||||
isRemote: false,
|
||||
displayHost: `localhost:${port}`,
|
||||
};
|
||||
}, [cliproxyConfig, port]);
|
||||
|
||||
// Check if CLIProxy is running
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
const checkConnection = async () => {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:${port}/`, {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
const response = await fetch(checkUrl, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (response.ok) {
|
||||
setIsConnected(true);
|
||||
setError(null);
|
||||
} else {
|
||||
setIsConnected(false);
|
||||
setError('CLIProxy returned an error');
|
||||
setError(
|
||||
isRemote
|
||||
? `Remote CLIProxy at ${displayHost} returned an error`
|
||||
: 'CLIProxy returned an error'
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
} catch (e) {
|
||||
// Ignore abort errors (component unmounting)
|
||||
if (e instanceof Error && e.name === 'AbortError') return;
|
||||
|
||||
setIsConnected(false);
|
||||
setError('CLIProxy is not running');
|
||||
setError(
|
||||
isRemote
|
||||
? `Remote CLIProxy at ${displayHost} is not reachable`
|
||||
: 'CLIProxy is not running'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
checkConnection();
|
||||
}, [port]);
|
||||
// Start connection check with timeout
|
||||
const timeoutId = setTimeout(() => controller.abort(), 2000);
|
||||
checkConnection().finally(() => clearTimeout(timeoutId));
|
||||
|
||||
// Cleanup: abort fetch on unmount
|
||||
return () => controller.abort();
|
||||
}, [checkUrl, isRemote, displayHost]);
|
||||
|
||||
// Handle iframe load - attempt to auto-login via postMessage
|
||||
const handleIframeLoad = useCallback(() => {
|
||||
@@ -57,26 +125,38 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
||||
// Try to inject credentials via postMessage
|
||||
// The management.html needs to listen for this message
|
||||
// If it doesn't support it, user will see the login page
|
||||
if (iframeRef.current?.contentWindow) {
|
||||
if (iframeRef.current?.contentWindow && authToken) {
|
||||
try {
|
||||
// Derive apiBase from checkUrl (remove trailing slash)
|
||||
const apiBase = checkUrl.replace(/\/$/, '');
|
||||
|
||||
// Security: Validate iframe src matches target origin before sending credentials
|
||||
const iframeSrc = iframeRef.current.src;
|
||||
if (!iframeSrc.startsWith(apiBase)) {
|
||||
console.warn('[ControlPanelEmbed] Iframe origin mismatch, skipping postMessage');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send credentials to iframe
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: 'ccs-auto-login',
|
||||
apiBase: `http://localhost:${port}`,
|
||||
managementKey: CCS_CONTROL_PANEL_SECRET,
|
||||
apiBase,
|
||||
managementKey: authToken,
|
||||
},
|
||||
`http://localhost:${port}`
|
||||
apiBase
|
||||
);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
// Cross-origin restriction - expected if not same origin
|
||||
console.debug('[ControlPanelEmbed] postMessage failed - cross-origin');
|
||||
console.debug('[ControlPanelEmbed] postMessage failed - cross-origin:', e);
|
||||
}
|
||||
}
|
||||
}, [port]);
|
||||
}, [checkUrl, authToken]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setIsConnected(false);
|
||||
if (iframeRef.current) {
|
||||
iframeRef.current.src = managementUrl;
|
||||
}
|
||||
@@ -119,15 +199,24 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col relative">
|
||||
{/* Login hint banner */}
|
||||
{/* Remote indicator and login hint banner */}
|
||||
{showLoginHint && !isLoading && (
|
||||
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-20">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-md text-sm">
|
||||
{isRemote && (
|
||||
<>
|
||||
<Globe className="h-3.5 w-3.5 text-green-600" />
|
||||
<span className="text-green-600 font-medium">Remote</span>
|
||||
<span className="text-blue-300 dark:text-blue-700">|</span>
|
||||
</>
|
||||
)}
|
||||
<Key className="h-3.5 w-3.5 text-blue-600" />
|
||||
<span>
|
||||
Key:{' '}
|
||||
<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded font-mono font-semibold">
|
||||
ccs
|
||||
{authToken && authToken.length > 4
|
||||
? `***${authToken.slice(-4)}`
|
||||
: authToken || 'ccs'}
|
||||
</code>
|
||||
</span>
|
||||
<button
|
||||
@@ -145,7 +234,11 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
|
||||
<div className="text-center">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-primary mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">Loading Control Panel...</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isRemote
|
||||
? `Loading Control Panel from ${displayHost}...`
|
||||
: 'Loading Control Panel...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -31,6 +31,7 @@ export function ProviderEditor({
|
||||
authStatus,
|
||||
catalog,
|
||||
logoProvider,
|
||||
isRemoteMode,
|
||||
onAddAccount,
|
||||
onSetDefault,
|
||||
onRemoveAccount,
|
||||
@@ -124,6 +125,7 @@ export function ProviderEditor({
|
||||
hasChanges={hasChanges}
|
||||
isRawJsonValid={isRawJsonValid}
|
||||
isSaving={saveMutation.isPending}
|
||||
isRemoteMode={isRemoteMode}
|
||||
onRefetch={refetch}
|
||||
onSave={() => saveMutation.mutate()}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Save, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { Save, Loader2, RefreshCw, Globe } from 'lucide-react';
|
||||
import { ProviderLogo } from '../provider-logo';
|
||||
import type { SettingsResponse } from './types';
|
||||
|
||||
@@ -18,6 +18,7 @@ interface ProviderEditorHeaderProps {
|
||||
hasChanges: boolean;
|
||||
isRawJsonValid: boolean;
|
||||
isSaving: boolean;
|
||||
isRemoteMode?: boolean;
|
||||
onRefetch: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
@@ -31,6 +32,7 @@ export function ProviderEditorHeader({
|
||||
hasChanges,
|
||||
isRawJsonValid,
|
||||
isSaving,
|
||||
isRemoteMode,
|
||||
onRefetch,
|
||||
onSave,
|
||||
}: ProviderEditorHeaderProps) {
|
||||
@@ -41,16 +43,31 @@ export function ProviderEditorHeader({
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">{displayName}</h2>
|
||||
{data?.path && (
|
||||
{isRemoteMode && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs gap-1 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
>
|
||||
<Globe className="w-3 h-3" />
|
||||
Remote
|
||||
</Badge>
|
||||
)}
|
||||
{!isRemoteMode && data?.path && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{data.path.replace(/^.*\//, '')}
|
||||
{data.path.replace(/^.*[\\/]/, '')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{data && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Last modified: {new Date(data.mtime).toLocaleString()}
|
||||
{isRemoteMode ? (
|
||||
<p className="text-xs text-blue-600 dark:text-blue-400 mt-0.5">
|
||||
Traffic auto-routed to remote server
|
||||
</p>
|
||||
) : (
|
||||
data && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Last modified: {new Date(data.mtime).toLocaleString()}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface ProviderEditorProps {
|
||||
catalog?: ProviderCatalog;
|
||||
/** Provider type for logo display (defaults to provider) */
|
||||
logoProvider?: string;
|
||||
/** True if using remote CLIProxy mode (hides local paths) */
|
||||
isRemoteMode?: boolean;
|
||||
onAddAccount: () => void;
|
||||
onSetDefault: (accountId: string) => void;
|
||||
onRemoveAccount: (accountId: string) => void;
|
||||
|
||||
@@ -3,11 +3,24 @@
|
||||
*
|
||||
* Displays CLIProxy process status with start/stop/restart controls.
|
||||
* Shows: running state, port, session count, uptime, update availability.
|
||||
* In remote mode: shows remote server info instead of local controls.
|
||||
*/
|
||||
|
||||
import { Activity, Power, RefreshCw, Clock, Users, Square, RotateCw, ArrowUp } from 'lucide-react';
|
||||
import {
|
||||
Activity,
|
||||
Power,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Users,
|
||||
Square,
|
||||
RotateCw,
|
||||
ArrowUp,
|
||||
Globe,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api, type CliproxyServerConfig } from '@/lib/api-client';
|
||||
import {
|
||||
useProxyStatus,
|
||||
useStartProxy,
|
||||
@@ -48,10 +61,32 @@ export function ProxyStatusWidget() {
|
||||
const startProxy = useStartProxy();
|
||||
const stopProxy = useStopProxy();
|
||||
|
||||
// Fetch cliproxy_server config for remote mode detection
|
||||
const { data: cliproxyConfig } = useQuery<CliproxyServerConfig>({
|
||||
queryKey: ['cliproxy-server-config'],
|
||||
queryFn: () => api.cliproxyServer.get(),
|
||||
staleTime: 30000, // 30 seconds
|
||||
});
|
||||
|
||||
// Determine if remote mode is enabled
|
||||
const remoteConfig = cliproxyConfig?.remote;
|
||||
const isRemoteMode = remoteConfig?.enabled && remoteConfig?.host;
|
||||
|
||||
const isRunning = status?.running ?? false;
|
||||
const isActioning = startProxy.isPending || stopProxy.isPending;
|
||||
const hasUpdate = updateCheck?.hasUpdate ?? false;
|
||||
|
||||
// Build remote display info
|
||||
const remoteDisplayHost = isRemoteMode
|
||||
? (() => {
|
||||
const protocol = remoteConfig.protocol || 'http';
|
||||
const port = remoteConfig.port || (protocol === 'https' ? 443 : 80);
|
||||
const isDefaultPort =
|
||||
(protocol === 'https' && port === 443) || (protocol === 'http' && port === 80);
|
||||
return isDefaultPort ? remoteConfig.host : `${remoteConfig.host}:${port}`;
|
||||
})()
|
||||
: null;
|
||||
|
||||
// Restart = stop then start
|
||||
const handleRestart = async () => {
|
||||
await stopProxy.mutateAsync();
|
||||
@@ -60,6 +95,43 @@ export function ProxyStatusWidget() {
|
||||
startProxy.mutate();
|
||||
};
|
||||
|
||||
// Remote mode: show remote server info
|
||||
if (isRemoteMode) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border p-3 transition-colors',
|
||||
'border-blue-500/30 bg-blue-500/5'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm font-medium">Remote Proxy</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] h-4 px-1.5 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
>
|
||||
Active
|
||||
</Badge>
|
||||
</div>
|
||||
<Activity className="w-3 h-3 text-blue-600" />
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<span className="font-mono">{remoteDisplayHost}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground/70 leading-tight">
|
||||
Traffic auto-routed to remote server
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Local mode: show original controls
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -249,7 +249,8 @@ export const api = {
|
||||
},
|
||||
cliproxy: {
|
||||
list: () => request<{ variants: Variant[] }>('/cliproxy'),
|
||||
getAuthStatus: () => request<{ authStatus: AuthStatus[] }>('/cliproxy/auth'),
|
||||
getAuthStatus: () =>
|
||||
request<{ authStatus: AuthStatus[]; source?: 'remote' | 'local' }>('/cliproxy/auth'),
|
||||
create: (data: CreateVariant) =>
|
||||
request('/cliproxy', {
|
||||
method: 'POST',
|
||||
@@ -307,16 +308,18 @@ export const api = {
|
||||
|
||||
// Multi-account management
|
||||
accounts: {
|
||||
list: () => request<{ accounts: ProviderAccountsMap }>('/cliproxy/accounts'),
|
||||
list: () => request<{ accounts: ProviderAccountsMap }>('/cliproxy/auth/accounts'),
|
||||
listByProvider: (provider: string) =>
|
||||
request<{ provider: string; accounts: OAuthAccount[] }>(`/cliproxy/accounts/${provider}`),
|
||||
request<{ provider: string; accounts: OAuthAccount[] }>(
|
||||
`/cliproxy/auth/accounts/${provider}`
|
||||
),
|
||||
setDefault: (provider: string, accountId: string) =>
|
||||
request(`/cliproxy/accounts/${provider}/default`, {
|
||||
request(`/cliproxy/auth/accounts/${provider}/default`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ accountId }),
|
||||
}),
|
||||
remove: (provider: string, accountId: string) =>
|
||||
request(`/cliproxy/accounts/${provider}/${accountId}`, { method: 'DELETE' }),
|
||||
request(`/cliproxy/auth/accounts/${provider}/${accountId}`, { method: 'DELETE' }),
|
||||
},
|
||||
// OAuth flow
|
||||
auth: {
|
||||
|
||||
@@ -192,6 +192,7 @@ export function CliproxyPage() {
|
||||
} | null>(null);
|
||||
|
||||
const providers = useMemo(() => authData?.authStatus || [], [authData?.authStatus]);
|
||||
const isRemoteMode = authData?.source === 'remote';
|
||||
const variants = useMemo(() => variantsData?.variants || [], [variantsData?.variants]);
|
||||
|
||||
// Auto-select first provider if nothing selected
|
||||
@@ -338,6 +339,7 @@ export function CliproxyPage() {
|
||||
authStatus={parentAuthForVariant}
|
||||
catalog={MODEL_CATALOGS[selectedVariantData.provider]}
|
||||
logoProvider={selectedVariantData.provider}
|
||||
isRemoteMode={isRemoteMode}
|
||||
onAddAccount={() =>
|
||||
setAddAccountProvider({
|
||||
provider: selectedVariantData.provider,
|
||||
@@ -365,6 +367,7 @@ export function CliproxyPage() {
|
||||
displayName={selectedStatus.displayName}
|
||||
authStatus={selectedStatus}
|
||||
catalog={MODEL_CATALOGS[selectedStatus.provider]}
|
||||
isRemoteMode={isRemoteMode}
|
||||
onAddAccount={() =>
|
||||
setAddAccountProvider({
|
||||
provider: selectedStatus.provider,
|
||||
|
||||
Reference in New Issue
Block a user