diff --git a/.github/scripts/test_update_fork.py b/.github/scripts/test_update_fork.py new file mode 100644 index 00000000000..24045e64d7e --- /dev/null +++ b/.github/scripts/test_update_fork.py @@ -0,0 +1,161 @@ +import contextlib +import importlib.util +import io +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location("update_fork", Path(__file__).with_name("update-fork.py")) +updater = importlib.util.module_from_spec(spec) +spec.loader.exec_module(updater) + + +class UpdateForkTest(unittest.TestCase): + def setUp(self): + environment = patch.dict(os.environ, {"GITHUB_ACTIONS": "false", "GITHUB_STEP_SUMMARY": ""}) + environment.start() + self.addCleanup(environment.stop) + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.original_cwd = Path.cwd() + self.addCleanup(os.chdir, self.original_cwd) + self.root = Path(self.directory.name) + self.upstream = self.root / "upstream.git" + self.origin = self.root / "origin.git" + self.checkout = self.root / "checkout" + for path in (self.upstream, self.origin): + self.git("init", "--bare", str(path), cwd=self.root) + self.git("init", "--initial-branch=main", str(self.checkout), cwd=self.root) + os.chdir(self.checkout) + self.git("config", "user.name", "Maintenance test") + self.git("config", "user.email", "maintenance@example.test") + self.git("config", "core.autocrlf", "false") + self.git("config", "commit.gpgsign", "false") + self.write("routing.txt", "official base\n") + self.git("add", ".") + self.git("commit", "-m", "official base") + self.base = self.git("rev-parse", "HEAD").stdout.strip() + self.git("remote", "add", "origin", str(self.origin)) + self.git("push", "origin", "main") + self.git("push", str(self.upstream), "main") + self.git("switch", "-c", "custom") + self.write("console.txt", "custom console\n") + self.git("add", ".") + self.git("commit", "-m", "custom console") + self.git("push", "origin", "custom") + self.release = self.base + self.tag = "v8.0.28" + self.prs = [] + self.dispatches = [] + self.real_run = updater.run + + def git(self, *args, cwd=None): + result = subprocess.run(["git", *args], cwd=cwd, text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + return result + + def write(self, name, text): + (self.checkout / name).write_text(text, encoding="utf-8") + + def upstream_release(self, conflict=False): + self.git("switch", "main") + self.write("routing.txt" if conflict else "official.txt", "new official behavior\n") + self.git("add", ".") + self.git("commit", "-m", "official release") + self.git("tag", self.tag) + self.release = self.git("rev-parse", "HEAD").stdout.strip() + self.git("push", str(self.upstream), "main", self.tag) + self.git("switch", "custom") + if conflict: + self.write("routing.txt", "custom routing behavior\n") + self.git("add", ".") + self.git("commit", "-m", "custom routing") + self.git("push", "origin", "custom") + + def fake_run(self, *args, check=True): + if args[0] == "gh": + if args[1:3] == ("repo", "view"): + data = {"nameWithOwner": updater.FORK} + elif args[1] == "api": + data = {"tag_name": self.tag, "draft": False, "prerelease": False, + "html_url": f"https://example.test/releases/{self.tag}"} + elif args[1:3] == ("pr", "list"): + data = self.prs + elif args[1:3] == ("pr", "create"): + self.created_args = args + self.created_body = Path(args[args.index("--body-file") + 1]).read_text(encoding="utf-8") + self.prs = [{"state": "OPEN", "url": "https://example.test/pull/1"}] + return subprocess.CompletedProcess(args, 0, self.prs[0]["url"], "") + elif args[1:3] == ("workflow", "run"): + self.dispatches.append(args) + data = {} + else: + raise AssertionError(args) + return subprocess.CompletedProcess(args, 0, json.dumps(data), "") + args = tuple(str(self.upstream) if item == f"https://github.com/{updater.UPSTREAM}.git" else item for item in args) + return self.real_run(*args, check=check) + + def update(self): + with patch.object(updater, "run", side_effect=self.fake_run), contextlib.redirect_stdout(io.StringIO()): + updater.main() + + def test_clean_update_preserves_custom_and_is_repeatable(self): + self.upstream_release() + self.update() + self.assertEqual(self.git("--git-dir", str(self.origin), "rev-parse", "main").stdout.strip(), self.release) + self.assertEqual((self.checkout / "console.txt").read_text(), "custom console\n") + self.assertEqual((self.checkout / "official.txt").read_text(), "new official behavior\n") + self.assertNotIn("--draft", self.created_args) + self.assertEqual(len(self.dispatches), 1) + self.assertNotEqual(self.git("--git-dir", str(self.origin), "rev-parse", "custom").stdout.strip(), self.release) + self.update() + self.assertEqual(len(self.dispatches), 1) + self.assertEqual(len(self.prs), 1) + + def test_conflict_creates_draft_without_claiming_validation(self): + self.upstream_release(conflict=True) + self.update() + self.assertIn("--draft", self.created_args) + self.assertIn("routing.txt", self.created_body) + self.assertEqual(self.dispatches, []) + self.assertFalse((self.checkout / ".git/MERGE_HEAD").exists()) + self.assertEqual(self.git("--git-dir", str(self.origin), "rev-parse", "updates/upstream-v8.0.28").stdout.strip(), self.release) + + def test_divergent_main_is_not_overwritten(self): + self.upstream_release() + self.git("switch", "main") + self.git("reset", "--hard", self.base) + self.write("fork-only.txt", "must not disappear\n") + self.git("add", ".") + self.git("commit", "-m", "divergent main") + divergent = self.git("rev-parse", "HEAD").stdout.strip() + self.git("push", "origin", "main") + self.git("switch", "custom") + with self.assertRaisesRegex(RuntimeError, "merge-base failed"): + self.update() + self.assertEqual(self.git("--git-dir", str(self.origin), "rev-parse", "main").stdout.strip(), divergent) + self.assertEqual(self.prs, []) + + def test_dirty_checkout_is_rejected(self): + self.write("console.txt", "uncommitted changes\n") + with self.assertRaisesRegex(RuntimeError, "Commit or stash"): + self.update() + self.assertEqual((self.checkout / "console.txt").read_text(), "uncommitted changes\n") + + def test_actions_prepares_branch_without_creating_pr(self): + self.upstream_release() + with patch.dict(os.environ, {"GITHUB_ACTIONS": "true"}): + self.update() + published = self.git("--git-dir", str(self.origin), "rev-parse", "updates/upstream-v8.0.28").stdout.strip() + self.update() + self.assertEqual(self.git("--git-dir", str(self.origin), "rev-parse", "updates/upstream-v8.0.28").stdout.strip(), published) + self.assertEqual(self.prs, []) + self.assertEqual(len(self.dispatches), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/update-fork.py b/.github/scripts/update-fork.py new file mode 100644 index 00000000000..e486f247b08 --- /dev/null +++ b/.github/scripts/update-fork.py @@ -0,0 +1,104 @@ +"""Mirror official main and propose a stable release update to custom.""" +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile + +UPSTREAM = "router-for-me/CLIProxyAPI" +FORK = "Raghav-56/CLIProxyAPI" + + +def run(*args, check=True): + result = subprocess.run(args, text=True, encoding="utf-8", capture_output=True) + if check and result.returncode: + raise RuntimeError(f"{args[0]} {args[1]} failed: {result.stderr.strip()}") + return result + + +def main(): + root = run("git", "rev-parse", "--show-toplevel").stdout.strip() + os.chdir(root) + actual = json.loads(run("gh", "repo", "view", "--json", "nameWithOwner").stdout)["nameWithOwner"] + if actual != FORK: + raise RuntimeError(f"Expected {FORK}, found {actual}") + if run("git", "status", "--porcelain").stdout.strip(): + raise RuntimeError("Commit or stash local changes before preparing an update.") + release = json.loads(run("gh", "api", f"repos/{UPSTREAM}/releases/latest").stdout) + tag = release["tag_name"] + if release["draft"] or release["prerelease"] or not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", tag): + raise RuntimeError("Expected a stable official release tag.") + run("git", "fetch", "--quiet", "--no-tags", "origin", + "refs/heads/main:refs/remotes/origin/main", "refs/heads/custom:refs/remotes/origin/custom") + run("git", "fetch", "--quiet", "--no-tags", f"https://github.com/{UPSTREAM}.git", + "refs/heads/main:refs/remotes/upstream/main", f"refs/tags/{tag}:refs/tags/{tag}") + run("git", "merge-base", "--is-ancestor", "origin/main", "upstream/main") + run("git", "push", "origin", "upstream/main:refs/heads/main") + print("main now matches official upstream/main", flush=True) + if run("git", "merge-base", "--is-ancestor", tag, "origin/custom", check=False).returncode == 0: + print(f"custom already contains {tag}") + return + branch = f"updates/upstream-{tag}" + existing = json.loads(run("gh", "pr", "list", "--repo", FORK, "--base", "custom", + "--head", branch, "--state", "all", "--json", "url,state").stdout) + if existing: + print(f"Existing update PR ({existing[0]['state']}): {existing[0]['url']}") + return + compare = f"https://github.com/{FORK}/compare/custom...{branch}?expand=1" + if run("git", "ls-remote", "--heads", "origin", branch).stdout.strip(): + print(f"Existing update branch: {compare}") + return + if run("git", "show-ref", "--verify", f"refs/heads/{branch}", check=False).returncode == 0: + raise RuntimeError(f"Local branch {branch} already exists. Inspect it before retrying.") + run("git", "switch", "--create", branch, "origin/custom") + merge = run("git", "merge", "--no-ff", "--no-edit", tag, check=False) + conflicts = [] + if merge.returncode: + conflicts = run("git", "diff", "--name-only", "--diff-filter=U").stdout.splitlines() + if not conflicts: + raise RuntimeError(merge.stderr.strip() or merge.stdout.strip()) + run("git", "merge", "--abort") + run("git", "switch", "--detach", tag) + run("git", "branch", "--force", branch, tag) + run("git", "switch", branch) + run("git", "push", "--set-upstream", "origin", branch) + if os.environ.get("GITHUB_ACTIONS") == "true": + print(f"Prepared update branch: {compare}", flush=True) + if conflicts: + print("Merge conflicts require AI review: " + ", ".join(conflicts)) + else: + run("gh", "workflow", "run", "custom-ci.yml", "--repo", FORK, "--ref", branch) + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if summary: + with open(summary, "a", encoding="utf-8") as output: + output.write(f"main synced. [Review the release update]({compare}).\n") + return + body = (f"Update the custom proxy from official [{tag}]({release['html_url']}).\n\n" + "Keep account selection, quota guidance, and request observability working. " + "This PR does not deploy to oracler.\n\n") + if conflicts: + body += ("This draft has merge conflicts. Have an AI coding agent check out this branch, " + "merge `origin/custom`, resolve the conflicts, and follow `CUSTOM.md`.\n\n" + "Conflicting files:\n" + "".join(f"- `{name}`\n" for name in conflicts)) + else: + body += "The release merged cleanly. The custom-ci workflow is dispatched on this branch. Check it before merging.\n" + with tempfile.TemporaryDirectory() as temporary: + body_path = Path(temporary) / "body.md" + body_path.write_text(body, encoding="utf-8") + args = ["gh", "pr", "create", "--repo", FORK, "--base", "custom", "--head", branch, + "--title", f"chore(upstream): merge {tag} into custom", "--body-file", str(body_path)] + if conflicts: + args.append("--draft") + url = run(*args).stdout.strip() + print(url, flush=True) + if not conflicts: + run("gh", "workflow", "run", "custom-ci.yml", "--repo", FORK, "--ref", branch) + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if summary: + with open(summary, "a", encoding="utf-8") as output: + output.write(f"main synced with upstream. Release update: {url}\n") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/agents-md-guard.yml b/.github/workflows/agents-md-guard.yml index c9ac0cb45b7..acbb4914c6d 100644 --- a/.github/workflows/agents-md-guard.yml +++ b/.github/workflows/agents-md-guard.yml @@ -14,6 +14,7 @@ permissions: jobs: close-when-agents-md-changed: + if: github.repository == 'router-for-me/CLIProxyAPI' runs-on: ubuntu-latest steps: - name: Detect AGENTS.md changes and close PR diff --git a/.github/workflows/auto-retarget-main-pr-to-dev.yml b/.github/workflows/auto-retarget-main-pr-to-dev.yml index 3732a72359f..bc65279e049 100644 --- a/.github/workflows/auto-retarget-main-pr-to-dev.yml +++ b/.github/workflows/auto-retarget-main-pr-to-dev.yml @@ -16,7 +16,7 @@ permissions: jobs: retarget: - if: github.actor != 'github-actions[bot]' + if: github.repository == 'router-for-me/CLIProxyAPI' && github.actor != 'github-actions[bot]' runs-on: ubuntu-latest steps: - name: Retarget PR base to dev diff --git a/.github/workflows/custom-ci.yml b/.github/workflows/custom-ci.yml new file mode 100644 index 00000000000..729acb5da9f --- /dev/null +++ b/.github/workflows/custom-ci.yml @@ -0,0 +1,43 @@ +name: custom-ci + +on: + push: + branches: [custom, 'updates/**'] + pull_request: + branches: [custom] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: custom-ci-${{ github.event.pull_request.head.ref || github.ref_name }} + cancel-in-progress: true + +jobs: + custom-tests: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Check formatting + run: | + git diff --check + test -z "$(gofmt -l cmd/config-export internal/api/handlers/management/codex_account_policy*.go internal/api/server_accounts*.go internal/api/server_codex_account_policy_test.go internal/api/recent_requests*.go sdk/cliproxy/auth/conductor_account_policy*.go)" + - name: Test account console + run: node --test internal/api/assets/accounts.test.cjs + - name: Test upstream updater + run: python3 -B -m unittest discover -s .github/scripts -p test_update_fork.py -v + - name: Test Go packages + run: go test ./... + - name: Build server and config exporter + run: | + go build -o "$RUNNER_TEMP/cli-proxy-api" ./cmd/server + go build -o "$RUNNER_TEMP/config-export" ./cmd/config-export diff --git a/.github/workflows/pr-path-guard.yml b/.github/workflows/pr-path-guard.yml index 4fe3d93881b..803f231cfe4 100644 --- a/.github/workflows/pr-path-guard.yml +++ b/.github/workflows/pr-path-guard.yml @@ -9,6 +9,7 @@ on: jobs: ensure-no-translator-changes: + if: github.repository == 'router-for-me/CLIProxyAPI' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/pr-test-build.yml b/.github/workflows/pr-test-build.yml index f1f0e2879c7..a3e054036b2 100644 --- a/.github/workflows/pr-test-build.yml +++ b/.github/workflows/pr-test-build.yml @@ -8,6 +8,7 @@ permissions: jobs: build: + if: github.repository == 'router-for-me/CLIProxyAPI' runs-on: ubuntu-latest steps: - name: Checkout diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 7407a19d3e7..9754cd510f5 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -15,6 +15,7 @@ env: jobs: prepare-release: + if: github.repository == 'router-for-me/CLIProxyAPI' runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/update-fork.yml b/.github/workflows/update-fork.yml new file mode 100644 index 00000000000..acad7c724dc --- /dev/null +++ b/.github/workflows/update-fork.yml @@ -0,0 +1,37 @@ +name: update-fork + +on: + schedule: + - cron: '17 3 * * 1' + workflow_dispatch: + +permissions: + contents: write + pull-requests: read + actions: write + +concurrency: + group: update-fork + cancel-in-progress: false + +jobs: + prepare-update: + if: github.repository == 'Raghav-56/CLIProxyAPI' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + ref: custom + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: '3.x' + - name: Identify maintenance commits + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + - name: Sync official main and prepare release branch + env: + GH_TOKEN: ${{ github.token }} + run: python .github/scripts/update-fork.py diff --git a/.gitignore b/.gitignore index e63d33ae031..b1ab993bfc3 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ _bmad/* _bmad-output/* .gocache/ .gitnexus/ +__pycache__/ # macOS .DS_Store diff --git a/CUSTOM.md b/CUSTOM.md new file mode 100644 index 00000000000..57008807ab7 --- /dev/null +++ b/CUSTOM.md @@ -0,0 +1,78 @@ +# Maintain the custom proxy + +| Branch | Contents | +| --- | --- | +| `main` | Exact official `router-for-me/CLIProxyAPI` main history | +| `custom` | Account console, manual Codex OAuth routing, quota guidance, request observability, and fork maintenance | +| `updates/upstream-vX.Y.Z` | A proposed official release update | + +Use `custom` for development and builds. Keep custom commits out of `main`. +Merge upstream update PRs with a merge commit. Squashing or rebasing loses the upstream ancestry used by later updates. +The fork disables squash and rebase merging to preserve that history. +The repository default branch is `custom` so its maintenance workflows can run. +Upstream-only PR guards and release jobs are restricted to the official repository. +This fork uses `custom-ci` instead, so an upstream translator change does not block a release update. + +## Prepare an official update + +The `update-fork` workflow runs weekly on Monday at 03:17 UTC, or manually from GitHub Actions. +It fast-forwards `main` to official upstream main and prepares the latest stable release update branch against `custom`. +It does not force-push main, merge a PR, switch accounts, or deploy a server. +Repeated runs reuse the existing release PR or update branch. A closed PR is left closed until you choose what to do. + +For a local run, start in a clean checkout with Git, Python 3, and authenticated GitHub CLI: + +```sh +git switch custom +git pull --ff-only origin custom +python .github/scripts/update-fork.py +``` + +A clean merge dispatches `custom-ci` on its branch. A conflict prepares a branch from the official release. +The Actions run summary links to a comparison where you or an AI agent can open a PR. +The scheduled workflow does not create or approve PRs and does not require a repository permission change. +A local run uses your existing GitHub CLI login to open a PR for a new update branch. +Resolve conflicts by merging `origin/custom` into the update branch. Never select an entire side without examining the behavior. +The workflow uses GitHub's repository token. It needs no OpenAI key or personal access token. +AI conflict resolution runs when you ask an agent to work on the update PR. It is not an unattended AI service. + +## Ask an AI agent to resolve an update + +Give the agent this instruction with the update PR URL: + +> Update this custom CLIProxyAPI fork using the release PR. Read AGENTS.md, CUSTOM.md, and docs/account-console.md. Check out the PR branch, merge origin/custom if needed, and resolve conflicts by preserving the behavior of both upstream and the custom features. Review upstream routing and config changes even if Git reports no conflicts. Run the checks below, review the diff for secrets, and update the PR with the results. Do not deploy or change live account selection. + +## Verify an update + +```sh +node --test internal/api/assets/accounts.test.cjs +go test ./... +go build -o cli-proxy-api ./cmd/server +go build -o config-export ./cmd/config-export +``` + +The routing tests must preserve selection of one persistent Codex OAuth account, request-level consistency through retries, +exclusion of other accounts, hot-reload verification, and rejection of manual routing when Home is enabled. +The console tests cover quota windows, model visibility, recommendations, and removal of credentials from displayed summaries. + +Before deployment, exercise account selection and a real response on a private staging proxy. +Record the selected account before a staging check and restore it afterward. +Keep server deployment separate from source updates. Retain the previous binary for rollback. + +## Record a deployment + +Tag the exact tested custom commit as `custom-vX.Y.Z.N`. Build with the commit and version in binary metadata. +Record the tag, full commit, binary SHA-256, upstream release, target host, and deployment time in a deployment record. +An official version tag alone does not identify custom changes. + +The existing oracler installation uses the local release name `account-console-v4`, based on official v8.0.27. +Its old binary reports a dirty tree and cannot prove an exact custom source commit. Preserve it as a rollback artifact. +Do not relabel it as a reproducible clean build. + +Live `config.yaml`, OAuth files, client keys, and management keys stay outside Git. +Commit sanitized templates and scripts only. The installed Windows and oracler client configuration is separate from this source repository. + +## Initial verification note + +On Windows, `TestEnsureClientsWaitsForPreviousTargetClose` fails on both the preserved custom source and untouched official v8.0.27. +The custom Go packages and both binaries pass their checks. Full-suite success is required before deploying an updated build. diff --git a/assets/aicodemirror.png b/assets/aicodemirror.png index b4585bcf3a4..206659e4c12 100644 Binary files a/assets/aicodemirror.png and b/assets/aicodemirror.png differ diff --git a/assets/apikey.png b/assets/apikey.png index 45687b253d8..baff392f892 100644 Binary files a/assets/apikey.png and b/assets/apikey.png differ diff --git a/assets/apimart-en.png b/assets/apimart-en.png index 1b524d91e60..20b63a1618d 100644 Binary files a/assets/apimart-en.png and b/assets/apimart-en.png differ diff --git a/assets/apimart-zh.png b/assets/apimart-zh.png index 853b9417ab9..4758c0fcf23 100644 Binary files a/assets/apimart-zh.png and b/assets/apimart-zh.png differ diff --git a/assets/axisnow.png b/assets/axisnow.png index f1acbdc6ee3..c5ccb1c42d9 100644 Binary files a/assets/axisnow.png and b/assets/axisnow.png differ diff --git a/assets/bestproxy.png b/assets/bestproxy.png index 96f2113cc7b..8397309b5ec 100644 Binary files a/assets/bestproxy.png and b/assets/bestproxy.png differ diff --git a/assets/cubence.png b/assets/cubence.png index c61f12f61ee..8c18bbd461d 100644 Binary files a/assets/cubence.png and b/assets/cubence.png differ diff --git a/assets/fennoai.png b/assets/fennoai.png index 125d08abcf9..11589f4ca6e 100644 Binary files a/assets/fennoai.png and b/assets/fennoai.png differ diff --git a/assets/fluxa-baidu-ai-cloud.png b/assets/fluxa-baidu-ai-cloud.png index 4315c911134..52181d6236a 100644 Binary files a/assets/fluxa-baidu-ai-cloud.png and b/assets/fluxa-baidu-ai-cloud.png differ diff --git a/assets/infistar.png b/assets/infistar.png index bf7261d76c3..0621552fe42 100644 Binary files a/assets/infistar.png and b/assets/infistar.png differ diff --git a/assets/kedaya.jpg b/assets/kedaya.jpg index 72b8ec2667b..ab7322f8629 100644 Binary files a/assets/kedaya.jpg and b/assets/kedaya.jpg differ diff --git a/assets/lingtrue.png b/assets/lingtrue.png index 2ab1a40bd1a..62060265cb2 100644 Binary files a/assets/lingtrue.png and b/assets/lingtrue.png differ diff --git a/assets/logo/antigravity.svg b/assets/logo/antigravity.svg index 784fc63dbed..ee424503e50 100644 --- a/assets/logo/antigravity.svg +++ b/assets/logo/antigravity.svg @@ -1 +1 @@ -Antigravity \ No newline at end of file +Antigravity \ No newline at end of file diff --git a/assets/logo/claude.svg b/assets/logo/claude.svg index e29f3282572..66fe5cf61d8 100644 --- a/assets/logo/claude.svg +++ b/assets/logo/claude.svg @@ -1 +1 @@ -Claude \ No newline at end of file +Claude \ No newline at end of file diff --git a/assets/logo/kimi.svg b/assets/logo/kimi.svg index 5c8451f83c9..3da6a64b7ab 100644 --- a/assets/logo/kimi.svg +++ b/assets/logo/kimi.svg @@ -1,5 +1 @@ - - - - - + \ No newline at end of file diff --git a/assets/logo/meta.svg b/assets/logo/meta.svg index cebc416f166..c17d3d776be 100644 --- a/assets/logo/meta.svg +++ b/assets/logo/meta.svg @@ -1 +1 @@ -Meta +Meta \ No newline at end of file diff --git a/assets/logo/openai.svg b/assets/logo/openai.svg index 78caf4fa20f..f4121e54a94 100644 --- a/assets/logo/openai.svg +++ b/assets/logo/openai.svg @@ -1 +1 @@ -OpenAI \ No newline at end of file +OpenAI \ No newline at end of file diff --git a/assets/logo/xai.svg b/assets/logo/xai.svg index 536e713902f..11d1fb3befb 100644 --- a/assets/logo/xai.svg +++ b/assets/logo/xai.svg @@ -1 +1 @@ -Grok \ No newline at end of file +Grok \ No newline at end of file diff --git a/assets/modelflare.png b/assets/modelflare.png index 91bd54198db..97a7bee3ee2 100644 Binary files a/assets/modelflare.png and b/assets/modelflare.png differ diff --git a/assets/openlux.png b/assets/openlux.png index 88caf23e4b6..adfa746cad5 100644 Binary files a/assets/openlux.png and b/assets/openlux.png differ diff --git a/assets/patewayai.png b/assets/patewayai.png index 5ab845ec860..d2c3f5c7d47 100644 Binary files a/assets/patewayai.png and b/assets/patewayai.png differ diff --git a/assets/poixeai.png b/assets/poixeai.png index 6732d2a0ce4..0dcb1b9012c 100644 Binary files a/assets/poixeai.png and b/assets/poixeai.png differ diff --git a/assets/qiniucloud.png b/assets/qiniucloud.png index 78a46eecfdf..64d7f0d2b7b 100644 Binary files a/assets/qiniucloud.png and b/assets/qiniucloud.png differ diff --git a/assets/rapidproxy.png b/assets/rapidproxy.png index 01c5aa8282d..87ff35e8366 100644 Binary files a/assets/rapidproxy.png and b/assets/rapidproxy.png differ diff --git a/assets/runapi.png b/assets/runapi.png index 7f522975a99..79c15b02719 100644 Binary files a/assets/runapi.png and b/assets/runapi.png differ diff --git a/assets/swiftproxy.png b/assets/swiftproxy.png index f5a44cc945f..cfb69a542b8 100644 Binary files a/assets/swiftproxy.png and b/assets/swiftproxy.png differ diff --git a/cmd/config-export/main.go b/cmd/config-export/main.go new file mode 100644 index 00000000000..62974cf15bf --- /dev/null +++ b/cmd/config-export/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + "gopkg.in/yaml.v3" +) + +func exportConfig(path string, output io.Writer) error { + raw, errRead := os.ReadFile(path) + if errRead != nil { + return fmt.Errorf("read configuration: %w", errRead) + } + canonical, _, errNormalize := config.NormalizeConfigLayout(raw, true) + if errNormalize != nil { + return fmt.Errorf("normalize configuration: %w", errNormalize) + } + var document yaml.Node + if errDecode := yaml.Unmarshal(canonical, &document); errDecode != nil { + return fmt.Errorf("decode configuration: %w", errDecode) + } + if len(document.Content) != 1 || document.Content[0].Kind != yaml.MappingNode { + return fmt.Errorf("configuration must be a mapping") + } + var value map[string]any + if errDecode := document.Content[0].Decode(&value); errDecode != nil { + return fmt.Errorf("decode configuration mapping: %w", errDecode) + } + encoder := json.NewEncoder(output) + encoder.SetIndent("", " ") + if errWrite := encoder.Encode(value); errWrite != nil { + return fmt.Errorf("write configuration: %w", errWrite) + } + return nil +} + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: config-export ") + os.Exit(2) + } + if errExport := exportConfig(os.Args[1], os.Stdout); errExport != nil { + fmt.Fprintln(os.Stderr, errExport) + os.Exit(1) + } +} diff --git a/cmd/config-export/main_test.go b/cmd/config-export/main_test.go new file mode 100644 index 00000000000..8dd1a8e0364 --- /dev/null +++ b/cmd/config-export/main_test.go @@ -0,0 +1,39 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestExportConfigPreservesSelectedAccountAndSecrets(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + raw := "config-version: 8\nserver: {host: '127.0.0.1', port: 8317}\noauth: {auth-dir: '/old/auth'}\nrouting: {codex-oauth-auth-id: 'selected.json', session-affinity: true}\naccess: {api-keys: [synthetic-client-key]}\nmanagement: {secret-key: synthetic-management-key}\n" + if errWrite := os.WriteFile(path, []byte(raw), 0600); errWrite != nil { + t.Fatal(errWrite) + } + var output bytes.Buffer + if errExport := exportConfig(path, &output); errExport != nil { + t.Fatal(errExport) + } + var result struct { + Routing struct { + Selected string `json:"codex-oauth-auth-id"` + Affinity bool `json:"session-affinity"` + } `json:"routing"` + Access struct { + Keys []string `json:"api-keys"` + } `json:"access"` + Management struct { + Key string `json:"secret-key"` + } `json:"management"` + } + if errDecode := json.Unmarshal(output.Bytes(), &result); errDecode != nil { + t.Fatal(errDecode) + } + if result.Routing.Selected != "selected.json" || !result.Routing.Affinity || len(result.Access.Keys) != 1 || result.Access.Keys[0] != "synthetic-client-key" || result.Management.Key != "synthetic-management-key" { + t.Fatalf("configuration fields did not survive export: %s", output.String()) + } +} diff --git a/docs/account-console.md b/docs/account-console.md new file mode 100644 index 00000000000..3e5a7b7ccae --- /dev/null +++ b/docs/account-console.md @@ -0,0 +1,75 @@ +# Choose a Codex account + +Open `/accounts.html` on your CLIProxyAPI server and connect with its management key. +The console shows persistent Codex OAuth accounts, their plans, quota windows, and reset times. +Click **Use this account** to select the subscription used for new Codex OAuth requests. + +The choice is global to this proxy. Each logical request keeps its choice through retries. +Other providers and Codex API-key credentials keep their existing routing behavior. +Account selection does not rewrite OAuth credentials or restart the server. + +All accounts can remain enabled. The selected account is stored in the configuration: + +```yaml +routing: + codex-oauth-auth-id: "codex-account.json" + session-affinity: true +``` + +Use the credential ID returned by `GET /v8/management/credentials`. Do not use an email address as the ID. +An empty `codex-oauth-auth-id` restores the existing routing strategy. +Manual selection is unavailable when CLIProxyAPIHome is enabled. + +Select an account through the management API: + +```http +PUT /v8/management/routing/codex-account +Authorization: Bearer +Content-Type: application/json + +{"auth_id":"codex-account.json"} +``` + +Read the saved and applied choice with `GET /v8/management/routing/codex-account`. +A disabled account must be enabled through the main management dashboard before selection. +A failed save or reload returns an error instead of claiming that the choice is active. +Configuration writes use the existing repository writer and are not crash-atomic. + +Quota refresh works independently of selection and uses server-side token substitution. +The browser receives quota information, not OAuth tokens. Windows are labeled by their actual duration; +missing windows remain unknown. A Go account can report a 30-day window instead of the Plus plan's windows. +Open **Available models** on an account card to inspect its live model catalog. +Selecting an account does not change your coding tool's selected model; use a model available to that subscription. + +The Plus recommendation uses quota snapshots retrieved within five minutes. An account must be enabled, +available, below its limits, and have positive 5-hour and weekly quota with known future reset times. +Among eligible Plus accounts, the console recommends the account whose weekly quota resets first. +Ties prefer more 5-hour quota, then more weekly quota. A reset or failed quota refresh invalidates the snapshot. +Click **Use recommended account** to apply the recommendation. Guidance never changes the saved selection automatically. + +## Inspect actual routing + +**Recent request attempts** shows the account, model, downstream and upstream transport, elapsed time, +time to first token, and input/output/cached-input tokens observed by the execution usage reporter. +Retries can produce multiple attempts. A successful attempt describes executor completion; it does not prove +that a disconnected client received the answer. Unknown token counts or first-token times remain unknown. + +`GET /v8/management/observability/requests/recent` requires management authentication and returns up to 200 +attempt summaries, newest first. History lives in the proxy process and resets on restart or host handoff. +It observes usage through an independent plugin subscription and does not consume the usage queue. +Only an allow-listed summary enters history. Prompts, response text, keys, OAuth tokens, token fingerprints, +and upstream error bodies are excluded. HTTP/SSE downstream transport is distinguished from WebSocket; +upstream transport is reported from the actual executor when known. + +The management key stays in browser memory unless you choose to keep it for that tab. +Disconnect clears it. The standard `/management.html` interface remains available for full configuration. + +## Connect another computer + +Use an authenticated private connection, such as an SSH localhost forward or Tailscale Serve. +Keep the inference and management keys enabled. Direct remote management must follow the server's remote-management policy. +Before moving a proxy between hosts, stop the source and transfer its latest credentials and configuration. +Keep exactly one host refreshing a shared set of OAuth credentials. + +The `config-export ` utility emits canonical v8 JSON for a stopped server's configuration. +Its output contains configured secrets. Redirect it into a protected staging file rather than displaying or committing it. diff --git a/internal/api/assets/accounts.css b/internal/api/assets/accounts.css new file mode 100644 index 00000000000..d6c99b2faf2 --- /dev/null +++ b/internal/api/assets/accounts.css @@ -0,0 +1,8 @@ +:root{color-scheme:light;--bg:#f4f6f8;--surface:#fff;--text:#15232c;--muted:#64737e;--border:#dce3e8;--accent:#166b55;--accent-soft:#eaf5ef;--danger:#b73535;--track:#e9eef1;--shadow:0 5px 22px #142b3b06;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:15px;line-height:1.5} +@media(prefers-color-scheme:dark){:root{color-scheme:dark;--bg:#10171b;--surface:#182229;--text:#e8eef1;--muted:#9cabb5;--border:#2b3a44;--accent:#79d5b7;--accent-soft:#173b31;--danger:#ff9a96;--track:#2b3840;--shadow:none}} +*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text)}[hidden]{display:none!important}button,input{font:inherit}button,a,input{outline-offset:4px}button:focus-visible,a:focus-visible,input:focus-visible{outline:2px solid var(--accent)}a{color:var(--accent);text-decoration:none}button{cursor:pointer}button:disabled{cursor:wait;opacity:.65}button,.button{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:42px;padding:10px 16px;border-radius:9px;border:1px solid transparent;font-weight:600;white-space:nowrap;text-align:center}.primary{background:var(--accent);color:var(--surface)}.secondary{background:var(--surface);border-color:var(--border);color:var(--text)}.quiet{background:transparent;color:var(--muted);padding:8px}.topbar{min-height:84px;padding:18px max(24px,calc((100vw - 1240px)/2));display:flex;align-items:center;gap:36px;border-bottom:1px solid var(--border);background:var(--surface)}.brand{display:flex;align-items:center;gap:12px;color:var(--text);font-weight:600;line-height:1.3;min-width:200px}.brand-mark{width:39px;height:39px;display:grid;place-items:center;background:var(--accent-soft);color:var(--accent);border-radius:10px;font-size:14px}.brand-sub{display:block;font-size:12px;font-weight:400;color:var(--muted);margin-top:3px}nav{display:flex;gap:24px;flex:1}nav a{font-size:14px;color:var(--muted)}nav a[aria-current]{color:var(--text);font-weight:600}main{max-width:1240px;padding:44px 24px 30px;margin:auto}.intro{display:flex;justify-content:space-between;align-items:center;gap:30px;margin-bottom:34px}.eyebrow{font-size:11px;font-weight:600;color:var(--accent);margin:0 0 12px}h1{font-size:clamp(28px,4vw,40px);line-height:1.15;margin:0 0 14px;font-weight:600}h2{font-size:19px;margin:0 0 5px;font-weight:600}.subtitle{margin:0;color:var(--muted);max-width:620px}.host{display:flex;align-items:center;gap:11px;background:var(--surface);border:1px solid var(--border);padding:12px 17px;border-radius:11px;min-width:160px;max-width:260px}.host strong{display:block;font-size:14px;overflow-wrap:anywhere}.host-dot,.state-dot{width:8px;height:8px;border-radius:50%;background:var(--accent);flex-shrink:0}.muted{color:var(--muted)}.small{font-size:12px}.overview{display:grid;grid-template-columns:1.6fr 1fr .8fr .9fr;gap:16px;background:var(--surface);padding:23px 25px;border:1px solid var(--border);border-radius:13px;margin-bottom:36px;box-shadow:var(--shadow)}.overview>div{min-width:0}.overview strong{display:block;font-size:14px;font-weight:600;margin-top:7px;overflow-wrap:anywhere}.section-heading{display:flex;align-items:center;justify-content:space-between;gap:24px;margin-bottom:20px}.section-heading p{font-size:13px;margin:5px 0 0}.actions{display:flex;gap:10px;flex-wrap:wrap}.account-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}.account-card{background:var(--surface);border:1px solid var(--border);border-radius:14px;overflow:hidden;box-shadow:var(--shadow);display:flex;flex-direction:column;min-width:0}.account-card.selected{border:2px solid var(--accent)}.card-body{padding:23px 21px;flex:1}.account-top{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:20px}.avatar{width:38px;height:38px;background:var(--track);display:grid;place-items:center;border-radius:11px;font-weight:600;font-size:12px}.badge{font-size:11px;padding:4px 9px;background:var(--track);border-radius:6px;font-weight:600}.badge.selected{color:var(--accent);background:var(--accent-soft)}.account-card h3{font-size:15px;line-height:1.5;margin:0;overflow-wrap:anywhere;font-weight:600}.plan{margin:5px 0 19px;color:var(--muted);font-size:12px;text-transform:capitalize}.quota-window{padding-top:17px;margin-top:17px;border-top:1px solid var(--border)}.quota-heading{display:flex;align-items:center;justify-content:space-between;font-size:12px;gap:10px}.quota-value{font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap}.quota-track{height:6px;background:var(--track);border-radius:6px;margin:10px 0;overflow:hidden}.quota-fill{height:100%;background:var(--accent);width:0}.quota-window.low .quota-fill{background:var(--danger)}.quota-reset{font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums}.quota-unknown{padding:22px 0;min-height:132px;color:var(--muted);font-size:13px}.quota-error{color:var(--danger);font-size:12px;margin:13px 0 0}.quota-time{color:var(--muted);font-size:11px;margin:16px 0 0}.card-footer{padding:14px 20px;border-top:1px solid var(--border);display:flex;gap:10px;align-items:center}.card-footer .select-account{flex:1}.refresh-quota{background:transparent;color:var(--muted);font-size:12px;padding:10px}.selected-button{background:var(--accent-soft);color:var(--accent)}.status-line{font-size:12px;color:var(--muted);margin:12px 0 0;overflow-wrap:anywhere}.status-line.error,.error{color:var(--danger)}.footnote{display:flex;justify-content:space-between;gap:24px;font-size:12px;color:var(--muted);padding:26px 1px}.notice,.page-error{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:13px 17px;font-size:13px;margin:0 0 18px}.notice{color:var(--accent);border-color:var(--accent)}.login-panel{max-width:670px;margin:50px auto;background:var(--surface);border:1px solid var(--border);border-radius:15px;padding:30px}.login-panel p{font-size:13px;color:var(--muted)}.login-panel label{display:block;font-size:13px;margin-top:24px}.login-fields{display:flex;gap:10px;margin-top:8px}.login-fields input{min-width:0;flex:1;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:9px;padding:11px 13px}.login-panel .remember{display:flex;gap:9px;align-items:center;color:var(--muted);margin-top:14px;font-size:12px}.remember input{accent-color:var(--accent)}.empty{background:var(--surface);border:1px dashed var(--border);text-align:center;padding:45px;border-radius:14px}.empty p{color:var(--muted);font-size:13px}.error{font-size:13px}.account-card.skeleton{min-height:330px}.skeleton .card-body{color:var(--muted)} +@media(max-width:1050px){.account-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.overview{grid-template-columns:repeat(2,minmax(0,1fr));gap:22px}.topbar{gap:24px}.brand{min-width:180px}} +@media(max-width:640px){.topbar{padding:14px 18px;gap:12px;flex-wrap:wrap}.brand{min-width:0;flex:1}.topbar nav{order:3;flex-basis:100%;gap:20px;padding:5px 0 0}.topbar nav a{font-size:12px}main{padding:29px 18px 20px}.intro{align-items:flex-start;flex-direction:column;gap:20px;margin-bottom:25px}.host{min-width:0;width:100%;max-width:none;padding:10px 14px}.section-heading{align-items:flex-start;flex-direction:column;gap:15px}.actions{width:100%}.actions button{flex:1;padding:10px 12px;font-size:12px}.overview{padding:18px;gap:20px;margin-bottom:26px}.account-grid{grid-template-columns:1fr}.login-panel{margin:20px 0;padding:22px}.login-fields{flex-direction:column}.footnote{flex-direction:column;gap:12px}.card-body{padding:22px}.card-footer{padding:14px 20px}} +.model-details{margin-top:18px;border-top:1px solid var(--border);padding-top:13px;font-size:12px}.model-details summary{color:var(--muted);cursor:pointer}.model-list{padding-left:17px;margin:10px 0 0;overflow-wrap:anywhere}.model-list li{margin:5px 0} +.recommendation{display:flex;align-items:center;justify-content:space-between;gap:24px;border:1px solid var(--border);background:var(--accent-soft);border-radius:13px;padding:22px 25px;margin:0 0 30px}.recommendation h2{font-size:17px}.recommendation p{margin:8px 0 0;font-size:13px;overflow-wrap:anywhere}.recommendation .eyebrow{font-size:10px;margin:0 0 8px}.recommendation-badge{font-size:11px;color:var(--accent);margin:0 0 8px}.diagnostics{margin-top:18px;border-top:1px solid var(--border);padding-top:30px}.diagnostics .eyebrow{margin:0 0 8px}.request-list{display:grid;gap:12px;max-height:620px;overflow:auto;padding:2px}.request-row{border:1px solid var(--border);background:var(--surface);border-radius:11px;padding:16px 18px;min-width:0}.request-title{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.request-title strong{font-size:14px;overflow-wrap:anywhere}.request-title>.muted{margin-left:auto}.request-result{color:var(--accent);font-size:11px}.request-result.failed{color:var(--danger)}.request-route{font-size:12px;overflow-wrap:anywhere;margin:8px 0;color:var(--muted)}.request-metrics{display:grid;grid-template-columns:1.4fr repeat(4,1fr);gap:12px;margin:14px 0 0}.request-metrics>div{min-width:0}.request-metrics dt{font-size:11px;color:var(--muted)}.request-metrics dd{margin:3px 0 0;font-size:12px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere} +@media(max-width:640px){.recommendation{align-items:stretch;flex-direction:column;padding:18px;gap:14px}.recommendation>button{white-space:normal}.request-metrics{grid-template-columns:repeat(2,minmax(0,1fr));gap:13px}.request-title>.muted{margin-left:0}.request-list{max-height:none;overflow:visible}.request-row{padding:15px}} diff --git a/internal/api/assets/accounts.html b/internal/api/assets/accounts.html new file mode 100644 index 00000000000..aacd3530151 --- /dev/null +++ b/internal/api/assets/accounts.html @@ -0,0 +1,58 @@ + + + + + + Accounts · CLIProxyAPI + + + + +
+ CLIProxyAPIAccount console + + +
+
+
+

YOUR CODEX SUBSCRIPTIONS

Choose your Codex account.

Select the account your coding tools use. Check its limits before you switch.

+
Proxy host
+
+ + +
+ + diff --git a/internal/api/assets/accounts.js b/internal/api/assets/accounts.js new file mode 100644 index 00000000000..ec67e21840b --- /dev/null +++ b/internal/api/assets/accounts.js @@ -0,0 +1,503 @@ +(function () { + 'use strict'; + + function numeric(value) { + return typeof value === 'number' && Number.isFinite(value) ? value : null; + } + + function windowLabel(seconds) { + if (seconds === 18000) return '5-hour quota'; + if (seconds === 604800) return 'Weekly quota'; + if (seconds && seconds % 86400 === 0) return `${seconds / 86400}-day quota`; + if (seconds && seconds % 3600 === 0) return `${seconds / 3600}-hour quota`; + return seconds ? `${Math.round(seconds / 60)}-minute quota` : 'Quota window'; + } + + function parseQuota(payload, fetchedAt) { + if (!payload || typeof payload !== 'object' || !payload.rate_limit || typeof payload.rate_limit !== 'object') { + throw new Error('The provider returned an unrecognized quota response.'); + } + const windows = []; + for (const key of ['primary_window', 'secondary_window']) { + const source = payload.rate_limit[key]; + if (!source || typeof source !== 'object') continue; + const used = numeric(source.used_percent); + if (used === null || used < 0) continue; + const seconds = numeric(source.limit_window_seconds); + const resetAt = numeric(source.reset_at); + const resetAfter = numeric(source.reset_after_seconds); + const resetsAt = resetAt !== null && resetAt > 0 ? resetAt * 1000 : + resetAfter !== null && resetAfter >= 0 ? fetchedAt + resetAfter * 1000 : null; + windows.push({ + key, + seconds: seconds !== null && seconds > 0 ? seconds : null, + label: windowLabel(seconds !== null && seconds > 0 ? seconds : null), + remaining: Math.max(0, 100 - used), + resetsAt, + }); + } + return { + windows, + plan: typeof payload.plan_type === 'string' ? payload.plan_type : '', + fetchedAt, + limitReached: payload.rate_limit.limit_reached === true, + }; + } + + function codexAccounts(files) { + if (!Array.isArray(files)) throw new Error('The proxy returned an invalid account list.'); + return files.filter(file => file && String(file.provider || file.type).toLowerCase() === 'codex' && + file.account_type === 'oauth' && !file.runtime_only && typeof file.id === 'string' && + typeof file.name === 'string' && typeof file.auth_index === 'string').map(file => ({ + id: file.id, + name: file.name, + authIndex: file.auth_index, + email: typeof file.email === 'string' && file.email ? file.email : file.name, + label: typeof file.label === 'string' ? file.label : '', + disabled: file.disabled === true, + unavailable: file.unavailable === true, + accountId: typeof file.id_token?.chatgpt_account_id === 'string' ? file.id_token.chatgpt_account_id : '', + plan: typeof file.id_token?.plan_type === 'string' ? file.id_token.plan_type : '', + })); + } + + function accountModels(payload) { + if (!Array.isArray(payload?.models)) throw new Error('The provider returned an invalid model catalog.'); + const slugs = payload.models.filter(model => model && model.supported_in_api === true && model.visibility === 'list' && typeof model.slug === 'string').map(model => model.slug); + if (!slugs.length) throw new Error('No visible API models were reported for this account.'); + return [...new Set(slugs)]; + } + + function recommendPlusAccount(accounts, quotas, now) { + const candidates = []; + for (const account of accounts) { + const quota = quotas.get(account.id); + if (account.disabled || account.unavailable || !quota || quota.limitReached || + (quota.plan || account.plan).toLowerCase() !== 'plus' || + quota.fetchedAt > now || now - quota.fetchedAt > 300000) continue; + const fiveHour = quota.windows.find(window => window.seconds === 18000); + const weekly = quota.windows.find(window => window.seconds === 604800); + if (!fiveHour || !weekly || fiveHour.remaining <= 0 || weekly.remaining <= 0 || + !fiveHour.resetsAt || !weekly.resetsAt || fiveHour.resetsAt <= now || weekly.resetsAt <= now) continue; + candidates.push({account, fiveHour, weekly}); + } + candidates.sort((a, b) => a.weekly.resetsAt - b.weekly.resetsAt || + b.fiveHour.remaining - a.fiveHour.remaining || b.weekly.remaining - a.weekly.remaining || + a.account.id.localeCompare(b.account.id)); + return candidates[0] || null; + } + + function recentRequests(payload) { + if (!Array.isArray(payload?.requests)) throw new Error('The proxy returned invalid request history.'); + return payload.requests.slice(0, 200).filter(row => row && typeof row.id === 'string' && typeof row.model === 'string'); + } + + if (typeof module !== 'undefined' && module.exports) module.exports = { parseQuota, windowLabel, codexAccounts, accountModels, recommendPlusAccount, recentRequests }; + if (typeof document === 'undefined') return; + + const $ = id => document.getElementById(id); + const storageKey = 'cliproxy-account-console-key'; + let managementKey = ''; + let accounts = []; + let policy = null; + let busy = false; + const quotas = new Map(); + const loadingQuota = new Set(); + const quotaErrors = new Map(); + const models = new Map(); + const loadingModels = new Set(); + const modelErrors = new Map(); + const expandedModels = new Set(); + let diagnosticsLoading = false; + let diagnosticsRows = []; + let visibleRequests = 20; + $('hostname').textContent = document.body.dataset.host || location.hostname; + + function node(tag, className, text) { + const element = document.createElement(tag); + if (className) element.className = className; + if (text !== undefined) element.textContent = text; + return element; + } + + function showError(message) { + $('page-error').textContent = message; + $('page-error').hidden = !message; + } + + function notify(message) { + $('notice').textContent = message; + $('notice').hidden = !message; + } + + function disconnect() { + managementKey = ''; + accounts = []; + policy = null; + quotas.clear(); + quotaErrors.clear(); + models.clear(); + modelErrors.clear(); + expandedModels.clear(); + diagnosticsRows = []; + visibleRequests = 20; + try { sessionStorage.removeItem(storageKey); } catch (_) { /* Storage may be unavailable. */ } + $('management-key').value = ''; + $('workspace').hidden = true; + $('login-panel').hidden = false; + $('logout').hidden = true; + $('account-grid').replaceChildren(); + $('request-list').replaceChildren(); + } + + async function api(path, options) { + const response = await fetch('/v8/management' + path, { + ...options, + headers: { Authorization: 'Bearer ' + managementKey, 'Content-Type': 'application/json' }, + cache: 'no-store', + }); + const data = await response.json().catch(() => null); + if (response.status === 401) { + disconnect(); + throw new Error('The management key was rejected. Connect again with the correct key.'); + } + if (!response.ok) { + throw new Error(typeof data?.error === 'string' ? data.error : `The proxy returned HTTP ${response.status}.`); + } + return data; + } + + function dateLabel(timestamp) { + if (!timestamp || !Number.isFinite(new Date(timestamp).getTime())) return 'Reset time unknown'; + const date = new Date(timestamp); + const absolute = new Intl.DateTimeFormat(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' }).format(date); + const minutes = Math.max(0, Math.ceil((timestamp - Date.now()) / 60000)); + const relative = minutes < 1 ? 'now' : minutes < 60 ? `in ${minutes}m` : + minutes < 1440 ? `in ${Math.ceil(minutes / 60)}h` : `in ${Math.ceil(minutes / 1440)}d`; + return `Resets ${absolute} · ${relative}`; + } + + function quotaWindow(window) { + const section = node('div', 'quota-window' + (window.remaining <= 15 ? ' low' : '')); + const heading = node('div', 'quota-heading'); + heading.append(node('span', '', window.label), node('span', 'quota-value', `${Math.round(window.remaining)}% left`)); + const track = node('div', 'quota-track'); + track.setAttribute('role', 'progressbar'); + track.setAttribute('aria-label', `${window.label} remaining`); + track.setAttribute('aria-valuemin', '0'); + track.setAttribute('aria-valuemax', '100'); + track.setAttribute('aria-valuenow', String(Math.min(100, window.remaining))); + const fill = node('div', 'quota-fill'); + fill.style.width = Math.min(100, window.remaining) + '%'; + track.append(fill); + section.append(heading, track, node('div', 'quota-reset', dateLabel(window.resetsAt))); + return section; + } + + function renderRecommendation(updateCards = true) { + const selectedId = policy?.mode === 'manual' ? policy.auth_id : null; + const recommendation = recommendPlusAccount(accounts, quotas, Date.now()); + $('recommendation-title').textContent = recommendation ? + (recommendation.account.id === selectedId ? 'Your selected Plus account is recommended' : 'A Plus account is ready for your next task') : 'Check Plus quotas for a recommendation'; + $('recommendation-detail').textContent = recommendation ? + `${recommendation.account.email} · ${Math.round(recommendation.fiveHour.remaining)}% of 5-hour quota left · ${Math.round(recommendation.weekly.remaining)}% weekly left. ${dateLabel(recommendation.weekly.resetsAt)}.` : + 'Recommendations need usable 5-hour and weekly quota checked within five minutes. Refresh after a quota reset.'; + $('use-recommended').hidden = !recommendation || recommendation.account.id === selectedId; + $('use-recommended').disabled = busy; + $('use-recommended').onclick = recommendation ? () => { + const current = recommendPlusAccount(accounts, quotas, Date.now()); + if (current?.account.id !== recommendation.account.id) { + renderRecommendation(); + notify('The recommendation changed. Refresh quotas before choosing an account.'); + return; + } + selectAccount(current.account); + } : null; + if (updateCards) { + for (const card of $('account-grid').children) { + let badge = card.querySelector('.recommendation-badge'); + if (recommendation?.account.id !== card.dataset.accountId) { + badge?.remove(); + } else if (!badge) { + badge = node('p', 'recommendation-badge', 'Recommended · weekly quota resets first'); + card.querySelector('.account-top').after(badge); + } + } + } + return recommendation; + } + + function render() { + if (!managementKey || !policy) return; + const selected = accounts.find(account => policy.mode === 'manual' && account.id === policy.auth_id); + $('selected-account').textContent = selected?.email || (policy.mode === 'manual' ? 'Selected account is missing' : 'No manual selection'); + $('routing-mode').textContent = policy.mode === 'manual' ? 'Manual · one account' : 'Existing routing strategy'; + $('account-count').textContent = String(accounts.length); + const checked = Array.from(quotas.values()).map(quota => quota.fetchedAt); + $('checked-at').textContent = checked.length ? new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(Math.max(...checked)) : 'Not yet'; + $('refresh-accounts').disabled = busy; + $('refresh-quotas').disabled = busy || loadingQuota.size > 0 || accounts.length === 0; + $('empty').hidden = accounts.length !== 0; + const recommendation = renderRecommendation(false); + const cards = []; + for (const account of accounts) { + const isSelected = selected?.id === account.id; + const card = node('article', 'account-card' + (isSelected ? ' selected' : '')); + card.dataset.accountId = account.id; + const body = node('div', 'card-body'); + const top = node('div', 'account-top'); + const avatarText = account.email.split('@')[0].split(/[._-]/).map(word => word.charAt(0)).slice(0, 2).join('').toUpperCase(); + const badgeText = isSelected ? 'Selected' : account.disabled ? 'Disabled' : account.unavailable ? 'Unavailable' : 'Ready'; + top.append(node('div', 'avatar', avatarText || 'CX'), node('span', 'badge' + (isSelected ? ' selected' : ''), badgeText)); + const quota = quotas.get(account.id); + if (recommendation?.account.id === account.id) body.append(node('p', 'recommendation-badge', 'Recommended · weekly quota resets first')); + body.append(top, node('h3', '', account.email), node('p', 'plan', `Codex · ${quota?.plan || account.plan || 'Plan unknown'}`)); + if (quota?.windows.length) { + for (const window of quota.windows) body.append(quotaWindow(window)); + } else { + body.append(node('div', 'quota-unknown', loadingQuota.has(account.id) ? 'Checking quota…' : + quota ? 'The provider did not report a quota window.' : 'Quota has not been checked yet.')); + } + if (quotaErrors.has(account.id)) body.append(node('p', 'quota-error', quotaErrors.get(account.id))); + if (quota) body.append(node('p', 'quota-time', `Checked ${new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(quota.fetchedAt)}`)); + const modelDetails = node('details', 'model-details'); + modelDetails.open = expandedModels.has(account.id); + modelDetails.append(node('summary', '', 'Available models')); + if (models.has(account.id)) { + const list = node('ul', 'model-list'); + for (const model of models.get(account.id)) list.append(node('li', '', model)); + modelDetails.append(list); + } else { + modelDetails.append(node('p', 'muted small', loadingModels.has(account.id) ? 'Checking model access…' : modelErrors.get(account.id) || 'Open this section to check model access.')); + if (modelErrors.has(account.id)) { + const retry = node('button', 'quiet', 'Retry model check'); + retry.addEventListener('click', () => refreshModels(account)); + modelDetails.append(retry); + } + } + modelDetails.addEventListener('toggle', () => { + if (!modelDetails.isConnected) return; + if (modelDetails.open) { + expandedModels.add(account.id); + if (!models.has(account.id) && !loadingModels.has(account.id) && !modelErrors.has(account.id)) refreshModels(account); + } else expandedModels.delete(account.id); + }); + body.append(modelDetails); + if ((quota?.plan || account.plan) === 'go') body.append(node('p', 'status-line', 'Go has a different model catalog. Choose a listed model in your coding tool.')); + if (account.disabled) body.append(node('p', 'status-line', 'Enable this credential in Advanced settings before selecting it.')); + else if (account.unavailable) body.append(node('p', 'status-line error', 'The proxy reports this account as unavailable.')); + const footer = node('div', 'card-footer'); + const select = node('button', 'select-account ' + (isSelected ? 'selected-button' : 'primary'), isSelected ? 'Currently selected' : 'Use this account'); + select.disabled = busy || account.disabled || isSelected; + select.setAttribute('aria-label', isSelected ? `Selected account ${account.email}` : `Use account ${account.email}`); + select.addEventListener('click', () => selectAccount(account)); + const refresh = node('button', 'refresh-quota', loadingQuota.has(account.id) ? 'Checking…' : 'Refresh'); + refresh.disabled = busy || loadingQuota.has(account.id); + refresh.setAttribute('aria-label', `Refresh quota for ${account.email}`); + refresh.addEventListener('click', () => refreshQuota(account)); + footer.append(select, refresh); + card.append(body, footer); + cards.push(card); + } + $('account-grid').replaceChildren(...cards); + } + + async function loadAccounts() { + const [list, routing, server] = await Promise.all([api('/credentials'), api('/routing/codex-account'), api('/server/info')]); + const parsed = codexAccounts(list?.files); + if (!routing || !['manual', 'automatic'].includes(routing.mode)) throw new Error('The proxy returned an invalid routing choice.'); + accounts = parsed; + policy = routing; + if (typeof server?.hostname === 'string' && server.hostname) $('hostname').textContent = server.hostname; + render(); + } + + async function selectAccount(account) { + if (busy) return; + busy = true; + showError(''); + notify(`Selecting ${account.email}…`); + render(); + try { + await api('/routing/codex-account', { method: 'PUT', body: JSON.stringify({ auth_id: account.id }) }); + await loadAccounts(); + if (policy.mode !== 'manual' || policy.auth_id !== account.id || policy.applied === false) { + throw new Error('The selected account has not reached the running proxy. Refresh accounts and try again.'); + } + notify(`${account.email} is selected. Your tools keep the same proxy address.`); + } catch (error) { + notify(''); + showError(error.message); + try { if (managementKey) await loadAccounts(); } catch (_) { /* Preserve the original operation error. */ } + } finally { + busy = false; + render(); + } + } + + async function refreshQuota(account) { + if (loadingQuota.has(account.id)) return; + loadingQuota.add(account.id); + quotaErrors.delete(account.id); + render(); + try { + const headers = { Authorization: 'Bearer $TOKEN$', Accept: 'application/json' }; + if (account.accountId) headers['ChatGPT-Account-Id'] = account.accountId; + const result = await api('/requests/api-call', { method: 'POST', body: JSON.stringify({ + auth_index: account.authIndex, + method: 'GET', + url: 'https://chatgpt.com/backend-api/wham/usage', + header: headers, + }) }); + if (result?.status_code !== 200) throw new Error(`Quota check returned HTTP ${result?.status_code || 'unknown'}. You can retry or refresh the OAuth login in Advanced settings.`); + let payload; + try { payload = JSON.parse(result.body); } catch (_) { throw new Error('The provider returned an invalid quota response.'); } + const quota = parseQuota(payload, Date.now()); + if (managementKey && accounts.some(current => current.id === account.id)) quotas.set(account.id, quota); + } catch (error) { + if (managementKey) { + quotas.delete(account.id); + quotaErrors.set(account.id, error.message); + } + } finally { + loadingQuota.delete(account.id); + render(); + } + } + + async function refreshModels(account) { + if (loadingModels.has(account.id)) return; + loadingModels.add(account.id); + modelErrors.delete(account.id); + render(); + try { + const headers = { Authorization: 'Bearer $TOKEN$', Accept: 'application/json' }; + if (account.accountId) headers['ChatGPT-Account-Id'] = account.accountId; + const result = await api('/requests/api-call', {method:'POST', body:JSON.stringify({ + auth_index:account.authIndex, method:'GET', + url:'https://chatgpt.com/backend-api/codex/models?client_version=0.162.1', header:headers, + })}); + if (result?.status_code !== 200) throw new Error(`Model check returned HTTP ${result?.status_code || 'unknown'}.`); + const slugs = accountModels(JSON.parse(result.body)); + if (managementKey) models.set(account.id, slugs); + } catch (error) { + if (managementKey) modelErrors.set(account.id, error.message); + } finally { + loadingModels.delete(account.id); + render(); + } + } + + async function connect() { + const submit = $('login-form').querySelector('button'); + submit.disabled = true; + submit.textContent = 'Connecting…'; + $('login-error').hidden = true; + try { + await loadAccounts(); + $('login-panel').hidden = true; + $('workspace').hidden = false; + $('logout').hidden = false; + if ($('remember').checked) { + try { sessionStorage.setItem(storageKey, managementKey); } catch (_) { /* Connected without storage. */ } + } + $('management-key').value = ''; + await Promise.all([Promise.all(accounts.map(account => refreshQuota(account))), refreshDiagnostics()]); + } catch (error) { + $('login-error').textContent = error.message; + $('login-error').hidden = false; + } finally { + submit.disabled = false; + submit.textContent = 'Connect'; + } + } + + function metric(value, suffix = '') { + return typeof value === 'number' && Number.isFinite(value) ? new Intl.NumberFormat().format(Math.round(value)) + suffix : 'Unknown'; + } + + function renderRequests(rows) { + const cards = rows.slice(0, visibleRequests).map(row => { + const card = node('article', 'request-row'); + const title = node('div', 'request-title'); + const timestamp = new Date(row.requested_at); + const time = Number.isFinite(timestamp.getTime()) ? new Intl.DateTimeFormat(undefined, {hour:'2-digit', minute:'2-digit', second:'2-digit'}).format(timestamp) : 'Time unknown'; + title.append(node('strong', '', row.model), node('span', row.success ? 'request-result' : 'request-result failed', row.success ? 'Completed' : 'Failed'), node('span', 'muted small', time)); + const route = node('p', 'request-route'); + const knownAccount = accounts.find(account => account.id === row.auth_id); + route.textContent = `${knownAccount?.email || row.account || row.auth_id || 'Account unknown'} · ${row.provider || 'Provider unknown'}`; + const metrics = node('dl', 'request-metrics'); + const pairs = [ + ['Transport', `${row.downstream_transport || 'unknown'} → ${row.upstream_transport || 'unknown'}`], + ['Duration', metric(row.latency_ms, ' ms')], + ['First token', metric(row.ttft_ms, ' ms')], + ['Cached input', row.usage_known ? metric(row.cached_input_tokens) : 'Unknown'], + ['Input / output', row.usage_known ? `${metric(row.input_tokens)} / ${metric(row.output_tokens)}` : 'Unknown'], + ]; + for (const [label, value] of pairs) { + const pair = node('div'); + pair.append(node('dt', '', label), node('dd', '', value)); + metrics.append(pair); + } + card.append(title, route, metrics); + return card; + }); + $('request-list').replaceChildren(...cards); + $('requests-empty').hidden = rows.length !== 0; + $('more-requests').hidden = rows.length <= visibleRequests; + } + + async function refreshDiagnostics() { + if (!managementKey || diagnosticsLoading) return; + diagnosticsLoading = true; + $('refresh-requests').disabled = true; + $('requests-error').hidden = true; + try { + const payload = await api('/observability/requests/recent'); + if (!managementKey) return; + diagnosticsRows = recentRequests(payload); + renderRequests(diagnosticsRows); + $('requests-host').textContent = payload.host || $('hostname').textContent; + $('requests-updated').textContent = `Updated ${new Intl.DateTimeFormat(undefined, {hour:'2-digit', minute:'2-digit', second:'2-digit'}).format(Date.now())}`; + } catch (error) { + if (managementKey) { + $('requests-error').textContent = error.message; + $('requests-error').hidden = false; + } + } finally { + diagnosticsLoading = false; + $('refresh-requests').disabled = false; + } + } + + $('login-form').addEventListener('submit', event => { + event.preventDefault(); + managementKey = $('management-key').value.trim(); + if (managementKey) connect(); + }); + $('logout').addEventListener('click', disconnect); + $('refresh-accounts').addEventListener('click', async () => { + busy = true; + showError(''); + render(); + try { await loadAccounts(); } catch (error) { showError(error.message); } + finally { busy = false; render(); } + }); + $('refresh-quotas').addEventListener('click', () => Promise.all(accounts.map(account => refreshQuota(account)))); + $('refresh-requests').addEventListener('click', refreshDiagnostics); + $('more-requests').addEventListener('click', () => { + visibleRequests = Math.min(200, visibleRequests + 20); + renderRequests(diagnosticsRows); + }); + setInterval(() => { + if (managementKey && !document.hidden) { + renderRecommendation(); + refreshDiagnostics(); + } + }, 15000); + try { managementKey = sessionStorage.getItem(storageKey) || ''; } catch (_) { /* Start disconnected. */ } + if (managementKey) { + $('remember').checked = true; + connect(); + } +})(); diff --git a/internal/api/assets/accounts.test.cjs b/internal/api/assets/accounts.test.cjs new file mode 100644 index 00000000000..e80dfa546e4 --- /dev/null +++ b/internal/api/assets/accounts.test.cjs @@ -0,0 +1,91 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const {parseQuota, codexAccounts, accountModels, recommendPlusAccount, recentRequests} = require('./accounts.js'); + +test('quota preserves a weekly primary window and missing secondary', () => { + assert.deepEqual(parseQuota({plan_type:'plus', rate_limit:{primary_window:{used_percent:37,limit_window_seconds:604800,reset_at:2000000000},secondary_window:null}}, 1000), { + windows:[{key:'primary_window',seconds:604800,label:'Weekly quota',remaining:63,resetsAt:2000000000000}], + plan:'plus',fetchedAt:1000,limitReached:false, + }); +}); + +test('missing percentages are unknown rather than zero consumption', () => { + assert.deepEqual(parseQuota({rate_limit:{primary_window:{used_percent:null,limit_window_seconds:18000}}},1000).windows, []); + assert.throws(() => parseQuota({error:'provider failed'},1000), /unrecognized/); +}); + +test('relative reset is based on retrieval and depleted quota remains zero', () => { + assert.deepEqual(parseQuota({rate_limit:{primary_window:{used_percent:110,limit_window_seconds:18000,reset_after_seconds:20},limit_reached:true}}, 1000), { + windows:[{key:'primary_window',seconds:18000,label:'5-hour quota',remaining:0,resetsAt:21000}], + plan:'',fetchedAt:1000,limitReached:true, + }); +}); + +test('account cards exclude API keys and other providers and never return credentials', () => { + const files = [ + {id:'a',name:'a.json',auth_index:'index',provider:'codex',account_type:'oauth',email:'a@example.test',access_token:'secret',id_token:{chatgpt_account_id:'account',plan_type:'plus'}}, + {id:'b',name:'b.json',auth_index:'b',provider:'codex',account_type:'apikey'}, + {id:'c',name:'c.json',auth_index:'c',provider:'claude',account_type:'oauth'}, + {id:'d',name:'d.json',auth_index:'d',provider:'codex',account_type:'oauth',runtime_only:true}, + ]; + assert.deepEqual(codexAccounts(files), [{id:'a',name:'a.json',authIndex:'index',email:'a@example.test',label:'',disabled:false,unavailable:false,accountId:'account',plan:'plus'}]); +}); + +test('model catalog shows only visible models supported by this account', () => { + assert.deepEqual(accountModels({models:[ + {slug:'gpt-6-luna',visibility:'list',supported_in_api:true}, + {slug:'gpt-reserve',visibility:'hide',supported_in_api:true}, + {slug:'gpt-6.1-sol',visibility:'list',supported_in_api:false}, + ]}), ['gpt-6-luna']); + assert.throws(() => accountModels({models:[]}), /No visible/); +}); + +const recommendationTime = 2000000000000; +const recommendationAccounts = [ + {id:'persona',plan:'plus',disabled:false,unavailable:false}, + {id:'second',plan:'plus',disabled:false,unavailable:false}, + {id:'go',plan:'go',disabled:false,unavailable:false}, +]; +function quotaFor(fiveHourLeft, weeklyLeft, weeklyResetAfter, plan = 'plus', fetchedAt = recommendationTime) { + return parseQuota({plan_type:plan, rate_limit:{ + primary_window:{used_percent:100-fiveHourLeft,limit_window_seconds:18000,reset_after_seconds:3600}, + secondary_window:{used_percent:100-weeklyLeft,limit_window_seconds:604800,reset_after_seconds:weeklyResetAfter}, + }}, fetchedAt); +} + +test('recommendation uses usable Plus quota with earliest weekly reset without mutating selection', () => { + const quota = new Map([ + ['persona',quotaFor(85,95,172800)], + ['second',quotaFor(40,30,3600)], + ['go',quotaFor(100,100,60,'go')], + ]); + const before = JSON.stringify(recommendationAccounts); + const suggested = recommendPlusAccount(recommendationAccounts, quota, recommendationTime); + assert.equal(suggested.account.id, 'second'); + assert.equal(suggested.fiveHour.remaining, 40); + assert.equal(JSON.stringify(recommendationAccounts), before); +}); + +test('depleted, unavailable, disabled, stale and reset-crossed accounts are excluded', () => { + for (const badQuota of [quotaFor(0,40,3600), quotaFor(40,0,3600), + quotaFor(40,40,3600,'plus',recommendationTime-300001), + quotaFor(40,40,0)]) { + const quota = new Map([['persona',badQuota]]); + assert.equal(recommendPlusAccount(recommendationAccounts,quota,recommendationTime), null); + } + const quota = new Map([['persona',quotaFor(40,40,3600)]]); + assert.equal(recommendPlusAccount([{...recommendationAccounts[0],disabled:true}],quota,recommendationTime), null); + assert.equal(recommendPlusAccount([{...recommendationAccounts[0],unavailable:true}],quota,recommendationTime), null); +}); + +test('unknown windows or reset times do not become a recommendation', () => { + const quota = new Map([['persona',parseQuota({plan_type:'plus',rate_limit:{primary_window:{used_percent:10,limit_window_seconds:604800}}},recommendationTime)]]); + assert.equal(recommendPlusAccount(recommendationAccounts,quota,recommendationTime), null); +}); + +test('request history is bounded and malformed rows are ignored', () => { + const requests = Array.from({length:250},(_,id) => ({id:String(id),model:'gpt-6.1-sol'})); + assert.equal(recentRequests({requests}).length, 200); + assert.deepEqual(recentRequests({requests:[null,{id:'bad'},{id:'ok',model:'gpt-6.1-sol'}]}), [{id:'ok',model:'gpt-6.1-sol'}]); + assert.throws(() => recentRequests({}), /invalid request/); +}); diff --git a/internal/api/handlers/management/codex_account_policy.go b/internal/api/handlers/management/codex_account_policy.go new file mode 100644 index 00000000000..b3381e735fd --- /dev/null +++ b/internal/api/handlers/management/codex_account_policy.go @@ -0,0 +1,101 @@ +package management + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" +) + +func codexAccountRoutingState(id string) gin.H { + if id == "" { + return gin.H{"mode": "automatic", "auth_id": nil} + } + return gin.H{"mode": "manual", "auth_id": id} +} + +func (h *Handler) GetCodexAccountRouting(c *gin.Context) { + h.mu.Lock() + defer h.mu.Unlock() + if h.cfg == nil || h.authManager == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "routing is unavailable"}) + return + } + state := codexAccountRoutingState(h.cfg.Routing.CodexOAuthAuthID) + state["applied"] = h.authManager.CodexOAuthAuthID() == h.cfg.Routing.CodexOAuthAuthID + c.JSON(http.StatusOK, state) +} + +func (h *Handler) PutCodexAccountRouting(c *gin.Context) { + var body map[string]json.RawMessage + if c.ShouldBindJSON(&body) != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + raw, exists := body["auth_id"] + var id string + if !exists || (string(raw) != "null" && json.Unmarshal(raw, &id) != nil) { + c.JSON(http.StatusBadRequest, gin.H{"error": "auth_id must be a string or null"}) + return + } + id = strings.TrimSpace(id) + h.mu.Lock() + if h.cfg == nil || h.authManager == nil { + h.mu.Unlock() + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "routing is unavailable"}) + return + } + manager := h.authManager + if id != "" { + target, ok := manager.GetByID(id) + if !ok { + h.mu.Unlock() + c.JSON(http.StatusNotFound, gin.H{"error": "account not found"}) + return + } + if !strings.EqualFold(strings.TrimSpace(target.Provider), "codex") || target.AuthKind() != coreauth.AuthKindOAuth || coreauth.IsPluginVirtualAuth(target) || coreauth.IsConfigAPIKeyAuth(target) || target.AuthSourceKind() == coreauth.AuthSourceConfig || isRuntimeOnlyAuth(target) || strings.TrimSpace(target.FileName) == "" { + h.mu.Unlock() + c.JSON(http.StatusBadRequest, gin.H{"error": "target must be a persistent Codex OAuth account"}) + return + } + if target.Disabled { + h.mu.Unlock() + c.JSON(http.StatusConflict, gin.H{"error": "account is disabled; enable it before selecting"}) + return + } + if manager.HomeEnabled() { + h.mu.Unlock() + c.JSON(http.StatusConflict, gin.H{"error": "manual account routing is unavailable while Home is enabled"}) + return + } + } + previous := h.cfg.Routing.CodexOAuthAuthID + if previous == id { + h.mu.Unlock() + if manager.CodexOAuthAuthID() != id { + c.JSON(http.StatusConflict, gin.H{"error": "saved routing has not been applied", "saved": true}) + return + } + c.JSON(http.StatusOK, codexAccountRoutingState(id)) + return + } + h.cfg.Routing.CodexOAuthAuthID = id + snapshot, ok := h.saveConfigAndSnapshotLocked(c) + if !ok { + h.cfg.Routing.CodexOAuthAuthID = previous + h.mu.Unlock() + return + } + h.mu.Unlock() + h.reloadConfigAfterManagementSave(context.WithoutCancel(c.Request.Context()), snapshot) + saved, err := config.LoadConfig(h.configFilePath) + if err != nil || saved.Routing.CodexOAuthAuthID != id || manager.CodexOAuthAuthID() != id { + c.JSON(http.StatusConflict, gin.H{"error": "routing saved but disk or runtime verification failed", "saved": true}) + return + } + c.JSON(http.StatusOK, codexAccountRoutingState(id)) +} diff --git a/internal/api/handlers/management/codex_account_policy_test.go b/internal/api/handlers/management/codex_account_policy_test.go new file mode 100644 index 00000000000..8552ac6c5e7 --- /dev/null +++ b/internal/api/handlers/management/codex_account_policy_test.go @@ -0,0 +1,118 @@ +package management + +import ( + "context" + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func accountPolicyHandler(t *testing.T) (*Handler, string) { + t.Helper() + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte("config-version: 8\nserver: {port: 8317}\nrouting: {strategy: fill-first}\n"), 0600); err != nil { + t.Fatal(err) + } + cfg, err := config.LoadConfig(path) + if err != nil { + t.Fatal(err) + } + m := coreauth.NewManager(nil, nil, nil) + for _, a := range []*coreauth.Auth{ + {ID: "a", FileName: "a.json", Provider: "codex", Metadata: map[string]any{"auth_kind": "oauth"}}, + {ID: "b", FileName: "b.json", Provider: "codex", Metadata: map[string]any{"auth_kind": "oauth"}}, + {ID: "disabled", FileName: "disabled.json", Provider: "codex", Disabled: true, Metadata: map[string]any{"auth_kind": "oauth"}}, + {ID: "key", FileName: "key.json", Provider: "codex", Attributes: map[string]string{"api_key": "synthetic"}}, + {ID: "runtime", Provider: "codex", Attributes: map[string]string{"runtime_only": "true"}, Metadata: map[string]any{"auth_kind": "oauth"}}, + {ID: "virtual", FileName: "virtual.json", Provider: "codex", Attributes: map[string]string{coreauth.AttributePluginVirtual: "true"}, Metadata: map[string]any{"auth_kind": "oauth"}}, + {ID: "other", FileName: "other.json", Provider: "gemini", Metadata: map[string]any{"auth_kind": "oauth"}}, + } { + if _, err := m.Register(context.Background(), a); err != nil { + t.Fatal(err) + } + } + m.SetConfig(cfg) + h := NewHandler(cfg, path, m) + h.SetConfigReloadHook(func(_ context.Context, cfg *config.Config) { m.SetConfig(cfg) }) + return h, path +} + +func policyPUT(h *Handler, body string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Set(ConfigV8ContextKey, true) + c.Request = httptest.NewRequest(http.MethodPut, "/v8/management/routing/codex-account", strings.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + h.PutCodexAccountRouting(c) + return rec +} + +func TestCodexAccountPolicyPersistReloadIdempotent(t *testing.T) { + h, path := accountPolicyHandler(t) + r := policyPUT(h, `{"auth_id":"b"}`) + if r.Code != 200 { + t.Fatalf("select: %d %s", r.Code, r.Body) + } + disk, err := config.LoadConfig(path) + if err != nil || disk.Routing.CodexOAuthAuthID != "b" { + t.Fatalf("reload: %v %v", disk, err) + } + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + r = policyPUT(h, `{"auth_id":"b"}`) + if r.Code != 200 { + t.Fatal(r.Body) + } + after, _ := os.ReadFile(path) + if string(before) != string(after) { + t.Fatal("repeated selection rewrote config") + } + for _, id := range []string{"a", "b", "key", "other"} { + a, _ := h.authManager.GetByID(id) + if a.Disabled { + t.Fatalf("selection disabled %s", id) + } + } + r = policyPUT(h, `{"auth_id":null}`) + if r.Code != 200 || h.authManager.CodexOAuthAuthID() != "" { + t.Fatalf("clear: %d %s", r.Code, r.Body) + } +} + +func TestCodexAccountPolicyInvalidTargetsDoNotMutate(t *testing.T) { + h, path := accountPolicyHandler(t) + before, _ := os.ReadFile(path) + for _, body := range []string{`{}`, `{"auth_id":42}`, `{"auth_id":"missing"}`, `{"auth_id":"disabled"}`, `{"auth_id":"key"}`, `{"auth_id":"runtime"}`, `{"auth_id":"virtual"}`, `{"auth_id":"other"}`} { + r := policyPUT(h, body) + if r.Code < 400 { + t.Fatalf("invalid accepted %s: %s", body, r.Body) + } + after, _ := os.ReadFile(path) + if string(before) != string(after) || h.cfg.Routing.CodexOAuthAuthID != "" || h.authManager.CodexOAuthAuthID() != "" { + t.Fatalf("invalid changed policy: %s", body) + } + } +} + +func TestCodexAccountPolicySaveFailureAndRuntimeMismatch(t *testing.T) { + h, path := accountPolicyHandler(t) + h.configFilePath = filepath.Join(path, "missing.yaml") + r := policyPUT(h, `{"auth_id":"b"}`) + if r.Code != 500 || h.cfg.Routing.CodexOAuthAuthID != "" || h.authManager.CodexOAuthAuthID() != "" { + t.Fatalf("save failure changed state: %d %s", r.Code, r.Body) + } + h.configFilePath = path + h.SetConfigReloadHook(func(context.Context, *config.Config) {}) + r = policyPUT(h, `{"auth_id":"b"}`) + if r.Code != 409 { + t.Fatalf("silent runtime mismatch: %d %s", r.Code, r.Body) + } +} diff --git a/internal/api/recent_requests.go b/internal/api/recent_requests.go new file mode 100644 index 00000000000..7eeead7b56f --- /dev/null +++ b/internal/api/recent_requests.go @@ -0,0 +1,150 @@ +package api + +import ( + "context" + "net/http" + "os" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/gin-gonic/gin" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage" +) + +const recentRequestCapacity = 200 +const recentRequestTextLimit = 512 + +// RecentRequest is an allow-listed execution summary. Request payloads, keys, +// upstream error bodies, and credential material never enter this store. +type RecentRequest struct { + ID string `json:"id"` + TraceID string `json:"trace_id"` + RequestedAt time.Time `json:"requested_at"` + Provider string `json:"provider"` + AuthID string `json:"auth_id"` + Account string `json:"account"` + Model string `json:"model"` + RequestedModel string `json:"requested_model"` + ResponseModel string `json:"response_model"` + DownstreamTransport string `json:"downstream_transport"` + UpstreamTransport string `json:"upstream_transport"` + LatencyMS float64 `json:"latency_ms"` + TTFTMS *float64 `json:"ttft_ms"` + Success bool `json:"success"` + StatusCode *int `json:"status_code"` + InputTokens *int64 `json:"input_tokens"` + OutputTokens *int64 `json:"output_tokens"` + CachedInputTokens *int64 `json:"cached_input_tokens"` + TotalTokens *int64 `json:"total_tokens"` + UsageKnown bool `json:"usage_known"` +} + +type recentRequestHistory struct { + mu sync.RWMutex + entries []RecentRequest + next int + host string +} + +var processRecentRequests = newRecentRequestHistory() +var registerRecentRequestsOnce sync.Once + +func newRecentRequestHistory() *recentRequestHistory { + host, _ := os.Hostname() + return &recentRequestHistory{host: host, entries: make([]RecentRequest, 0, recentRequestCapacity)} +} + +func (h *recentRequestHistory) HandleUsage(ctx context.Context, record usage.Record) { + if h == nil { + return + } + downstream := "http" + if cliproxyexecutor.DownstreamWebsocket(ctx) { + downstream = "websocket" + } else if record.Stream { + downstream = "sse" + } + upstream := "unknown" + switch record.ExecutorType { + case "CodexWebsocketsExecutor": + upstream = "websocket" + case "CodexExecutor": + upstream = "http" + } + cachedInput := record.Detail.CacheReadTokens + if cachedInput == 0 && record.Provider == "codex" { + cachedInput = record.Detail.CachedTokens + } + entry := RecentRequest{ + ID: diagnosticText(record.RequestID), TraceID: diagnosticText(record.TraceID), RequestedAt: record.RequestedAt, + Provider: diagnosticText(record.Provider), AuthID: diagnosticText(record.AuthID), Account: diagnosticText(record.AccountEmail), + Model: diagnosticText(record.Model), RequestedModel: diagnosticText(record.Alias), ResponseModel: diagnosticText(record.ResponseModel), + DownstreamTransport: downstream, UpstreamTransport: upstream, + LatencyMS: float64(record.Latency) / float64(time.Millisecond), + Success: !record.Failed, + UsageKnown: record.Detail.TotalTokens > 0 || record.Detail.InputTokens > 0 || record.Detail.OutputTokens > 0, + } + if record.Fail.StatusCode > 0 { + value := record.Fail.StatusCode + entry.StatusCode = &value + } + if record.TTFT > 0 { + value := float64(record.TTFT) / float64(time.Millisecond) + entry.TTFTMS = &value + } + if entry.UsageKnown { + input := record.Detail.InputTokens + output := record.Detail.OutputTokens + total := record.Detail.TotalTokens + entry.InputTokens = &input + entry.OutputTokens = &output + entry.CachedInputTokens = &cachedInput + entry.TotalTokens = &total + } + h.mu.Lock() + if len(h.entries) < recentRequestCapacity { + h.entries = append(h.entries, entry) + } else { + h.entries[h.next] = entry + } + h.next = (h.next + 1) % recentRequestCapacity + h.mu.Unlock() +} + +func diagnosticText(value string) string { + if len(value) > recentRequestTextLimit { + value = value[:recentRequestTextLimit] + for !utf8.ValidString(value) && len(value) > 0 { + value = value[:len(value)-1] + } + } + return strings.Clone(value) +} + +func (h *recentRequestHistory) snapshot() []RecentRequest { + if h == nil { + return []RecentRequest{} + } + h.mu.RLock() + defer h.mu.RUnlock() + entries := make([]RecentRequest, len(h.entries)) + for i := range entries { + index := (h.next - 1 - i + len(h.entries)) % len(h.entries) + entries[i] = h.entries[index] + } + return entries +} + +func (s *Server) getRecentRequests(c *gin.Context) { + host := "" + if s.recentRequests != nil { + host = s.recentRequests.host + } + c.Header("Cache-Control", "no-store") + c.JSON(http.StatusOK, gin.H{ + "host": host, "capacity": recentRequestCapacity, "retention": "process", "requests": s.recentRequests.snapshot(), + }) +} diff --git a/internal/api/recent_requests_test.go b/internal/api/recent_requests_test.go new file mode 100644 index 00000000000..fee59105dbf --- /dev/null +++ b/internal/api/recent_requests_test.go @@ -0,0 +1,123 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + "unicode/utf8" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v8/internal/api/handlers/management" + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage" +) + +type historyDeliveryProbe chan usage.Record + +func (p historyDeliveryProbe) HandleUsage(_ context.Context, record usage.Record) { p <- record } + +func TestRecentRequestsObservesUsageWithoutConsumingOtherSinks(t *testing.T) { + history := newRecentRequestHistory() + probe := make(historyDeliveryProbe, 1) + manager := usage.NewManager(8) + manager.Register(history) + manager.Register(probe) + defer manager.Stop() + ctx := cliproxyexecutor.WithDownstreamWebsocket(context.Background()) + manager.Publish(ctx, usage.Record{ + RequestID: "actual-attempt", AuthID: "chosen-oauth", AccountEmail: "selected@example.test", + Provider: "codex", Model: "served-model", Alias: "requested-model", ResponseModel: "returned-model", + ExecutorType: "CodexWebsocketsExecutor", RequestedAt: time.Unix(123, 0), Latency: 2500 * time.Millisecond, + TTFT: 125 * time.Millisecond, Detail: usage.Detail{InputTokens: 100, CachedTokens: 40, OutputTokens: 12, TotalTokens: 112}, + APIKey: "never-expose-client-secret", AccessTokenSHA256: "never-expose-fingerprint", Source: "never-expose-source", + }) + select { + case record := <-probe: + if record.RequestID != "actual-attempt" { + t.Fatal("existing sink missed record") + } + case <-time.After(5 * time.Second): + t.Fatal("usage dispatch did not complete") + } + entries := history.snapshot() + if len(entries) != 1 { + t.Fatalf("entries=%d", len(entries)) + } + entry := entries[0] + if entry.AuthID != "chosen-oauth" || entry.Account != "selected@example.test" || entry.DownstreamTransport != "websocket" || entry.UpstreamTransport != "websocket" || !entry.Success { + t.Fatalf("identity/transport mismatch: %+v", entry) + } + if entry.InputTokens == nil || *entry.InputTokens != 100 || entry.CachedInputTokens == nil || *entry.CachedInputTokens != 40 || entry.LatencyMS != 2500 || entry.TTFTMS == nil || *entry.TTFTMS != 125 { + t.Fatalf("final usage mismatch: %+v", entry) + } + encoded, err := json.Marshal(entry) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), "never-expose") || entry.StatusCode != nil { + t.Fatalf("secret or invented success status: %s", encoded) + } +} + +func TestRecentRequestsBoundedNewestFirstAndUnknownUsage(t *testing.T) { + history := newRecentRequestHistory() + for i := 0; i < recentRequestCapacity+5; i++ { + history.HandleUsage(context.Background(), usage.Record{RequestID: fmt.Sprint(i), Stream: true, ExecutorType: "CodexExecutor", Failed: true, Fail: usage.Failure{StatusCode: 429, Body: "private upstream detail"}}) + } + entries := history.snapshot() + if len(entries) != 200 || entries[0].ID != "204" || entries[199].ID != "5" { + t.Fatalf("ring order/count incorrect: %+v", entries) + } + entry := entries[0] + if entry.DownstreamTransport != "sse" || entry.UpstreamTransport != "http" || entry.StatusCode == nil || *entry.StatusCode != 429 || entry.InputTokens != nil || entry.TotalTokens != nil || entry.TTFTMS != nil || entry.Success || entry.UsageKnown { + t.Fatalf("unknown/failure mismatch: %+v", entry) + } + entries[0].ID = "client changed snapshot" + if history.snapshot()[0].ID != "204" { + t.Fatal("reader mutated history") + } + if len(newRecentRequestHistory().snapshot()) != 0 { + t.Fatal("new process history is not empty") + } +} + +func TestRecentRequestsV8RequiresManagementAuthentication(t *testing.T) { + gin.SetMode(gin.TestMode) + cfg := &config.Config{} + cfg.RemoteManagement.SecretKey = "synthetic-enabled-management" + h := management.NewHandlerWithoutConfigFilePath(cfg, nil) + h.SetLocalPassword("synthetic-management-password") + s := &Server{cfg: cfg, engine: gin.New(), mgmt: h, recentRequests: newRecentRequestHistory()} + s.managementRoutesEnabled.Store(true) + s.registerManagementRoutes() + for _, authorized := range []bool{false, true} { + req := httptest.NewRequest(http.MethodGet, "/v8/management/observability/requests/recent", nil) + req.RemoteAddr = "127.0.0.1:1234" + if authorized { + req.Header.Set("Authorization", "Bearer synthetic-management-password") + } + rec := httptest.NewRecorder() + s.engine.ServeHTTP(rec, req) + if !authorized && rec.Code != http.StatusUnauthorized { + t.Fatalf("anonymous status=%d", rec.Code) + } + if authorized && (rec.Code != http.StatusOK || rec.Header().Get("Cache-Control") != "no-store" || !strings.Contains(rec.Body.String(), `"retention":"process"`) || !strings.Contains(rec.Body.String(), `"requests":[]`)) { + t.Fatalf("authorized response=%d %s", rec.Code, rec.Body.String()) + } + } +} + +func TestRecentRequestsLimitsRetainedMetadata(t *testing.T) { + history := newRecentRequestHistory() + history.HandleUsage(context.Background(), usage.Record{RequestID: "bounded", Alias: strings.Repeat("文", 1000), Model: strings.Repeat("m", 1000)}) + entry := history.snapshot()[0] + if len(entry.RequestedModel) > 512 || !utf8.ValidString(entry.RequestedModel) || len(entry.Model) != 512 { + t.Fatal("retained metadata is unbounded or invalid UTF-8") + } +} diff --git a/internal/api/server.go b/internal/api/server.go index fc1646c9da7..d19b355bff3 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -30,6 +30,7 @@ import ( sdkaccess "github.com/router-for-me/CLIProxyAPI/v8/sdk/access" "github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers" "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage" log "github.com/sirupsen/logrus" "golang.org/x/net/http2" "gopkg.in/yaml.v3" @@ -83,7 +84,8 @@ type Server struct { wsAuthEnabled atomic.Bool // management handler - mgmt *managementHandlers.Handler + mgmt *managementHandlers.Handler + recentRequests *recentRequestHistory // pluginHost owns dynamic plugin Management API route dispatch. pluginHost *pluginhost.Host @@ -217,6 +219,8 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk applyClaudeToolSearchConfig(cfg) // Initialize management handler s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager) + s.recentRequests = processRecentRequests + registerRecentRequestsOnce.Do(func() { usage.RegisterNamedPlugin("account-console-recent-requests", processRecentRequests) }) s.mgmt.SetPluginHost(optionState.pluginHost) s.mgmt.SetConfigReloadHook(optionState.configReloadHook) if optionState.localPassword != "" { diff --git a/internal/api/server_accounts.go b/internal/api/server_accounts.go new file mode 100644 index 00000000000..03ee8fa6897 --- /dev/null +++ b/internal/api/server_accounts.go @@ -0,0 +1,57 @@ +package api + +import ( + "embed" + "html" + "net/http" + "os" + "strings" + + "github.com/gin-gonic/gin" +) + +//go:embed assets/accounts.html assets/accounts.css assets/accounts.js +var accountConsoleAssets embed.FS + +func (s *Server) serveAccountConsole(c *gin.Context) { + if s.cfg == nil || s.cfg.Home.Enabled || s.cfg.RemoteManagement.DisableControlPanel { + c.AbortWithStatus(http.StatusNotFound) + return + } + page, errRead := accountConsoleAssets.ReadFile("assets/accounts.html") + if errRead != nil { + c.AbortWithStatus(http.StatusInternalServerError) + return + } + hostname, _ := os.Hostname() + c.Header("Cache-Control", "no-store") + c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'") + c.Header("X-Content-Type-Options", "nosniff") + c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(strings.ReplaceAll(string(page), "__PROXY_HOST__", html.EscapeString(hostname)))) +} + +func (s *Server) serveAccountConsoleAsset(c *gin.Context) { + if s.cfg == nil || s.cfg.Home.Enabled || s.cfg.RemoteManagement.DisableControlPanel { + c.AbortWithStatus(http.StatusNotFound) + return + } + name := c.Param("name") + contentType := "" + switch name { + case "accounts.css": + contentType = "text/css; charset=utf-8" + case "accounts.js": + contentType = "text/javascript; charset=utf-8" + default: + c.AbortWithStatus(http.StatusNotFound) + return + } + content, errRead := accountConsoleAssets.ReadFile("assets/" + name) + if errRead != nil { + c.AbortWithStatus(http.StatusInternalServerError) + return + } + c.Header("Cache-Control", "no-cache") + c.Header("X-Content-Type-Options", "nosniff") + c.Data(http.StatusOK, contentType, content) +} diff --git a/internal/api/server_accounts_test.go b/internal/api/server_accounts_test.go new file mode 100644 index 00000000000..483c5d865db --- /dev/null +++ b/internal/api/server_accounts_test.go @@ -0,0 +1,71 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" +) + +func TestAccountConsoleShellAndAssets(t *testing.T) { + gin.SetMode(gin.TestMode) + s := &Server{cfg: &config.Config{}, engine: gin.New()} + s.engine.GET("/accounts.html", s.serveAccountConsole) + s.engine.GET("/accounts-assets/:name", s.serveAccountConsoleAsset) + for _, tc := range []struct { + path string + want int + content string + }{ + {"/accounts.html", http.StatusOK, "Management key"}, + {"/accounts-assets/accounts.css", http.StatusOK, ".account-grid"}, + {"/accounts-assets/accounts.js", http.StatusOK, "/routing/codex-account"}, + {"/accounts-assets/private.json", http.StatusNotFound, ""}, + } { + t.Run(tc.path, func(t *testing.T) { + recorder := httptest.NewRecorder() + s.engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, tc.path, nil)) + if recorder.Code != tc.want || !strings.Contains(recorder.Body.String(), tc.content) { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if recorder.Code == http.StatusOK && recorder.Header().Get("X-Content-Type-Options") != "nosniff" { + t.Fatal("asset lacks nosniff policy") + } + if tc.path == "/accounts.html" { + if strings.Contains(recorder.Body.String(), "__PROXY_HOST__") || recorder.Header().Get("Cache-Control") != "no-store" { + t.Fatal("host was not rendered or shell could be cached") + } + if !strings.Contains(recorder.Header().Get("Content-Security-Policy"), "frame-ancestors 'none'") { + t.Fatal("shell lacks a frame policy") + } + } + }) + } +} + +func TestAccountConsoleHonorsManagementPanelGuards(t *testing.T) { + for _, tc := range []struct { + name string + cfg *config.Config + }{ + {"missing config", nil}, + {"Home owns management", &config.Config{Home: config.HomeConfig{Enabled: true}}}, + {"control panel disabled", &config.Config{RemoteManagement: config.RemoteManagement{DisableControlPanel: true}}}, + } { + t.Run(tc.name, func(t *testing.T) { + s := &Server{cfg: tc.cfg, engine: gin.New()} + s.engine.GET("/accounts.html", s.serveAccountConsole) + s.engine.GET("/accounts-assets/:name", s.serveAccountConsoleAsset) + for _, path := range []string{"/accounts.html", "/accounts-assets/accounts.js"} { + recorder := httptest.NewRecorder() + s.engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil)) + if recorder.Code != http.StatusNotFound { + t.Fatalf("%s status=%d want404", path, recorder.Code) + } + } + }) + } +} diff --git a/internal/api/server_codex_account_policy_test.go b/internal/api/server_codex_account_policy_test.go new file mode 100644 index 00000000000..644b7e6eee9 --- /dev/null +++ b/internal/api/server_codex_account_policy_test.go @@ -0,0 +1,44 @@ +package api + +import ( + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v8/internal/api/handlers/management" + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestCodexAccountPolicyV8AccessControl(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, authorized := range []bool{false, true} { + cfg := &config.Config{} + cfg.RemoteManagement.SecretKey = "synthetic-enabled-management" + m := coreauth.NewManager(nil, nil, nil) + m.SetConfig(cfg) + h := management.NewHandlerWithoutConfigFilePath(cfg, m) + h.SetLocalPassword("synthetic-management-password") + s := &Server{cfg: cfg, engine: gin.New(), mgmt: h} + s.managementRoutesEnabled.Store(true) + s.registerManagementRoutes() + for _, method := range []string{http.MethodGet, http.MethodPut} { + req := httptest.NewRequest(method, "/v8/management/routing/codex-account", strings.NewReader(`{"auth_id":null}`)) + req.RemoteAddr = "127.0.0.1:1234" + req.Header.Set("Content-Type", "application/json") + if authorized { + req.Header.Set("Authorization", "Bearer synthetic-management-password") + } + rec := httptest.NewRecorder() + s.engine.ServeHTTP(rec, req) + want := 401 + if authorized { + want = 200 + } + if rec.Code != want { + t.Fatalf("%s authorized=%v: %d %s", method, authorized, rec.Code, rec.Body) + } + } + } +} diff --git a/internal/api/server_management_v8.go b/internal/api/server_management_v8.go index 0a1d10a7446..8eadb89586d 100644 --- a/internal/api/server_management_v8.go +++ b/internal/api/server_management_v8.go @@ -2,6 +2,8 @@ package api import ( "net/http" + "os" + "runtime" "github.com/gin-gonic/gin" "github.com/router-for-me/CLIProxyAPI/v8/internal/api/handlers/management" @@ -28,8 +30,14 @@ func (s *Server) registerManagementV8Routes() { } v8.GET("/server/latest-version", s.mgmt.GetLatestVersion) + v8.GET("/server/info", func(c *gin.Context) { + hostname, _ := os.Hostname() + c.JSON(http.StatusOK, gin.H{"hostname": hostname, "os": runtime.GOOS, "architecture": runtime.GOARCH}) + }) v8.POST("/requests/api-call", s.mgmt.APICall) v8.POST("/routing/cooldown/reset", s.mgmt.ResetQuota) + v8.GET("/routing/codex-account", s.mgmt.GetCodexAccountRouting) + v8.PUT("/routing/codex-account", s.mgmt.PutCodexAccountRouting) v8.GET("/routing/model-definitions/:channel", s.mgmt.GetStaticModelDefinitions) v8.GET("/observability/logs", s.mgmt.GetLogs) @@ -39,6 +47,7 @@ func (s *Server) registerManagementV8Routes() { v8.GET("/observability/logs/requests/:id", s.mgmt.GetRequestLogByID) v8.GET("/observability/usage/api-keys", s.mgmt.GetAPIKeyUsage) v8.GET("/observability/usage/queue", s.mgmt.GetUsageQueue) + v8.GET("/observability/requests/recent", s.getRecentRequests) v8.GET("/credentials", s.mgmt.ListAuthFiles) v8.POST("/credentials", s.mgmt.UploadAuthFileV8) diff --git a/internal/api/server_management_v8_test.go b/internal/api/server_management_v8_test.go index 09c20b18847..5cbc00c9326 100644 --- a/internal/api/server_management_v8_test.go +++ b/internal/api/server_management_v8_test.go @@ -45,7 +45,7 @@ func TestManagementV8RoutesShareAccessControl(t *testing.T) { s := &Server{cfg: cfg, engine: gin.New(), mgmt: h} s.managementRoutesEnabled.Store(tc.enabled) s.registerManagementRoutes() - for _, route := range []string{"/v0/management/config", "/v8/management/config", "/v0/management/plugins", "/v8/management/plugins"} { + for _, route := range []string{"/v0/management/config", "/v8/management/config", "/v0/management/plugins", "/v8/management/plugins", "/v8/management/server/info"} { req := httptest.NewRequest(http.MethodGet, route, nil) req.RemoteAddr = "127.0.0.1:1234" if tc.authorized { @@ -91,6 +91,7 @@ func TestManagementV8IndependentContract(t *testing.T) { "GET /v8/management/credentials", "POST /v8/management/credentials", "GET /v8/management/oauth/auth-url", "POST /v8/management/oauth/import", "POST /v8/management/oauth/callback", "POST /v8/management/routing/cooldown/reset", + "GET /v8/management/routing/codex-account", "PUT /v8/management/routing/codex-account", "GET /v8/management/plugins/:id/quota", "POST /v8/management/plugins/:id/quota", "DELETE /v8/management/plugins/:id/quota", "POST /v8/management/plugins/store/:id/install", "DELETE /v8/management/plugins/:id", } { diff --git a/internal/api/server_middleware.go b/internal/api/server_middleware.go index f3f73d530ca..66040dfa81f 100644 --- a/internal/api/server_middleware.go +++ b/internal/api/server_middleware.go @@ -83,7 +83,7 @@ func (s *Server) homeHeartbeatMiddleware() gin.HandlerFunc { } if c != nil && c.Request != nil { path := c.Request.URL.Path - if strings.HasPrefix(path, "/v0/management/") || path == "/v0/management" || strings.HasPrefix(path, "/v8/management/") || path == "/v8/management" || strings.HasPrefix(path, "/v0/resource/plugins/") || path == "/management.html" { + if strings.HasPrefix(path, "/v0/management/") || path == "/v0/management" || strings.HasPrefix(path, "/v8/management/") || path == "/v8/management" || strings.HasPrefix(path, "/v0/resource/plugins/") || path == "/management.html" || path == "/accounts.html" || strings.HasPrefix(path, "/accounts-assets/") { c.Next() return } diff --git a/internal/api/server_routes.go b/internal/api/server_routes.go index 61786b4a9a1..df9bcc72adb 100644 --- a/internal/api/server_routes.go +++ b/internal/api/server_routes.go @@ -54,6 +54,8 @@ func (s *Server) setupRoutes() { s.engine.HEAD("/healthz", healthzHandler) s.engine.GET("/management.html", s.serveManagementControlPanel) + s.engine.GET("/accounts.html", s.serveAccountConsole) + s.engine.GET("/accounts-assets/:name", s.serveAccountConsoleAsset) openaiHandlers := openai.NewOpenAIAPIHandler(s.handlers) geminiHandlers := gemini.NewGeminiAPIHandler(s.handlers) claudeCodeHandlers := claude.NewClaudeCodeAPIHandler(s.handlers) diff --git a/internal/config/codex_account_policy_test.go b/internal/config/codex_account_policy_test.go new file mode 100644 index 00000000000..f863a58781f --- /dev/null +++ b/internal/config/codex_account_policy_test.go @@ -0,0 +1,34 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCodexAccountPolicyConfigRoundTrip(t *testing.T) { + if err := ValidateV8Config([]byte("config-version: 8\nrouting: {codex-oauth-auth-id: subscription-b}\n")); err != nil { + t.Fatalf("v8 schema rejected policy: %v", err) + } + for _, v8 := range []bool{false, true} { + raw := []byte("routing: {strategy: fill-first, codex-oauth-auth-id: subscription-b}\n") + cfg, err := ParseConfigBytes(raw) + if err != nil { + t.Fatal(err) + } + if cfg.Routing.CodexOAuthAuthID != "subscription-b" { + t.Fatal("policy not parsed") + } + path := filepath.Join(t.TempDir(), "config.yaml") + if err = os.WriteFile(path, raw, 0600); err != nil { + t.Fatal(err) + } + if err = SaveConfigPreserveComments(path, cfg, v8); err != nil { + t.Fatal(err) + } + loaded, err := LoadConfig(path) + if err != nil || loaded.Routing.CodexOAuthAuthID != "subscription-b" || loaded.Routing.Strategy != "fill-first" { + t.Fatalf("policy round trip v8=%v: %v %v", v8, loaded, err) + } + } +} diff --git a/internal/config/config_types.go b/internal/config/config_types.go index 2f412157da9..89e1bc1267c 100644 --- a/internal/config/config_types.go +++ b/internal/config/config_types.go @@ -415,6 +415,7 @@ type QuotaExceeded struct { // RoutingConfig configures how credentials are selected for requests. type RoutingConfig struct { + CodexOAuthAuthID string `yaml:"codex-oauth-auth-id" json:"codex-oauth-auth-id,omitempty"` // Strategy selects the credential selection strategy. // Supported values: "round-robin" (default), "weighted-round-robin", "fill-first". Strategy string `yaml:"strategy,omitempty" json:"strategy,omitempty"` diff --git a/internal/runtime/executor/helps/usage_account_identity_test.go b/internal/runtime/executor/helps/usage_account_identity_test.go new file mode 100644 index 00000000000..dfc065d5348 --- /dev/null +++ b/internal/runtime/executor/helps/usage_account_identity_test.go @@ -0,0 +1,19 @@ +package helps + +import ( + "context" + "testing" + + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage" +) + +func TestUsageReporterCapturesAccountIdentityBeforeAuthChanges(t *testing.T) { + auth := &cliproxyauth.Auth{ID: "selected-auth", Metadata: map[string]any{"email": "chosen@example.test", "access_token": "private-token"}} + reporter := NewUsageReporter(context.Background(), "codex", "model", auth) + auth.Metadata["email"] = "later@example.test" + record := reporter.buildRecord(usage.Detail{InputTokens: 5}, false) + if record.AuthID != "selected-auth" || record.AccountEmail != "chosen@example.test" { + t.Fatalf("request identity not captured: %+v", record) + } +} diff --git a/internal/runtime/executor/helps/usage_helpers.go b/internal/runtime/executor/helps/usage_helpers.go index 8348d6e74f1..98038914761 100644 --- a/internal/runtime/executor/helps/usage_helpers.go +++ b/internal/runtime/executor/helps/usage_helpers.go @@ -34,6 +34,7 @@ type UsageReporter struct { model string alias string authID string + accountEmail string authIndex string authMu sync.RWMutex accessTokenHash string @@ -144,6 +145,11 @@ func NewUsageReporter(ctx context.Context, provider, model string, auth *cliprox } if auth != nil { reporter.authID = auth.ID + reporter.accountEmail = strings.TrimSpace(auth.Attributes["email"]) + if reporter.accountEmail == "" && auth.Metadata != nil { + reporter.accountEmail, _ = auth.Metadata["email"].(string) + reporter.accountEmail = strings.TrimSpace(reporter.accountEmail) + } reporter.authIndex = auth.EnsureIndex() reporter.accessTokenHash = authAccessTokenSHA256(auth) } @@ -651,6 +657,7 @@ func (r *UsageReporter) buildRecordForModel(model string, detail usage.Detail, f SessionID: r.sessionID, ParentSessionID: r.parentSessionID, AuthID: r.authID, + AccountEmail: r.accountEmail, AuthIndex: r.authIndex, AccessTokenSHA256: r.accessTokenFingerprint(), AuthType: r.authType, diff --git a/internal/runtime/executor/xai_websockets_executor_test.go b/internal/runtime/executor/xai_websockets_executor_test.go index b49923782eb..868ca466100 100644 --- a/internal/runtime/executor/xai_websockets_executor_test.go +++ b/internal/runtime/executor/xai_websockets_executor_test.go @@ -2232,6 +2232,7 @@ func TestXAIWebsockets_KeepalivePingDuringUpload_WithSession(t *testing.T) { serverPongCh := make(chan string, 1) inWriteHook := make(chan struct{}) pongDeliveredDuringWrite := make(chan struct{}) + requestReceived := make(chan struct{}) testWebsocketWritePayloadHook = func(conn *websocket.Conn) { close(inWriteHook) @@ -2257,11 +2258,10 @@ func TestXAIWebsockets_KeepalivePingDuringUpload_WithSession(t *testing.T) { }) go func() { - for { - if _, _, errReadLoop := conn.ReadMessage(); errReadLoop != nil { - return - } + if _, _, errRead := conn.ReadMessage(); errRead != nil { + return } + close(requestReceived) }() select { @@ -2284,6 +2284,13 @@ func TestXAIWebsockets_KeepalivePingDuringUpload_WithSession(t *testing.T) { return } + select { + case <-requestReceived: + case <-time.After(2 * time.Second): + t.Error("request payload was not received after pong delivery") + return + } + respPayload := []byte(`{"type":"response.done","response":{"id":"resp-1","status":"completed","output":[]}}`) _ = conn.WriteMessage(websocket.TextMessage, respPayload) })) @@ -2328,6 +2335,7 @@ func TestXAIWebsockets_KeepalivePingDuringUpload_Sessionless(t *testing.T) { serverPongCh := make(chan string, 1) inWriteHook := make(chan struct{}) pongDeliveredDuringWrite := make(chan struct{}) + requestReceived := make(chan struct{}) testWebsocketWritePayloadHook = func(conn *websocket.Conn) { close(inWriteHook) @@ -2353,11 +2361,10 @@ func TestXAIWebsockets_KeepalivePingDuringUpload_Sessionless(t *testing.T) { }) go func() { - for { - if _, _, errReadLoop := conn.ReadMessage(); errReadLoop != nil { - return - } + if _, _, errRead := conn.ReadMessage(); errRead != nil { + return } + close(requestReceived) }() select { @@ -2380,6 +2387,13 @@ func TestXAIWebsockets_KeepalivePingDuringUpload_Sessionless(t *testing.T) { return } + select { + case <-requestReceived: + case <-time.After(2 * time.Second): + t.Error("request payload was not received after pong delivery") + return + } + respPayload := []byte(`{"type":"response.done","response":{"id":"resp-1","status":"completed","output":[]}}`) _ = conn.WriteMessage(websocket.TextMessage, respPayload) })) diff --git a/sdk/cliproxy/auth/conductor_account_policy.go b/sdk/cliproxy/auth/conductor_account_policy.go new file mode 100644 index 00000000000..f25afff2bed --- /dev/null +++ b/sdk/cliproxy/auth/conductor_account_policy.go @@ -0,0 +1,53 @@ +package auth + +import ( + "context" + "net/http" + "strings" + + internalconfig "github.com/router-for-me/CLIProxyAPI/v8/internal/config" +) + +type codexAccountPolicy struct{ authID string } +type codexAccountPolicyContextKey struct{} + +func (p codexAccountPolicy) allows(auth *Auth) bool { + return p.authID == "" || !strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") || auth.AuthKind() != AuthKindOAuth || auth.ID == p.authID +} + +func accountPolicyFromContext(ctx context.Context) codexAccountPolicy { + if ctx == nil { + return codexAccountPolicy{} + } + policy, _ := ctx.Value(codexAccountPolicyContextKey{}).(codexAccountPolicy) + return policy +} + +// CodexOAuthAuthID reports the currently applied subscription routing policy. +func (m *Manager) CodexOAuthAuthID() string { + if m == nil { + return "" + } + cfg, _ := m.runtimeConfig.Load().(*internalconfig.Config) + if cfg == nil { + return "" + } + return cfg.Routing.CodexOAuthAuthID +} + +func (m *Manager) withAccountPolicySnapshot(ctx context.Context) context.Context { + if ctx == nil { + ctx = context.Background() + } + if _, ok := ctx.Value(codexAccountPolicyContextKey{}).(codexAccountPolicy); ok { + return ctx + } + return context.WithValue(ctx, codexAccountPolicyContextKey{}, codexAccountPolicy{authID: m.CodexOAuthAuthID()}) +} + +func (m *Manager) accountPolicyHomeError(ctx context.Context) error { + if m.HomeEnabled() && accountPolicyFromContext(ctx).authID != "" { + return &Error{Code: "codex_account_policy_home", Message: "manual Codex OAuth account routing is unavailable while Home is enabled", HTTPStatus: http.StatusServiceUnavailable} + } + return nil +} diff --git a/sdk/cliproxy/auth/conductor_account_policy_test.go b/sdk/cliproxy/auth/conductor_account_policy_test.go new file mode 100644 index 00000000000..13ca1a0e7a8 --- /dev/null +++ b/sdk/cliproxy/auth/conductor_account_policy_test.go @@ -0,0 +1,221 @@ +package auth + +import ( + "context" + internalconfig "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" + "net/http" + "testing" + "time" +) + +func policyManager(t *testing.T, selector Selector) *Manager { + t.Helper() + m := NewManager(nil, selector, nil) + m.RegisterExecutor(schedulerTestExecutor{provider: "codex"}) + m.RegisterExecutor(schedulerTestExecutor{provider: "gemini"}) + for _, a := range []*Auth{ + {ID: "policy-a", Provider: "codex", Metadata: map[string]any{"auth_kind": "oauth"}}, + {ID: "policy-b", Provider: "codex", Metadata: map[string]any{"auth_kind": "oauth"}}, + {ID: "policy-c", Provider: "codex", Metadata: map[string]any{"auth_kind": "oauth"}}, + {ID: "policy-key", Provider: "codex", Attributes: map[string]string{"api_key": "synthetic"}}, + {ID: "policy-gemini", Provider: "gemini"}, + } { + if _, err := m.Register(context.Background(), a); err != nil { + t.Fatal(err) + } + } + m.SetConfig(&internalconfig.Config{Routing: internalconfig.RoutingConfig{CodexOAuthAuthID: "policy-b"}}) + return m +} + +type accountCaptureExecutor struct { + schedulerTestExecutor + ids []string + change func() +} + +func (e *accountCaptureExecutor) Execute(_ context.Context, a *Auth, _ cliproxyexecutor.Request, _ cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + e.ids = append(e.ids, a.ID) + if e.change != nil { + e.change() + return cliproxyexecutor.Response{}, &Error{Code: "upstream", Message: "synthetic failure", HTTPStatus: 500} + } + return cliproxyexecutor.Response{}, nil +} +func (e *accountCaptureExecutor) CountTokens(ctx context.Context, a *Auth, r cliproxyexecutor.Request, o cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + return e.Execute(ctx, a, r, o) +} +func (e *accountCaptureExecutor) ExecuteStream(ctx context.Context, a *Auth, r cliproxyexecutor.Request, o cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) { + _, err := e.Execute(ctx, a, r, o) + if err != nil { + return nil, err + } + chunks := make(chan cliproxyexecutor.StreamChunk, 1) + chunks <- cliproxyexecutor.StreamChunk{Payload: []byte("ok")} + close(chunks) + return &cliproxyexecutor.StreamResult{Chunks: chunks}, nil +} + +func TestCodexAccountPolicyExecuteCountStreamAndRetry(t *testing.T) { + for _, method := range []string{"execute", "count", "stream", "retry"} { + t.Run(method, func(t *testing.T) { + m := policyManager(t, &RoundRobinSelector{}) + for _, id := range []string{"policy-a", "policy-b", "policy-c"} { + registerSchedulerModels(t, "codex", "policy-model", id) + } + e := &accountCaptureExecutor{schedulerTestExecutor: schedulerTestExecutor{provider: "codex"}} + m.RegisterExecutor(e) + if method == "retry" { + m.SetRetryConfig(1, 0, 0) + m.SetConfig(&internalconfig.Config{RequestRetry: 1, DisableCooling: true, Routing: internalconfig.RoutingConfig{CodexOAuthAuthID: "policy-b"}}) + e.change = func() { + m.SetConfig(&internalconfig.Config{RequestRetry: 1, DisableCooling: true, Routing: internalconfig.RoutingConfig{CodexOAuthAuthID: "policy-c"}}) + } + } + opts := cliproxyexecutor.Options{} + req := cliproxyexecutor.Request{Model: "policy-model"} + var err error + switch method { + case "count": + _, err = m.ExecuteCount(context.Background(), []string{"codex"}, req, opts) + case "stream": + var result *cliproxyexecutor.StreamResult + result, err = m.ExecuteStream(context.Background(), []string{"codex"}, req, opts) + if err == nil { + for range result.Chunks { + } + } + default: + _, err = m.Execute(context.Background(), []string{"codex"}, req, opts) + } + if method != "retry" && err != nil { + t.Fatal(err) + } + if len(e.ids) == 0 { + t.Fatal("no upstream execution") + } + for _, id := range e.ids { + if id != "policy-b" { + t.Fatalf("execution escaped snapshot: %v", e.ids) + } + } + if method == "retry" && len(e.ids) < 2 { + t.Fatalf("retry not exercised: %v error=%v", e.ids, err) + } + if method == "retry" && err == nil { + t.Fatal("failed selected account silently fell back") + } + }) + } +} + +func TestCodexAccountPolicyPluginCandidateFiltering(t *testing.T) { + m := policyManager(t, &RoundRobinSelector{}) + scheduler := &fakePluginScheduler{handled: true, pick: func(_ context.Context, r pluginapi.SchedulerPickRequest) (pluginapi.SchedulerPickResponse, bool, error) { + for _, a := range r.Candidates { + if a.ID == "policy-a" || a.ID == "policy-c" { + t.Fatalf("forbidden plugin candidate %s", a.ID) + } + } + return pluginapi.SchedulerPickResponse{AuthID: "policy-b"}, true, nil + }} + m.SetPluginScheduler(scheduler) + a, _, err := m.pickNext(context.Background(), "codex", "", cliproxyexecutor.Options{}, nil) + if err != nil || a.ID != "policy-b" || scheduler.calls == 0 { + t.Fatalf("plugin selection %v %v", a, err) + } +} + +func TestCodexAccountPolicyHomeFailsClosed(t *testing.T) { + m := policyManager(t, &RoundRobinSelector{}) + cfg := &internalconfig.Config{Routing: internalconfig.RoutingConfig{CodexOAuthAuthID: "policy-b"}} + cfg.Home.Enabled = true + m.SetConfig(cfg) + _, err := m.Execute(context.Background(), []string{"codex"}, cliproxyexecutor.Request{Model: "policy-model"}, cliproxyexecutor.Options{}) + if err == nil || err.(*Error).Code != "codex_account_policy_home" { + t.Fatalf("Home did not reject manual policy: %v", err) + } +} + +func TestCodexAccountPolicySelectionPaths(t *testing.T) { + for _, legacy := range []bool{false, true} { + t.Run(map[bool]string{false: "scheduler", true: "legacy"}[legacy], func(t *testing.T) { + var selector Selector = &RoundRobinSelector{} + if legacy { + selector = &trackingSelector{} + } + m := policyManager(t, selector) + opts := cliproxyexecutor.Options{} + for i := 0; i < 9; i++ { + a, _, err := m.pickNext(context.Background(), "codex", "", opts, nil) + if err != nil { + t.Fatal(err) + } + if a.ID != "policy-b" && a.ID != "policy-key" { + t.Fatalf("forbidden subscription selected: %s", a.ID) + } + } + a, _, err := m.pickNext(context.Background(), "gemini", "", opts, nil) + if err != nil || a.ID != "policy-gemini" { + t.Fatalf("other provider was constrained: %v %v", a, err) + } + a, _, _, err = m.pickNextMixed(context.Background(), []string{"codex", "gemini"}, "", opts, nil) + if err != nil || a.ID == "policy-a" || a.ID == "policy-c" { + t.Fatalf("mixed selection bypassed policy: %v %v", a, err) + } + }) + } +} + +func TestCodexAccountPolicySnapshotSurvivesChangeAndRetry(t *testing.T) { + m := policyManager(t, &RoundRobinSelector{}) + ctx := m.withAccountPolicySnapshot(context.Background()) + m.SetConfig(&internalconfig.Config{Routing: internalconfig.RoutingConfig{CodexOAuthAuthID: "policy-c"}}) + opts := cliproxyexecutor.Options{Metadata: map[string]any{cliproxyexecutor.PinnedAuthMetadataKey: "policy-b"}} + a, _, err := m.pickNext(ctx, "codex", "", opts, nil) + if err != nil || a.ID != "policy-b" { + t.Fatalf("in-flight snapshot changed: %v %v", a, err) + } + if _, _, err = m.pickNext(ctx, "codex", "", opts, map[string]struct{}{"policy-b": {}}); err == nil { + t.Fatal("retry escaped exhausted selected account") + } + if _, _, err = m.pickNext(context.Background(), "codex", "", opts, nil); err == nil { + t.Fatal("stale explicit pin bypassed new policy") + } + opts.Metadata[cliproxyexecutor.PinnedAuthMetadataKey] = "policy-c" + a, _, err = m.pickNext(context.Background(), "codex", "", opts, nil) + if err != nil || a.ID != "policy-c" { + t.Fatalf("next request did not observe switch: %v %v", a, err) + } +} + +func TestCodexAccountPolicyAutomaticRetainsBaseline(t *testing.T) { + m := policyManager(t, &RoundRobinSelector{}) + m.SetConfig(&internalconfig.Config{}) + seen := map[string]bool{} + for i := 0; i < 8; i++ { + a, _, err := m.pickNext(context.Background(), "codex", "", cliproxyexecutor.Options{}, nil) + if err != nil { + t.Fatal(err) + } + seen[a.ID] = true + } + if !seen["policy-a"] || !seen["policy-c"] { + t.Fatalf("automatic pool constrained: %v", seen) + } +} + +func TestCodexAccountPolicyOldAffinityCannotBypass(t *testing.T) { + selector := NewSessionAffinitySelectorWithConfig(SessionAffinityConfig{Fallback: &RoundRobinSelector{}, TTL: time.Hour}) + defer selector.Stop() + m := policyManager(t, selector) + registerSchedulerModels(t, "codex", "policy-affinity", "policy-a", "policy-b", "policy-c") + selector.cache.Set("codex::header:policy-session::policy-affinity", "policy-a") + opts := cliproxyexecutor.Options{Headers: http.Header{"X-Session-Id": []string{"policy-session"}}} + a, _, err := m.pickNext(context.Background(), "codex", "policy-affinity", opts, nil) + if err != nil || a.ID != "policy-b" { + t.Fatalf("stale affinity bypassed manual choice: %v %v", a, err) + } +} diff --git a/sdk/cliproxy/auth/conductor_execution.go b/sdk/cliproxy/auth/conductor_execution.go index 17a1b458ca8..c429b0557dd 100644 --- a/sdk/cliproxy/auth/conductor_execution.go +++ b/sdk/cliproxy/auth/conductor_execution.go @@ -122,6 +122,10 @@ func preferredExecutionAttemptError(fallback, upstream error) error { func (m *Manager) Execute(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { ctx = cliproxyexecutor.WithRequestProxyURL(ctx, opts.ProxyURL) req, opts = cliproxysession.Enrich(req, opts) + ctx = m.withAccountPolicySnapshot(ctx) + if errPolicy := m.accountPolicyHomeError(ctx); errPolicy != nil { + return cliproxyexecutor.Response{}, errPolicy + } normalized := m.normalizeProviders(providers) if len(normalized) == 0 { return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "no provider supplied"} @@ -182,6 +186,10 @@ func (m *Manager) Execute(ctx context.Context, providers []string, req cliproxye func (m *Manager) ExecuteCount(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { ctx = cliproxyexecutor.WithRequestProxyURL(ctx, opts.ProxyURL) req, opts = cliproxysession.Enrich(req, opts) + ctx = m.withAccountPolicySnapshot(ctx) + if errPolicy := m.accountPolicyHomeError(ctx); errPolicy != nil { + return cliproxyexecutor.Response{}, errPolicy + } normalized := m.normalizeProviders(providers) if len(normalized) == 0 { return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "no provider supplied"} @@ -235,6 +243,10 @@ func (m *Manager) ExecuteCount(ctx context.Context, providers []string, req clip func (m *Manager) ExecuteStream(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) { ctx = cliproxyexecutor.WithRequestProxyURL(ctx, opts.ProxyURL) req, opts = cliproxysession.Enrich(req, opts) + ctx = m.withAccountPolicySnapshot(ctx) + if errPolicy := m.accountPolicyHomeError(ctx); errPolicy != nil { + return nil, errPolicy + } if m.HomeEnabled() { if unlockSession := m.lockHomeWebsocketSession(ctx, opts); unlockSession != nil { defer unlockSession() diff --git a/sdk/cliproxy/auth/conductor_home.go b/sdk/cliproxy/auth/conductor_home.go index 1810c78d29b..552e658d45a 100644 --- a/sdk/cliproxy/auth/conductor_home.go +++ b/sdk/cliproxy/auth/conductor_home.go @@ -949,6 +949,10 @@ func (m *Manager) pickNextViaHome(ctx context.Context, model string, opts clipro } func (m *Manager) pickHomeDispatchSelection(ctx context.Context, model string, opts cliproxyexecutor.Options) (*HomeDispatchSelection, error) { + ctx = m.withAccountPolicySnapshot(ctx) + if errPolicy := m.accountPolicyHomeError(ctx); errPolicy != nil { + return nil, errPolicy + } if m == nil { return nil, &Error{Code: "auth_not_found", Message: "no auth available"} } diff --git a/sdk/cliproxy/auth/conductor_selection.go b/sdk/cliproxy/auth/conductor_selection.go index 7cdacb7185f..96981cc2831 100644 --- a/sdk/cliproxy/auth/conductor_selection.go +++ b/sdk/cliproxy/auth/conductor_selection.go @@ -83,6 +83,7 @@ type requiredAuthKindContextKey struct{} type credentialPolicyContextKey struct{} type authSelectionEligibility struct { + accountPolicy codexAccountPolicy requiredKind string credentialPolicy string disallowFreeAuth bool @@ -105,7 +106,7 @@ func credentialPolicyFromContext(ctx context.Context) string { } func authSelectionEligibilityForRequest(ctx context.Context, opts cliproxyexecutor.Options) authSelectionEligibility { - eligibility := authSelectionEligibility{disallowFreeAuth: disallowFreeAuthFromMetadata(opts.Metadata)} + eligibility := authSelectionEligibility{accountPolicy: accountPolicyFromContext(ctx), disallowFreeAuth: disallowFreeAuthFromMetadata(opts.Metadata)} if ctx != nil { eligibility.requiredKind, _ = ctx.Value(requiredAuthKindContextKey{}).(string) eligibility.credentialPolicy, _ = ctx.Value(credentialPolicyContextKey{}).(string) @@ -114,7 +115,7 @@ func authSelectionEligibilityForRequest(ctx context.Context, opts cliproxyexecut } func (e authSelectionEligibility) allows(auth *Auth) bool { - if auth == nil { + if auth == nil || !e.accountPolicy.allows(auth) { return false } if e.requiredKind != "" && auth.AuthKind() != e.requiredKind { @@ -1816,6 +1817,7 @@ func (m *Manager) routeAwareSelectionRequired(auth *Auth, routeModel string) boo } func (m *Manager) pickNextLegacy(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, error) { + ctx = m.withAccountPolicySnapshot(ctx) if m.HomeEnabled() { auth, exec, _, err := m.pickNextViaHome(ctx, model, opts, tried) return auth, exec, err @@ -2086,6 +2088,7 @@ func (m *Manager) SelectHomeAuthByKind(ctx context.Context, provider string, mod } func (m *Manager) pickNext(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, error) { + ctx = m.withAccountPolicySnapshot(ctx) opts.EnsureMetadata() if m.HomeEnabled() { auth, exec, _, err := m.pickNextViaHome(ctx, model, opts, tried) @@ -2147,6 +2150,7 @@ func (m *Manager) pickNext(ctx context.Context, provider, model string, opts cli } func (m *Manager) pickNextMixedLegacy(ctx context.Context, providers []string, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, string, error) { + ctx = m.withAccountPolicySnapshot(ctx) if m.HomeEnabled() { return m.pickNextViaHome(ctx, model, opts, tried) } @@ -2272,6 +2276,7 @@ func (m *Manager) pickNextMixedLegacy(ctx context.Context, providers []string, m } func (m *Manager) pickNextMixed(ctx context.Context, providers []string, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, string, error) { + ctx = m.withAccountPolicySnapshot(ctx) opts.EnsureMetadata() if m.HomeEnabled() { return m.pickNextViaHome(ctx, model, opts, tried) diff --git a/sdk/cliproxy/usage/manager.go b/sdk/cliproxy/usage/manager.go index 2e46ee6a318..011f788f355 100644 --- a/sdk/cliproxy/usage/manager.go +++ b/sdk/cliproxy/usage/manager.go @@ -42,7 +42,9 @@ type Record struct { SessionID string ParentSessionID string AuthID string - AuthIndex string + // AccountEmail is the non-secret account identity captured before execution. + AccountEmail string + AuthIndex string // AccessTokenSHA256 identifies the OAuth token version without exposing the token. AccessTokenSHA256 string AuthType string