@timo972/cc-router 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/Dockerfile +42 -0
  3. package/LICENSE +21 -0
  4. package/README.md +716 -0
  5. package/accounts.example.json +25 -0
  6. package/dist/cli/cmd-accounts.js +248 -0
  7. package/dist/cli/cmd-client.js +612 -0
  8. package/dist/cli/cmd-configure.js +145 -0
  9. package/dist/cli/cmd-docker.js +140 -0
  10. package/dist/cli/cmd-logs.js +85 -0
  11. package/dist/cli/cmd-models.js +125 -0
  12. package/dist/cli/cmd-service.js +193 -0
  13. package/dist/cli/cmd-setup.js +501 -0
  14. package/dist/cli/cmd-start.js +318 -0
  15. package/dist/cli/cmd-status.js +177 -0
  16. package/dist/cli/cmd-stop.js +100 -0
  17. package/dist/cli/cmd-telemetry.js +58 -0
  18. package/dist/cli/cmd-update.js +37 -0
  19. package/dist/cli/index.js +59 -0
  20. package/dist/config/manager.js +262 -0
  21. package/dist/config/paths.js +21 -0
  22. package/dist/config/telemetry.js +64 -0
  23. package/dist/daemon/launcher.js +163 -0
  24. package/dist/daemon/pid.js +98 -0
  25. package/dist/daemon/service.js +260 -0
  26. package/dist/interceptor/mitmproxy-manager.js +616 -0
  27. package/dist/protocol/anthropic-to-openai.js +51 -0
  28. package/dist/protocol/anthropic-types.js +1 -0
  29. package/dist/protocol/model-ref.js +36 -0
  30. package/dist/protocol/model-routing-config.js +30 -0
  31. package/dist/protocol/openai-response-to-anthropic.js +20 -0
  32. package/dist/protocol/openai-responses-types.js +1 -0
  33. package/dist/protocol/openai-stream-to-anthropic.js +75 -0
  34. package/dist/protocol/openai-to-anthropic.js +61 -0
  35. package/dist/protocol/sse.js +17 -0
  36. package/dist/providers/model-discovery.js +71 -0
  37. package/dist/providers/openai/account-pool.js +11 -0
  38. package/dist/providers/openai/account-record.js +33 -0
  39. package/dist/providers/openai/codex-transport.js +36 -0
  40. package/dist/providers/openai/device-oauth.js +116 -0
  41. package/dist/providers/openai/token-refresher.js +56 -0
  42. package/dist/providers/route-selector.js +8 -0
  43. package/dist/providers/types.js +1 -0
  44. package/dist/proxy/account-deletion.js +44 -0
  45. package/dist/proxy/anthropic-proxy.js +26 -0
  46. package/dist/proxy/anthropic-routing.js +90 -0
  47. package/dist/proxy/lease-lifecycle.js +68 -0
  48. package/dist/proxy/logger.js +39 -0
  49. package/dist/proxy/messages-cross-route.js +179 -0
  50. package/dist/proxy/models-server.js +150 -0
  51. package/dist/proxy/provider-routing.js +14 -0
  52. package/dist/proxy/responses-server.js +91 -0
  53. package/dist/proxy/server.js +875 -0
  54. package/dist/proxy/session-router.js +171 -0
  55. package/dist/proxy/stats.js +25 -0
  56. package/dist/proxy/stream-lifecycle.js +83 -0
  57. package/dist/proxy/token-pool.js +407 -0
  58. package/dist/proxy/token-refresher.js +209 -0
  59. package/dist/proxy/types.js +29 -0
  60. package/dist/ui/Dashboard.js +640 -0
  61. package/dist/ui/accountsApi.js +48 -0
  62. package/dist/ui/modelsApi.js +47 -0
  63. package/dist/utils/claude-config.js +185 -0
  64. package/dist/utils/codex-config.js +62 -0
  65. package/dist/utils/network.js +16 -0
  66. package/dist/utils/platform.js +13 -0
  67. package/dist/utils/self-update.js +239 -0
  68. package/dist/utils/telemetry.js +88 -0
  69. package/dist/utils/token-extractor.js +95 -0
  70. package/dist/utils/token-validator.js +26 -0
  71. package/docker-compose.yml +63 -0
  72. package/litellm-config.yaml +44 -0
  73. package/package.json +69 -0
  74. package/src/interceptor/addon.py +78 -0
