No files matched your search
@@ -0,0 +1,218 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Logs UI Redesign - Evidence (#1142, parent #1138)</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #ffffff;
|
||||
--fg: #18181b;
|
||||
--muted: #71717a;
|
||||
--border: #e4e4e7;
|
||||
--accent: #2563eb;
|
||||
--callout: #ef4444;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
line-height: 1.5;
|
||||
}
|
||||
header.page { max-width: 1180px; margin: 0 auto; padding: 48px 32px 24px; }
|
||||
header.page h1 { font-size: 28px; margin: 0 0 8px; letter-spacing: -0.01em; }
|
||||
header.page p { margin: 4px 0; color: var(--muted); font-size: 14px; }
|
||||
main { max-width: 1180px; margin: 0 auto; padding: 0 32px 64px; }
|
||||
section.surface {
|
||||
margin: 32px 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #fafafa;
|
||||
}
|
||||
section.surface > header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 20px; border-bottom: 1px solid var(--border); background: #ffffff;
|
||||
}
|
||||
section.surface > header h2 { margin: 0; font-size: 16px; }
|
||||
section.surface > header .meta { font-size: 12px; color: var(--muted); }
|
||||
.body { padding: 20px; background: #ffffff; }
|
||||
figure { margin: 0; }
|
||||
figure img { width: 100%; height: auto; display: block; border-radius: 8px; border: 1px solid var(--border); }
|
||||
figure figcaption { font-size: 13px; color: var(--muted); margin-top: 10px; }
|
||||
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.mobile-row { display: grid; grid-template-columns: 1fr 380px; gap: 24px; align-items: start; }
|
||||
ul.checklist { margin: 16px 0; padding-left: 20px; font-size: 14px; }
|
||||
ul.checklist li { margin: 6px 0; }
|
||||
code { background: #f4f4f5; padding: 1px 6px; border-radius: 4px; font-size: 90%; }
|
||||
.badge {
|
||||
display: inline-block; padding: 2px 8px; border-radius: 999px;
|
||||
background: #f4f4f5; color: #52525b; font-size: 11px;
|
||||
font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
.legend { background: #fffbeb; border: 1px solid #fde68a; padding: 12px 16px; border-radius: 8px; font-size: 13px; color: #713f12; margin: 12px 0 0; }
|
||||
hr { border: 0; border-top: 1px solid var(--border); margin: 32px 0; }
|
||||
.meta-list { list-style: none; padding: 0; margin: 0; font-size: 13px; color: var(--muted); }
|
||||
.meta-list li { margin: 4px 0; }
|
||||
@media (max-width: 900px) {
|
||||
.pair, .mobile-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="page">
|
||||
<span class="badge">Force-UI evidence . light theme . targeted mode</span>
|
||||
<h1>Dashboard logs page redesign</h1>
|
||||
<p>Sub-issue <a href="https://github.com/kaitranntt/ccs/issues/1142">#1142</a> .
|
||||
parent <a href="https://github.com/kaitranntt/ccs/issues/1138">#1138</a> .
|
||||
sibling backend <a href="https://github.com/kaitranntt/ccs/issues/1141">#1141</a></p>
|
||||
<p>Branch <code>kai/feat/1138-logs-ui-redesign</code> .
|
||||
target <code>kai/feat/1138-logging-revamp</code> (integration) .
|
||||
captures via <code>?mock=logs</code> against local dashboard at 1440x900 (mobile at 390x844)</p>
|
||||
<p class="legend">
|
||||
Targeted mode chosen: original "before" UI is captured in
|
||||
<a href="https://github.com/kaitranntt/ccs/issues/1138#issuecomment-4351038690">issue #1138 comment</a>
|
||||
(contributor screenshot labeled "looks terrible"). This evidence shows the redesigned
|
||||
state with red callout boxes annotating new behaviors.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="surface">
|
||||
<header>
|
||||
<h2>What changed</h2>
|
||||
<span class="meta">redesign-existing-projects . web-design-guidelines</span>
|
||||
</header>
|
||||
<div class="body">
|
||||
<ul class="checklist">
|
||||
<li>Removed ornamental glow / radial gradients / faux-HUD copy from <code>logs.tsx</code> and <code>components/logs/*</code>.</li>
|
||||
<li>Calm 3-pane shell on desktop, stacked + bottom-sheet on mobile.</li>
|
||||
<li>List virtualized via <code>react-virtuoso</code>; trace-grouped rows by <code>requestId</code>.</li>
|
||||
<li>Filters split into primary (search/level/source) + advanced (module/stage/requestId/time window).</li>
|
||||
<li>Live-tail pause/resume with rotation-safe id-set diff for new-entries pill.</li>
|
||||
<li>Detail panel: Overview / Context / Raw tabs; copy JSON, copy requestId, "Show trace" jump.</li>
|
||||
<li>Designed states: empty (selection / no data / no results / out-of-scope) and error w/ retry.</li>
|
||||
<li>Keyboard: <code>j</code>/<code>k</code> . <code>Enter</code> . <code>Esc</code> . <code>/</code> . <code>Space</code> . <code>?</code> + visible <code>?</code> button in header.</li>
|
||||
<li>Reduced-motion respected; AA contrast tokens; visible focus rings.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="surface">
|
||||
<header>
|
||||
<h2>1. Overview - default state</h2>
|
||||
<span class="meta">1440x900 . light . callouts on Live pill (1) and ? button (2)</span>
|
||||
</header>
|
||||
<div class="body">
|
||||
<figure>
|
||||
<img src="screenshots/01-overview.png" alt="Logs page default state" />
|
||||
<figcaption>List + filters + detail panel populated. <strong>(1)</strong> connection status pill ("Live"). <strong>(2)</strong> new <code>?</code> button surfaces keyboard shortcuts dialog.</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="surface">
|
||||
<header>
|
||||
<h2>2. Trace grouping</h2>
|
||||
<span class="meta">collapse-by-requestId . expanded vs collapsed states</span>
|
||||
</header>
|
||||
<div class="body">
|
||||
<figure>
|
||||
<img src="screenshots/02-trace-grouping.png" alt="Trace grouping with expanded and collapsed states" />
|
||||
<figcaption><strong>(1)</strong> expanded trace shows 5 indented stage rows (intake / auth / route / handler / provider) under the parent row, all sharing <code>req_0001</code>. <strong>(2)</strong> collapsed trace badge ("trace . 6 stages") with chevron - one click toggles.</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="surface">
|
||||
<header>
|
||||
<h2>3. Live-tail paused</h2>
|
||||
<span class="meta">pause/resume affordance</span>
|
||||
</header>
|
||||
<div class="body">
|
||||
<figure>
|
||||
<img src="screenshots/03-live-tail-paused.png" alt="Live tail paused state" />
|
||||
<figcaption><strong>(1)</strong> sticky header swaps to "Resume tail" with play icon when paused (was "Pause tail" with pause icon). <strong>(2)</strong> connection pill stays "Live" - polling continues to count new entries; UI just stops auto-scrolling.</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="surface">
|
||||
<header>
|
||||
<h2>4. Advanced filters</h2>
|
||||
<span class="meta">primary + advanced split</span>
|
||||
</header>
|
||||
<div class="body">
|
||||
<figure>
|
||||
<img src="screenshots/04-advanced-filters.png" alt="Advanced filters expanded" />
|
||||
<figcaption>Advanced filters expand inline below primary filters: <code>module</code> . <code>stage</code> . <code>requestId</code> . <code>time window</code>. Disclosure chevron flips on toggle. All inputs debounced 250ms; in-flight queries cancelled on filter change.</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="surface">
|
||||
<header>
|
||||
<h2>5. Empty state - filtered to nothing</h2>
|
||||
<span class="meta">designed empty + active filter chip</span>
|
||||
</header>
|
||||
<div class="body">
|
||||
<figure>
|
||||
<img src="screenshots/05-empty-state.png" alt="Empty state with active filter chip" />
|
||||
<figcaption>Filter <code>requestId: req_99999</code> matches nothing. Center pane shows centered icon + "No entries match your filters" + actionable copy. Active filter chip <code>requestId: req_99999</code> with x to clear; "Clear all" link below. Right pane shows distinct "No entry selected" empty variant.</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="surface">
|
||||
<header>
|
||||
<h2>6. Keyboard shortcuts dialog</h2>
|
||||
<span class="meta">discoverable via ? button or ? keypress</span>
|
||||
</header>
|
||||
<div class="body">
|
||||
<figure>
|
||||
<img src="screenshots/06-shortcuts-dialog.png" alt="Keyboard shortcuts dialog" />
|
||||
<figcaption><strong>(1)</strong> new <code>?</code> button in header. <strong>(2)</strong> dialog lists all 7 keybindings: <code>j</code>/<code>arrow-down</code> next, <code>k</code>/<code>arrow-up</code> prev, <code>Enter</code> focus detail, <code>Esc</code> close mobile, <code>/</code> search, <code>Space</code> pause/resume, <code>?</code> help. Background shows the upstream-errors view with multiple error traces visible.</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="surface">
|
||||
<header>
|
||||
<h2>7. Mobile - 390x844</h2>
|
||||
<span class="meta">stacked layout, no detail-pane fight</span>
|
||||
</header>
|
||||
<div class="body">
|
||||
<div class="mobile-row">
|
||||
<div></div>
|
||||
<figure>
|
||||
<img src="screenshots/07-mobile.png" alt="Mobile layout 390x844" />
|
||||
<figcaption>Header collapses to icon row (Live pill, Refresh, ?, Settings). Filters stack vertically. Detail panel becomes bottom sheet on row tap (not shown here - empty state at small viewport just shows the list).</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
|
||||
<section class="surface">
|
||||
<header>
|
||||
<h2>Capture metadata</h2>
|
||||
</header>
|
||||
<div class="body">
|
||||
<ul class="meta-list">
|
||||
<li>Theme: light (default per force-UI rule).</li>
|
||||
<li>Backend: mock fixture via <code>?mock=logs</code> URL flag (150-entry deterministic fixture incl. 5 traces of 3-6 stages each).</li>
|
||||
<li>User / dataset / fixture / theme / viewport held constant within each capture.</li>
|
||||
<li>Callouts: DOM overlay injected via Playwright <code>browser_evaluate</code>, red <code>#ef4444</code> 3px solid border, 8px expanded padding (no ImageMagick).</li>
|
||||
<li>Capture tool: Playwright MCP. Annotations baked into the PNG by injecting overlay divs before screenshot.</li>
|
||||
<li>HEAD SHA: <code>1c814787a671be1121154b1c72f48932ec4f9904</code> - orchestrator pins after final commit.</li>
|
||||
<li>PR number: <code>1146</code> - orchestrator renames asset directory after PR creation.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 66 KiB |
@@ -8,7 +8,7 @@ on:
|
||||
jobs:
|
||||
release:
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, linux, x64]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
jobs:
|
||||
sync-dev:
|
||||
if: ${{ !github.event.release.prerelease && github.event.release.target_commitish == 'main' && startsWith(github.event.release.tag_name, 'v') && !contains(github.event.release.tag_name, '-') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, linux, x64]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -17,8 +17,9 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Always use built-in workflow token so PAT rotation never breaks sync jobs.
|
||||
token: ${{ github.token }}
|
||||
# PAT_TOKEN is required because this pushes to protected dev and must
|
||||
# trigger follow-up Push CI / Dev Release workflows.
|
||||
token: ${{ secrets.PAT_TOKEN }}
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
@@ -31,7 +32,7 @@ jobs:
|
||||
git checkout dev
|
||||
|
||||
# Attempt merge, handle version conflicts by taking main's versions
|
||||
if ! git merge origin/main --no-edit -m "chore(sync): merge main into dev after release [skip ci]"; then
|
||||
if ! git merge origin/main --no-edit -m "chore(sync): merge main into dev after release"; then
|
||||
echo "Merge conflicts detected, resolving version files..."
|
||||
|
||||
# For version files, take main's version (new stable base)
|
||||
|
||||
@@ -46,3 +46,19 @@ logs/
|
||||
|
||||
# Test coverage
|
||||
ui/coverage/
|
||||
|
||||
# UI build artifacts (generated by tsc -b in pre-commit hooks)
|
||||
ui/src/lib/*.js
|
||||
ui/src/lib/*.d.ts
|
||||
ui/src/lib/*.js.map
|
||||
ui/src/lib/*.d.ts.map
|
||||
|
||||
# Test mock build artifacts
|
||||
tests/mocks/*.js
|
||||
tests/mocks/*.d.ts
|
||||
tests/mocks/*.js.map
|
||||
tests/mocks/*.d.ts.map
|
||||
tests/mocks/fixtures/*.js
|
||||
tests/mocks/fixtures/*.d.ts
|
||||
tests/mocks/fixtures/*.js.map
|
||||
tests/mocks/fixtures/*.d.ts.map
|
||||
@@ -417,7 +417,7 @@ Windows fallback: Copies if symlinks unavailable
|
||||
- Native JSON only, no external dependencies
|
||||
|
||||
### TypeScript (src/*.ts)
|
||||
- Node.js 14+, Bun 1.0+, TypeScript 5.3, strict mode
|
||||
- Node.js 18+, Bun 1.0+, TypeScript 5.3, strict mode
|
||||
- `child_process.spawn`, handle SIGINT/SIGTERM
|
||||
|
||||
### Terminal Output
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# Logging Contract
|
||||
|
||||
Single source of truth for structured backend logging in CCS CLI. Companion to GitHub issues #1138 (umbrella) and #1141 (backend instrumentation).
|
||||
|
||||
## Overview
|
||||
|
||||
CCS emits structured JSONL log entries for backend behavior (proxy daemons, OAuth flows, target spawn lifecycle, executor errors, etc.). This document defines the canonical schema, request-correlation pattern, lifecycle stages, and redaction policy.
|
||||
|
||||
> CLI text output (`ok / info / warn / fail` from `src/utils/ui.ts`) is **NOT** affected by this contract. Logs are a separate channel — never printed to stdout/stderr.
|
||||
|
||||
## Schema (`LogEntry`)
|
||||
|
||||
Defined in `src/services/logging/log-types.ts`.
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `id` | `string` | yes | UUID per entry. |
|
||||
| `timestamp` | `string` | yes | ISO 8601. |
|
||||
| `level` | `'error'\|'warn'\|'info'\|'debug'` | yes | |
|
||||
| `source` | `string` | yes | Module-scoped identifier (e.g. `proxy:openai-compat:messages`). |
|
||||
| `event` | `string` | yes | Dotted machine-readable event name (e.g. `request.received`). |
|
||||
| `message` | `string` | yes | Human-readable summary. |
|
||||
| `processId` | `number` | yes | `process.pid`. |
|
||||
| `runId` | `string` | yes | Stable per-process id. |
|
||||
| `context` | `object` | no | Free-form structured fields (redacted). |
|
||||
| `requestId` | `string` | no | Correlates entries belonging to one inbound request across stages. |
|
||||
| `stage` | `LogStage` | no | Lifecycle stage tag. |
|
||||
| `latencyMs` | `number` | no | Elapsed ms (typically on `respond` / `cleanup`). |
|
||||
| `error` | `{name, message, code?, stack?}` | no | Structured error metadata; never raw token strings. |
|
||||
|
||||
Old free-form entries (no `requestId` / `stage`) are still valid; new fields are additive.
|
||||
|
||||
### Example
|
||||
|
||||
```jsonl
|
||||
{"id":"...","timestamp":"2026-04-30T12:34:56.000Z","level":"info","source":"proxy:openai-compat:messages","event":"request.received","message":"Proxy /v1/messages request received","processId":42,"runId":"r1","requestId":"a1b2...","stage":"intake","context":{"method":"POST"}}
|
||||
```
|
||||
|
||||
## Lifecycle Stages
|
||||
|
||||
`LogStage` is one of:
|
||||
|
||||
| Stage | When to emit |
|
||||
|-------|--------------|
|
||||
| `intake` | Inbound request received at an entry edge (HTTP handler, CLI dispatch). |
|
||||
| `route` | Destination/profile/target resolution. |
|
||||
| `auth` | Authentication / authorization (token exchange, profile auth). |
|
||||
| `dispatch` | Outbound request prepared / child process spawned. |
|
||||
| `upstream` | Upstream call in flight (provider HTTP / spawned child running). |
|
||||
| `transform` | Payload translation (request/response shape conversion). |
|
||||
| `respond` | Response written / dispatched (`latencyMs` typically populated). |
|
||||
| `cleanup` | Error path, abort, teardown. |
|
||||
|
||||
Stages may be skipped or repeated. Streaming responses tag `upstream` only at start/end (NOT per chunk).
|
||||
|
||||
## RequestId Propagation (AsyncLocalStorage)
|
||||
|
||||
`requestId` is propagated implicitly via Node `AsyncLocalStorage`. Entry edges wrap their handler in `withRequestContext`; every `createLogger`-emitted entry inside the context auto-merges `requestId` from the active store.
|
||||
|
||||
```ts
|
||||
import { withRequestContext, createLogger } from './services/logging';
|
||||
|
||||
const logger = createLogger('proxy:my-edge');
|
||||
|
||||
http.createServer((req, res) => {
|
||||
const requestId = req.headers['x-ccs-request-id'] ?? randomUUID();
|
||||
res.setHeader('x-ccs-request-id', requestId);
|
||||
withRequestContext({ requestId }, async () => {
|
||||
logger.stage('intake', 'request.received', 'inbound');
|
||||
// ... downstream work emits with the same requestId
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Cross-daemon header
|
||||
|
||||
`x-ccs-request-id` round-trips across the proxy edge:
|
||||
- Inbound: if the header is present and matches the UUID-ish guard (`/^[A-Za-z0-9._-]{8,128}$/`), it is reused; otherwise a fresh UUID is minted.
|
||||
- Outbound (response): the resolved id is echoed back via `res.setHeader('x-ccs-request-id', ...)`.
|
||||
- When CCS calls another daemon (copilot, cursor, glmt), forward the active id in the same header so that daemon can correlate.
|
||||
|
||||
### Ordering guarantee
|
||||
|
||||
Emit-time ordering of entries within a single `requestId` is monotonic — the active context is single-threaded relative to the request, so `timestamp` ordering reflects emit order. The UI layer (#1142) consumes this guarantee.
|
||||
|
||||
### What NOT to put in the context
|
||||
|
||||
The ALS context object is mixed into every downstream entry. Never store:
|
||||
- Raw tokens, API keys, refresh tokens, OAuth codes
|
||||
- Raw request/response bodies
|
||||
- User-supplied secrets
|
||||
|
||||
Only benign correlation metadata: `requestId`, `method`, `path`, `command`, `profile`.
|
||||
|
||||
### Worker threads / spawned children
|
||||
|
||||
ALS context is **not** inherited by worker threads or `child_process.spawn` stdio pipes. At those boundaries, mint a fresh `requestId` at the child entry and pass the parent id explicitly via env var or header for correlation.
|
||||
|
||||
## Redaction
|
||||
|
||||
`src/services/logging/log-redaction.ts` is the single source of truth.
|
||||
|
||||
### Sensitive key matcher
|
||||
|
||||
`SENSITIVE_KEY_PATTERN` matches (case-insensitive, with `_` / `-` / camelCase variants):
|
||||
`authorization`, `proxy-authorization`, `cookie`, `set-cookie`, `password`, `password_hash`, `secret`, `client_secret`, `token`, `auth_token`, `access_token`, `refresh_token`, `id_token`, `bearer`, `assertion`, `api_key`, `x-api-key`, `x-goog-api-key`, `management_key`, `copilot_token`, `cursor_session_key`, `oauth_code`, `auth_code`.
|
||||
|
||||
String/object values for matching keys are replaced with `[redacted]`. Numeric/boolean values pass through (e.g., `expires_at` epoch numbers stay readable).
|
||||
|
||||
### Auth-scheme value masking
|
||||
|
||||
Raw string values whose prefix matches `^(Bearer|Basic|Token)\s+\S+` are rewritten to `<scheme> [redacted]` even when nested under non-sensitive keys.
|
||||
|
||||
### Argv redaction
|
||||
|
||||
`redactArgv(argv)` redacts the value following any sensitive flag (`--token`, `--api-key`, `--auth`, `--bearer`, `--secret`, `--client-secret`, `--access-token`, `--refresh-token`, `--id-token`, `--password`).
|
||||
|
||||
### Adding new sensitive keys
|
||||
|
||||
1. Extend `SENSITIVE_KEY_PATTERN` in `src/services/logging/log-redaction.ts`.
|
||||
2. Add a unit test in `tests/unit/services/logging/log-redaction-extended.test.ts`.
|
||||
3. Verify regex stays O(1) per key (no catastrophic backtracking).
|
||||
|
||||
## Contributor Guide
|
||||
|
||||
### When to use `logger.stage()` vs `logger.info()`
|
||||
|
||||
Use `stage()` whenever the entry corresponds to one of the canonical lifecycle stages — this is what observability tooling and the dashboard rely on. Use `info()` / `warn()` / `error()` for one-off events that don't fit a stage.
|
||||
|
||||
### What NOT to log
|
||||
|
||||
- Token values (use metadata: `expires_at`, `scopes`, account display name).
|
||||
- Request/response bodies (sample lengths only).
|
||||
- Authorization headers (log header *names* present, not values).
|
||||
|
||||
### Level guidance
|
||||
|
||||
| Level | Use for |
|
||||
|-------|---------|
|
||||
| `error` | Failures requiring action (cleanup stage). |
|
||||
| `warn` | Recoverable issues (auth rejected, route fallback). |
|
||||
| `info` | Lifecycle stage entries by default. |
|
||||
| `debug` | High-volume detail (per-chunk stream metrics, lock acquire/release). |
|
||||
|
||||
### Level config
|
||||
|
||||
Default level is `info`. Configure via `logging.level` in `~/.ccs/config.yaml`. Streaming providers MUST gate per-chunk metrics behind `debug`.
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
- All new `LogEntry` fields (`requestId`, `stage`, `latencyMs`, `error`) are optional. Old readers ignore them.
|
||||
- Existing `console.*` UX prints in `src/commands/`, `src/utils/ui.ts`, and similar user-facing paths are intentionally **not** converted to logger.
|
||||
- `/api/logs` reader unchanged in this PR; UI surfacing of new fields tracked under #1142.
|
||||
|
||||
## Future Work
|
||||
|
||||
- UI surfacing of `requestId` / `stage` / `latencyMs` in the dashboard (#1142).
|
||||
- `ccs logs` CLI improvements (filter by `requestId` / `stage`).
|
||||
- Per-stage performance budgets (see #1071).
|
||||
@@ -1,6 +1,6 @@
|
||||
# CCS Project Roadmap
|
||||
|
||||
Last Updated: 2026-04-28
|
||||
Last Updated: 2026-04-30
|
||||
|
||||
Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans.
|
||||
|
||||
@@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic
|
||||
|
||||
### Recent Fixes
|
||||
|
||||
- **2026-04-30**: **#1153** Native Claude launches now accept session-scoped `--effort low|medium|high|xhigh|max` overrides through CCS without mutating global Claude settings. CCS validates invalid or missing effort values before spawning Claude, normalizes accepted values, keeps default headless `-p/--prompt` launches on native Claude instead of delegation parsing, and preserves CLIProxy/Codex/Droid effort aliases.
|
||||
- **2026-04-28**: **#1123** CLIProxy quota failover now uses the dashboard/manual pause mechanism for all quota-visible OAuth providers with CCS quota fetchers: Antigravity, Claude, Codex, Gemini CLI, and GitHub Copilot. When a healthy fallback exists, CCS moves the exhausted account token out of the live `auth/` folder into `auth-paused/`, marks the account paused for dashboard visibility, persists the cooldown for auto-resume, and still avoids self-pausing the last usable account.
|
||||
- **2026-04-28**: **#1115** CCS now exposes upstream CLIProxy session affinity as a first-class local managed setting. Users can inspect and toggle local `session-affinity` plus TTL from `ccs cliproxy routing affinity`, from the `/cliproxy` dashboard routing card, and through the local dashboard API. The generated local CLIProxy config now persists `routing.session-affinity` and `routing.session-affinity-ttl`, help/copy explains that CLIProxy prefers explicit session or thread identifiers before falling back to prompt-history hashing, and remote session-affinity management stays explicitly unsupported until upstream management APIs expose more than `routing.strategy`.
|
||||
- **2026-04-24**: **#1065** Local CLIProxy Plus is available again as an explicit opt-in backend through the community-maintained `kaitranntt/CLIProxyAPIPlus` fork. CCS keeps `original` as the default backend, no longer downgrades saved `backend: plus` configs to `original`, updates Plus release lookups to the maintained fork, and documents Plus as a targeted path for plus-only providers.
|
||||
|
||||
@@ -233,6 +233,11 @@ export class ClaudeAdapter implements TargetAdapter {
|
||||
}
|
||||
```
|
||||
|
||||
Native Claude launches keep user arguments session-scoped. The launch layer validates and normalizes
|
||||
`--effort low|medium|high|xhigh|max` before spawning Claude, then passes it through without writing
|
||||
to Claude or CCS configuration. CLIProxy-backed Claude launches still treat `--effort` as the CCS
|
||||
thinking alias handled by CLIProxy.
|
||||
|
||||
### Credential Delivery
|
||||
|
||||
**Method**: Environment variables
|
||||
|
||||
@@ -5,6 +5,7 @@ import prettier from 'eslint-config-prettier';
|
||||
export default [
|
||||
{
|
||||
files: ['src/**/*.ts'],
|
||||
ignores: ['src/**/__tests__/**'],
|
||||
languageOptions: {
|
||||
parser: tsparser,
|
||||
parserOptions: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "7.75.0",
|
||||
"version": "7.75.0-dev.11",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -5,7 +5,7 @@ const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
const candidateRoots = ['tests/unit', 'tests/integration', 'tests/npm'];
|
||||
const candidateRoots = ['tests/unit', 'tests/integration', 'tests/npm', 'src'];
|
||||
// Add a `.ts` test to `slowTests` when ANY of these apply:
|
||||
// 1. It spawns a child process (CLI, bun test, node, gh, etc.).
|
||||
// 2. It binds a port, starts a server, or talks to localhost.
|
||||
@@ -20,6 +20,7 @@ const candidateRoots = ['tests/unit', 'tests/integration', 'tests/npm'];
|
||||
// Automated perf-budget enforcement tracked in issue #1071.
|
||||
const slowTests = [
|
||||
'tests/integration/cursor-daemon-lifecycle.test.ts',
|
||||
'tests/integration/logging-request-context.test.ts',
|
||||
'tests/integration/proxy/daemon-lifecycle.test.ts',
|
||||
'tests/unit/commands/persist-command-handler.test.ts',
|
||||
'tests/unit/hooks/browser-mcp-advanced-interactions.test.ts',
|
||||
@@ -32,6 +33,7 @@ const slowTests = [
|
||||
'tests/unit/targets/codex-settings-bridge-launch.test.ts',
|
||||
'tests/unit/targets/droid-command-routing-integration.test.ts',
|
||||
'tests/unit/targets/droid-config-manager.test.ts',
|
||||
'tests/unit/targets/native-claude-effort-launch.test.ts',
|
||||
'tests/unit/targets/settings-profile-browser-launch.test.ts',
|
||||
'tests/unit/targets/settings-profile-image-analysis-launch.test.ts',
|
||||
'tests/unit/targets/settings-profile-websearch-launch.test.ts',
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir, loadConfigSafe } from '../../utils/config-manager';
|
||||
import { buildProxyUrl, getProxyTarget } from '../../cliproxy/proxy-target-resolver';
|
||||
import { getEffectiveApiKey } from '../../cliproxy/auth-token-manager';
|
||||
import { getModelMappingFromConfig } from '../../cliproxy/base-config-loader';
|
||||
import { buildProxyUrl, getProxyTarget } from '../../cliproxy/proxy/proxy-target-resolver';
|
||||
import { getEffectiveApiKey } from '../../cliproxy/auth/auth-token-manager';
|
||||
import { getModelMappingFromConfig } from '../../cliproxy/config/base-config-loader';
|
||||
import {
|
||||
CLIPROXY_PROVIDER_IDS,
|
||||
getProviderDescription,
|
||||
getProviderDisplayName,
|
||||
mapExternalProviderName,
|
||||
} from '../../cliproxy/provider-capabilities';
|
||||
import { extractProviderFromPathname } from '../../cliproxy/model-id-normalizer';
|
||||
import { extractProviderFromPathname } from '../../cliproxy/ai-providers/model-id-normalizer';
|
||||
import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import type { TargetType } from '../../targets/target-adapter';
|
||||
import type { Settings } from '../../types/config';
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import {
|
||||
extractProviderFromPathname,
|
||||
getDeniedModelIdReasonForProvider,
|
||||
} from '../../cliproxy/model-id-normalizer';
|
||||
} from '../../cliproxy/ai-providers/model-id-normalizer';
|
||||
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
|
||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||
import type { ProfileValidationIssue, ProfileValidationSummary } from './profile-types';
|
||||
|
||||
@@ -17,7 +17,7 @@ import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
|
||||
import {
|
||||
extractProviderFromPathname,
|
||||
getDeniedModelIdReasonForProvider,
|
||||
} from '../../cliproxy/model-id-normalizer';
|
||||
} from '../../cliproxy/ai-providers/model-id-normalizer';
|
||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||
import type {
|
||||
ModelMapping,
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
import type { AccountConfig } from '../config/unified-config-types';
|
||||
import { getCcsDir } from '../utils/config-manager';
|
||||
import { isValidContextGroupName, normalizeContextGroupName } from './account-context';
|
||||
import { createLogger } from '../services/logging';
|
||||
|
||||
const logger = createLogger('auth:profile-registry');
|
||||
|
||||
/**
|
||||
* Profile Registry (Simplified)
|
||||
@@ -182,6 +185,10 @@ export class ProfileRegistry {
|
||||
// Default always stays on implicit 'default' profile (uses ~/.claude/)
|
||||
|
||||
this._write(data);
|
||||
logger.stage('route', 'auth.profile.created', 'Profile created in registry', {
|
||||
profile: name,
|
||||
profileType: metadata.type || 'account',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -235,6 +242,9 @@ export class ProfileRegistry {
|
||||
}
|
||||
|
||||
this._write(data);
|
||||
logger.stage('cleanup', 'auth.profile.deleted', 'Profile deleted from registry', {
|
||||
profile: name,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -84,7 +84,7 @@ import { execClaude, stripAnthropicRoutingEnv, stripBrowserEnv } from './utils/s
|
||||
import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation';
|
||||
import { createOpenAICompatLaunchSettings } from './utils/openai-compat-launch-settings';
|
||||
import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning';
|
||||
import { createLogger } from './services/logging';
|
||||
import { createLogger, runWithRequestId } from './services/logging';
|
||||
import { buildCodexBrowserMcpOverrides } from './utils/browser-codex-overrides';
|
||||
import type { ProfileDetectionResult } from './auth/profile-detector';
|
||||
import type { BrowserLaunchOverride } from './utils/browser';
|
||||
@@ -140,6 +140,8 @@ interface RuntimeReasoningResolution {
|
||||
|
||||
const CODEX_RUNTIME_REASONING_LEVELS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']);
|
||||
const CODEX_NATIVE_PASSTHROUGH_FLAGS = new Set(['--help', '-h', '--version', '-v']);
|
||||
const NATIVE_CLAUDE_EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'] as const;
|
||||
const NATIVE_CLAUDE_EFFORT_LEVEL_SET = new Set<string>(NATIVE_CLAUDE_EFFORT_LEVELS);
|
||||
|
||||
function resolveCodexRuntimeConfigOverrides(
|
||||
target: ReturnType<typeof resolveTargetType>,
|
||||
@@ -241,6 +243,49 @@ function exitWithRuntimeReasoningFlagError(
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function normalizeNativeClaudeEffortArgs(args: string[]): string[] {
|
||||
const normalizedArgs: string[] = [];
|
||||
const allowedValues = NATIVE_CLAUDE_EFFORT_LEVELS.join(', ');
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === '--effort') {
|
||||
const rawValue = args[i + 1];
|
||||
if (!rawValue || rawValue.startsWith('-') || !rawValue.trim()) {
|
||||
throw new Error(`--effort requires a value: ${allowedValues}`);
|
||||
}
|
||||
const value = rawValue.toLowerCase();
|
||||
if (!NATIVE_CLAUDE_EFFORT_LEVEL_SET.has(value)) {
|
||||
throw new Error(`Invalid --effort value: ${rawValue}. Expected one of: ${allowedValues}.`);
|
||||
}
|
||||
normalizedArgs.push(arg, value);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--effort=')) {
|
||||
const rawValue = arg.slice('--effort='.length);
|
||||
if (!rawValue.trim()) {
|
||||
throw new Error(`--effort requires a value: ${allowedValues}`);
|
||||
}
|
||||
const value = rawValue.toLowerCase();
|
||||
if (!NATIVE_CLAUDE_EFFORT_LEVEL_SET.has(value)) {
|
||||
throw new Error(`Invalid --effort value: ${rawValue}. Expected one of: ${allowedValues}.`);
|
||||
}
|
||||
normalizedArgs.push(`--effort=${value}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
normalizedArgs.push(arg);
|
||||
}
|
||||
|
||||
return normalizedArgs;
|
||||
}
|
||||
|
||||
function shouldNormalizeNativeClaudeEffort(profileType: ProfileDetectionResult['type']): boolean {
|
||||
return profileType === 'default' || profileType === 'account' || profileType === 'settings';
|
||||
}
|
||||
|
||||
// ========== Main Execution ==========
|
||||
|
||||
interface ProfileError extends Error {
|
||||
@@ -780,6 +825,7 @@ async function main(): Promise<void> {
|
||||
|
||||
let targetRemainingArgs = remainingArgs;
|
||||
let runtimeReasoningOverride: string | number | undefined;
|
||||
let nativeClaudeRemainingArgs = remainingArgs;
|
||||
if (resolvedTarget === 'droid') {
|
||||
try {
|
||||
const droidRoute = routeDroidCommandArgs(remainingArgs);
|
||||
@@ -838,17 +884,19 @@ async function main(): Promise<void> {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} else if (resolvedTarget === 'claude' && shouldNormalizeNativeClaudeEffort(profileInfo.type)) {
|
||||
nativeClaudeRemainingArgs = normalizeNativeClaudeEffortArgs(remainingArgs);
|
||||
}
|
||||
|
||||
// Special case: headless delegation (-p/--prompt)
|
||||
// Keep existing behavior for Claude targets only; non-claude targets must continue
|
||||
// through normal adapter dispatch logic.
|
||||
if (args.includes('-p') || args.includes('--prompt')) {
|
||||
const shouldUseDelegation = resolvedTarget === 'claude' && profileInfo.type !== 'cliproxy';
|
||||
const shouldUseDelegation = resolvedTarget === 'claude' && profileInfo.type === 'settings';
|
||||
if (shouldUseDelegation) {
|
||||
const { DelegationHandler } = await import('./delegation/delegation-handler');
|
||||
const handler = new DelegationHandler();
|
||||
await handler.route(cleanArgs);
|
||||
await handler.route([profile, ...nativeClaudeRemainingArgs]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1425,8 +1473,8 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
const imageAnalysisArgs = imageAnalysisMcpReady
|
||||
? appendThirdPartyImageAnalysisToolArgs(remainingArgs)
|
||||
: remainingArgs;
|
||||
? appendThirdPartyImageAnalysisToolArgs(nativeClaudeRemainingArgs)
|
||||
: nativeClaudeRemainingArgs;
|
||||
const browserArgs = browserRuntimeEnv
|
||||
? appendBrowserToolArgs(imageAnalysisArgs)
|
||||
: imageAnalysisArgs;
|
||||
@@ -1527,8 +1575,16 @@ async function main(): Promise<void> {
|
||||
CCS_WEBSEARCH_SKIP: '1',
|
||||
CCS_IMAGE_ANALYSIS_SKIP: '1',
|
||||
};
|
||||
await maybeWarnAboutResumeLaneMismatch(profileInfo.name, instancePath, remainingArgs);
|
||||
const launchArgs = resolveNativeClaudeLaunchArgs(remainingArgs, 'account', instancePath);
|
||||
await maybeWarnAboutResumeLaneMismatch(
|
||||
profileInfo.name,
|
||||
instancePath,
|
||||
nativeClaudeRemainingArgs
|
||||
);
|
||||
const launchArgs = resolveNativeClaudeLaunchArgs(
|
||||
nativeClaudeRemainingArgs,
|
||||
'account',
|
||||
instancePath
|
||||
);
|
||||
execClaude(claudeCli, launchArgs, envVars);
|
||||
} else {
|
||||
// DEFAULT: No profile configured, use Claude's own defaults
|
||||
@@ -1626,7 +1682,9 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
const launchArgs = resolveNativeClaudeLaunchArgs(
|
||||
browserRuntimeEnv ? appendBrowserToolArgs(remainingArgs) : remainingArgs,
|
||||
browserRuntimeEnv
|
||||
? appendBrowserToolArgs(nativeClaudeRemainingArgs)
|
||||
: nativeClaudeRemainingArgs,
|
||||
'default',
|
||||
envVars.CLAUDE_CONFIG_DIR
|
||||
);
|
||||
@@ -1683,5 +1741,35 @@ process.on('SIGINT', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Run main
|
||||
main().catch(handleError);
|
||||
// Run main inside a per-invocation request context so all backend logging
|
||||
// emitted during this CLI run shares a single requestId. CLI text output
|
||||
// (stdout/stderr) is unaffected — the requestId lives in logs only.
|
||||
const cliEntryStartedAt = Date.now();
|
||||
const cliEntryLogger = createLogger('cli:entry');
|
||||
runWithRequestId(() => {
|
||||
cliEntryLogger.stage('intake', 'cli.command.start', 'CLI invocation started', {
|
||||
argv: process.argv.slice(2),
|
||||
});
|
||||
return main()
|
||||
.then(() => {
|
||||
cliEntryLogger.stage(
|
||||
'respond',
|
||||
'cli.command.complete',
|
||||
'CLI invocation completed',
|
||||
{ exitCode: process.exitCode ?? 0 },
|
||||
{ latencyMs: Date.now() - cliEntryStartedAt }
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
const error =
|
||||
err instanceof Error
|
||||
? { name: err.name, message: err.message, stack: err.stack }
|
||||
: { name: 'Error', message: String(err) };
|
||||
cliEntryLogger.stage('cleanup', 'cli.command.failed', 'CLI invocation failed', undefined, {
|
||||
level: 'error',
|
||||
latencyMs: Date.now() - cliEntryStartedAt,
|
||||
error,
|
||||
});
|
||||
handleError(err);
|
||||
});
|
||||
});
|
||||
@@ -16,12 +16,13 @@ function writeCliproxyConfig(homeDir: string, value: Record<string, unknown>): v
|
||||
|
||||
function readCliproxyConfig(homeDir: string): Record<string, any> {
|
||||
return (
|
||||
(yaml.load(fs.readFileSync(getCliproxyConfigPath(homeDir), 'utf8')) as Record<string, any>) || {}
|
||||
(yaml.load(fs.readFileSync(getCliproxyConfigPath(homeDir), 'utf8')) as Record<string, any>) ||
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
async function loadAiProviderService() {
|
||||
return import(`../../../src/cliproxy/ai-providers/service?stable-id=${Date.now()}`);
|
||||
return import(`../ai-providers/service?stable-id=${Date.now()}`);
|
||||
}
|
||||
|
||||
describe('ai-provider service stable ids', () => {
|
||||
@@ -62,7 +63,9 @@ describe('ai-provider service stable ids', () => {
|
||||
expect(family?.entries[1]?.id).toBeTruthy();
|
||||
expect(family?.entries[0]?.id).not.toBe(family?.entries[1]?.id);
|
||||
|
||||
const persisted = readCliproxyConfig(tempHome)['gemini-api-key'] as Array<Record<string, unknown>>;
|
||||
const persisted = readCliproxyConfig(tempHome)['gemini-api-key'] as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
expect(persisted[0]?.id).toBe(family?.entries[0]?.id);
|
||||
expect(persisted[1]?.id).toBe(family?.entries[1]?.id);
|
||||
});
|
||||
@@ -82,7 +85,9 @@ describe('ai-provider service stable ids', () => {
|
||||
baseUrl: 'https://example.test/gemini',
|
||||
});
|
||||
|
||||
let persisted = readCliproxyConfig(tempHome)['gemini-api-key'] as Array<Record<string, unknown>>;
|
||||
let persisted = readCliproxyConfig(tempHome)['gemini-api-key'] as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
expect(persisted[0]?.id).toBe('gemini-a');
|
||||
expect(persisted[0]?.['api-key']).toBe('gamma');
|
||||
expect(persisted[0]?.['base-url']).toBe('https://example.test/gemini');
|
||||
@@ -105,7 +110,9 @@ describe('ai-provider service stable ids', () => {
|
||||
apiKey: 'legacy-index-update',
|
||||
});
|
||||
|
||||
const persisted = readCliproxyConfig(tempHome)['gemini-api-key'] as Array<Record<string, unknown>>;
|
||||
const persisted = readCliproxyConfig(tempHome)['gemini-api-key'] as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
expect(persisted[0]?.id).toBe('gemini-a');
|
||||
expect(persisted[0]?.['api-key']).toBe('legacy-index-update');
|
||||
});
|
||||
@@ -140,9 +147,9 @@ describe('ai-provider service stable ids', () => {
|
||||
>;
|
||||
expect(persisted[0]?.id).toBe(connectorId);
|
||||
expect(persisted[0]?.['base-url']).toBe('https://router.example/v1');
|
||||
expect((persisted[0]?.['api-key-entries'] as Array<Record<string, unknown>>)[0]?.['api-key']).toBe(
|
||||
'sk-openrouter'
|
||||
);
|
||||
expect(
|
||||
(persisted[0]?.['api-key-entries'] as Array<Record<string, unknown>>)[0]?.['api-key']
|
||||
).toBe('sk-openrouter');
|
||||
});
|
||||
|
||||
it('normalizes plain openai-compatible model rules without aliases', async () => {
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { findModel, supportsThinking } from '../../../src/cliproxy/model-catalog';
|
||||
import {
|
||||
PROVIDER_TO_CHANNEL,
|
||||
SYNCABLE_PROVIDERS,
|
||||
mergeCatalog,
|
||||
} from '../../../src/cliproxy/catalog-cache';
|
||||
import { findModel, supportsThinking } from '../model-catalog';
|
||||
import { PROVIDER_TO_CHANNEL, SYNCABLE_PROVIDERS, mergeCatalog } from '../services/catalog-cache';
|
||||
|
||||
describe('model-catalog compatibility lookups', () => {
|
||||
it('finds agy Claude models using dotted major.minor IDs', () => {
|
||||
@@ -9,7 +9,7 @@ const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
describe('Model Config', () => {
|
||||
const modelConfig = require('../../../dist/cliproxy/model-config');
|
||||
const modelConfig = require('../../../dist/cliproxy/config/model-config');
|
||||
const modelCatalog = require('../../../dist/cliproxy/model-catalog');
|
||||
|
||||
describe('hasUserSettings', () => {
|
||||
@@ -118,10 +118,7 @@ describe('Model Config', () => {
|
||||
|
||||
assert(parsed.env, 'Should have env object');
|
||||
assert(parsed.env.ANTHROPIC_MODEL, 'Should have ANTHROPIC_MODEL');
|
||||
assert.strictEqual(
|
||||
parsed.env.ANTHROPIC_MODEL,
|
||||
'claude-opus-4-5-thinking'
|
||||
);
|
||||
assert.strictEqual(parsed.env.ANTHROPIC_MODEL, 'claude-opus-4-5-thinking');
|
||||
});
|
||||
|
||||
it('all env values should be strings (PowerShell safety)', () => {
|
||||
@@ -13,11 +13,11 @@ import {
|
||||
isQuotaSupportedProvider,
|
||||
QUOTA_PROVIDER_OPTION_VALUES,
|
||||
QUOTA_PROVIDER_HELP_TEXT,
|
||||
} from '../../../src/cliproxy/provider-capabilities';
|
||||
} from '../provider-capabilities';
|
||||
import {
|
||||
OAUTH_CALLBACK_PORTS as DIAGNOSTIC_CALLBACK_PORTS,
|
||||
OAUTH_FLOW_TYPES,
|
||||
} from '../../../src/management/oauth-port-diagnostics';
|
||||
} from '../../management/oauth-port-diagnostics';
|
||||
import {
|
||||
DEFAULT_KIRO_AUTH_METHOD,
|
||||
getKiroCallbackPort,
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
normalizeKiroAuthMethod,
|
||||
OAUTH_CALLBACK_PORTS as AUTH_CALLBACK_PORTS,
|
||||
toKiroManagementMethod,
|
||||
} from '../../../src/cliproxy/auth/auth-types';
|
||||
} from '../auth/auth-types';
|
||||
|
||||
describe('provider-capabilities', () => {
|
||||
it('keeps canonical provider IDs backward-compatible', () => {
|
||||
@@ -167,9 +167,13 @@ describe('provider-capabilities', () => {
|
||||
expect(getKiroCLIAuthFlag('aws-authcode')).toBe('--kiro-aws-authcode');
|
||||
expect(getKiroCLIAuthFlag('google')).toBe('--kiro-google-login');
|
||||
expect(getKiroCLIAuthFlag('idc')).toBe('--kiro-idc-login');
|
||||
expect(getKiroCLIAuthArgs('idc', { idcStartUrl: 'https://d-123.awsapps.com/start' })).toEqual(
|
||||
['--kiro-idc-login', '--kiro-idc-start-url', 'https://d-123.awsapps.com/start', '--kiro-idc-flow', 'authcode']
|
||||
);
|
||||
expect(getKiroCLIAuthArgs('idc', { idcStartUrl: 'https://d-123.awsapps.com/start' })).toEqual([
|
||||
'--kiro-idc-login',
|
||||
'--kiro-idc-start-url',
|
||||
'https://d-123.awsapps.com/start',
|
||||
'--kiro-idc-flow',
|
||||
'authcode',
|
||||
]);
|
||||
|
||||
expect(getKiroCallbackPort('aws')).toBeNull();
|
||||
expect(getKiroCallbackPort('google')).toBe(9876);
|
||||
@@ -30,7 +30,7 @@ const {
|
||||
getSessionLockPath,
|
||||
deleteSessionLockForPort,
|
||||
} = require('../../../dist/cliproxy/session-tracker');
|
||||
const { CLIPROXY_DEFAULT_PORT } = require('../../../dist/cliproxy/config-generator');
|
||||
const { CLIPROXY_DEFAULT_PORT } = require('../../../dist/cliproxy/config/config-generator');
|
||||
const { setGlobalConfigDir } = require('../../../dist/utils/config-manager');
|
||||
|
||||
describe('Session Tracker Port-Specific', function () {
|
||||
@@ -11,7 +11,10 @@ const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
// Set test isolation environment before importing
|
||||
const testHome = path.join(os.tmpdir(), `ccs-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
const testHome = path.join(
|
||||
os.tmpdir(),
|
||||
`ccs-test-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
);
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
const originalCcsDir = process.env.CCS_DIR;
|
||||
process.env.CCS_HOME = testHome;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { parseThinkingOverride } from '../../../src/cliproxy/executor/thinking-arg-parser';
|
||||
import { parseThinkingOverride } from '../executor/thinking-arg-parser';
|
||||
|
||||
describe('parseThinkingOverride', () => {
|
||||
it('parses --thinking with separate value', () => {
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import type { ThinkingConfig } from '../../../src/config/unified-config-types';
|
||||
import type { ThinkingConfig } from '../../config/unified-config-types';
|
||||
import {
|
||||
buildThinkingStartupStatus,
|
||||
parseEnvThinkingOverride,
|
||||
resolveRuntimeThinkingOverride,
|
||||
shouldDisableCodexReasoning,
|
||||
} from '../../../src/cliproxy/executor/thinking-override-resolver';
|
||||
} from '../executor/thinking-override-resolver';
|
||||
|
||||
const baseConfig: ThinkingConfig = {
|
||||
mode: 'auto',
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
THINKING_BUDGET_MIN,
|
||||
THINKING_BUDGET_MAX,
|
||||
THINKING_BUDGET_DEFAULT_MIN,
|
||||
} from '../../../src/cliproxy/thinking-validator';
|
||||
} from '../thinking-validator';
|
||||
|
||||
describe('Thinking Validator', () => {
|
||||
describe('Constants', () => {
|
||||
@@ -29,18 +29,18 @@ describe('Account Manager - discoverExistingAccounts', () => {
|
||||
process.env.CCS_HOME = testDir;
|
||||
|
||||
// Clear cache and reload - must clear all modular submodules too
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/account-manager')];
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/config-generator')];
|
||||
delete require.cache[require.resolve('../../../dist/utils/config-manager')];
|
||||
delete require.cache[require.resolve('../../../../dist/cliproxy/accounts/account-manager')];
|
||||
delete require.cache[require.resolve('../../../../dist/cliproxy/config/config-generator')];
|
||||
delete require.cache[require.resolve('../../../../dist/utils/config-manager')];
|
||||
// Clear new modular structure (accounts/ and config/ submodules)
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/accounts/index')];
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/accounts/registry')];
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/accounts/token-file-ops')];
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/accounts/query')];
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/accounts/types')];
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/config/index')];
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/config/path-resolver')];
|
||||
accountManager = require('../../../dist/cliproxy/account-manager');
|
||||
delete require.cache[require.resolve('../../../../dist/cliproxy/accounts/index')];
|
||||
delete require.cache[require.resolve('../../../../dist/cliproxy/accounts/registry')];
|
||||
delete require.cache[require.resolve('../../../../dist/cliproxy/accounts/token-file-ops')];
|
||||
delete require.cache[require.resolve('../../../../dist/cliproxy/accounts/query')];
|
||||
delete require.cache[require.resolve('../../../../dist/cliproxy/accounts/types')];
|
||||
delete require.cache[require.resolve('../../../../dist/cliproxy/config/index')];
|
||||
delete require.cache[require.resolve('../../../../dist/cliproxy/config/path-resolver')];
|
||||
accountManager = require('../../../../dist/cliproxy/accounts/account-manager');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -183,7 +183,11 @@ describe('Account Manager - discoverExistingAccounts', () => {
|
||||
|
||||
const accountIds = Object.keys(accounts.providers.kiro.accounts);
|
||||
// For kiro/ghcp without email, generates unique ID like "kiro-1"
|
||||
assert.strictEqual(accountIds[0], 'kiro-1', 'Should generate unique ID for kiro without email');
|
||||
assert.strictEqual(
|
||||
accountIds[0],
|
||||
'kiro-1',
|
||||
'Should generate unique ID for kiro without email'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles dots in email local part', () => {
|
||||
@@ -269,10 +273,10 @@ describe('Account Manager - discoverExistingAccounts', () => {
|
||||
});
|
||||
|
||||
// Reload module to clear in-memory cache (including all submodules)
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/account-manager')];
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/accounts/index')];
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/accounts/registry')];
|
||||
accountManager = require('../../../dist/cliproxy/account-manager');
|
||||
delete require.cache[require.resolve('../../../../dist/cliproxy/accounts/account-manager')];
|
||||
delete require.cache[require.resolve('../../../../dist/cliproxy/accounts/index')];
|
||||
delete require.cache[require.resolve('../../../../dist/cliproxy/accounts/registry')];
|
||||
accountManager = require('../../../../dist/cliproxy/accounts/account-manager');
|
||||
accountManager.discoverExistingAccounts();
|
||||
|
||||
const accounts = getAccountsFile();
|
||||
@@ -353,10 +357,10 @@ describe('Account Manager - discoverExistingAccounts', () => {
|
||||
});
|
||||
|
||||
// Reload module to pick up pre-existing accounts.json (including all submodules)
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/account-manager')];
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/accounts/index')];
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/accounts/registry')];
|
||||
accountManager = require('../../../dist/cliproxy/account-manager');
|
||||
delete require.cache[require.resolve('../../../../dist/cliproxy/accounts/account-manager')];
|
||||
delete require.cache[require.resolve('../../../../dist/cliproxy/accounts/index')];
|
||||
delete require.cache[require.resolve('../../../../dist/cliproxy/accounts/registry')];
|
||||
accountManager = require('../../../../dist/cliproxy/accounts/account-manager');
|
||||
accountManager.discoverExistingAccounts();
|
||||
|
||||
const accounts = getAccountsFile();
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it, spyOn } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { runWithScopedCcsHome } from '../../../src/utils/config-manager';
|
||||
import { runWithScopedCcsHome } from '../../../utils/config-manager';
|
||||
|
||||
async function withIsolatedHome<T>(fn: (homeDir: string) => Promise<T> | T): Promise<T> {
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-account-registry-'));
|
||||
@@ -14,14 +14,16 @@ async function withIsolatedHome<T>(fn: (homeDir: string) => Promise<T> | T): Pro
|
||||
}
|
||||
|
||||
async function loadRegistryModule() {
|
||||
return import(`../../../src/cliproxy/accounts/registry?registry-integrity=${Date.now()}`);
|
||||
return import(`../registry?registry-integrity=${Date.now()}`);
|
||||
}
|
||||
|
||||
async function loadAccountManager() {
|
||||
return import(`../../../src/cliproxy/account-manager?account-registry-integrity=${Date.now()}`);
|
||||
return import(`../account-manager?account-registry-integrity=${Date.now()}`);
|
||||
}
|
||||
|
||||
async function captureConsoleError<T>(fn: () => Promise<T> | T): Promise<{ result: T; messages: string[] }> {
|
||||
async function captureConsoleError<T>(
|
||||
fn: () => Promise<T> | T
|
||||
): Promise<{ result: T; messages: string[] }> {
|
||||
const originalError = console.error;
|
||||
const messages: string[] = [];
|
||||
|
||||
@@ -351,7 +353,9 @@ describe('account registry integrity', () => {
|
||||
fs.writeFileSync(registryPath, '{"providers":', 'utf8');
|
||||
|
||||
const { loadAccountsRegistry } = await loadRegistryModule();
|
||||
const { result: registry, messages } = await captureConsoleError(() => loadAccountsRegistry());
|
||||
const { result: registry, messages } = await captureConsoleError(() =>
|
||||
loadAccountsRegistry()
|
||||
);
|
||||
|
||||
expect(registry).toEqual({
|
||||
version: 1,
|
||||
@@ -2,10 +2,10 @@ import { describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { runWithScopedCcsHome } from '../../../src/utils/config-manager';
|
||||
import { runWithScopedCcsHome } from '../../../utils/config-manager';
|
||||
|
||||
async function loadAccountManager() {
|
||||
return import(`../../../src/cliproxy/account-manager?optional-nickname=${Date.now()}`);
|
||||
return import(`../account-manager?optional-nickname=${Date.now()}`);
|
||||
}
|
||||
|
||||
function writeTokenFile(homeDir: string, tokenFile: string): void {
|
||||
@@ -121,7 +121,8 @@ describe('registerAccount optional nickname flow', () => {
|
||||
const result = await withIsolatedHome(async (homeDir) => {
|
||||
writeTokenFile(homeDir, 'codex-04a0f049-kaidu.kd@gmail.com-team.json');
|
||||
writeTokenFile(homeDir, 'codex-kaidu.kd@gmail.com-free.json');
|
||||
const { registerAccount, getProviderAccounts, findAccountByQuery } = await loadAccountManager();
|
||||
const { registerAccount, getProviderAccounts, findAccountByQuery } =
|
||||
await loadAccountManager();
|
||||
const team = registerAccount(
|
||||
'codex',
|
||||
'codex-04a0f049-kaidu.kd@gmail.com-team.json',
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
maskEmail,
|
||||
pauseAccountForQuotaCooldown,
|
||||
restoreExpiredQuotaPauses,
|
||||
} from '../../../src/cliproxy/account-safety';
|
||||
import { sanitizeEmail } from '../../../src/cliproxy/auth-utils';
|
||||
} from '../../accounts/account-safety';
|
||||
import { sanitizeEmail } from '../../auth/auth-utils';
|
||||
|
||||
// Setup test isolation
|
||||
let tmpDir: string;
|
||||
@@ -243,7 +243,7 @@ describe('Quota Exhaustion Handlers', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { isOnCooldown } = await import('../../../src/cliproxy/quota-manager');
|
||||
const { isOnCooldown } = await import('../../quota/quota-manager');
|
||||
|
||||
const result = await handleQuotaExhaustion('agy', 'exhausted@gmail.com', 10);
|
||||
|
||||
@@ -285,7 +285,7 @@ describe('Quota Exhaustion Handlers', () => {
|
||||
});
|
||||
|
||||
const result = await handleQuotaExhaustion('agy', 'only@gmail.com', 10);
|
||||
const { getAccount } = await import('../../../src/cliproxy/account-manager');
|
||||
const { getAccount } = await import('../account-manager');
|
||||
|
||||
// Should return gracefully with null switched
|
||||
expect(result.switchedTo).toBeNull();
|
||||
@@ -342,16 +342,12 @@ describe('Quota Exhaustion Handlers', () => {
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await handleQuotaExhaustion('claude', 'exhausted@example.com', 10);
|
||||
const { getAccount, getDefaultAccount } = await import(
|
||||
'../../../src/cliproxy/account-manager'
|
||||
);
|
||||
const { getAccount, getDefaultAccount } = await import('../account-manager');
|
||||
|
||||
expect(result.switchedTo).toBe('fallback@example.com');
|
||||
expect(getDefaultAccount('claude')?.id).toBe('fallback@example.com');
|
||||
expect(getAccount('claude', 'exhausted@example.com')?.paused).not.toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(
|
||||
false
|
||||
);
|
||||
expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(false);
|
||||
expect(
|
||||
fs.existsSync(
|
||||
path.join(
|
||||
@@ -429,9 +425,7 @@ describe('Quota Exhaustion Handlers', () => {
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await handleQuotaExhaustion('codex', 'exhausted@example.com', 10);
|
||||
const { getAccount, getDefaultAccount } = await import(
|
||||
'../../../src/cliproxy/account-manager'
|
||||
);
|
||||
const { getAccount, getDefaultAccount } = await import('../account-manager');
|
||||
|
||||
expect(result.switchedTo).toBe('fallback@example.com');
|
||||
expect(getDefaultAccount('codex')?.id).toBe('fallback@example.com');
|
||||
@@ -490,9 +484,9 @@ describe('Quota Exhaustion Handlers', () => {
|
||||
)
|
||||
) as typeof fetch;
|
||||
|
||||
const { preflightCheck } = await import('../../../src/cliproxy/quota-manager');
|
||||
const { preflightCheck } = await import('../../quota/quota-manager');
|
||||
const result = await preflightCheck('codex');
|
||||
const { getAccount } = await import('../../../src/cliproxy/account-manager');
|
||||
const { getAccount } = await import('../account-manager');
|
||||
|
||||
expect(result.accountId).toBe('only-codex@example.com');
|
||||
expect(result.switchedFrom).toBeUndefined();
|
||||
@@ -567,11 +561,9 @@ describe('Quota Exhaustion Handlers', () => {
|
||||
);
|
||||
}) as typeof fetch;
|
||||
|
||||
const { preflightCheck } = await import('../../../src/cliproxy/quota-manager');
|
||||
const { preflightCheck } = await import('../../quota/quota-manager');
|
||||
const result = await preflightCheck('codex');
|
||||
const { getAccount, getDefaultAccount } = await import(
|
||||
'../../../src/cliproxy/account-manager'
|
||||
);
|
||||
const { getAccount, getDefaultAccount } = await import('../account-manager');
|
||||
|
||||
expect(result.switchedFrom).toBe('preflight-exhausted@example.com');
|
||||
expect(result.accountId).toBe('preflight-fallback@example.com');
|
||||
@@ -647,18 +639,14 @@ describe('Quota Exhaustion Handlers', () => {
|
||||
);
|
||||
}) as typeof fetch;
|
||||
|
||||
const { preflightCheck } = await import('../../../src/cliproxy/quota-manager');
|
||||
const { preflightCheck } = await import('../../quota/quota-manager');
|
||||
const result = await preflightCheck('codex');
|
||||
const { getAccount, getDefaultAccount } = await import(
|
||||
'../../../src/cliproxy/account-manager'
|
||||
);
|
||||
const { getAccount, getDefaultAccount } = await import('../account-manager');
|
||||
|
||||
expect(result.switchedFrom).toBe('preflight-unknown-exhausted@example.com');
|
||||
expect(result.accountId).toBe('preflight-unknown-fallback@example.com');
|
||||
expect(getDefaultAccount('codex')?.id).toBe('preflight-unknown-fallback@example.com');
|
||||
expect(getAccount('codex', 'preflight-unknown-exhausted@example.com')?.paused).not.toBe(
|
||||
true
|
||||
);
|
||||
expect(getAccount('codex', 'preflight-unknown-exhausted@example.com')?.paused).not.toBe(true);
|
||||
expect(
|
||||
fs.existsSync(
|
||||
path.join(tmpDir, '.ccs', 'cliproxy', 'auth', 'codex-preflight-unknown-exhausted.json')
|
||||
@@ -675,9 +663,7 @@ describe('Quota Exhaustion Handlers', () => {
|
||||
)
|
||||
)
|
||||
).toBe(false);
|
||||
expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(
|
||||
false
|
||||
);
|
||||
expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should self-pause exhausted Gemini accounts when a healthy fallback exists', async () => {
|
||||
@@ -736,9 +722,7 @@ describe('Quota Exhaustion Handlers', () => {
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await handleQuotaExhaustion('gemini', 'gemini-exhausted@example.com', 10);
|
||||
const { getAccount, getDefaultAccount } = await import(
|
||||
'../../../src/cliproxy/account-manager'
|
||||
);
|
||||
const { getAccount, getDefaultAccount } = await import('../account-manager');
|
||||
|
||||
expect(result.switchedTo).toBe('gemini-fallback@example.com');
|
||||
expect(getDefaultAccount('gemini')?.id).toBe('gemini-fallback@example.com');
|
||||
@@ -804,9 +788,7 @@ describe('Quota Exhaustion Handlers', () => {
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await handleQuotaExhaustion('ghcp', 'ghcp-exhausted', 10);
|
||||
const { getAccount, getDefaultAccount } = await import(
|
||||
'../../../src/cliproxy/account-manager'
|
||||
);
|
||||
const { getAccount, getDefaultAccount } = await import('../account-manager');
|
||||
|
||||
expect(result.switchedTo).toBe('ghcp-fallback');
|
||||
expect(getDefaultAccount('ghcp')?.id).toBe('ghcp-fallback');
|
||||
@@ -868,9 +850,7 @@ describe('Quota Exhaustion Handlers', () => {
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await handleQuotaExhaustion('ghcp', 'ghcp-partial-exhausted', 10);
|
||||
const { getAccount, getDefaultAccount } = await import(
|
||||
'../../../src/cliproxy/account-manager'
|
||||
);
|
||||
const { getAccount, getDefaultAccount } = await import('../account-manager');
|
||||
|
||||
expect(result.switchedTo).toBe('ghcp-partial-fallback');
|
||||
expect(getDefaultAccount('ghcp')?.id).toBe('ghcp-partial-fallback');
|
||||
@@ -986,7 +966,7 @@ describe('Quota Exhaustion Handlers', () => {
|
||||
const now = Date.now();
|
||||
expect(pauseAccountForQuotaCooldown('agy', 'cooldown@gmail.com', 5, now)).toBe(true);
|
||||
|
||||
const { getAccount } = await import('../../../src/cliproxy/account-manager');
|
||||
const { getAccount } = await import('../account-manager');
|
||||
expect(getAccount('agy', 'cooldown@gmail.com')?.paused).toBe(true);
|
||||
|
||||
const resumed = restoreExpiredQuotaPauses(now + 6 * 60 * 1000);
|
||||
@@ -1030,7 +1010,7 @@ describe('Quota Exhaustion Handlers', () => {
|
||||
fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2));
|
||||
|
||||
const resumed = restoreExpiredQuotaPauses(now + 6 * 60 * 1000);
|
||||
const { getAccount } = await import('../../../src/cliproxy/account-manager');
|
||||
const { getAccount } = await import('../account-manager');
|
||||
|
||||
expect(resumed).toBe(0);
|
||||
expect(getAccount('agy', 'cooldown@gmail.com')?.paused).toBe(true);
|
||||
@@ -1067,7 +1047,7 @@ describe('Quota Exhaustion Handlers', () => {
|
||||
});
|
||||
|
||||
const resumed = restoreExpiredQuotaPauses(now + 6 * 60 * 1000);
|
||||
const { getAccount } = await import('../../../src/cliproxy/account-manager');
|
||||
const { getAccount } = await import('../account-manager');
|
||||
const quotaPausedPath = path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json');
|
||||
const quotaPaused = JSON.parse(fs.readFileSync(quotaPausedPath, 'utf8')) as {
|
||||
entries?: Array<{ accountId?: string }>;
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
checkNewAccountConflict,
|
||||
handleBanDetection,
|
||||
warnCrossProviderDuplicates,
|
||||
} from '../../../src/cliproxy/account-safety';
|
||||
} from '../../accounts/account-safety';
|
||||
|
||||
// --- Test isolation: use temp CCS_HOME ---
|
||||
|
||||
@@ -20,7 +20,7 @@ export type {
|
||||
ProviderAccounts,
|
||||
BulkOperationResult,
|
||||
SoloOperationResult,
|
||||
} from './accounts';
|
||||
} from '.';
|
||||
|
||||
export {
|
||||
PROVIDERS_WITHOUT_EMAIL,
|
||||
@@ -56,4 +56,4 @@ export {
|
||||
bulkPauseAccounts,
|
||||
bulkResumeAccounts,
|
||||
soloAccount,
|
||||
} from './accounts';
|
||||
} from '.';
|
||||
@@ -11,10 +11,10 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { warn, info } from '../utils/ui';
|
||||
import { CLIProxyProvider } from './types';
|
||||
import { loadAccountsRegistry, pauseAccount, resumeAccount } from './accounts/registry';
|
||||
import { getCcsDir } from '../utils/config-manager';
|
||||
import { warn, info } from '../../utils/ui';
|
||||
import { CLIProxyProvider } from '../types';
|
||||
import { loadAccountsRegistry, pauseAccount, resumeAccount } from './registry';
|
||||
import { getCcsDir } from '../../utils/config-manager';
|
||||
|
||||
const ISSUE_509_URL = 'https://github.com/kaitranntt/ccs/issues/509';
|
||||
|
||||
@@ -688,9 +688,9 @@ export async function handleQuotaExhaustion(
|
||||
cooldownMinutes: number
|
||||
): Promise<{ switchedTo: string | null; reason: string }> {
|
||||
// Dynamic imports to avoid circular dependencies
|
||||
const { applyCooldown, findHealthyAccount } = await import('./quota-manager');
|
||||
const { applyCooldown, findHealthyAccount } = await import('../quota/quota-manager');
|
||||
const { setDefaultAccount, touchAccount } = await import('./account-manager');
|
||||
const { loadOrCreateUnifiedConfig } = await import('../config/unified-config-loader');
|
||||
const { loadOrCreateUnifiedConfig } = await import('../../config/unified-config-loader');
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
const threshold = config.quota_management?.auto?.exhaustion_threshold ?? 5;
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as path from 'path';
|
||||
import * as lockfile from 'proper-lockfile';
|
||||
import { CLIProxyProvider } from '../types';
|
||||
import { PROVIDER_TYPE_VALUES } from '../auth/auth-types';
|
||||
import { getAuthDir, getCliproxyDir } from '../config-generator';
|
||||
import { getAuthDir, getCliproxyDir } from '../config/config-generator';
|
||||
import { AccountsRegistry, AccountInfo, PROVIDERS_WITHOUT_EMAIL } from './types';
|
||||
import {
|
||||
getAccountsRegistryPath,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getCliproxyDir, getAuthDir } from '../config-generator';
|
||||
import { getCliproxyDir, getAuthDir } from '../config/config-generator';
|
||||
import type { CLIProxyProvider } from '../types';
|
||||
import { AccountInfo } from './types';
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||
import { clearQuotaCache } from '../../../src/cliproxy/quota-response-cache';
|
||||
import { clearQuotaCache } from '../../quota/quota-response-cache';
|
||||
|
||||
afterEach(() => {
|
||||
clearQuotaCache();
|
||||
@@ -39,7 +39,7 @@ function createCodexSettingsFixture(haikuModel: string = 'gpt-5.4-mini'): {
|
||||
}
|
||||
|
||||
async function importCompatibilityModule(cacheTag: string) {
|
||||
return import(`../../../src/cliproxy/codex-plan-compatibility?${cacheTag}=${Date.now()}`);
|
||||
return import(`../codex-plan-compatibility?${cacheTag}=${Date.now()}`);
|
||||
}
|
||||
|
||||
const identity = (message: string) => message;
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { getProviderCatalog, getModelMaxLevel } from '../../../src/cliproxy/model-catalog';
|
||||
import { getProviderCatalog, getModelMaxLevel } from '../../model-catalog';
|
||||
import {
|
||||
getDefaultCodexModel,
|
||||
getFreePlanFallbackCodexModel,
|
||||
parseCodexUnsupportedModelError,
|
||||
resolveRuntimeCodexFallbackModel,
|
||||
} from '../../../src/cliproxy/codex-plan-compatibility';
|
||||
} from '../../ai-providers/codex-plan-compatibility';
|
||||
|
||||
describe('codex plan compatibility', () => {
|
||||
it('uses a cross-plan safe Codex default', () => {
|
||||
@@ -4,12 +4,12 @@ import {
|
||||
buildCodexModelEffortMap,
|
||||
CodexReasoningProxy,
|
||||
getEffortForModel,
|
||||
} from '../../../src/cliproxy/codex-reasoning-proxy';
|
||||
} from '../../ai-providers/codex-reasoning-proxy';
|
||||
import {
|
||||
parseEnvThinkingOverride,
|
||||
resolveRuntimeThinkingOverride,
|
||||
shouldDisableCodexReasoning,
|
||||
} from '../../../src/cliproxy/executor/thinking-override-resolver';
|
||||
} from '../../executor/thinking-override-resolver';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -4,7 +4,7 @@ const {
|
||||
buildCodexModelEffortMap,
|
||||
getEffortForModel,
|
||||
injectReasoningEffortIntoBody,
|
||||
} = require('../../../dist/cliproxy/codex-reasoning-proxy');
|
||||
} = require('../../../../dist/cliproxy/ai-providers/codex-reasoning-proxy');
|
||||
|
||||
describe('Codex Reasoning Proxy', () => {
|
||||
describe('buildCodexModelEffortMap', () => {
|
||||
@@ -153,7 +153,10 @@ describe('Codex Reasoning Proxy', () => {
|
||||
});
|
||||
|
||||
it('preserves query strings after stripping', () => {
|
||||
const result = stripPathPrefix('/api/provider/codex/v1/messages?stream=true', '/api/provider/codex');
|
||||
const result = stripPathPrefix(
|
||||
'/api/provider/codex/v1/messages?stream=true',
|
||||
'/api/provider/codex'
|
||||
);
|
||||
assert.strictEqual(result, '/v1/messages?stream=true');
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
applyExtendedContextSuffix,
|
||||
shouldApplyExtendedContext,
|
||||
applyExtendedContextConfig,
|
||||
} from '../../../src/cliproxy/config/extended-context-config';
|
||||
} from '../../config/extended-context-config';
|
||||
|
||||
describe('applyExtendedContextSuffix', () => {
|
||||
it('appends [1m] to model without suffix', () => {
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test';
|
||||
import { ensureManagedModelPrefixes } from '../../../src/cliproxy/managed-model-prefixes';
|
||||
import { ensureManagedModelPrefixes } from '../managed-model-prefixes';
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
normalizeModelIdForProvider,
|
||||
normalizeModelIdForRouting,
|
||||
normalizeModelEnvVarsForProvider,
|
||||
} from '../../../src/cliproxy/model-id-normalizer';
|
||||
} from '../model-id-normalizer';
|
||||
|
||||
describe('model-id-normalizer', () => {
|
||||
describe('provider parsing', () => {
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
buildCliproxyRoutingHints,
|
||||
getManagedModelPrefix,
|
||||
} from '../../../src/shared/cliproxy-model-routing';
|
||||
} from '../../../shared/cliproxy-model-routing';
|
||||
|
||||
describe('cliproxy model routing hints', () => {
|
||||
it('uses short managed prefixes for overlapping Gemini and Antigravity models', () => {
|
||||
@@ -17,12 +17,14 @@ describe('openai-compat manager', () => {
|
||||
process.env.CCS_HOME = testDir;
|
||||
process.env.CCS_DIR = path.join(testDir, '.ccs');
|
||||
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/config-generator')];
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/openai-compat-manager')];
|
||||
delete require.cache[require.resolve('../../../dist/utils/config-manager')];
|
||||
delete require.cache[require.resolve('../../../../dist/cliproxy/config/config-generator')];
|
||||
delete require.cache[
|
||||
require.resolve('../../../../dist/cliproxy/ai-providers/openai-compat-manager')
|
||||
];
|
||||
delete require.cache[require.resolve('../../../../dist/utils/config-manager')];
|
||||
|
||||
configGenerator = require('../../../dist/cliproxy/config-generator');
|
||||
openAICompatManager = require('../../../dist/cliproxy/openai-compat-manager');
|
||||
configGenerator = require('../../../../dist/cliproxy/config/config-generator');
|
||||
openAICompatManager = require('../../../../dist/cliproxy/ai-providers/openai-compat-manager');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -56,8 +58,15 @@ describe('openai-compat manager', () => {
|
||||
});
|
||||
|
||||
const afterWrite = fs.readFileSync(configPath, 'utf8');
|
||||
assert.strictEqual(afterWrite.split('\n')[0], initialHeader, 'Should preserve the generated header');
|
||||
assert(afterWrite.includes('openai-compatibility:'), 'Should write the openai-compatibility section');
|
||||
assert.strictEqual(
|
||||
afterWrite.split('\n')[0],
|
||||
initialHeader,
|
||||
'Should preserve the generated header'
|
||||
);
|
||||
assert(
|
||||
afterWrite.includes('openai-compatibility:'),
|
||||
'Should write the openai-compatibility section'
|
||||
);
|
||||
assert.strictEqual(
|
||||
configGenerator.configNeedsRegeneration(),
|
||||
false,
|
||||
@@ -67,7 +76,10 @@ describe('openai-compat manager', () => {
|
||||
configGenerator.regenerateConfig();
|
||||
|
||||
const afterRegen = fs.readFileSync(configPath, 'utf8');
|
||||
assert(afterRegen.includes('openai-compatibility:'), 'Connector section should survive regeneration');
|
||||
assert(
|
||||
afterRegen.includes('openai-compatibility:'),
|
||||
'Connector section should survive regeneration'
|
||||
);
|
||||
assert(afterRegen.includes('name: mimo'), 'Connector name should survive regeneration');
|
||||
assert(
|
||||
afterRegen.includes('base-url: https://api.xiaomimimo.com/v1'),
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { sanitizeInputSchema, sanitizeToolSchemas } from '../../../src/cliproxy/schema-sanitizer';
|
||||
import { sanitizeInputSchema, sanitizeToolSchemas } from '../schema-sanitizer';
|
||||
|
||||
describe('sanitizeInputSchema', () => {
|
||||
test('preserves Gemini-supported properties', () => {
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
const { ToolNameMapper } = require('../../../dist/cliproxy/tool-name-mapper');
|
||||
const { ToolNameMapper } = require('../../../../dist/cliproxy/ai-providers/tool-name-mapper');
|
||||
|
||||
describe('Tool Name Mapper', () => {
|
||||
describe('registerTools', () => {
|
||||
@@ -71,11 +71,7 @@ describe('Tool Name Mapper', () => {
|
||||
|
||||
it('handles mixed valid and invalid names', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
const tools = [
|
||||
{ name: 'valid_tool' },
|
||||
{ name: 'foo__bar__bar' },
|
||||
{ name: 'another_valid' },
|
||||
];
|
||||
const tools = [{ name: 'valid_tool' }, { name: 'foo__bar__bar' }, { name: 'another_valid' }];
|
||||
|
||||
const result = mapper.registerTools(tools);
|
||||
|
||||
@@ -92,9 +88,7 @@ describe('Tool Name Mapper', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
mapper.registerTools([{ name: 'foo__bar__bar' }]);
|
||||
|
||||
const content = [
|
||||
{ type: 'tool_use', id: 'call_1', name: 'foo__bar', input: {} },
|
||||
];
|
||||
const content = [{ type: 'tool_use', id: 'call_1', name: 'foo__bar', input: {} }];
|
||||
|
||||
const result = mapper.restoreToolUse(content);
|
||||
|
||||
@@ -121,9 +115,7 @@ describe('Tool Name Mapper', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
mapper.registerTools([{ name: 'foo__bar__bar' }]);
|
||||
|
||||
const content = [
|
||||
{ type: 'tool_use', id: 'call_1', name: 'unknown_tool', input: {} },
|
||||
];
|
||||
const content = [{ type: 'tool_use', id: 'call_1', name: 'unknown_tool', input: {} }];
|
||||
|
||||
const result = mapper.restoreToolUse(content);
|
||||
|
||||
@@ -196,10 +188,7 @@ describe('Tool Name Mapper', () => {
|
||||
|
||||
it('returns list of all changes', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
mapper.registerTools([
|
||||
{ name: 'foo__bar__bar' },
|
||||
{ name: 'baz__qux__qux' },
|
||||
]);
|
||||
mapper.registerTools([{ name: 'foo__bar__bar' }, { name: 'baz__qux__qux' }]);
|
||||
|
||||
const changes = mapper.getChanges();
|
||||
|
||||
@@ -254,4 +243,93 @@ describe('Tool Name Mapper', () => {
|
||||
assert.strictEqual(mapper.restoreName('foo__bar'), 'foo__bar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('collision handling', () => {
|
||||
it('disambiguates when sanitized name collides with unchanged name', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
// 'abc__def__def' sanitizes to 'abc__def' (changed)
|
||||
// 'abc__def' is unchanged but collides with the sanitized name
|
||||
const tools = [{ name: 'abc__def__def' }, { name: 'abc__def' }];
|
||||
|
||||
const result = mapper.registerTools(tools);
|
||||
|
||||
assert.strictEqual(result[0].name, 'abc__def');
|
||||
assert.strictEqual(result[1].name, 'abc__def_2');
|
||||
assert.strictEqual(mapper.hasCollisions(), true);
|
||||
});
|
||||
|
||||
it('disambiguates different originals that sanitize to the same name', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
const tools = [{ name: 'abc__def__def' }, { name: 'abc__def' }];
|
||||
|
||||
const result = mapper.registerTools(tools);
|
||||
|
||||
// 'abc__def__def' → 'abc__def' (deduped)
|
||||
// 'abc__def' → 'abc__def_2' (disambiguated, unchanged but collides)
|
||||
assert.strictEqual(result[0].name, 'abc__def');
|
||||
assert.strictEqual(result[1].name, 'abc__def_2');
|
||||
});
|
||||
|
||||
it('restores both colliding tools to correct originals', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
const tools = [{ name: 'a__b__b' }, { name: 'a__b' }];
|
||||
|
||||
mapper.registerTools(tools);
|
||||
|
||||
const content = [
|
||||
{ type: 'tool_use', id: '1', name: 'a__b', input: {} },
|
||||
{ type: 'tool_use', id: '2', name: 'a__b_2', input: {} },
|
||||
];
|
||||
|
||||
const restored = mapper.restoreToolUse(content);
|
||||
|
||||
assert.strictEqual(restored[0].name, 'a__b__b');
|
||||
assert.strictEqual(restored[1].name, 'a__b');
|
||||
});
|
||||
|
||||
it('records collision metadata for logging', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
const tools = [{ name: 'x__y__y' }, { name: 'x__y' }];
|
||||
|
||||
mapper.registerTools(tools);
|
||||
|
||||
assert.strictEqual(mapper.hasCollisions(), true);
|
||||
const collisions = mapper.getCollisions();
|
||||
assert.strictEqual(collisions.length, 1);
|
||||
assert.strictEqual(collisions[0].base, 'x__y');
|
||||
assert.ok(collisions[0].originals.includes('x__y__y'));
|
||||
assert.ok(collisions[0].originals.includes('x__y'));
|
||||
});
|
||||
|
||||
it('detects collision when unchanged tool appears before sanitized one', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
const tools = [
|
||||
{ name: 'abc__def' }, // unchanged, but registered in mapping
|
||||
{ name: 'abc__def__def' }, // sanitizes to 'abc__def', collides
|
||||
];
|
||||
|
||||
const result = mapper.registerTools(tools);
|
||||
|
||||
assert.strictEqual(result[0].name, 'abc__def');
|
||||
assert.strictEqual(result[1].name, 'abc__def_2');
|
||||
assert.strictEqual(mapper.hasCollisions(), true);
|
||||
});
|
||||
|
||||
it('restores correctly for reverse-order collision', () => {
|
||||
const mapper = new ToolNameMapper();
|
||||
const tools = [{ name: 'abc__def' }, { name: 'abc__def__def' }];
|
||||
|
||||
mapper.registerTools(tools);
|
||||
|
||||
const content = [
|
||||
{ type: 'tool_use', id: '1', name: 'abc__def', input: {} },
|
||||
{ type: 'tool_use', id: '2', name: 'abc__def_2', input: {} },
|
||||
];
|
||||
|
||||
const restored = mapper.restoreToolUse(content);
|
||||
|
||||
assert.strictEqual(restored[0].name, 'abc__def');
|
||||
assert.strictEqual(restored[1].name, 'abc__def__def');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ const {
|
||||
smartTruncate,
|
||||
sanitizeToolName,
|
||||
GEMINI_MAX_TOOL_NAME_LENGTH,
|
||||
} = require('../../../dist/cliproxy/tool-name-sanitizer');
|
||||
} = require('../../../../dist/cliproxy/ai-providers/tool-name-sanitizer');
|
||||
|
||||
describe('Tool Name Sanitizer', () => {
|
||||
describe('GEMINI_MAX_TOOL_NAME_LENGTH', () => {
|
||||
@@ -170,5 +170,62 @@ describe('Tool Name Sanitizer', () => {
|
||||
assert.strictEqual(result.changed, true);
|
||||
assert.strictEqual(result.sanitized, 'gitmcp__plus-pro-components');
|
||||
});
|
||||
|
||||
it('forces valid characters when name contains spaces', () => {
|
||||
const result = sanitizeToolName('has space');
|
||||
|
||||
assert.strictEqual(result.sanitized, 'has_space');
|
||||
assert.strictEqual(result.changed, true);
|
||||
assert.ok(isValidToolName(result.sanitized));
|
||||
});
|
||||
|
||||
it('forces valid characters when name starts with digit', () => {
|
||||
const result = sanitizeToolName('123start');
|
||||
|
||||
assert.ok(result.sanitized.startsWith('_'));
|
||||
assert.strictEqual(result.changed, true);
|
||||
assert.ok(isValidToolName(result.sanitized));
|
||||
});
|
||||
|
||||
it('forces valid characters when name contains symbols', () => {
|
||||
const result = sanitizeToolName('tool@name#here');
|
||||
|
||||
assert.ok(!result.sanitized.includes('@'));
|
||||
assert.ok(!result.sanitized.includes('#'));
|
||||
assert.strictEqual(result.changed, true);
|
||||
assert.ok(isValidToolName(result.sanitized));
|
||||
});
|
||||
|
||||
it('produces valid name for digit-starting long name', () => {
|
||||
const name = '1' + 'a'.repeat(100);
|
||||
const result = sanitizeToolName(name);
|
||||
|
||||
assert.ok(isValidToolName(result.sanitized), `Expected valid name, got: ${result.sanitized}`);
|
||||
assert.ok(result.sanitized.length <= 64);
|
||||
});
|
||||
|
||||
it('forces valid prefix for dot-starting name', () => {
|
||||
const result = sanitizeToolName('.tool');
|
||||
|
||||
assert.ok(result.sanitized.startsWith('_'), `Expected _ prefix, got: ${result.sanitized}`);
|
||||
assert.ok(isValidToolName(result.sanitized));
|
||||
assert.strictEqual(result.changed, true);
|
||||
});
|
||||
|
||||
it('forces valid prefix for hyphen-starting name', () => {
|
||||
const result = sanitizeToolName('-tool');
|
||||
|
||||
assert.ok(result.sanitized.startsWith('_'), `Expected _ prefix, got: ${result.sanitized}`);
|
||||
assert.ok(isValidToolName(result.sanitized));
|
||||
assert.strictEqual(result.changed, true);
|
||||
});
|
||||
|
||||
it('forces valid prefix for colon-starting name', () => {
|
||||
const result = sanitizeToolName(':tool');
|
||||
|
||||
assert.ok(result.sanitized.startsWith('_'), `Expected _ prefix, got: ${result.sanitized}`);
|
||||
assert.ok(isValidToolName(result.sanitized));
|
||||
assert.strictEqual(result.changed, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import { getDefaultAccount } from './account-manager';
|
||||
import { getProviderCatalog } from './model-catalog';
|
||||
import { getDefaultAccount } from '../accounts/account-manager';
|
||||
import { getProviderCatalog } from '../model-catalog';
|
||||
import { normalizeModelIdForProvider } from './model-id-normalizer';
|
||||
import { fetchCodexQuota } from './quota-fetcher-codex';
|
||||
import { getCachedQuota, setCachedQuota } from './quota-response-cache';
|
||||
import type { CodexQuotaResult } from './quota-types';
|
||||
import { info, warn } from '../utils/ui';
|
||||
import { fetchCodexQuota } from '../quota/quota-fetcher-codex';
|
||||
import { getCachedQuota, setCachedQuota } from '../quota/quota-response-cache';
|
||||
import type { CodexQuotaResult } from '../quota/quota-types';
|
||||
import { info, warn } from '../../utils/ui';
|
||||
|
||||
export type CodexPlanType = CodexQuotaResult['planType'];
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
parseCodexUnsupportedModelError,
|
||||
resolveRuntimeCodexFallbackModel,
|
||||
} from './codex-plan-compatibility';
|
||||
import { getModelMaxLevel } from './model-catalog';
|
||||
import { getModelMaxLevel } from '../model-catalog';
|
||||
|
||||
export type CodexReasoningEffort = 'medium' | 'high' | 'xhigh';
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { configExists, getCliproxyConfigPath, regenerateConfig } from '../config-generator';
|
||||
import { getProxyTarget } from '../proxy-target-resolver';
|
||||
import { createManagementClient } from '../management-api-client';
|
||||
import { configExists, getCliproxyConfigPath, regenerateConfig } from '../config/config-generator';
|
||||
import { getProxyTarget } from '../proxy/proxy-target-resolver';
|
||||
import { createManagementClient } from '../management/management-api-client';
|
||||
import { rewriteTopLevelYamlSection } from './config-yaml-sections';
|
||||
import type {
|
||||
AiProviderApiKeyEntry,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { getManagedModelPrefix } from '../shared/cliproxy-model-routing';
|
||||
import { buildManagementHeaders, buildProxyUrl, getProxyTarget } from './proxy-target-resolver';
|
||||
import { mapExternalProviderName } from './provider-capabilities';
|
||||
import type { CLIProxyProvider } from './types';
|
||||
import { getManagedModelPrefix } from '../../shared/cliproxy-model-routing';
|
||||
import {
|
||||
buildManagementHeaders,
|
||||
buildProxyUrl,
|
||||
getProxyTarget,
|
||||
} from '../proxy/proxy-target-resolver';
|
||||
import { mapExternalProviderName } from '../provider-capabilities';
|
||||
import type { CLIProxyProvider } from '../types';
|
||||
|
||||
const MANAGED_PREFIX_REQUEST_TIMEOUT_MS = 3000;
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* model version formats (e.g., 4.6 vs 4-6).
|
||||
*/
|
||||
|
||||
import type { CLIProxyProvider } from './types';
|
||||
import { ANTHROPIC_MODEL_ENV_KEYS } from '../shared/extended-context-utils';
|
||||
import type { CLIProxyProvider } from '../types';
|
||||
import { ANTHROPIC_MODEL_ENV_KEYS } from '../../shared/extended-context-utils';
|
||||
|
||||
/** Env vars that carry model identifiers. */
|
||||
export const MODEL_ENV_VAR_KEYS = ANTHROPIC_MODEL_ENV_KEYS;
|
||||
@@ -7,8 +7,9 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { configExists, getCliproxyConfigPath, regenerateConfig } from './config-generator';
|
||||
import { rewriteTopLevelYamlSection } from './ai-providers/config-yaml-sections';
|
||||
import { configExists, regenerateConfig } from '../config/generator';
|
||||
import { getCliproxyConfigPath } from '../config/path-resolver';
|
||||
import { rewriteTopLevelYamlSection } from './config-yaml-sections';
|
||||
|
||||
/** Model alias configuration */
|
||||
export interface OpenAICompatModel {
|
||||
@@ -41,8 +41,12 @@ export interface SanitizationChange {
|
||||
|
||||
/** Record of a hash collision */
|
||||
export interface HashCollision {
|
||||
sanitized: string;
|
||||
/** The base sanitized name before disambiguation */
|
||||
base: string;
|
||||
/** All original names that collided */
|
||||
originals: string[];
|
||||
/** The sanitized name used (after disambiguation) */
|
||||
sanitized: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,7 +67,8 @@ export class ToolNameMapper {
|
||||
|
||||
/**
|
||||
* Register tools and sanitize their names.
|
||||
* Stores mapping for later restoration.
|
||||
* Stores mapping for later restoration. On collision (two originals
|
||||
* sanitizing to the same name), disambiguates with a numeric suffix.
|
||||
*
|
||||
* @param tools Array of tool definitions
|
||||
* @returns Array of tools with sanitized names
|
||||
@@ -71,36 +76,52 @@ export class ToolNameMapper {
|
||||
registerTools(tools: Tool[]): Tool[] {
|
||||
return tools.map((tool) => {
|
||||
const result: SanitizeResult = sanitizeToolName(tool.name);
|
||||
let finalName = result.sanitized;
|
||||
|
||||
if (result.changed) {
|
||||
// Check for collision: sanitized name already maps to different original
|
||||
const existingOriginal = this.mapping.get(result.sanitized);
|
||||
if (existingOriginal && existingOriginal !== tool.name) {
|
||||
// Record collision
|
||||
const existing = this.collisions.find((c) => c.sanitized === result.sanitized);
|
||||
if (existing) {
|
||||
if (!existing.originals.includes(tool.name)) {
|
||||
existing.originals.push(tool.name);
|
||||
}
|
||||
} else {
|
||||
this.collisions.push({
|
||||
sanitized: result.sanitized,
|
||||
originals: [existingOriginal, tool.name],
|
||||
});
|
||||
// Check for collision regardless of whether the name changed.
|
||||
// An unchanged name can still collide with a previously sanitized name.
|
||||
const existingOriginal = this.mapping.get(finalName);
|
||||
if (existingOriginal && existingOriginal !== tool.name) {
|
||||
// Record collision for logging
|
||||
const existing = this.collisions.find((c) => c.base === finalName);
|
||||
if (existing) {
|
||||
if (!existing.originals.includes(tool.name)) {
|
||||
existing.originals.push(tool.name);
|
||||
}
|
||||
} else {
|
||||
this.collisions.push({
|
||||
base: finalName,
|
||||
originals: [existingOriginal, tool.name],
|
||||
sanitized: finalName,
|
||||
});
|
||||
}
|
||||
|
||||
// Store mapping: sanitized → original
|
||||
this.mapping.set(result.sanitized, tool.name);
|
||||
// Disambiguate: append _N suffix to make unique
|
||||
let suffix = 2;
|
||||
let disambiguated: string;
|
||||
do {
|
||||
disambiguated = `${finalName}_${suffix}`;
|
||||
suffix++;
|
||||
} while (this.mapping.has(disambiguated) && this.mapping.get(disambiguated) !== tool.name);
|
||||
|
||||
finalName = disambiguated;
|
||||
}
|
||||
|
||||
// Always register in mapping so later tools can detect collisions
|
||||
// even when this tool's name was unchanged
|
||||
this.mapping.set(finalName, tool.name);
|
||||
|
||||
// Record change only if name actually changed
|
||||
if (finalName !== tool.name) {
|
||||
this.changes.push({
|
||||
original: tool.name,
|
||||
sanitized: result.sanitized,
|
||||
sanitized: finalName,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...tool,
|
||||
name: result.sanitized,
|
||||
name: finalName,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -100,13 +100,34 @@ export function smartTruncate(name: string, maxLen: number = GEMINI_MAX_TOOL_NAM
|
||||
return `${prefix}_${hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a string to comply with Gemini tool name character rules.
|
||||
* Replaces invalid characters with underscores and ensures the name
|
||||
* starts with a letter or underscore (not a digit, dot, colon, etc.).
|
||||
*/
|
||||
function forceValidChars(name: string): string {
|
||||
// Replace any character not in [a-zA-Z0-9_.:/-] with underscore
|
||||
let fixed = name.replace(/[^a-zA-Z0-9_.:/-]/g, '_');
|
||||
|
||||
// Collapse multiple consecutive underscores from replacement
|
||||
fixed = fixed.replace(/_+/g, '_');
|
||||
|
||||
// Ensure starts with letter or underscore (not digit, dot, colon, hyphen, slash)
|
||||
if (fixed.length > 0 && !/^[a-zA-Z_]/.test(fixed)) {
|
||||
fixed = '_' + fixed;
|
||||
}
|
||||
|
||||
return fixed || '_';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a tool name to comply with Gemini API constraints.
|
||||
*
|
||||
* Process:
|
||||
* 1. Remove duplicate segments (always, as duplicates are likely unintentional)
|
||||
* 2. Truncate with hash if >64 chars
|
||||
* 3. Return result with changed flag
|
||||
* 3. Force valid characters if still invalid
|
||||
* 4. Truncate again if forced-valid name exceeds limit
|
||||
*
|
||||
* @param name The original tool name
|
||||
* @returns Sanitization result with sanitized name and changed flag
|
||||
@@ -121,9 +142,12 @@ export function sanitizeToolName(name: string): SanitizeResult {
|
||||
sanitized = smartTruncate(sanitized);
|
||||
}
|
||||
|
||||
// Step 3: If still invalid after sanitization, apply truncation to original
|
||||
// Step 3: If still invalid, fix characters and truncate if needed
|
||||
if (!isValidToolName(sanitized)) {
|
||||
sanitized = smartTruncate(name);
|
||||
sanitized = forceValidChars(sanitized);
|
||||
if (sanitized.length > GEMINI_MAX_TOOL_NAME_LENGTH) {
|
||||
sanitized = smartTruncate(sanitized);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
hasAntigravityRiskAcceptanceFlag,
|
||||
isAntigravityResponsibilityBypassEnabled,
|
||||
validateAntigravityRiskAcknowledgement,
|
||||
} from '../../../src/cliproxy/antigravity-responsibility';
|
||||
} from '../../auth/antigravity-responsibility';
|
||||
|
||||
describe('antigravity-responsibility', () => {
|
||||
let tempHome = '';
|
||||
@@ -19,8 +19,8 @@ describe('Auth Token Manager', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear cache and reload
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/auth-token-manager')];
|
||||
const authTokenManager = require('../../../dist/cliproxy/auth-token-manager');
|
||||
delete require.cache[require.resolve('../../../../dist/cliproxy/auth/auth-token-manager')];
|
||||
const authTokenManager = require('../../../../dist/cliproxy/auth/auth-token-manager');
|
||||
generateSecureToken = authTokenManager.generateSecureToken;
|
||||
});
|
||||
|
||||
@@ -398,8 +398,8 @@ describe('Auth Token Manager', () => {
|
||||
let CCS_CONTROL_PANEL_SECRET;
|
||||
|
||||
beforeEach(() => {
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/config-generator')];
|
||||
const configGenerator = require('../../../dist/cliproxy/config-generator');
|
||||
delete require.cache[require.resolve('../../../../dist/cliproxy/config/config-generator')];
|
||||
const configGenerator = require('../../../../dist/cliproxy/config/config-generator');
|
||||
CCS_INTERNAL_API_KEY = configGenerator.CCS_INTERNAL_API_KEY;
|
||||
CCS_CONTROL_PANEL_SECRET = configGenerator.CCS_CONTROL_PANEL_SECRET;
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
getManagementAuthUrlPath,
|
||||
getPasteCallbackStartPath,
|
||||
getManagementOAuthCallbackPath,
|
||||
} from '../../../src/cliproxy/auth/auth-types';
|
||||
} from '../auth-types';
|
||||
|
||||
describe('auth-types paste-callback start path', () => {
|
||||
it('maps providers to CLIProxyAPI management auth-url routes', () => {
|
||||
@@ -5,11 +5,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import {
|
||||
getTokenExpiryTimestamp,
|
||||
isTokenExpired,
|
||||
sanitizeEmail,
|
||||
} from '../../../src/cliproxy/auth-utils';
|
||||
import { getTokenExpiryTimestamp, isTokenExpired, sanitizeEmail } from '../../auth/auth-utils';
|
||||
|
||||
describe('Auth Utilities', () => {
|
||||
describe('sanitizeEmail', () => {
|
||||
@@ -36,10 +36,10 @@ describe('Gemini refresh delegation', () => {
|
||||
|
||||
it('treats Gemini as runtime-managed even when the local token lacks OAuth client metadata', async () => {
|
||||
const { getProviderAuthDir } = await import(
|
||||
`../../../src/cliproxy/config-generator?gemini-delegation-config=${moduleVersion}`
|
||||
`../../config/config-generator?gemini-delegation-config=${moduleVersion}`
|
||||
);
|
||||
const { ensureTokenValid } = await import(
|
||||
`../../../src/cliproxy/auth/token-manager?gemini-delegation-manager=${moduleVersion}`
|
||||
`../token-manager?gemini-delegation-manager=${moduleVersion}`
|
||||
);
|
||||
|
||||
const authDir = getProviderAuthDir('gemini');
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { parseGitLabPatAuthResponse } from '../../../src/cliproxy/auth/gitlab-pat-response';
|
||||
import { parseGitLabPatAuthResponse } from '../gitlab-pat-response';
|
||||
|
||||
describe('parseGitLabPatAuthResponse', () => {
|
||||
it('sanitizes HTML error bodies for non-ok responses', () => {
|
||||
@@ -2,9 +2,9 @@ import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import type { ProxyTarget } from '../../../src/cliproxy/proxy-target-resolver';
|
||||
import { InteractivePrompt } from '../../../src/utils/prompt';
|
||||
import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../mocks';
|
||||
import type { ProxyTarget } from '../../proxy/proxy-target-resolver';
|
||||
import { InteractivePrompt } from '../../../utils/prompt';
|
||||
import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../../../tests/mocks';
|
||||
|
||||
const remoteTarget: ProxyTarget = {
|
||||
host: 'proxy.example.com',
|
||||
@@ -28,7 +28,7 @@ describe('requestPasteCallbackStart', () => {
|
||||
]);
|
||||
|
||||
const { requestPasteCallbackStart } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?request-claude-start=${Date.now()}`
|
||||
`../oauth-handler?request-claude-start=${Date.now()}`
|
||||
);
|
||||
const startData = await requestPasteCallbackStart('claude', remoteTarget);
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('requestPasteCallbackStart', () => {
|
||||
]);
|
||||
|
||||
const { requestPasteCallbackStart } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?request-kiro-start=${Date.now()}`
|
||||
`../oauth-handler?request-kiro-start=${Date.now()}`
|
||||
);
|
||||
const startData = await requestPasteCallbackStart('kiro', remoteTarget, {
|
||||
kiroMethod: 'aws',
|
||||
@@ -71,7 +71,7 @@ describe('requestPasteCallbackStart', () => {
|
||||
|
||||
it('throws for Kiro methods that require the local callback server flow', async () => {
|
||||
const { requestPasteCallbackStart } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?request-kiro-authcode-start=${Date.now()}`
|
||||
`../oauth-handler?request-kiro-authcode-start=${Date.now()}`
|
||||
);
|
||||
|
||||
await expect(
|
||||
@@ -83,7 +83,7 @@ describe('requestPasteCallbackStart', () => {
|
||||
describe('usesKiroLocalCallbackReplay', () => {
|
||||
it('limits local callback replay to CLI auth-code flows', async () => {
|
||||
const { usesKiroLocalCallbackReplay } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?kiro-local-callback-mode=${Date.now()}`
|
||||
`../oauth-handler?kiro-local-callback-mode=${Date.now()}`
|
||||
);
|
||||
|
||||
expect(usesKiroLocalCallbackReplay('aws-authcode', 'authcode')).toBe(true);
|
||||
@@ -97,7 +97,7 @@ describe('usesKiroLocalCallbackReplay', () => {
|
||||
describe('resolvePasteCallbackAuthUrl', () => {
|
||||
it('returns the immediate auth URL without polling', async () => {
|
||||
const { resolvePasteCallbackAuthUrl } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?resolve-immediate-auth-url=${Date.now()}`
|
||||
`../oauth-handler?resolve-immediate-auth-url=${Date.now()}`
|
||||
);
|
||||
const authUrl = await resolvePasteCallbackAuthUrl(
|
||||
remoteTarget,
|
||||
@@ -119,7 +119,7 @@ describe('resolvePasteCallbackAuthUrl', () => {
|
||||
]);
|
||||
|
||||
const { resolvePasteCallbackAuthUrl } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?resolve-polled-auth-url=${Date.now()}`
|
||||
`../oauth-handler?resolve-polled-auth-url=${Date.now()}`
|
||||
);
|
||||
const authUrl = await resolvePasteCallbackAuthUrl(remoteTarget, { state: 'state-123' }, 50, 0);
|
||||
|
||||
@@ -142,17 +142,15 @@ describe('findNewTokenSnapshotForManualAuth', () => {
|
||||
const existingMtimeMs = fs.statSync(existingFile).mtimeMs;
|
||||
|
||||
const { findNewTokenSnapshotForManualAuth } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?manual-auth-new-token=${Date.now()}`
|
||||
`../oauth-handler?manual-auth-new-token=${Date.now()}`
|
||||
);
|
||||
|
||||
const newFile = path.join(tokenDir, 'kiro-new.json');
|
||||
fs.writeFileSync(newFile, JSON.stringify({ type: 'kiro', email: 'new@example.com' }));
|
||||
|
||||
const snapshot = findNewTokenSnapshotForManualAuth(
|
||||
'kiro',
|
||||
tokenDir,
|
||||
[{ file: 'kiro-existing.json', mtimeMs: existingMtimeMs }]
|
||||
);
|
||||
const snapshot = findNewTokenSnapshotForManualAuth('kiro', tokenDir, [
|
||||
{ file: 'kiro-existing.json', mtimeMs: existingMtimeMs },
|
||||
]);
|
||||
|
||||
expect(snapshot?.file).toBe('kiro-new.json');
|
||||
fs.rmSync(tokenDir, { recursive: true, force: true });
|
||||
@@ -165,10 +163,13 @@ describe('findNewTokenSnapshotForManualAuth', () => {
|
||||
const existingMtimeMs = fs.statSync(tokenFile).mtimeMs;
|
||||
|
||||
const { findNewTokenSnapshotForManualAuth } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?manual-auth-updated-token=${Date.now()}`
|
||||
`../oauth-handler?manual-auth-updated-token=${Date.now()}`
|
||||
);
|
||||
|
||||
fs.writeFileSync(tokenFile, JSON.stringify({ type: 'kiro', email: 'existing@example.com', refreshed: true }));
|
||||
fs.writeFileSync(
|
||||
tokenFile,
|
||||
JSON.stringify({ type: 'kiro', email: 'existing@example.com', refreshed: true })
|
||||
);
|
||||
const bumpedTime = new Date(existingMtimeMs + 10_000);
|
||||
fs.utimesSync(tokenFile, bumpedTime, bumpedTime);
|
||||
|
||||
@@ -187,7 +188,7 @@ describe('findNewTokenSnapshotForManualAuth', () => {
|
||||
describe('getCliAuthNicknameError', () => {
|
||||
it('allows omitted nicknames for no-email providers', async () => {
|
||||
const { getCliAuthNicknameError } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?cli-nickname-empty=${Date.now()}`
|
||||
`../oauth-handler?cli-nickname-empty=${Date.now()}`
|
||||
);
|
||||
|
||||
expect(getCliAuthNicknameError('kiro', undefined, [])).toBeNull();
|
||||
@@ -196,7 +197,7 @@ describe('getCliAuthNicknameError', () => {
|
||||
|
||||
it('rejects invalid supplied nicknames before OAuth starts', async () => {
|
||||
const { getCliAuthNicknameError } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?cli-nickname-invalid=${Date.now()}`
|
||||
`../oauth-handler?cli-nickname-invalid=${Date.now()}`
|
||||
);
|
||||
|
||||
expect(getCliAuthNicknameError('kiro', 'bad nickname', [])).toBe(
|
||||
@@ -206,7 +207,7 @@ describe('getCliAuthNicknameError', () => {
|
||||
|
||||
it('rejects supplied nicknames that collide with existing ids or nicknames', async () => {
|
||||
const { getCliAuthNicknameError } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?cli-nickname-conflict=${Date.now()}`
|
||||
`../oauth-handler?cli-nickname-conflict=${Date.now()}`
|
||||
);
|
||||
const existingAccounts = [
|
||||
{ id: 'github-ABC123', nickname: 'work' },
|
||||
@@ -223,7 +224,7 @@ describe('getCliAuthNicknameError', () => {
|
||||
|
||||
it('allows reauth when the supplied nickname already belongs to the same account', async () => {
|
||||
const { getCliAuthNicknameError } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?cli-nickname-reauth=${Date.now()}`
|
||||
`../oauth-handler?cli-nickname-reauth=${Date.now()}`
|
||||
);
|
||||
const existingAccounts = [
|
||||
{ id: 'github-ABC123', nickname: 'work' },
|
||||
@@ -231,7 +232,9 @@ describe('getCliAuthNicknameError', () => {
|
||||
];
|
||||
|
||||
expect(getCliAuthNicknameError('kiro', 'work', existingAccounts, 'github-ABC123')).toBeNull();
|
||||
expect(getCliAuthNicknameError('kiro', 'github-ABC123', existingAccounts, 'github-ABC123')).toBeNull();
|
||||
expect(
|
||||
getCliAuthNicknameError('kiro', 'github-ABC123', existingAccounts, 'github-ABC123')
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -242,7 +245,7 @@ describe('promptGitLabPersonalAccessToken', () => {
|
||||
);
|
||||
|
||||
const { promptGitLabPersonalAccessToken } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?gitlab-pat-prompt=${Date.now()}`
|
||||
`../oauth-handler?gitlab-pat-prompt=${Date.now()}`
|
||||
);
|
||||
|
||||
await expect(promptGitLabPersonalAccessToken()).resolves.toBe('glpat-secret-token');
|
||||
@@ -255,7 +258,7 @@ describe('promptGitLabPersonalAccessToken', () => {
|
||||
);
|
||||
|
||||
const { promptGitLabPersonalAccessToken } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?gitlab-pat-prompt-blank=${Date.now()}`
|
||||
`../oauth-handler?gitlab-pat-prompt-blank=${Date.now()}`
|
||||
);
|
||||
|
||||
await expect(promptGitLabPersonalAccessToken()).resolves.toBeNull();
|
||||
@@ -266,7 +269,7 @@ describe('promptGitLabPersonalAccessToken', () => {
|
||||
describe('normalizeGitLabBaseUrl', () => {
|
||||
it('returns undefined for blank values', async () => {
|
||||
const { normalizeGitLabBaseUrl } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?gitlab-url-empty=${Date.now()}`
|
||||
`../oauth-handler?gitlab-url-empty=${Date.now()}`
|
||||
);
|
||||
|
||||
expect(normalizeGitLabBaseUrl(undefined)).toBeUndefined();
|
||||
@@ -275,7 +278,7 @@ describe('normalizeGitLabBaseUrl', () => {
|
||||
|
||||
it('normalizes whitespace and trailing slashes for self-hosted URLs', async () => {
|
||||
const { normalizeGitLabBaseUrl } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?gitlab-url-normalize=${Date.now()}`
|
||||
`../oauth-handler?gitlab-url-normalize=${Date.now()}`
|
||||
);
|
||||
|
||||
expect(normalizeGitLabBaseUrl(' https://gitlab.example.com/custom/ ')).toBe(
|
||||
@@ -285,7 +288,7 @@ describe('normalizeGitLabBaseUrl', () => {
|
||||
|
||||
it('rejects malformed or scheme-less URLs before hitting CLIProxy', async () => {
|
||||
const { normalizeGitLabBaseUrl } = await import(
|
||||
`../../../src/cliproxy/auth/oauth-handler?gitlab-url-invalid=${Date.now()}`
|
||||
`../oauth-handler?gitlab-url-invalid=${Date.now()}`
|
||||
);
|
||||
|
||||
expect(() => normalizeGitLabBaseUrl('gitlab.example.com')).toThrow(
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
getExpectedLocalCallback,
|
||||
getKiroBuilderIdSelectionInput,
|
||||
validateManualCallbackUrl,
|
||||
} from '../../../src/cliproxy/auth/oauth-process';
|
||||
} from '../oauth-process';
|
||||
|
||||
describe('oauth-process stderr parsing', () => {
|
||||
it('does not match provider-specific patterns for other providers', () => {
|
||||
@@ -7,8 +7,8 @@ import * as assert from 'assert';
|
||||
const fs = require('fs');
|
||||
|
||||
describe('Profile Mapper', () => {
|
||||
const profileMapper = require('../../../dist/cliproxy/sync/profile-mapper');
|
||||
const profileReader = require('../../../dist/api/services/profile-reader');
|
||||
const profileMapper = require('../../../../dist/cliproxy/sync/profile-mapper');
|
||||
const profileReader = require('../../../../dist/api/services/profile-reader');
|
||||
|
||||
describe('mapProfileToClaudeKey', () => {
|
||||
it('returns null when env is missing', () => {
|
||||
@@ -9,8 +9,8 @@
|
||||
*/
|
||||
|
||||
import { createInterface, Interface } from 'readline';
|
||||
import { fail, info, ok, warn } from '../utils/ui';
|
||||
import { getCliproxySafetyConfig } from '../config/unified-config-loader';
|
||||
import { fail, info, ok, warn } from '../../utils/ui';
|
||||
import { getCliproxySafetyConfig } from '../../config/unified-config-loader';
|
||||
|
||||
export const ANTIGRAVITY_RISK_ISSUE_URL = 'https://github.com/kaitranntt/ccs/issues/509';
|
||||
export const ANTIGRAVITY_ACK_VERSION = '2026-02-24-antigravity-oauth-v2';
|
||||
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
|
||||
// Re-export types
|
||||
export type { AuthStatus, ProviderOAuthConfig, OAuthOptions } from './auth';
|
||||
export type { AuthStatus, ProviderOAuthConfig, OAuthOptions } from '.';
|
||||
|
||||
// Re-export configurations
|
||||
export {
|
||||
@@ -29,7 +29,7 @@ export {
|
||||
PROVIDER_AUTH_PREFIXES,
|
||||
PROVIDER_TYPE_VALUES,
|
||||
getOAuthConfig,
|
||||
} from './auth';
|
||||
} from '.';
|
||||
|
||||
// Re-export token management functions
|
||||
export {
|
||||
@@ -40,21 +40,16 @@ export {
|
||||
getAllAuthStatus,
|
||||
clearAuth,
|
||||
displayAuthStatus,
|
||||
} from './auth';
|
||||
} from '.';
|
||||
|
||||
// Re-export environment detection functions
|
||||
export {
|
||||
isHeadlessEnvironment,
|
||||
killProcessOnPort,
|
||||
getTimeoutTroubleshooting,
|
||||
showStep,
|
||||
} from './auth';
|
||||
export { isHeadlessEnvironment, killProcessOnPort, getTimeoutTroubleshooting, showStep } from '.';
|
||||
|
||||
// Re-export OAuth handling functions
|
||||
export { triggerOAuth, ensureAuth } from './auth';
|
||||
export { triggerOAuth, ensureAuth } from '.';
|
||||
|
||||
// Re-export account management initialization
|
||||
import { discoverExistingAccounts } from './account-manager';
|
||||
import { discoverExistingAccounts } from '../accounts/account-manager';
|
||||
|
||||
/**
|
||||
* Initialize accounts registry from existing tokens
|
||||
@@ -39,14 +39,18 @@ function startCleanupInterval(): void {
|
||||
authSessionEvents.emit('session:expired', sessionId);
|
||||
}
|
||||
}
|
||||
// Stop interval if no active sessions
|
||||
if (activeSessions.size === 0 && cleanupInterval) {
|
||||
clearInterval(cleanupInterval);
|
||||
cleanupInterval = null;
|
||||
}
|
||||
stopCleanupIfEmpty();
|
||||
}, 60000); // Check every minute
|
||||
}
|
||||
|
||||
/** Clear the cleanup interval when no sessions remain. */
|
||||
function stopCleanupIfEmpty(): void {
|
||||
if (activeSessions.size === 0 && cleanupInterval) {
|
||||
clearInterval(cleanupInterval);
|
||||
cleanupInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an active OAuth session
|
||||
*/
|
||||
@@ -82,6 +86,7 @@ export function attachProcessToSession(sessionId: string, process: ChildProcess)
|
||||
export function unregisterAuthSession(sessionId: string): void {
|
||||
activeSessions.delete(sessionId);
|
||||
authSessionEvents.emit('session:ended', sessionId);
|
||||
stopCleanupIfEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,6 +106,7 @@ export function cancelAuthSession(sessionId: string): boolean {
|
||||
|
||||
activeSessions.delete(sessionId);
|
||||
authSessionEvents.emit('session:cancelled', sessionId);
|
||||
stopCleanupIfEmpty();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -112,15 +118,16 @@ export function getActiveSession(sessionId: string): ActiveAuthSession | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active session for a provider (most recent)
|
||||
* Get active session for a provider (most recent by startedAt)
|
||||
*/
|
||||
export function getActiveSessionForProvider(provider: string): ActiveAuthSession | null {
|
||||
let latest: ActiveAuthSession | null = null;
|
||||
for (const session of activeSessions.values()) {
|
||||
if (session.provider === provider) {
|
||||
return session;
|
||||
if (session.provider === provider && (!latest || session.startedAt > latest.startedAt)) {
|
||||
latest = session;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return latest;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,5 +152,6 @@ export function cancelAllSessionsForProvider(provider: string): number {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
stopCleanupIfEmpty();
|
||||
return count;
|
||||
}
|
||||
@@ -8,8 +8,8 @@
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'crypto';
|
||||
import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../config/unified-config-loader';
|
||||
import { CCS_INTERNAL_API_KEY, CCS_CONTROL_PANEL_SECRET } from './config-generator';
|
||||
import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import { CCS_INTERNAL_API_KEY, CCS_CONTROL_PANEL_SECRET } from '../config/generator';
|
||||
|
||||
/**
|
||||
* Generate a cryptographically secure token.
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { CLIProxyProvider } from '../types';
|
||||
import type { AccountInfo } from '../account-manager';
|
||||
import type { AccountInfo } from '../accounts/account-manager';
|
||||
import {
|
||||
buildProviderMap,
|
||||
CLIPROXY_PROVIDER_IDS,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { info, ok, fail } from '../../utils/ui';
|
||||
import { ensureCLIProxyBinary } from '../binary-manager';
|
||||
import { generateConfig } from '../config-generator';
|
||||
import { generateConfig } from '../config/config-generator';
|
||||
import { getProviderTokenDir } from './token-manager';
|
||||
|
||||
export interface KiroImportResult {
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import { fail, info, warn, color, ok } from '../../utils/ui';
|
||||
import { createLogger } from '../../services/logging';
|
||||
import { ensureCLIProxyBinary } from '../binary-manager';
|
||||
import { generateConfig } from '../config-generator';
|
||||
import { generateConfig } from '../config/config-generator';
|
||||
import { CLIProxyProvider } from '../types';
|
||||
import {
|
||||
AccountInfo,
|
||||
@@ -24,7 +25,7 @@ import {
|
||||
findAccountNameMatch,
|
||||
PROVIDERS_WITHOUT_EMAIL,
|
||||
validateNickname,
|
||||
} from '../account-manager';
|
||||
} from '../accounts/account-manager';
|
||||
import {
|
||||
enhancedPreflightOAuthCheck,
|
||||
OAUTH_CALLBACK_PORTS as OAUTH_PORTS,
|
||||
@@ -62,14 +63,14 @@ import {
|
||||
buildProxyUrl,
|
||||
buildManagementHeaders,
|
||||
type ProxyTarget,
|
||||
} from '../proxy-target-resolver';
|
||||
} from '../proxy/proxy-target-resolver';
|
||||
import {
|
||||
checkNewAccountConflict,
|
||||
warnNewAccountConflict,
|
||||
warnOAuthBanRisk,
|
||||
warnPossible403Ban,
|
||||
} from '../account-safety';
|
||||
import { ensureCliAntigravityResponsibility } from '../antigravity-responsibility';
|
||||
} from '../accounts/account-safety';
|
||||
import { ensureCliAntigravityResponsibility } from '../auth/antigravity-responsibility';
|
||||
import { InteractivePrompt } from '../../utils/prompt';
|
||||
|
||||
interface PasteCallbackStartData {
|
||||
@@ -82,6 +83,8 @@ interface PasteCallbackStartData {
|
||||
const PASTE_CALLBACK_AUTH_URL_POLL_INTERVAL_MS = 3000;
|
||||
const POLLED_AUTH_LOCAL_TOKEN_GRACE_MS = 15 * 1000;
|
||||
|
||||
const logger = createLogger('cliproxy:auth:oauth');
|
||||
|
||||
export async function requestPasteCallbackStart(
|
||||
provider: CLIProxyProvider,
|
||||
target: ProxyTarget,
|
||||
@@ -800,6 +803,12 @@ export async function triggerOAuth(
|
||||
): Promise<AccountInfo | null> {
|
||||
const oauthConfig = getOAuthConfig(provider);
|
||||
warnOAuthBanRisk(provider);
|
||||
const oauthStartedAt = Date.now();
|
||||
logger.stage('auth', 'cliproxy.oauth.start', 'Triggering OAuth flow', {
|
||||
provider,
|
||||
add: options.add === true,
|
||||
fromUI: options.fromUI === true,
|
||||
});
|
||||
const { verbose = false, add = false, fromUI = false, noIncognito = true } = options;
|
||||
const acceptAgyRisk = options.acceptAgyRisk === true;
|
||||
const { nickname } = options;
|
||||
@@ -1055,6 +1064,24 @@ export async function triggerOAuth(
|
||||
}
|
||||
}
|
||||
|
||||
if (account) {
|
||||
logger.stage(
|
||||
'auth',
|
||||
'cliproxy.oauth.success',
|
||||
'OAuth flow completed successfully',
|
||||
{ provider, accountId: account.id },
|
||||
{ latencyMs: Date.now() - oauthStartedAt }
|
||||
);
|
||||
} else {
|
||||
logger.stage(
|
||||
'cleanup',
|
||||
'cliproxy.oauth.failed',
|
||||
'OAuth flow failed or was cancelled',
|
||||
{ provider },
|
||||
{ level: 'warn', latencyMs: Date.now() - oauthStartedAt }
|
||||
);
|
||||
}
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
@@ -1067,6 +1094,9 @@ export async function ensureAuth(
|
||||
options: { verbose?: boolean; headless?: boolean; account?: string } = {}
|
||||
): Promise<boolean> {
|
||||
if (isAuthenticated(provider)) {
|
||||
logger.stage('auth', 'cliproxy.auth.cached', 'Provider already authenticated', {
|
||||
provider,
|
||||
});
|
||||
if (options.verbose) {
|
||||
console.error(`[auth] ${provider} already authenticated`);
|
||||
}
|
||||
@@ -1077,6 +1107,10 @@ export async function ensureAuth(
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.stage('auth', 'cliproxy.auth.required', 'Provider needs authentication', {
|
||||
provider,
|
||||
});
|
||||
|
||||
const oauthConfig = getOAuthConfig(provider);
|
||||
console.log(info(`${oauthConfig.displayName} authentication required`));
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ok, fail, info, warn } from '../../utils/ui';
|
||||
import { killWithEscalation } from '../../utils/process-utils';
|
||||
import { tryKiroImport } from './kiro-import';
|
||||
import { CLIProxyProvider } from '../types';
|
||||
import { AccountInfo } from '../account-manager';
|
||||
import { AccountInfo } from '../accounts/account-manager';
|
||||
import {
|
||||
parseProjectList,
|
||||
parseDefaultProject,
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
cancelProjectSelection,
|
||||
type GCloudProject,
|
||||
type ProjectSelectionPrompt,
|
||||
} from '../project-selection-handler';
|
||||
} from '../auth/project-selection-handler';
|
||||
import { KiroAuthMethod, ProviderOAuthConfig } from './auth-types';
|
||||
import { getTimeoutTroubleshooting, showStep } from './environment-detector';
|
||||
import {
|
||||
@@ -34,14 +34,14 @@ import {
|
||||
deviceCodeEvents,
|
||||
DEVICE_CODE_TIMEOUT_MS,
|
||||
type DeviceCodePrompt,
|
||||
} from '../device-code-handler';
|
||||
} from '../auth/device-code-handler';
|
||||
import { OAUTH_FLOW_TYPES } from '../../management';
|
||||
import {
|
||||
registerAuthSession,
|
||||
attachProcessToSession,
|
||||
unregisterAuthSession,
|
||||
authSessionEvents,
|
||||
} from '../auth-session-manager';
|
||||
} from '../auth/auth-session-manager';
|
||||
|
||||
/** Options for OAuth process execution */
|
||||
export interface OAuthProcessOptions {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AccountTier } from './accounts/types';
|
||||
import type { AccountTier } from '../accounts/types';
|
||||
import type {
|
||||
ProviderAccessState,
|
||||
ProviderCapacityState,
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AccountTier } from './accounts/types';
|
||||
import type { AccountTier } from '../accounts/types';
|
||||
|
||||
export type ProviderEntitlementSource =
|
||||
| 'runtime_api'
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import { CLIProxyProvider } from '../../types';
|
||||
import { getProviderAccounts } from '../../account-manager';
|
||||
import { getProviderAccounts } from '../../accounts/account-manager';
|
||||
import {
|
||||
getTokenRefreshOwnership,
|
||||
isRefreshDelegatedToCLIProxy,
|
||||
|
||||
@@ -9,7 +9,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { CLIProxyProvider } from '../types';
|
||||
import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
|
||||
import { getProviderAccounts, getAccountTokenPath } from '../account-manager';
|
||||
import { getProviderAccounts, getAccountTokenPath } from '../accounts/account-manager';
|
||||
import { isRefreshDelegated } from './provider-refreshers';
|
||||
|
||||
/** Preemptive refresh time: refresh tokens 45 minutes before expiry */
|
||||
|
||||
@@ -10,8 +10,8 @@ import * as path from 'path';
|
||||
import { createHash } from 'crypto';
|
||||
import { CLIProxyProvider } from '../types';
|
||||
import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
|
||||
import { getProviderAuthDir } from '../config-generator';
|
||||
import { getProviderAccounts, getDefaultAccount } from '../account-manager';
|
||||
import { getProviderAuthDir } from '../config/config-generator';
|
||||
import { getProviderAccounts, getDefaultAccount } from '../accounts/account-manager';
|
||||
import { deleteTokenFile, extractAccountIdFromTokenFile } from '../accounts/token-file-ops';
|
||||
import { buildEmailBackedAccountId } from '../accounts/email-account-identity';
|
||||
import { getTokenRefreshOwnership } from '../provider-capabilities';
|
||||
@@ -367,8 +367,8 @@ export function registerAccountFromToken(
|
||||
nickname?: string,
|
||||
verbose = false,
|
||||
expectedAccountId?: string
|
||||
): import('../account-manager').AccountInfo | null {
|
||||
const { registerAccount } = require('../account-manager');
|
||||
): import('../accounts/account-manager').AccountInfo | null {
|
||||
const { registerAccount } = require('../accounts/account-manager');
|
||||
let selectedCandidate: Omit<TokenCandidate, 'mtimeMs'> | null = null;
|
||||
try {
|
||||
const candidates = listTokenCandidates(provider, tokenDir);
|
||||
@@ -438,7 +438,7 @@ export function registerAccountFromToken(
|
||||
*/
|
||||
function uploadTokenToRemoteAsync(tokenPath: string, verbose: boolean): void {
|
||||
// Dynamic import to avoid circular dependencies
|
||||
import('../remote-token-uploader')
|
||||
import('../management/remote-token-uploader')
|
||||
.then(({ uploadTokenToRemote, isRemoteUploadEnabled }) => {
|
||||
if (isRemoteUploadEnabled()) {
|
||||
// uploadTokenToRemote handles its own success/failure logging
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { info, warn } from '../utils/ui';
|
||||
import { getBinDir, CLIPROXY_DEFAULT_PORT } from './config-generator';
|
||||
import { getBinDir, CLIPROXY_DEFAULT_PORT } from './config/config-generator';
|
||||
import { BinaryInfo, BinaryManagerConfig } from './types';
|
||||
import {
|
||||
BACKEND_CONFIG,
|
||||
DEFAULT_BACKEND,
|
||||
CLIPROXY_MAX_STABLE_VERSION,
|
||||
getExecutableName,
|
||||
} from './platform-detector';
|
||||
} from './binary/platform-detector';
|
||||
import { stopProxy } from './services/proxy-lifecycle-service';
|
||||
import { waitForPortFree } from '../utils/port-utils';
|
||||
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
||||
|
||||