@@ -0,0 +1,88 @@
1
+ import os from "os";
2
+ import { isTelemetryEnabled, loadTelemetryState } from "../config/telemetry.js";
3
+ import { detectPlatform } from "./platform.js";
4
+ import { getCurrentVersion } from "./self-update.js";
5
+ // ─── Aptabase configuration ──────────────────────────────────────────────────
6
+ // Aptabase is a privacy-first, open source analytics service.
7
+ // The full payload we send is documented below — search for "trackEvent" calls
8
+ // in the codebase to audit every event. Nothing here contains PII.
9
+ const APTABASE_APP_KEY = "A-EU-1060569594";
10
+ const APTABASE_ENDPOINT = "https://eu.aptabase.com/api/v0/event";
11
+ const TIMEOUT_MS = 3_000;
12
+ function getOsName() {
13
+ switch (detectPlatform()) {
14
+ case "macos": return "macOS";
15
+ case "linux": return "Linux";
16
+ case "windows": return "Windows";
17
+ }
18
+ }
19
+ function getLocale() {
20
+ try {
21
+ // Aptabase limits locale to 10 characters — truncate extended subtags
22
+ const raw = Intl.DateTimeFormat().resolvedOptions().locale;
23
+ return raw.length <= 10 ? raw : raw.slice(0, 10);
24
+ }
25
+ catch {
26
+ return process.env["LANG"]?.split(".")[0]?.slice(0, 10) ?? "unknown";
27
+ }
28
+ }
29
+ function getSystemProps() {
30
+ return {
31
+ isDebug: false,
32
+ locale: getLocale(),
33
+ osName: getOsName(),
34
+ osVersion: os.release(),
35
+ appVersion: getCurrentVersion(),
36
+ engineName: "node",
37
+ engineVersion: process.versions.node,
38
+ sdkVersion: `cc-router@${getCurrentVersion()}`,
39
+ };
40
+ }
41
+ // Session ID — use the installId directly so Aptabase always identifies the
42
+ // same machine as the same user. Aptabase limits sessionId to 36 characters;
43
+ // a standard UUID with dashes is exactly 36, so we pass it through unchanged.
44
+ function getSessionId(installId) {
45
+ return installId.slice(0, 36);
46
+ }
47
+ // ─── Public API ──────────────────────────────────────────────────────────────
48
+ // Fire-and-forget: never throws, never blocks the caller. If telemetry is
49
+ // disabled (env var or opt-out) this is a synchronous no-op.
50
+ export async function trackEvent(eventName, props) {
51
+ try {
52
+ if (!isTelemetryEnabled())
53
+ return;
54
+ const state = loadTelemetryState();
55
+ const body = {
56
+ timestamp: new Date().toISOString(),
57
+ sessionId: getSessionId(state.installId),
58
+ eventName,
59
+ systemProps: getSystemProps(),
60
+ props: props ?? {},
61
+ };
62
+ await fetch(APTABASE_ENDPOINT, {
63
+ method: "POST",
64
+ headers: {
65
+ "Content-Type": "application/json",
66
+ "App-Key": APTABASE_APP_KEY,
67
+ },
68
+ body: JSON.stringify(body),
69
+ signal: AbortSignal.timeout(TIMEOUT_MS),
70
+ });
71
+ }
72
+ catch {
73
+ // Silently swallow — telemetry must never disrupt the proxy
74
+ }
75
+ }
76
+ // Start a heartbeat that fires every hour while the proxy is running.
77
+ // Uses .unref() so the timer does not prevent Node from exiting.
78
+ export function startHeartbeat(accountCount) {
79
+ const startTime = Date.now();
80
+ const timer = setInterval(() => {
81
+ const uptimeMinutes = Math.floor((Date.now() - startTime) / 60_000);
82
+ trackEvent("proxy_heartbeat", {
83
+ uptime_minutes: uptimeMinutes,
84
+ account_count: accountCount,
85
+ });
86
+ }, 60 * 60 * 1000);
87
+ timer.unref();
88
+ }
@@ -0,0 +1,95 @@
1
+ import { execFile } from "child_process";
2
+ import { promisify } from "util";
3
+ import { readFileSync, existsSync } from "fs";
4
+ import { join } from "path";
5
+ import os from "os";
6
+ const execFileAsync = promisify(execFile);
7
+ /**
8
+ * macOS: extract OAuth tokens from the macOS Keychain.
9
+ * Uses execFile (not exec/execSync) — args are passed as an array,
10
+ * preventing any shell injection.
11
+ */
12
+ export async function extractFromKeychain() {
13
+ try {
14
+ const { stdout } = await execFileAsync("security", [
15
+ "find-generic-password",
16
+ "-s", "Claude Code-credentials",
17
+ "-w",
18
+ ]);
19
+ const raw = JSON.parse(stdout.trim());
20
+ // Keychain JSON can be either:
21
+ // { claudeAiOauth: { accessToken, refreshToken, ... }, mcpOAuth: {...} }
22
+ // { accessToken, refreshToken, ... } (direct, older versions)
23
+ const oauth = raw.claudeAiOauth ?? raw;
24
+ return parseCredentialJson(oauth);
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ /**
31
+ * Linux / Windows: read from ~/.claude/.credentials.json.
32
+ * Claude Code writes credentials here on non-macOS platforms.
33
+ * No shell — pure Node.js file read.
34
+ */
35
+ export function extractFromCredentialsFile() {
36
+ const credPath = join(os.homedir(), ".claude", ".credentials.json");
37
+ if (!existsSync(credPath))
38
+ return null;
39
+ try {
40
+ const raw = JSON.parse(readFileSync(credPath, "utf-8"));
41
+ // The file can have two shapes:
42
+ // { claudeAiOauth: { accessToken, refreshToken, expiresAt, scopes } }
43
+ // { accessToken, refreshToken, expiresAt, scopes } (direct)
44
+ const oauth = raw.claudeAiOauth ?? raw;
45
+ return parseCredentialJson(oauth);
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ /** Parse and normalise either a raw JSON string or an already-parsed object. */
52
+ function parseCredentialJson(raw) {
53
+ try {
54
+ const obj = typeof raw === "string" ? JSON.parse(raw) : raw;
55
+ const accessToken = obj["accessToken"];
56
+ const refreshToken = obj["refreshToken"];
57
+ const expiresAt = obj["expiresAt"];
58
+ if (typeof accessToken !== "string" ||
59
+ typeof refreshToken !== "string" ||
60
+ !accessToken.startsWith("sk-ant-")) {
61
+ return null;
62
+ }
63
+ const scopes = Array.isArray(obj["scopes"])
64
+ ? obj["scopes"]
65
+ : ["user:inference", "user:profile"];
66
+ let expiresAtMs;
67
+ if (typeof expiresAt === "number") {
68
+ expiresAtMs = expiresAt;
69
+ }
70
+ else if (typeof expiresAt === "string") {
71
+ expiresAtMs = new Date(expiresAt).getTime();
72
+ }
73
+ else {
74
+ // No expiry info — assume 8h from now (standard OAuth token lifetime)
75
+ expiresAtMs = Date.now() + 8 * 60 * 60 * 1000;
76
+ }
77
+ return { accessToken, refreshToken, expiresAt: expiresAtMs, scopes };
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
83
+ /** Format a token expiry timestamp as a human-readable string */
84
+ export function formatExpiry(expiresAtMs) {
85
+ const ms = expiresAtMs - Date.now();
86
+ if (ms <= 0)
87
+ return "EXPIRED";
88
+ const h = Math.floor(ms / 3_600_000);
89
+ const m = Math.floor((ms % 3_600_000) / 60_000);
90
+ return h > 0 ? `${h}h ${m}m` : `${m}m`;
91
+ }
92
+ /** Redact a token for safe display: show first 20 chars + "..." */
93
+ export function redactToken(token) {
94
+ return token.length > 20 ? `${token.slice(0, 20)}...` : token;
95
+ }
@@ -0,0 +1,26 @@
1
+ export async function validateToken(accessToken) {
2
+ try {
3
+ const res = await fetch("https://api.anthropic.com/v1/models", {
4
+ headers: {
5
+ "Authorization": `Bearer ${accessToken}`,
6
+ "anthropic-version": "2023-06-01",
7
+ // Required for api.anthropic.com to accept OAuth tokens (sk-ant-oat01-*)
8
+ "anthropic-beta": "oauth-2025-04-20",
9
+ },
10
+ });
11
+ if (res.ok)
12
+ return { valid: true };
13
+ if (res.status === 401) {
14
+ return { valid: false, reason: "Token invalid or expired (401)" };
15
+ }
16
+ if (res.status === 403) {
17
+ return { valid: false, reason: "Token lacks required scopes (403) — needs user:inference" };
18
+ }
19
+ // Any other non-ok status is unexpected but the token may still work
20
+ return { valid: false, reason: `Unexpected HTTP ${res.status}` };
21
+ }
22
+ catch (err) {
23
+ // Network error — can't validate, let user decide
24
+ return { valid: false, reason: `Network error: ${err.message}` };
25
+ }
26
+ }
@@ -0,0 +1,63 @@
1
+ services:
2
+ cc-router:
3
+ build:
4
+ context: .
5
+ dockerfile: Dockerfile
6
+ ports:
7
+ # Bind to localhost by default. To serve other devices, set a proxySecret
8
+ # (see docs/security.md) and change this to "0.0.0.0:${PORT:-3456}:3456".
9
+ - "127.0.0.1:${PORT:-3456}:3456"
10
+ volumes:
11
+ # Mount accounts.json read-write: cc-router must update it when tokens rotate
12
+ - ${HOME}/.cc-router/accounts.json:/app/accounts.json
13
+ environment:
14
+ - PORT=3456
15
+ - LITELLM_URL=http://litellm:4000
16
+ - ACCOUNTS_PATH=/app/accounts.json
17
+ - NODE_ENV=production
18
+ depends_on:
19
+ litellm:
20
+ condition: service_healthy
21
+ restart: unless-stopped
22
+ # Least privilege: no capabilities, no privilege escalation.
23
+ cap_drop:
24
+ - ALL
25
+ security_opt:
26
+ - no-new-privileges:true
27
+ healthcheck:
28
+ test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3456/cc-router/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
29
+ interval: 30s
30
+ timeout: 10s
31
+ retries: 3
32
+ start_period: 10s
33
+
34
+ litellm:
35
+ # Pinned by digest (snapshot of main-stable at pin time) — a moving tag can
36
+ # silently ship a bad build. The BerriAI 1.82.7/1.82.8 releases contained
37
+ # malware; a digest pin prevents a re-tag from re-introducing that class of
38
+ # supply-chain risk. Update deliberately after vetting the release:
39
+ # crane digest ghcr.io/berriai/litellm:main-stable
40
+ image: ghcr.io/berriai/litellm:main-stable@sha256:a1745e629abfb17d434426ff48b115f54f4f4c4a0f5af241de569e93c63c411e
41
+ ports:
42
+ # Localhost only — the LiteLLM admin UI/key management must not be exposed.
43
+ - "127.0.0.1:${LITELLM_PORT:-4000}:4000"
44
+ volumes:
45
+ - ./litellm-config.yaml:/app/config.yaml:ro
46
+ environment:
47
+ # Require an explicit master key — fail fast instead of falling back to a
48
+ # published, guessable default that would gate the admin plane.
49
+ - LITELLM_MASTER_KEY=${LITELLM_MASTER_KEY:?set LITELLM_MASTER_KEY in your .env}
50
+ # No --detailed_debug: with forward_client_headers_to_llm_api it would write
51
+ # the injected "Authorization: Bearer <oauth-token>" into container logs.
52
+ command: ["--config", "/app/config.yaml", "--port", "4000"]
53
+ restart: unless-stopped
54
+ cap_drop:
55
+ - ALL
56
+ security_opt:
57
+ - no-new-privileges:true
58
+ healthcheck:
59
+ test: ["CMD-SHELL", "curl -sf http://localhost:4000/health || exit 1"]
60
+ interval: 15s
61
+ timeout: 5s
62
+ retries: 5
63
+ start_period: 20s
@@ -0,0 +1,44 @@
1
+ model_list:
2
+ # ── Claude 4.6 (latest) ───────────────────────────────────────────────────
3
+ - model_name: claude-opus-4-6
4
+ litellm_params:
5
+ model: anthropic/claude-opus-4-6
6
+ # No api_key — Authorization header is injected by cc-router (OAuth token)
7
+
8
+ - model_name: claude-sonnet-4-6
9
+ litellm_params:
10
+ model: anthropic/claude-sonnet-4-6
11
+
12
+ # ── Claude 4.5 ────────────────────────────────────────────────────────────
13
+ - model_name: claude-sonnet-4-5-20250929
14
+ litellm_params:
15
+ model: anthropic/claude-sonnet-4-5-20250929
16
+
17
+ - model_name: claude-haiku-4-5-20251001
18
+ litellm_params:
19
+ model: anthropic/claude-haiku-4-5-20251001
20
+
21
+ # ── Short-name aliases (Claude Code uses these interchangeably) ───────────
22
+ - model_name: claude-sonnet-4-5
23
+ litellm_params:
24
+ model: anthropic/claude-sonnet-4-5-20250929
25
+
26
+ - model_name: claude-haiku-4-5
27
+ litellm_params:
28
+ model: anthropic/claude-haiku-4-5-20251001
29
+
30
+ general_settings:
31
+ # CRITICAL: forward the Authorization header from cc-router to Anthropic.
32
+ # cc-router injects "Authorization: Bearer <oauth-token>" on every request.
33
+ # Without this setting, LiteLLM would strip it and auth would fail.
34
+ forward_client_headers_to_llm_api: true
35
+
36
+ # Master key for LiteLLM UI and virtual key management.
37
+ # Set LITELLM_MASTER_KEY in your environment or .env file.
38
+ master_key: os.environ/LITELLM_MASTER_KEY
39
+
40
+ litellm_settings:
41
+ # High timeouts for Claude Code long-running requests (thinking, agents)
42
+ request_timeout: 300
43
+ # Drop params not supported by a model silently (avoids errors on older models)
44
+ drop_params: true
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@timo972/cc-router",
3
+ "version": "0.7.0",
4
+ "description": "Cache-aware session router for Claude Max OAuth tokens — use multiple Claude Max accounts with Claude Code",
5
+ "type": "module",
6
+ "bin": {
7
+ "cc-router": "dist/cli/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "dev": "tsx src/cli/index.ts",
12
+ "start": "node dist/cli/index.js",
13
+ "test": "vitest run",
14
+ "test:watch": "vitest",
15
+ "lint": "tsc --noEmit"
16
+ },
17
+ "keywords": [
18
+ "claude",
19
+ "anthropic",
20
+ "proxy",
21
+ "oauth",
22
+ "session-routing",
23
+ "claude-code",
24
+ "claude-max"
25
+ ],
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/Timo972/cc-router.git"
30
+ },
31
+ "bugs": {
32
+ "url": "https://github.com/Timo972/cc-router/issues"
33
+ },
34
+ "homepage": "https://github.com/Timo972/cc-router#readme",
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "files": [
39
+ "dist/",
40
+ "src/interceptor/addon.py",
41
+ "litellm-config.yaml",
42
+ "docker-compose.yml",
43
+ "Dockerfile",
44
+ "accounts.example.json",
45
+ "README.md",
46
+ "CHANGELOG.md",
47
+ "LICENSE"
48
+ ],
49
+ "dependencies": {
50
+ "@inquirer/prompts": "^7.0.0",
51
+ "chalk": "^5.3.0",
52
+ "commander": "^12.0.0",
53
+ "express": "^4.21.0",
54
+ "http-proxy-middleware": "^3.0.5",
55
+ "ink": "^5.0.0",
56
+ "react": "^18.3.0"
57
+ },
58
+ "devDependencies": {
59
+ "@types/express": "^4.17.21",
60
+ "@types/node": "^20.0.0",
61
+ "@types/react": "^18.3.0",
62
+ "tsx": "^4.19.0",
63
+ "typescript": "^5.6.0",
64
+ "vitest": "^4.1.2"
65
+ },
66
+ "engines": {
67
+ "node": ">=20.0.0"
68
+ }
69
+ }
@@ -0,0 +1,78 @@
1
+ # mitmproxy addon — redirects ONLY /v1/messages traffic to CC-Router and
2
+ # injects the proxy secret as an auth header.
3
+ #
4
+ # There are TWO cases to handle:
5
+ #
6
+ # 1. Requests to api.anthropic.com (Claude Desktop native features)
7
+ # → rewrite host/port to CC-Router target + inject x-api-key
8
+ #
9
+ # 2. Requests already pointed at the CC-Router target host (Claude Code
10
+ # inside Desktop Cowork/Agent mode, which reads ~/.claude/settings.json
11
+ # and goes direct to ANTHROPIC_BASE_URL)
12
+ # → inject x-api-key (no rewrite needed)
13
+ #
14
+ # Claude Desktop sends many types of requests:
15
+ # /v1/messages → LLM inference (redirect + auth)
16
+ # /v1/messages/count_tokens → token counting (redirect + auth)
17
+ # /v1/oauth/* → session auth (must NOT touch)
18
+ # /v1/environments/* → bridge/cowork (must NOT touch)
19
+ # /v1/models → model listing (redirect + auth)
20
+ # /api/* → desktop features (must NOT touch)
21
+ #
22
+ # Only /v1/messages* and /v1/models are safe to touch because CC-Router
23
+ # injects its own OAuth token. Everything else carries the user's own
24
+ # session token for features CC-Router doesn't handle.
25
+
26
+ import os
27
+ from urllib.parse import urlparse
28
+
29
+ from mitmproxy import http
30
+
31
+ _target_raw = os.environ.get("CC_ROUTER_TARGET", "http://localhost:3456")
32
+ _target = _target_raw.rstrip("/")
33
+ _target_parsed = urlparse(_target)
34
+
35
+ if not _target_parsed.scheme or not _target_parsed.netloc:
36
+ raise RuntimeError(f"CC_ROUTER_TARGET is not a valid URL: {_target_raw!r}")
37
+
38
+ _target_host = (_target_parsed.hostname or "").lower()
39
+ _target_port = _target_parsed.port or (443 if _target_parsed.scheme == "https" else 80)
40
+
41
+ # Optional proxy secret — when set, injected as x-api-key on routed requests
42
+ _secret = os.environ.get("CC_ROUTER_SECRET", "")
43
+
44
+ # Paths that CC-Router can handle (it injects its own OAuth token)
45
+ _REDIRECT_PREFIXES = (
46
+ "/v1/messages",
47
+ "/v1/models",
48
+ )
49
+
50
+
51
+ def request(flow: http.HTTPFlow) -> None:
52
+ host = (flow.request.pretty_host or "").lower()
53
+ port = flow.request.port
54
+ is_anthropic = host == "api.anthropic.com"
55
+ is_target = host == _target_host and port == _target_port
56
+
57
+ # Not a host we care about — pass through untouched
58
+ if not is_anthropic and not is_target:
59
+ return
60
+
61
+ # Only touch inference and model-listing paths
62
+ if not flow.request.path.startswith(_REDIRECT_PREFIXES):
63
+ return
64
+
65
+ # Case 1: rewrite api.anthropic.com → CC-Router target
66
+ if is_anthropic:
67
+ flow.request.scheme = _target_parsed.scheme
68
+ flow.request.host = _target_host or "localhost"
69
+ flow.request.port = _target_port
70
+ flow.request.headers["host"] = flow.request.host + (
71
+ f":{flow.request.port}"
72
+ if flow.request.port not in (80, 443)
73
+ else ""
74
+ )
75
+
76
+ # Case 1 and 2: authenticate against the proxy if a secret is configured
77
+ if _secret:
78
+ flow.request.headers["x-api-key"] = _secret