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/README.md b/README.md index 3bf232bd2fd..9fb7ba0bde5 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,11 @@ You can access the following providers locally and with multiple CLI accounts th


&client_id="}
+```
+
+The v8 callback also accepts `state`, `code`, and `client_id` directly as POST JSON
+fields or GET query parameters. SIWC requires the **issued** `client_id` from the
+callback; the authorization URL's `dynamic_agent_client` is not a substitute.
+Error callbacks can supply `error` instead of `code` and `client_id`. Conflicting
+parameters are rejected. The target, PKCE verifier, device ID, and redirect URI
+are retained on the server and cannot be changed by a callback. Callback values
+are handed directly to the SIWC session rather than written to legacy callback
+files. Neither callbacks nor status responses return tokens.
+
+Poll `GET /oauth/status?state=` until `ok` or `error`. `wait` means binding
+has not completed, including while tokens are being exchanged. Success means the
+new `siwc` partition and `use_siwc=true` have been saved and published to the auth
+manager. Other credential fields are retained. Rebinding explicitly replaces the
+SIWC partition and reuses a valid existing device ID.
+
+`DELETE /oauth/session?state=` cancels a pending binding. Cancellation that
+wins the commit gate prevents saving a late token response; cancellation after
+commit returns `cancelled:false`. Duplicate callbacks and a second pending binding
+for the same credential return `409`. Missing targets return `404`; ineligible
+targets and incomplete callbacks return `400`. Deleting or replacing the target
+through the v8 credential API invalidates its pending binding. An old server
+returns `404` for the start endpoint; clients must not fall back to ordinary Codex
+login. Sessions are process-local, expire after five minutes, and do not survive
+restart; deployments with multiple independent instances require session affinity.
+
+For Codex entries, `GET /credentials` includes these non-secret summary fields:
+
+| Field | Type | Meaning |
+| --- | --- | --- |
+| `siwc_bind_supported` | boolean | This credential is eligible for the binding flow. Missing means unsupported. |
+| `use_siwc` | boolean | Requests are configured to use the nested SIWC credential. |
+| `siwc_bound` | boolean | SIWC access token, refresh token, and issued client ID are present; not a live validity check. |
+| `siwc_expires_at` | optional RFC3339 string | Stored SIWC expiration, when available. |
+
+The v0 callback and credential-list contracts are unchanged. SIWC activation,
+unbinding, and standalone SIWC credential creation are not separate management
+operations in this release.
+
## v0 compatibility
The `/v0/management` endpoints keep their original paths and payloads, including
diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go
index 86878e9bf54..13cb26cd6db 100644
--- a/internal/api/handlers/management/auth_files.go
+++ b/internal/api/handlers/management/auth_files.go
@@ -42,6 +42,7 @@ var (
callbackForwarders = make(map[int]*callbackForwarder)
authFileEntryMu sync.Mutex
errAuthFileMustBeJSON = errors.New("auth file must be .json")
+ errInvalidAuthFile = errors.New("invalid auth file")
errAuthFileNotFound = errors.New("auth file not found")
errPluginVirtualAuth = errors.New("plugin virtual auth cannot be modified directly; edit or delete the source auth file")
newCodexOAuthService = func(cfg *config.Config) codexOAuthService { return codex.NewCodexAuth(cfg) }
@@ -783,6 +784,18 @@ func (h *Handler) buildAuthFileEntryLocked(auth *coreauth.Auth, quota authFileQu
if requestRetry, ok := auth.RequestRetryOverride(); ok {
entry["request_retry"] = requestRetry
}
+ if quota.v8 && strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") {
+ entry["siwc_bind_supported"] = siwcBindEligible(auth)
+ entry["use_siwc"] = coreauth.IsCodexUseSIWC(auth)
+ section, _ := auth.Metadata["siwc"].(map[string]any)
+ accessToken, _ := section["access_token"].(string)
+ refreshToken, _ := section["refresh_token"].(string)
+ clientID, _ := section["client_id"].(string)
+ entry["siwc_bound"] = strings.TrimSpace(accessToken) != "" && strings.TrimSpace(refreshToken) != "" && strings.TrimSpace(clientID) != ""
+ if expiry, okExpiry := auth.SIWCExpirationTime(); okExpiry {
+ entry["siwc_expires_at"] = expiry.UTC().Format(time.RFC3339)
+ }
+ }
return entry
}
diff --git a/internal/api/handlers/management/auth_files_crud.go b/internal/api/handlers/management/auth_files_crud.go
index 73476434703..8b104094081 100644
--- a/internal/api/handlers/management/auth_files_crud.go
+++ b/internal/api/handlers/management/auth_files_crud.go
@@ -67,6 +67,10 @@ func (h *Handler) UploadAuthFile(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "file must be .json"})
return
}
+ if errors.Is(errUpload, errInvalidAuthFile) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": errUpload.Error()})
+ return
+ }
c.JSON(http.StatusInternalServerError, gin.H{"error": errUpload.Error()})
return
}
@@ -123,7 +127,11 @@ func (h *Handler) UploadAuthFile(c *gin.Context) {
return
}
if err = h.writeAuthFile(ctx, filepath.Base(name), data); err != nil {
- c.JSON(500, gin.H{"error": err.Error()})
+ status := http.StatusInternalServerError
+ if errors.Is(err, errInvalidAuthFile) {
+ status = http.StatusBadRequest
+ }
+ c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"status": "ok"})
@@ -157,13 +165,22 @@ func (h *Handler) DeleteAuthFile(c *gin.Context) {
full = abs
}
}
- if err = os.Remove(full); err == nil {
+ errDelete := func() error {
+ release := h.beginSIWCFileMutation(ctx, full)
+ defer release()
+ if errRemove := os.Remove(full); errRemove != nil {
+ return nil
+ }
if errDel := h.deleteTokenRecord(ctx, full); errDel != nil {
- c.JSON(500, gin.H{"error": errDel.Error()})
- return
+ return errDel
}
deleted++
h.removeAuth(ctx, full)
+ return nil
+ }()
+ if errDelete != nil {
+ c.JSON(500, gin.H{"error": errDelete.Error()})
+ return
}
}
c.JSON(200, gin.H{"status": "ok", "deleted": deleted})
@@ -270,6 +287,8 @@ func (h *Handler) writeAuthFile(ctx context.Context, name string, data []byte) e
if err != nil {
return err
}
+ release := h.beginSIWCFileMutation(ctx, dst)
+ defer release()
if errWrite := os.WriteFile(dst, data, 0o600); errWrite != nil {
return fmt.Errorf("failed to write file: %w", errWrite)
}
@@ -371,6 +390,8 @@ func (h *Handler) deleteAuthFileByName(ctx context.Context, name string) (string
targetPath = abs
}
}
+ release := h.beginSIWCFileMutation(ctx, targetPath)
+ defer release()
if errRemove := os.Remove(targetPath); errRemove != nil {
if os.IsNotExist(errRemove) {
return filepath.Base(name), http.StatusNotFound, errAuthFileNotFound
@@ -484,9 +505,13 @@ func (h *Handler) buildAuthFromFileData(path string, data []byte) (*coreauth.Aut
}
metadata := make(map[string]any)
if err := json.Unmarshal(data, &metadata); err != nil {
- return nil, fmt.Errorf("invalid auth file: %w", err)
+ return nil, fmt.Errorf("%w: %w", errInvalidAuthFile, err)
}
coreauth.NormalizeCredentialMetadata(metadata)
+ // Loading tolerates invalid exclusion rules, so reject them here before anything is persisted.
+ if _, errExcluded := synthesizer.ExtractExcludedModelsFromMetadata(metadata); errExcluded != nil {
+ return nil, fmt.Errorf("%w: %w", errInvalidAuthFile, errExcluded)
+ }
provider, _ := metadata["type"].(string)
if provider == "" {
provider = "unknown"
@@ -511,7 +536,7 @@ func (h *Handler) buildAuthFromFileData(path string, data []byte) (*coreauth.Aut
}
generated, errSynthesize := synthesizer.SynthesizeAuthFile(sctx, path, data)
if errSynthesize != nil {
- return nil, fmt.Errorf("invalid auth file: %w", errSynthesize)
+ return nil, fmt.Errorf("%w: %w", errInvalidAuthFile, errSynthesize)
}
if len(generated) > 0 && generated[0] != nil {
auth = generated[0].Clone()
diff --git a/internal/api/handlers/management/auth_files_excluded_models_test.go b/internal/api/handlers/management/auth_files_excluded_models_test.go
new file mode 100644
index 00000000000..f93a1a04b47
--- /dev/null
+++ b/internal/api/handlers/management/auth_files_excluded_models_test.go
@@ -0,0 +1,221 @@
+package management
+
+import (
+ "bytes"
+ "context"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/watcher/diff"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+)
+
+func newExcludedModelsPatchTestHandler(t *testing.T, kind string) (*Handler, *coreauth.Manager, *coreauth.Auth) {
+ t.Helper()
+ manager := coreauth.NewManager(nil, nil, nil)
+ record := &coreauth.Auth{
+ ID: "credential.json", FileName: "credential.json", Provider: "codex",
+ Attributes: map[string]string{"auth_kind": kind, "excluded_models": "stale-*", "excluded_models_hash": "stale-hash"},
+ Metadata: map[string]any{"excluded-models": []string{"old-*"}},
+ }
+ if _, err := manager.Register(context.Background(), record); err != nil {
+ t.Fatal(err)
+ }
+ // Global rules must never be copied into the credential attributes.
+ h := NewHandlerWithoutConfigFilePath(&config.Config{OAuthExcludedModels: map[string][]string{"codex": {"global-*"}}}, manager)
+ return h, manager, record
+}
+
+func patchExcludedModelsFields(h *Handler, fields string) *httptest.ResponseRecorder {
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPatch, "/v8/management/credentials/fields", strings.NewReader(`{"name":"credential.json",`+fields+`}`))
+ c.Request.Header.Set("Content-Type", "application/json")
+ h.PatchAuthFileFields(c)
+ return rec
+}
+
+func TestPatchAuthFileFieldsExcludedModelsRuntime(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ for _, tt := range []struct {
+ name string
+ fields string
+ kind string
+ want string
+ }{
+ {"override", `"excluded_models":[" Local-* ","LOCAL-*"]`, "oauth", "local-*"},
+ {"alias override", `"excluded-models":["local-*"]`, "oauth", "local-*"},
+ {"canonical wins", `"excluded_models":[],"excluded-models":["alias-*"]`, "oauth", ""},
+ {"empty clears", `"excluded_models":[]`, "oauth", ""},
+ {"whitespace clears", `"excluded_models":[" ","\t"]`, "oauth", ""},
+ {"null clears", `"excluded_models":null`, "oauth", ""},
+ {"apikey override", `"excluded_models":["local-*"]`, "apikey", "local-*"},
+ {"apikey clear", `"excluded_models":[]`, "apikey", ""},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ h, manager, record := newExcludedModelsPatchTestHandler(t, tt.kind)
+ hookCalled := false
+ check := func(auth *coreauth.Auth) {
+ t.Helper()
+ if got := auth.Attributes["excluded_models"]; got != tt.want {
+ t.Errorf("excluded_models = %q, want %q", got, tt.want)
+ }
+ var rules []string
+ if tt.want != "" {
+ rules = strings.Split(tt.want, ",")
+ }
+ if got, want := auth.Attributes["excluded_models_hash"], diff.ComputeExcludedModelsHash(rules); got != want {
+ t.Errorf("excluded_models_hash = %q, want %q", got, want)
+ }
+ if tt.want == "" {
+ for _, key := range []string{"excluded_models", "excluded_models_hash"} {
+ if _, exists := auth.Attributes[key]; exists {
+ t.Errorf("stale attribute %s retained", key)
+ }
+ }
+ }
+ if got := auth.Attributes["auth_kind"]; got != tt.kind {
+ t.Errorf("auth_kind = %q, want %q", got, tt.kind)
+ }
+ }
+ h.postAuthPersistHook = func(_ context.Context, auth *coreauth.Auth) error { hookCalled = true; check(auth); return nil }
+ rec := patchExcludedModelsFields(h, tt.fields)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d: %s", rec.Code, rec.Body.String())
+ }
+ updated, ok := manager.GetByID(record.ID)
+ if !ok {
+ t.Fatal("updated auth missing")
+ }
+ check(updated)
+ if !hookCalled {
+ t.Error("post-persist hook not called")
+ }
+ if _, exists := updated.Metadata["excluded-models"]; exists {
+ t.Error("legacy alias retained")
+ }
+ })
+ }
+}
+
+func TestUploadAuthFileRejectsInvalidExcludedModels(t *testing.T) {
+ t.Setenv("MANAGEMENT_PASSWORD", "")
+ gin.SetMode(gin.TestMode)
+ for _, content := range []string{
+ `{"type":"codex","email":"bad@example.test","excluded_models":123}`,
+ `{"type":"gemini-cli","email":"bad@example.test","excluded_models":123}`,
+ `{"type":"codex","email":"bad@example.test","excluded_models":["codex-new",1]}`,
+ } {
+ for _, form := range []string{"multipart", "raw"} {
+ t.Run(form+"/"+content, func(t *testing.T) {
+ authDir := t.TempDir()
+ manager := coreauth.NewManager(nil, nil, nil)
+ h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ if form == "multipart" {
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+ part, errCreate := writer.CreateFormFile("file", "bad.json")
+ if errCreate != nil {
+ t.Fatal(errCreate)
+ }
+ if _, errWrite := part.Write([]byte(content)); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ if errClose := writer.Close(); errClose != nil {
+ t.Fatal(errClose)
+ }
+ c.Request = httptest.NewRequest(http.MethodPost, "/v8/management/credentials", &body)
+ c.Request.Header.Set("Content-Type", writer.FormDataContentType())
+ } else {
+ c.Request = httptest.NewRequest(http.MethodPost, "/v8/management/credentials?name=bad.json", strings.NewReader(content))
+ c.Request.Header.Set("Content-Type", "application/json")
+ }
+ h.UploadAuthFile(c)
+
+ // Invalid client input is a 400, not a server failure.
+ if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "excluded_models") || !strings.Contains(rec.Body.String(), "must be a string") {
+ t.Fatalf("status = %d body = %s, want 400 excluded_models validation error", rec.Code, rec.Body.String())
+ }
+ if entries, errRead := os.ReadDir(authDir); errRead != nil || len(entries) != 0 {
+ t.Fatalf("auth dir entries = %v (err %v), want no file written", entries, errRead)
+ }
+ if auths := manager.List(); len(auths) != 0 {
+ t.Fatalf("registered auths = %d, want none", len(auths))
+ }
+ })
+ }
+ }
+}
+
+func TestPatchAuthFileFieldsRejectsConfigCredential(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ manager := coreauth.NewManager(nil, nil, nil)
+ record := &coreauth.Auth{
+ ID: "credential.json", Provider: "claude",
+ Attributes: map[string]string{"auth_kind": "apikey", "source": "config:claude[token]", "excluded_models": "config-*"},
+ }
+ if _, err := manager.Register(context.Background(), record); err != nil {
+ t.Fatal(err)
+ }
+ h := NewHandlerWithoutConfigFilePath(&config.Config{}, manager)
+ before, _ := manager.GetByID(record.ID)
+ h.postAuthPersistHook = func(context.Context, *coreauth.Auth) error {
+ t.Error("post-persist hook called for config credential")
+ return nil
+ }
+
+ // Config credentials are not auth files, so this endpoint must not find them.
+ rec := patchExcludedModelsFields(h, `"excluded_models":[]`)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("status = %d: %s, want 404", rec.Code, rec.Body.String())
+ }
+ after, _ := manager.GetByID(record.ID)
+ if !reflect.DeepEqual(after.Attributes, before.Attributes) || !reflect.DeepEqual(after.Metadata, before.Metadata) {
+ t.Fatalf("config credential changed: before=%+v/%+v after=%+v/%+v", before.Attributes, before.Metadata, after.Attributes, after.Metadata)
+ }
+}
+
+func TestPatchAuthFileFieldsRejectsInvalidExcludedModels(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ for _, fields := range []string{
+ `"abc":true,"excluded_models":123`,
+ `"abc":true,"excluded-models":true`,
+ `"abc":true,"excluded_models":{}`,
+ `"abc":true,"excluded_models":["codex-new",null]`,
+ `"abc":true,"excluded_models":[42,false]`,
+ `"abc":true,"excluded_models":[["codex-new"]]`,
+ `"abc":true,"excluded_models":"codex-new"`,
+ `"abc":true,"excluded_models":" codex-a , codex-b "`,
+ `"abc":true,"excluded-models":"codex-new"`,
+ `"abc":true,"excluded_models":" "`,
+ `"abc":true,"excluded_models.child":["codex-new"]`,
+ `"abc":true,"excluded-models.0":"codex-new"`,
+ } {
+ t.Run(fields, func(t *testing.T) {
+ h, manager, record := newExcludedModelsPatchTestHandler(t, "oauth")
+ before, _ := manager.GetByID(record.ID)
+ h.postAuthPersistHook = func(context.Context, *coreauth.Auth) error {
+ t.Error("post-persist hook called for rejected patch")
+ return nil
+ }
+ rec := patchExcludedModelsFields(h, fields)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d: %s, want 400", rec.Code, rec.Body.String())
+ }
+ after, _ := manager.GetByID(record.ID)
+ if !reflect.DeepEqual(after.Attributes, before.Attributes) || !reflect.DeepEqual(after.Metadata, before.Metadata) {
+ t.Fatalf("auth changed after rejected patch: before=%+v/%+v after=%+v/%+v", before.Attributes, before.Metadata, after.Attributes, after.Metadata)
+ }
+ })
+ }
+}
diff --git a/internal/api/handlers/management/auth_files_fields.go b/internal/api/handlers/management/auth_files_fields.go
index 27496777512..766202a3b8a 100644
--- a/internal/api/handlers/management/auth_files_fields.go
+++ b/internal/api/handlers/management/auth_files_fields.go
@@ -126,19 +126,21 @@ func (h *Handler) PatchAuthFileStatus(c *gin.Context) {
return
}
- applyAuthDisabledState(targetAuth, *req.Disabled)
- updatedAuth, err := h.authManager.Update(ctx, targetAuth)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update auth: %v", err)})
+ updatedAuth, committed, errModify := h.authManager.Modify(ctx, targetAuth.ID, targetAuth.RegistrationEpoch, func(current *coreauth.Auth) error {
+ applyAuthDisabledState(current, *req.Disabled)
+ return nil
+ })
+ if errModify != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update auth: %v", errModify)})
return
}
- hookAuth := updatedAuth
- if hookAuth == nil {
- hookAuth = targetAuth
+ if !committed || updatedAuth == nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "auth file not found"})
+ return
}
locked = false
h.authStatusMu.Unlock()
- if errHook := h.invokePostAuthPersistHooks(ctx, []*coreauth.Auth{hookAuth}); errHook != nil {
+ if errHook := h.invokePostAuthPersistHooks(ctx, []*coreauth.Auth{updatedAuth}); errHook != nil {
log.Errorf("post-auth persist hook failed for status update on %s: %v", targetAuth.ID, errHook)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to synchronize auth runtime: %v", errHook)})
return
@@ -176,17 +178,18 @@ func (h *Handler) patchPluginVirtualSourceStatus(ctx context.Context, targetAuth
!sameAuthFilePath(authAttribute(auth, coreauth.AttributeVirtualSource), sourcePath) {
continue
}
- applyAuthDisabledState(auth, disabled)
- auth.UpdatedAt = now
- updated, errUpdate := h.authManager.Update(ctx, auth)
+ updated, committed, errUpdate := h.authManager.Modify(ctx, auth.ID, auth.RegistrationEpoch, func(current *coreauth.Auth) error {
+ applyAuthDisabledState(current, disabled)
+ current.UpdatedAt = now
+ return nil
+ })
if errUpdate != nil {
return nil, fmt.Errorf("failed to update auth %s: %w", auth.ID, errUpdate)
}
- hookAuth := updated
- if hookAuth == nil {
- hookAuth = auth
+ if !committed || updated == nil {
+ continue
}
- hookAuths = append(hookAuths, hookAuth)
+ hookAuths = append(hookAuths, updated)
}
return hookAuths, nil
}
@@ -319,7 +322,8 @@ func (h *Handler) PatchAuthFileFields(c *gin.Context) {
}
}
- if targetAuth == nil {
+ // Config-sourced credentials are managed through config endpoints, not as auth files.
+ if targetAuth == nil || targetAuth.AuthSourceKind() == coreauth.AuthSourceConfig {
c.JSON(http.StatusNotFound, gin.H{"error": "auth file not found"})
return
}
@@ -327,6 +331,42 @@ func (h *Handler) PatchAuthFileFields(c *gin.Context) {
c.JSON(http.StatusConflict, gin.H{"error": errPluginVirtualAuth.Error()})
return
}
+
+ updatedAuth, committed, errModify := h.authManager.Modify(ctx, targetAuth.ID, targetAuth.RegistrationEpoch, func(current *coreauth.Auth) error {
+ return applyAuthFileFieldsPatch(current, req, requestRetryPatch)
+ })
+ if errModify != nil {
+ var patchErr *authFileFieldPatchError
+ if errors.As(errModify, &patchErr) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": patchErr.message})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update auth: %v", errModify)})
+ return
+ }
+ if !committed || updatedAuth == nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "auth file not found"})
+ return
+ }
+ if h.postAuthPersistHook != nil {
+ if errHook := h.postAuthPersistHook(ctx, updatedAuth); errHook != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("post-auth persist hook failed: %v", errHook)})
+ return
+ }
+ }
+
+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
+}
+
+type authFileFieldPatchError struct {
+ message string
+}
+
+func (e *authFileFieldPatchError) Error() string {
+ return e.message
+}
+
+func applyAuthFileFieldsPatch(targetAuth *coreauth.Auth, req map[string]json.RawMessage, requestRetryPatch authFileRequestRetryPatch) error {
coreauth.NormalizeCredentialMetadata(targetAuth.Metadata)
changed := false
@@ -334,13 +374,29 @@ func (h *Handler) PatchAuthFileFields(c *gin.Context) {
for key, rawValue := range req {
fieldPath := strings.TrimSpace(key)
if fieldPath == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "field name is required"})
- return
+ return &authFileFieldPatchError{message: "field name is required"}
}
value, errDecode := decodeAuthFileFieldValue(rawValue)
if errDecode != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid field %s", fieldPath)})
- return
+ return &authFileFieldPatchError{message: fmt.Sprintf("invalid field %s", fieldPath)}
+ }
+ if rootAuthFileField(fieldPath) == "excluded_models" {
+ if fieldPath != "excluded_models" {
+ return &authFileFieldPatchError{message: "excluded_models does not support nested fields"}
+ }
+ if value != nil {
+ models, okModels := value.([]any)
+ validModels := okModels
+ for _, model := range models {
+ if _, okString := model.(string); !okString {
+ validModels = false
+ break
+ }
+ }
+ if !validModels {
+ return &authFileFieldPatchError{message: "excluded_models must be a string array or null"}
+ }
+ }
}
if targetAuth.Metadata == nil {
targetAuth.Metadata = make(map[string]any)
@@ -351,24 +407,20 @@ func (h *Handler) PatchAuthFileFields(c *gin.Context) {
delete(targetAuth.Metadata, coreauth.AttributeWeight)
} else {
if _, okNumber := value.(json.Number); !okNumber {
- c.JSON(http.StatusBadRequest, gin.H{"error": "weight must be an integer"})
- return
+ return &authFileFieldPatchError{message: "weight must be an integer"}
}
weight, errWeight := credentialweight.ParseValue(value)
if errWeight != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": errWeight.Error()})
- return
+ return &authFileFieldPatchError{message: errWeight.Error()}
}
targetAuth.Metadata[coreauth.AttributeWeight] = weight
}
} else if rootAuthFileField(fieldPath) == coreauth.AttributeWeight {
- c.JSON(http.StatusBadRequest, gin.H{"error": "weight does not support nested fields"})
- return
+ return &authFileFieldPatchError{message: "weight does not support nested fields"}
} else if fieldPath == "headers" {
applyAuthFileHeadersPatch(targetAuth, value)
} else if errSet := setAuthFileMetadataValue(targetAuth.Metadata, fieldPath, value); errSet != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": errSet.Error()})
- return
+ return &authFileFieldPatchError{message: errSet.Error()}
}
if root := rootAuthFileField(fieldPath); root != "" {
touchedRoots[root] = struct{}{}
@@ -388,32 +440,26 @@ func (h *Handler) PatchAuthFileFields(c *gin.Context) {
}
if changed {
syncAuthFileMetadataFields(targetAuth, touchedRoots)
+ if _, touched := touchedRoots["excluded_models"]; touched {
+ excluded, errExcluded := synthesizer.ExtractExcludedModelsFromMetadata(targetAuth.Metadata)
+ if errExcluded != nil {
+ return &authFileFieldPatchError{message: errExcluded.Error()}
+ }
+ // ApplyAuthExcludedModelsMeta never clears attributes, so drop stale values first.
+ delete(targetAuth.Attributes, "excluded_models")
+ delete(targetAuth.Attributes, "excluded_models_hash")
+ // Store only the credential's own rules; global rules are resolved at model registration.
+ // An empty authKind keeps the existing auth_kind attribute unchanged.
+ synthesizer.ApplyAuthExcludedModelsMeta(targetAuth, excluded, "")
+ }
}
if !changed {
- c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
- return
+ return &authFileFieldPatchError{message: "no fields to update"}
}
targetAuth.UpdatedAt = time.Now()
-
- updatedAuth, err := h.authManager.Update(ctx, targetAuth)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update auth: %v", err)})
- return
- }
- if h.postAuthPersistHook != nil {
- hookAuth := updatedAuth
- if hookAuth == nil {
- hookAuth = targetAuth
- }
- if errHook := h.postAuthPersistHook(ctx, hookAuth); errHook != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("post-auth persist hook failed: %v", errHook)})
- return
- }
- }
-
- c.JSON(http.StatusOK, gin.H{"status": "ok"})
+ return nil
}
func decodeAuthFileFieldValue(raw json.RawMessage) (any, error) {
diff --git a/internal/api/handlers/management/auth_files_patch_fields_test.go b/internal/api/handlers/management/auth_files_patch_fields_test.go
index a44d1bebee2..4e2331f2830 100644
--- a/internal/api/handlers/management/auth_files_patch_fields_test.go
+++ b/internal/api/handlers/management/auth_files_patch_fields_test.go
@@ -10,7 +10,9 @@ import (
"os"
"path/filepath"
"strings"
+ "sync"
"testing"
+ "testing/synctest"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
@@ -791,3 +793,722 @@ func TestPatchAuthFileFields_IDTokenMissingPlanTypeDefaultsToFree(t *testing.T)
t.Fatalf("auth plan_type attribute = %q, want free", got)
}
}
+
+type patchRegressionTokenStorage struct {
+ AccessToken string
+ RefreshToken string
+}
+
+func (s *patchRegressionTokenStorage) SaveTokenToFile(string) error { return nil }
+
+type blockingPatchAuthStore struct {
+ memoryAuthStore
+ entered chan struct{}
+ release chan struct{}
+ once sync.Once
+ listEntered chan struct{}
+ listRelease chan struct{}
+ listOnce sync.Once
+}
+
+func (s *blockingPatchAuthStore) List(ctx context.Context) ([]*coreauth.Auth, error) {
+ if s.listEntered != nil && s.listRelease != nil {
+ s.listOnce.Do(func() {
+ close(s.listEntered)
+ <-s.listRelease
+ })
+ }
+ return s.memoryAuthStore.List(ctx)
+}
+
+func (s *blockingPatchAuthStore) Save(ctx context.Context, auth *coreauth.Auth) (string, error) {
+ if s.entered != nil && s.release != nil {
+ s.once.Do(func() {
+ close(s.entered)
+ <-s.release
+ })
+ }
+ var cloned *coreauth.Auth
+ if auth != nil {
+ cloned = auth.Clone()
+ }
+ return s.memoryAuthStore.Save(ctx, cloned)
+}
+
+func (s *blockingPatchAuthStore) savedAuth(id string) *coreauth.Auth {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.items == nil {
+ return nil
+ }
+ if item := s.items[id]; item != nil {
+ return item.Clone()
+ }
+ return nil
+}
+
+func TestPatchAuthFileFields_KeepsCommitLandedDuringPatch(t *testing.T) {
+ t.Setenv("MANAGEMENT_PASSWORD", "")
+ gin.SetMode(gin.TestMode)
+
+ t.Run("concurrent_fields_patch", func(t *testing.T) {
+ authDir := t.TempDir()
+ h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, nil)
+ synctest.Test(t, func(t *testing.T) {
+ store := &blockingPatchAuthStore{}
+ manager := coreauth.NewManager(store, nil, nil)
+ h.authManager = manager
+ base, errRegister := manager.Register(context.Background(), &coreauth.Auth{
+ ID: "cred.json",
+ FileName: "cred.json",
+ Provider: "codex",
+ Status: coreauth.StatusActive,
+ Attributes: map[string]string{
+ "path": "/tmp/cred.json",
+ },
+ Metadata: map[string]any{
+ "type": "codex",
+ "access_token": "tok-initial",
+ "refresh_token": "rt-initial",
+ },
+ })
+ if errRegister != nil {
+ t.Fatalf("Register() error = %v", errRegister)
+ }
+ _ = base
+ store.entered = make(chan struct{})
+ store.release = make(chan struct{})
+
+ firstDone := make(chan int, 1)
+ go func() {
+ rec := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(rec)
+ req := httptest.NewRequest(http.MethodPatch, "/v8/management/credentials/fields", strings.NewReader(`{"name":"cred.json","prefix":"p1"}`))
+ req.Header.Set("Content-Type", "application/json")
+ ctx.Request = req
+ h.PatchAuthFileFields(ctx)
+ firstDone <- rec.Code
+ }()
+ <-store.entered
+
+ secondStarted := make(chan struct{})
+ secondDone := make(chan int, 1)
+ go func() {
+ close(secondStarted)
+ rec := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(rec)
+ req := httptest.NewRequest(http.MethodPatch, "/v8/management/credentials/fields", strings.NewReader(`{"name":"cred.json","note":"second-note"}`))
+ req.Header.Set("Content-Type", "application/json")
+ ctx.Request = req
+ h.PatchAuthFileFields(ctx)
+ secondDone <- rec.Code
+ }()
+ <-secondStarted
+ synctest.Wait()
+
+ close(store.release)
+ if code := <-firstDone; code != http.StatusOK {
+ t.Fatalf("first PATCH status = %d, want 200", code)
+ }
+ if code := <-secondDone; code != http.StatusOK {
+ t.Fatalf("second PATCH status = %d, want 200", code)
+ }
+
+ for label, got := range map[string]*coreauth.Auth{
+ "runtime": mustGetAuthByID(t, manager, "cred.json"),
+ "stored": store.savedAuth("cred.json"),
+ } {
+ if got == nil {
+ t.Fatalf("%s auth is nil", label)
+ }
+ if got.Prefix != "p1" || got.Metadata["prefix"] != "p1" {
+ t.Fatalf("%s lost concurrent prefix: prefix=%q metadata.prefix=%#v", label, got.Prefix, got.Metadata["prefix"])
+ }
+ if got.Metadata["note"] != "second-note" || got.Attributes["note"] != "second-note" {
+ t.Fatalf("%s missing second patch note: metadata.note=%#v attr.note=%q", label, got.Metadata["note"], got.Attributes["note"])
+ }
+ }
+ })
+ })
+
+ t.Run("concurrent_refresh", func(t *testing.T) {
+ authDir := t.TempDir()
+ h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, nil)
+ synctest.Test(t, func(t *testing.T) {
+ store := &blockingPatchAuthStore{}
+ manager := coreauth.NewManager(store, nil, nil)
+ h.authManager = manager
+ initialStorage := &patchRegressionTokenStorage{AccessToken: "tok-initial", RefreshToken: "rt-initial"}
+ base, errRegister := manager.Register(context.Background(), &coreauth.Auth{
+ ID: "cred.json",
+ FileName: "cred.json",
+ Provider: "codex",
+ Status: coreauth.StatusActive,
+ Storage: initialStorage,
+ Attributes: map[string]string{
+ "path": "/tmp/cred.json",
+ },
+ Metadata: map[string]any{
+ "type": "codex",
+ "access_token": "tok-initial",
+ "refresh_token": "rt-initial",
+ },
+ })
+ if errRegister != nil {
+ t.Fatalf("Register() error = %v", errRegister)
+ }
+ store.entered = make(chan struct{})
+ store.release = make(chan struct{})
+
+ refreshedStorage := &patchRegressionTokenStorage{AccessToken: "tok-refreshed", RefreshToken: "rt-refreshed"}
+ refreshed := base.Clone()
+ refreshed.Storage = refreshedStorage
+ refreshed.Metadata["access_token"] = "tok-refreshed"
+ refreshed.Metadata["refresh_token"] = "rt-refreshed"
+
+ refreshDone := make(chan error, 1)
+ go func() {
+ _, errRefresh := manager.UpdateRefreshedAuth(context.Background(), base, refreshed)
+ refreshDone <- errRefresh
+ }()
+ <-store.entered
+
+ patchStarted := make(chan struct{})
+ patchDone := make(chan int, 1)
+ go func() {
+ close(patchStarted)
+ rec := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(rec)
+ req := httptest.NewRequest(http.MethodPatch, "/v8/management/credentials/fields", strings.NewReader(`{"name":"cred.json","prefix":"p1"}`))
+ req.Header.Set("Content-Type", "application/json")
+ ctx.Request = req
+ h.PatchAuthFileFields(ctx)
+ patchDone <- rec.Code
+ }()
+ <-patchStarted
+ synctest.Wait()
+
+ close(store.release)
+ if errRefresh := <-refreshDone; errRefresh != nil {
+ t.Fatalf("UpdateRefreshedAuth() error = %v", errRefresh)
+ }
+ if code := <-patchDone; code != http.StatusOK {
+ t.Fatalf("PATCH status = %d, want 200", code)
+ }
+
+ for label, got := range map[string]*coreauth.Auth{
+ "runtime": mustGetAuthByID(t, manager, "cred.json"),
+ "stored": store.savedAuth("cred.json"),
+ } {
+ if got == nil {
+ t.Fatalf("%s auth is nil", label)
+ }
+ if got.Prefix != "p1" || got.Metadata["prefix"] != "p1" {
+ t.Fatalf("%s missing patched prefix: prefix=%q metadata.prefix=%#v", label, got.Prefix, got.Metadata["prefix"])
+ }
+ if got.Metadata["access_token"] != "tok-refreshed" || got.Metadata["refresh_token"] != "rt-refreshed" {
+ t.Fatalf("%s rolled back refreshed tokens: access_token=%#v refresh_token=%#v", label, got.Metadata["access_token"], got.Metadata["refresh_token"])
+ }
+ gotStorage, okStorage := got.Storage.(*patchRegressionTokenStorage)
+ if !okStorage || gotStorage == nil || gotStorage.AccessToken != "tok-refreshed" || gotStorage.RefreshToken != "rt-refreshed" {
+ t.Fatalf("%s rolled back refreshed Storage: %#v", label, got.Storage)
+ }
+ if got.CredentialVersion != 2 {
+ t.Fatalf("%s CredentialVersion = %d, want 2", label, got.CredentialVersion)
+ }
+ }
+ })
+ })
+
+ t.Run("concurrent_remove_answers_404", func(t *testing.T) {
+ authDir := t.TempDir()
+ h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, nil)
+ synctest.Test(t, func(t *testing.T) {
+ store := &blockingPatchAuthStore{}
+ manager := coreauth.NewManager(store, nil, nil)
+ h.authManager = manager
+ base, errRegister := manager.Register(context.Background(), &coreauth.Auth{
+ ID: "cred.json",
+ FileName: "cred.json",
+ Provider: "codex",
+ Status: coreauth.StatusActive,
+ Attributes: map[string]string{
+ "path": "/tmp/cred.json",
+ },
+ Metadata: map[string]any{
+ "type": "codex",
+ },
+ })
+ if errRegister != nil {
+ t.Fatalf("Register() error = %v", errRegister)
+ }
+ store.entered = make(chan struct{})
+ store.release = make(chan struct{})
+
+ hookCalled := false
+ h.SetPostAuthPersistHook(func(_ context.Context, _ *coreauth.Auth) error {
+ hookCalled = true
+ return nil
+ })
+
+ saveDone := make(chan error, 1)
+ go func() {
+ _, errUpdate := manager.Update(context.Background(), base.Clone())
+ saveDone <- errUpdate
+ }()
+ <-store.entered
+
+ removeStarted := make(chan struct{})
+ removeDone := make(chan struct{})
+ go func() {
+ close(removeStarted)
+ manager.Remove(context.Background(), "cred.json")
+ close(removeDone)
+ }()
+ <-removeStarted
+ synctest.Wait()
+
+ patchStarted := make(chan struct{})
+ patchDone := make(chan int, 1)
+ go func() {
+ close(patchStarted)
+ rec := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(rec)
+ req := httptest.NewRequest(http.MethodPatch, "/v8/management/credentials/fields", strings.NewReader(`{"name":"cred.json","prefix":"p1"}`))
+ req.Header.Set("Content-Type", "application/json")
+ ctx.Request = req
+ h.PatchAuthFileFields(ctx)
+ patchDone <- rec.Code
+ }()
+ <-patchStarted
+ synctest.Wait()
+
+ close(store.release)
+ if errUpdate := <-saveDone; errUpdate != nil {
+ t.Fatalf("Update() error = %v", errUpdate)
+ }
+ <-removeDone
+ if code := <-patchDone; code != http.StatusNotFound {
+ t.Fatalf("PATCH status after concurrent Remove = %d, want 404", code)
+ }
+ if hookCalled {
+ t.Fatal("postAuthPersistHook must not run when auth was removed before commit")
+ }
+ if _, ok := manager.GetByID("cred.json"); ok {
+ t.Fatal("removed auth must not be resurrected in manager")
+ }
+ })
+ })
+
+ t.Run("concurrent_reregister_preserves_epoch_check", func(t *testing.T) {
+ authDir := t.TempDir()
+ h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, nil)
+ synctest.Test(t, func(t *testing.T) {
+ store := &blockingPatchAuthStore{}
+ manager := coreauth.NewManager(store, nil, nil)
+ h.authManager = manager
+ _, errRegister := manager.Register(context.Background(), &coreauth.Auth{
+ ID: "cred.json",
+ FileName: "cred.json",
+ Provider: "codex",
+ Status: coreauth.StatusActive,
+ Attributes: map[string]string{
+ "path": "/tmp/cred.json",
+ },
+ Metadata: map[string]any{
+ "type": "codex",
+ "access_token": "tok-v1",
+ },
+ })
+ if errRegister != nil {
+ t.Fatalf("Register() error = %v", errRegister)
+ }
+ // Stage replacement credential in store for Load().
+ _, _ = store.memoryAuthStore.Save(context.Background(), &coreauth.Auth{
+ ID: "cred.json",
+ FileName: "cred.json",
+ Provider: "codex",
+ Status: coreauth.StatusActive,
+ Attributes: map[string]string{
+ "path": "/tmp/cred.json",
+ },
+ Metadata: map[string]any{
+ "type": "codex",
+ "access_token": "tok-v2",
+ },
+ })
+ store.listEntered = make(chan struct{})
+ store.listRelease = make(chan struct{})
+
+ loadDone := make(chan error, 1)
+ go func() {
+ loadDone <- manager.Load(context.Background())
+ }()
+ <-store.listEntered
+
+ patchStarted := make(chan struct{})
+ patchDone := make(chan int, 1)
+ go func() {
+ close(patchStarted)
+ rec := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(rec)
+ req := httptest.NewRequest(http.MethodPatch, "/v8/management/credentials/fields", strings.NewReader(`{"name":"cred.json","prefix":"stale-prefix"}`))
+ req.Header.Set("Content-Type", "application/json")
+ ctx.Request = req
+ h.PatchAuthFileFields(ctx)
+ patchDone <- rec.Code
+ }()
+ <-patchStarted
+ synctest.Wait()
+
+ close(store.listRelease)
+ if errLoad := <-loadDone; errLoad != nil {
+ t.Fatalf("Load() error = %v", errLoad)
+ }
+ if code := <-patchDone; code == http.StatusOK {
+ t.Fatalf("expected stale-epoch PATCH to be rejected after replacement registration, got 200")
+ }
+
+ got := mustGetAuthByID(t, manager, "cred.json")
+ if got.Prefix != "" || got.Metadata["access_token"] != "tok-v2" {
+ t.Fatalf("stale PATCH landed on replacement registration: prefix=%q access_token=%#v", got.Prefix, got.Metadata["access_token"])
+ }
+ })
+ })
+}
+
+func TestPatchAuthFileStatus_KeepsCommitLandedDuringPatch(t *testing.T) {
+ t.Setenv("MANAGEMENT_PASSWORD", "")
+ gin.SetMode(gin.TestMode)
+
+ t.Run("concurrent_fields_patch", func(t *testing.T) {
+ authDir := t.TempDir()
+ h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, nil)
+ synctest.Test(t, func(t *testing.T) {
+ store := &blockingPatchAuthStore{}
+ manager := coreauth.NewManager(store, nil, nil)
+ h.authManager = manager
+ _, errRegister := manager.Register(context.Background(), &coreauth.Auth{
+ ID: "cred.json",
+ FileName: "cred.json",
+ Provider: "codex",
+ Status: coreauth.StatusActive,
+ Attributes: map[string]string{
+ "path": "/tmp/cred.json",
+ },
+ Metadata: map[string]any{
+ "type": "codex",
+ "disabled": false,
+ },
+ })
+ if errRegister != nil {
+ t.Fatalf("Register() error = %v", errRegister)
+ }
+ store.entered = make(chan struct{})
+ store.release = make(chan struct{})
+
+ fieldsDone := make(chan int, 1)
+ go func() {
+ rec := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(rec)
+ req := httptest.NewRequest(http.MethodPatch, "/v8/management/credentials/fields", strings.NewReader(`{"name":"cred.json","prefix":"p1"}`))
+ req.Header.Set("Content-Type", "application/json")
+ ctx.Request = req
+ h.PatchAuthFileFields(ctx)
+ fieldsDone <- rec.Code
+ }()
+ <-store.entered
+
+ statusStarted := make(chan struct{})
+ statusDone := make(chan int, 1)
+ go func() {
+ close(statusStarted)
+ rec := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(rec)
+ req := httptest.NewRequest(http.MethodPatch, "/v8/management/credentials/status", strings.NewReader(`{"name":"cred.json","disabled":true}`))
+ req.Header.Set("Content-Type", "application/json")
+ ctx.Request = req
+ h.PatchAuthFileStatus(ctx)
+ statusDone <- rec.Code
+ }()
+ <-statusStarted
+ synctest.Wait()
+
+ close(store.release)
+ if code := <-fieldsDone; code != http.StatusOK {
+ t.Fatalf("fields PATCH status = %d, want 200", code)
+ }
+ if code := <-statusDone; code != http.StatusOK {
+ t.Fatalf("status PATCH status = %d, want 200", code)
+ }
+
+ for label, got := range map[string]*coreauth.Auth{
+ "runtime": mustGetAuthByID(t, manager, "cred.json"),
+ "stored": store.savedAuth("cred.json"),
+ } {
+ if got == nil {
+ t.Fatalf("%s auth is nil", label)
+ }
+ if got.Prefix != "p1" || got.Metadata["prefix"] != "p1" {
+ t.Fatalf("%s lost concurrent prefix: prefix=%q metadata.prefix=%#v", label, got.Prefix, got.Metadata["prefix"])
+ }
+ if !got.Disabled || got.Status != coreauth.StatusDisabled || got.Metadata["disabled"] != true {
+ t.Fatalf("%s missing disabled status: disabled=%v status=%v metadata.disabled=%#v", label, got.Disabled, got.Status, got.Metadata["disabled"])
+ }
+ }
+ })
+ })
+
+ t.Run("concurrent_refresh", func(t *testing.T) {
+ authDir := t.TempDir()
+ h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, nil)
+ synctest.Test(t, func(t *testing.T) {
+ store := &blockingPatchAuthStore{}
+ manager := coreauth.NewManager(store, nil, nil)
+ h.authManager = manager
+ initialStorage := &patchRegressionTokenStorage{AccessToken: "tok-initial", RefreshToken: "rt-initial"}
+ base, errRegister := manager.Register(context.Background(), &coreauth.Auth{
+ ID: "cred.json",
+ FileName: "cred.json",
+ Provider: "codex",
+ Status: coreauth.StatusActive,
+ Storage: initialStorage,
+ Attributes: map[string]string{
+ "path": "/tmp/cred.json",
+ },
+ Metadata: map[string]any{
+ "type": "codex",
+ "access_token": "tok-initial",
+ "refresh_token": "rt-initial",
+ "disabled": false,
+ },
+ })
+ if errRegister != nil {
+ t.Fatalf("Register() error = %v", errRegister)
+ }
+ store.entered = make(chan struct{})
+ store.release = make(chan struct{})
+
+ refreshedStorage := &patchRegressionTokenStorage{AccessToken: "tok-refreshed", RefreshToken: "rt-refreshed"}
+ refreshed := base.Clone()
+ refreshed.Storage = refreshedStorage
+ refreshed.Metadata["access_token"] = "tok-refreshed"
+ refreshed.Metadata["refresh_token"] = "rt-refreshed"
+
+ refreshDone := make(chan error, 1)
+ go func() {
+ _, errRefresh := manager.UpdateRefreshedAuth(context.Background(), base, refreshed)
+ refreshDone <- errRefresh
+ }()
+ <-store.entered
+
+ patchStarted := make(chan struct{})
+ patchDone := make(chan int, 1)
+ go func() {
+ close(patchStarted)
+ rec := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(rec)
+ req := httptest.NewRequest(http.MethodPatch, "/v8/management/credentials/status", strings.NewReader(`{"name":"cred.json","disabled":true}`))
+ req.Header.Set("Content-Type", "application/json")
+ ctx.Request = req
+ h.PatchAuthFileStatus(ctx)
+ patchDone <- rec.Code
+ }()
+ <-patchStarted
+ synctest.Wait()
+
+ close(store.release)
+ if errRefresh := <-refreshDone; errRefresh != nil {
+ t.Fatalf("UpdateRefreshedAuth() error = %v", errRefresh)
+ }
+ if code := <-patchDone; code != http.StatusOK {
+ t.Fatalf("PATCH status = %d, want 200", code)
+ }
+
+ for label, got := range map[string]*coreauth.Auth{
+ "runtime": mustGetAuthByID(t, manager, "cred.json"),
+ "stored": store.savedAuth("cred.json"),
+ } {
+ if got == nil {
+ t.Fatalf("%s auth is nil", label)
+ }
+ if !got.Disabled || got.Status != coreauth.StatusDisabled || got.Metadata["disabled"] != true {
+ t.Fatalf("%s missing disabled status: disabled=%v status=%v metadata.disabled=%#v", label, got.Disabled, got.Status, got.Metadata["disabled"])
+ }
+ if got.Metadata["access_token"] != "tok-refreshed" || got.Metadata["refresh_token"] != "rt-refreshed" {
+ t.Fatalf("%s rolled back refreshed tokens: access_token=%#v refresh_token=%#v", label, got.Metadata["access_token"], got.Metadata["refresh_token"])
+ }
+ gotStorage, okStorage := got.Storage.(*patchRegressionTokenStorage)
+ if !okStorage || gotStorage == nil || gotStorage.AccessToken != "tok-refreshed" || gotStorage.RefreshToken != "rt-refreshed" {
+ t.Fatalf("%s rolled back refreshed Storage: %#v", label, got.Storage)
+ }
+ if got.CredentialVersion != 2 {
+ t.Fatalf("%s CredentialVersion = %d, want 2", label, got.CredentialVersion)
+ }
+ }
+ })
+ })
+
+ t.Run("concurrent_remove_answers_404", func(t *testing.T) {
+ authDir := t.TempDir()
+ h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, nil)
+ synctest.Test(t, func(t *testing.T) {
+ store := &blockingPatchAuthStore{}
+ manager := coreauth.NewManager(store, nil, nil)
+ h.authManager = manager
+ base, errRegister := manager.Register(context.Background(), &coreauth.Auth{
+ ID: "cred.json",
+ FileName: "cred.json",
+ Provider: "codex",
+ Status: coreauth.StatusActive,
+ Attributes: map[string]string{
+ "path": "/tmp/cred.json",
+ },
+ Metadata: map[string]any{
+ "type": "codex",
+ },
+ })
+ if errRegister != nil {
+ t.Fatalf("Register() error = %v", errRegister)
+ }
+ store.entered = make(chan struct{})
+ store.release = make(chan struct{})
+
+ hookCalled := false
+ h.SetPostAuthPersistHook(func(_ context.Context, _ *coreauth.Auth) error {
+ hookCalled = true
+ return nil
+ })
+
+ saveDone := make(chan error, 1)
+ go func() {
+ _, errUpdate := manager.Update(context.Background(), base.Clone())
+ saveDone <- errUpdate
+ }()
+ <-store.entered
+
+ removeStarted := make(chan struct{})
+ removeDone := make(chan struct{})
+ go func() {
+ close(removeStarted)
+ manager.Remove(context.Background(), "cred.json")
+ close(removeDone)
+ }()
+ <-removeStarted
+ synctest.Wait()
+
+ patchStarted := make(chan struct{})
+ patchDone := make(chan int, 1)
+ go func() {
+ close(patchStarted)
+ rec := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(rec)
+ req := httptest.NewRequest(http.MethodPatch, "/v8/management/credentials/status", strings.NewReader(`{"name":"cred.json","disabled":true}`))
+ req.Header.Set("Content-Type", "application/json")
+ ctx.Request = req
+ h.PatchAuthFileStatus(ctx)
+ patchDone <- rec.Code
+ }()
+ <-patchStarted
+ synctest.Wait()
+
+ close(store.release)
+ if errUpdate := <-saveDone; errUpdate != nil {
+ t.Fatalf("Update() error = %v", errUpdate)
+ }
+ <-removeDone
+ if code := <-patchDone; code != http.StatusNotFound {
+ t.Fatalf("PATCH status after concurrent Remove = %d, want 404", code)
+ }
+ if hookCalled {
+ t.Fatal("postAuthPersistHook must not run when auth was removed before commit")
+ }
+ })
+ })
+
+ t.Run("concurrent_reregister_preserves_epoch_check", func(t *testing.T) {
+ authDir := t.TempDir()
+ h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, nil)
+ synctest.Test(t, func(t *testing.T) {
+ store := &blockingPatchAuthStore{}
+ manager := coreauth.NewManager(store, nil, nil)
+ h.authManager = manager
+ _, errRegister := manager.Register(context.Background(), &coreauth.Auth{
+ ID: "cred.json",
+ FileName: "cred.json",
+ Provider: "codex",
+ Status: coreauth.StatusActive,
+ Attributes: map[string]string{
+ "path": "/tmp/cred.json",
+ },
+ Metadata: map[string]any{
+ "type": "codex",
+ "access_token": "tok-v1",
+ "disabled": false,
+ },
+ })
+ if errRegister != nil {
+ t.Fatalf("Register() error = %v", errRegister)
+ }
+ _, _ = store.memoryAuthStore.Save(context.Background(), &coreauth.Auth{
+ ID: "cred.json",
+ FileName: "cred.json",
+ Provider: "codex",
+ Status: coreauth.StatusActive,
+ Attributes: map[string]string{
+ "path": "/tmp/cred.json",
+ },
+ Metadata: map[string]any{
+ "type": "codex",
+ "access_token": "tok-v2",
+ "disabled": false,
+ },
+ })
+ store.listEntered = make(chan struct{})
+ store.listRelease = make(chan struct{})
+
+ loadDone := make(chan error, 1)
+ go func() {
+ loadDone <- manager.Load(context.Background())
+ }()
+ <-store.listEntered
+
+ patchStarted := make(chan struct{})
+ patchDone := make(chan int, 1)
+ go func() {
+ close(patchStarted)
+ rec := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(rec)
+ req := httptest.NewRequest(http.MethodPatch, "/v8/management/credentials/status", strings.NewReader(`{"name":"cred.json","disabled":true}`))
+ req.Header.Set("Content-Type", "application/json")
+ ctx.Request = req
+ h.PatchAuthFileStatus(ctx)
+ patchDone <- rec.Code
+ }()
+ <-patchStarted
+ synctest.Wait()
+
+ close(store.listRelease)
+ if errLoad := <-loadDone; errLoad != nil {
+ t.Fatalf("Load() error = %v", errLoad)
+ }
+ if code := <-patchDone; code == http.StatusOK {
+ t.Fatalf("expected stale-epoch PATCH to be rejected after replacement registration, got 200")
+ }
+
+ got := mustGetAuthByID(t, manager, "cred.json")
+ if got.Disabled || got.Metadata["access_token"] != "tok-v2" {
+ t.Fatalf("stale PATCH landed on replacement registration: disabled=%v access_token=%#v", got.Disabled, got.Metadata["access_token"])
+ }
+ })
+ })
+}
+
+func mustGetAuthByID(t *testing.T, manager *coreauth.Manager, id string) *coreauth.Auth {
+ t.Helper()
+ got, ok := manager.GetByID(id)
+ if !ok || got == nil {
+ t.Fatalf("expected auth %q in manager", id)
+ }
+ return got
+}
diff --git a/internal/api/handlers/management/config_basic.go b/internal/api/handlers/management/config_basic.go
index c480c730e61..a6c02c4d60d 100644
--- a/internal/api/handlers/management/config_basic.go
+++ b/internal/api/handlers/management/config_basic.go
@@ -235,8 +235,10 @@ func (h *Handler) PutLogsMaxTotalSizeMB(c *gin.Context) {
if value < 0 {
value = 0
}
+ h.mu.Lock()
+ defer h.mu.Unlock()
h.cfg.LogsMaxTotalSizeMB = value
- h.persist(c)
+ h.persistLocked(c)
}
// ErrorLogsMaxFiles
@@ -255,8 +257,10 @@ func (h *Handler) PutErrorLogsMaxFiles(c *gin.Context) {
if value < 0 {
value = 10
}
+ h.mu.Lock()
+ defer h.mu.Unlock()
h.cfg.ErrorLogsMaxFiles = value
- h.persist(c)
+ h.persistLocked(c)
}
// Request log
@@ -341,8 +345,10 @@ func (h *Handler) PutRoutingStrategy(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid strategy"})
return
}
+ h.mu.Lock()
+ defer h.mu.Unlock()
h.cfg.Routing.Strategy = normalized
- h.persist(c)
+ h.persistLocked(c)
}
// Proxy URL
@@ -351,6 +357,8 @@ func (h *Handler) PutProxyURL(c *gin.Context) {
h.updateStringField(c, func(v string) { h.cfg.ProxyURL = v })
}
func (h *Handler) DeleteProxyURL(c *gin.Context) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
h.cfg.ProxyURL = ""
- h.persist(c)
+ h.persistLocked(c)
}
diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go
index 0d978ab1cff..5863ece7c1b 100644
--- a/internal/api/handlers/management/config_lists.go
+++ b/internal/api/handlers/management/config_lists.go
@@ -65,14 +65,18 @@ func (h *Handler) putStringList(c *gin.Context, set func([]string), after func()
}
arr = obj.Items
}
+ h.mu.Lock()
+ defer h.mu.Unlock()
set(arr)
if after != nil {
after()
}
- h.persist(c)
+ h.persistLocked(c)
}
-func (h *Handler) patchStringList(c *gin.Context, target *[]string, after func()) {
+// patchStringList resolves the list through targetOf under h.mu, so it edits the
+// current config instead of one a reload has replaced.
+func (h *Handler) patchStringList(c *gin.Context, targetOf func() *[]string, after func()) {
var body struct {
Old *string `json:"old"`
New *string `json:"new"`
@@ -83,12 +87,15 @@ func (h *Handler) patchStringList(c *gin.Context, target *[]string, after func()
c.JSON(400, gin.H{"error": "invalid body"})
return
}
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ target := targetOf()
if body.Index != nil && body.Value != nil && *body.Index >= 0 && *body.Index < len(*target) {
(*target)[*body.Index] = *body.Value
if after != nil {
after()
}
- h.persist(c)
+ h.persistLocked(c)
return
}
if body.Old != nil && body.New != nil {
@@ -98,7 +105,7 @@ func (h *Handler) patchStringList(c *gin.Context, target *[]string, after func()
if after != nil {
after()
}
- h.persist(c)
+ h.persistLocked(c)
return
}
}
@@ -106,13 +113,18 @@ func (h *Handler) patchStringList(c *gin.Context, target *[]string, after func()
if after != nil {
after()
}
- h.persist(c)
+ h.persistLocked(c)
return
}
c.JSON(400, gin.H{"error": "missing fields"})
}
-func (h *Handler) deleteFromStringList(c *gin.Context, target *[]string, after func()) {
+// deleteFromStringList resolves the list through targetOf under h.mu, so it edits
+// the current config instead of one a reload has replaced.
+func (h *Handler) deleteFromStringList(c *gin.Context, targetOf func() *[]string, after func()) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ target := targetOf()
if idxStr := c.Query("index"); idxStr != "" {
var idx int
_, err := fmt.Sscanf(idxStr, "%d", &idx)
@@ -121,7 +133,7 @@ func (h *Handler) deleteFromStringList(c *gin.Context, target *[]string, after f
if after != nil {
after()
}
- h.persist(c)
+ h.persistLocked(c)
return
}
}
@@ -136,7 +148,7 @@ func (h *Handler) deleteFromStringList(c *gin.Context, target *[]string, after f
if after != nil {
after()
}
- h.persist(c)
+ h.persistLocked(c)
return
}
c.JSON(400, gin.H{"error": "missing index or value"})
@@ -150,10 +162,10 @@ func (h *Handler) PutAPIKeys(c *gin.Context) {
}, nil)
}
func (h *Handler) PatchAPIKeys(c *gin.Context) {
- h.patchStringList(c, &h.cfg.APIKeys, func() {})
+ h.patchStringList(c, func() *[]string { return &h.cfg.APIKeys }, func() {})
}
func (h *Handler) DeleteAPIKeys(c *gin.Context) {
- h.deleteFromStringList(c, &h.cfg.APIKeys, func() {})
+ h.deleteFromStringList(c, func() *[]string { return &h.cfg.APIKeys }, func() {})
}
// gemini-api-key: []GeminiKey
@@ -1230,8 +1242,10 @@ func (h *Handler) PutOAuthExcludedModels(c *gin.Context) {
}
entries = wrapper.Items
}
+ h.mu.Lock()
+ defer h.mu.Unlock()
h.cfg.OAuthExcludedModels = config.NormalizeOAuthExcludedModels(entries)
- h.persist(c)
+ h.persistLocked(c)
}
func (h *Handler) PatchOAuthExcludedModels(c *gin.Context) {
@@ -1249,6 +1263,8 @@ func (h *Handler) PatchOAuthExcludedModels(c *gin.Context) {
return
}
normalized := config.NormalizeExcludedModels(body.Models)
+ h.mu.Lock()
+ defer h.mu.Unlock()
if len(normalized) == 0 {
if h.cfg.OAuthExcludedModels == nil {
c.JSON(404, gin.H{"error": "provider not found"})
@@ -1262,14 +1278,14 @@ func (h *Handler) PatchOAuthExcludedModels(c *gin.Context) {
if len(h.cfg.OAuthExcludedModels) == 0 {
h.cfg.OAuthExcludedModels = nil
}
- h.persist(c)
+ h.persistLocked(c)
return
}
if h.cfg.OAuthExcludedModels == nil {
h.cfg.OAuthExcludedModels = make(map[string][]string)
}
h.cfg.OAuthExcludedModels[provider] = normalized
- h.persist(c)
+ h.persistLocked(c)
}
func (h *Handler) DeleteOAuthExcludedModels(c *gin.Context) {
@@ -1278,6 +1294,8 @@ func (h *Handler) DeleteOAuthExcludedModels(c *gin.Context) {
c.JSON(400, gin.H{"error": "missing provider"})
return
}
+ h.mu.Lock()
+ defer h.mu.Unlock()
if h.cfg.OAuthExcludedModels == nil {
c.JSON(404, gin.H{"error": "provider not found"})
return
@@ -1290,7 +1308,7 @@ func (h *Handler) DeleteOAuthExcludedModels(c *gin.Context) {
if len(h.cfg.OAuthExcludedModels) == 0 {
h.cfg.OAuthExcludedModels = nil
}
- h.persist(c)
+ h.persistLocked(c)
}
// oauth-model-alias: map[string][]OAuthModelAlias
@@ -1315,8 +1333,10 @@ func (h *Handler) PutOAuthModelAlias(c *gin.Context) {
}
entries = wrapper.Items
}
+ h.mu.Lock()
+ defer h.mu.Unlock()
h.cfg.OAuthModelAlias = sanitizedOAuthModelAlias(entries)
- h.persist(c)
+ h.persistLocked(c)
}
func (h *Handler) PatchOAuthModelAlias(c *gin.Context) {
@@ -1343,6 +1363,8 @@ func (h *Handler) PatchOAuthModelAlias(c *gin.Context) {
normalizedMap := sanitizedOAuthModelAlias(map[string][]config.OAuthModelAlias{channel: body.Aliases})
normalized := normalizedMap[channel]
+ h.mu.Lock()
+ defer h.mu.Unlock()
if len(normalized) == 0 {
if h.cfg.OAuthModelAlias == nil {
c.JSON(404, gin.H{"error": "channel not found"})
@@ -1356,14 +1378,14 @@ func (h *Handler) PatchOAuthModelAlias(c *gin.Context) {
if len(h.cfg.OAuthModelAlias) == 0 {
h.cfg.OAuthModelAlias = nil
}
- h.persist(c)
+ h.persistLocked(c)
return
}
if h.cfg.OAuthModelAlias == nil {
h.cfg.OAuthModelAlias = make(map[string][]config.OAuthModelAlias)
}
h.cfg.OAuthModelAlias[channel] = normalized
- h.persist(c)
+ h.persistLocked(c)
}
func (h *Handler) DeleteOAuthModelAlias(c *gin.Context) {
@@ -1375,6 +1397,8 @@ func (h *Handler) DeleteOAuthModelAlias(c *gin.Context) {
c.JSON(400, gin.H{"error": "missing channel"})
return
}
+ h.mu.Lock()
+ defer h.mu.Unlock()
if h.cfg.OAuthModelAlias == nil {
c.JSON(404, gin.H{"error": "channel not found"})
return
@@ -1387,7 +1411,7 @@ func (h *Handler) DeleteOAuthModelAlias(c *gin.Context) {
if len(h.cfg.OAuthModelAlias) == 0 {
h.cfg.OAuthModelAlias = nil
}
- h.persist(c)
+ h.persistLocked(c)
}
// oauth-request-scoped-errors: map[string][]RequestScopedErrorRule
@@ -1412,8 +1436,10 @@ func (h *Handler) PutOAuthRequestScopedErrors(c *gin.Context) {
}
entries = wrapper.Items
}
+ h.mu.Lock()
+ defer h.mu.Unlock()
h.cfg.OAuthRequestScopedErrors = sanitizedOAuthRequestScopedErrors(entries)
- h.persist(c)
+ h.persistLocked(c)
}
func (h *Handler) PatchOAuthRequestScopedErrors(c *gin.Context) {
@@ -1440,6 +1466,8 @@ func (h *Handler) PatchOAuthRequestScopedErrors(c *gin.Context) {
normalizedMap := sanitizedOAuthRequestScopedErrors(map[string][]config.RequestScopedErrorRule{channel: body.Rules})
normalized := normalizedMap[channel]
+ h.mu.Lock()
+ defer h.mu.Unlock()
if len(normalized) == 0 {
if h.cfg.OAuthRequestScopedErrors == nil {
c.JSON(404, gin.H{"error": "channel not found"})
@@ -1453,14 +1481,14 @@ func (h *Handler) PatchOAuthRequestScopedErrors(c *gin.Context) {
if len(h.cfg.OAuthRequestScopedErrors) == 0 {
h.cfg.OAuthRequestScopedErrors = nil
}
- h.persist(c)
+ h.persistLocked(c)
return
}
if h.cfg.OAuthRequestScopedErrors == nil {
h.cfg.OAuthRequestScopedErrors = make(map[string][]config.RequestScopedErrorRule)
}
h.cfg.OAuthRequestScopedErrors[channel] = normalized
- h.persist(c)
+ h.persistLocked(c)
}
func (h *Handler) DeleteOAuthRequestScopedErrors(c *gin.Context) {
@@ -1472,6 +1500,8 @@ func (h *Handler) DeleteOAuthRequestScopedErrors(c *gin.Context) {
c.JSON(400, gin.H{"error": "missing channel"})
return
}
+ h.mu.Lock()
+ defer h.mu.Unlock()
if h.cfg.OAuthRequestScopedErrors == nil {
c.JSON(404, gin.H{"error": "channel not found"})
return
@@ -1484,7 +1514,7 @@ func (h *Handler) DeleteOAuthRequestScopedErrors(c *gin.Context) {
if len(h.cfg.OAuthRequestScopedErrors) == 0 {
h.cfg.OAuthRequestScopedErrors = nil
}
- h.persist(c)
+ h.persistLocked(c)
}
// codex-api-key: []CodexKey
diff --git a/internal/api/handlers/management/config_reload_fence_test.go b/internal/api/handlers/management/config_reload_fence_test.go
new file mode 100644
index 00000000000..5ac24272076
--- /dev/null
+++ b/internal/api/handlers/management/config_reload_fence_test.go
@@ -0,0 +1,149 @@
+package management
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+)
+
+// newIssue6631Handler returns a handler backed by a legacy config file and a router
+// exposing the field endpoints used in the issue report.
+func newIssue6631Handler(t *testing.T, raw string) (*Handler, string, *gin.Engine) {
+ t.Helper()
+ gin.SetMode(gin.TestMode)
+ path := filepath.Join(t.TempDir(), "config.yaml")
+ if errWrite := os.WriteFile(path, []byte(raw), 0o600); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ cfg, errLoad := config.LoadConfig(path)
+ if errLoad != nil {
+ t.Fatal(errLoad)
+ }
+ h := &Handler{cfg: cfg, configFilePath: path}
+ router := gin.New()
+ router.PUT("/v0/management/request-retry", h.PutRequestRetry)
+ router.PUT("/v0/management/max-retry-credentials", h.PutMaxRetryCredentials)
+ return h, path, router
+}
+
+func putIssue6631Value(t *testing.T, router *gin.Engine, url, body string) {
+ t.Helper()
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPut, url, strings.NewReader(body)))
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("PUT %s: status=%d body=%s", url, recorder.Code, recorder.Body.String())
+ }
+}
+
+func readIssue6631Disk(t *testing.T, path string) *config.Config {
+ t.Helper()
+ data, errRead := os.ReadFile(path)
+ if errRead != nil {
+ t.Fatal(errRead)
+ }
+ cfg, errParse := config.ParseConfigBytes(data)
+ if errParse != nil {
+ t.Fatal(errParse)
+ }
+ return cfg
+}
+
+func TestSetConfigIgnoresConfigLoadedBeforeManagementSave_Issue6631(t *testing.T) {
+ h, path, router := newIssue6631Handler(t, "request-retry: 3\nmax-retry-credentials: 0\n")
+ // A watcher reload read config.yaml before the next save and publishes late.
+ staleReload, errLoad := config.LoadConfig(path)
+ if errLoad != nil {
+ t.Fatal(errLoad)
+ }
+
+ putIssue6631Value(t, router, "/v0/management/request-retry", `{"value":5}`)
+ h.SetConfig(staleReload)
+ putIssue6631Value(t, router, "/v0/management/max-retry-credentials", `{"value":7}`)
+
+ h.mu.Lock()
+ memoryRetry, memoryCredentials := h.cfg.RequestRetry, h.cfg.MaxRetryCredentials
+ h.mu.Unlock()
+ if memoryRetry != 5 || memoryCredentials != 7 {
+ t.Fatalf("in-memory config = request-retry %d, max-retry-credentials %d; want 5, 7", memoryRetry, memoryCredentials)
+ }
+ disk := readIssue6631Disk(t, path)
+ if disk.RequestRetry != 5 || disk.MaxRetryCredentials != 7 {
+ t.Fatalf("config.yaml = request-retry %d, max-retry-credentials %d; want 5, 7", disk.RequestRetry, disk.MaxRetryCredentials)
+ }
+}
+
+func TestSetConfigAppliesCurrentFileAndNonFileConfigs_Issue6631(t *testing.T) {
+ h, path, _ := newIssue6631Handler(t, "request-retry: 3\n")
+
+ // An external edit is published by the reload that read it.
+ if errWrite := os.WriteFile(path, []byte("request-retry: 9\n"), 0o600); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ external, errLoad := config.LoadConfig(path)
+ if errLoad != nil {
+ t.Fatal(errLoad)
+ }
+ h.SetConfig(external)
+ h.mu.Lock()
+ gotExternal := h.cfg.RequestRetry
+ h.mu.Unlock()
+ if gotExternal != 9 {
+ t.Fatalf("request-retry after external edit = %d, want 9", gotExternal)
+ }
+
+ // Configs that were not loaded from the file, such as Home updates, still apply.
+ h.SetConfig(&config.Config{RequestRetry: 11})
+ h.mu.Lock()
+ gotNonFile := h.cfg.RequestRetry
+ h.mu.Unlock()
+ if gotNonFile != 11 {
+ t.Fatalf("request-retry after non-file config = %d, want 11", gotNonFile)
+ }
+}
+
+func TestManagementWritesSurviveConcurrentReloadPublications_Issue6631(t *testing.T) {
+ h, path, router := newIssue6631Handler(t, "request-retry: 0\nmax-retry-credentials: 0\n")
+
+ stop := make(chan struct{})
+ var wg sync.WaitGroup
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for {
+ select {
+ case <-stop:
+ return
+ default:
+ }
+ // Mirror watcher reloads: load config.yaml, then publish it to the handler.
+ if reloaded, errLoad := config.LoadConfig(path); errLoad == nil {
+ h.SetConfig(reloaded)
+ }
+ }
+ }()
+ stopPublisher := sync.OnceFunc(func() {
+ close(stop)
+ wg.Wait()
+ })
+ t.Cleanup(stopPublisher)
+
+ const writes = 40
+ for i := 1; i <= writes; i++ {
+ putIssue6631Value(t, router, "/v0/management/request-retry", fmt.Sprintf(`{"value":%d}`, i))
+ putIssue6631Value(t, router, "/v0/management/max-retry-credentials", fmt.Sprintf(`{"value":%d}`, i))
+ }
+ stopPublisher()
+
+ disk := readIssue6631Disk(t, path)
+ if disk.RequestRetry != writes || disk.MaxRetryCredentials != writes {
+ t.Fatalf("config.yaml = request-retry %d, max-retry-credentials %d; want %d, %d", disk.RequestRetry, disk.MaxRetryCredentials, writes, writes)
+ }
+}
diff --git a/internal/api/handlers/management/config_v8.go b/internal/api/handlers/management/config_v8.go
index b4b8e41844f..5f944095803 100644
--- a/internal/api/handlers/management/config_v8.go
+++ b/internal/api/handlers/management/config_v8.go
@@ -19,15 +19,26 @@ const ConfigV8ContextKey = "management.config-v8"
// ConfigV8 serves the persisted configuration using v8 names. GET is a view;
// only a successful configuration mutation migrates the stored document.
+//
+// Reads return an ETag holding the SHA-256 of the on-disk config bytes.
+// Writes honour an optional If-Match against that token, so a client can
+// detect that another writer saved after its read (412) instead of silently
+// overwriting the change. Without If-Match, writes stay unconditional.
func (h *Handler) ConfigV8(c *gin.Context) {
h.mu.Lock()
defer h.mu.Unlock()
- data, err := os.ReadFile(h.configFilePath)
+ raw, err := os.ReadFile(h.configFilePath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "read_failed"})
return
}
- data, _, err = config.NormalizeConfigLayout(data, true)
+ diskHash := config.ComputeSourceHash(raw)
+ if c.Request.Method != http.MethodGet && !ifMatchSatisfied(c.Request.Header.Values("If-Match"), diskHash) {
+ c.Header("ETag", configV8ETag(diskHash))
+ c.JSON(http.StatusPreconditionFailed, gin.H{"error": "precondition_failed"})
+ return
+ }
+ data, _, err := config.NormalizeConfigLayout(raw, true)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid_config", "message": err.Error()})
return
@@ -61,6 +72,7 @@ func (h *Handler) ConfigV8(c *gin.Context) {
return
}
c.Header("Cache-Control", "no-store")
+ c.Header("ETag", configV8ETag(diskHash))
if yamlRequest {
c.Data(http.StatusOK, "application/yaml; charset=utf-8", data)
return
@@ -188,12 +200,66 @@ func (h *Handler) ConfigV8(c *gin.Context) {
}
// Runtime-only ownership state is never sourced from an uploaded YAML document.
next.Home = h.cfg.Home
+ // WriteConfig may normalize the bytes, so report the version actually on
+ // disk. If it cannot be read back, omit the ETag instead of guessing one.
+ // Like Home, SourceHash keeps its runtime owner and is published by the
+ // reload, so it is not overwritten on next here.
+ if saved, errSaved := os.ReadFile(h.configFilePath); errSaved == nil {
+ c.Header("ETag", configV8ETag(config.ComputeSourceHash(saved)))
+ }
h.cfg = next
snapshot := h.reloadSnapshotConfigLocked()
h.reloadConfigAfterManagementSaveAsync(c.Request.Context(), snapshot)
c.JSON(http.StatusOK, gin.H{"status": "ok", "config-version": 8})
}
+// configV8ETag renders a config source hash as a strong ETag.
+func configV8ETag(hash string) string {
+ return `"` + hash + `"`
+}
+
+// ifMatchSatisfied reports whether the optional If-Match header lines pass
+// the RFC 9110 strong comparison against currentHash. Repeated lines form
+// one list. An absent header always passes; a present but blank header
+// carries no tag and fails; the whole field "*" matches any current
+// version; weak and malformed entity tags never match.
+func ifMatchSatisfied(values []string, currentHash string) bool {
+ if len(values) == 0 {
+ return true
+ }
+ field := strings.TrimSpace(strings.Join(values, ","))
+ if field == "*" {
+ return true
+ }
+ for _, tag := range splitEntityTagList(field) {
+ tag = strings.TrimSpace(tag)
+ if len(tag) >= 2 && tag[0] == '"' && tag[len(tag)-1] == '"' && !strings.Contains(tag[1:len(tag)-1], `"`) && tag[1:len(tag)-1] == currentHash {
+ return true
+ }
+ }
+ return false
+}
+
+// splitEntityTagList splits an If-Match field value on list commas. A comma
+// inside a quoted entity tag is tag content, not a separator.
+func splitEntityTagList(field string) []string {
+ parts := []string{}
+ start := 0
+ quoted := false
+ for i := 0; i < len(field); i++ {
+ switch field[i] {
+ case '"':
+ quoted = !quoted
+ case ',':
+ if !quoted {
+ parts = append(parts, field[start:i])
+ start = i + 1
+ }
+ }
+ }
+ return append(parts, field[start:])
+}
+
var v8ICEServersPath = []string{"oauth", "providers", "codex", "live-media-relay", "ice-servers"}
// JSON reads redact TURN secrets. Preserve omitted credentials when a client
diff --git a/internal/api/handlers/management/config_v8_etag_test.go b/internal/api/handlers/management/config_v8_etag_test.go
new file mode 100644
index 00000000000..9351d0e5766
--- /dev/null
+++ b/internal/api/handlers/management/config_v8_etag_test.go
@@ -0,0 +1,196 @@
+package management
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+)
+
+// TestConfigV8ETagIfMatch covers issue #6666: the v8 config endpoints must
+// support optimistic concurrency so a stale whole-document write cannot
+// resurrect a change another writer already saved (e.g. a revoked API key).
+func TestConfigV8ETagIfMatch(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ path := filepath.Join(t.TempDir(), "config.yaml")
+ raw := "config-version: 8\nserver: {port: 8317}\naccess: {api-keys: [revoked-key]}\n"
+ if err := os.WriteFile(path, []byte(raw), 0600); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err := config.LoadConfig(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ h := &Handler{cfg: cfg, configFilePath: path}
+ router := gin.New()
+ for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodPatch, http.MethodDelete} {
+ router.Handle(method, "/v8/management/config", h.ConfigV8)
+ router.Handle(method, "/v8/management/config.yaml", h.ConfigV8)
+ router.Handle(method, "/v8/management/config/*path", h.ConfigV8)
+ }
+
+ do := func(method, url, body, ifMatch string) *httptest.ResponseRecorder {
+ t.Helper()
+ recorder := httptest.NewRecorder()
+ req := httptest.NewRequest(method, url, strings.NewReader(body))
+ if ifMatch != "" {
+ req.Header.Set("If-Match", ifMatch)
+ }
+ router.ServeHTTP(recorder, req)
+ return recorder
+ }
+ wantStatus := func(recorder *httptest.ResponseRecorder, status int) {
+ t.Helper()
+ if recorder.Code != status {
+ t.Fatalf("status=%d want %d body=%s", recorder.Code, status, recorder.Body.String())
+ }
+ }
+ fileBytes := func() []byte {
+ t.Helper()
+ data, errRead := os.ReadFile(path)
+ if errRead != nil {
+ t.Fatal(errRead)
+ }
+ return data
+ }
+ fileETag := func() string { return `"` + config.ComputeSourceHash(fileBytes()) + `"` }
+
+ // PATCH at the root with a stale ETag fails fast with 412, so PATCH is
+ // fenced by the same precondition instead of merging blindly.
+ recorder := do(http.MethodPatch, "/v8/management/config", `{"server":{"port":8319}}`, `"stale"`)
+ wantStatus(recorder, http.StatusPreconditionFailed)
+ if string(fileBytes()) != raw {
+ t.Fatal("stale PATCH changed the file")
+ }
+ // PATCH with the current ETag goes through and returns the new one.
+ recorder = do(http.MethodPatch, "/v8/management/config", `{"server":{"port":8319}}`, fileETag())
+ wantStatus(recorder, http.StatusOK)
+ if got := recorder.Header().Get("ETag"); got != fileETag() {
+ t.Fatalf("PATCH response ETag=%q want %q", got, fileETag())
+ }
+ loaded, err := config.LoadConfig(path)
+ if err != nil || loaded.Port != 8319 {
+ t.Fatalf("PATCH with matching If-Match not applied: port=%d err=%v", loaded.Port, err)
+ }
+
+ // GET responses advertise the on-disk version as a strong ETag.
+ get := do(http.MethodGet, "/v8/management/config.yaml", "", "")
+ wantStatus(get, http.StatusOK)
+ etag := get.Header().Get("ETag")
+ if etag != fileETag() {
+ t.Fatalf("GET config.yaml ETag=%q want %q", etag, fileETag())
+ }
+ subPathGet := do(http.MethodGet, "/v8/management/config/server", "", "")
+ wantStatus(subPathGet, http.StatusOK)
+ if got := subPathGet.Header().Get("ETag"); got != etag {
+ t.Fatalf("GET sub-path ETag=%q want %q", got, etag)
+ }
+
+ // Another writer revokes the API key between the read above and our write.
+ revoked := do(http.MethodPut, "/v8/management/config/access/api-keys", `["other-key"]`, "")
+ wantStatus(revoked, http.StatusOK)
+ if got := revoked.Header().Get("ETag"); got != fileETag() {
+ t.Fatalf("PUT response ETag=%q want %q", got, fileETag())
+ }
+
+ // A whole-document PUT based on the stale read must not resurrect the key.
+ before := string(fileBytes())
+ stale := do(http.MethodPut, "/v8/management/config.yaml", raw, etag)
+ wantStatus(stale, http.StatusPreconditionFailed)
+ if got := stale.Header().Get("ETag"); got != fileETag() {
+ t.Fatalf("412 response ETag=%q want current %q", got, fileETag())
+ }
+ if string(fileBytes()) != before {
+ t.Fatal("stale PUT changed the file")
+ }
+ loaded, err = config.LoadConfig(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(loaded.APIKeys) != 1 || loaded.APIKeys[0] != "other-key" {
+ t.Fatalf("stale PUT resurrected revoked key: %v", loaded.APIKeys)
+ }
+
+ // DELETE with a stale ETag is rejected the same way.
+ staleDelete := do(http.MethodDelete, "/v8/management/config/access/api-keys", "", etag)
+ wantStatus(staleDelete, http.StatusPreconditionFailed)
+ if string(fileBytes()) != before {
+ t.Fatal("stale DELETE changed the file")
+ }
+
+ // Weak and malformed validators never match under strong comparison. A
+ // comma inside quotes is tag content, not a list separator: neither a
+ // quoted nor a weakly quoted "stale,*,tag" may be mistaken for the
+ // wildcard, and only the whole field can be the wildcard.
+ for _, ifMatch := range []string{"W/" + fileETag(), "not-an-etag", `"unterminated`, `"a"b"`, `"stale,*,tag"`, `W/"stale,*,tag"`, `"stale", *`} {
+ recorder = do(http.MethodPut, "/v8/management/config.yaml", before, ifMatch)
+ wantStatus(recorder, http.StatusPreconditionFailed)
+ }
+
+ // Entity tags split across repeated header lines are one list.
+ recorder = httptest.NewRecorder()
+ splitReq := httptest.NewRequest(http.MethodPut, "/v8/management/config/server/port", strings.NewReader(`8318`))
+ splitReq.Header.Add("If-Match", `"stale"`)
+ splitReq.Header.Add("If-Match", fileETag())
+ router.ServeHTTP(recorder, splitReq)
+ wantStatus(recorder, http.StatusOK)
+
+ // A quoted current hash with a trailing comma inside the quotes is a
+ // different (stale) tag, not the current one.
+ quotedComma := `"` + config.ComputeSourceHash(fileBytes()) + `,"`
+ recorder = do(http.MethodPut, "/v8/management/config/server/port", `8317`, quotedComma)
+ wantStatus(recorder, http.StatusPreconditionFailed)
+
+ // An explicitly present but empty/blank If-Match carries no tag and fails;
+ // only a fully absent header stays unconditional. (The do helper cannot
+ // express an empty header, so add it directly.)
+ for _, blank := range []string{"", " \t "} {
+ recorder = httptest.NewRecorder()
+ blankReq := httptest.NewRequest(http.MethodPut, "/v8/management/config/server/port", strings.NewReader(`8317`))
+ blankReq.Header.Add("If-Match", blank)
+ router.ServeHTTP(recorder, blankReq)
+ wantStatus(recorder, http.StatusPreconditionFailed)
+ }
+
+ // A list containing the current tag (with whitespace and weak members) passes.
+ current := fileETag()
+ listed := do(http.MethodDelete, "/v8/management/config/access/api-keys", "", `"stale", `+current+` , W/`+current)
+ wantStatus(listed, http.StatusOK)
+ loaded, err = config.LoadConfig(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(loaded.APIKeys) != 0 {
+ t.Fatalf("DELETE with matching If-Match list not applied: %v", loaded.APIKeys)
+ }
+
+ // Star matches any existing version.
+ star := do(http.MethodPut, "/v8/management/config/server/port", `8320`, "*")
+ wantStatus(star, http.StatusOK)
+
+ // A matching ETag chains: reuse the PUT response ETag without re-reading.
+ first := do(http.MethodPut, "/v8/management/config/server/port", `8321`, fileETag())
+ wantStatus(first, http.StatusOK)
+ nextETag := first.Header().Get("ETag")
+ if nextETag == "" || nextETag != fileETag() {
+ t.Fatalf("PUT response ETag=%q want on-disk hash %q", nextETag, fileETag())
+ }
+ second := do(http.MethodPut, "/v8/management/config/server/port", `8322`, nextETag)
+ wantStatus(second, http.StatusOK)
+
+ // Without If-Match the behaviour is unchanged.
+ unconditional := do(http.MethodPut, "/v8/management/config/server/port", `8323`, "")
+ wantStatus(unconditional, http.StatusOK)
+
+ // GET 404 responses do not advertise an ETag.
+ missing := do(http.MethodGet, "/v8/management/config/no-such-key", "", "")
+ wantStatus(missing, http.StatusNotFound)
+ if got := missing.Header().Get("ETag"); got != "" {
+ t.Fatalf("404 ETag=%q want empty", got)
+ }
+}
diff --git a/internal/api/handlers/management/config_v8_test.go b/internal/api/handlers/management/config_v8_test.go
index fb675f09cd0..a9d9eb97942 100644
--- a/internal/api/handlers/management/config_v8_test.go
+++ b/internal/api/handlers/management/config_v8_test.go
@@ -683,6 +683,8 @@ plugins:
t.Fatal(errLoad)
}
loaded.Home = cfg.Home
+ // SourceHash is runtime-only metadata of a file load, not persisted config.
+ loaded.SourceHash = ""
select {
case snapshot := <-reloads:
if !reflect.DeepEqual(snapshot, loaded) || !reflect.DeepEqual(h.cfg, loaded) {
diff --git a/internal/api/handlers/management/handler.go b/internal/api/handlers/management/handler.go
index 21630e85fe8..78dde7ac7ad 100644
--- a/internal/api/handlers/management/handler.go
+++ b/internal/api/handlers/management/handler.go
@@ -61,6 +61,8 @@ type Handler struct {
pluginStoreHTTPClient pluginstore.HTTPDoer
pluginStoreRateLimiter *pluginstore.GitHubRateLimiter
pluginReleases pluginReleaseCache
+ siwcOAuthFactory func(*config.Config) siwcOAuthService
+ siwcCommitMu sync.Mutex
}
type configReloadSnapshot struct {
@@ -74,7 +76,7 @@ func NewHandler(cfg *config.Config, configFilePath string, manager *coreauth.Man
envSecret = strings.TrimSpace(envSecret)
h := &Handler{
- cfg: cfg,
+ cfg: cfg.CloneForRuntime(),
configFilePath: configFilePath,
failedAttempts: make(map[string]*attemptInfo),
authManager: manager,
@@ -122,13 +124,34 @@ func NewHandlerWithoutConfigFilePath(cfg *config.Config, manager *coreauth.Manag
}
// SetConfig updates the in-memory config reference when the server hot-reloads.
+// A config parsed from an older version of the config file is ignored: a later
+// save already changed the file, and the reload for that save publishes it.
func (h *Handler) SetConfig(cfg *config.Config) {
if h == nil {
return
}
+ cloned := cfg.CloneForRuntime()
h.mu.Lock()
- h.cfg = cfg
- h.mu.Unlock()
+ defer h.mu.Unlock()
+ if h.isStaleConfigFileSnapshotLocked(cloned) {
+ log.Debug("management: ignoring config reload parsed from an older config file version")
+ return
+ }
+ h.cfg = cloned
+}
+
+// isStaleConfigFileSnapshotLocked reports whether cfg was parsed from config file
+// bytes that are no longer on disk. Management saves hold h.mu, so none can land
+// during the check. Callers must hold h.mu.
+func (h *Handler) isStaleConfigFileSnapshotLocked(cfg *config.Config) bool {
+ if cfg == nil || cfg.SourceHash == "" || h.configFilePath == "" {
+ return false
+ }
+ data, errRead := os.ReadFile(h.configFilePath)
+ if errRead != nil {
+ return false
+ }
+ return config.ComputeSourceHash(data) != cfg.SourceHash
}
// SetAuthManager updates the auth manager reference used by management endpoints.
@@ -137,7 +160,9 @@ func (h *Handler) SetAuthManager(manager *coreauth.Manager) {
return
}
h.mu.Lock()
- h.authManager = manager
+ if h.authManager != manager {
+ h.authManager = manager
+ }
h.mu.Unlock()
}
@@ -147,7 +172,9 @@ func (h *Handler) SetPluginHost(host *pluginhost.Host) {
return
}
h.mu.Lock()
- h.pluginHost = host
+ if h.pluginHost != host {
+ h.pluginHost = host
+ }
h.mu.Unlock()
}
@@ -306,6 +333,7 @@ func (h *Handler) AuthenticateManagementKey(clientIP string, localClient bool, p
return false, http.StatusForbidden, "remote management disabled"
}
+ h.mu.Lock()
cfg := h.cfg
var (
allowRemote bool
@@ -315,6 +343,7 @@ func (h *Handler) AuthenticateManagementKey(clientIP string, localClient bool, p
allowRemote = cfg.RemoteManagement.AllowRemote
secretHash = cfg.RemoteManagement.SecretKey
}
+ h.mu.Unlock()
if h.allowRemoteOverride {
allowRemote = true
}
@@ -397,13 +426,6 @@ func (h *Handler) AuthenticateManagementKey(clientIP string, localClient bool, p
return true, 0, ""
}
-// persist saves the current in-memory config to disk.
-func (h *Handler) persist(c *gin.Context) bool {
- h.mu.Lock()
- defer h.mu.Unlock()
- return h.persistLocked(c)
-}
-
// persistLocked saves the current in-memory config to disk.
// It expects the caller to hold h.mu.
func (h *Handler) persistLocked(c *gin.Context) bool {
@@ -422,7 +444,8 @@ func (h *Handler) persistLocked(c *gin.Context) bool {
return true
}
-// Helper methods for simple types
+// Helper methods for simple types. The setter and the save run under h.mu, so a
+// reload publishing h.cfg cannot replace it between the change and the save.
func (h *Handler) updateBoolField(c *gin.Context, set func(bool)) {
var body struct {
Value *bool `json:"value"`
@@ -431,8 +454,10 @@ func (h *Handler) updateBoolField(c *gin.Context, set func(bool)) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
+ h.mu.Lock()
+ defer h.mu.Unlock()
set(*body.Value)
- h.persist(c)
+ h.persistLocked(c)
}
func (h *Handler) updateIntField(c *gin.Context, set func(int)) {
@@ -443,8 +468,10 @@ func (h *Handler) updateIntField(c *gin.Context, set func(int)) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
+ h.mu.Lock()
+ defer h.mu.Unlock()
set(*body.Value)
- h.persist(c)
+ h.persistLocked(c)
}
func (h *Handler) updateStringField(c *gin.Context, set func(string)) {
@@ -455,6 +482,8 @@ func (h *Handler) updateStringField(c *gin.Context, set func(string)) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
+ h.mu.Lock()
+ defer h.mu.Unlock()
set(*body.Value)
- h.persist(c)
+ h.persistLocked(c)
}
diff --git a/internal/api/handlers/management/model_definitions.go b/internal/api/handlers/management/model_definitions.go
index 4e85c72f259..e1bbdfd0073 100644
--- a/internal/api/handlers/management/model_definitions.go
+++ b/internal/api/handlers/management/model_definitions.go
@@ -2,9 +2,11 @@ package management
import (
"net/http"
+ "sort"
"strings"
"github.com/gin-gonic/gin"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/pluginhost"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
)
@@ -21,7 +23,10 @@ func (h *Handler) GetStaticModelDefinitions(c *gin.Context) {
}
models := registry.GetStaticModelDefinitionsByChannel(channel)
- if models == nil {
+ if len(models) == 0 {
+ models = h.pluginModelDefinitionsByChannel(channel)
+ }
+ if len(models) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown channel", "channel": channel})
return
}
@@ -31,3 +36,30 @@ func (h *Handler) GetStaticModelDefinitions(c *gin.Context) {
"models": models,
})
}
+
+func (h *Handler) pluginModelDefinitionsByChannel(channel string) []*registry.ModelInfo {
+ var host *pluginhost.Host
+ if h != nil {
+ h.mu.Lock()
+ host = h.pluginHost
+ h.mu.Unlock()
+ }
+ if host != nil {
+ if models := host.ModelsForProvider(channel); len(models) > 0 {
+ return models
+ }
+ }
+ models := registry.GetGlobalRegistry().GetAvailableModelsByProvider(channel)
+ if len(models) > 1 {
+ sort.Slice(models, func(i, j int) bool {
+ if models[i] == nil {
+ return models[j] != nil
+ }
+ if models[j] == nil {
+ return false
+ }
+ return models[i].ID < models[j].ID
+ })
+ }
+ return models
+}
diff --git a/internal/api/handlers/management/model_definitions_test.go b/internal/api/handlers/management/model_definitions_test.go
new file mode 100644
index 00000000000..73b3ededc01
--- /dev/null
+++ b/internal/api/handlers/management/model_definitions_test.go
@@ -0,0 +1,153 @@
+package management
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/pluginhost"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
+)
+
+type staticPluginModelProvider struct {
+ provider string
+ models []pluginapi.ModelInfo
+}
+
+func (p staticPluginModelProvider) StaticModels(context.Context, pluginapi.StaticModelRequest) (pluginapi.ModelResponse, error) {
+ return pluginapi.ModelResponse{
+ Provider: p.provider,
+ Models: p.models,
+ }, nil
+}
+
+func (p staticPluginModelProvider) ModelsForAuth(context.Context, pluginapi.AuthModelRequest) (pluginapi.ModelResponse, error) {
+ return pluginapi.ModelResponse{}, nil
+}
+
+func invokeGetStaticModelDefinitions(t *testing.T, h *Handler, channel string) (*httptest.ResponseRecorder, struct {
+ Channel string `json:"channel"`
+ Error string `json:"error"`
+ Models []registry.ModelInfo `json:"models"`
+}) {
+ t.Helper()
+ gin.SetMode(gin.TestMode)
+ rec := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(rec)
+ ctx.Params = gin.Params{{Key: "channel", Value: channel}}
+ ctx.Request = httptest.NewRequest(http.MethodGet, "/v8/management/routing/model-definitions/"+channel, nil)
+ h.GetStaticModelDefinitions(ctx)
+
+ var body struct {
+ Channel string `json:"channel"`
+ Error string `json:"error"`
+ Models []registry.ModelInfo `json:"models"`
+ }
+ if errUnmarshal := json.Unmarshal(rec.Body.Bytes(), &body); errUnmarshal != nil {
+ t.Fatalf("unmarshal response body %q: %v", rec.Body.String(), errUnmarshal)
+ }
+ return rec, body
+}
+
+func TestGetStaticModelDefinitions_BuiltInChannel(t *testing.T) {
+ h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, nil)
+
+ rec, body := invokeGetStaticModelDefinitions(t, h, "claude")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+ if body.Channel != "claude" {
+ t.Fatalf("channel = %q, want %q", body.Channel, "claude")
+ }
+ if len(body.Models) == 0 {
+ t.Fatal("expected non-empty models for built-in channel claude")
+ }
+}
+
+func TestGetStaticModelDefinitions_FallsBackToPluginStaticModels(t *testing.T) {
+ const provider = "workbuddy"
+ reg := registry.GetGlobalRegistry()
+ clientID := "workbuddy-cred-1.json"
+ reg.RegisterClient(clientID, provider, []*registry.ModelInfo{
+ {ID: "workbuddy-from-registry", DisplayName: "WorkBuddy Registry"},
+ })
+ t.Cleanup(func() {
+ reg.UnregisterClient(clientID)
+ })
+
+ host := pluginhost.New()
+ pluginID := "workbuddy-plugin"
+ host.RegisterPluginForTest(pluginID, pluginapi.Plugin{
+ Capabilities: pluginapi.Capabilities{
+ ModelProvider: staticPluginModelProvider{
+ provider: provider,
+ models: []pluginapi.ModelInfo{
+ {ID: "workbuddy-pro", DisplayName: "WorkBuddy Pro"},
+ {ID: "workbuddy-lite", DisplayName: "WorkBuddy Lite"},
+ },
+ },
+ },
+ })
+ host.RegisterModels(t.Context(), reg)
+ host.RegisterExecutors(coreauth.NewManager(nil, nil, nil), reg)
+
+ h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, nil)
+ h.SetPluginHost(host)
+
+ rec, body := invokeGetStaticModelDefinitions(t, h, "WorkBuddy")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+ if body.Channel != provider {
+ t.Fatalf("channel = %q, want %q", body.Channel, provider)
+ }
+ if len(body.Models) != 2 || body.Models[0].ID != "workbuddy-pro" || body.Models[1].ID != "workbuddy-lite" {
+ t.Fatalf("models = %#v, want [workbuddy-pro workbuddy-lite]", body.Models)
+ }
+}
+
+func TestGetStaticModelDefinitions_FallsBackToRegistryModelsForPerAuthPlugin(t *testing.T) {
+ const provider = "qoderwork"
+ reg := registry.GetGlobalRegistry()
+ clientID := "qoderwork-cred-1.json"
+ reg.RegisterClient(clientID, provider, []*registry.ModelInfo{
+ {ID: "qwen3.8-plus-QoderWork", DisplayName: "Qwen 3.8 Plus"},
+ {ID: "qwen3.8-flash-QoderWork", DisplayName: "Qwen 3.8 Flash"},
+ })
+ t.Cleanup(func() {
+ reg.UnregisterClient(clientID)
+ })
+
+ h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, nil)
+ h.SetPluginHost(pluginhost.New())
+
+ rec, body := invokeGetStaticModelDefinitions(t, h, "QoderWork")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+ if body.Channel != provider {
+ t.Fatalf("channel = %q, want %q", body.Channel, provider)
+ }
+ if len(body.Models) != 2 || body.Models[0].ID != "qwen3.8-flash-QoderWork" || body.Models[1].ID != "qwen3.8-plus-QoderWork" {
+ t.Fatalf("models = %#v, want [qwen3.8-flash-QoderWork qwen3.8-plus-QoderWork]", body.Models)
+ }
+}
+
+func TestGetStaticModelDefinitions_RejectsUnknownChannel(t *testing.T) {
+ h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, nil)
+ h.SetPluginHost(pluginhost.New())
+
+ rec, body := invokeGetStaticModelDefinitions(t, h, "nonexistent-provider")
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusBadRequest, rec.Body.String())
+ }
+ if body.Error != "unknown channel" || body.Channel != "nonexistent-provider" {
+ t.Fatalf("body = %#v, want unknown channel error for nonexistent-provider", body)
+ }
+}
diff --git a/internal/api/handlers/management/oauth_sessions.go b/internal/api/handlers/management/oauth_sessions.go
index 336f9bbcdd7..2b010efec9f 100644
--- a/internal/api/handlers/management/oauth_sessions.go
+++ b/internal/api/handlers/management/oauth_sessions.go
@@ -32,14 +32,19 @@ var (
errOAuthSessionExists = errors.New("oauth session already exists")
)
+// ErrSIWCCallbackRequiresV8 prevents legacy callback sinks from accepting SIWC codes.
+var ErrSIWCCallbackRequiresV8 = fmt.Errorf("%w: SIWC callbacks require /v8/management/oauth/callback", errOAuthSessionNotPending)
+
type oauthSession struct {
- Provider string
- Status string
- Source string
- Metadata map[string]any
- Completed bool
- CreatedAt time.Time
- ExpiresAt time.Time
+ Provider string
+ Status string
+ Source string
+ Metadata map[string]any
+ SIWC *siwcBindSession
+ SIWCCommitting bool
+ Completed bool
+ CreatedAt time.Time
+ ExpiresAt time.Time
}
type oauthSessionStore struct {
@@ -66,7 +71,10 @@ func newOAuthSessionStore(ttl time.Duration) *oauthSessionStore {
func (s *oauthSessionStore) purgeExpiredLocked(now time.Time) {
for state, session := range s.sessions {
- if !session.ExpiresAt.IsZero() && now.After(session.ExpiresAt) {
+ if !session.SIWCCommitting && !session.ExpiresAt.IsZero() && now.After(session.ExpiresAt) {
+ if session.SIWC != nil {
+ session.SIWC.cancel()
+ }
delete(s.sessions, state)
}
}
@@ -142,6 +150,7 @@ func (s *oauthSessionStore) SetError(state, message string) {
return
}
session.Status = message
+ session.SIWCCommitting = false
session.ExpiresAt = now.Add(s.ttl)
s.sessions[state] = session
}
@@ -163,6 +172,7 @@ func (s *oauthSessionStore) Complete(state string) {
}
session.Status = ""
session.Metadata = nil
+ session.SIWCCommitting = false
session.Completed = true
session.ExpiresAt = now.Add(s.completedTTL)
s.sessions[state] = session
@@ -182,7 +192,7 @@ func (s *oauthSessionStore) CompleteProvider(provider string, source string) int
s.purgeExpiredLocked(now)
removed := 0
for state, session := range s.sessions {
- if !session.Completed && strings.EqualFold(session.Provider, provider) && (source == "" || session.Source == source) {
+ if session.SIWC == nil && !session.Completed && strings.EqualFold(session.Provider, provider) && (source == "" || session.Source == source) {
session.Status = ""
session.Metadata = nil
session.Completed = true
@@ -236,6 +246,12 @@ func (s *oauthSessionStore) Cancel(state string) bool {
if state == "" {
return false
}
+ // SIWC completion and cancellation share a per-session gate. A cancellation
+ // that wins this gate prevents persistence; after a committed bind it is a no-op.
+ if session, ok := s.Get(state); ok && session.SIWC != nil {
+ session.SIWC.mu.Lock()
+ defer session.SIWC.mu.Unlock()
+ }
now := time.Now()
s.mu.Lock()
@@ -246,6 +262,10 @@ func (s *oauthSessionStore) Cancel(state string) bool {
if !ok || session.Completed || session.Status != "" {
return false
}
+ if session.SIWC != nil {
+ session.SIWC.cancel()
+ session.SIWC.pkce = nil
+ }
delete(s.sessions, state)
return true
}
@@ -481,6 +501,9 @@ func WriteOAuthCallbackFileForPendingSession(authDir, provider, state, code, err
if err != nil {
return "", err
}
+ if session, ok := oauthSessions.Get(state); ok && session.SIWC != nil {
+ return "", ErrSIWCCallbackRequiresV8
+ }
if !IsOAuthSessionPending(state, canonicalProvider) {
return "", errOAuthSessionNotPending
}
diff --git a/internal/api/handlers/management/oauth_siwc.go b/internal/api/handlers/management/oauth_siwc.go
new file mode 100644
index 00000000000..e1e184f7ae8
--- /dev/null
+++ b/internal/api/handlers/management/oauth_siwc.go
@@ -0,0 +1,397 @@
+package management
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/codex"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ log "github.com/sirupsen/logrus"
+)
+
+const siwcBindTTL = 5 * time.Minute
+
+type siwcOAuthService interface {
+ GenerateSIWCAuthURL(state, nonce, deviceID, redirectURI string, pkce *codex.PKCECodes) (string, error)
+ ExchangeSIWCCodeForTokens(ctx context.Context, code, clientID, redirectURI, deviceID string, pkce *codex.PKCECodes) (*codex.CodexSIWCTokenData, error)
+}
+
+// siwcBindSession is server-owned: callbacks cannot select a different target,
+// redirect URI, PKCE verifier, or token endpoint. It never contains an auth snapshot.
+type siwcBindSession struct {
+ mu sync.Mutex
+ owner *Handler
+ manager *coreauth.Manager
+ service siwcOAuthService
+ targetID string
+ epoch uint64
+ path string
+ authDir string
+ deviceID string
+ redirect string
+ pkce *codex.PKCECodes
+ ctx context.Context
+ cancel context.CancelFunc
+ exchanging bool
+ exchangeDone chan struct{}
+}
+
+func siwcBindEligible(auth *coreauth.Auth) bool {
+ if auth == nil || !strings.EqualFold(auth.Provider, "codex") || auth.AuthSourceKind() != coreauth.AuthSourceFile || auth.AuthKind() != coreauth.AuthKindOAuth || isRuntimeOnlyAuth(auth) {
+ return false
+ }
+ for _, key := range []string{"access_token", "refresh_token"} {
+ value, _ := auth.Metadata[key].(string)
+ if strings.TrimSpace(value) == "" {
+ return false
+ }
+ }
+ return strings.TrimSpace(authAttribute(auth, "path")) != ""
+}
+
+// resolveSIWCBindFile resolves only a server-selected auth path, confines it to
+// auth-dir, and reads the current credential rather than trusting a cached token.
+func resolveSIWCBindFile(authDir, path string) (string, map[string]any, error) {
+ dir, errResolve := util.ResolveAuthDir(authDir)
+ if errResolve != nil || strings.TrimSpace(dir) == "" {
+ return "", nil, errors.New("auth directory is unavailable")
+ }
+ dir, errAbs := filepath.Abs(dir)
+ if errAbs != nil {
+ return "", nil, errAbs
+ }
+ dir, errDir := filepath.EvalSymlinks(dir)
+ if errDir != nil {
+ return "", nil, errDir
+ }
+ // Match the commit-time rule: directory aliases are allowed, but the
+ // credential itself must not be a symlink.
+ originalInfo, errLstat := os.Lstat(path)
+ if errLstat != nil || !originalInfo.Mode().IsRegular() {
+ return "", nil, errors.New("credential must be an existing regular file")
+ }
+ realPath, errPath := filepath.EvalSymlinks(path)
+ if errPath != nil {
+ return "", nil, errPath
+ }
+ realPath, errAbs = filepath.Abs(realPath)
+ if errAbs != nil {
+ return "", nil, errAbs
+ }
+ relative, errRel := filepath.Rel(dir, realPath)
+ if errRel != nil || relative == "." || relative == ".." || filepath.IsAbs(relative) || strings.HasPrefix(relative, ".."+string(os.PathSeparator)) {
+ return "", nil, errors.New("credential must belong to auth-dir")
+ }
+ info, errStat := os.Stat(realPath)
+ if errStat != nil || !info.Mode().IsRegular() {
+ return "", nil, errors.New("credential must be an existing regular file")
+ }
+ data, errRead := os.ReadFile(realPath)
+ if errRead != nil {
+ return "", nil, errRead
+ }
+ var metadata map[string]any
+ if errJSON := json.Unmarshal(data, &metadata); errJSON != nil {
+ return "", nil, errors.New("invalid credential JSON")
+ }
+ provider, _ := metadata["type"].(string)
+ if !strings.EqualFold(strings.TrimSpace(provider), "codex") {
+ return "", nil, errors.New("credential type must be codex")
+ }
+ for _, key := range []string{"access_token", "refresh_token"} {
+ value, _ := metadata[key].(string)
+ if strings.TrimSpace(value) == "" {
+ return "", nil, fmt.Errorf("codex credential requires %s", key)
+ }
+ }
+ return realPath, metadata, nil
+}
+
+// StartSIWCBind creates a v8-only OAuth session for an existing Codex credential.
+// Remote browsers complete it by submitting the full loopback callback URL; no
+// listener on the server's port 1455 is required or started by this endpoint.
+func (h *Handler) StartSIWCBind(c *gin.Context) {
+ var req struct {
+ Name string `json:"name"`
+ }
+ if errBind := c.ShouldBindJSON(&req); errBind != nil {
+ c.JSON(clienterror.HTTPStatusFromErrorOr(errBind, http.StatusBadRequest), gin.H{"error": "invalid request body"})
+ return
+ }
+ req.Name = strings.TrimSpace(req.Name)
+ if req.Name == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
+ return
+ }
+ h.siwcCommitMu.Lock()
+ defer h.siwcCommitMu.Unlock()
+ h.mu.Lock()
+ cfg, manager, factory := h.cfg.CloneForRuntime(), h.authManager, h.siwcOAuthFactory
+ h.mu.Unlock()
+ if cfg == nil || manager == nil {
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "auth manager unavailable"})
+ return
+ }
+ target, ok := manager.GetByID(req.Name)
+ if !ok {
+ for _, auth := range manager.List() {
+ if auth.FileName == req.Name {
+ target = auth
+ break
+ }
+ }
+ }
+ if target == nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "auth file not found"})
+ return
+ }
+ if !siwcBindEligible(target) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "SIWC binding requires a file-backed Codex OAuth credential"})
+ return
+ }
+ path, metadata, errPath := resolveSIWCBindFile(cfg.AuthDir, authAttribute(target, "path"))
+ if errPath != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "SIWC binding target is unavailable or invalid"})
+ return
+ }
+ deviceID := uuid.NewString()
+ if section, okSection := metadata["siwc"].(map[string]any); okSection {
+ if existing, _ := section["device_id"].(string); codex.IsValidUUID(existing) {
+ deviceID = strings.ToLower(strings.TrimSpace(existing))
+ }
+ }
+ pkce, errPKCE := codex.GeneratePKCECodes()
+ state, errState := codex.GenerateRandomBase64URL()
+ nonce, errNonce := codex.GenerateRandomBase64URL()
+ if errPKCE != nil || errState != nil || errNonce != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to initialize SIWC authorization"})
+ return
+ }
+ var service siwcOAuthService = codex.NewCodexAuth(cfg)
+ if factory != nil {
+ service = factory(cfg)
+ }
+ authURL, errURL := service.GenerateSIWCAuthURL(state, nonce, deviceID, codex.SIWCRedirectURI, pkce)
+ if errURL != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate SIWC authorization URL"})
+ return
+ }
+ ctx, cancel := context.WithTimeout(PopulateAuthContext(context.Background(), c), siwcBindTTL)
+ flow := &siwcBindSession{owner: h, manager: manager, service: service, targetID: target.ID, epoch: target.RegistrationEpoch,
+ path: path, authDir: cfg.AuthDir, deviceID: deviceID, redirect: codex.SIWCRedirectURI, pkce: pkce, ctx: ctx, cancel: cancel}
+ if !oauthSessions.registerSIWC(state, flow) {
+ cancel()
+ c.JSON(http.StatusConflict, gin.H{"error": "a SIWC binding is already pending for this credential"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"status": "ok", "url": authURL, "state": state, "flow": "siwc_bind", "expires_in": int(siwcBindTTL.Seconds())})
+}
+
+func (s *oauthSessionStore) registerSIWC(state string, flow *siwcBindSession) bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ now := time.Now()
+ s.purgeExpiredLocked(now)
+ if _, exists := s.sessions[state]; exists {
+ return false
+ }
+ for _, session := range s.sessions {
+ if session.SIWC != nil && !session.Completed && session.Status == "" && session.SIWC.manager == flow.manager && session.SIWC.targetID == flow.targetID {
+ return false
+ }
+ }
+ s.sessions[state] = oauthSession{Provider: "codex", Source: oauthSessionSourceBuiltin, SIWC: flow, CreatedAt: now, ExpiresAt: now.Add(siwcBindTTL)}
+ return true
+}
+
+// claimSIWCCommit keeps a session addressable until an already-started disk
+// commit finishes. Expiration must not turn a durable successful bind into an
+// unknown-state response. Cancellation is serialized by the session gate.
+func (s *oauthSessionStore) claimSIWCCommit(state string, flow *siwcBindSession) bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.purgeExpiredLocked(time.Now())
+ session, ok := s.sessions[state]
+ if !ok || session.SIWC != flow || session.Completed || session.Status != "" || session.SIWCCommitting || flow.ctx.Err() != nil {
+ return false
+ }
+ session.SIWCCommitting = true
+ s.sessions[state] = session
+ return true
+}
+
+type siwcCallbackRequest struct {
+ oauthCallbackRequest
+ ClientID string `json:"client_id"`
+}
+
+func (h *Handler) PostOAuthCallbackV8(c *gin.Context) {
+ var req siwcCallbackRequest
+ if errBind := c.ShouldBindJSON(&req); errBind != nil {
+ c.JSON(clienterror.HTTPStatusFromErrorOr(errBind, http.StatusBadRequest), gin.H{"status": "error", "error": "invalid body"})
+ return
+ }
+ h.handleOAuthCallbackV8(c, req)
+}
+
+func (h *Handler) GetOAuthCallbackV8(c *gin.Context) {
+ h.handleOAuthCallbackV8(c, siwcCallbackRequest{oauthCallbackRequest: oauthCallbackRequest{
+ Provider: c.Query("provider"), State: c.Query("state"), Code: c.Query("code"),
+ Error: firstNonEmpty(c.Query("error"), c.Query("error_description")),
+ }, ClientID: c.Query("client_id")})
+}
+
+func (h *Handler) handleOAuthCallbackV8(c *gin.Context, req siwcCallbackRequest) {
+ state := strings.TrimSpace(req.State)
+ var redirect *url.URL
+ if raw := strings.TrimSpace(req.RedirectURL); raw != "" {
+ var errParse error
+ redirect, errParse = url.Parse(raw)
+ if errParse != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid redirect_url"})
+ return
+ }
+ if state == "" {
+ state = strings.TrimSpace(redirect.Query().Get("state"))
+ }
+ }
+ session, ok := oauthSessions.Get(state)
+ if !ok || session.SIWC == nil {
+ h.handleOAuthCallback(c, req.oauthCallbackRequest)
+ return
+ }
+ flow := session.SIWC
+ if c.Request.Method == http.MethodGet {
+ query := c.Request.URL.Query()
+ for _, key := range []string{"provider", "state", "code", "client_id", "error"} {
+ if len(query[key]) > 1 {
+ c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "conflicting callback parameters"})
+ return
+ }
+ }
+ }
+ if flow.owner != h {
+ c.JSON(http.StatusNotFound, gin.H{"status": "error", "error": "unknown or expired state"})
+ return
+ }
+ if provider := strings.TrimSpace(req.Provider); provider != "" {
+ canonical, errProvider := NormalizeOAuthProvider(provider)
+ if errProvider != nil || canonical != "codex" {
+ c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "provider does not match state"})
+ return
+ }
+ }
+ code, clientID, oauthError := strings.TrimSpace(req.Code), strings.TrimSpace(req.ClientID), strings.TrimSpace(req.Error)
+ if redirect != nil {
+ expected, _ := url.Parse(flow.redirect)
+ if redirect.Scheme != expected.Scheme || redirect.Host != expected.Host || redirect.Path != expected.Path || redirect.User != nil || redirect.Fragment != "" {
+ c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "unexpected SIWC callback URL"})
+ return
+ }
+ query := redirect.Query()
+ for key, target := range map[string]*string{"state": &state, "code": &code, "client_id": &clientID, "error": &oauthError} {
+ value := strings.TrimSpace(query.Get(key))
+ if len(query[key]) > 1 || (value != "" && *target != "" && value != *target) {
+ c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "conflicting callback parameters"})
+ return
+ }
+ if *target == "" {
+ *target = value
+ }
+ }
+ }
+ if oauthError == "" && (code == "" || clientID == "") {
+ c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "SIWC callback requires code and client_id"})
+ return
+ }
+ flow.mu.Lock()
+ defer flow.mu.Unlock()
+ if flow.exchanging || flow.ctx.Err() != nil || !oauthSessions.IsPending(state, "codex") {
+ c.JSON(http.StatusConflict, gin.H{"status": "error", "error": "SIWC session is no longer accepting callbacks"})
+ return
+ }
+ if oauthError != "" {
+ flow.fail(state, "SIWC authorization was denied or failed")
+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
+ return
+ }
+ flow.exchanging = true
+ flow.exchangeDone = make(chan struct{})
+ go flow.exchange(state, code, clientID)
+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
+}
+
+// fail intentionally never publishes upstream response bodies or callback values.
+// Caller holds the session gate.
+func (flow *siwcBindSession) fail(state, message string) {
+ SetOAuthSessionError(state, message)
+ flow.cancel()
+ flow.pkce = nil
+}
+
+func (flow *siwcBindSession) exchange(state, code, clientID string) {
+ defer close(flow.exchangeDone)
+ // Copy under the gate so cancellation and terminal cleanup cannot race PKCE use.
+ flow.mu.Lock()
+ pkce := flow.pkce
+ flow.mu.Unlock()
+ tokenData, errExchange := flow.service.ExchangeSIWCCodeForTokens(flow.ctx, code, clientID, flow.redirect, flow.deviceID, pkce)
+ flow.mu.Lock()
+ flow.owner.siwcCommitMu.Lock()
+ var persisted *coreauth.Auth
+ defer func() {
+ flow.owner.siwcCommitMu.Unlock()
+ flow.mu.Unlock()
+ if persisted != nil {
+ flow.owner.mu.Lock()
+ hook := flow.owner.postAuthPersistHook
+ flow.owner.mu.Unlock()
+ if hook != nil {
+ if errHook := hook(context.Background(), persisted); errHook != nil {
+ log.Warn("SIWC binding saved but post-auth notification failed")
+ }
+ }
+ }
+ }()
+ if flow.ctx.Err() != nil || !oauthSessions.IsPending(state, "codex") {
+ flow.pkce = nil
+ return
+ }
+ if errExchange != nil || tokenData == nil {
+ flow.fail(state, "Failed to exchange SIWC authorization code; start a new binding")
+ return
+ }
+ path, _, errPath := resolveSIWCBindFile(flow.authDir, flow.path)
+ if errPath != nil || path != flow.path {
+ flow.fail(state, "SIWC binding target is no longer available")
+ return
+ }
+ if !oauthSessions.claimSIWCCommit(state, flow) {
+ flow.cancel()
+ flow.pkce = nil
+ return
+ }
+ updated, errBind := flow.manager.BindCodexSIWC(flow.ctx, flow.targetID, flow.epoch, tokenData)
+ if errBind != nil {
+ flow.fail(state, "Failed to save SIWC binding; the target may have changed")
+ return
+ }
+ // Binding is durable and published before the session reports success.
+ persisted = updated
+ CompleteOAuthSession(state)
+ flow.cancel()
+ flow.pkce = nil
+}
diff --git a/internal/api/handlers/management/oauth_siwc_mutations.go b/internal/api/handlers/management/oauth_siwc_mutations.go
new file mode 100644
index 00000000000..71537d28d13
--- /dev/null
+++ b/internal/api/handlers/management/oauth_siwc_mutations.go
@@ -0,0 +1,76 @@
+package management
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+)
+
+type siwcFileMutationKey struct{}
+
+// UploadAuthFileV8 coordinates file replacement with explicit SIWC binding.
+func (h *Handler) UploadAuthFileV8(c *gin.Context) {
+ c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), siwcFileMutationKey{}, true))
+ h.UploadAuthFile(c)
+}
+
+// DeleteAuthFileV8 coordinates file removal with explicit SIWC binding.
+func (h *Handler) DeleteAuthFileV8(c *gin.Context) {
+ c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), siwcFileMutationKey{}, true))
+ h.DeleteAuthFile(c)
+}
+
+// beginSIWCFileMutation fences disk mutation through manager publication. Legacy
+// callers deliberately retain their existing behavior. Never acquire flow.mu:
+// exchange commits acquire flow.mu before this gate and then the session store.
+func (h *Handler) beginSIWCFileMutation(ctx context.Context, path string) func() {
+ if ctx == nil || ctx.Value(siwcFileMutationKey{}) != true {
+ return func() {}
+ }
+ h.siwcCommitMu.Lock()
+ oauthSessions.mu.Lock()
+ now := time.Now()
+ for state, session := range oauthSessions.sessions {
+ flow := session.SIWC
+ if flow == nil || flow.owner != h || session.Completed || session.Status != "" || !sameSIWCMutationPath(path, flow.path) {
+ continue
+ }
+ // Even identical reuploads invalidate the old authorization intent.
+ session.Status = "SIWC binding target changed; start a new binding"
+ session.ExpiresAt = now.Add(oauthSessions.completedTTL)
+ oauthSessions.sessions[state] = session
+ flow.cancel()
+ }
+ oauthSessions.mu.Unlock()
+ return h.siwcCommitMu.Unlock
+}
+
+func sameSIWCMutationPath(a, b string) bool {
+ // SameFile also handles case-insensitive filesystems on macOS and aliases.
+ if ai, errA := os.Stat(a); errA == nil {
+ if bi, errB := os.Stat(b); errB == nil && os.SameFile(ai, bi) {
+ return true
+ }
+ }
+ canonical := func(path string) string {
+ if real, err := filepath.EvalSymlinks(path); err == nil {
+ path = real
+ } else if parent, errParent := filepath.EvalSymlinks(filepath.Dir(path)); errParent == nil {
+ path = filepath.Join(parent, filepath.Base(path))
+ }
+ if abs, err := filepath.Abs(path); err == nil {
+ path = abs
+ }
+ return filepath.Clean(path)
+ }
+ a, b = canonical(a), canonical(b)
+ if runtime.GOOS == "windows" {
+ return strings.EqualFold(a, b)
+ }
+ return a == b
+}
diff --git a/internal/api/handlers/management/oauth_siwc_mutations_test.go b/internal/api/handlers/management/oauth_siwc_mutations_test.go
new file mode 100644
index 00000000000..a2c246115be
--- /dev/null
+++ b/internal/api/handlers/management/oauth_siwc_mutations_test.go
@@ -0,0 +1,120 @@
+package management
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "os"
+ "path/filepath"
+ "testing"
+
+ coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+)
+
+func TestSIWCFileMutationRejectsLateExchange(t *testing.T) {
+ for _, operation := range []string{"upload", "delete", "delete-all", "delete-batch"} {
+ t.Run(operation, func(t *testing.T) {
+ f := setupSIWCFlow(t, true)
+ f.router.POST("/credentials", f.h.UploadAuthFileV8)
+ f.router.DELETE("/credentials", f.h.DeleteAuthFileV8)
+ state, flow := f.start(t)
+ w := siwcRequest(t, f.router, http.MethodPost, "/callback", map[string]any{"state": state, "redirect_url": siwcCallbackURL(state), "provider": "codex"})
+ if w.Code != http.StatusOK {
+ t.Fatalf("callback: %d %s", w.Code, w.Body.String())
+ }
+ <-f.service.inputs
+ original, errRead := os.ReadFile(f.path)
+ if errRead != nil {
+ t.Fatal(errRead)
+ }
+ switch operation {
+ case "upload":
+ w = siwcRequest(t, f.router, http.MethodPost, "/credentials?name=codex.json", json.RawMessage(original))
+ case "delete":
+ w = siwcRequest(t, f.router, http.MethodDelete, "/credentials?name=codex.json", nil)
+ case "delete-all":
+ if errWrite := f.h.writeAuthFile(context.Background(), "other.json", original); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ w = siwcRequest(t, f.router, http.MethodDelete, "/credentials?all=true", nil)
+ case "delete-batch":
+ if errWrite := f.h.writeAuthFile(context.Background(), "other.json", original); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ w = siwcRequest(t, f.router, http.MethodDelete, "/credentials", []string{"codex.json", "other.json"})
+ }
+ if w.Code != http.StatusOK {
+ t.Fatalf("mutation: %d %s", w.Code, w.Body.String())
+ }
+ session, ok := f.store.Get(state)
+ if !ok || session.Status == "" || session.Completed || flow.ctx.Err() == nil {
+ t.Fatal("mutation did not invalidate pending binding")
+ }
+ f.unblock()
+ waitSIWCExchange(t, flow)
+ if operation == "upload" {
+ data, err := os.ReadFile(f.path)
+ if err != nil || string(data) != string(original) {
+ t.Fatalf("late exchange changed reupload: %s, %v", data, err)
+ }
+ } else {
+ if _, err := os.Stat(f.path); !os.IsNotExist(err) {
+ t.Fatalf("deleted file resurrected: %v", err)
+ }
+ if _, ok := f.manager.GetByID(f.auth.ID); ok {
+ t.Fatal("deleted registration resurrected")
+ }
+ }
+ })
+ }
+}
+
+func TestSIWCFileMutationScopeAndValidation(t *testing.T) {
+ f := setupSIWCFlow(t, false)
+ f.router.POST("/credentials", f.h.UploadAuthFileV8)
+ f.router.POST("/legacy", f.h.UploadAuthFile)
+ state, flow := f.start(t)
+ for _, test := range []struct {
+ path string
+ body any
+ status int
+ }{
+ {"/credentials?name=codex.json", map[string]any{"excluded_models": 123}, http.StatusBadRequest},
+ {"/credentials?name=other.json", f.auth.Metadata, http.StatusOK},
+ {"/legacy?name=codex.json", f.auth.Metadata, http.StatusOK},
+ } {
+ w := siwcRequest(t, f.router, http.MethodPost, test.path, test.body)
+ if w.Code != test.status {
+ t.Fatalf("%s: %d %s", test.path, w.Code, w.Body.String())
+ }
+ if flow.ctx.Err() != nil || !f.store.IsPending(state, "codex") {
+ t.Fatalf("%s invalidated unrelated or legacy binding", test.path)
+ }
+ }
+ f.h.postAuthPersistHook = func(_ context.Context, _ *coreauth.Auth) error {
+ if f.h.siwcCommitMu.TryLock() {
+ f.h.siwcCommitMu.Unlock()
+ t.Error("commit gate released before publication hook")
+ }
+ return nil
+ }
+ w := siwcRequest(t, f.router, http.MethodPost, "/credentials?name=codex.json", f.auth.Metadata)
+ if w.Code != http.StatusOK || flow.ctx.Err() == nil {
+ t.Fatalf("reupload failed to invalidate binding: %d", w.Code)
+ }
+}
+
+func TestSIWCMutationPathAliases(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "auth.json")
+ if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ alias := filepath.Join(dir, "alias.json")
+ if err := os.Symlink(path, alias); err != nil {
+ t.Skipf("symlinks unavailable: %v", err)
+ }
+ if !sameSIWCMutationPath(alias, path) || sameSIWCMutationPath(path, filepath.Join(dir, "other.json")) {
+ t.Fatal("incorrect canonical path matching")
+ }
+}
diff --git a/internal/api/handlers/management/oauth_siwc_test.go b/internal/api/handlers/management/oauth_siwc_test.go
new file mode 100644
index 00000000000..b96322b1204
--- /dev/null
+++ b/internal/api/handlers/management/oauth_siwc_test.go
@@ -0,0 +1,456 @@
+package management
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/codex"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+)
+
+type siwcExchangeInput struct {
+ code, clientID, redirect, deviceID string
+ pkce *codex.PKCECodes
+}
+
+type testSIWCOAuth struct {
+ calls atomic.Int32
+ inputs chan siwcExchangeInput
+ release chan struct{}
+ err error
+}
+
+func (s *testSIWCOAuth) GenerateSIWCAuthURL(state, nonce, deviceID, redirect string, pkce *codex.PKCECodes) (string, error) {
+ return codex.NewCodexAuth(nil).GenerateSIWCAuthURL(state, nonce, deviceID, redirect, pkce)
+}
+
+func (s *testSIWCOAuth) ExchangeSIWCCodeForTokens(ctx context.Context, code, clientID, redirect, deviceID string, pkce *codex.PKCECodes) (*codex.CodexSIWCTokenData, error) {
+ s.calls.Add(1)
+ s.inputs <- siwcExchangeInput{code, clientID, redirect, deviceID, pkce}
+ if s.release != nil {
+ // Deliberately ignore cancellation to prove the commit gate rejects a
+ // successful but late upstream result after cancellation or expiration.
+ <-s.release
+ }
+ if s.err != nil {
+ return nil, s.err
+ }
+ return &codex.CodexSIWCTokenData{AccessToken: "new-siwc", RefreshToken: "new-siwc-refresh", ClientID: clientID, DeviceID: deviceID, Expire: "2035-01-01T00:00:00Z"}, nil
+}
+
+type siwcFixture struct {
+ h *Handler
+ router *gin.Engine
+ manager *coreauth.Manager
+ auth *coreauth.Auth
+ path string
+ service *testSIWCOAuth
+ store *oauthSessionStore
+ unblock func()
+}
+
+func setupSIWCFlow(t *testing.T, blocked bool) *siwcFixture {
+ t.Helper()
+ store := newOAuthSessionStore(time.Minute)
+ replaceOAuthSessionStoreForTest(t, store)
+ dir := t.TempDir()
+ path := filepath.Join(dir, "codex.json")
+ metadata := map[string]any{"type": "codex", "access_token": "outer", "refresh_token": "outer-refresh", "account_id": "account", "note": "keep", "siwc": map[string]any{"device_id": "e61bbe28-07ef-466d-8e5d-a344f94ab305"}}
+ if errWrite := codex.WriteJSONFileAtomic(path, metadata); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ cfg := &config.Config{AuthDir: dir}
+ manager := coreauth.NewManager(nil, nil, nil)
+ manager.SetConfig(cfg)
+ auth, errRegister := manager.Register(context.Background(), &coreauth.Auth{ID: "codex.json", FileName: "codex.json", Provider: "codex", Metadata: metadata, Attributes: map[string]string{"path": path}, Status: coreauth.StatusActive})
+ if errRegister != nil {
+ t.Fatal(errRegister)
+ }
+ service := &testSIWCOAuth{inputs: make(chan siwcExchangeInput, 4)}
+ if blocked {
+ service.release = make(chan struct{})
+ }
+ var once sync.Once
+ unblock := func() {
+ once.Do(func() {
+ if service.release != nil {
+ close(service.release)
+ }
+ })
+ }
+ h := &Handler{cfg: cfg, authManager: manager, siwcOAuthFactory: func(*config.Config) siwcOAuthService { return service }}
+ router := gin.New()
+ router.POST("/bind", h.StartSIWCBind)
+ router.POST("/callback", h.PostOAuthCallbackV8)
+ router.GET("/callback", h.GetOAuthCallbackV8)
+ router.POST("/legacy-callback", h.PostOAuthCallback)
+ router.GET("/status", h.GetAuthStatus)
+ router.DELETE("/session", h.CancelAuthSession)
+ t.Cleanup(func() {
+ unblock()
+ store.mu.Lock()
+ var flows []*siwcBindSession
+ for _, session := range store.sessions {
+ if session.SIWC != nil {
+ flows = append(flows, session.SIWC)
+ }
+ }
+ store.mu.Unlock()
+ for _, flow := range flows {
+ flow.cancel()
+ flow.mu.Lock()
+ done := flow.exchangeDone
+ flow.mu.Unlock()
+ if done != nil {
+ <-done
+ }
+ }
+ })
+ return &siwcFixture{h, router, manager, auth, path, service, store, unblock}
+}
+
+func siwcRequest(t *testing.T, router http.Handler, method, path string, body any) *httptest.ResponseRecorder {
+ t.Helper()
+ data, errJSON := json.Marshal(body)
+ if errJSON != nil {
+ t.Fatal(errJSON)
+ }
+ req := httptest.NewRequest(method, path, strings.NewReader(string(data)))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ return w
+}
+
+func (f *siwcFixture) start(t *testing.T) (string, *siwcBindSession) {
+ t.Helper()
+ w := siwcRequest(t, f.router, http.MethodPost, "/bind", map[string]any{"name": f.auth.FileName})
+ if w.Code != http.StatusOK {
+ t.Fatalf("start: %d %s", w.Code, w.Body.String())
+ }
+ var response struct {
+ URL, State, Flow string
+ ExpiresIn int `json:"expires_in"`
+ }
+ if errJSON := json.Unmarshal(w.Body.Bytes(), &response); errJSON != nil {
+ t.Fatal(errJSON)
+ }
+ if response.Flow != "siwc_bind" || response.ExpiresIn != 300 {
+ t.Fatalf("unexpected response: %s", w.Body.String())
+ }
+ u, errURL := url.Parse(response.URL)
+ if errURL != nil {
+ t.Fatal(errURL)
+ }
+ q := u.Query()
+ if q.Get("client_id") != codex.SIWCDynamicClientID || q.Get("redirect_uri") != codex.SIWCRedirectURI || q.Get("nonce") == "" || q.Get("code_challenge") == "" {
+ t.Fatal("missing SIWC authorization parameters")
+ }
+ session, ok := f.store.Get(response.State)
+ if !ok || session.SIWC == nil {
+ t.Fatal("missing SIWC session")
+ }
+ if strings.Contains(w.Body.String(), session.SIWC.pkce.CodeVerifier) {
+ t.Fatal("PKCE verifier exposed")
+ }
+ return response.State, session.SIWC
+}
+
+func siwcCallbackURL(state string) string {
+ return codex.SIWCRedirectURI + "?" + url.Values{"state": {state}, "code": {"issued-code"}, "client_id": {"oaiapp_issued"}}.Encode()
+}
+
+func waitSIWCExchange(t *testing.T, flow *siwcBindSession) {
+ t.Helper()
+ flow.mu.Lock()
+ done := flow.exchangeDone
+ flow.mu.Unlock()
+ if done == nil {
+ t.Fatal("exchange did not start")
+ }
+ select {
+ case <-done:
+ case <-time.After(5 * time.Second):
+ t.Fatal("exchange did not finish")
+ }
+}
+
+func TestSIWCBindCallbackFormsAndSummary(t *testing.T) {
+ for _, form := range []string{"redirect", "json", "get"} {
+ t.Run(form, func(t *testing.T) {
+ f := setupSIWCFlow(t, false)
+ state, flow := f.start(t)
+ var w *httptest.ResponseRecorder
+ switch form {
+ case "redirect":
+ w = siwcRequest(t, f.router, http.MethodPost, "/callback", map[string]any{"provider": "codex", "redirect_url": siwcCallbackURL(state)})
+ case "json":
+ w = siwcRequest(t, f.router, http.MethodPost, "/callback", map[string]any{"state": state, "code": "issued-code", "client_id": "oaiapp_issued"})
+ case "get":
+ w = siwcRequest(t, f.router, http.MethodGet, "/callback?"+url.Values{"state": {state}, "code": {"issued-code"}, "client_id": {"oaiapp_issued"}}.Encode(), nil)
+ }
+ if w.Code != http.StatusOK {
+ t.Fatalf("callback: %d %s", w.Code, w.Body.String())
+ }
+ waitSIWCExchange(t, flow)
+ input := <-f.service.inputs
+ if input.clientID != "oaiapp_issued" || input.code != "issued-code" || input.redirect != codex.SIWCRedirectURI || input.deviceID != "e61bbe28-07ef-466d-8e5d-a344f94ab305" || input.pkce == nil {
+ t.Fatal("exchange lost server context or dynamic client_id")
+ }
+ if status := performOAuthStatusRequest(t, f.router, state); status.Status != "ok" {
+ t.Fatalf("status: %#v", status)
+ }
+ got, _ := f.manager.GetByID(f.auth.ID)
+ if got.Metadata["access_token"] != "outer" || got.Metadata["note"] != "keep" || got.Metadata["use_siwc"] != true {
+ t.Fatal("binding corrupted outer credential")
+ }
+ entry := f.h.buildAuthFileEntry(got, authFileQuotaContext{v8: true})
+ if entry["siwc_bound"] != true || entry["use_siwc"] != true || entry["siwc_bind_supported"] != true || entry["siwc_expires_at"] != "2035-01-01T00:00:00Z" {
+ t.Fatalf("missing summary: %#v", entry)
+ }
+ encoded, _ := json.Marshal(entry)
+ if strings.Contains(string(encoded), "new-siwc") || strings.Contains(string(encoded), "oaiapp_issued") {
+ t.Fatal("credential secrets exposed in summary")
+ }
+ legacy := f.h.buildAuthFileEntry(got, authFileQuotaContext{})
+ if _, exists := legacy["siwc_bound"]; exists {
+ t.Fatal("v0 response changed")
+ }
+ if CancelOAuthSession(state) {
+ t.Fatal("completed binding cancelled")
+ }
+ })
+ }
+}
+
+func TestSIWCBindRejectsInvalidCallbacksWithoutExchange(t *testing.T) {
+ f := setupSIWCFlow(t, false)
+ state, _ := f.start(t)
+ cases := []map[string]any{
+ {"state": state, "code": "code"},
+ {"state": state, "client_id": "client"},
+ {"state": state, "code": "code", "client_id": "client", "provider": "claude"},
+ {"state": state, "redirect_url": siwcCallbackURL("other-state")},
+ {"state": state, "redirect_url": strings.Replace(siwcCallbackURL(state), "127.0.0.1", "evil.example", 1)},
+ {"redirect_url": siwcCallbackURL(state) + "&client_id=other"},
+ {"state": state, "client_id": "other", "redirect_url": siwcCallbackURL(state)},
+ }
+ for _, body := range cases {
+ w := siwcRequest(t, f.router, http.MethodPost, "/callback", body)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("invalid callback: %d %s", w.Code, w.Body.String())
+ }
+ }
+ w := siwcRequest(t, f.router, http.MethodGet, "/callback?state="+state+"&code=code&client_id=first&client_id=second", nil)
+ if w.Code != http.StatusBadRequest {
+ t.Fatal("duplicate GET callback parameters accepted")
+ }
+ if f.service.calls.Load() != 0 || !f.store.IsPending(state, "codex") {
+ t.Fatal("invalid callback consumed pending session")
+ }
+}
+
+func TestSIWCBindDuplicateAndCancellation(t *testing.T) {
+ f := setupSIWCFlow(t, true)
+ state, flow := f.start(t)
+ duplicate := siwcRequest(t, f.router, http.MethodPost, "/bind", map[string]any{"name": f.auth.FileName})
+ if duplicate.Code != http.StatusConflict {
+ t.Fatal("parallel binding accepted for same target")
+ }
+ body := map[string]any{"redirect_url": siwcCallbackURL(state)}
+ if w := siwcRequest(t, f.router, http.MethodPost, "/callback", body); w.Code != 200 {
+ t.Fatal(w.Body.String())
+ }
+ <-f.service.inputs
+ if status := performOAuthStatusRequest(t, f.router, state); status.Status != "wait" {
+ t.Fatal("callback acceptance reported success before persistence")
+ }
+ if w := siwcRequest(t, f.router, http.MethodPost, "/callback", body); w.Code != http.StatusConflict {
+ t.Fatal("duplicate callback accepted")
+ }
+ if !CancelOAuthSession(state) {
+ t.Fatal("inflight binding could not be cancelled")
+ }
+ f.unblock()
+ waitSIWCExchange(t, flow)
+ got, _ := f.manager.GetByID(f.auth.ID)
+ if got.Metadata["use_siwc"] == true || f.service.calls.Load() != 1 {
+ t.Fatal("cancelled or duplicate exchange committed")
+ }
+ if w := siwcRequest(t, f.router, http.MethodPost, "/callback", body); w.Code != http.StatusNotFound {
+ t.Fatal("cancelled callback accepted")
+ }
+}
+
+func TestSIWCBindRejectsLateResults(t *testing.T) {
+ for _, change := range []string{"expire", "delete", "reimport", "exchange-error"} {
+ t.Run(change, func(t *testing.T) {
+ f := setupSIWCFlow(t, true)
+ if change == "exchange-error" {
+ f.service.err = errors.New("upstream contains secret-token")
+ }
+ state, flow := f.start(t)
+ w := siwcRequest(t, f.router, http.MethodPost, "/callback", map[string]any{"redirect_url": siwcCallbackURL(state)})
+ if w.Code != http.StatusOK {
+ t.Fatal(w.Body.String())
+ }
+ <-f.service.inputs
+ switch change {
+ case "expire":
+ f.store.mu.Lock()
+ session := f.store.sessions[state]
+ session.ExpiresAt = time.Now().Add(-time.Hour)
+ f.store.sessions[state] = session
+ f.store.mu.Unlock()
+ case "delete":
+ if errRemove := os.Remove(f.path); errRemove != nil {
+ t.Fatal(errRemove)
+ }
+ case "reimport":
+ f.manager.Remove(context.Background(), f.auth.ID)
+ if _, errRegister := f.manager.Register(context.Background(), f.auth.Clone()); errRegister != nil {
+ t.Fatal(errRegister)
+ }
+ }
+ f.unblock()
+ waitSIWCExchange(t, flow)
+ got, _ := f.manager.GetByID(f.auth.ID)
+ if got.Metadata["use_siwc"] == true {
+ t.Fatal("late binding committed")
+ }
+ status := performOAuthStatusRequest(t, f.router, state)
+ if status.Status != "error" || strings.Contains(status.Error, "secret-token") {
+ t.Fatalf("unsafe status: %#v", status)
+ }
+ })
+ }
+}
+
+func TestSIWCBindCommitSurvivesSessionExpiry(t *testing.T) {
+ f := setupSIWCFlow(t, false)
+ state, flow := f.start(t)
+ if !f.store.claimSIWCCommit(state, flow) {
+ t.Fatal("commit could not be claimed")
+ }
+ f.store.mu.Lock()
+ session := f.store.sessions[state]
+ session.ExpiresAt = time.Now().Add(-time.Hour)
+ f.store.sessions[state] = session
+ f.store.mu.Unlock()
+ if _, ok := f.store.Get(state); !ok {
+ t.Fatal("in-progress commit expired before reporting its outcome")
+ }
+ if f.store.CompleteProvider("codex", oauthSessionSourceBuiltin) != 0 {
+ t.Fatal("ordinary provider completion consumed SIWC binding")
+ }
+ f.store.Complete(state)
+ if status := performOAuthStatusRequest(t, f.router, state); status.Status != "ok" {
+ t.Fatalf("committed session lost outcome: %#v", status)
+ }
+ if session, ok := f.store.Get(state); !ok || session.SIWCCommitting {
+ t.Fatal("commit claim was not released")
+ }
+}
+
+func TestSIWCBindOAuthErrorAndEligibility(t *testing.T) {
+ f := setupSIWCFlow(t, false)
+ for _, kind := range []string{"config", "runtime", "api-key", "missing-outer", "other-provider"} {
+ candidate := f.auth.Clone()
+ switch kind {
+ case "config":
+ candidate.Attributes["source_backend"] = "config"
+ case "runtime":
+ candidate.Attributes["runtime_only"] = "true"
+ case "api-key":
+ candidate.Attributes["api_key"] = "key"
+ case "missing-outer":
+ delete(candidate.Metadata, "refresh_token")
+ case "other-provider":
+ candidate.Provider = "claude"
+ }
+ if siwcBindEligible(candidate) {
+ t.Fatalf("%s unexpectedly eligible", kind)
+ }
+ }
+ state, _ := f.start(t)
+ w := siwcRequest(t, f.router, http.MethodPost, "/callback", map[string]any{"state": state, "error": "access_denied: secret-data"})
+ if w.Code != http.StatusOK || f.service.calls.Load() != 0 {
+ t.Fatalf("error callback triggered exchange: %d", w.Code)
+ }
+ if status := performOAuthStatusRequest(t, f.router, state); status.Status != "error" || strings.Contains(status.Error, "secret-data") {
+ t.Fatalf("unsafe OAuth error: %#v", status)
+ }
+}
+
+func TestSIWCBindRejectsLegacyCallback(t *testing.T) {
+ f := setupSIWCFlow(t, false)
+ state, flow := f.start(t)
+ w := siwcRequest(t, f.router, http.MethodPost, "/legacy-callback", map[string]any{"state": state, "code": "issued-code"})
+ if w.Code != http.StatusConflict {
+ t.Fatalf("legacy callback: %d %s", w.Code, w.Body.String())
+ }
+ path := filepath.Join(filepath.Dir(f.path), ".oauth-codex-"+state+".oauth")
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
+ t.Fatalf("legacy callback created a file: %v", err)
+ }
+ if !f.store.IsPending(state, "codex") || f.service.calls.Load() != 0 {
+ t.Fatal("legacy callback consumed the SIWC session")
+ }
+ w = siwcRequest(t, f.router, http.MethodPost, "/callback", map[string]any{"redirect_url": siwcCallbackURL(state)})
+ if w.Code != http.StatusOK {
+ t.Fatalf("v8 retry: %d %s", w.Code, w.Body.String())
+ }
+ waitSIWCExchange(t, flow)
+}
+
+func TestSIWCBindRejectsCredentialSymlinkAtStart(t *testing.T) {
+ f := setupSIWCFlow(t, false)
+ target := filepath.Join(filepath.Dir(f.path), "target.json")
+ if err := os.Rename(f.path, target); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(target, f.path); err != nil {
+ t.Skipf("symlinks unavailable: %v", err)
+ }
+ w := siwcRequest(t, f.router, http.MethodPost, "/bind", map[string]any{"name": f.auth.FileName})
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("symlink accepted: %d %s", w.Code, w.Body.String())
+ }
+ f.store.mu.Lock()
+ defer f.store.mu.Unlock()
+ if len(f.store.sessions) != 0 || f.service.calls.Load() != 0 {
+ t.Fatal("symlink rejection created an OAuth session or exchanged tokens")
+ }
+}
+
+func TestSIWCBindTargetValidationAndLegacyCallback(t *testing.T) {
+ f := setupSIWCFlow(t, false)
+ for _, name := range []string{"", "../codex.json", "missing.json", f.path} {
+ w := siwcRequest(t, f.router, http.MethodPost, "/bind", map[string]any{"name": name})
+ if w.Code != http.StatusBadRequest && w.Code != http.StatusNotFound {
+ t.Fatalf("invalid name accepted: %q %d", name, w.Code)
+ }
+ }
+ RegisterOAuthSession("ordinary", "codex")
+ w := siwcRequest(t, f.router, http.MethodPost, "/callback", map[string]any{"state": "ordinary", "code": "ordinary-code"})
+ if w.Code != http.StatusOK {
+ t.Fatalf("ordinary v8 callback regressed: %s", w.Body.String())
+ }
+ data, errRead := os.ReadFile(filepath.Join(filepath.Dir(f.path), ".oauth-codex-ordinary.oauth"))
+ if errRead != nil || !strings.Contains(string(data), "ordinary-code") {
+ t.Fatal("ordinary callback file missing")
+ }
+}
diff --git a/internal/api/handlers/management/plugins.go b/internal/api/handlers/management/plugins.go
index cd7f6fb0bcb..2259abdca96 100644
--- a/internal/api/handlers/management/plugins.go
+++ b/internal/api/handlers/management/plugins.go
@@ -51,6 +51,7 @@ type pluginMetadataInfo struct {
}
type pluginConfigFieldInfo struct {
+ Default any `json:"default"`
Name string `json:"name"`
Type string `json:"type"`
EnumValues []string `json:"enum_values"`
@@ -469,6 +470,7 @@ func pluginConfigFields(fields []pluginapi.ConfigField) []pluginConfigFieldInfo
out = append(out, pluginConfigFieldInfo{
Name: htmlsanitize.String(field.Name),
Type: htmlsanitize.String(string(field.Type)),
+ Default: htmlsanitize.JSONValue(field.Default),
EnumValues: htmlsanitize.Strings(field.EnumValues),
Description: htmlsanitize.String(field.Description),
})
diff --git a/internal/api/handlers/management/plugins_test.go b/internal/api/handlers/management/plugins_test.go
index a266e66a12b..008fb675cd0 100644
--- a/internal/api/handlers/management/plugins_test.go
+++ b/internal/api/handlers/management/plugins_test.go
@@ -740,6 +740,7 @@ func TestPluginDisplayFieldsEscapeHTML(t *testing.T) {
fields := pluginConfigFields([]pluginapi.ConfigField{{
Name: `
`,
Type: pluginapi.ConfigFieldTypeEnum,
+ Default: "safe & sound",
EnumValues: []string{``, `safe & sound`},
Description: `"quoted" 'single' mode`,
}})
@@ -752,6 +753,45 @@ func TestPluginDisplayFieldsEscapeHTML(t *testing.T) {
if fields[0].EnumValues[0] != html.EscapeString(``) || fields[0].EnumValues[1] != html.EscapeString(`safe & sound`) {
t.Fatalf("enum values = %#v, want escaped values", fields[0].EnumValues)
}
+ if fields[0].Default != html.EscapeString(`safe & sound`) {
+ t.Fatalf("field default = %#v, want escaped default", fields[0].Default)
+ }
+
+ nested := pluginConfigFields([]pluginapi.ConfigField{
+ {
+ Name: "scalar",
+ Type: pluginapi.ConfigFieldTypeInteger,
+ Default: map[string]any{
+ "tier": `
`,
+ "options": []any{`x`, float64(2), true, nil},
+ },
+ },
+ {
+ Name: "count",
+ Type: pluginapi.ConfigFieldTypeInteger,
+ Default: float64(3),
+ },
+ })
+ if len(nested) != 2 {
+ t.Fatalf("nested fields len = %d, want 2", len(nested))
+ }
+ nestedDefault, ok := nested[0].Default.(map[string]any)
+ if !ok {
+ t.Fatalf("nested default type = %T, want map[string]any", nested[0].Default)
+ }
+ if nestedDefault["tier"] != html.EscapeString(`
`) {
+ t.Fatalf("nested default tier = %#v, want escaped", nestedDefault["tier"])
+ }
+ options, ok := nestedDefault["options"].([]any)
+ if !ok || len(options) != 4 {
+ t.Fatalf("nested options = %#v, want []any with 4 items", nestedDefault["options"])
+ }
+ if options[0] != html.EscapeString(`x`) || options[1] != float64(2) || options[2] != true || options[3] != nil {
+ t.Fatalf("nested options = %#v, want escaped string and preserved non-strings", options)
+ }
+ if nested[1].Default != float64(3) {
+ t.Fatalf("numeric default = %#v, want preserved", nested[1].Default)
+ }
if fields[0].Description != html.EscapeString(`"quoted" 'single' mode`) {
t.Fatalf("description = %q, want escaped", fields[0].Description)
}
diff --git a/internal/api/home_models_empty_test.go b/internal/api/home_models_empty_test.go
new file mode 100644
index 00000000000..b4bef17d032
--- /dev/null
+++ b/internal/api/home_models_empty_test.go
@@ -0,0 +1,130 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/home"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+)
+
+func TestDecodeHomeModelsEmptyCatalog(t *testing.T) {
+ for _, raw := range []string{"{}", " { } ", `{"claude":[],"codex-pro":[]}`} {
+ entries, errDecode := decodeHomeModels([]byte(raw))
+ if errDecode != nil || entries == nil || len(entries) != 0 {
+ t.Errorf("decodeHomeModels(%q) = %v, %v, want empty list", raw, entries, errDecode)
+ }
+ }
+ for _, raw := range []string{
+ "", "null", "[]", "{bad}", `{"claude":[{}]}`, `{"claude":null}`, `{"error":null}`,
+ `{"error":{"type":"internal_error","message":"unavailable"}}`,
+ `{"error":[]}`, `{"error":[],"claude":[]}`,
+ `{"error":[{"id":"not-a-model"}]}`, `{"error":[],"claude":[{"id":"model-a"}]}`,
+ } {
+ if _, errDecode := decodeHomeModels([]byte(raw)); errDecode == nil {
+ t.Errorf("decodeHomeModels(%q) accepted malformed or error payload", raw)
+ }
+ }
+}
+
+func TestHomeEmptyModelCatalogResponses(t *testing.T) {
+ server := newTestServer(t)
+ server.cfg.Home.Enabled = true
+ engine := gin.New()
+ engine.GET("/v1/models", server.unifiedModelsHandler(nil, nil))
+ engine.GET("/v1beta/models", server.geminiModelsHandler(nil))
+ engine.GET("/v1beta/models/*action", server.geminiGetHandler(nil))
+ client := newHomeCatalogClient(t, "{}")
+ previousHome := home.Current()
+ home.SetCurrent(client)
+ t.Cleanup(func() { home.SetCurrent(previousHome) })
+ registry.GetGlobalRegistry().RegisterClient("empty-catalog-local", "codex", []*registry.ModelInfo{{ID: "local-only-model"}})
+ t.Cleanup(func() { registry.GetGlobalRegistry().UnregisterClient("empty-catalog-local") })
+
+ tests := []struct {
+ name string
+ path string
+ headers map[string]string
+ field string
+ }{
+ {name: "openai", path: "/v1/models", field: "data"},
+ {name: "claude", path: "/v1/models", headers: map[string]string{"Anthropic-Version": "2023-06-01"}, field: "data"},
+ {name: "codex", path: "/v1/models?client_version=0.153.4", field: "models"},
+ {name: "cpa", path: "/v1/models?client_version=cpa", field: "models"},
+ {name: "gemini", path: "/v1beta/models", field: "models"},
+ {name: "grok", path: "/v1/models", headers: map[string]string{"User-Agent": "grok-shell"}, field: "data"},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ request := httptest.NewRequest(http.MethodGet, test.path, nil)
+ request.Header.Set("Authorization", "Bearer scoped-key")
+ for key, value := range test.headers {
+ request.Header.Set(key, value)
+ }
+ recorder := httptest.NewRecorder()
+ engine.ServeHTTP(recorder, request)
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("status = %d; body=%s", recorder.Code, recorder.Body.String())
+ }
+ var payload map[string]json.RawMessage
+ if errUnmarshal := json.Unmarshal(recorder.Body.Bytes(), &payload); errUnmarshal != nil {
+ t.Fatal(errUnmarshal)
+ }
+ if string(payload[test.field]) != "[]" {
+ t.Fatalf("%s = %s, want []; body=%s", test.field, payload[test.field], recorder.Body.String())
+ }
+ })
+ }
+ recorder := httptest.NewRecorder()
+ engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1beta/models/inaccessible-model", nil))
+ if recorder.Code != http.StatusNotFound {
+ t.Fatalf("inaccessible model status = %d, want 404; body=%s", recorder.Code, recorder.Body.String())
+ }
+}
+
+func TestHomeModelCatalogErrorsRemainErrors(t *testing.T) {
+ server := newTestServer(t)
+ server.cfg.Home.Enabled = true
+ engine := gin.New()
+ engine.GET("/v1/models", server.unifiedModelsHandler(nil, nil))
+ for _, test := range []struct {
+ name string
+ payload string
+ status int
+ }{
+ {name: "missing credentials", payload: `{"error":{"type":"no_credentials","message":"Missing API key"}}`, status: http.StatusUnauthorized},
+ {name: "invalid credentials", payload: `{"error":{"type":"invalid_credential","message":"Invalid API key"}}`, status: http.StatusUnauthorized},
+ {name: "policy failure", payload: `{"error":{"type":"internal_error","message":"Model access service error"}}`, status: http.StatusBadGateway},
+ {name: "empty array error", payload: `{"error":[]}`, status: http.StatusBadGateway},
+ {name: "error alongside empty section", payload: `{"error":[],"claude":[]}`, status: http.StatusBadGateway},
+ {name: "error containing model-shaped records", payload: `{"error":[{"id":"not-a-model"}]}`, status: http.StatusBadGateway},
+ {name: "error alongside models", payload: `{"error":[],"claude":[{"id":"model-a"}]}`, status: http.StatusBadGateway},
+ {name: "empty response", payload: "", status: http.StatusBadGateway},
+ {name: "malformed JSON", payload: "{bad}", status: http.StatusBadGateway},
+ {name: "null catalog", payload: "null", status: http.StatusBadGateway},
+ {name: "null section", payload: `{"claude":null}`, status: http.StatusBadGateway},
+ {name: "missing model IDs", payload: `{"claude":[{}]}`, status: http.StatusBadGateway},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ client := newHomeCatalogClient(t, test.payload)
+ previousHome := home.Current()
+ home.SetCurrent(client)
+ t.Cleanup(func() { home.SetCurrent(previousHome) })
+ recorder := httptest.NewRecorder()
+ engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1/models", nil))
+ if recorder.Code != test.status {
+ t.Fatalf("status = %d, want %d; body=%s", recorder.Code, test.status, recorder.Body.String())
+ }
+ var payload map[string]json.RawMessage
+ if errUnmarshal := json.Unmarshal(recorder.Body.Bytes(), &payload); errUnmarshal != nil {
+ t.Fatal(errUnmarshal)
+ }
+ if _, exists := payload["error"]; !exists {
+ t.Fatalf("expected error response; body=%s", recorder.Body.String())
+ }
+ })
+ }
+}
diff --git a/internal/api/redis_queue_protocol.go b/internal/api/redis_queue_protocol.go
index b70c4a4ea35..f14344a1fce 100644
--- a/internal/api/redis_queue_protocol.go
+++ b/internal/api/redis_queue_protocol.go
@@ -58,7 +58,7 @@ func (s *Server) handleRedisConnection(conn net.Conn, reader *bufio.Reader) {
return true
}
- if s.cfg != nil && s.cfg.Home.Enabled {
+ if cfg := s.getConfig(); cfg != nil && cfg.Home.Enabled {
_ = writeRedisError(writer, "ERR redis usage output disabled in home mode")
_ = writer.Flush()
return
diff --git a/internal/api/runtime_reload_regression_test.go b/internal/api/runtime_reload_regression_test.go
new file mode 100644
index 00000000000..8cf7f21519b
--- /dev/null
+++ b/internal/api/runtime_reload_regression_test.go
@@ -0,0 +1,44 @@
+package api
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "sync"
+ "testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+)
+
+func TestHandlerRuntimeReloadConcurrentWithModelLists(t *testing.T) {
+ server := newTestServer(t)
+ host := &mockModelListInterceptorHost{}
+ start := make(chan struct{})
+ var wg sync.WaitGroup
+ wg.Add(2)
+ go func() {
+ defer wg.Done()
+ <-start
+ for i := 0; i < 500; i++ {
+ server.handlers.SetPluginHost(host)
+ server.handlers.UpdateClients(&config.SDKConfig{RequestLog: i%2 == 0})
+ server.handlers.SetPluginHost(nil)
+ }
+ }()
+ go func() {
+ defer wg.Done()
+ <-start
+ for i := 0; i < 500; i++ {
+ req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
+ req.Header.Set("Authorization", "Bearer test-key")
+ req.Header.Set("User-Agent", "codex_cli_rs/0.100.0")
+ rr := httptest.NewRecorder()
+ server.engine.ServeHTTP(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Errorf("model list status = %d, want 200", rr.Code)
+ return
+ }
+ }
+ }()
+ close(start)
+ wg.Wait()
+}
diff --git a/internal/api/server.go b/internal/api/server.go
index 808e37b05ec..d19b355bff3 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -121,6 +121,9 @@ type Server struct {
// Returns:
// - *Server: A new server instance
func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdkaccess.Manager, configFilePath string, opts ...ServerOption) *Server {
+ if cfg != nil {
+ cfg = cfg.CloneForRuntime()
+ }
optionState := &serverOptionConfig{
requestLoggerFactory: defaultRequestLoggerFactory,
}
diff --git a/internal/api/server_config_v8_etag_test.go b/internal/api/server_config_v8_etag_test.go
new file mode 100644
index 00000000000..0ca4eeed2ea
--- /dev/null
+++ b/internal/api/server_config_v8_etag_test.go
@@ -0,0 +1,70 @@
+package api
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+
+ proxyconfig "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+)
+
+// TestConfigV8ETagExposedForCORS covers issue #6666 through the real server:
+// a cross-origin management page must be able to read the ETag that the v8
+// config endpoints return, otherwise the GET -> If-Match flow cannot run.
+func TestConfigV8ETagExposedForCORS(t *testing.T) {
+ t.Setenv("MANAGEMENT_PASSWORD", "test-management-key")
+
+ server := newTestServer(t)
+ if err := os.WriteFile(server.configFilePath, []byte("config-version: 8\nserver: {port: 8317}\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err := proxyconfig.LoadConfig(server.configFilePath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ server.cfgMu.Lock()
+ server.cfg = cfg
+ server.cfgMu.Unlock()
+ server.mgmt.SetConfig(cfg)
+
+ do := func(method, body, ifMatch string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(method, "/v8/management/config.yaml", strings.NewReader(body))
+ req.Header.Set("Origin", "http://127.0.0.1:5173")
+ req.Header.Set("Authorization", "Bearer test-management-key")
+ if ifMatch != "" {
+ req.Header.Set("If-Match", ifMatch)
+ }
+ recorder := httptest.NewRecorder()
+ server.engine.ServeHTTP(recorder, req)
+ return recorder
+ }
+ wantETagExposed := func(recorder *httptest.ResponseRecorder, status int) string {
+ t.Helper()
+ if recorder.Code != status {
+ t.Fatalf("status = %d, want %d body=%s", recorder.Code, status, recorder.Body.String())
+ }
+ exposed := false
+ for _, name := range strings.Split(recorder.Header().Get("Access-Control-Expose-Headers"), ",") {
+ if strings.EqualFold(strings.TrimSpace(name), "ETag") {
+ exposed = true
+ }
+ }
+ if !exposed {
+ t.Fatalf("Access-Control-Expose-Headers missing ETag: %q", recorder.Header().Get("Access-Control-Expose-Headers"))
+ }
+ etag := recorder.Header().Get("ETag")
+ if etag == "" {
+ t.Fatal("response is missing ETag")
+ }
+ return etag
+ }
+
+ etag := wantETagExposed(do(http.MethodGet, "", ""), http.StatusOK)
+ // A stale precondition reports the current version, still CORS-readable.
+ wantETagExposed(do(http.MethodPut, "config-version: 8\n", `"stale"`), http.StatusPreconditionFailed)
+ // A successful conditional write chains through its exposed new ETag.
+ wantETagExposed(do(http.MethodPut, "config-version: 8\nserver: {port: 8318}\n", etag), http.StatusOK)
+}
diff --git a/internal/api/server_management.go b/internal/api/server_management.go
index 12a2ba53715..1eabc9cdc38 100644
--- a/internal/api/server_management.go
+++ b/internal/api/server_management.go
@@ -211,11 +211,12 @@ func (s *Server) managementAvailabilityMiddleware() gin.HandlerFunc {
}
func (s *Server) managementAvailable(c *gin.Context) bool {
- if s == nil || s.cfg == nil {
+ cfg := s.getConfig()
+ if s == nil || cfg == nil {
c.AbortWithStatus(http.StatusNotFound)
return false
}
- if s.cfg.Home.Enabled {
+ if cfg.Home.Enabled {
c.AbortWithStatus(http.StatusNotFound)
return false
}
@@ -296,7 +297,8 @@ func (s *Server) pluginResourceNoRoute(c *gin.Context) {
}
return
}
- if s.cfg == nil || s.cfg.Home.Enabled || s.pluginHost == nil {
+ cfg := s.getConfig()
+ if cfg == nil || cfg.Home.Enabled || s.pluginHost == nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
@@ -308,7 +310,7 @@ func (s *Server) pluginResourceNoRoute(c *gin.Context) {
}
func (s *Server) serveManagementControlPanel(c *gin.Context) {
- cfg := s.cfg
+ cfg := s.getConfig()
if cfg == nil || cfg.Home.Enabled || cfg.RemoteManagement.DisableControlPanel {
c.AbortWithStatus(http.StatusNotFound)
return
diff --git a/internal/api/server_management_v8.go b/internal/api/server_management_v8.go
index e2c0121e24b..8eadb89586d 100644
--- a/internal/api/server_management_v8.go
+++ b/internal/api/server_management_v8.go
@@ -13,8 +13,8 @@ import (
// Configuration paths mirror the v8 YAML tree; operational routes use its groups.
func (s *Server) registerManagementV8Routes() {
const prefix = "/v8/management"
- s.engine.GET(prefix+"/oauth/callback", s.managementAvailabilityMiddleware(), s.mgmt.GetOAuthCallback)
- s.engine.POST(prefix+"/oauth/callback", s.managementAvailabilityMiddleware(), s.mgmt.PostOAuthCallback)
+ s.engine.GET(prefix+"/oauth/callback", s.managementAvailabilityMiddleware(), s.mgmt.GetOAuthCallbackV8)
+ s.engine.POST(prefix+"/oauth/callback", s.managementAvailabilityMiddleware(), s.mgmt.PostOAuthCallbackV8)
v8 := s.engine.Group(prefix)
v8.Use(s.managementAvailabilityMiddleware(), s.mgmt.Middleware(), func(c *gin.Context) {
@@ -50,8 +50,8 @@ func (s *Server) registerManagementV8Routes() {
v8.GET("/observability/requests/recent", s.getRecentRequests)
v8.GET("/credentials", s.mgmt.ListAuthFiles)
- v8.POST("/credentials", s.mgmt.UploadAuthFile)
- v8.DELETE("/credentials", s.mgmt.DeleteAuthFile)
+ v8.POST("/credentials", s.mgmt.UploadAuthFileV8)
+ v8.DELETE("/credentials", s.mgmt.DeleteAuthFileV8)
v8.GET("/credentials/models", s.mgmt.GetAuthFileModels)
v8.GET("/credentials/download", s.mgmt.DownloadAuthFile)
v8.PATCH("/credentials/status", s.mgmt.PatchAuthFileStatus)
@@ -59,6 +59,7 @@ func (s *Server) registerManagementV8Routes() {
v8.POST("/credentials/refresh", s.mgmt.RefreshAuthFiles)
v8.POST("/oauth/import", s.mgmt.ImportOAuthV8)
v8.GET("/oauth/auth-url", s.mgmt.StartOAuthV8)
+ v8.POST("/oauth/siwc/bind", s.mgmt.StartSIWCBind)
v8.GET("/oauth/status", s.mgmt.GetAuthStatus)
v8.DELETE("/oauth/session", s.mgmt.CancelAuthSession)
diff --git a/internal/api/server_middleware.go b/internal/api/server_middleware.go
index 59a8128fc42..66040dfa81f 100644
--- a/internal/api/server_middleware.go
+++ b/internal/api/server_middleware.go
@@ -28,6 +28,9 @@ var corsExposedResponseHeaders = []string{
"X-SERVER-BUILD-DATE",
"Location",
"Retry-After",
+ // Cross-origin management clients must read the config version token to
+ // send it back as If-Match on writes.
+ "ETag",
"X-Request-Id",
"OpenAI-Request-Id",
}
@@ -73,7 +76,8 @@ func (s *Server) maxRequestBodyBytesMiddleware() gin.HandlerFunc {
func (s *Server) homeHeartbeatMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
- if s == nil || s.cfg == nil || !s.cfg.Home.Enabled {
+ cfg := s.getConfig()
+ if s == nil || cfg == nil || !cfg.Home.Enabled {
c.Next()
return
}
@@ -127,7 +131,7 @@ func (s *Server) exampleAPIKeySafeModeMiddleware() gin.HandlerFunc {
}
func (s *Server) serveExampleAPIKeyWarningPage(c *gin.Context) {
- cfg := s.cfg
+ cfg := s.getConfig()
var keys []string
if cfg != nil {
keys = safemode.ExampleAPIKeys(cfg.APIKeys)
diff --git a/internal/api/server_reload.go b/internal/api/server_reload.go
index 0b639b992c5..36c8e1cebe9 100644
--- a/internal/api/server_reload.go
+++ b/internal/api/server_reload.go
@@ -54,10 +54,14 @@ func (s *Server) UpdateClientsContext(ctx context.Context, cfg *config.Config) b
if errContext := ctx.Err(); errContext != nil {
return false
}
+ cfg = cfg.CloneForRuntime()
// Reconstruct old config from YAML snapshot to avoid reference sharing issues
var oldCfg *config.Config
- if len(s.oldConfigYaml) > 0 {
- _ = yaml.Unmarshal(s.oldConfigYaml, &oldCfg)
+ s.cfgMu.RLock()
+ oldConfigYaml := append([]byte(nil), s.oldConfigYaml...)
+ s.cfgMu.RUnlock()
+ if len(oldConfigYaml) > 0 {
+ _ = yaml.Unmarshal(oldConfigYaml, &oldCfg)
}
// Update request logger enabled state if it has changed
@@ -188,7 +192,10 @@ func (s *Server) UpdateClientsContext(ctx context.Context, cfg *config.Config) b
return false
}
// Save YAML snapshot for next comparison
- s.oldConfigYaml, _ = yaml.Marshal(cfg)
+ marshaledCfg, _ := yaml.Marshal(cfg)
+ s.cfgMu.Lock()
+ s.oldConfigYaml = marshaledCfg
+ s.cfgMu.Unlock()
s.handlers.UpdateClients(effectiveSDKConfig(cfg))
s.handlers.SetPluginHost(s.pluginHost)
diff --git a/internal/api/server_routes.go b/internal/api/server_routes.go
index 41b22c646fa..df9bcc72adb 100644
--- a/internal/api/server_routes.go
+++ b/internal/api/server_routes.go
@@ -160,7 +160,9 @@ func (s *Server) setupRoutes() {
errStr = c.Query("error_description")
}
if state != "" {
- _, _ = managementHandlers.WriteOAuthCallbackFileForPendingSession(s.cfg.AuthDir, "anthropic", state, code, errStr)
+ if cfg := s.getConfig(); cfg != nil {
+ _, _ = managementHandlers.WriteOAuthCallbackFileForPendingSession(cfg.AuthDir, "anthropic", state, code, errStr)
+ }
}
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, oauthCallbackSuccessHTML)
@@ -174,7 +176,12 @@ func (s *Server) setupRoutes() {
errStr = c.Query("error_description")
}
if state != "" {
- _, _ = managementHandlers.WriteOAuthCallbackFileForPendingSession(s.cfg.AuthDir, "codex", state, code, errStr)
+ if cfg := s.getConfig(); cfg != nil {
+ if _, errWrite := managementHandlers.WriteOAuthCallbackFileForPendingSession(cfg.AuthDir, "codex", state, code, errStr); errors.Is(errWrite, managementHandlers.ErrSIWCCallbackRequiresV8) {
+ c.JSON(http.StatusConflict, gin.H{"error": "SIWC callbacks require /v8/management/oauth/callback"})
+ return
+ }
+ }
}
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, oauthCallbackSuccessHTML)
@@ -188,7 +195,9 @@ func (s *Server) setupRoutes() {
errStr = c.Query("error_description")
}
if state != "" {
- _, _ = managementHandlers.WriteOAuthCallbackFileForPendingSession(s.cfg.AuthDir, "antigravity", state, code, errStr)
+ if cfg := s.getConfig(); cfg != nil {
+ _, _ = managementHandlers.WriteOAuthCallbackFileForPendingSession(cfg.AuthDir, "antigravity", state, code, errStr)
+ }
}
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, oauthCallbackSuccessHTML)
@@ -206,7 +215,12 @@ func (s *Server) setupRoutes() {
c.JSON(http.StatusBadRequest, gin.H{"error": "code or error is required"})
return
}
- if _, errWrite := managementHandlers.WriteOAuthCallbackFileForPendingSession(s.cfg.AuthDir, "devin", state, code, errStr); errWrite != nil {
+ cfg := s.getConfig()
+ authDir := ""
+ if cfg != nil {
+ authDir = cfg.AuthDir
+ }
+ if _, errWrite := managementHandlers.WriteOAuthCallbackFileForPendingSession(authDir, "devin", state, code, errStr); errWrite != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid or expired OAuth callback"})
return
}
@@ -227,8 +241,10 @@ func (s *Server) codexAlphaSearchModelRouterHost() handlers.PluginModelRouterHos
if s.pluginHost != nil {
return s.pluginHost
}
- if s.handlers != nil && s.handlers.ModelRouterHost != nil {
- return s.handlers.ModelRouterHost
+ if s.handlers != nil {
+ if host := s.handlers.ModelRouterHostSnapshot(); host != nil {
+ return host
+ }
}
return nil
}
@@ -467,7 +483,7 @@ func (s *Server) codexAlphaSearch(c *gin.Context) {
return nil, errRequest
}
authType, authValue := current.AccountInfo()
- helps.RecordAPIRequest(ctx, s.cfg, helps.UpstreamRequestLog{
+ helps.RecordAPIRequest(ctx, s.getConfig(), helps.UpstreamRequestLog{
URL: upstreamURL,
Method: http.MethodPost,
Headers: req.Header.Clone(),
@@ -488,6 +504,7 @@ func (s *Server) codexAlphaSearch(c *gin.Context) {
c.JSON(clienterror.HTTPStatusFromErrorOr(errCtx, http.StatusRequestTimeout), gin.H{"error": errCtx.Error()})
return
}
+ cfg := s.getConfig()
resp, err := performRequest(selected)
if err != nil {
if errors.Is(err, errMissingBaseURL) {
@@ -500,7 +517,7 @@ func (s *Server) codexAlphaSearch(c *gin.Context) {
if selection != nil {
selection.End("request_failed")
}
- helps.RecordAPIResponseError(ctx, s.cfg, err)
+ helps.RecordAPIResponseError(ctx, cfg, err)
c.JSON(clienterror.HTTPStatusFromErrorOr(err, http.StatusBadGateway), gin.H{"error": err.Error()})
return
}
@@ -524,18 +541,18 @@ func (s *Server) codexAlphaSearch(c *gin.Context) {
} else {
defer func() { _ = closeResponseBody() }()
}
- helps.RecordAPIResponseMetadata(ctx, s.cfg, resp.StatusCode, resp.Header.Clone())
+ helps.RecordAPIResponseMetadata(ctx, cfg, resp.StatusCode, resp.Header.Clone())
upstreamBody, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
if err != nil {
- helps.AppendAPIResponseChunk(ctx, s.cfg, upstreamBody)
+ helps.AppendAPIResponseChunk(ctx, cfg, upstreamBody)
if selection != nil && resp.StatusCode == http.StatusUnauthorized {
s.handlers.AuthManager.ReportHomeUnauthorized(ctx, selected, "codex", selectionModel, upstreamBody)
}
- helps.RecordAPIResponseError(ctx, s.cfg, err)
+ helps.RecordAPIResponseError(ctx, cfg, err)
c.JSON(clienterror.HTTPStatusFromErrorOr(err, http.StatusBadGateway), gin.H{"error": "Failed to read Codex search response"})
return
}
- helps.AppendAPIResponseChunk(ctx, s.cfg, upstreamBody)
+ helps.AppendAPIResponseChunk(ctx, cfg, upstreamBody)
if selection != nil && resp.StatusCode == http.StatusUnauthorized {
s.handlers.AuthManager.ReportHomeUnauthorized(ctx, selected, "codex", selectionModel, upstreamBody)
log.WithField("status", resp.StatusCode).Warnf("codex alpha search upstream request failed: %s", logging.SafeDiagnosticForLog(string(upstreamBody)))
@@ -605,9 +622,10 @@ func (s *Server) unifiedModelsHandler(openaiHandler *openai.OpenAIAPIHandler, cl
return
}
+ cfg := s.getConfig()
if _, ok := c.Request.URL.Query()["client_version"]; ok {
clientVersion := c.Query("client_version")
- if s != nil && s.cfg != nil && s.cfg.Home.Enabled {
+ if cfg != nil && cfg.Home.Enabled {
s.handleHomeCodexClientModels(c, clientVersion)
return
}
@@ -615,7 +633,7 @@ func (s *Server) unifiedModelsHandler(openaiHandler *openai.OpenAIAPIHandler, cl
return
}
- if s != nil && s.cfg != nil && s.cfg.Home.Enabled {
+ if cfg != nil && cfg.Home.Enabled {
s.handleHomeModels(c)
return
}
@@ -662,7 +680,7 @@ func grokModelsFromRegistryInfos(infos []*registry.ModelInfo) []grokbuild.ModelI
func (s *Server) handleGrokModels(c *gin.Context) {
var models []grokbuild.ModelInfo
- if s != nil && s.cfg != nil && s.cfg.Home.Enabled {
+ if cfg := s.getConfig(); cfg != nil && cfg.Home.Enabled {
entries, ok := s.loadHomeModelEntries(c)
if !ok {
return
@@ -690,9 +708,10 @@ func (s *Server) handleHomeCodexClientModels(c *gin.Context, clientVersion strin
return
}
+ cfg := s.getConfig()
models := make([]map[string]any, 0, len(entries))
for _, entry := range entries {
- models = append(models, formatHomeCodexModelWithSettings(entry, s.cfg))
+ models = append(models, formatHomeCodexModelWithSettings(entry, cfg))
}
var webSearchCapabilityForModel codexmodels.WebSearchCapabilityForModelFunc
@@ -704,16 +723,17 @@ func (s *Server) handleHomeCodexClientModels(c *gin.Context, clientVersion strin
manager = s.handlers.AuthManager
}
var applyPatchCapabilityForModel codexmodels.ApplyPatchCapabilityForModelFunc
- if s.cfg.Client.Codex.EnableApplyPatch {
+ if cfg != nil && cfg.Client.Codex.EnableApplyPatch {
applyPatchCapabilityForModel = homeApplyPatchCapabilityForModel(entries, manager)
}
+ optimizeMultiAgentV2 := cfg != nil && cfg.Client.Codex.OptimizeMultiAgentV2
var payload map[string]any
if responsesclaude.ClientToolSearchEnabled() {
- payload = codexmodels.BuildResponseForClientWithSearchToolRoutes(models, nil, homeProvidersForModel(entries), webSearchCapabilityForModel, applyPatchCapabilityForModel, homeToolSearchCapabilityForModel(entries), s.cfg.Client.Codex.OptimizeMultiAgentV2, clientVersion)
+ payload = codexmodels.BuildResponseForClientWithSearchToolRoutes(models, nil, homeProvidersForModel(entries), webSearchCapabilityForModel, applyPatchCapabilityForModel, homeToolSearchCapabilityForModel(entries), optimizeMultiAgentV2, clientVersion)
} else {
// upstream.claude.tool-search off keeps the catalog that predates
// tool_search, which never consulted Home routes for search support.
- payload = codexmodels.BuildResponseForClientWithToolCapabilities(models, nil, webSearchCapabilityForModel, applyPatchCapabilityForModel, nil, s.cfg.Client.Codex.OptimizeMultiAgentV2, clientVersion)
+ payload = codexmodels.BuildResponseForClientWithToolCapabilities(models, nil, webSearchCapabilityForModel, applyPatchCapabilityForModel, nil, optimizeMultiAgentV2, clientVersion)
}
body, errMarshal := codexmodels.MarshalCompact(payload)
if errMarshal != nil {
@@ -853,7 +873,7 @@ func formatHomeCodexModelWithSettings(entry homeModelEntry, cfg *config.Config)
func (s *Server) geminiModelsHandler(geminiHandler *gemini.GeminiAPIHandler) gin.HandlerFunc {
return func(c *gin.Context) {
- if s != nil && s.cfg != nil && s.cfg.Home.Enabled {
+ if cfg := s.getConfig(); cfg != nil && cfg.Home.Enabled {
s.handleHomeGeminiModels(c)
return
}
@@ -864,7 +884,7 @@ func (s *Server) geminiModelsHandler(geminiHandler *gemini.GeminiAPIHandler) gin
func (s *Server) geminiGetHandler(geminiHandler *gemini.GeminiAPIHandler) gin.HandlerFunc {
return func(c *gin.Context) {
- if s != nil && s.cfg != nil && s.cfg.Home.Enabled {
+ if cfg := s.getConfig(); cfg != nil && cfg.Home.Enabled {
s.handleHomeGeminiModel(c)
return
}
@@ -895,7 +915,8 @@ func (s *Server) handleHomeModels(c *gin.Context) {
isClaude := isAnthropicModelsRequest(c)
if isClaude {
- disableCloaking := s.cfg != nil && s.cfg.ClaudeCode.DisableCloakingModelList
+ cfg := s.getConfig()
+ disableCloaking := cfg != nil && cfg.ClaudeCode.DisableCloakingModelList
s.writeModelListResponse(c, "claude", claudemodels.BuildResponse(formatHomeClaudeModels(entries), disableCloaking))
return
}
@@ -1148,13 +1169,22 @@ func decodeHomeModels(raw []byte) ([]homeModelEntry, error) {
if err := json.Unmarshal(raw, &bySection); err != nil {
return nil, fmt.Errorf("parse home models payload: %w", err)
}
- if len(bySection) == 0 {
- return nil, fmt.Errorf("home models payload has no sections")
+ if bySection == nil {
+ return nil, fmt.Errorf("home models payload is not an object")
+ }
+ // The error envelope must never be interpreted as a provider model section.
+ if _, hasError := bySection["error"]; hasError {
+ return nil, fmt.Errorf("home models payload contains an error envelope")
}
indexByID := make(map[string]int)
out := make([]homeModelEntry, 0, 256)
+ hasModelRecords := false
for section, models := range bySection {
+ if models == nil {
+ return nil, fmt.Errorf("home models section %q is null", section)
+ }
+ hasModelRecords = hasModelRecords || len(models) > 0
provider := strings.ToLower(strings.TrimSpace(section))
for _, model := range models {
id, _ := model["id"].(string)
@@ -1205,7 +1235,9 @@ func decodeHomeModels(raw []byte) ([]homeModelEntry, error) {
}
sort.Slice(out, func(i, j int) bool { return out[i].id < out[j].id })
- if len(out) == 0 {
+ // An empty catalog is valid when the client's access scopes have no routes.
+ // Non-empty sections with no usable model IDs still indicate malformed data.
+ if len(out) == 0 && hasModelRecords {
return nil, fmt.Errorf("home models payload contains no models")
}
return out, nil
diff --git a/internal/api/server_siwc_test.go b/internal/api/server_siwc_test.go
new file mode 100644
index 00000000000..30b6e840eb2
--- /dev/null
+++ b/internal/api/server_siwc_test.go
@@ -0,0 +1,135 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "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"
+ "golang.org/x/crypto/bcrypt"
+)
+
+func TestSIWCManagementRouteAccessControl(t *testing.T) {
+ hash, errHash := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
+ if errHash != nil {
+ t.Fatal(errHash)
+ }
+ for _, tc := range []struct {
+ name string
+ enabled, home, authorized bool
+ want int
+ }{
+ {"authorized invalid target", true, false, true, http.StatusBadRequest},
+ {"missing management key", true, false, false, http.StatusUnauthorized},
+ {"management disabled", false, false, true, http.StatusNotFound},
+ {"home disabled local API", true, true, true, http.StatusNotFound},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ cfg := &config.Config{AuthDir: t.TempDir()}
+ cfg.Home.Enabled = tc.home
+ cfg.RemoteManagement.SecretKey = string(hash)
+ h := management.NewHandlerWithoutConfigFilePath(cfg, nil)
+ h.SetLocalPassword("test-password")
+ s := &Server{cfg: cfg, engine: gin.New(), mgmt: h}
+ s.managementRoutesEnabled.Store(tc.enabled)
+ s.registerManagementRoutes()
+ req := httptest.NewRequest(http.MethodPost, "/v8/management/oauth/siwc/bind", strings.NewReader(`{}`))
+ req.Header.Set("Content-Type", "application/json")
+ req.RemoteAddr = "127.0.0.1:1234"
+ if tc.authorized {
+ req.Header.Set("Authorization", "Bearer test-password")
+ }
+ w := httptest.NewRecorder()
+ s.engine.ServeHTTP(w, req)
+ if w.Code != tc.want {
+ t.Fatalf("status=%d want=%d: %s", w.Code, tc.want, w.Body.String())
+ }
+ for _, route := range s.engine.Routes() {
+ if strings.HasPrefix(route.Path, "/v0/") && strings.Contains(route.Path, "siwc") {
+ t.Fatal("SIWC route registered under v0")
+ }
+ }
+ })
+ }
+}
+
+func TestCodexCallbackRejectsSIWCState(t *testing.T) {
+ hash, errHash := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
+ if errHash != nil {
+ t.Fatal(errHash)
+ }
+ cfg := &config.Config{}
+ cfg.RemoteManagement.SecretKey = string(hash)
+ s := newTestServerWithConfig(t, cfg)
+ metadata := map[string]any{"type": "codex", "access_token": "outer-token", "refresh_token": "outer-refresh"}
+ data, err := json.Marshal(metadata)
+ if err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(s.cfg.AuthDir, "codex.json")
+ if errWrite := os.WriteFile(path, data, 0o600); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ manager := coreauth.NewManager(nil, nil, nil)
+ manager.SetConfig(s.cfg)
+ if _, errRegister := manager.Register(context.Background(), &coreauth.Auth{
+ ID: "codex.json", FileName: "codex.json", Provider: "codex", Status: coreauth.StatusActive,
+ Metadata: metadata, Attributes: map[string]string{"path": path},
+ }); errRegister != nil {
+ t.Fatal(errRegister)
+ }
+ s.mgmt.SetAuthManager(manager)
+ s.mgmt.SetLocalPassword("test-password")
+ s.managementRoutesEnabled.Store(true)
+ s.registerManagementRoutes()
+ req := httptest.NewRequest(http.MethodPost, "/v8/management/oauth/siwc/bind", strings.NewReader(`{"name":"codex.json"}`))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer test-password")
+ req.RemoteAddr = "127.0.0.1:1234"
+ started := httptest.NewRecorder()
+ s.engine.ServeHTTP(started, req)
+ if started.Code != http.StatusOK {
+ t.Fatalf("start status=%d: %s", started.Code, started.Body.String())
+ }
+ var flow struct {
+ State string `json:"state"`
+ Flow string `json:"flow"`
+ }
+ if errDecode := json.Unmarshal(started.Body.Bytes(), &flow); errDecode != nil {
+ t.Fatal(errDecode)
+ }
+ if flow.State == "" || flow.Flow != "siwc_bind" {
+ t.Fatalf("unexpected bind response: %s", started.Body.String())
+ }
+ t.Cleanup(func() { management.CancelOAuthSession(flow.State) })
+ callback := httptest.NewRecorder()
+ query := url.Values{"state": {flow.State}, "code": {"issued-code"}, "client_id": {"oaiapp_issued"}}
+ s.engine.ServeHTTP(callback, httptest.NewRequest(http.MethodGet, "/codex/callback?"+query.Encode(), nil))
+ if callback.Code != http.StatusConflict {
+ t.Fatalf("callback status=%d want=409: %s", callback.Code, callback.Body.String())
+ }
+ if !strings.Contains(callback.Body.String(), "/v8/management/oauth/callback") {
+ t.Fatalf("missing v8 callback guidance: %s", callback.Body.String())
+ }
+ entries, errRead := os.ReadDir(s.cfg.AuthDir)
+ if errRead != nil {
+ t.Fatal(errRead)
+ }
+ for _, entry := range entries {
+ if strings.HasPrefix(entry.Name(), ".oauth") {
+ t.Fatalf("legacy callback created OAuth file %q", entry.Name())
+ }
+ }
+ if !management.IsOAuthSessionPending(flow.State, "codex") {
+ t.Fatal("rejected callback consumed SIWC session")
+ }
+}
diff --git a/internal/api/server_test.go b/internal/api/server_test.go
index 3e83e75ef06..44862bb9dba 100644
--- a/internal/api/server_test.go
+++ b/internal/api/server_test.go
@@ -1720,6 +1720,7 @@ func TestManagementPluginsRouteRegistered(t *testing.T) {
server.cfg.Plugins.Configs = map[string]proxyconfig.PluginInstanceConfig{
"sample": {Enabled: &enabled, Priority: 4},
}
+ server.mgmt.SetConfig(server.cfg)
if errWrite := os.WriteFile(server.configFilePath, []byte("{}\n"), 0o600); errWrite != nil {
t.Fatalf("failed to write config file: %v", errWrite)
}
diff --git a/internal/cache/claude_web_search_result_cache.go b/internal/cache/claude_web_search_result_cache.go
new file mode 100644
index 00000000000..6573881e676
--- /dev/null
+++ b/internal/cache/claude_web_search_result_cache.go
@@ -0,0 +1,221 @@
+package cache
+
+import (
+ "bytes"
+ "container/list"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "strings"
+ "sync"
+ "time"
+
+ homekv "github.com/router-for-me/CLIProxyAPI/v8/internal/home"
+ log "github.com/sirupsen/logrus"
+)
+
+const (
+ defaultClaudeWebSearchResultTTL = 24 * time.Hour
+ defaultClaudeWebSearchResultLimit = 1024
+)
+
+type claudeWebSearchResultEntry struct {
+ key string
+ content []byte
+ ttl time.Duration
+ expiresAt time.Time
+}
+
+var (
+ claudeWebSearchResultMu sync.Mutex
+ claudeWebSearchResultIndex = make(map[string]*list.Element)
+ claudeWebSearchResultOrder = list.New()
+ claudeWebSearchResultNow = time.Now
+)
+
+type claudeWebSearchResultKVClient interface {
+ KVGet(ctx context.Context, key string) ([]byte, bool, error)
+ KVSet(ctx context.Context, key string, value []byte, opts homekv.KVSetOptions) (bool, error)
+ KVExpire(ctx context.Context, key string, ttl time.Duration) (bool, error)
+}
+
+var currentClaudeWebSearchResultKVClient = func() (claudeWebSearchResultKVClient, bool, error) {
+ return homekv.CurrentKVClient()
+}
+
+func claudeWebSearchResultKey(sessionKey, toolUseID string) (string, bool) {
+ sessionKey = strings.TrimSpace(sessionKey)
+ toolUseID = strings.TrimSpace(toolUseID)
+ if sessionKey == "" || toolUseID == "" {
+ return "", false
+ }
+ sum := sha256.Sum256([]byte(sessionKey + "\x00" + toolUseID))
+ return hex.EncodeToString(sum[:]), true
+}
+
+func claudeWebSearchResultKVKey(sessionKey, toolUseID string) string {
+ key, ok := claudeWebSearchResultKey(sessionKey, toolUseID)
+ if !ok {
+ return ""
+ }
+ return "cpa:claude:web-search-result:" + key
+}
+
+// CacheClaudeWebSearchResult stores the raw content of one web_search_tool_result
+// block for a session. Memory is bounded by the maximum entry count (limit);
+// least recently used entries are evicted when the entry limit is exceeded.
+func CacheClaudeWebSearchResult(sessionKey, toolUseID string, content []byte, ttl time.Duration, limit int) {
+ CacheClaudeWebSearchResultBestEffort(context.Background(), sessionKey, toolUseID, content, ttl, limit)
+}
+
+// CacheClaudeWebSearchResultBestEffort stores the raw content of one
+// web_search_tool_result block for completed response paths.
+func CacheClaudeWebSearchResultBestEffort(ctx context.Context, sessionKey, toolUseID string, content []byte, ttl time.Duration, limit int) bool {
+ key, ok := claudeWebSearchResultKey(sessionKey, toolUseID)
+ if !ok || len(content) == 0 {
+ return false
+ }
+ if ttl <= 0 {
+ ttl = defaultClaudeWebSearchResultTTL
+ }
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ if client, homeMode, errClient := currentClaudeWebSearchResultKVClient(); homeMode {
+ if errClient != nil {
+ log.Errorf("home kv best-effort claude web search result set failed prefix=cpa:claude:*: %v", errClient)
+ return false
+ }
+ written, errSet := client.KVSet(ctx, "cpa:claude:web-search-result:"+key, bytes.Clone(content), homekv.KVSetOptions{EX: ttl})
+ if errSet != nil {
+ log.Errorf("home kv best-effort claude web search result set failed prefix=cpa:claude:*: %v", errSet)
+ return false
+ }
+ return written
+ }
+ if limit <= 0 {
+ limit = defaultClaudeWebSearchResultLimit
+ }
+ cacheCleanupOnce.Do(startCacheCleanup)
+
+ claudeWebSearchResultMu.Lock()
+ defer claudeWebSearchResultMu.Unlock()
+
+ now := claudeWebSearchResultNow()
+ if element, exists := claudeWebSearchResultIndex[key]; exists {
+ removeClaudeWebSearchResultLocked(element)
+ }
+ if claudeWebSearchResultOrder.Len() >= limit {
+ purgeExpiredClaudeWebSearchResultLocked(now)
+ }
+ entry := &claudeWebSearchResultEntry{
+ key: key,
+ content: bytes.Clone(content),
+ ttl: ttl,
+ expiresAt: now.Add(ttl),
+ }
+ claudeWebSearchResultIndex[key] = claudeWebSearchResultOrder.PushFront(entry)
+ for claudeWebSearchResultOrder.Len() > limit {
+ oldest := claudeWebSearchResultOrder.Back()
+ if oldest == nil {
+ break
+ }
+ removeClaudeWebSearchResultLocked(oldest)
+ }
+ return true
+}
+
+// GetClaudeWebSearchResult returns a copy of the stored content and extends its
+// lifetime. Expired entries are removed and reported as missing.
+func GetClaudeWebSearchResult(sessionKey, toolUseID string, ttl ...time.Duration) ([]byte, bool) {
+ content, ok, errGet := GetClaudeWebSearchResultRequired(context.Background(), sessionKey, toolUseID, ttl...)
+ if errGet != nil {
+ return nil, false
+ }
+ return content, ok
+}
+
+// GetClaudeWebSearchResultRequired retrieves a copy of the stored content for
+// request-time paths and extends its lifetime.
+func GetClaudeWebSearchResultRequired(ctx context.Context, sessionKey, toolUseID string, ttl ...time.Duration) ([]byte, bool, error) {
+ key, ok := claudeWebSearchResultKey(sessionKey, toolUseID)
+ if !ok {
+ return nil, false, nil
+ }
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ if client, homeMode, errClient := currentClaudeWebSearchResultKVClient(); homeMode {
+ if errClient != nil {
+ return nil, false, errClient
+ }
+ kvKey := "cpa:claude:web-search-result:" + key
+ raw, found, errGet := client.KVGet(ctx, kvKey)
+ if errGet != nil || !found || len(raw) == 0 {
+ return nil, false, errGet
+ }
+ effectiveTTL := defaultClaudeWebSearchResultTTL
+ if len(ttl) > 0 && ttl[0] > 0 {
+ effectiveTTL = ttl[0]
+ }
+ if _, errExpire := client.KVExpire(ctx, kvKey, effectiveTTL); errExpire != nil {
+ log.Warnf("home kv claude web search result expire failed prefix=cpa:claude:*: %v", errExpire)
+ }
+ return bytes.Clone(raw), true, nil
+ }
+ claudeWebSearchResultMu.Lock()
+ defer claudeWebSearchResultMu.Unlock()
+
+ element, exists := claudeWebSearchResultIndex[key]
+ if !exists {
+ return nil, false, nil
+ }
+ entry := element.Value.(*claudeWebSearchResultEntry)
+ now := claudeWebSearchResultNow()
+ if !now.Before(entry.expiresAt) {
+ removeClaudeWebSearchResultLocked(element)
+ return nil, false, nil
+ }
+ effectiveTTL := entry.ttl
+ if len(ttl) > 0 && ttl[0] > 0 {
+ effectiveTTL = ttl[0]
+ entry.ttl = effectiveTTL
+ }
+ if effectiveTTL <= 0 {
+ effectiveTTL = defaultClaudeWebSearchResultTTL
+ }
+ entry.expiresAt = now.Add(effectiveTTL)
+ claudeWebSearchResultOrder.MoveToFront(element)
+ return bytes.Clone(entry.content), true, nil
+}
+
+// ClearClaudeWebSearchResultCache removes every stored entry.
+func ClearClaudeWebSearchResultCache() {
+ claudeWebSearchResultMu.Lock()
+ defer claudeWebSearchResultMu.Unlock()
+ claudeWebSearchResultIndex = make(map[string]*list.Element)
+ claudeWebSearchResultOrder.Init()
+}
+
+func purgeExpiredClaudeWebSearchResultCache(now time.Time) {
+ claudeWebSearchResultMu.Lock()
+ defer claudeWebSearchResultMu.Unlock()
+ purgeExpiredClaudeWebSearchResultLocked(now)
+}
+
+func purgeExpiredClaudeWebSearchResultLocked(now time.Time) {
+ for element := claudeWebSearchResultOrder.Back(); element != nil; {
+ prev := element.Prev()
+ entry := element.Value.(*claudeWebSearchResultEntry)
+ if !now.Before(entry.expiresAt) {
+ removeClaudeWebSearchResultLocked(element)
+ }
+ element = prev
+ }
+}
+
+func removeClaudeWebSearchResultLocked(element *list.Element) {
+ entry := element.Value.(*claudeWebSearchResultEntry)
+ delete(claudeWebSearchResultIndex, entry.key)
+ claudeWebSearchResultOrder.Remove(element)
+}
diff --git a/internal/cache/claude_web_search_result_cache_test.go b/internal/cache/claude_web_search_result_cache_test.go
new file mode 100644
index 00000000000..51141ad5eee
--- /dev/null
+++ b/internal/cache/claude_web_search_result_cache_test.go
@@ -0,0 +1,294 @@
+package cache
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ homekv "github.com/router-for-me/CLIProxyAPI/v8/internal/home"
+)
+
+type fakeClaudeWebSearchResultKVClient struct {
+ values map[string][]byte
+ getErr error
+ setErr error
+ expireErr error
+ getCount int
+ setCount int
+ expireCount int
+ lastSetTTL time.Duration
+ lastExpireTTL time.Duration
+}
+
+func newFakeClaudeWebSearchResultKVClient() *fakeClaudeWebSearchResultKVClient {
+ return &fakeClaudeWebSearchResultKVClient{values: make(map[string][]byte)}
+}
+
+func (c *fakeClaudeWebSearchResultKVClient) KVGet(_ context.Context, key string) ([]byte, bool, error) {
+ c.getCount++
+ if c.getErr != nil {
+ return nil, false, c.getErr
+ }
+ value, ok := c.values[key]
+ if !ok {
+ return nil, false, nil
+ }
+ return bytes.Clone(value), true, nil
+}
+
+func (c *fakeClaudeWebSearchResultKVClient) KVSet(_ context.Context, key string, value []byte, opts homekv.KVSetOptions) (bool, error) {
+ c.setCount++
+ c.lastSetTTL = opts.EX
+ if c.setErr != nil {
+ return false, c.setErr
+ }
+ c.values[key] = bytes.Clone(value)
+ return true, nil
+}
+
+func (c *fakeClaudeWebSearchResultKVClient) KVExpire(_ context.Context, _ string, ttl time.Duration) (bool, error) {
+ c.expireCount++
+ c.lastExpireTTL = ttl
+ if c.expireErr != nil {
+ return false, c.expireErr
+ }
+ return true, nil
+}
+
+func useFakeClaudeWebSearchResultKVClient(t *testing.T, client *fakeClaudeWebSearchResultKVClient, homeMode bool, errClient error) {
+ t.Helper()
+ previous := currentClaudeWebSearchResultKVClient
+ currentClaudeWebSearchResultKVClient = func() (claudeWebSearchResultKVClient, bool, error) {
+ return client, homeMode, errClient
+ }
+ t.Cleanup(func() {
+ currentClaudeWebSearchResultKVClient = previous
+ })
+}
+
+func useClaudeWebSearchResultClock(t *testing.T, now *time.Time) {
+ t.Helper()
+ ClearClaudeWebSearchResultCache()
+ previous := claudeWebSearchResultNow
+ claudeWebSearchResultNow = func() time.Time { return *now }
+ t.Cleanup(func() {
+ claudeWebSearchResultNow = previous
+ ClearClaudeWebSearchResultCache()
+ })
+}
+
+func TestClaudeWebSearchResultCacheScopesBySessionAndToolUse(t *testing.T) {
+ now := time.Unix(1_800_000_000, 0)
+ useClaudeWebSearchResultClock(t, &now)
+
+ CacheClaudeWebSearchResult("session-a", "srvtoolu_1", []byte(`[{"url":"a"}]`), 30*time.Minute, 16)
+
+ got, ok := GetClaudeWebSearchResult("session-a", "srvtoolu_1")
+ if !ok || string(got) != `[{"url":"a"}]` {
+ t.Fatalf("Get = %q, %v; want stored content", got, ok)
+ }
+ if _, ok := GetClaudeWebSearchResult("session-b", "srvtoolu_1"); ok {
+ t.Fatal("content leaked to another session")
+ }
+ if _, ok := GetClaudeWebSearchResult("session-a", "srvtoolu_2"); ok {
+ t.Fatal("content returned for another tool use id")
+ }
+ if _, ok := GetClaudeWebSearchResult("", "srvtoolu_1"); ok {
+ t.Fatal("empty session key must not match")
+ }
+ got[0] = 'X'
+ if again, _ := GetClaudeWebSearchResult("session-a", "srvtoolu_1"); !bytes.Equal(again, []byte(`[{"url":"a"}]`)) {
+ t.Fatalf("caller mutation changed the cache: %q", again)
+ }
+}
+
+func TestClaudeWebSearchResultCacheExpiresAndSlides(t *testing.T) {
+ now := time.Unix(1_800_000_000, 0)
+ useClaudeWebSearchResultClock(t, &now)
+
+ ttl := 30 * time.Minute
+ CacheClaudeWebSearchResult("s", "srvtoolu_1", []byte(`[1]`), ttl, 16)
+ now = now.Add(ttl - time.Minute)
+ if _, ok := GetClaudeWebSearchResult("s", "srvtoolu_1", ttl); !ok {
+ t.Fatal("entry expired before its TTL")
+ }
+ // The read above extended the lifetime.
+ now = now.Add(ttl - time.Minute)
+ if _, ok := GetClaudeWebSearchResult("s", "srvtoolu_1", ttl); !ok {
+ t.Fatal("read did not extend the entry lifetime")
+ }
+ now = now.Add(ttl)
+ if _, ok := GetClaudeWebSearchResult("s", "srvtoolu_1", ttl); ok {
+ t.Fatal("entry survived past its TTL")
+ }
+ if claudeWebSearchResultOrder.Len() != 0 {
+ t.Fatalf("expired entry not removed: len=%d", claudeWebSearchResultOrder.Len())
+ }
+}
+
+func TestClaudeWebSearchResultCacheEnforcesEntryLimitWithoutByteCap(t *testing.T) {
+ now := time.Unix(1_800_000_000, 0)
+ useClaudeWebSearchResultClock(t, &now)
+
+ const limit = 4
+ longSession := strings.Repeat("s", 4096)
+ // Large payloads must not be rejected by a byte limit; only the entry limit applies.
+ largeContent := bytes.Repeat([]byte("a"), 5<<20)
+ CacheClaudeWebSearchResult("s", "large", largeContent, time.Hour, limit)
+ if got, ok := GetClaudeWebSearchResult("s", "large"); !ok || len(got) != len(largeContent) {
+ t.Fatalf("large entry len = %d, ok = %v; want %d, true", len(got), ok, len(largeContent))
+ }
+
+ for i := 0; i < limit+10; i++ {
+ CacheClaudeWebSearchResult(longSession+strconv.Itoa(i), "srvtoolu_"+strconv.Itoa(i), []byte(`[1]`), time.Hour, limit)
+ }
+ if got := claudeWebSearchResultOrder.Len(); got != limit {
+ t.Fatalf("entries = %d, want %d", got, limit)
+ }
+ for key := range claudeWebSearchResultIndex {
+ if len(key) != 64 {
+ t.Fatalf("stored key len = %d, want 64-char SHA-256 hex", len(key))
+ }
+ }
+ last := limit + 9
+ if _, ok := GetClaudeWebSearchResult(longSession+strconv.Itoa(last), "srvtoolu_"+strconv.Itoa(last)); !ok {
+ t.Fatal("newest entry was evicted")
+ }
+ if _, ok := GetClaudeWebSearchResult(longSession+"0", "srvtoolu_0"); ok {
+ t.Fatal("oldest entry survived the entry limit")
+ }
+
+ CacheClaudeWebSearchResult(longSession+strconv.Itoa(last), "srvtoolu_"+strconv.Itoa(last), []byte(`[2]`), time.Hour, limit)
+ if got, _ := GetClaudeWebSearchResult(longSession+strconv.Itoa(last), "srvtoolu_"+strconv.Itoa(last)); string(got) != `[2]` {
+ t.Fatalf("overwrite = %q, want [2]", got)
+ }
+}
+
+func TestClaudeWebSearchResultCachePurgesExpiredBeforeEvictingLiveEntries(t *testing.T) {
+ now := time.Unix(1_800_000_000, 0)
+ useClaudeWebSearchResultClock(t, &now)
+
+ CacheClaudeWebSearchResult("s", "short", []byte(`[1]`), 5*time.Minute, 2)
+ CacheClaudeWebSearchResult("s", "long", []byte(`[2]`), 30*time.Minute, 2)
+ // Touch "short" so "long" is the LRU tail, then advance past "short"'s TTL.
+ _, _ = GetClaudeWebSearchResult("s", "short", 5*time.Minute)
+ now = now.Add(10 * time.Minute)
+
+ CacheClaudeWebSearchResult("s", "new", []byte(`[3]`), 30*time.Minute, 2)
+ if _, ok := GetClaudeWebSearchResult("s", "long"); !ok {
+ t.Fatal("live entry was evicted while an expired entry could have been purged")
+ }
+ if _, ok := GetClaudeWebSearchResult("s", "new"); !ok {
+ t.Fatal("new entry missing")
+ }
+}
+
+func TestClaudeWebSearchResultCacheHomeModeDoesNotUseLocalCache(t *testing.T) {
+ now := time.Unix(1_800_000_000, 0)
+ useClaudeWebSearchResultClock(t, &now)
+
+ CacheClaudeWebSearchResult("session-local", "srvtoolu_local", []byte(`[{"url":"local"}]`), 30*time.Minute, 16)
+
+ homekv.SetCurrent(&homekv.Client{})
+ t.Cleanup(homekv.ClearCurrent)
+
+ if got, ok := GetClaudeWebSearchResult("session-local", "srvtoolu_local", 30*time.Minute); ok {
+ t.Fatalf("GetClaudeWebSearchResult() = %q, true; want Home miss without local fallback", got)
+ }
+
+ CacheClaudeWebSearchResult("session-home", "srvtoolu_home", []byte(`[{"url":"home"}]`), 30*time.Minute, 16)
+ homekv.ClearCurrent()
+
+ if got, ok := GetClaudeWebSearchResult("session-home", "srvtoolu_home", 30*time.Minute); ok {
+ t.Fatalf("local cache = %q, true; want empty after Home write failure", got)
+ }
+}
+
+func TestClaudeWebSearchResultCacheHomeWriteReadAndSlidingExpire(t *testing.T) {
+ now := time.Unix(1_800_000_000, 0)
+ useClaudeWebSearchResultClock(t, &now)
+
+ client := newFakeClaudeWebSearchResultKVClient()
+ useFakeClaudeWebSearchResultKVClient(t, client, true, nil)
+
+ content := []byte(`[{"type":"web_search_result","url":"https://example.com","encrypted_content":"enc"}]`)
+ if !CacheClaudeWebSearchResultBestEffort(context.Background(), "session-home", "srvtoolu_1", content, 45*time.Minute, 16) {
+ t.Fatal("CacheClaudeWebSearchResultBestEffort() = false, want true")
+ }
+ if claudeWebSearchResultOrder.Len() != 0 {
+ t.Fatalf("local cache len = %d, want 0 in Home mode", claudeWebSearchResultOrder.Len())
+ }
+ kvKey := claudeWebSearchResultKVKey("session-home", "srvtoolu_1")
+ if client.setCount != 1 || client.lastSetTTL != 45*time.Minute {
+ t.Fatalf("KVSet count/ttl = %d/%v, want 1/45m", client.setCount, client.lastSetTTL)
+ }
+ if !bytes.Equal(client.values[kvKey], content) {
+ t.Fatalf("KV stored value = %q, want %q", client.values[kvKey], content)
+ }
+
+ got, ok, errGet := GetClaudeWebSearchResultRequired(context.Background(), "session-home", "srvtoolu_1", 30*time.Minute)
+ if errGet != nil || !ok || !bytes.Equal(got, content) {
+ t.Fatalf("GetClaudeWebSearchResultRequired() = %q, %v, %v; want %q, true, nil", got, ok, errGet, content)
+ }
+ if client.expireCount != 1 || client.lastExpireTTL != 30*time.Minute {
+ t.Fatalf("KVExpire count/ttl = %d/%v, want 1/30m", client.expireCount, client.lastExpireTTL)
+ }
+ got[0] = 'X'
+ if !bytes.Equal(client.values[kvKey], content) {
+ t.Fatalf("caller mutation altered Home KV fake state: %q", client.values[kvKey])
+ }
+
+ // Expire error should not discard a successfully fetched result.
+ client.expireErr = errors.New("expire failed")
+ gotAfterExpireErr, okAfterExpireErr, errAfterExpireErr := GetClaudeWebSearchResultRequired(context.Background(), "session-home", "srvtoolu_1")
+ if errAfterExpireErr != nil || !okAfterExpireErr || !bytes.Equal(gotAfterExpireErr, content) {
+ t.Fatalf("GetClaudeWebSearchResultRequired() with expireErr = %q, %v, %v; want %q, true, nil", gotAfterExpireErr, okAfterExpireErr, errAfterExpireErr, content)
+ }
+ if client.expireCount != 2 || client.lastExpireTTL != defaultClaudeWebSearchResultTTL {
+ t.Fatalf("KVExpire default ttl = %d/%v, want 2/%v", client.expireCount, client.lastExpireTTL, defaultClaudeWebSearchResultTTL)
+ }
+}
+
+func TestClaudeWebSearchResultCacheHomeRejectsEmptyScopeAndPropagatesErrors(t *testing.T) {
+ now := time.Unix(1_800_000_000, 0)
+ useClaudeWebSearchResultClock(t, &now)
+
+ client := newFakeClaudeWebSearchResultKVClient()
+ useFakeClaudeWebSearchResultKVClient(t, client, true, nil)
+
+ if CacheClaudeWebSearchResultBestEffort(context.Background(), "", "srvtoolu_1", []byte(`[1]`), time.Minute, 16) {
+ t.Fatal("empty session key should be rejected")
+ }
+ if CacheClaudeWebSearchResultBestEffort(context.Background(), "s", "", []byte(`[1]`), time.Minute, 16) {
+ t.Fatal("empty toolUseID should be rejected")
+ }
+ if CacheClaudeWebSearchResultBestEffort(context.Background(), "s", "srvtoolu_1", nil, time.Minute, 16) {
+ t.Fatal("empty content should be rejected")
+ }
+ if _, ok, errGet := GetClaudeWebSearchResultRequired(context.Background(), "", "srvtoolu_1"); errGet != nil || ok {
+ t.Fatalf("empty session key Get = %v, %v; want false, nil", ok, errGet)
+ }
+ if client.getCount != 0 || client.setCount != 0 || client.expireCount != 0 {
+ t.Fatalf("KV calls = get %d set %d expire %d, want all 0", client.getCount, client.setCount, client.expireCount)
+ }
+
+ client.setErr = errors.New("set failed")
+ if CacheClaudeWebSearchResultBestEffort(context.Background(), "s", "srvtoolu_1", []byte(`[1]`), 0, 16) {
+ t.Fatal("CacheClaudeWebSearchResultBestEffort() = true on setErr, want false")
+ }
+ if client.lastSetTTL != defaultClaudeWebSearchResultTTL {
+ t.Fatalf("zero ttl defaulted to %v, want %v", client.lastSetTTL, defaultClaudeWebSearchResultTTL)
+ }
+
+ client.getErr = errors.New("get failed")
+ if _, ok, errGet := GetClaudeWebSearchResultRequired(context.Background(), "s", "srvtoolu_1"); errGet == nil || ok {
+ t.Fatalf("GetClaudeWebSearchResultRequired() on getErr = %v, %v; want false, error", ok, errGet)
+ }
+ if _, ok := GetClaudeWebSearchResult("s", "srvtoolu_1"); ok {
+ t.Fatal("GetClaudeWebSearchResult() on getErr = true, want false")
+ }
+}
diff --git a/internal/cache/codex_reasoning_replay_cache.go b/internal/cache/codex_reasoning_replay_cache.go
index 7c63b96d30a..d899218821b 100644
--- a/internal/cache/codex_reasoning_replay_cache.go
+++ b/internal/cache/codex_reasoning_replay_cache.go
@@ -405,7 +405,7 @@ func normalizeCodexReasoningReplayReasoningItem(itemResult gjson.Result) ([]byte
if encryptedContent != strings.TrimSpace(encryptedContent) {
return nil, false
}
- if _, err := signature.InspectGPTReasoningSignature(encryptedContent); err != nil {
+ if !signature.IsValidGPTReasoningSignature(encryptedContent) && !signature.IsValidMetaReasoningSignature(encryptedContent) {
return nil, false
}
diff --git a/internal/cache/signature_cache.go b/internal/cache/signature_cache.go
index 2a6c0a48472..da7186c0a39 100644
--- a/internal/cache/signature_cache.go
+++ b/internal/cache/signature_cache.go
@@ -113,6 +113,7 @@ func purgeExpiredCaches() {
purgeExpiredAntigravityReasoningReplayCache(now)
purgeExpiredKimiThinkingReplayCache(now)
purgeExpiredClaudeThinkingReplayCache(now)
+ purgeExpiredClaudeWebSearchResultCache(now)
}
// CacheSignature stores a thinking signature for a given model group and text.
diff --git a/internal/client/codex/models/models.go b/internal/client/codex/models/models.go
index 595092a9cce..d15faca40e1 100644
--- a/internal/client/codex/models/models.go
+++ b/internal/client/codex/models/models.go
@@ -570,6 +570,9 @@ func applyCodexClientProviderCapabilities(entry map[string]any, id string, provi
// Codex requires these keys to be present; omitting them rejects the entire catalog.
func nullCodexClientRequiredOptions(entry map[string]any) {
entry["apply_patch_tool_type"] = nil
+ entry["tool_mode"] = "direct"
+ entry["use_responses_lite"] = false
+ entry["supports_reasoning_effort_updates"] = false
entry["upgrade"] = nil
entry["availability_nux"] = nil
}
diff --git a/internal/client/codex/models/models_test.go b/internal/client/codex/models/models_test.go
index f28cf7078e7..d3b40311818 100644
--- a/internal/client/codex/models/models_test.go
+++ b/internal/client/codex/models/models_test.go
@@ -1002,6 +1002,15 @@ func TestCodexClientModelsResponse_UnrelatedProviderCannotWidenSearchTool(t *tes
if tiers, okTiers := entry["service_tiers"].([]any); !okTiers || len(tiers) != 0 {
t.Errorf("service_tiers = %#v, want empty array for non-Codex provider", entry["service_tiers"])
}
+ if got := stringModelValue(entry, "tool_mode"); got != "direct" {
+ t.Errorf("tool_mode = %#v, want direct for non-Codex provider", entry["tool_mode"])
+ }
+ if got, ok := entry["use_responses_lite"].(bool); !ok || got {
+ t.Errorf("use_responses_lite = %#v, want false for non-Codex provider", entry["use_responses_lite"])
+ }
+ if got, ok := entry["supports_reasoning_effort_updates"].(bool); !ok || got {
+ t.Errorf("supports_reasoning_effort_updates = %#v, want false for non-Codex provider", entry["supports_reasoning_effort_updates"])
+ }
assertCodexNullableFieldCleared(t, entry, "upgrade")
assertCodexNullableFieldCleared(t, entry, "availability_nux")
// Reasoning levels must NOT be widened beyond provider's registered capabilities
@@ -1098,6 +1107,15 @@ func TestCodexClientModelsResponse_MixedProvidersRestrictProtocolCapabilities(t
if tiers, okTiers := entry["service_tiers"].([]any); !okTiers || len(tiers) != 0 {
t.Errorf("service_tiers = %#v, want empty array for mixed provider", entry["service_tiers"])
}
+ if got := stringModelValue(entry, "tool_mode"); got != "direct" {
+ t.Errorf("tool_mode = %#v, want direct for mixed provider", entry["tool_mode"])
+ }
+ if got, ok := entry["use_responses_lite"].(bool); !ok || got {
+ t.Errorf("use_responses_lite = %#v, want false for mixed provider", entry["use_responses_lite"])
+ }
+ if got, ok := entry["supports_reasoning_effort_updates"].(bool); !ok || got {
+ t.Errorf("supports_reasoning_effort_updates = %#v, want false for mixed provider", entry["supports_reasoning_effort_updates"])
+ }
assertCodexNullableFieldCleared(t, entry, "upgrade")
assertCodexNullableFieldCleared(t, entry, "availability_nux")
// Reasoning levels must NOT be widened to ultra/max/xhigh regardless of registration order
@@ -1733,3 +1751,106 @@ func assertCodexNullableFieldCleared(t *testing.T, entry map[string]any, key str
t.Errorf("%s = %#v, want null", key, value)
}
}
+
+func TestCodexClientModelsResponse_DoesNotLeakResponsesLiteOrCodeModeToNonCodexRoutes_Issue6769(t *testing.T) {
+ _, revision := registry.GetCodexClientModelsSnapshot()
+ codexClientModelTemplatesMu.Lock()
+ previousLoaded := codexClientModelTemplatesLoaded
+ previousRevision := codexClientModelTemplatesRevision
+ previousTemplates := codexClientModelTemplates
+ previousDefault := codexClientDefaultTemplate
+ previousErr := codexClientModelTemplatesErr
+ codexClientModelTemplatesLoaded = false
+ codexClientModelTemplatesMu.Unlock()
+ t.Cleanup(func() {
+ codexClientModelTemplatesMu.Lock()
+ codexClientModelTemplatesLoaded = previousLoaded
+ codexClientModelTemplatesRevision = previousRevision
+ codexClientModelTemplates = previousTemplates
+ codexClientDefaultTemplate = previousDefault
+ codexClientModelTemplatesErr = previousErr
+ codexClientModelTemplatesMu.Unlock()
+ })
+
+ // Simulate a post-retirement catalog where the first/default template (gpt-6.1-sol)
+ // advertises code_mode_only, use_responses_lite, and supports_reasoning_effort_updates.
+ snapshot := []byte(`{"models":[{
+ "slug":"gpt-6.1-sol",
+ "display_name":"GPT-6.1-Sol",
+ "description":"Latest workhorse model.",
+ "base_instructions":"Full instructions",
+ "minimal_client_version":"0.153.0",
+ "visibility":"list",
+ "context_window":500000,
+ "max_context_window":872000,
+ "priority":0,
+ "default_reasoning_level":"low",
+ "supported_reasoning_levels":[{"effort":"low","description":"Fast"}],
+ "prefer_websockets":true,
+ "apply_patch_tool_type":"freeform",
+ "tool_mode":"code_mode_only",
+ "use_responses_lite":true,
+ "supports_reasoning_effort_updates":true,
+ "supports_search_tool":true,
+ "service_tiers":[{"id":"priority"}],
+ "upgrade":null,
+ "availability_nux":{"message":"nux"}
+ }]}`)
+ if _, _, errLoad := loadCodexClientModelTemplatesSnapshot(snapshot, revision); errLoad != nil {
+ t.Fatalf("loadCodexClientModelTemplatesSnapshot: %v", errLoad)
+ }
+
+ available := []map[string]any{
+ {"id": "gpt-6.1-sol"},
+ {"id": "kimi-k3"},
+ {"id": "muse-wonder-1.0"},
+ }
+ providers := map[string][]string{
+ "gpt-6.1-sol": {"codex"},
+ "kimi-k3": {"kimi"},
+ "muse-wonder-1.0": {"meta"},
+ }
+
+ resp := BuildResponseForClient(available, func(id string) []string {
+ return providers[id]
+ }, false, "0.163.0-alpha.10.2")
+ models, ok := resp["models"].([]map[string]any)
+ if !ok || len(models) != 3 {
+ t.Fatalf("models = %#v, want 3 entries", resp["models"])
+ }
+
+ bySlug := make(map[string]map[string]any, len(models))
+ for _, entry := range models {
+ bySlug[stringModelValue(entry, "slug")] = entry
+ }
+
+ native := bySlug["gpt-6.1-sol"]
+ if native == nil {
+ t.Fatal("missing gpt-6.1-sol entry")
+ }
+ if got := stringModelValue(native, "tool_mode"); got != "code_mode_only" {
+ t.Errorf("gpt-6.1-sol tool_mode = %#v, want code_mode_only", native["tool_mode"])
+ }
+ if got, ok := native["use_responses_lite"].(bool); !ok || !got {
+ t.Errorf("gpt-6.1-sol use_responses_lite = %#v, want true", native["use_responses_lite"])
+ }
+ if got, ok := native["supports_reasoning_effort_updates"].(bool); !ok || !got {
+ t.Errorf("gpt-6.1-sol supports_reasoning_effort_updates = %#v, want true", native["supports_reasoning_effort_updates"])
+ }
+
+ for _, slug := range []string{"kimi-k3", "muse-wonder-1.0"} {
+ entry := bySlug[slug]
+ if entry == nil {
+ t.Fatalf("missing %s entry", slug)
+ }
+ if got := stringModelValue(entry, "tool_mode"); got != "direct" {
+ t.Errorf("%s tool_mode = %#v, want direct", slug, entry["tool_mode"])
+ }
+ if got, ok := entry["use_responses_lite"].(bool); !ok || got {
+ t.Errorf("%s use_responses_lite = %#v, want false", slug, entry["use_responses_lite"])
+ }
+ if got, ok := entry["supports_reasoning_effort_updates"].(bool); !ok || got {
+ t.Errorf("%s supports_reasoning_effort_updates = %#v, want false", slug, entry["supports_reasoning_effort_updates"])
+ }
+ }
+}
diff --git a/internal/config/claude_code_test.go b/internal/config/claude_code_test.go
index bb88187ba3d..ff40947028f 100644
--- a/internal/config/claude_code_test.go
+++ b/internal/config/claude_code_test.go
@@ -1,6 +1,11 @@
package config
-import "testing"
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
func TestParseConfigBytesClaudeCodeModelListCloaking(t *testing.T) {
tests := []struct {
@@ -64,6 +69,95 @@ func TestParseConfigBytesClaudeModelLevelCooling(t *testing.T) {
}
}
+func TestParseConfigBytesClaudeSessionDatePinDays(t *testing.T) {
+ tests := []struct {
+ name string
+ yaml string
+ want int
+ }{
+ {name: "defaults to 7 days", yaml: "port: 8317\n", want: 7},
+ {name: "legacy layout", yaml: "claude:\n session-date-pin-days: 14\n", want: 14},
+ {name: "v8 layout", yaml: "config-version: 8\nupstream:\n claude:\n session-date-pin-days: 7\n", want: 7},
+ {name: "v8 explicit zero", yaml: "config-version: 8\nupstream:\n claude:\n session-date-pin-days: 0\n", want: 0},
+ {name: "legacy explicit zero", yaml: "claude:\n session-date-pin-days: 0\n", want: 0},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg, errParse := ParseConfigBytes([]byte(tt.yaml))
+ if errParse != nil {
+ t.Fatalf("ParseConfigBytes() error = %v", errParse)
+ }
+ if got := cfg.Claude.SessionDatePinDays; got == nil || *got != tt.want {
+ t.Fatalf("cfg.Claude.SessionDatePinDays = %v, want %d", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestSaveConfigPreserveComments_SessionDatePinDaysExplicitZeroWhenAbsent(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.yaml")
+ if errWrite := os.WriteFile(configPath, []byte("port: 8317\n"), 0o600); errWrite != nil {
+ t.Fatalf("os.WriteFile() error = %v", errWrite)
+ }
+
+ zero := 0
+ cfg := &Config{Claude: ClaudeConfig{SessionDatePinDays: &zero}}
+ if errSave := SaveConfigPreserveComments(configPath, cfg); errSave != nil {
+ t.Fatalf("SaveConfigPreserveComments() error = %v", errSave)
+ }
+
+ savedBytes, errRead := os.ReadFile(configPath)
+ if errRead != nil {
+ t.Fatalf("os.ReadFile() error = %v", errRead)
+ }
+ if !strings.Contains(string(savedBytes), "session-date-pin-days: 0") {
+ t.Fatalf("saved YAML missing explicit 'session-date-pin-days: 0':\n%s", savedBytes)
+ }
+ loaded, errLoad := LoadConfigOptional(configPath, false)
+ if errLoad != nil {
+ t.Fatalf("LoadConfigOptional() error = %v", errLoad)
+ }
+ if loaded.Claude.SessionDatePinDays == nil || *loaded.Claude.SessionDatePinDays != 0 {
+ t.Fatalf("loaded SessionDatePinDays = %v, want explicit 0", loaded.Claude.SessionDatePinDays)
+ }
+}
+
+func TestSaveConfigPreserveComments_SessionDatePinDaysDefaultNotPersisted(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.yaml")
+ if errWrite := os.WriteFile(configPath, []byte("port: 8317\n"), 0o600); errWrite != nil {
+ t.Fatalf("os.WriteFile() error = %v", errWrite)
+ }
+
+ cfg, errLoad := LoadConfigOptional(configPath, false)
+ if errLoad != nil {
+ t.Fatalf("LoadConfigOptional() error = %v", errLoad)
+ }
+ if errSave := SaveConfigPreserveComments(configPath, cfg); errSave != nil {
+ t.Fatalf("SaveConfigPreserveComments() error = %v", errSave)
+ }
+
+ savedBytes, errRead := os.ReadFile(configPath)
+ if errRead != nil {
+ t.Fatalf("os.ReadFile() error = %v", errRead)
+ }
+ if strings.Contains(string(savedBytes), "session-date-pin-days") {
+ t.Fatalf("saved YAML must not contain the default session-date-pin-days:\n%s", savedBytes)
+ }
+}
+
+func TestClaudeSessionDatePinRetentionClampsOverflow(t *testing.T) {
+ days := 1 << 40
+ cfg := ClaudeConfig{SessionDatePinDays: &days}
+ if got := cfg.SessionDatePinRetention(); got <= 0 {
+ t.Fatalf("SessionDatePinRetention() = %v, want positive clamped duration", got)
+ }
+ zero := 0
+ if got := (ClaudeConfig{SessionDatePinDays: &zero}).SessionDatePinRetention(); got != 0 {
+ t.Fatalf("explicit-zero retention = %v, want 0", got)
+ }
+}
+
func TestParseConfigBytesClaudeToolSearch(t *testing.T) {
tests := []struct {
name string
diff --git a/internal/config/claude_header_defaults_test.go b/internal/config/claude_header_defaults_test.go
index a161a650e65..3ec0245e173 100644
--- a/internal/config/claude_header_defaults_test.go
+++ b/internal/config/claude_header_defaults_test.go
@@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
+ "reflect"
"testing"
)
@@ -57,3 +58,38 @@ claude-header-defaults:
t.Fatalf("StabilizeDeviceProfile = %v, want false", got)
}
}
+
+func TestParseConfigBytes_ClaudeHeaderDefaultsOperatorOverrides(t *testing.T) {
+ cfg, err := ParseConfigBytes([]byte(`upstream:
+ claude:
+ header-defaults:
+ anthropic-beta-strip: [" cache-diagnosis-2026-04-07 ", "", "cache-diagnosis-2026-04-07"]
+ anthropic-beta-append: ["context-1m-2025-08-07", " context-1m-2025-08-07 ", "advisor-tool-2026-03-01"]
+ inject-diagnostics: false
+ client-request-id: " PassThrough "
+`))
+ if err != nil {
+ t.Fatalf("ParseConfigBytes() error = %v", err)
+ }
+ hd := cfg.ClaudeHeaderDefaults
+ if want := []string{"cache-diagnosis-2026-04-07"}; !reflect.DeepEqual(hd.AnthropicBetaStrip, want) {
+ t.Fatalf("AnthropicBetaStrip = %q, want %q", hd.AnthropicBetaStrip, want)
+ }
+ if want := []string{"context-1m-2025-08-07", "advisor-tool-2026-03-01"}; !reflect.DeepEqual(hd.AnthropicBetaAppend, want) {
+ t.Fatalf("AnthropicBetaAppend = %q, want %q", hd.AnthropicBetaAppend, want)
+ }
+ if hd.InjectDiagnostics == nil || *hd.InjectDiagnostics {
+ t.Fatalf("InjectDiagnostics = %v, want explicit false", hd.InjectDiagnostics)
+ }
+ if hd.ClientRequestID != ClaudeClientRequestIDPassthrough {
+ t.Fatalf("ClientRequestID = %q, want %q", hd.ClientRequestID, ClaudeClientRequestIDPassthrough)
+ }
+
+ cfg, err = ParseConfigBytes([]byte("claude-header-defaults: {client-request-id: sometimes}\n"))
+ if err != nil {
+ t.Fatalf("ParseConfigBytes() error = %v", err)
+ }
+ if got := cfg.ClaudeHeaderDefaults.ClientRequestID; got != "" {
+ t.Fatalf("ClientRequestID = %q, want unknown mode reset to default", got)
+ }
+}
diff --git a/internal/config/client_test.go b/internal/config/client_test.go
index c4d6d31f2f7..9dcf08e876d 100644
--- a/internal/config/client_test.go
+++ b/internal/config/client_test.go
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"testing"
+ "time"
"gopkg.in/yaml.v3"
)
@@ -103,3 +104,159 @@ func TestClientCodexEnableApplyPatchSave(t *testing.T) {
}
}
}
+
+func TestClientCodexWebSearchResultCache(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ raw string
+ want CodexWebSearchResultCacheConfig
+ wantTTL time.Duration
+ wantLimit int
+ }{
+ {
+ name: "omitted",
+ raw: "config-version: 8\n",
+ want: CodexWebSearchResultCacheConfig{},
+ wantTTL: DefaultCodexWebSearchResultCacheTTL,
+ wantLimit: DefaultCodexWebSearchResultCacheLimit,
+ },
+ {
+ name: "enabled with integer seconds ttl",
+ raw: "client: {codex: {web-search-result-cache: {enabled: true, ttl: 1800, limit: 64}}}\n",
+ want: CodexWebSearchResultCacheConfig{Enabled: true, TTL: "1800", Limit: 64},
+ wantTTL: 30 * time.Minute,
+ wantLimit: 64,
+ },
+ {
+ name: "enabled with 30m ttl",
+ raw: "client: {codex: {web-search-result-cache: {enabled: true, ttl: 30m, limit: 128}}}\n",
+ want: CodexWebSearchResultCacheConfig{Enabled: true, TTL: "30m", Limit: 128},
+ wantTTL: 30 * time.Minute,
+ wantLimit: 128,
+ },
+ {
+ name: "disabled with 24h ttl",
+ raw: "client: {codex: {web-search-result-cache: {enabled: false, ttl: 24h, limit: 1024}}}\n",
+ want: CodexWebSearchResultCacheConfig{Enabled: false, TTL: "24h", Limit: 1024},
+ wantTTL: 24 * time.Hour,
+ wantLimit: 1024,
+ },
+ {
+ name: "null fields fall back to defaults",
+ raw: "client: {codex: {web-search-result-cache: {enabled: null, ttl: null, limit: null}}}\n",
+ want: CodexWebSearchResultCacheConfig{},
+ wantTTL: DefaultCodexWebSearchResultCacheTTL,
+ wantLimit: DefaultCodexWebSearchResultCacheLimit,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if errValidate := ValidateV8Config([]byte(tc.raw)); errValidate != nil {
+ t.Fatalf("validate: %v", errValidate)
+ }
+ cfg, errParse := ParseConfigBytes([]byte(tc.raw))
+ if errParse != nil {
+ t.Fatalf("parse: %v", errParse)
+ }
+ got := cfg.Client.Codex.WebSearchResultCache
+ if got != tc.want {
+ t.Fatalf("WebSearchResultCache = %+v, want %+v", got, tc.want)
+ }
+ if got.EffectiveTTL() != tc.wantTTL {
+ t.Fatalf("EffectiveTTL() = %v, want %v", got.EffectiveTTL(), tc.wantTTL)
+ }
+ if got.EffectiveLimit() != tc.wantLimit {
+ t.Fatalf("EffectiveLimit() = %d, want %d", got.EffectiveLimit(), tc.wantLimit)
+ }
+ var sdkCfg SDKConfig
+ if errUnmarshal := yaml.Unmarshal([]byte(tc.raw), &sdkCfg); errUnmarshal != nil {
+ t.Fatalf("decode SDK config: %v", errUnmarshal)
+ }
+ if sdkCfg.Client.Codex.WebSearchResultCache != tc.want {
+ t.Fatalf("SDK WebSearchResultCache = %+v, want %+v", sdkCfg.Client.Codex.WebSearchResultCache, tc.want)
+ }
+ data, errMarshal := json.Marshal(cfg)
+ if errMarshal != nil {
+ t.Fatalf("encode JSON: %v", errMarshal)
+ }
+ var decoded Config
+ if errUnmarshal := json.Unmarshal(data, &decoded); errUnmarshal != nil {
+ t.Fatalf("decode JSON: %v", errUnmarshal)
+ }
+ if decoded.Client.Codex.WebSearchResultCache != tc.want {
+ t.Fatalf("JSON round trip WebSearchResultCache = %+v, want %+v", decoded.Client.Codex.WebSearchResultCache, tc.want)
+ }
+ migrated, _, errNormalize := NormalizeConfigLayout([]byte(tc.raw), true)
+ if errNormalize != nil {
+ t.Fatalf("normalize: %v", errNormalize)
+ }
+ if errValidate := ValidateV8Config(migrated); errValidate != nil {
+ t.Fatalf("validate normalized config: %v", errValidate)
+ }
+ if errUnmarshal := yaml.Unmarshal(migrated, &decoded); errUnmarshal != nil {
+ t.Fatalf("decode normalized config: %v", errUnmarshal)
+ }
+ if decoded.Client.Codex.WebSearchResultCache != tc.want {
+ t.Fatalf("normalized WebSearchResultCache = %+v, want %+v", decoded.Client.Codex.WebSearchResultCache, tc.want)
+ }
+ })
+ }
+
+ var fromJSONNumber Config
+ if errUnmarshal := json.Unmarshal([]byte(`{"client":{"codex":{"web-search-result-cache":{"enabled":true,"ttl":1800,"limit":256}}}}`), &fromJSONNumber); errUnmarshal != nil {
+ t.Fatalf("unmarshal JSON number ttl: %v", errUnmarshal)
+ }
+ if fromJSONNumber.Client.Codex.WebSearchResultCache.EffectiveTTL() != 30*time.Minute || fromJSONNumber.Client.Codex.WebSearchResultCache.EffectiveLimit() != 256 {
+ t.Fatalf("JSON number ttl config = %+v", fromJSONNumber.Client.Codex.WebSearchResultCache)
+ }
+
+ for _, raw := range []string{
+ "client: {codex: {web-search-result-cache: false}}\n",
+ "client: {codex: {web-search-result-cache: {unknown: true}}}\n",
+ "client: {codex: {web-search-result-cache: {enabled: invalid}}}\n",
+ "client: {codex: {web-search-result-cache: {ttl: invalid}}}\n",
+ "client: {codex: {web-search-result-cache: {ttl: -30m}}}\n",
+ "client: {codex: {web-search-result-cache: {ttl: -10}}}\n",
+ "client: {codex: {web-search-result-cache: {ttl: true}}}\n",
+ "client: {codex: {web-search-result-cache: {limit: -1}}}\n",
+ "client: {codex: {web-search-result-cache: {limit: invalid}}}\n",
+ } {
+ if errValidate := ValidateV8Config([]byte(raw)); errValidate == nil {
+ t.Fatalf("accepted invalid config %q", raw)
+ }
+ if _, errParse := ParseConfigBytes([]byte(raw)); errParse == nil {
+ t.Fatalf("ParseConfigBytes accepted invalid config %q", raw)
+ }
+ }
+}
+
+func TestClientCodexWebSearchResultCacheSave(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "config.yaml")
+ if errWrite := os.WriteFile(path, []byte("config-version: 8\n"), 0600); errWrite != nil {
+ t.Fatalf("write: %v", errWrite)
+ }
+ cfg := &Config{}
+ for _, want := range []CodexWebSearchResultCacheConfig{
+ {Enabled: true, TTL: "30m", Limit: 128},
+ {Enabled: false, TTL: "24h", Limit: 1024},
+ {Enabled: false, TTL: "", Limit: 0},
+ } {
+ cfg.Client.Codex.WebSearchResultCache = want
+ if errSave := SaveConfigPreserveComments(path, cfg, true); errSave != nil {
+ t.Fatalf("save: %v", errSave)
+ }
+ data, errRead := os.ReadFile(path)
+ if errRead != nil {
+ t.Fatalf("read: %v", errRead)
+ }
+ if errValidate := ValidateV8Config(data); errValidate != nil {
+ t.Fatalf("validate saved config: %v", errValidate)
+ }
+ loaded, errLoad := LoadConfig(path)
+ if errLoad != nil {
+ t.Fatalf("load: %v", errLoad)
+ }
+ if loaded.Client.Codex.WebSearchResultCache != want {
+ t.Fatalf("saved WebSearchResultCache = %+v, want %+v", loaded.Client.Codex.WebSearchResultCache, want)
+ }
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index b7f9a6ef724..cbe344933f3 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -29,6 +29,10 @@ type Config struct {
// Home config is runtime-only and is populated from -home-jwt.
Home HomeConfig `yaml:"-" json:"-"`
+ // SourceHash is runtime-only: the SHA-256 of the config file bytes this config was
+ // parsed from, or empty when the config was not loaded from a file.
+ SourceHash string `yaml:"-" json:"-"`
+
// CredentialConcurrency contains Home-authoritative credential lifecycle settings.
CredentialConcurrency CredentialConcurrencyConfig `yaml:"credential-concurrency" json:"credential-concurrency"`
diff --git a/internal/config/config_load.go b/internal/config/config_load.go
index 20780461ffc..baca01e92e6 100644
--- a/internal/config/config_load.go
+++ b/internal/config/config_load.go
@@ -2,6 +2,8 @@ package config
import (
"bytes"
+ "crypto/sha256"
+ "encoding/hex"
"errors"
"fmt"
"os"
@@ -26,6 +28,129 @@ func LoadConfig(configFile string) (*Config, error) {
return LoadConfigOptional(configFile, false)
}
+// ComputeSourceHash returns the Config.SourceHash value for config file bytes.
+func ComputeSourceHash(data []byte) string {
+ sum := sha256.Sum256(data)
+ return hex.EncodeToString(sum[:])
+}
+
+// PrepareConfigFileForUpload applies the file rewrites of a startup load: a plaintext
+// management secret is replaced with its bcrypt hash and conflicting legacy fields are
+// removed. Stores that seed remote storage from configFile before the startup load call
+// it first, so the plaintext secret never leaves the machine and the local and remote
+// copies stay identical. When the plaintext cannot be removed it returns an error and
+// leaves configFile untouched, so every later attempt refuses the file again.
+func PrepareConfigFileForUpload(configFile string) error {
+ data, errRead := os.ReadFile(configFile)
+ if errRead != nil {
+ return errRead
+ }
+ prepared, errPrepare := prepareConfigForUpload(data)
+ if errPrepare != nil {
+ return errPrepare
+ }
+ if bytes.Equal(prepared, data) {
+ return nil
+ }
+ if errWrite := os.WriteFile(configFile, prepared, 0600); errWrite != nil {
+ return fmt.Errorf("failed to write prepared config file: %w", errWrite)
+ }
+ return nil
+}
+
+// prepareConfigForUpload returns data with the file rewrites of a startup load applied.
+// It fails when the result would still hold a plaintext management secret: hashing
+// replaces only the effective key and the layout cleanup only drops an overridden legacy
+// key, so a YAML anchor or a copy elsewhere keeps the plaintext, and rewriting those could
+// change other settings that share them.
+func prepareConfigForUpload(data []byte) ([]byte, error) {
+ var cfg Config
+ if errParse := yaml.Unmarshal(data, &cfg); errParse != nil {
+ return nil, fmt.Errorf("failed to parse config file: %w", errParse)
+ }
+ prepared := data
+ if secret := cfg.RemoteManagement.SecretKey; secret != "" && !looksLikeBcrypt(secret) {
+ hashed, errHash := hashSecret(secret)
+ if errHash != nil {
+ return nil, fmt.Errorf("failed to hash remote management key: %w", errHash)
+ }
+ updated, errUpdate := updateNestedScalarYAML(data, managementSecretKeyPath(data), hashed)
+ if errUpdate != nil {
+ return nil, fmt.Errorf("failed to update remote management key: %w", errUpdate)
+ }
+ prepared = updated
+ }
+ cleaned, _, errLayout := NormalizeConfigLayout(prepared, false)
+ if errLayout != nil {
+ return nil, errLayout
+ }
+ if plaintexts := plaintextManagementSecrets(data, cfg.RemoteManagement.SecretKey); len(plaintexts) > 0 {
+ var doc yaml.Node
+ if errParse := yaml.Unmarshal(cleaned, &doc); errParse != nil {
+ return nil, fmt.Errorf("failed to parse prepared config: %w", errParse)
+ }
+ for _, plaintext := range plaintexts {
+ if yamlScalarExists(&doc, plaintext) {
+ return nil, errors.New("plaintext remote management key is still present elsewhere in the config file, for example through a YAML anchor or as another setting's value")
+ }
+ }
+ }
+ return cleaned, nil
+}
+
+// plaintextManagementSecrets returns the plaintext management secrets of data: the
+// effective one and the values of the legacy and v8 secret keys, since an overridden
+// legacy key still holds its own. YAML anchors, aliases and merge keys are resolved, so
+// data must already decode as a Config, which rejects cyclic aliases.
+func plaintextManagementSecrets(data []byte, effective string) []string {
+ candidates := []string{effective}
+ var doc yaml.Node
+ if yaml.Unmarshal(data, &doc) == nil && len(doc.Content) > 0 {
+ root := expandConfigAliases(doc.Content[0])
+ for _, path := range []string{"remote-management.secret-key", "management.secret-key"} {
+ if node := yamlPath(root, path); node != nil && node.Kind == yaml.ScalarNode && node.ShortTag() != "!!null" {
+ candidates = append(candidates, node.Value)
+ }
+ }
+ }
+ var plaintexts []string
+ for _, candidate := range candidates {
+ if candidate != "" && !looksLikeBcrypt(candidate) {
+ plaintexts = append(plaintexts, candidate)
+ }
+ }
+ return plaintexts
+}
+
+// yamlScalarExists reports whether node or any value below it is a non-null scalar equal
+// to value. Mapping keys only name settings and are skipped, as are alias nodes, whose
+// anchors are visited where they are defined.
+func yamlScalarExists(node *yaml.Node, value string) bool {
+ if node.Kind == yaml.ScalarNode {
+ return node.ShortTag() != "!!null" && node.Value == value
+ }
+ first, step := 0, 1
+ if node.Kind == yaml.MappingNode {
+ first, step = 1, 2
+ }
+ for i := first; i < len(node.Content); i += step {
+ if yamlScalarExists(node.Content[i], value) {
+ return true
+ }
+ }
+ return false
+}
+
+// managementSecretKeyPath returns the management secret key path used by data: the v8
+// path when the document defines it, the legacy path otherwise.
+func managementSecretKeyPath(data []byte) []string {
+ var source yaml.Node
+ if yaml.Unmarshal(data, &source) == nil && len(source.Content) > 0 && yamlPath(expandConfigAliases(source.Content[0]), "management.secret-key") != nil {
+ return []string{"management", "secret-key"}
+ }
+ return []string{"remote-management", "secret-key"}
+}
+
// LoadConfigOptional reads YAML from configFile.
// If optional is true and the file is missing, it returns an empty Config.
// If optional is true and the file is empty or invalid, it returns an empty Config.
@@ -38,6 +163,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
// Missing and optional: return empty config (cloud deploy standby).
cfg := &Config{CredentialInFlight: DefaultCredentialInFlightConfig()}
cfg.NormalizePluginsConfig()
+ cfg.NormalizeClaudeConfig()
return cfg, nil
}
}
@@ -48,6 +174,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
if optional && len(bytes.TrimSpace(data)) == 0 {
cfg := &Config{CredentialInFlight: DefaultCredentialInFlightConfig()}
cfg.NormalizePluginsConfig()
+ cfg.NormalizeClaudeConfig()
return cfg, nil
}
@@ -55,6 +182,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
if optional {
cfgOptional := &Config{CredentialInFlight: DefaultCredentialInFlightConfig()}
cfgOptional.NormalizePluginsConfig()
+ cfgOptional.NormalizeClaudeConfig()
return cfgOptional, nil
}
return nil, errValidate
@@ -86,6 +214,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
// In cloud deploy mode, if YAML parsing fails, return empty config instead of error.
cfgOptional := &Config{CredentialInFlight: DefaultCredentialInFlightConfig()}
cfgOptional.NormalizePluginsConfig()
+ cfgOptional.NormalizeClaudeConfig()
return cfgOptional, nil
}
return nil, fmt.Errorf("failed to parse config file: %w", err)
@@ -122,12 +251,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
// Persist the hashed value back to the config file to avoid re-hashing on next startup.
// Preserve YAML comments and ordering; update only the nested key.
- secretPath := []string{"remote-management", "secret-key"}
- var source yaml.Node
- if yaml.Unmarshal(data, &source) == nil && len(source.Content) > 0 && yamlPath(expandConfigAliases(source.Content[0]), "management.secret-key") != nil {
- secretPath[0] = "management"
- }
- _ = SaveConfigPreserveCommentsUpdateNestedScalar(configFile, secretPath, hashed)
+ _ = SaveConfigPreserveCommentsUpdateNestedScalar(configFile, managementSecretKeyPath(data), hashed)
}
cfg.RemoteManagement.PanelGitHubRepository = strings.TrimSpace(cfg.RemoteManagement.PanelGitHubRepository)
@@ -160,6 +284,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
}
cfg.NormalizePluginsConfig()
+ cfg.NormalizeClaudeConfig()
if errResolvePluginsDir := cfg.ResolvePluginsDir(); errResolvePluginsDir != nil && cfg.Plugins.Enabled {
return nil, errResolvePluginsDir
}
@@ -225,6 +350,10 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
}
}
+ // Record the exact bytes this config was parsed from, so a consumer can tell
+ // it apart from a newer version of the file.
+ cfg.SourceHash = ComputeSourceHash(data)
+
// Return the populated configuration struct.
return &cfg, nil
}
diff --git a/internal/config/config_load_test.go b/internal/config/config_load_test.go
new file mode 100644
index 00000000000..dc48e0a9790
--- /dev/null
+++ b/internal/config/config_load_test.go
@@ -0,0 +1,282 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "golang.org/x/crypto/bcrypt"
+ "gopkg.in/yaml.v3"
+)
+
+const uploadTestPlaintextSecret = "upload-plaintext-secret"
+
+func writeUploadTestConfig(t *testing.T, content string) string {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "config.yaml")
+ if errWrite := os.WriteFile(path, []byte(content), 0o600); errWrite != nil {
+ t.Fatalf("write config: %v", errWrite)
+ }
+ return path
+}
+
+func readUploadTestConfig(t *testing.T, path string) string {
+ t.Helper()
+ data, errRead := os.ReadFile(path)
+ if errRead != nil {
+ t.Fatalf("read config: %v", errRead)
+ }
+ return string(data)
+}
+
+func uploadTestBcrypt(t *testing.T) string {
+ t.Helper()
+ hashed, errHash := bcrypt.GenerateFromPassword([]byte(uploadTestPlaintextSecret), bcrypt.MinCost)
+ if errHash != nil {
+ t.Fatalf("bcrypt.GenerateFromPassword() error = %v", errHash)
+ }
+ return string(hashed)
+}
+
+// uploadTestSecret returns the secret-key stored under section and whether it exists.
+func uploadTestSecret(t *testing.T, data, section string) (string, bool) {
+ t.Helper()
+ var doc map[string]any
+ if errParse := yaml.Unmarshal([]byte(data), &doc); errParse != nil {
+ t.Fatalf("parse config: %v", errParse)
+ }
+ management, _ := doc[section].(map[string]any)
+ secret, found := management["secret-key"].(string)
+ return secret, found
+}
+
+func assertUploadTestHashed(t *testing.T, data, section string) {
+ t.Helper()
+ secret, _ := uploadTestSecret(t, data, section)
+ if errCompare := bcrypt.CompareHashAndPassword([]byte(secret), []byte(uploadTestPlaintextSecret)); errCompare != nil {
+ t.Fatalf("%s.secret-key %q is not the bcrypt hash of the plaintext secret:\n%s", section, secret, data)
+ }
+}
+
+func TestPrepareConfigFileForUploadHashesPlaintextSecret(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ section string
+ other string
+ }{
+ {name: "legacy layout", section: "remote-management", other: "management"},
+ {name: "v8 layout", section: "management", other: "remote-management"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ path := writeUploadTestConfig(t, "# operator note\nport: 8317\n"+tc.section+":\n secret-key: "+uploadTestPlaintextSecret+"\n")
+
+ if errPrepare := PrepareConfigFileForUpload(path); errPrepare != nil {
+ t.Fatalf("PrepareConfigFileForUpload() error = %v", errPrepare)
+ }
+
+ saved := readUploadTestConfig(t, path)
+ if strings.Contains(saved, uploadTestPlaintextSecret) {
+ t.Fatalf("config still holds the plaintext secret:\n%s", saved)
+ }
+ if !strings.Contains(saved, "# operator note") || !strings.Contains(saved, "port: 8317") {
+ t.Fatalf("hashing dropped unrelated content:\n%s", saved)
+ }
+ assertUploadTestHashed(t, saved, tc.section)
+ if _, found := uploadTestSecret(t, saved, tc.other); found {
+ t.Fatalf("hashing wrote a secret under %s:\n%s", tc.other, saved)
+ }
+ })
+ }
+}
+
+// Keys only name settings, so a secret spelled like a key must not block the upload.
+func TestPrepareConfigFileForUploadIgnoresKeysSpelledLikeSecret(t *testing.T) {
+ hashed := uploadTestBcrypt(t)
+ for _, tc := range []struct {
+ name string
+ content string
+ check func(t *testing.T, saved string)
+ }{
+ {
+ name: "v8 secret spelled like its section",
+ content: "management:\n secret-key: management\n",
+ check: func(t *testing.T, saved string) {
+ secret, _ := uploadTestSecret(t, saved, "management")
+ if errCompare := bcrypt.CompareHashAndPassword([]byte(secret), []byte("management")); errCompare != nil {
+ t.Fatalf("management.secret-key %q is not the bcrypt hash of the secret:\n%s", secret, saved)
+ }
+ },
+ },
+ {
+ name: "overridden legacy secret spelled like the v8 section",
+ content: "remote-management:\n secret-key: management\nmanagement:\n secret-key: '" + hashed + "'\n",
+ check: func(t *testing.T, saved string) {
+ if secret, _ := uploadTestSecret(t, saved, "management"); secret != hashed {
+ t.Fatalf("management.secret-key = %q, want the existing hash kept:\n%s", secret, saved)
+ }
+ if _, found := uploadTestSecret(t, saved, "remote-management"); found {
+ t.Fatalf("conflicting legacy secret kept:\n%s", saved)
+ }
+ },
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ path := writeUploadTestConfig(t, tc.content)
+
+ if errPrepare := PrepareConfigFileForUpload(path); errPrepare != nil {
+ t.Fatalf("PrepareConfigFileForUpload() error = %v", errPrepare)
+ }
+
+ tc.check(t, readUploadTestConfig(t, path))
+ })
+ }
+}
+
+// A YAML null elsewhere is not the string secret "null", so it must not block the upload.
+func TestPrepareConfigFileForUploadIgnoresYAMLNullBesideStringNullSecret(t *testing.T) {
+ path := writeUploadTestConfig(t, "proxy-url: null\nmanagement:\n secret-key: 'null'\n")
+
+ if errPrepare := PrepareConfigFileForUpload(path); errPrepare != nil {
+ t.Fatalf("PrepareConfigFileForUpload() error = %v", errPrepare)
+ }
+
+ saved := readUploadTestConfig(t, path)
+ secret, _ := uploadTestSecret(t, saved, "management")
+ if errCompare := bcrypt.CompareHashAndPassword([]byte(secret), []byte("null")); errCompare != nil {
+ t.Fatalf("management.secret-key %q is not the bcrypt hash of the string secret:\n%s", secret, saved)
+ }
+}
+
+// A partially migrated config can keep a legacy plaintext secret next to the v8 key.
+// The startup load drops that conflicting legacy field, so it must not be uploaded.
+func TestPrepareConfigFileForUploadDropsConflictingLegacySecret(t *testing.T) {
+ hashed := uploadTestBcrypt(t)
+ for _, tc := range []struct {
+ name string
+ v8Secret string
+ }{
+ {name: "duplicated plaintext", v8Secret: uploadTestPlaintextSecret},
+ {name: "stale legacy beside hashed v8", v8Secret: "'" + hashed + "'"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ path := writeUploadTestConfig(t, "remote-management:\n secret-key: "+uploadTestPlaintextSecret+"\nmanagement:\n secret-key: "+tc.v8Secret+"\n")
+
+ if errPrepare := PrepareConfigFileForUpload(path); errPrepare != nil {
+ t.Fatalf("PrepareConfigFileForUpload() error = %v", errPrepare)
+ }
+
+ saved := readUploadTestConfig(t, path)
+ if strings.Contains(saved, uploadTestPlaintextSecret) {
+ t.Fatalf("config still holds the plaintext secret:\n%s", saved)
+ }
+ assertUploadTestHashed(t, saved, "management")
+ if _, found := uploadTestSecret(t, saved, "remote-management"); found {
+ t.Fatalf("conflicting legacy secret kept:\n%s", saved)
+ }
+ })
+ }
+}
+
+func TestPrepareConfigFileForUploadLeavesSettledConfigUntouched(t *testing.T) {
+ hashed := uploadTestBcrypt(t)
+ shippedTemplate, errTemplate := os.ReadFile(filepath.Join("..", "..", "config.example.yaml"))
+ if errTemplate != nil {
+ t.Fatalf("read config.example.yaml: %v", errTemplate)
+ }
+ for name, content := range map[string]string{
+ "hashed legacy secret": "# operator note\nremote-management:\n secret-key: '" + hashed + "'\n",
+ "hashed v8 secret": "# operator note\nmanagement:\n secret-key: '" + hashed + "'\n",
+ "no secret": "# operator note\nport: 8317\n",
+ "null v8 secret": "# operator note\nmanagement:\n secret-key: null\n",
+ "tilde legacy secret": "# operator note\nremote-management:\n secret-key: ~\n",
+ "empty file": "",
+ "shipped template": string(shippedTemplate),
+ } {
+ t.Run(name, func(t *testing.T) {
+ path := writeUploadTestConfig(t, content)
+
+ if errPrepare := PrepareConfigFileForUpload(path); errPrepare != nil {
+ t.Fatalf("PrepareConfigFileForUpload() error = %v", errPrepare)
+ }
+
+ if saved := readUploadTestConfig(t, path); saved != content {
+ t.Fatalf("config rewritten to %q, want it untouched", saved)
+ }
+ })
+ }
+}
+
+// A config whose secret cannot be hashed into the file still holds the plaintext, so
+// callers must hear about it and not upload the file.
+func TestPrepareConfigFileForUploadReportsSecretItCannotHash(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ content string
+ readOnly bool
+ }{
+ {name: "unparsable", content: "remote-management:\n secret-key: " + uploadTestPlaintextSecret + "\nport: [8317\n"},
+ {name: "secret too long for bcrypt", content: "remote-management:\n secret-key: " + strings.Repeat("s", 73) + "\n"},
+ {name: "read-only file", content: "remote-management:\n secret-key: " + uploadTestPlaintextSecret + "\n", readOnly: true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ path := writeUploadTestConfig(t, tc.content)
+ if tc.readOnly {
+ if errChmod := os.Chmod(path, 0o400); errChmod != nil {
+ t.Fatalf("chmod config: %v", errChmod)
+ }
+ t.Cleanup(func() {
+ if errChmod := os.Chmod(path, 0o600); errChmod != nil {
+ t.Errorf("restore config permissions: %v", errChmod)
+ }
+ })
+ if file, errOpen := os.OpenFile(path, os.O_WRONLY, 0); errOpen == nil {
+ if errClose := file.Close(); errClose != nil {
+ t.Errorf("close config: %v", errClose)
+ }
+ t.Skip("read-only file stays writable for this user")
+ }
+ }
+
+ if errPrepare := PrepareConfigFileForUpload(path); errPrepare == nil {
+ t.Fatal("PrepareConfigFileForUpload() reported success for a secret it could not hash into the file")
+ }
+
+ if saved := readUploadTestConfig(t, path); saved != tc.content {
+ t.Fatalf("config rewritten to %q, want it untouched", saved)
+ }
+ })
+ }
+}
+
+// Hashing replaces only the effective key. A YAML anchor keeps the plaintext at its
+// source, and rewriting the source could change other settings sharing it, so the file
+// must be refused and left as it was; otherwise a later call would find the key hashed
+// and accept the file with the plaintext still at the anchor.
+func TestPrepareConfigFileForUploadRefusesPlaintextKeptByReferences(t *testing.T) {
+ anchoredLegacy := "defaults: &legacy\n secret-key: " + uploadTestPlaintextSecret + "\nremote-management: *legacy\nmanagement:\n secret-key: "
+ for name, content := range map[string]string{
+ "mapping anchor": "defaults: &management\n secret-key: " + uploadTestPlaintextSecret + "\nmanagement: *management\n",
+ "scalar anchor": "password: &password " + uploadTestPlaintextSecret + "\nmanagement:\n secret-key: *password\n",
+ "merge key": "defaults: &management\n secret-key: " + uploadTestPlaintextSecret + "\nmanagement:\n <<: *management\n allow-remote: true\n",
+ "shared anchor": "management: &management\n secret-key: " + uploadTestPlaintextSecret + "\nother: *management\n",
+ // The overridden legacy secret is dropped from remote-management, but its anchor
+ // still holds the plaintext.
+ "anchored legacy beside plaintext v8": anchoredLegacy + "current-plaintext-secret\n",
+ "anchored legacy beside hashed v8": anchoredLegacy + "'" + uploadTestBcrypt(t) + "'\n",
+ } {
+ t.Run(name, func(t *testing.T) {
+ path := writeUploadTestConfig(t, content)
+
+ for attempt := 1; attempt <= 2; attempt++ {
+ if errPrepare := PrepareConfigFileForUpload(path); errPrepare == nil {
+ t.Fatalf("attempt %d: PrepareConfigFileForUpload() reported success, but the file still holds the plaintext secret:\n%s", attempt, readUploadTestConfig(t, path))
+ }
+ }
+
+ if saved := readUploadTestConfig(t, path); saved != content {
+ t.Fatalf("refused config rewritten to %q, want it untouched", saved)
+ }
+ })
+ }
+}
diff --git a/internal/config/config_normalization.go b/internal/config/config_normalization.go
index 6dc033ba211..8c351481e19 100644
--- a/internal/config/config_normalization.go
+++ b/internal/config/config_normalization.go
@@ -5,6 +5,7 @@ import (
"strings"
sdkpluginstore "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginstore"
+ log "github.com/sirupsen/logrus"
)
// NormalizePluginsConfig applies default plugin configuration values.
@@ -33,6 +34,17 @@ func (cfg *Config) NormalizePluginsConfig() {
}
}
+// NormalizeClaudeConfig applies default Claude provider configuration values.
+func (cfg *Config) NormalizeClaudeConfig() {
+ if cfg == nil {
+ return
+ }
+ if cfg.Claude.SessionDatePinDays == nil {
+ days := DefaultClaudeSessionDatePinDays
+ cfg.Claude.SessionDatePinDays = &days
+ }
+}
+
// SanitizeCodexHeaderDefaults trims surrounding whitespace from the
// configured Codex header fallback values.
func (cfg *Config) SanitizeCodexHeaderDefaults() {
@@ -45,11 +57,22 @@ func (cfg *Config) SanitizeCodexHeaderDefaults() {
}
// SanitizeClaudeHeaderDefaults trims surrounding whitespace from the
-// configured Claude fingerprint baseline values.
+// configured Claude fingerprint baseline values, normalizes the beta override
+// lists, and resets an unknown client-request-id mode to the default.
func (cfg *Config) SanitizeClaudeHeaderDefaults() {
if cfg == nil {
return
}
+ cfg.ClaudeHeaderDefaults.AnthropicBetaStrip = normalizeClaudeBetaNames(cfg.ClaudeHeaderDefaults.AnthropicBetaStrip)
+ cfg.ClaudeHeaderDefaults.AnthropicBetaAppend = normalizeClaudeBetaNames(cfg.ClaudeHeaderDefaults.AnthropicBetaAppend)
+ mode := strings.ToLower(strings.TrimSpace(cfg.ClaudeHeaderDefaults.ClientRequestID))
+ switch mode {
+ case "", ClaudeClientRequestIDOn, ClaudeClientRequestIDOff, ClaudeClientRequestIDPassthrough:
+ default:
+ log.Warnf("unrecognized claude header-defaults client-request-id %q (supported: on, off, passthrough); falling back to on", cfg.ClaudeHeaderDefaults.ClientRequestID)
+ mode = ""
+ }
+ cfg.ClaudeHeaderDefaults.ClientRequestID = mode
cfg.ClaudeHeaderDefaults.UserAgent = strings.TrimSpace(cfg.ClaudeHeaderDefaults.UserAgent)
cfg.ClaudeHeaderDefaults.PackageVersion = strings.TrimSpace(cfg.ClaudeHeaderDefaults.PackageVersion)
cfg.ClaudeHeaderDefaults.RuntimeVersion = strings.TrimSpace(cfg.ClaudeHeaderDefaults.RuntimeVersion)
@@ -59,6 +82,31 @@ func (cfg *Config) SanitizeClaudeHeaderDefaults() {
cfg.ClaudeHeaderDefaults.Timezone = strings.TrimSpace(cfg.ClaudeHeaderDefaults.Timezone)
}
+// normalizeClaudeBetaNames trims beta names and drops empty and duplicate
+// entries while keeping the configured order.
+func normalizeClaudeBetaNames(names []string) []string {
+ if len(names) == 0 {
+ return nil
+ }
+ out := make([]string, 0, len(names))
+ seen := make(map[string]struct{}, len(names))
+ for _, name := range names {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ continue
+ }
+ if _, ok := seen[name]; ok {
+ continue
+ }
+ seen[name] = struct{}{}
+ out = append(out, name)
+ }
+ if len(out) == 0 {
+ return nil
+ }
+ return out
+}
+
// SanitizeOAuthModelAlias normalizes and deduplicates global OAuth model name aliases.
// It trims whitespace, normalizes channel keys to lower-case, drops empty entries,
// allows multiple aliases per upstream name, and ensures aliases are unique within each channel.
diff --git a/internal/config/config_types.go b/internal/config/config_types.go
index 20afc7d43a3..89e1bc1267c 100644
--- a/internal/config/config_types.go
+++ b/internal/config/config_types.go
@@ -121,6 +121,34 @@ type ClaudeConfig struct {
// translation elsewhere, and per-credential search advertising in the Codex
// catalog. Default false keeps the translation that predates tool_search support.
ToolSearch bool `yaml:"tool-search" json:"tool-search"`
+ // SessionDatePinDays retains the cloaked currentDate pin per session for
+ // this many days, independently of continuity expiry and credential
+ // switches. A nil value (unset) defaults to 7 days; an explicit 0 keeps
+ // the continuity-scoped pin (1h lifetime).
+ SessionDatePinDays *int `yaml:"session-date-pin-days" json:"session-date-pin-days"`
+}
+
+// DefaultClaudeSessionDatePinDays is the session date pin retention applied
+// when session-date-pin-days is not configured.
+const DefaultClaudeSessionDatePinDays = 7
+
+// SessionDatePinRetention returns the configured session date pin retention.
+// An unset value defaults to 7 days; an explicit zero keeps the legacy
+// continuity-scoped behavior. Days are clamped so the duration cannot
+// overflow.
+func (c ClaudeConfig) SessionDatePinRetention() time.Duration {
+ days := DefaultClaudeSessionDatePinDays
+ if c.SessionDatePinDays != nil {
+ days = *c.SessionDatePinDays
+ }
+ if days <= 0 {
+ return 0
+ }
+ maxDays := int((time.Duration(math.MaxInt64) / (24 * time.Hour)))
+ if days > maxDays {
+ days = maxDays
+ }
+ return time.Duration(days) * 24 * time.Hour
}
// ClaudeHeaderDefaults configures the measured Claude Code software baseline.
@@ -128,16 +156,31 @@ type ClaudeConfig struct {
// Claude Code, package, and runtime versions exactly match this baseline; unmeasured
// versions use the configured values. Timeout remains a fallback. Stabilized profiles
// also pin OS and Arch and never learn newer software versions automatically.
+// AnthropicBetaStrip and AnthropicBetaAppend are exact-name operator overrides applied
+// to the final Anthropic-Beta set; InjectDiagnostics and ClientRequestID tune the
+// first-party Claude Code CLI request shape.
type ClaudeHeaderDefaults struct {
- UserAgent string `yaml:"user-agent" json:"user-agent"`
- PackageVersion string `yaml:"package-version" json:"package-version"`
- RuntimeVersion string `yaml:"runtime-version" json:"runtime-version"`
- OS string `yaml:"os" json:"os"`
- Arch string `yaml:"arch" json:"arch"`
- Timeout string `yaml:"timeout" json:"timeout"`
- Timezone string `yaml:"timezone" json:"timezone"`
- StabilizeDeviceProfile *bool `yaml:"stabilize-device-profile,omitempty" json:"stabilize-device-profile,omitempty"`
-}
+ UserAgent string `yaml:"user-agent" json:"user-agent"`
+ PackageVersion string `yaml:"package-version" json:"package-version"`
+ RuntimeVersion string `yaml:"runtime-version" json:"runtime-version"`
+ OS string `yaml:"os" json:"os"`
+ Arch string `yaml:"arch" json:"arch"`
+ Timeout string `yaml:"timeout" json:"timeout"`
+ Timezone string `yaml:"timezone" json:"timezone"`
+ StabilizeDeviceProfile *bool `yaml:"stabilize-device-profile,omitempty" json:"stabilize-device-profile,omitempty"`
+ AnthropicBetaStrip []string `yaml:"anthropic-beta-strip,omitempty" json:"anthropic-beta-strip,omitempty"`
+ AnthropicBetaAppend []string `yaml:"anthropic-beta-append,omitempty" json:"anthropic-beta-append,omitempty"`
+ InjectDiagnostics *bool `yaml:"inject-diagnostics,omitempty" json:"inject-diagnostics,omitempty"`
+ ClientRequestID string `yaml:"client-request-id,omitempty" json:"client-request-id,omitempty"`
+}
+
+// Supported ClaudeHeaderDefaults.ClientRequestID values. An empty value means
+// ClaudeClientRequestIDOn.
+const (
+ ClaudeClientRequestIDOn = "on"
+ ClaudeClientRequestIDOff = "off"
+ ClaudeClientRequestIDPassthrough = "passthrough"
+)
// CodexHeaderDefaults configures fallback header values injected into Codex
// model requests for OAuth/file-backed auth when the client omits them.
@@ -522,11 +565,11 @@ type PayloadModelRule struct {
// CloakConfig configures request cloaking for non-Claude-Code clients.
// Cloaking disguises API requests to appear as originating from the official Claude Code CLI.
type CloakConfig struct {
- // Mode controls cloaking behavior: "auto" (default), "always", or "never".
+ // Mode controls cloaking behavior: "auto" (default), "always", "never", or "uncloak".
// Supplying this CloakConfig explicitly enables cloaking for an unprofiled API key.
// - "auto": cloak unless strong request signals identify a verified native entrypoint
// - "always": cloak every unconfirmed client; confirmed native Claude Code remains passthrough
- // - "never": never apply cloaking
+ // - "never" / "uncloak": never apply cloaking; on non-CLI API keys, also strip incoming Claude Code identity and billing attribution
Mode string `yaml:"mode,omitempty" json:"mode,omitempty"`
// StrictMode controls how caller system prompts are handled when cloaking.
diff --git a/internal/config/config_v8.go b/internal/config/config_v8.go
index 9e927fd6483..68bd7d98d19 100644
--- a/internal/config/config_v8.go
+++ b/internal/config/config_v8.go
@@ -80,7 +80,7 @@ func buildV8Paths() []configPath {
{"max-retry-interval", "routing.retry.max-retry-interval"},
{"disable-cooling", "routing.cooldown.disable-cooling"}, {"save-cooldown-status", "routing.cooldown.save-cooldown-status"},
{"transient-error-cooldown-seconds", "routing.cooldown.transient-error-cooldown-seconds"},
- {"proxy-url", "requests.proxy-url"}, {"passthrough-headers", "requests.passthrough-headers"},
+ {"proxy-url", "requests.proxy-url"}, {"tls-handshake-timeout-seconds", "requests.tls-handshake-timeout-seconds"}, {"passthrough-headers", "requests.passthrough-headers"},
{"nonstream-keepalive-interval", "requests.nonstream-keepalive-interval"}, {"streaming", "requests.streaming"}, {"payload", "requests.payload"},
{"auth-dir", "oauth.auth-dir"}, {"auth-auto-refresh-workers", "oauth.auth-auto-refresh-workers"},
{"oauth-model-alias", "oauth.model-alias"}, {"oauth-excluded-models", "oauth.excluded-models"},
diff --git a/internal/config/config_yaml.go b/internal/config/config_yaml.go
index 96b55e867fe..95cf138af90 100644
--- a/internal/config/config_yaml.go
+++ b/internal/config/config_yaml.go
@@ -4,6 +4,7 @@ import (
"bytes"
"fmt"
"os"
+ "strconv"
"strings"
"gopkg.in/yaml.v3"
@@ -130,18 +131,28 @@ func SaveConfigPreserveCommentsUpdateNestedScalar(configFile string, path []stri
if err != nil {
return err
}
- var root yaml.Node
- if err = yaml.Unmarshal(data, &root); err != nil {
+ data, err = updateNestedScalarYAML(data, path, value)
+ if err != nil {
return err
}
+ return os.WriteFile(configFile, data, 0600)
+}
+
+// updateNestedScalarYAML returns data with the nested scalar key path like ["a","b"] set to
+// value, preserving comments and positions.
+func updateNestedScalarYAML(data []byte, path []string, value string) ([]byte, error) {
+ var root yaml.Node
+ if errUnmarshal := yaml.Unmarshal(data, &root); errUnmarshal != nil {
+ return nil, errUnmarshal
+ }
if root.Kind != yaml.DocumentNode || len(root.Content) == 0 {
- return fmt.Errorf("invalid yaml document structure")
+ return nil, fmt.Errorf("invalid yaml document structure")
}
// Resolve aliases and merge keys before updating a path. Otherwise replacing
// an alias with a mapping drops inherited siblings or mutates a shared anchor.
var decoded map[string]any
- if err = root.Decode(&decoded); err != nil {
- return err
+ if errDecode := root.Decode(&decoded); errDecode != nil {
+ return nil, errDecode
}
root.Content[0] = expandConfigAliases(root.Content[0])
node := root.Content[0]
@@ -162,24 +173,17 @@ func SaveConfigPreserveCommentsUpdateNestedScalar(configFile string, path []stri
node = next
}
}
- f, err := os.Create(configFile)
- if err != nil {
- return err
- }
- defer func() { _ = f.Close() }()
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
- if err = enc.Encode(&root); err != nil {
+ if errEncode := enc.Encode(&root); errEncode != nil {
_ = enc.Close()
- return err
+ return nil, errEncode
}
- if err = enc.Close(); err != nil {
- return err
+ if errClose := enc.Close(); errClose != nil {
+ return nil, errClose
}
- data = NormalizeCommentIndentation(buf.Bytes())
- _, err = f.Write(data)
- return err
+ return NormalizeCommentIndentation(buf.Bytes()), nil
}
// NormalizeCommentIndentation removes indentation from standalone YAML comment lines to keep them left aligned.
@@ -384,11 +388,25 @@ func isKnownDefaultValue(path []string, node *yaml.Node) bool {
return false
}
+ // session-date-pin-days is pointer-backed: only an explicit zero is
+ // preserved; the default value must not pollute saves.
+ if len(path) > 0 && path[len(path)-1] == "session-date-pin-days" && node != nil && node.Kind == yaml.ScalarNode && node.Tag == "!!int" && node.Value == "0" {
+ return false
+ }
+
// Pointer-backed booleans (such as cache-user-id and disable-cooling): explicit false is meaningful and must be preserved.
if len(path) > 0 && (path[len(path)-1] == "cache-user-id" || path[len(path)-1] == "disable-cooling") && node != nil && node.Kind == yaml.ScalarNode && node.Tag == "!!bool" {
return false
}
+ // A parent mapping carrying an explicit session-date-pin-days value must
+ // survive the zero-value check below, or the new key is never written.
+ if node != nil && node.Kind == yaml.MappingNode {
+ if pinIdx := findMapKeyIndex(node, "session-date-pin-days"); pinIdx >= 0 {
+ return false
+ }
+ }
+
// First check if it's a zero value
if isZeroValueNode(node) {
return true
@@ -420,6 +438,8 @@ func isKnownDefaultValue(path []string, node *yaml.Node) bool {
switch fullPath {
case "error-logs-max-files":
return node.Value == "10"
+ case "claude.session-date-pin-days":
+ return node.Value == strconv.Itoa(DefaultClaudeSessionDatePinDays)
}
}
diff --git a/internal/config/parse.go b/internal/config/parse.go
index 9172a2314d7..c3442be1dd5 100644
--- a/internal/config/parse.go
+++ b/internal/config/parse.go
@@ -104,6 +104,7 @@ func ParseConfigBytes(data []byte) (*Config, error) {
}
cfg.NormalizePluginsConfig()
+ cfg.NormalizeClaudeConfig()
if errResolvePluginsDir := cfg.ResolvePluginsDir(); errResolvePluginsDir != nil && cfg.Plugins.Enabled {
return nil, errResolvePluginsDir
}
diff --git a/internal/config/sdk_config.go b/internal/config/sdk_config.go
index a9b23210dfe..1c52dc2b8ef 100644
--- a/internal/config/sdk_config.go
+++ b/internal/config/sdk_config.go
@@ -4,6 +4,18 @@
// debug settings, proxy configuration, and API keys.
package config
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "math"
+ "strconv"
+ "strings"
+ "time"
+
+ "gopkg.in/yaml.v3"
+)
+
// SDKConfig represents the application's configuration, loaded from a YAML file.
type SDKConfig struct {
// MaxRequestBodyBytes limits the size (in bytes) of incoming HTTP request bodies.
@@ -24,6 +36,21 @@ type SDKConfig struct {
// ProxyURL is the URL of an optional proxy server to use for outbound requests.
ProxyURL string `yaml:"proxy-url" json:"proxy-url"`
+ // TLSHandshakeTimeoutSeconds bounds upstream TLS handshakes made by the
+ // shared uTLS client, in seconds. It applies to both the Claude Code
+ // Anthropic transport and the Chrome ChatGPT/Codex transport; the
+ // standard fallback transport is unaffected. It starts after TCP or
+ // proxy connection establishment returns and does not bound dialing or
+ // proxy CONNECT. Those uTLS paths use custom TLS dialing, for which Go
+ // ignores http.Transport.TLSHandshakeTimeout, so a peer that accepts the
+ // connection but never answers the ClientHello would otherwise block the
+ // request indefinitely and request-retry could never engage. The bound
+ // applies only during the TLS handshake and is removed once it succeeds,
+ // so inference and long-running streams are unaffected. The default 0
+ // disables the bound (previous behavior); negative values are treated as
+ // 0, and values beyond the representable duration range saturate.
+ TLSHandshakeTimeoutSeconds int `yaml:"tls-handshake-timeout-seconds" json:"tls-handshake-timeout-seconds"`
+
// DisableImageGeneration controls whether the built-in image_generation tool is injected/allowed.
//
// Supported values:
@@ -78,6 +105,20 @@ type SDKConfig struct {
NonStreamKeepAliveInterval int `yaml:"nonstream-keepalive-interval,omitempty" json:"nonstream-keepalive-interval,omitempty"`
}
+// TLSHandshakeTimeout returns the configured upstream TLS handshake bound
+// for requests using the shared uTLS client. A non-positive configured
+// value disables the bound and yields 0.
+func (c *SDKConfig) TLSHandshakeTimeout() time.Duration {
+ if c == nil || c.TLSHandshakeTimeoutSeconds <= 0 {
+ return 0
+ }
+ const maxTLSHandshakeTimeoutSeconds = math.MaxInt64 / int64(time.Second)
+ if int64(c.TLSHandshakeTimeoutSeconds) > maxTLSHandshakeTimeoutSeconds {
+ return time.Duration(math.MaxInt64)
+ }
+ return time.Duration(c.TLSHandshakeTimeoutSeconds) * time.Second
+}
+
// ClientConfig configures client-facing compatibility behavior.
type ClientConfig struct {
Codex CodexClientConfig `yaml:"codex" json:"codex"`
@@ -92,6 +133,214 @@ type CodexClientConfig struct {
// EnableApplyPatch advertises freeform apply_patch only for supported models.
// Default false clears the capability regardless of template metadata.
EnableApplyPatch bool `yaml:"enable-apply-patch" json:"enable-apply-patch"`
+
+ // WebSearchResultCache configures caching of upstream web search results for
+ // Codex Responses clients that drop search results from replayed history.
+ WebSearchResultCache CodexWebSearchResultCacheConfig `yaml:"web-search-result-cache" json:"web-search-result-cache"`
+}
+
+const (
+ // DefaultCodexWebSearchResultCacheTTL is the default lifetime of cached web search results.
+ DefaultCodexWebSearchResultCacheTTL = 24 * time.Hour
+
+ // DefaultCodexWebSearchResultCacheLimit is the default maximum number of cached web search result entries.
+ DefaultCodexWebSearchResultCacheLimit = 1024
+
+ maxCodexWebSearchResultCacheTTLSeconds = int64(math.MaxInt64 / time.Second)
+)
+
+// CodexWebSearchResultCacheConfig configures the session-scoped web search result cache.
+type CodexWebSearchResultCacheConfig struct {
+ // Enabled controls whether web search results are cached and restored for Codex Responses requests.
+ // Default is false (disabled).
+ Enabled bool `yaml:"enabled" json:"enabled"`
+
+ // TTL specifies how long cached web search results remain valid (e.g. 1800, "30m", "24h").
+ // Pure numbers are interpreted as seconds. Defaults to "24h" when unset.
+ TTL string `yaml:"ttl" json:"ttl"`
+
+ // Limit specifies the maximum number of entries retained in the web search result cache.
+ // Defaults to 1024 when unset or zero.
+ Limit int `yaml:"limit" json:"limit"`
+}
+
+// Validate checks that the configured TTL and entry limit are valid.
+func (c CodexWebSearchResultCacheConfig) Validate() error {
+ if _, err := parseCodexWebSearchResultCacheTTL(c.TTL); err != nil {
+ return err
+ }
+ if c.Limit < 0 {
+ return fmt.Errorf("client.codex.web-search-result-cache.limit must not be negative")
+ }
+ return nil
+}
+
+// EffectiveTTL returns the parsed cache TTL, or DefaultCodexWebSearchResultCacheTTL when unset or zero.
+func (c CodexWebSearchResultCacheConfig) EffectiveTTL() time.Duration {
+ d, err := parseCodexWebSearchResultCacheTTL(c.TTL)
+ if err != nil || d <= 0 {
+ return DefaultCodexWebSearchResultCacheTTL
+ }
+ return d
+}
+
+// EffectiveLimit returns the configured entry limit, or DefaultCodexWebSearchResultCacheLimit when unset or non-positive.
+func (c CodexWebSearchResultCacheConfig) EffectiveLimit() int {
+ if c.Limit > 0 {
+ return c.Limit
+ }
+ return DefaultCodexWebSearchResultCacheLimit
+}
+
+func (c *CodexWebSearchResultCacheConfig) UnmarshalYAML(value *yaml.Node) error {
+ if c == nil {
+ return nil
+ }
+ if value == nil || value.Kind == 0 || value.Tag == "!!null" {
+ *c = CodexWebSearchResultCacheConfig{}
+ return nil
+ }
+ if value.Kind != yaml.MappingNode {
+ return fmt.Errorf("client.codex.web-search-result-cache must be a mapping")
+ }
+ var next CodexWebSearchResultCacheConfig
+ seen := make(map[string]bool, len(value.Content)/2)
+ for i := 0; i+1 < len(value.Content); i += 2 {
+ keyNode := value.Content[i]
+ valNode := value.Content[i+1]
+ if keyNode == nil || valNode == nil {
+ continue
+ }
+ if seen[keyNode.Value] {
+ return fmt.Errorf("client.codex.web-search-result-cache: duplicate field %q", keyNode.Value)
+ }
+ seen[keyNode.Value] = true
+ switch keyNode.Value {
+ case "enabled":
+ if valNode.Tag == "!!null" {
+ next.Enabled = false
+ continue
+ }
+ if valNode.Kind != yaml.ScalarNode || valNode.Tag != "!!bool" {
+ return fmt.Errorf("client.codex.web-search-result-cache.enabled must be a boolean")
+ }
+ var enabled bool
+ if errDecode := valNode.Decode(&enabled); errDecode != nil {
+ return fmt.Errorf("client.codex.web-search-result-cache.enabled: %w", errDecode)
+ }
+ next.Enabled = enabled
+ case "ttl":
+ if valNode.Tag == "!!null" {
+ next.TTL = ""
+ continue
+ }
+ if valNode.Kind != yaml.ScalarNode || (valNode.Tag != "!!str" && valNode.Tag != "!!int") {
+ return fmt.Errorf("client.codex.web-search-result-cache.ttl must be a duration string or integer seconds")
+ }
+ next.TTL = strings.TrimSpace(valNode.Value)
+ case "limit":
+ if valNode.Tag == "!!null" {
+ next.Limit = 0
+ continue
+ }
+ if valNode.Kind != yaml.ScalarNode || valNode.Tag != "!!int" {
+ return fmt.Errorf("client.codex.web-search-result-cache.limit must be an integer")
+ }
+ var limit int
+ if errDecode := valNode.Decode(&limit); errDecode != nil {
+ return fmt.Errorf("client.codex.web-search-result-cache.limit: %w", errDecode)
+ }
+ next.Limit = limit
+ default:
+ return fmt.Errorf("client.codex.web-search-result-cache: unknown field %q", keyNode.Value)
+ }
+ }
+ if errValidate := next.Validate(); errValidate != nil {
+ return errValidate
+ }
+ *c = next
+ return nil
+}
+
+func (c *CodexWebSearchResultCacheConfig) UnmarshalJSON(data []byte) error {
+ if c == nil {
+ return nil
+ }
+ trimmed := bytes.TrimSpace(data)
+ if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
+ *c = CodexWebSearchResultCacheConfig{}
+ return nil
+ }
+ var raw struct {
+ Enabled bool `json:"enabled"`
+ TTL json.RawMessage `json:"ttl"`
+ Limit int `json:"limit"`
+ }
+ decoder := json.NewDecoder(bytes.NewReader(trimmed))
+ decoder.DisallowUnknownFields()
+ if errDecode := decoder.Decode(&raw); errDecode != nil {
+ return errDecode
+ }
+ ttl, errTTL := parseCodexWebSearchResultCacheTTLJSON(raw.TTL)
+ if errTTL != nil {
+ return errTTL
+ }
+ next := CodexWebSearchResultCacheConfig{
+ Enabled: raw.Enabled,
+ TTL: ttl,
+ Limit: raw.Limit,
+ }
+ if errValidate := next.Validate(); errValidate != nil {
+ return errValidate
+ }
+ *c = next
+ return nil
+}
+
+func parseCodexWebSearchResultCacheTTLJSON(raw json.RawMessage) (string, error) {
+ trimmed := bytes.TrimSpace(raw)
+ if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
+ return "", nil
+ }
+ if trimmed[0] == '"' {
+ var s string
+ if errUnmarshal := json.Unmarshal(trimmed, &s); errUnmarshal != nil {
+ return "", fmt.Errorf("client.codex.web-search-result-cache.ttl: %w", errUnmarshal)
+ }
+ return strings.TrimSpace(s), nil
+ }
+ var n int64
+ if errUnmarshal := json.Unmarshal(trimmed, &n); errUnmarshal != nil {
+ return "", fmt.Errorf("client.codex.web-search-result-cache.ttl must be a duration string or integer seconds")
+ }
+ return strconv.FormatInt(n, 10), nil
+}
+
+func parseCodexWebSearchResultCacheTTL(raw string) (time.Duration, error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" || raw == "0" {
+ return DefaultCodexWebSearchResultCacheTTL, nil
+ }
+ if secs, errParseInt := strconv.ParseInt(raw, 10, 64); errParseInt == nil {
+ if secs < 0 {
+ return 0, fmt.Errorf("client.codex.web-search-result-cache.ttl must not be negative")
+ }
+ if secs > maxCodexWebSearchResultCacheTTLSeconds {
+ return 0, fmt.Errorf("client.codex.web-search-result-cache.ttl overflows duration")
+ }
+ return time.Duration(secs) * time.Second, nil
+ }
+ d, errParseDuration := time.ParseDuration(raw)
+ if errParseDuration != nil {
+ return 0, fmt.Errorf("client.codex.web-search-result-cache.ttl %q is invalid: %w", raw, errParseDuration)
+ }
+ if d < 0 {
+ return 0, fmt.Errorf("client.codex.web-search-result-cache.ttl must not be negative")
+ }
+ if d == 0 {
+ return DefaultCodexWebSearchResultCacheTTL, nil
+ }
+ return d, nil
}
// ClaudeCodeConfig configures Claude Code compatibility behavior.
diff --git a/internal/config/tls_handshake_timeout_test.go b/internal/config/tls_handshake_timeout_test.go
new file mode 100644
index 00000000000..22d4e270998
--- /dev/null
+++ b/internal/config/tls_handshake_timeout_test.go
@@ -0,0 +1,116 @@
+package config
+
+import (
+ "math"
+ "testing"
+ "time"
+)
+
+// TestSDKConfigTLSHandshakeTimeout pins the Issue #6615 contract for the
+// opt-in upstream TLS handshake bound: a positive value in seconds enables
+// the bound, while zero or negative values disable it.
+func TestSDKConfigTLSHandshakeTimeout(t *testing.T) {
+ tests := []struct {
+ name string
+ cfg *SDKConfig
+ want time.Duration
+ }{
+ {name: "nil receiver disables", cfg: nil, want: 0},
+ {name: "zero disables", cfg: &SDKConfig{TLSHandshakeTimeoutSeconds: 0}, want: 0},
+ {name: "negative disables", cfg: &SDKConfig{TLSHandshakeTimeoutSeconds: -5}, want: 0},
+ {name: "positive seconds convert", cfg: &SDKConfig{TLSHandshakeTimeoutSeconds: 10}, want: 10 * time.Second},
+ {name: "overflowing seconds saturate", cfg: &SDKConfig{TLSHandshakeTimeoutSeconds: math.MaxInt}, want: time.Duration(math.MaxInt64)},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := tt.cfg.TLSHandshakeTimeout(); got != tt.want {
+ t.Fatalf("TLSHandshakeTimeout() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+// TestParseConfigBytesRequestsTLSHandshakeTimeout ensures the canonical v8
+// layout exposes the setting under requests and parses as integer seconds.
+func TestParseConfigBytesRequestsTLSHandshakeTimeout(t *testing.T) {
+ cfg, errParse := ParseConfigBytes([]byte(`
+requests:
+ tls-handshake-timeout-seconds: 12
+`))
+ if errParse != nil {
+ t.Fatalf("ParseConfigBytes() error = %v", errParse)
+ }
+ if cfg.TLSHandshakeTimeoutSeconds != 12 {
+ t.Fatalf("tls-handshake-timeout-seconds = %d, want 12", cfg.TLSHandshakeTimeoutSeconds)
+ }
+ if got := cfg.TLSHandshakeTimeout(); got != 12*time.Second {
+ t.Fatalf("TLSHandshakeTimeout() = %v, want 12s", got)
+ }
+}
+
+// TestParseConfigBytesRequestsTLSHandshakeTimeoutLegacyLayout ensures the
+// pre-v8 flat layout maps tls-handshake-timeout-seconds onto the same field.
+func TestParseConfigBytesRequestsTLSHandshakeTimeoutLegacyLayout(t *testing.T) {
+ cfg, errParse := ParseConfigBytes([]byte(`
+tls-handshake-timeout-seconds: 7
+`))
+ if errParse != nil {
+ t.Fatalf("ParseConfigBytes() error = %v", errParse)
+ }
+ if cfg.TLSHandshakeTimeoutSeconds != 7 {
+ t.Fatalf("tls-handshake-timeout-seconds = %d, want 7", cfg.TLSHandshakeTimeoutSeconds)
+ }
+ if got := cfg.TLSHandshakeTimeout(); got != 7*time.Second {
+ t.Fatalf("TLSHandshakeTimeout() = %v, want 7s", got)
+ }
+}
+
+// TestParseConfigBytesRequestsTLSHandshakeTimeoutPrecedence ensures the
+// canonical requests value, including explicit zero, wins over the flat
+// spelling when a partially migrated document contains both.
+func TestParseConfigBytesRequestsTLSHandshakeTimeoutPrecedence(t *testing.T) {
+ tests := []struct {
+ name string
+ yaml string
+ want int
+ }{
+ {
+ name: "canonical positive wins",
+ yaml: "tls-handshake-timeout-seconds: 7\nrequests:\n tls-handshake-timeout-seconds: 12\n",
+ want: 12,
+ },
+ {
+ name: "canonical explicit zero wins",
+ yaml: "tls-handshake-timeout-seconds: 7\nrequests:\n tls-handshake-timeout-seconds: 0\n",
+ want: 0,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg, errParse := ParseConfigBytes([]byte(tt.yaml))
+ if errParse != nil {
+ t.Fatalf("ParseConfigBytes() error = %v", errParse)
+ }
+ if cfg.TLSHandshakeTimeoutSeconds != tt.want {
+ t.Fatalf("tls-handshake-timeout-seconds = %d, want %d", cfg.TLSHandshakeTimeoutSeconds, tt.want)
+ }
+ })
+ }
+}
+
+// TestParseConfigBytesUpstreamClaudeTLSHandshakeTimeoutIgnored pins the
+// relocation: the never-released upstream.claude spelling must not remain
+// a second functional entry point after moving to requests.
+func TestParseConfigBytesUpstreamClaudeTLSHandshakeTimeoutIgnored(t *testing.T) {
+ cfg, errParse := ParseConfigBytes([]byte(`
+upstream:
+ claude:
+ tls-handshake-timeout-seconds: 99
+`))
+ if errParse != nil {
+ t.Fatalf("ParseConfigBytes() error = %v", errParse)
+ }
+ if cfg.TLSHandshakeTimeoutSeconds != 0 {
+ t.Fatalf("requests tls-handshake-timeout-seconds = %d, want 0 from retired upstream.claude spelling", cfg.TLSHandshakeTimeoutSeconds)
+ }
+}
diff --git a/internal/config/vertex_compat.go b/internal/config/vertex_compat.go
index ae96a7703ab..9a555456618 100644
--- a/internal/config/vertex_compat.go
+++ b/internal/config/vertex_compat.go
@@ -29,8 +29,11 @@ type VertexCompatKey struct {
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
// BaseURL optionally overrides the Vertex-compatible API endpoint.
- // The executor will append "/v1/publishers/google/models/{model}:action" to this.
- // When empty, requests fall back to the default Vertex API base URL.
+ // When it contains a "/publishers/" segment it is used as the full resource
+ // prefix (e.g. "https://aiplatform.googleapis.com/v1/projects/{p}/locations/{l}/publishers/google/models")
+ // and the executor only appends "/{model}:{action}". Otherwise "/v1" is
+ // inserted if absent, then "/publishers/google/models" is appended; a
+ // trailing version segment like "/v1" or "/v1beta1" is never duplicated.
BaseURL string `yaml:"base-url,omitempty" json:"base-url,omitempty"`
// ProxyURL optionally overrides the global proxy for this API key.
@@ -110,12 +113,12 @@ func (cfg *Config) SanitizeVertexCompatKeys() {
entry.Headers = NormalizeHeaders(entry.Headers)
entry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels)
- // Sanitize models: remove entries without valid alias
+ // Sanitize models: remove entries without valid model name
sanitizedModels := make([]VertexCompatModel, 0, len(entry.Models))
for _, model := range entry.Models {
model.Alias = strings.TrimSpace(model.Alias)
model.Name = strings.TrimSpace(model.Name)
- if model.Alias != "" && model.Name != "" {
+ if model.Name != "" {
sanitizedModels = append(sanitizedModels, model)
}
}
diff --git a/internal/discovery/discovery_test.go b/internal/discovery/discovery_test.go
index a860ab5cbe2..266255beb0b 100644
--- a/internal/discovery/discovery_test.go
+++ b/internal/discovery/discovery_test.go
@@ -1,16 +1,21 @@
package discovery
import (
+ "bytes"
"context"
"fmt"
"net"
"os"
+ "runtime"
"strings"
"sync"
+ "sync/atomic"
"testing"
"time"
"github.com/libp2p/zeroconf/v2"
+ "golang.org/x/net/dns/dnsmessage"
+ "golang.org/x/net/ipv4"
)
func TestInstanceID_PersistenceAndFormat(t *testing.T) {
@@ -506,7 +511,7 @@ func TestAdvertiserAndBrowser_Integration(t *testing.T) {
}
adv := NewZeroconfAdvertiser()
- startCtx, startCancel := context.WithTimeout(context.Background(), 400*time.Millisecond)
+ startCtx, startCancel := context.WithTimeout(context.Background(), 2*time.Second)
defer startCancel()
if err := adv.Start(startCtx, spec); err != nil {
t.Skipf("skipping live multicast test: %v", err)
@@ -535,6 +540,13 @@ func TestAdvertiserAndBrowser_Integration(t *testing.T) {
if res.Product != ProductCPA {
t.Errorf("expected product %s, got %s", ProductCPA, res.Product)
}
+ osHost := extractCleanHost() + "." + DefaultDomain
+ if strings.EqualFold(res.Host, osHost) {
+ t.Errorf("expected proxy-specific host instead of OS host %q, got %q", osHost, res.Host)
+ }
+ if res.Host != "cpa-livetest-42.local." {
+ t.Errorf("expected advertised host cpa-livetest-42.local., got %q", res.Host)
+ }
break
}
}
@@ -542,4 +554,1076 @@ func TestAdvertiserAndBrowser_Integration(t *testing.T) {
if !found {
t.Fatalf("advertised instance CPA-LiveTest-42 was not discovered")
}
+
+ if errDirect := verifyDirectHostQueryAndGoodbye(t, ifaces, "cpa-livetest-42.local.", adv.Stop); errDirect != nil {
+ t.Fatalf("direct host query / goodbye verification failed: %v", errDirect)
+ }
+}
+
+func verifyDirectHostQueryAndGoodbye(t *testing.T, ifaces []net.Interface, hostFQDN string, stopAdv func() error) error {
+ t.Helper()
+
+ qName, errName := dnsmessage.NewName(hostFQDN)
+ if errName != nil {
+ return errName
+ }
+ builder := dnsmessage.NewBuilder(nil, dnsmessage.Header{})
+ if errStartQ := builder.StartQuestions(); errStartQ != nil {
+ return errStartQ
+ }
+ if errQ := builder.Question(dnsmessage.Question{
+ Name: qName,
+ Type: dnsmessage.TypeALL,
+ Class: dnsmessage.ClassINET,
+ }); errQ != nil {
+ return errQ
+ }
+ queryBytes, errFinish := builder.Finish()
+ if errFinish != nil {
+ return errFinish
+ }
+
+ uConn, errListen := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
+ if errListen != nil {
+ return errListen
+ }
+ defer func() {
+ if errClose := uConn.Close(); errClose != nil {
+ t.Logf("close unicast query conn: %v", errClose)
+ }
+ }()
+ pConn := ipv4.NewPacketConn(uConn)
+ for i := range ifaces {
+ _ = pConn.SetMulticastInterface(&ifaces[i])
+ _, _ = pConn.WriteTo(queryBytes, nil, &net.UDPAddr{IP: net.IPv4(224, 0, 0, 251), Port: 5353})
+ }
+
+ _ = uConn.SetReadDeadline(time.Now().Add(1500 * time.Millisecond))
+ buf := make([]byte, 4096)
+ gotDirectAddr := false
+ for !gotDirectAddr {
+ n, _, errRead := uConn.ReadFromUDP(buf)
+ if errRead != nil {
+ break
+ }
+ if hasHostAddressRecord(buf[:n], hostFQDN, false) {
+ gotDirectAddr = true
+ }
+ }
+ if !gotDirectAddr {
+ return fmt.Errorf("no A/AAAA response received for direct query to %s", hostFQDN)
+ }
+
+ supersetIPs := append(extractInterfaceIPs(ifaces), "198.51.100.254")
+ conflictOwned, errProbeOwned := probeHostConflictOnLAN(context.Background(), hostFQDN, ifaces, supersetIPs)
+ if errProbeOwned != nil || !conflictOwned {
+ return fmt.Errorf("expected probeHostConflictOnLAN to detect conflict when responder already owns %s even for subset IPs (conflict=%v, err=%v)", hostFQDN, conflictOwned, errProbeOwned)
+ }
+ conflictFree, errProbeFree := probeHostConflictOnLAN(context.Background(), "cpa-unowned-"+hostFQDN, ifaces, supersetIPs)
+ if errProbeFree != nil || conflictFree {
+ return fmt.Errorf("expected probeHostConflictOnLAN not to report conflict for unowned host (conflict=%v, err=%v)", conflictFree, errProbeFree)
+ }
+
+ mConn, errMcast := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(224, 0, 0, 0), Port: 5353})
+ if errMcast != nil {
+ return errMcast
+ }
+ defer func() {
+ if errClose := mConn.Close(); errClose != nil {
+ t.Logf("close multicast goodbye conn: %v", errClose)
+ }
+ }()
+ mpConn := ipv4.NewPacketConn(mConn)
+ for i := range ifaces {
+ _ = mpConn.JoinGroup(&ifaces[i], &net.UDPAddr{IP: net.IPv4(224, 0, 0, 251)})
+ }
+
+ if errStop := stopAdv(); errStop != nil {
+ return fmt.Errorf("stop advertiser: %w", errStop)
+ }
+
+ _ = mConn.SetReadDeadline(time.Now().Add(1500 * time.Millisecond))
+ for {
+ n, _, errRead := mConn.ReadFromUDP(buf)
+ if errRead != nil {
+ return fmt.Errorf("no TTL=0 A/AAAA goodbye record received for %s on Stop()", hostFQDN)
+ }
+ if hasHostAddressRecord(buf[:n], hostFQDN, true) {
+ return nil
+ }
+ }
+}
+
+func hasHostAddressRecord(pkt []byte, hostFQDN string, wantZeroTTL bool) bool {
+ var p dnsmessage.Parser
+ hdr, err := p.Start(rewriteWireLabelChar(pkt, '.', dotInLabelSentinel))
+ if err != nil || !hdr.Response {
+ return false
+ }
+ if errSkipQ := p.SkipAllQuestions(); errSkipQ != nil {
+ return false
+ }
+ checkSection := func(headers func() (dnsmessage.ResourceHeader, error), skip func() error) bool {
+ for {
+ rh, errH := headers()
+ if errH != nil {
+ return false
+ }
+ if errSkip := skip(); errSkip != nil {
+ return false
+ }
+ if !strings.EqualFold(rh.Name.String(), hostFQDN) {
+ continue
+ }
+ if rh.Type != dnsmessage.TypeA && rh.Type != dnsmessage.TypeAAAA {
+ continue
+ }
+ if wantZeroTTL && rh.TTL == 0 {
+ return true
+ }
+ if !wantZeroTTL && rh.TTL > 0 {
+ return true
+ }
+ }
+ }
+ if checkSection(p.AnswerHeader, p.SkipAnswer) {
+ return true
+ }
+ if errSkipAuth := p.SkipAllAuthorities(); errSkipAuth != nil {
+ return false
+ }
+ return checkSection(p.AdditionalHeader, p.SkipAdditional)
+}
+
+type fakeZeroconfServer struct {
+ mu sync.Mutex
+ shutdowns int
+}
+
+func (f *fakeZeroconfServer) Shutdown() {
+ f.mu.Lock()
+ f.shutdowns++
+ f.mu.Unlock()
+}
+
+func (f *fakeZeroconfServer) shutdownCount() int {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return f.shutdowns
+}
+
+func TestBuildAdvertisedHost(t *testing.T) {
+ if got := buildAdvertisedHost("CPA-8F3B", "devbox.local."); got != "cpa-8f3b" {
+ t.Fatalf("buildAdvertisedHost(CPA-8F3B) = %q, want cpa-8f3b", got)
+ }
+ if got := buildAdvertisedHost("office-8F3B", "devbox"); got != "cpa-office-8f3b" {
+ t.Fatalf("buildAdvertisedHost(office-8F3B) = %q, want cpa-office-8f3b", got)
+ }
+ if got := buildAdvertisedHost("中文节点-8F3B", "devbox"); got != "cpa-8f3b" {
+ t.Fatalf("buildAdvertisedHost(non-ASCII) = %q, want cpa-8f3b", got)
+ }
+ if got := buildAdvertisedHost("", "devbox"); got != "cpa-0001" {
+ t.Fatalf("buildAdvertisedHost(empty) = %q, want cpa-0001", got)
+ }
+ if got := buildAdvertisedHost("CPA-8F3B", "cpa-8f3b.local"); got == "cpa-8f3b" || !strings.HasPrefix(got, "cpa-8f3b-") {
+ t.Fatalf("expected collision avoidance when OS hostname matches advertised host, got %q", got)
+ }
+
+ longInstance := FormatInstanceName(strings.Repeat("a", 70), "8F3B")
+ longHost := buildAdvertisedHost(longInstance, "devbox")
+ if len(longHost) > maxDNSLabelBytes {
+ t.Fatalf("len(longHost) = %d, exceeds %d", len(longHost), maxDNSLabelBytes)
+ }
+ if !strings.HasPrefix(longHost, "cpa-") || !strings.HasSuffix(longHost, "-8f3b") {
+ t.Fatalf("longHost = %q, want cpa-...-8f3b", longHost)
+ }
+}
+
+func TestExtractInterfaceAddrs_PrefersGlobalIPv6OverLinkLocal(t *testing.T) {
+ withGlobal := []net.Addr{
+ &net.IPNet{IP: net.ParseIP("192.0.2.10"), Mask: net.CIDRMask(24, 32)},
+ &net.IPNet{IP: net.ParseIP("2001:db8::10"), Mask: net.CIDRMask(64, 128)},
+ &net.IPNet{IP: net.ParseIP("fe80::1"), Mask: net.CIDRMask(64, 128)},
+ &net.IPNet{IP: net.ParseIP("127.0.0.1"), Mask: net.CIDRMask(8, 32)},
+ }
+ gotGlobal := extractInterfaceAddrs(withGlobal)
+ if len(gotGlobal) != 2 || gotGlobal[0] != "192.0.2.10" || gotGlobal[1] != "2001:db8::10" {
+ t.Fatalf("extractInterfaceAddrs(withGlobal) = %v, want [192.0.2.10 2001:db8::10]", gotGlobal)
+ }
+
+ linkLocalOnly := []net.Addr{
+ &net.IPNet{IP: net.ParseIP("192.0.2.10"), Mask: net.CIDRMask(24, 32)},
+ &net.IPNet{IP: net.ParseIP("fe80::1"), Mask: net.CIDRMask(64, 128)},
+ }
+ gotLinkLocal := extractInterfaceAddrs(linkLocalOnly)
+ if len(gotLinkLocal) != 2 || gotLinkLocal[0] != "192.0.2.10" || gotLinkLocal[1] != "fe80::1" {
+ t.Fatalf("extractInterfaceAddrs(linkLocalOnly) = %v, want [192.0.2.10 fe80::1]", gotLinkLocal)
+ }
+}
+
+func TestZeroconfAdvertiser_Start_ProxyHostAndPerInterfaceIPs(t *testing.T) {
+ ifaces := []net.Interface{
+ {Index: 1, Name: "eth0"},
+ {Index: 2, Name: "wlan0"},
+ }
+ allIPs := []string{"192.0.2.10", "198.51.100.20"}
+
+ var gotHost string
+ var gotIPs []string
+ srv := &fakeZeroconfServer{}
+
+ adv := NewZeroconfAdvertiser()
+ adv.osHostname = func() (string, error) { return "devbox.local", nil }
+ adv.interfaceIPs = func([]net.Interface) []string { return append([]string(nil), allIPs...) }
+ adv.registerProxy = func(instance, service, domain string, port int, host string, ips []string, text []string, regIfaces []net.Interface, opts ...zeroconf.ServerOption) (zeroconfServer, error) {
+ gotHost = host
+ gotIPs = append([]string(nil), ips...)
+ return srv, nil
+ }
+
+ err := adv.Start(context.Background(), ServiceSpec{
+ InstanceName: "CPA-8F3B",
+ ServiceType: DefaultServiceType,
+ Domain: DefaultDomain,
+ Port: 8317,
+ Interfaces: ifaces,
+ AdvertisedIPs: allIPs,
+ })
+ if err != nil {
+ t.Fatalf("Start() error = %v", err)
+ }
+ if gotHost != "cpa-8f3b" {
+ t.Fatalf("registered host = %q, want cpa-8f3b (not OS hostname devbox)", gotHost)
+ }
+ if supportsPerInterfaceAddrs(runtime.GOOS) {
+ if len(gotIPs) != 0 {
+ t.Fatalf("Start() registered IPs on %s = %v, want empty slice for per-interface emission", runtime.GOOS, gotIPs)
+ }
+ } else if len(gotIPs) != 2 || gotIPs[0] != "192.0.2.10" || gotIPs[1] != "198.51.100.20" {
+ t.Fatalf("Start() registered IPs on %s = %v, want %v", runtime.GOOS, gotIPs, allIPs)
+ }
+ perIfaceIPs, errResolve := resolveRegisterProxyIPs(ifaces, allIPs, "linux", adv.interfaceIPs)
+ if errResolve != nil || len(perIfaceIPs) != 0 {
+ t.Fatalf("resolveRegisterProxyIPs(multi-homed unrestricted, linux) = %v, %v; want nil, nil", perIfaceIPs, errResolve)
+ }
+ winIPs, errWin := resolveRegisterProxyIPs(ifaces, allIPs, "windows", adv.interfaceIPs)
+ if errWin != nil || len(winIPs) != 2 || winIPs[0] != "192.0.2.10" || winIPs[1] != "198.51.100.20" {
+ t.Fatalf("resolveRegisterProxyIPs(multi-homed unrestricted, windows) = %v, %v; want %v, nil", winIPs, errWin, allIPs)
+ }
+ boundMultiIPs, errBoundMulti := resolveRegisterProxyIPs(ifaces, []string{"192.0.2.10"}, "linux", adv.interfaceIPs)
+ if errBoundMulti != nil || len(boundMultiIPs) != 1 || boundMultiIPs[0] != "192.0.2.10" {
+ t.Fatalf("resolveRegisterProxyIPs(bound subset, linux) = %v, %v; want [192.0.2.10], nil", boundMultiIPs, errBoundMulti)
+ }
+ if errStop := adv.Stop(); errStop != nil {
+ t.Fatalf("Stop() error = %v", errStop)
+ }
+ if srv.shutdowns != 1 {
+ t.Fatalf("shutdowns = %d, want 1", srv.shutdowns)
+ }
+
+ // When a bind-host restriction narrows AdvertisedIPs, the restricted IP list must be preserved.
+ advBound := NewZeroconfAdvertiser()
+ advBound.osHostname = func() (string, error) { return "devbox.local", nil }
+ advBound.interfaceIPs = func([]net.Interface) []string { return append([]string(nil), allIPs...) }
+ advBound.registerProxy = func(instance, service, domain string, port int, host string, ips []string, text []string, regIfaces []net.Interface, opts ...zeroconf.ServerOption) (zeroconfServer, error) {
+ gotHost = host
+ gotIPs = append([]string(nil), ips...)
+ return &fakeZeroconfServer{}, nil
+ }
+ if errBound := advBound.Start(context.Background(), ServiceSpec{
+ InstanceName: "CPA-8F3B",
+ ServiceType: DefaultServiceType,
+ Domain: DefaultDomain,
+ Port: 8317,
+ Interfaces: ifaces[:1],
+ AdvertisedIPs: []string{"192.0.2.10"},
+ }); errBound != nil {
+ t.Fatalf("Start(bound) error = %v", errBound)
+ }
+ if len(gotIPs) != 1 || gotIPs[0] != "192.0.2.10" {
+ t.Fatalf("bound registered IPs = %v, want [192.0.2.10]", gotIPs)
+ }
+ _ = advBound.Stop()
+}
+
+func TestZeroconfAdvertiser_Start_RegisterFailureDoesNotPanic(t *testing.T) {
+ adv := NewZeroconfAdvertiser()
+ adv.probeHostConflict = func(context.Context, string, []net.Interface, []string) (bool, error) {
+ return false, nil
+ }
+ err := adv.Start(context.Background(), ServiceSpec{
+ InstanceName: "CPA-8F3B",
+ ServiceType: DefaultServiceType,
+ Domain: DefaultDomain,
+ Port: 8317,
+ Interfaces: []net.Interface{{Index: 1, Name: "eth0"}},
+ AdvertisedIPs: []string{"invalid-ip"},
+ })
+ if err == nil {
+ _ = adv.Stop()
+ t.Fatal("expected error when RegisterProxy fails on invalid IP, got nil")
+ }
+ if !strings.Contains(err.Error(), "failed to register primary service") {
+ t.Fatalf("expected error from RegisterProxy path, got: %v", err)
+ }
+}
+
+func TestBuildHostAddressReplyAndGoodbyePacket(t *testing.T) {
+ hostFQDN := "cpa-8f3b.local."
+ ips := []string{"192.0.2.10", "2001:db8::10"}
+
+ makeQuery := func(name string, qtype dnsmessage.Type, unicast bool) []byte {
+ qName, err := dnsmessage.NewName(name)
+ if err != nil {
+ t.Fatalf("NewName(%q): %v", name, err)
+ }
+ qClass := dnsmessage.ClassINET
+ if unicast {
+ qClass |= dnsmessage.Class(mdnsCacheFlushBit)
+ }
+ b := dnsmessage.NewBuilder(nil, dnsmessage.Header{ID: 42})
+ _ = b.StartQuestions()
+ _ = b.Question(dnsmessage.Question{Name: qName, Type: qtype, Class: qClass})
+ out, err := b.Finish()
+ if err != nil {
+ t.Fatalf("Finish(): %v", err)
+ }
+ return out
+ }
+
+ replyA, unicastA := buildHostAddressReply(makeQuery(hostFQDN, dnsmessage.TypeA, true), hostFQDN, ips, false)
+ if !unicastA || !hasHostAddressRecord(replyA, hostFQDN, false) {
+ t.Fatalf("expected unicast A reply for %s", hostFQDN)
+ }
+ var stdParser dnsmessage.Parser
+ if _, err := stdParser.Start(replyA); err != nil {
+ t.Fatalf("stdParser.Start: %v", err)
+ }
+ stdQs, _ := stdParser.AllQuestions()
+ if len(stdQs) != 0 {
+ t.Fatalf("standard mDNS reply must not contain questions, got %d", len(stdQs))
+ }
+ stdAnsHdr, errStdAns := stdParser.AnswerHeader()
+ if errStdAns != nil || uint16(stdAnsHdr.Class)&mdnsCacheFlushBit == 0 {
+ t.Fatalf("expected cache-flush bit on standard mDNS answer, got class %v (err %v)", stdAnsHdr.Class, errStdAns)
+ }
+
+ replyLegacy, unicastLegacy := buildHostAddressReply(makeQuery(hostFQDN, dnsmessage.TypeA, false), hostFQDN, ips, true)
+ if !unicastLegacy || !hasHostAddressRecord(replyLegacy, hostFQDN, false) {
+ t.Fatalf("expected unicast reply for legacy query without QU bit")
+ }
+ var legParser dnsmessage.Parser
+ if _, err := legParser.Start(replyLegacy); err != nil {
+ t.Fatalf("legParser.Start: %v", err)
+ }
+ legQs, _ := legParser.AllQuestions()
+ if len(legQs) != 1 {
+ t.Fatalf("legacy unicast reply must echo question, got %d questions", len(legQs))
+ }
+ legAnsHdr, errLegAns := legParser.AnswerHeader()
+ if errLegAns != nil || uint16(legAnsHdr.Class)&mdnsCacheFlushBit != 0 {
+ t.Fatalf("legacy unicast answer must not set cache-flush bit, got class %v (err %v)", legAnsHdr.Class, errLegAns)
+ }
+
+ replyAAAA, unicastAAAA := buildHostAddressReply(makeQuery(hostFQDN, dnsmessage.TypeAAAA, false), hostFQDN, ips, false)
+ if unicastAAAA || !hasHostAddressRecord(replyAAAA, hostFQDN, false) {
+ t.Fatalf("expected multicast AAAA reply for %s", hostFQDN)
+ }
+
+ // RFC 6762 §6.1: IPv4-only service queried for AAAA (or IPv6-only queried for A) must return NSEC + complementary family in Additionals.
+ nsecAAAA, _ := buildHostAddressReply(makeQuery(hostFQDN, dnsmessage.TypeAAAA, false), hostFQDN, []string{"192.0.2.10"}, false)
+ if !hasHostAddressRecord(nsecAAAA, hostFQDN, false) {
+ t.Fatal("expected IPv4-only AAAA query reply to include NSEC answer and Additional A record")
+ }
+ var nsecParser dnsmessage.Parser
+ if _, errN := nsecParser.Start(nsecAAAA); errN != nil || nsecParser.SkipAllQuestions() != nil {
+ t.Fatalf("nsecParser.Start: %v", errN)
+ }
+ nsecHdr, errNHdr := nsecParser.AnswerHeader()
+ if errNHdr != nil || nsecHdr.Type != dnsTypeNSEC {
+ t.Fatalf("expected NSEC answer header, got type=%v err=%v", nsecHdr.Type, errNHdr)
+ }
+ nsecRes, errNRes := nsecParser.UnknownResource()
+ if errNRes != nil || !bytes.HasSuffix(nsecRes.Data, []byte{0x00, 0x01, 0x40}) {
+ t.Fatalf("expected IPv4-only NSEC bitmap 00 01 40 (no trailing zero bytes), got %x (err %v)", nsecRes.Data, errNRes)
+ }
+
+ nsecA, _ := buildHostAddressReply(makeQuery(hostFQDN, dnsmessage.TypeA, false), hostFQDN, []string{"2001:db8::10"}, false)
+ if !hasHostAddressRecord(nsecA, hostFQDN, false) {
+ t.Fatal("expected IPv6-only A query reply to include NSEC answer and Additional AAAA record")
+ }
+ var nsecParser6 dnsmessage.Parser
+ if _, errN6 := nsecParser6.Start(nsecA); errN6 != nil || nsecParser6.SkipAllQuestions() != nil {
+ t.Fatalf("nsecParser6.Start: %v", errN6)
+ }
+ if _, errH6 := nsecParser6.AnswerHeader(); errH6 != nil {
+ t.Fatalf("nsecParser6.AnswerHeader: %v", errH6)
+ }
+ nsecRes6, errNRes6 := nsecParser6.UnknownResource()
+ if errNRes6 != nil || !bytes.HasSuffix(nsecRes6.Data, []byte{0x00, 0x04, 0x00, 0x00, 0x00, 0x08}) {
+ t.Fatalf("expected IPv6-only NSEC bitmap 00 04 00 00 00 08, got %x (err %v)", nsecRes6.Data, errNRes6)
+ }
+
+ if otherReply, _ := buildHostAddressReply(makeQuery("other.local.", dnsmessage.TypeA, false), hostFQDN, ips, false); len(otherReply) != 0 {
+ t.Fatalf("expected no reply for unrelated hostname, got %d bytes", len(otherReply))
+ }
+
+ // RFC 6762 probe packets include proposed records in the Authority section; responder must still answer to defend its host name.
+ qName, _ := dnsmessage.NewName(hostFQDN)
+ probeBuilder := dnsmessage.NewBuilder(nil, dnsmessage.Header{})
+ _ = probeBuilder.StartQuestions()
+ _ = probeBuilder.Question(dnsmessage.Question{Name: qName, Type: dnsmessage.TypeALL, Class: dnsmessage.ClassINET})
+ _ = probeBuilder.StartAuthorities()
+ _ = probeBuilder.AResource(dnsmessage.ResourceHeader{
+ Name: qName,
+ Type: dnsmessage.TypeA,
+ Class: dnsmessage.ClassINET,
+ TTL: 120,
+ }, dnsmessage.AResource{A: [4]byte{198, 51, 100, 99}})
+ probePkt, errProbePkt := probeBuilder.Finish()
+ if errProbePkt != nil {
+ t.Fatalf("probeBuilder.Finish(): %v", errProbePkt)
+ }
+ probeReply, _ := buildHostAddressReply(probePkt, hostFQDN, ips, false)
+ if !hasHostAddressRecord(probeReply, hostFQDN, false) {
+ t.Fatalf("expected responder to defend %s against probe carrying Authority records", hostFQDN)
+ }
+
+ goodbye := buildHostGoodbyePacket(hostFQDN, ips)
+ if !hasHostAddressRecord(goodbye, hostFQDN, true) {
+ t.Fatalf("expected TTL=0 goodbye records for %s", hostFQDN)
+ }
+}
+
+func TestResolveAdvertisedHost_CollisionStepping(t *testing.T) {
+ ifaces := []net.Interface{{Index: 1, Name: "eth0"}}
+ localIPs := []string{"192.0.2.10"}
+
+ var gotHost string
+ adv := NewZeroconfAdvertiser()
+ adv.osHostname = func() (string, error) { return "devbox.local", nil }
+ adv.interfaceIPs = func([]net.Interface) []string { return localIPs }
+ adv.probeHostConflict = func(_ context.Context, hostFQDN string, _ []net.Interface, _ []string) (bool, error) {
+ return hostFQDN == "cpa-8f3b.local." || hostFQDN == "cpa-8f3b-2.local.", nil
+ }
+ adv.registerProxy = func(instance, service, domain string, port int, host string, ips []string, text []string, regIfaces []net.Interface, opts ...zeroconf.ServerOption) (zeroconfServer, error) {
+ gotHost = host
+ return &fakeZeroconfServer{}, nil
+ }
+
+ if err := adv.Start(context.Background(), ServiceSpec{
+ InstanceName: "CPA-8F3B",
+ ServiceType: DefaultServiceType,
+ Domain: DefaultDomain,
+ Port: 8317,
+ Interfaces: ifaces,
+ AdvertisedIPs: localIPs,
+ }); err != nil {
+ t.Fatalf("Start() error = %v", err)
+ }
+ defer func() {
+ _ = adv.Stop()
+ }()
+ if gotHost != "cpa-8f3b-3" {
+ t.Fatalf("registered host after collisions = %q, want cpa-8f3b-3", gotHost)
+ }
+
+ // Candidate matching OS hostname on step-2 must be skipped.
+ skippedHost, errSkip := resolveAdvertisedHost(
+ context.Background(),
+ "CPA-8F3B",
+ "cpa-8f3b-2.local",
+ DefaultDomain,
+ ifaces,
+ localIPs,
+ func(_ context.Context, hostFQDN string, _ []net.Interface, _ []string) (bool, error) {
+ return hostFQDN == "cpa-8f3b.local.", nil
+ },
+ )
+ if errSkip != nil || skippedHost != "cpa-8f3b-3" {
+ t.Fatalf("resolveAdvertisedHost(skip OS hostname) = %q, %v; want cpa-8f3b-3, nil", skippedHost, errSkip)
+ }
+
+ // Exhausting all collision attempts must return an error instead of using an unprobed fallback.
+ if exhausted, errExhausted := resolveAdvertisedHost(
+ context.Background(),
+ "CPA-8F3B",
+ "devbox.local",
+ DefaultDomain,
+ ifaces,
+ localIPs,
+ func(_ context.Context, _ string, _ []net.Interface, _ []string) (bool, error) {
+ return true, nil
+ },
+ ); errExhausted == nil {
+ t.Fatalf("expected error when all collision attempts are exhausted, got host=%q", exhausted)
+ }
+
+ // Probe send failure on invalid interfaces (including IPv6-only or partial interface failure) must return an error.
+ if _, errV6Fail := probeHostConflictOnLAN(
+ context.Background(),
+ "cpa-8f3b.local.",
+ []net.Interface{{Index: -1, Name: "invalid0"}},
+ []string{"2001:db8::10"},
+ ); errV6Fail == nil {
+ t.Fatal("expected error when IPv6-only probe cannot be sent on invalid interface")
+ }
+ if realIfaces, _ := FilterInterfaces(nil, nil); len(realIfaces) > 0 {
+ mixedIfaces := []net.Interface{realIfaces[0], {Index: -1, Name: "invalid0"}}
+ if _, errPartial := probeHostConflictOnLAN(
+ context.Background(),
+ "cpa-8f3b.local.",
+ mixedIfaces,
+ []string{"192.0.2.10"},
+ ); errPartial == nil {
+ t.Fatal("expected error when probe fails on one of the selected interfaces")
+ }
+ shortCtx, shortCancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ defer shortCancel()
+ if _, errShort := probeHostConflictOnLAN(
+ shortCtx,
+ "cpa-8f3b.local.",
+ realIfaces[:1],
+ []string{"192.0.2.10"},
+ ); errShort != context.DeadlineExceeded {
+ t.Fatalf("expected context.DeadlineExceeded when deadline is shorter than full probe schedule, got %v", errShort)
+ }
+ }
+
+ // Cross-family probe conflict: an IPv6 authority probe (TypeAAAA = 28) is lexicographically larger than
+ // an IPv4-only local proposal (TypeA = 1), so the IPv4-only proposer must detect the conflict and step.
+ peerV6ProbePkt, errPeerV6 := buildHostProbeQuery("cpa-8f3b.local.", 200, []string{"2001:db8::10"})
+ if errPeerV6 != nil {
+ t.Fatalf("buildHostProbeQuery(v6): %v", errPeerV6)
+ }
+ if !isHostProbeConflict(peerV6ProbePkt, "cpa-8f3b.local.", []string{"192.0.2.10"}, 50000, 50001) {
+ t.Fatal("expected IPv4-only proposer to detect conflict against peer IPv6 authority probe")
+ }
+
+ // Deterministic check: when both conflictCh and doneCh are ready simultaneously,
+ // awaitProbeConflictResult must always report the detected conflict.
+ for i := 0; i < 100; i++ {
+ conflictCh := make(chan bool, 1)
+ conflictCh <- true
+ doneCh := make(chan struct{})
+ close(doneCh)
+ var detected atomic.Bool
+ detected.Store(true)
+ gotConflict, errAwait := awaitProbeConflictResult(conflictCh, doneCh, &detected, context.Background(), nil)
+ if errAwait != nil || !gotConflict {
+ t.Fatalf("iteration %d: awaitProbeConflictResult = %v, %v; want true, nil", i, gotConflict, errAwait)
+ }
+ }
+
+ // Simultaneous probe tie-breaking (RFC 6762 §8.2): lexicographically smaller RDATA loses while larger RDATA wins,
+ // even if DNS Header.ID happens to match; equal RDATA from different ports breaks ties by port; loopback is filtered by exact packet + source port.
+ peerProbePkt, errPeerPkt := buildHostProbeQuery("cpa-8f3b.local.", 200, []string{"192.0.2.20"})
+ if errPeerPkt != nil {
+ t.Fatalf("buildHostProbeQuery: %v", errPeerPkt)
+ }
+ if !isHostProbeConflict(peerProbePkt, "cpa-8f3b.local.", []string{"192.0.2.10"}, 50000, 50001) {
+ t.Fatal("expected smaller RDATA (192.0.2.10 < 192.0.2.20) to lose tie-break even when DNS ID matches")
+ }
+ if isHostProbeConflict(peerProbePkt, "cpa-8f3b.local.", []string{"192.0.2.30"}, 50000, 50001) {
+ t.Fatal("expected larger RDATA (192.0.2.30 > 192.0.2.20) to win tie-break and not report conflict")
+ }
+ if !isHostProbeConflict(peerProbePkt, "cpa-8f3b.local.", []string{"192.0.2.20"}, 50000, 50001) {
+ t.Fatal("expected equal RDATA with lower source port (50000 < 50001) to lose tie-break")
+ }
+ if isHostProbeConflict(peerProbePkt, "cpa-8f3b.local.", []string{"192.0.2.20"}, 50002, 50001) {
+ t.Fatal("expected equal RDATA with higher source port (50002 > 50001) to win tie-break")
+ }
+ if isOwnLoopbackProbe(peerProbePkt, []byte("different-query"), &net.UDPAddr{IP: net.IPv4(192, 0, 2, 10), Port: 50000}, map[int]struct{}{50000: {}}, ifaces) {
+ t.Fatal("expected packet with different payload not to be treated as own loopback probe")
+ }
+
+ // Multi-probe schedule (RFC 6762 §8.1): must send 3 probes and detect conflict even if first probe is lost and response arrives on probe 2.
+ sendCount := 0
+ conflictCh2 := make(chan bool, 1)
+ doneCh2 := make(chan struct{})
+ var detected2 atomic.Bool
+ gotScheduleConflict, errSched := runHostProbeSchedule(
+ context.Background(),
+ mdnsHostProbeCount,
+ time.Millisecond,
+ func() error {
+ sendCount++
+ if sendCount == 2 {
+ detected2.Store(true)
+ conflictCh2 <- true
+ }
+ return nil
+ },
+ conflictCh2,
+ doneCh2,
+ &detected2,
+ func() {
+ select {
+ case <-doneCh2:
+ default:
+ close(doneCh2)
+ }
+ },
+ )
+ if errSched != nil || !gotScheduleConflict || sendCount != 2 {
+ t.Fatalf("runHostProbeSchedule(conflict on 2nd probe) = conflict=%v err=%v sends=%d; want true, nil, 2", gotScheduleConflict, errSched, sendCount)
+ }
+
+ sendCountAll := 0
+ conflictCh3 := make(chan bool, 1)
+ doneCh3 := make(chan struct{})
+ var detected3 atomic.Bool
+ gotNoConflict, errSchedAll := runHostProbeSchedule(
+ context.Background(),
+ mdnsHostProbeCount,
+ time.Millisecond,
+ func() error {
+ sendCountAll++
+ return nil
+ },
+ conflictCh3,
+ doneCh3,
+ &detected3,
+ func() {
+ select {
+ case <-doneCh3:
+ default:
+ close(doneCh3)
+ }
+ },
+ )
+ if errSchedAll != nil || gotNoConflict || sendCountAll != mdnsHostProbeCount {
+ t.Fatalf("runHostProbeSchedule(no conflict) = conflict=%v err=%v sends=%d; want false, nil, %d", gotNoConflict, errSchedAll, sendCountAll, mdnsHostProbeCount)
+ }
+
+ // Premature reader exit before all probe intervals complete must return an error rather than false-negative "no conflict".
+ prematureDoneCh := make(chan struct{})
+ close(prematureDoneCh)
+ var detectedPremature atomic.Bool
+ if _, errPremature := runHostProbeSchedule(
+ context.Background(),
+ mdnsHostProbeCount,
+ time.Second,
+ func() error { return nil },
+ make(chan bool, 1),
+ prematureDoneCh,
+ &detectedPremature,
+ nil,
+ ); errPremature == nil {
+ t.Fatal("expected error when probe readers terminate prematurely before probe window completes")
+ }
+
+ // Runtime conflict resolution: receiving a conflicting mDNS response for our active hostFQDN
+ // must skip OS hostname candidates, probe replacement candidates, and rebind only after success.
+ var reboundHosts []string
+ var rebindMu sync.Mutex
+ oldInner := &fakeZeroconfServer{}
+ newInner := &fakeZeroconfServer{}
+ responder := &hostRecordResponder{
+ inner: oldInner,
+ baseHost: "cpa-8f3b",
+ osHost: "cpa-8f3b-2.local",
+ domain: DefaultDomain,
+ attempt: 1,
+ hostFQDN: "cpa-8f3b.local.",
+ fixedIPs: []string{"192.0.2.10"},
+ ifaces: ifaces,
+ probeConflict: func(_ context.Context, fqdn string, _ []net.Interface, _ []string) (bool, error) {
+ return fqdn == "cpa-8f3b-3.local.", nil
+ },
+ rebindInner: func(nextHost string) (zeroconfServer, error) {
+ rebindMu.Lock()
+ reboundHosts = append(reboundHosts, nextHost)
+ rebindMu.Unlock()
+ return newInner, nil
+ },
+ stopCh: make(chan struct{}),
+ }
+ qName, _ := dnsmessage.NewName("cpa-8f3b.local.")
+ conflictBuilder := dnsmessage.NewBuilder(nil, dnsmessage.Header{Response: true, Authoritative: true})
+ _ = conflictBuilder.StartAnswers()
+ _ = conflictBuilder.AResource(dnsmessage.ResourceHeader{
+ Name: qName,
+ Type: dnsmessage.TypeA,
+ Class: dnsmessage.ClassINET | dnsmessage.Class(mdnsCacheFlushBit),
+ TTL: 120,
+ }, dnsmessage.AResource{A: [4]byte{192, 0, 2, 77}})
+ conflictRespPkt, errConflictPkt := conflictBuilder.Finish()
+ if errConflictPkt != nil {
+ t.Fatalf("conflictBuilder.Finish(): %v", errConflictPkt)
+ }
+ onLinkPeer := &net.UDPAddr{IP: net.IPv4(192, 0, 2, 77), Port: mdnsPort}
+
+ // Off-link or non-5353 port responses must be ignored per RFC 6762 §11 and not trigger conflict rebinding.
+ responder.handlePacket(conflictRespPkt, 1, &net.UDPAddr{IP: net.IPv4(203, 0, 113, 77), Port: mdnsPort})
+ responder.handlePacket(conflictRespPkt, 1, &net.UDPAddr{IP: net.IPv4(192, 0, 2, 77), Port: 54321})
+ if responder.currentHostFQDN() != "cpa-8f3b.local." || len(reboundHosts) != 0 {
+ t.Fatalf("off-link/non-5353 response should not trigger conflict: hostFQDN=%q rebound=%v", responder.currentHostFQDN(), reboundHosts)
+ }
+
+ responder.handlePacket(conflictRespPkt, 1, onLinkPeer)
+ if responder.currentHostFQDN() != "cpa-8f3b-4.local." || len(reboundHosts) != 1 || reboundHosts[0] != "cpa-8f3b-4" {
+ t.Fatalf("after runtime conflict: hostFQDN=%q reboundHosts=%v, want cpa-8f3b-4.local. / [cpa-8f3b-4]", responder.currentHostFQDN(), reboundHosts)
+ }
+ if oldInner.shutdownCount() != 1 {
+ t.Fatalf("oldInner shutdowns = %d, want 1", oldInner.shutdownCount())
+ }
+
+ // Non-address records (such as CNAME) for the target host must also be treated as conflicts per RFC 6762 §8.1.
+ cnameTarget, _ := dnsmessage.NewName("alias-target.local.")
+ cnameBuilder := dnsmessage.NewBuilder(nil, dnsmessage.Header{Response: true, Authoritative: true})
+ _ = cnameBuilder.StartAnswers()
+ _ = cnameBuilder.CNAMEResource(dnsmessage.ResourceHeader{
+ Name: qName,
+ Type: dnsmessage.TypeCNAME,
+ Class: dnsmessage.ClassINET,
+ TTL: 120,
+ }, dnsmessage.CNAMEResource{CNAME: cnameTarget})
+ cnamePkt, errCNAME := cnameBuilder.Finish()
+ if errCNAME != nil {
+ t.Fatalf("cnameBuilder.Finish(): %v", errCNAME)
+ }
+ if !isHostProbeConflict(cnamePkt, "cpa-8f3b.local.", []string{"192.0.2.10"}, 50000, mdnsPort) {
+ t.Fatal("expected CNAME response for target host to be detected as conflict")
+ }
+
+ // If rebind fails or replacement attempts are exhausted, responder must withdraw the conflicted host and shut down the old server.
+ failOldInner := &fakeZeroconfServer{}
+ failResponder := &hostRecordResponder{
+ inner: failOldInner,
+ baseHost: "cpa-8f3b",
+ osHost: "devbox",
+ domain: DefaultDomain,
+ attempt: 1,
+ hostFQDN: "cpa-8f3b.local.",
+ fixedIPs: []string{"192.0.2.10"},
+ ifaces: ifaces,
+ probeConflict: func(context.Context, string, []net.Interface, []string) (bool, error) {
+ return false, nil
+ },
+ rebindInner: func(string) (zeroconfServer, error) {
+ return nil, fmt.Errorf("simulated rebind failure")
+ },
+ stopCh: make(chan struct{}),
+ }
+ failResponder.handlePacket(conflictRespPkt, 1, onLinkPeer)
+ if failResponder.currentHostFQDN() != "" || failOldInner.shutdownCount() != 1 {
+ t.Fatalf("after failed rebind: hostFQDN=%q oldShutdowns=%d, want empty (withdrawn) / 1", failResponder.currentHostFQDN(), failOldInner.shutdownCount())
+ }
+
+ exhaustOldInner := &fakeZeroconfServer{}
+ exhaustResponder := &hostRecordResponder{
+ inner: exhaustOldInner,
+ baseHost: "cpa-8f3b",
+ osHost: "devbox",
+ domain: DefaultDomain,
+ attempt: maxHostCollisionAttempts,
+ hostFQDN: "cpa-8f3b.local.",
+ fixedIPs: []string{"192.0.2.10"},
+ ifaces: ifaces,
+ stopCh: make(chan struct{}),
+ }
+ exhaustResponder.handlePacket(conflictRespPkt, 1, onLinkPeer)
+ if exhaustResponder.currentHostFQDN() != "" || exhaustOldInner.shutdownCount() != 1 {
+ t.Fatalf("after exhausted attempts: hostFQDN=%q oldShutdowns=%d, want empty (withdrawn) / 1", exhaustResponder.currentHostFQDN(), exhaustOldInner.shutdownCount())
+ }
+
+ // Concurrent conflict packets for the same observed FQDN must not leak servers or double-rebind.
+ concOldInner := &fakeZeroconfServer{}
+ enteredRebind := make(chan struct{})
+ releaseRebind := make(chan struct{})
+ var createdServers []*fakeZeroconfServer
+ var concMu sync.Mutex
+ concResponder := &hostRecordResponder{
+ inner: concOldInner,
+ baseHost: "cpa-8f3b",
+ osHost: "devbox",
+ domain: DefaultDomain,
+ attempt: 1,
+ hostFQDN: "cpa-8f3b.local.",
+ fixedIPs: []string{"192.0.2.10"},
+ ifaces: ifaces,
+ probeConflict: func(context.Context, string, []net.Interface, []string) (bool, error) {
+ return false, nil
+ },
+ rebindInner: func(string) (zeroconfServer, error) {
+ select {
+ case <-enteredRebind:
+ default:
+ close(enteredRebind)
+ }
+ <-releaseRebind
+ s := &fakeZeroconfServer{}
+ concMu.Lock()
+ createdServers = append(createdServers, s)
+ concMu.Unlock()
+ return s, nil
+ },
+ stopCh: make(chan struct{}),
+ }
+ var concWG sync.WaitGroup
+ concWG.Add(2)
+ go func() {
+ defer concWG.Done()
+ concResponder.handlePacket(conflictRespPkt, 1, onLinkPeer)
+ }()
+ <-enteredRebind
+ go func() {
+ defer concWG.Done()
+ concResponder.handlePacket(conflictRespPkt, 1, onLinkPeer)
+ }()
+ close(releaseRebind)
+ concWG.Wait()
+ if len(createdServers) != 1 || concOldInner.shutdownCount() != 1 || createdServers[0].shutdownCount() != 0 {
+ t.Fatalf("concurrent conflict: created=%d oldShutdowns=%d newShutdowns=%d", len(createdServers), concOldInner.shutdownCount(), createdServers[0].shutdownCount())
+ }
+
+ // Malformed/truncated DNS records must terminate immediately without spinning.
+ truncatedAns := conflictRespPkt[:len(conflictRespPkt)-2]
+ if got := extractHostAnswerIPs(truncatedAns, "cpa-8f3b.local."); len(got) != 0 {
+ t.Fatalf("expected empty IPs on truncated answer packet, got %v", got)
+ }
+ truncatedAuth := peerProbePkt[:len(peerProbePkt)-2]
+ if _, gotAuth, _ := extractHostAuthorityIPs(truncatedAuth, "cpa-8f3b.local."); len(gotAuth) != 0 {
+ t.Fatalf("expected empty IPs on truncated authority packet, got %v", gotAuth)
+ }
+
+ // When fixedIPs is restricted (bound host), hostRecordResponder must not build extra goodbye records
+ // from raw interface addresses (which could revoke other addresses on the same interface).
+ if realIfaces, _ := FilterInterfaces(nil, nil); len(realIfaces) > 0 {
+ boundResp := &hostRecordResponder{
+ hostFQDN: "cpa-8f3b.local.",
+ fixedIPs: []string{"192.0.2.10"},
+ ifaces: realIfaces[:1],
+ }
+ pkts := boundResp.goodbyePacketsForHost("cpa-8f3b.local.")
+ for _, pkt := range pkts {
+ var gp dnsmessage.Parser
+ if _, errG := gp.Start(pkt); errG != nil {
+ t.Fatalf("gp.Start: %v", errG)
+ }
+ _ = gp.SkipAllQuestions()
+ for {
+ rh, errH := gp.AnswerHeader()
+ if errH != nil {
+ break
+ }
+ if rh.Type == dnsmessage.TypeA {
+ r, _ := gp.AResource()
+ if net.IP(r.A[:]).String() != "192.0.2.10" {
+ t.Fatalf("unexpected goodbye A record %s; want only 192.0.2.10", net.IP(r.A[:]).String())
+ }
+ } else if rh.Type == dnsmessage.TypeAAAA {
+ t.Fatal("unexpected goodbye AAAA record when fixedIPs is IPv4-only")
+ } else {
+ _ = gp.SkipAnswer()
+ }
+ }
+ }
+ }
+
+ // Off-link queries must not be answered; unified service responder must answer SRV/TXT/PTR queries directly
+ // and preserve dots inside single DNS-SD instance labels on the wire (e.g. "\x0eoffice.v1-8F3B").
+ dotCfg := parseMDNSServiceConfig("office.v1-8F3B", DefaultServiceType, DefaultDomain, 8317, []string{"product=cliproxyapi"})
+ dotAnnPkt := buildServicePacket(dotCfg, "cpa-office-v1-8f3b.local.", []string{"192.0.2.10"}, mdnsServiceRecordTTL)
+ if !bytes.Contains(dotAnnPkt, []byte("\x0eoffice.v1-8F3B")) {
+ t.Fatalf("expected single 14-byte wire label for office.v1-8F3B in announcement packet")
+ }
+ if !isValidProbePeer(&net.UDPAddr{IP: net.IPv4(198, 51, 100, 77), Port: mdnsPort}, 1, ifaces, localIPs, false, true) {
+ t.Fatal("expected probe reply arriving on allowed interface ifIndex=1 from port 5353 to be accepted across subnets")
+ }
+ if isValidProbePeer(&net.UDPAddr{IP: net.IPv4(198, 51, 100, 77), Port: mdnsPort}, 99, ifaces, localIPs, false, true) {
+ t.Fatal("expected unicast probe reply on disallowed interface and foreign subnet to be rejected")
+ }
+ svcCfg := parseMDNSServiceConfig("CPA-8F3B", DefaultServiceType+","+SubtypeResponses, DefaultDomain, 8317, []string{"product=cliproxyapi"})
+
+ // A browse query carrying a Known-Answer PTR record whose target has a literal dot inside a single label
+ // ("\x0eoffice.v1-1234") must not fail parsing and must still return our CPA-8F3B browse answer.
+ svcQName, _ := dnsmessage.NewName("_ai-gateway._tcp.local.")
+ knownDotTarget, _ := dnsmessage.NewName("office" + string(dotInLabelSentinel) + "v1-1234._ai-gateway._tcp.local.")
+ browseWithDotBuilder := dnsmessage.NewBuilder(nil, dnsmessage.Header{ID: 76})
+ _ = browseWithDotBuilder.StartQuestions()
+ _ = browseWithDotBuilder.Question(dnsmessage.Question{Name: svcQName, Type: dnsmessage.TypePTR, Class: dnsmessage.ClassINET})
+ _ = browseWithDotBuilder.StartAnswers()
+ _ = browseWithDotBuilder.PTRResource(dnsmessage.ResourceHeader{
+ Name: svcQName,
+ Type: dnsmessage.TypePTR,
+ Class: dnsmessage.ClassINET,
+ TTL: mdnsServiceRecordTTL,
+ }, dnsmessage.PTRResource{PTR: knownDotTarget})
+ rawBrowseWithDot, errBrowseDot := browseWithDotBuilder.Finish()
+ if errBrowseDot != nil {
+ t.Fatalf("browseWithDotBuilder.Finish(): %v", errBrowseDot)
+ }
+ wireBrowseWithDot := rewriteWireLabelChar(rawBrowseWithDot, dotInLabelSentinel, '.')
+ if !bytes.Contains(wireBrowseWithDot, []byte("\x0eoffice.v1-1234")) {
+ t.Fatal("expected single-label dotted PTR target in test query wire packet")
+ }
+ browseDotReply, _ := buildServiceQueryReply(wireBrowseWithDot, svcCfg, "cpa-8f3b.local.", []string{"192.0.2.10"}, true)
+ if !hasHostAddressRecord(browseDotReply, "cpa-8f3b.local.", false) {
+ t.Fatal("expected browse query with dotted foreign Known-Answer PTR to still return CPA-8F3B answer")
+ }
+ otherDotCfg := parseMDNSServiceConfig("office.v1-1234", DefaultServiceType, DefaultDomain, 8317, []string{"product=cliproxyapi"})
+ if suppressedReply, _ := buildServiceQueryReply(wireBrowseWithDot, otherDotCfg, "cpa-office-v1-1234.local.", []string{"192.0.2.10"}, true); len(suppressedReply) != 0 {
+ t.Fatal("expected Known-Answer Suppression to suppress matching dotted instance PTR")
+ }
+ instQName, _ := dnsmessage.NewName("CPA-8F3B._ai-gateway._tcp.local.")
+ instQBuilder := dnsmessage.NewBuilder(nil, dnsmessage.Header{ID: 77})
+ _ = instQBuilder.StartQuestions()
+ _ = instQBuilder.Question(dnsmessage.Question{Name: instQName, Type: dnsmessage.TypeSRV, Class: dnsmessage.ClassINET})
+ instQueryPkt, errInstQ := instQBuilder.Finish()
+ if errInstQ != nil {
+ t.Fatalf("instQBuilder.Finish(): %v", errInstQ)
+ }
+ svcReply, svcUnicast := buildServiceQueryReply(instQueryPkt, svcCfg, "cpa-8f3b.local.", []string{"192.0.2.10"}, true)
+ if !svcUnicast || !hasHostAddressRecord(svcReply, "cpa-8f3b.local.", false) {
+ t.Fatal("expected unified service query reply to include SRV and host address records")
+ }
+ if isOnLinkSource(&net.UDPAddr{IP: net.IPv4(203, 0, 113, 99), Port: 54321}, 1, ifaces, localIPs) {
+ t.Fatal("expected off-link query source 203.0.113.99 to be rejected by isOnLinkSource")
+ }
+
+ // Multicast conflict response from a peer on the same L2 link with a different IP subnet must still trigger conflict resolution.
+ mcastSubResp := &hostRecordResponder{
+ baseHost: "cpa-8f3b",
+ osHost: "devbox",
+ domain: DefaultDomain,
+ attempt: 1,
+ hostFQDN: "cpa-8f3b.local.",
+ fixedIPs: []string{"192.0.2.10"},
+ ifaces: ifaces,
+ probeConflict: func(context.Context, string, []net.Interface, []string) (bool, error) {
+ return false, nil
+ },
+ stopCh: make(chan struct{}),
+ }
+ mcastSubResp.handlePacketWithMeta(conflictRespPkt, 1, &net.UDPAddr{IP: net.IPv4(198, 51, 100, 77), Port: mdnsPort}, true)
+ if mcastSubResp.currentHostFQDN() != "cpa-8f3b-2.local." {
+ t.Fatalf("expected multicast conflict from different subnet on same link to rename to cpa-8f3b-2.local., got %q", mcastSubResp.currentHostFQDN())
+ }
+
+ // Multi-question query containing both host A and service PTR/SRV questions (and mixed QU/QM questions)
+ // must produce separate replies preserving each question's unicast/multicast mode.
+ enumQName, _ := dnsmessage.NewName("_services._dns-sd._udp.local.")
+ mixedBuilder := dnsmessage.NewBuilder(nil, dnsmessage.Header{ID: 88})
+ _ = mixedBuilder.StartQuestions()
+ _ = mixedBuilder.Question(dnsmessage.Question{Name: qName, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET})
+ _ = mixedBuilder.Question(dnsmessage.Question{Name: enumQName, Type: dnsmessage.TypePTR, Class: dnsmessage.ClassINET | dnsmessage.Class(mdnsCacheFlushBit)})
+ _ = mixedBuilder.Question(dnsmessage.Question{Name: svcQName, Type: dnsmessage.TypePTR, Class: dnsmessage.ClassINET})
+ mixedQueryPkt, errMixed := mixedBuilder.Finish()
+ if errMixed != nil {
+ t.Fatalf("mixedBuilder.Finish(): %v", errMixed)
+ }
+ combinedReplies := buildCombinedQueryReplies(mixedQueryPkt, svcCfg, "cpa-8f3b.local.", []string{"192.0.2.10"}, false)
+ if len(combinedReplies) != 3 {
+ t.Fatalf("expected 3 replies (QM host + QM service + QU service) for mixed QU/QM query, got %d", len(combinedReplies))
+ }
+ if combinedReplies[0].unicast || combinedReplies[1].unicast || !combinedReplies[2].unicast {
+ t.Fatalf("expected [multicast, multicast, unicast] replies, got [%v, %v, %v]", combinedReplies[0].unicast, combinedReplies[1].unicast, combinedReplies[2].unicast)
+ }
+
+ // Repeated announcement schedule (RFC 6762 §8.3) and cancellation on stopCh via scheduleServiceAnnouncements.
+ schedResp := &hostRecordResponder{
+ svc: svcCfg,
+ hostFQDN: "cpa-8f3b.local.",
+ stopCh: make(chan struct{}),
+ }
+ var annCount atomic.Int32
+ secondSent := make(chan struct{})
+ schedResp.scheduleServiceAnnouncements(mdnsAnnounceRepetitions, 5*time.Millisecond, func() {
+ if annCount.Add(1) == 2 {
+ close(secondSent)
+ }
+ })
+ <-secondSent
+ schedResp.wg.Wait()
+ if got := int(annCount.Load()); got != mdnsAnnounceRepetitions {
+ t.Fatalf("scheduleServiceAnnouncements count = %d, want %d", got, mdnsAnnounceRepetitions)
+ }
+
+ cancelResp := &hostRecordResponder{
+ svc: svcCfg,
+ hostFQDN: "cpa-8f3b.local.",
+ stopCh: make(chan struct{}),
+ }
+ var cancelCount atomic.Int32
+ cancelResp.scheduleServiceAnnouncements(mdnsAnnounceRepetitions, time.Hour, func() {
+ cancelCount.Add(1)
+ })
+ if got := int(cancelCount.Load()); got != 1 {
+ t.Fatalf("initial synchronous announcement count = %d, want 1", got)
+ }
+ close(cancelResp.stopCh)
+ cancelResp.wg.Wait()
+ if got := int(cancelCount.Load()); got != 1 {
+ t.Fatalf("canceled scheduleServiceAnnouncements count = %d, want 1", got)
+ }
+
+ // Stale host send fence: a send prepared under an older hostFQDN must be dropped
+ // once handleConflict rotates the responder to a new hostFQDN.
+ var sentFQDNs []string
+ fenceResp := &hostRecordResponder{
+ baseHost: "cpa-8f3b",
+ osHost: "devbox",
+ domain: DefaultDomain,
+ attempt: 1,
+ hostFQDN: "cpa-8f3b.local.",
+ fixedIPs: []string{"192.0.2.10"},
+ ifaces: ifaces,
+ probeConflict: func(context.Context, string, []net.Interface, []string) (bool, error) {
+ return false, nil
+ },
+ onSend: func(fqdn string, _ bool, _ []byte) {
+ sentFQDNs = append(sentFQDNs, fqdn)
+ },
+ stopCh: make(chan struct{}),
+ }
+ staleFQDN := fenceResp.currentHostFQDN()
+ fenceResp.handlePacketWithMeta(conflictRespPkt, 1, onLinkPeer, true)
+ if fenceResp.currentHostFQDN() != "cpa-8f3b-2.local." {
+ t.Fatalf("expected rename to cpa-8f3b-2.local., got %q", fenceResp.currentHostFQDN())
+ }
+ if fenceResp.sendIfHostCurrent(staleFQDN, false, []byte{1, 2, 3}, 1, nil) {
+ t.Fatal("expected sendIfHostCurrent to reject stale hostFQDN after rename")
+ }
+ if !fenceResp.sendIfHostCurrent("cpa-8f3b-2.local.", false, []byte{1, 2, 3}, 1, nil) {
+ t.Fatal("expected sendIfHostCurrent to accept current hostFQDN")
+ }
+ if len(sentFQDNs) != 1 || sentFQDNs[0] != "cpa-8f3b-2.local." {
+ t.Fatalf("sentFQDNs = %v, want [cpa-8f3b-2.local.]", sentFQDNs)
+ }
+
+ // Duplicate questions in a single query packet must be deduplicated in linear time.
+ dupBuilder := dnsmessage.NewBuilder(nil, dnsmessage.Header{ID: 99})
+ _ = dupBuilder.StartQuestions()
+ for i := 0; i < 200; i++ {
+ _ = dupBuilder.Question(dnsmessage.Question{Name: svcQName, Type: dnsmessage.TypePTR, Class: dnsmessage.ClassINET})
+ }
+ dupQuery, errDup := dupBuilder.Finish()
+ if errDup != nil {
+ t.Fatalf("dupBuilder.Finish(): %v", errDup)
+ }
+ dupReply, _ := buildServiceQueryReply(dupQuery, svcCfg, "cpa-8f3b.local.", []string{"192.0.2.10"}, true)
+ var dupParser dnsmessage.Parser
+ if _, errDP := dupParser.Start(dupReply); errDP != nil {
+ t.Fatalf("dupParser.Start: %v", errDP)
+ }
+ dupQs, _ := dupParser.AllQuestions()
+ if len(dupQs) != 1 {
+ t.Fatalf("expected 200 duplicate questions to be deduplicated to 1, got %d", len(dupQs))
+ }
+}
+
+func TestNewHostRecordResponder_FailsAndCleansUpWhenAllMulticastJoinsFail(t *testing.T) {
+ inner := &fakeZeroconfServer{}
+ resp, err := newHostRecordResponder(inner, "cpa-8f3b", DefaultDomain, []string{"192.0.2.10"}, []net.Interface{{Index: -1, Name: "invalid0"}})
+ if err == nil || resp != nil {
+ if resp != nil {
+ resp.Shutdown()
+ }
+ t.Fatalf("expected error when all multicast joins fail, got resp=%v err=%v", resp, err)
+ }
+ if inner.shutdowns != 1 {
+ t.Fatalf("inner shutdowns = %d, want 1", inner.shutdowns)
+ }
}
diff --git a/internal/discovery/service.go b/internal/discovery/service.go
index 6b4180318da..874c71c0b8b 100644
--- a/internal/discovery/service.go
+++ b/internal/discovery/service.go
@@ -232,6 +232,9 @@ func BuildServiceSpec(cfg *config.Config, port int, tlsEnabled bool) (ServiceSpe
filtered = append(filtered, advertisedIP)
}
}
+ if len(filtered) == 0 {
+ filtered = append(filtered, bindIP.String())
+ }
advertisedIPs = filtered
}
if len(advertisedIPs) == 0 {
diff --git a/internal/discovery/zeroconf.go b/internal/discovery/zeroconf.go
index a08a72860d7..df95b1f81f8 100644
--- a/internal/discovery/zeroconf.go
+++ b/internal/discovery/zeroconf.go
@@ -1,45 +1,2445 @@
package discovery
import (
+ "bytes"
"context"
+ "crypto/rand"
+ "encoding/binary"
"fmt"
"net"
"os"
+ "runtime"
+ "sort"
"strings"
"sync"
+ "sync/atomic"
+ "time"
"github.com/libp2p/zeroconf/v2"
log "github.com/sirupsen/logrus"
+ "golang.org/x/net/dns/dnsmessage"
+ "golang.org/x/net/ipv4"
+ "golang.org/x/net/ipv6"
)
+const (
+ maxDNSLabelBytes = 63
+ maxHostCollisionAttempts = 8
+ mdnsCacheFlushBit = 1 << 15
+ mdnsServiceRecordTTL = 3200
+ mdnsHostRecordTTL = 120
+ mdnsLegacyRecordTTL = 10
+ mdnsPort = 5353
+ mdnsHostProbeCount = 3
+ mdnsHostProbeInterval = 250 * time.Millisecond
+ mdnsAnnounceRepetitions = 2
+ mdnsAnnounceInterval = time.Second
+ dnsTypeNSEC = dnsmessage.Type(47)
+)
+
+var (
+ mdnsGroupAddrIPv4 = &net.UDPAddr{IP: net.IPv4(224, 0, 0, 251), Port: mdnsPort}
+ mdnsGroupAddrIPv6 = &net.UDPAddr{IP: net.ParseIP("ff02::fb"), Port: mdnsPort}
+ mdnsWildcardAddrIPv4 = &net.UDPAddr{IP: net.IPv4(224, 0, 0, 0), Port: mdnsPort}
+ mdnsWildcardAddrIPv6 = &net.UDPAddr{IP: net.ParseIP("ff02::"), Port: mdnsPort}
+)
+
+type zeroconfServer interface {
+ Shutdown()
+}
+
+type registerProxyFunc func(instance, service, domain string, port int, host string, ips []string, text []string, ifaces []net.Interface, opts ...zeroconf.ServerOption) (zeroconfServer, error)
+type hostConflictProbeFunc func(ctx context.Context, hostFQDN string, ifaces []net.Interface, localIPs []string) (bool, error)
+
+func validateRegisterProxyArgs(instance, service, domain string, port int, host string, ips []string) error {
+ if strings.TrimSpace(instance) == "" {
+ return fmt.Errorf("missing service instance name")
+ }
+ if strings.TrimSpace(service) == "" {
+ return fmt.Errorf("missing service name")
+ }
+ if strings.TrimSpace(host) == "" {
+ return fmt.Errorf("missing host name")
+ }
+ if port <= 0 || port > 65535 {
+ return fmt.Errorf("invalid port %d", port)
+ }
+ for _, ip := range ips {
+ if net.ParseIP(strings.TrimSpace(ip)) == nil {
+ return fmt.Errorf("failed to parse given IP: %v", ip)
+ }
+ }
+ return nil
+}
+
+func defaultRegisterProxy(instance, service, domain string, port int, host string, ips []string, text []string, ifaces []net.Interface, _ ...zeroconf.ServerOption) (zeroconfServer, error) {
+ if errValid := validateRegisterProxyArgs(instance, service, domain, port, host, ips); errValid != nil {
+ return nil, errValid
+ }
+ resp, err := newHostRecordResponderWithRebind(nil, host, domain, ips, ifaces, nil)
+ if err != nil {
+ return nil, err
+ }
+ if hr, ok := resp.(*hostRecordResponder); ok && hr != nil {
+ hr.configureService(instance, service, domain, port, text)
+ hr.announceService()
+ }
+ return resp, nil
+}
+
+func formatHostFQDN(host, domain string) string {
+ h := strings.TrimSuffix(strings.TrimSpace(host), ".")
+ d := strings.Trim(strings.TrimSpace(domain), ".")
+ if d == "" {
+ d = "local"
+ }
+ if !strings.HasSuffix(strings.ToLower(h), "."+strings.ToLower(d)) {
+ h = h + "." + d
+ }
+ return h + "."
+}
+
+func splitIPv4AndIPv6(ips []string) (v4 [][4]byte, v6 [][16]byte) {
+ for _, raw := range ips {
+ ip := net.ParseIP(strings.TrimSpace(raw))
+ if ip == nil || ip.IsLoopback() || ip.IsUnspecified() {
+ continue
+ }
+ if ip4 := ip.To4(); ip4 != nil {
+ v4 = append(v4, [4]byte(ip4))
+ continue
+ }
+ if ip16 := ip.To16(); ip16 != nil {
+ v6 = append(v6, [16]byte(ip16))
+ }
+ }
+ return v4, v6
+}
+
+func packWireDomainName(fqdn string) []byte {
+ trimmed := strings.Trim(strings.TrimSpace(fqdn), ".")
+ if trimmed == "" {
+ return []byte{0}
+ }
+ parts := strings.Split(trimmed, ".")
+ var out []byte
+ for _, part := range parts {
+ if len(part) == 0 || len(part) > maxDNSLabelBytes {
+ return nil
+ }
+ out = append(out, byte(len(part)))
+ out = append(out, part...)
+ }
+ out = append(out, 0)
+ return out
+}
+
+func buildHostNSECRData(hostFQDN string, hasA, hasAAAA bool) []byte {
+ wireName := packWireDomainName(hostFQDN)
+ if len(wireName) == 0 || (!hasA && !hasAAAA) {
+ return nil
+ }
+ var bitmap [4]byte
+ bitmapLen := 0
+ if hasA {
+ bitmap[0] |= 1 << (7 - 1) // TypeA = 1
+ bitmapLen = 1
+ }
+ if hasAAAA {
+ bitmap[3] |= 1 << (7 - (28 - 24)) // TypeAAAA = 28
+ bitmapLen = 4
+ }
+ out := append(wireName, 0x00, byte(bitmapLen))
+ return append(out, bitmap[:bitmapLen]...)
+}
+
+func buildHostAddressReply(query []byte, hostFQDN string, ips []string, legacyUnicast bool) (reply []byte, unicast bool) {
+ return buildHostAddressReplyFiltered(query, hostFQDN, ips, legacyUnicast, nil)
+}
+
+func buildHostAddressReplyFiltered(query []byte, hostFQDN string, ips []string, legacyUnicast bool, onlyQU *bool) (reply []byte, unicast bool) {
+ normQuery := rewriteWireLabelChar(query, '.', dotInLabelSentinel)
+ var p dnsmessage.Parser
+ hdr, err := p.Start(normQuery)
+ if err != nil || hdr.Response {
+ return nil, false
+ }
+ questions, errQ := p.AllQuestions()
+ if errQ != nil || len(questions) == 0 {
+ return nil, false
+ }
+ if errSkipAns := p.SkipAllAnswers(); errSkipAns != nil {
+ return nil, false
+ }
+ if errSkipAuth := p.SkipAllAuthorities(); errSkipAuth != nil {
+ return nil, false
+ }
+
+ var wantA, wantAAAA, extraA, extraAAAA bool
+ var matchedQuestions []dnsmessage.Question
+ seenQ := make(map[string]struct{}, len(questions))
+ for _, q := range questions {
+ isQU := uint16(q.Class)&mdnsCacheFlushBit != 0
+ if onlyQU != nil && isQU != *onlyQU {
+ continue
+ }
+ if !strings.EqualFold(q.Name.String(), hostFQDN) {
+ continue
+ }
+ baseClass := uint16(q.Class) &^ mdnsCacheFlushBit
+ if baseClass != uint16(dnsmessage.ClassINET) && baseClass != uint16(dnsmessage.ClassANY) {
+ continue
+ }
+ switch q.Type {
+ case dnsmessage.TypeA:
+ wantA = true
+ extraAAAA = true
+ case dnsmessage.TypeAAAA:
+ wantAAAA = true
+ extraA = true
+ case dnsmessage.TypeALL:
+ wantA = true
+ wantAAAA = true
+ default:
+ continue
+ }
+ qKey := fmt.Sprintf("%s\x00%d\x00%d", strings.ToLower(q.Name.String()), q.Type, baseClass)
+ if _, dup := seenQ[qKey]; !dup {
+ seenQ[qKey] = struct{}{}
+ matchedQuestions = append(matchedQuestions, dnsmessage.Question{
+ Name: q.Name,
+ Type: q.Type,
+ Class: dnsmessage.Class(baseClass),
+ })
+ }
+ if isQU {
+ unicast = true
+ }
+ }
+ if !wantA && !wantAAAA {
+ return nil, false
+ }
+ if legacyUnicast {
+ unicast = true
+ }
+
+ v4, v6 := splitIPv4AndIPv6(ips)
+ if len(v4) == 0 && len(v6) == 0 {
+ return nil, false
+ }
+ hasAnswerA := wantA && len(v4) > 0
+ hasAnswerAAAA := wantAAAA && len(v6) > 0
+ needNSEC := (wantA && len(v4) == 0) || (wantAAAA && len(v6) == 0)
+
+ name, errName := dnsmessage.NewName(hostFQDN)
+ if errName != nil {
+ return nil, false
+ }
+ rrClass := dnsmessage.ClassINET | dnsmessage.Class(mdnsCacheFlushBit)
+ ttl := uint32(mdnsHostRecordTTL)
+ if legacyUnicast {
+ rrClass = dnsmessage.ClassINET
+ ttl = mdnsLegacyRecordTTL
+ }
+ builder := dnsmessage.NewBuilder(nil, dnsmessage.Header{
+ ID: hdr.ID,
+ Response: true,
+ Authoritative: true,
+ })
+ builder.EnableCompression()
+ if legacyUnicast && len(matchedQuestions) > 0 {
+ if errStartQ := builder.StartQuestions(); errStartQ != nil {
+ return nil, false
+ }
+ for _, mq := range matchedQuestions {
+ if errQAdd := builder.Question(mq); errQAdd != nil {
+ return nil, false
+ }
+ }
+ }
+ if errAns := builder.StartAnswers(); errAns != nil {
+ return nil, false
+ }
+ if hasAnswerA {
+ for _, addr := range v4 {
+ if errAdd := builder.AResource(dnsmessage.ResourceHeader{
+ Name: name,
+ Type: dnsmessage.TypeA,
+ Class: rrClass,
+ TTL: ttl,
+ }, dnsmessage.AResource{A: addr}); errAdd != nil {
+ return nil, false
+ }
+ }
+ }
+ if hasAnswerAAAA {
+ for _, addr := range v6 {
+ if errAdd := builder.AAAAResource(dnsmessage.ResourceHeader{
+ Name: name,
+ Type: dnsmessage.TypeAAAA,
+ Class: rrClass,
+ TTL: ttl,
+ }, dnsmessage.AAAAResource{AAAA: addr}); errAdd != nil {
+ return nil, false
+ }
+ }
+ }
+ if needNSEC {
+ nsecData := buildHostNSECRData(hostFQDN, len(v4) > 0, len(v6) > 0)
+ if len(nsecData) == 0 {
+ return nil, false
+ }
+ if errNSEC := builder.UnknownResource(dnsmessage.ResourceHeader{
+ Name: name,
+ Type: dnsTypeNSEC,
+ Class: rrClass,
+ TTL: ttl,
+ }, dnsmessage.UnknownResource{
+ Type: dnsTypeNSEC,
+ Data: nsecData,
+ }); errNSEC != nil {
+ return nil, false
+ }
+ }
+
+ addExtraA := !hasAnswerA && extraA && len(v4) > 0
+ addExtraAAAA := !hasAnswerAAAA && extraAAAA && len(v6) > 0
+ if addExtraA || addExtraAAAA {
+ if errExt := builder.StartAdditionals(); errExt != nil {
+ return nil, false
+ }
+ if addExtraA {
+ for _, addr := range v4 {
+ _ = builder.AResource(dnsmessage.ResourceHeader{
+ Name: name,
+ Type: dnsmessage.TypeA,
+ Class: rrClass,
+ TTL: ttl,
+ }, dnsmessage.AResource{A: addr})
+ }
+ }
+ if addExtraAAAA {
+ for _, addr := range v6 {
+ _ = builder.AAAAResource(dnsmessage.ResourceHeader{
+ Name: name,
+ Type: dnsmessage.TypeAAAA,
+ Class: rrClass,
+ TTL: ttl,
+ }, dnsmessage.AAAAResource{AAAA: addr})
+ }
+ }
+ }
+
+ out, errFinish := builder.Finish()
+ if errFinish != nil {
+ return nil, false
+ }
+ return rewriteWireLabelChar(out, dotInLabelSentinel, '.'), unicast
+}
+
+func buildHostGoodbyePacket(hostFQDN string, ips []string) []byte {
+ v4, v6 := splitIPv4AndIPv6(ips)
+ if len(v4) == 0 && len(v6) == 0 {
+ return nil
+ }
+ name, errName := dnsmessage.NewName(hostFQDN)
+ if errName != nil {
+ return nil
+ }
+ rrClass := dnsmessage.ClassINET | dnsmessage.Class(mdnsCacheFlushBit)
+ builder := dnsmessage.NewBuilder(nil, dnsmessage.Header{
+ Response: true,
+ Authoritative: true,
+ })
+ builder.EnableCompression()
+ if errAns := builder.StartAnswers(); errAns != nil {
+ return nil
+ }
+ for _, addr := range v4 {
+ if errAdd := builder.AResource(dnsmessage.ResourceHeader{
+ Name: name,
+ Type: dnsmessage.TypeA,
+ Class: rrClass,
+ TTL: 0,
+ }, dnsmessage.AResource{A: addr}); errAdd != nil {
+ return nil
+ }
+ }
+ for _, addr := range v6 {
+ if errAdd := builder.AAAAResource(dnsmessage.ResourceHeader{
+ Name: name,
+ Type: dnsmessage.TypeAAAA,
+ Class: rrClass,
+ TTL: 0,
+ }, dnsmessage.AAAAResource{AAAA: addr}); errAdd != nil {
+ return nil
+ }
+ }
+ out, errFinish := builder.Finish()
+ if errFinish != nil {
+ return nil
+ }
+ return out
+}
+
+const dotInLabelSentinel byte = '\x01'
+
+type mdnsServiceConfig struct {
+ serviceTypeName string
+ serviceName string
+ instanceName string
+ subtypes []string
+ port uint16
+ text []string
+}
+
+func rewriteWireName(buf []byte, off int, fromByte, toByte byte) int {
+ for off < len(buf) {
+ b := buf[off]
+ if b == 0 {
+ return off + 1
+ }
+ if b&0xC0 == 0xC0 {
+ if off+2 > len(buf) {
+ return -1
+ }
+ return off + 2
+ }
+ if b >= 64 {
+ return -1
+ }
+ end := off + 1 + int(b)
+ if end > len(buf) {
+ return -1
+ }
+ for i := off + 1; i < end; i++ {
+ if buf[i] == fromByte {
+ buf[i] = toByte
+ }
+ }
+ off = end
+ }
+ return -1
+}
+
+func rewriteWireLabelChar(pkt []byte, fromByte, toByte byte) []byte {
+ if len(pkt) < 12 {
+ return pkt
+ }
+ buf := bytes.Clone(pkt)
+ qd := int(binary.BigEndian.Uint16(buf[4:6]))
+ an := int(binary.BigEndian.Uint16(buf[6:8]))
+ ns := int(binary.BigEndian.Uint16(buf[8:10]))
+ ar := int(binary.BigEndian.Uint16(buf[10:12]))
+ off := 12
+ for i := 0; i < qd; i++ {
+ off = rewriteWireName(buf, off, fromByte, toByte)
+ if off < 0 || off+4 > len(buf) {
+ return buf
+ }
+ off += 4
+ }
+ totalRR := an + ns + ar
+ for i := 0; i < totalRR; i++ {
+ off = rewriteWireName(buf, off, fromByte, toByte)
+ if off < 0 || off+10 > len(buf) {
+ return buf
+ }
+ rrType := dnsmessage.Type(binary.BigEndian.Uint16(buf[off : off+2]))
+ rdLen := int(binary.BigEndian.Uint16(buf[off+8 : off+10]))
+ rdataStart := off + 10
+ rdataEnd := rdataStart + rdLen
+ if rdataEnd > len(buf) {
+ return buf
+ }
+ switch rrType {
+ case dnsmessage.TypePTR, dnsmessage.TypeCNAME, dnsmessage.TypeNS:
+ _ = rewriteWireName(buf[:rdataEnd], rdataStart, fromByte, toByte)
+ case dnsmessage.TypeSRV:
+ if rdLen >= 6 {
+ _ = rewriteWireName(buf[:rdataEnd], rdataStart+6, fromByte, toByte)
+ }
+ }
+ off = rdataEnd
+ }
+ return buf
+}
+
+func parseMDNSServiceConfig(instance, service, domain string, port int, text []string) mdnsServiceConfig {
+ cleanDom := strings.Trim(strings.TrimSpace(domain), ".")
+ if cleanDom == "" {
+ cleanDom = "local"
+ }
+ parts := strings.Split(service, ",")
+ baseSvc := strings.Trim(strings.TrimSpace(parts[0]), ".")
+ svcName := baseSvc + "." + cleanDom + "."
+ cleanInst := strings.ReplaceAll(strings.Trim(strings.TrimSpace(instance), "."), ".", string(dotInLabelSentinel))
+ var subs []string
+ for _, sub := range parts[1:] {
+ s := strings.Trim(strings.TrimSpace(sub), ".")
+ if s != "" {
+ subs = append(subs, s+"._sub."+svcName)
+ }
+ }
+ return mdnsServiceConfig{
+ serviceTypeName: "_services._dns-sd._udp." + cleanDom + ".",
+ serviceName: svcName,
+ instanceName: cleanInst + "." + svcName,
+ subtypes: subs,
+ port: uint16(port),
+ text: append([]string(nil), text...),
+ }
+}
+
+func appendHostAddrResources(builder *dnsmessage.Builder, hostName dnsmessage.Name, rrClass dnsmessage.Class, ttl uint32, v4 [][4]byte, v6 [][16]byte) error {
+ for _, addr := range v4 {
+ if err := builder.AResource(dnsmessage.ResourceHeader{
+ Name: hostName,
+ Type: dnsmessage.TypeA,
+ Class: rrClass,
+ TTL: ttl,
+ }, dnsmessage.AResource{A: addr}); err != nil {
+ return err
+ }
+ }
+ for _, addr := range v6 {
+ if err := builder.AAAAResource(dnsmessage.ResourceHeader{
+ Name: hostName,
+ Type: dnsmessage.TypeAAAA,
+ Class: rrClass,
+ TTL: ttl,
+ }, dnsmessage.AAAAResource{AAAA: addr}); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func buildServicePacket(svc mdnsServiceConfig, hostFQDN string, ips []string, ttl uint32) []byte {
+ v4, v6 := splitIPv4AndIPv6(ips)
+ if svc.serviceName == "" {
+ if ttl == 0 {
+ return buildHostGoodbyePacket(hostFQDN, ips)
+ }
+ return nil
+ }
+ svcTypeName, err1 := dnsmessage.NewName(svc.serviceTypeName)
+ svcName, err2 := dnsmessage.NewName(svc.serviceName)
+ instName, err3 := dnsmessage.NewName(svc.instanceName)
+ hostName, err4 := dnsmessage.NewName(hostFQDN)
+ if err1 != nil || err2 != nil || err3 != nil || err4 != nil {
+ return nil
+ }
+ var subNames []dnsmessage.Name
+ for _, sub := range svc.subtypes {
+ if sn, errS := dnsmessage.NewName(sub); errS == nil {
+ subNames = append(subNames, sn)
+ }
+ }
+
+ svcTTL := ttl
+ hostTTL := ttl
+ if ttl > 0 {
+ svcTTL = mdnsServiceRecordTTL
+ hostTTL = mdnsHostRecordTTL
+ }
+ flushClass := dnsmessage.ClassINET | dnsmessage.Class(mdnsCacheFlushBit)
+ builder := dnsmessage.NewBuilder(nil, dnsmessage.Header{
+ Response: true,
+ Authoritative: true,
+ })
+ builder.EnableCompression()
+ if errAns := builder.StartAnswers(); errAns != nil {
+ return nil
+ }
+ if err := builder.SRVResource(dnsmessage.ResourceHeader{
+ Name: instName,
+ Type: dnsmessage.TypeSRV,
+ Class: flushClass,
+ TTL: svcTTL,
+ }, dnsmessage.SRVResource{Port: svc.port, Target: hostName}); err != nil {
+ return nil
+ }
+ if err := builder.TXTResource(dnsmessage.ResourceHeader{
+ Name: instName,
+ Type: dnsmessage.TypeTXT,
+ Class: flushClass,
+ TTL: svcTTL,
+ }, dnsmessage.TXTResource{TXT: svc.text}); err != nil {
+ return nil
+ }
+ if err := builder.PTRResource(dnsmessage.ResourceHeader{
+ Name: svcName,
+ Type: dnsmessage.TypePTR,
+ Class: dnsmessage.ClassINET,
+ TTL: svcTTL,
+ }, dnsmessage.PTRResource{PTR: instName}); err != nil {
+ return nil
+ }
+ if err := builder.PTRResource(dnsmessage.ResourceHeader{
+ Name: svcTypeName,
+ Type: dnsmessage.TypePTR,
+ Class: dnsmessage.ClassINET,
+ TTL: svcTTL,
+ }, dnsmessage.PTRResource{PTR: svcName}); err != nil {
+ return nil
+ }
+ for _, sn := range subNames {
+ if err := builder.PTRResource(dnsmessage.ResourceHeader{
+ Name: sn,
+ Type: dnsmessage.TypePTR,
+ Class: dnsmessage.ClassINET,
+ TTL: svcTTL,
+ }, dnsmessage.PTRResource{PTR: instName}); err != nil {
+ return nil
+ }
+ }
+ if err := appendHostAddrResources(&builder, hostName, flushClass, hostTTL, v4, v6); err != nil {
+ return nil
+ }
+ out, errFinish := builder.Finish()
+ if errFinish != nil {
+ return nil
+ }
+ return rewriteWireLabelChar(out, dotInLabelSentinel, '.')
+}
+
+func ptrKnownKey(ownerName, targetName string) string {
+ return strings.ToLower(ownerName) + "\x00" + strings.ToLower(targetName)
+}
+
+func collectKnownPTRAnswers(p *dnsmessage.Parser, replyTTL uint32) (map[string]struct{}, error) {
+ var known map[string]struct{}
+ for {
+ rh, errH := p.AnswerHeader()
+ if errH != nil {
+ if errH == dnsmessage.ErrSectionDone {
+ return known, nil
+ }
+ return nil, errH
+ }
+ if rh.Type != dnsmessage.TypePTR || rh.TTL < replyTTL/2 {
+ if errSkip := p.SkipAnswer(); errSkip != nil {
+ return nil, errSkip
+ }
+ continue
+ }
+ ptr, errPTR := p.PTRResource()
+ if errPTR != nil {
+ return nil, errPTR
+ }
+ if known == nil {
+ known = make(map[string]struct{})
+ }
+ known[ptrKnownKey(rh.Name.String(), ptr.PTR.String())] = struct{}{}
+ }
+}
+
+func isKnownPTRAnswer(known map[string]struct{}, ownerName, targetName string) bool {
+ if len(known) == 0 {
+ return false
+ }
+ _, ok := known[ptrKnownKey(ownerName, targetName)]
+ return ok
+}
+
+func buildServiceQueryReply(query []byte, svc mdnsServiceConfig, hostFQDN string, ips []string, legacyUnicast bool) (reply []byte, unicast bool) {
+ return buildServiceQueryReplyFiltered(query, svc, hostFQDN, ips, legacyUnicast, nil)
+}
+
+func buildServiceQueryReplyFiltered(query []byte, svc mdnsServiceConfig, hostFQDN string, ips []string, legacyUnicast bool, onlyQU *bool) (reply []byte, unicast bool) {
+ if svc.serviceName == "" {
+ return nil, false
+ }
+ normQuery := rewriteWireLabelChar(query, '.', dotInLabelSentinel)
+ var p dnsmessage.Parser
+ hdr, err := p.Start(normQuery)
+ if err != nil || hdr.Response {
+ return nil, false
+ }
+ questions, errQ := p.AllQuestions()
+ if errQ != nil || len(questions) == 0 {
+ return nil, false
+ }
+
+ svcTTL := uint32(mdnsServiceRecordTTL)
+ hostTTL := uint32(mdnsHostRecordTTL)
+ rrClass := dnsmessage.ClassINET | dnsmessage.Class(mdnsCacheFlushBit)
+ if legacyUnicast {
+ svcTTL = mdnsLegacyRecordTTL
+ hostTTL = mdnsLegacyRecordTTL
+ rrClass = dnsmessage.ClassINET
+ unicast = true
+ }
+
+ knownPTRs, errKnown := collectKnownPTRAnswers(&p, svcTTL)
+ if errKnown != nil {
+ return nil, false
+ }
+
+ svcTypeName, err1 := dnsmessage.NewName(svc.serviceTypeName)
+ svcName, err2 := dnsmessage.NewName(svc.serviceName)
+ instName, err3 := dnsmessage.NewName(svc.instanceName)
+ hostName, err4 := dnsmessage.NewName(hostFQDN)
+ if err1 != nil || err2 != nil || err3 != nil || err4 != nil {
+ return nil, false
+ }
+
+ var matchedQuestions []dnsmessage.Question
+ var answerTypeEnum, answerBrowse, answerLookup bool
+ var browseSubtypes []dnsmessage.Name
+ seenSubtypes := make(map[string]struct{}, len(svc.subtypes))
+ seenQ := make(map[string]struct{}, len(questions))
+
+ for _, q := range questions {
+ isQU := uint16(q.Class)&mdnsCacheFlushBit != 0
+ if onlyQU != nil && isQU != *onlyQU {
+ continue
+ }
+ baseClass := uint16(q.Class) &^ mdnsCacheFlushBit
+ if baseClass != uint16(dnsmessage.ClassINET) && baseClass != uint16(dnsmessage.ClassANY) {
+ continue
+ }
+ qNameStr := q.Name.String()
+ switch {
+ case strings.EqualFold(qNameStr, svc.serviceTypeName):
+ if q.Type != dnsmessage.TypePTR && q.Type != dnsmessage.TypeALL {
+ continue
+ }
+ if isKnownPTRAnswer(knownPTRs, svc.serviceTypeName, svc.serviceName) {
+ continue
+ }
+ answerTypeEnum = true
+ case strings.EqualFold(qNameStr, svc.serviceName):
+ if q.Type != dnsmessage.TypePTR && q.Type != dnsmessage.TypeALL {
+ continue
+ }
+ if isKnownPTRAnswer(knownPTRs, svc.serviceName, svc.instanceName) {
+ continue
+ }
+ answerBrowse = true
+ case strings.EqualFold(qNameStr, svc.instanceName):
+ if q.Type != dnsmessage.TypeSRV && q.Type != dnsmessage.TypeTXT && q.Type != dnsmessage.TypePTR && q.Type != dnsmessage.TypeALL {
+ continue
+ }
+ answerLookup = true
+ default:
+ matchedSub := ""
+ for _, sub := range svc.subtypes {
+ if strings.EqualFold(qNameStr, sub) {
+ matchedSub = sub
+ break
+ }
+ }
+ if matchedSub == "" || (q.Type != dnsmessage.TypePTR && q.Type != dnsmessage.TypeALL) {
+ continue
+ }
+ if isKnownPTRAnswer(knownPTRs, matchedSub, svc.instanceName) {
+ continue
+ }
+ if _, dupSub := seenSubtypes[matchedSub]; !dupSub {
+ seenSubtypes[matchedSub] = struct{}{}
+ if sn, errSN := dnsmessage.NewName(matchedSub); errSN == nil {
+ browseSubtypes = append(browseSubtypes, sn)
+ }
+ }
+ }
+ qKey := fmt.Sprintf("%s\x00%d\x00%d", strings.ToLower(qNameStr), q.Type, baseClass)
+ if _, dup := seenQ[qKey]; !dup {
+ seenQ[qKey] = struct{}{}
+ matchedQuestions = append(matchedQuestions, dnsmessage.Question{
+ Name: q.Name,
+ Type: q.Type,
+ Class: dnsmessage.Class(baseClass),
+ })
+ }
+ if isQU {
+ unicast = true
+ }
+ }
+
+ if !answerTypeEnum && !answerBrowse && !answerLookup && len(browseSubtypes) == 0 {
+ return nil, false
+ }
+
+ v4, v6 := splitIPv4AndIPv6(ips)
+ builder := dnsmessage.NewBuilder(nil, dnsmessage.Header{
+ ID: hdr.ID,
+ Response: true,
+ Authoritative: true,
+ })
+ builder.EnableCompression()
+ if legacyUnicast && len(matchedQuestions) > 0 {
+ if errQ := builder.StartQuestions(); errQ != nil {
+ return nil, false
+ }
+ for _, mq := range matchedQuestions {
+ if errAddQ := builder.Question(mq); errAddQ != nil {
+ return nil, false
+ }
+ }
+ }
+ if errAns := builder.StartAnswers(); errAns != nil {
+ return nil, false
+ }
+
+ if answerTypeEnum {
+ if err := builder.PTRResource(dnsmessage.ResourceHeader{
+ Name: svcTypeName,
+ Type: dnsmessage.TypePTR,
+ Class: dnsmessage.ClassINET,
+ TTL: svcTTL,
+ }, dnsmessage.PTRResource{PTR: svcName}); err != nil {
+ return nil, false
+ }
+ }
+ if answerBrowse {
+ if err := builder.PTRResource(dnsmessage.ResourceHeader{
+ Name: svcName,
+ Type: dnsmessage.TypePTR,
+ Class: dnsmessage.ClassINET,
+ TTL: svcTTL,
+ }, dnsmessage.PTRResource{PTR: instName}); err != nil {
+ return nil, false
+ }
+ }
+ for _, sn := range browseSubtypes {
+ if err := builder.PTRResource(dnsmessage.ResourceHeader{
+ Name: sn,
+ Type: dnsmessage.TypePTR,
+ Class: dnsmessage.ClassINET,
+ TTL: svcTTL,
+ }, dnsmessage.PTRResource{PTR: instName}); err != nil {
+ return nil, false
+ }
+ }
+ if answerLookup {
+ if err := builder.SRVResource(dnsmessage.ResourceHeader{
+ Name: instName,
+ Type: dnsmessage.TypeSRV,
+ Class: rrClass,
+ TTL: svcTTL,
+ }, dnsmessage.SRVResource{Port: svc.port, Target: hostName}); err != nil {
+ return nil, false
+ }
+ if err := builder.TXTResource(dnsmessage.ResourceHeader{
+ Name: instName,
+ Type: dnsmessage.TypeTXT,
+ Class: rrClass,
+ TTL: svcTTL,
+ }, dnsmessage.TXTResource{TXT: svc.text}); err != nil {
+ return nil, false
+ }
+ if !answerBrowse {
+ _ = builder.PTRResource(dnsmessage.ResourceHeader{
+ Name: svcName,
+ Type: dnsmessage.TypePTR,
+ Class: dnsmessage.ClassINET,
+ TTL: svcTTL,
+ }, dnsmessage.PTRResource{PTR: instName})
+ }
+ if err := appendHostAddrResources(&builder, hostName, rrClass, hostTTL, v4, v6); err != nil {
+ return nil, false
+ }
+ } else if answerBrowse || len(browseSubtypes) > 0 {
+ if errExt := builder.StartAdditionals(); errExt != nil {
+ return nil, false
+ }
+ _ = builder.SRVResource(dnsmessage.ResourceHeader{
+ Name: instName,
+ Type: dnsmessage.TypeSRV,
+ Class: dnsmessage.ClassINET,
+ TTL: svcTTL,
+ }, dnsmessage.SRVResource{Port: svc.port, Target: hostName})
+ _ = builder.TXTResource(dnsmessage.ResourceHeader{
+ Name: instName,
+ Type: dnsmessage.TypeTXT,
+ Class: dnsmessage.ClassINET,
+ TTL: svcTTL,
+ }, dnsmessage.TXTResource{TXT: svc.text})
+ _ = appendHostAddrResources(&builder, hostName, dnsmessage.ClassINET, hostTTL, v4, v6)
+ }
+
+ out, errFinish := builder.Finish()
+ if errFinish != nil {
+ return nil, false
+ }
+ return rewriteWireLabelChar(out, dotInLabelSentinel, '.'), unicast
+}
+
+type hostRecordResponder struct {
+ mu sync.Mutex
+ inner zeroconfServer
+ svc mdnsServiceConfig
+ baseHost string
+ osHost string
+ domain string
+ attempt int
+ hostFQDN string
+ fixedIPs []string
+ ifaces []net.Interface
+ rebindInner func(string) (zeroconfServer, error)
+ probeConflict hostConflictProbeFunc
+ onSend func(hostFQDN string, unicast bool, pkt []byte)
+ inConflict bool
+ stopped bool
+ ipv4conn *ipv4.PacketConn
+ ipv6conn *ipv6.PacketConn
+ stopCh chan struct{}
+ wg sync.WaitGroup
+ shutdownOnce sync.Once
+}
+
+func (r *hostRecordResponder) configureService(instance, service, domain string, port int, text []string) {
+ r.mu.Lock()
+ r.svc = parseMDNSServiceConfig(instance, service, domain, port, text)
+ r.mu.Unlock()
+}
+
+func (r *hostRecordResponder) sendIfHostCurrent(expectedHostFQDN string, unicast bool, buf []byte, ifIndex int, from net.Addr) bool {
+ if len(buf) == 0 || expectedHostFQDN == "" {
+ return false
+ }
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.stopped || r.hostFQDN == "" || r.hostFQDN != expectedHostFQDN {
+ return false
+ }
+ if r.onSend != nil {
+ r.onSend(expectedHostFQDN, unicast, buf)
+ }
+ if unicast && from != nil {
+ r.sendUnicast(buf, ifIndex, from)
+ return true
+ }
+ r.sendMulticast(buf, ifIndex)
+ return true
+}
+
+func (r *hostRecordResponder) announceServiceOnce() {
+ r.mu.Lock()
+ if r.stopped || r.hostFQDN == "" || r.svc.serviceName == "" {
+ r.mu.Unlock()
+ return
+ }
+ svc := r.svc
+ hostFQDN := r.hostFQDN
+ r.mu.Unlock()
+
+ for _, iface := range r.ifaces {
+ ips := r.ipsForInterface(iface.Index)
+ if len(ips) == 0 {
+ continue
+ }
+ if pkt := buildServicePacket(svc, hostFQDN, ips, mdnsServiceRecordTTL); len(pkt) > 0 {
+ _ = r.sendIfHostCurrent(hostFQDN, false, pkt, iface.Index, nil)
+ }
+ }
+}
+
+func (r *hostRecordResponder) announceService() {
+ r.scheduleServiceAnnouncements(mdnsAnnounceRepetitions, mdnsAnnounceInterval, r.announceServiceOnce)
+}
+
+func (r *hostRecordResponder) scheduleServiceAnnouncements(repetitions int, initialDelay time.Duration, sendOnce func()) {
+ if repetitions < 1 || sendOnce == nil {
+ return
+ }
+ r.mu.Lock()
+ if r.stopped || r.hostFQDN == "" || r.svc.serviceName == "" {
+ r.mu.Unlock()
+ return
+ }
+ if repetitions > 1 {
+ r.wg.Add(1)
+ }
+ r.mu.Unlock()
+
+ sendOnce()
+ if repetitions <= 1 {
+ return
+ }
+ go func() {
+ defer r.wg.Done()
+ delay := initialDelay
+ for i := 1; i < repetitions; i++ {
+ timer := time.NewTimer(delay)
+ select {
+ case <-r.stopCh:
+ timer.Stop()
+ return
+ case <-timer.C:
+ sendOnce()
+ delay *= 2
+ }
+ }
+ }()
+}
+
+func newHostRecordResponder(inner zeroconfServer, host, domain string, fixedIPs []string, ifaces []net.Interface) (zeroconfServer, error) {
+ return newHostRecordResponderWithRebind(inner, host, domain, fixedIPs, ifaces, nil)
+}
+
+func newHostRecordResponderWithRebind(inner zeroconfServer, host, domain string, fixedIPs []string, ifaces []net.Interface, rebind func(string) (zeroconfServer, error)) (zeroconfServer, error) {
+ ipv4conn, err4 := openMDNSConn4(ifaces)
+ ipv6conn, err6 := openMDNSConn6(ifaces)
+ if ipv4conn == nil && ipv6conn == nil {
+ if inner != nil {
+ inner.Shutdown()
+ }
+ return nil, fmt.Errorf("discovery: failed to start host record responder (ipv4: %v, ipv6: %v)", err4, err6)
+ }
+ r := &hostRecordResponder{
+ inner: inner,
+ baseHost: host,
+ osHost: extractCleanHost(),
+ domain: domain,
+ attempt: 1,
+ hostFQDN: formatHostFQDN(host, domain),
+ fixedIPs: append([]string(nil), fixedIPs...),
+ ifaces: append([]net.Interface(nil), ifaces...),
+ rebindInner: rebind,
+ probeConflict: probeHostConflictOnLAN,
+ ipv4conn: ipv4conn,
+ ipv6conn: ipv6conn,
+ stopCh: make(chan struct{}),
+ }
+ if r.ipv4conn != nil {
+ r.wg.Add(1)
+ go r.recv4()
+ }
+ if r.ipv6conn != nil {
+ r.wg.Add(1)
+ go r.recv6()
+ }
+ return r, nil
+}
+
+func openMDNSConn4(ifaces []net.Interface) (*ipv4.PacketConn, error) {
+ udpConn, err := net.ListenUDP("udp4", mdnsWildcardAddrIPv4)
+ if err != nil {
+ return nil, err
+ }
+ pkConn := ipv4.NewPacketConn(udpConn)
+ _ = pkConn.SetControlMessage(ipv4.FlagInterface|ipv4.FlagDst, true)
+ joined := 0
+ var lastErr error
+ for i := range ifaces {
+ if errJoin := pkConn.JoinGroup(&ifaces[i], mdnsGroupAddrIPv4); errJoin == nil {
+ joined++
+ } else {
+ lastErr = errJoin
+ }
+ }
+ if joined == 0 {
+ _ = pkConn.Close()
+ if lastErr == nil {
+ lastErr = fmt.Errorf("no multicast interfaces joined")
+ }
+ return nil, lastErr
+ }
+ _ = pkConn.SetMulticastTTL(255)
+ return pkConn, nil
+}
+
+func openMDNSConn6(ifaces []net.Interface) (*ipv6.PacketConn, error) {
+ udpConn, err := net.ListenUDP("udp6", mdnsWildcardAddrIPv6)
+ if err != nil {
+ return nil, err
+ }
+ pkConn := ipv6.NewPacketConn(udpConn)
+ _ = pkConn.SetControlMessage(ipv6.FlagInterface|ipv6.FlagDst, true)
+ joined := 0
+ var lastErr error
+ for i := range ifaces {
+ if errJoin := pkConn.JoinGroup(&ifaces[i], mdnsGroupAddrIPv6); errJoin == nil {
+ joined++
+ } else {
+ lastErr = errJoin
+ }
+ }
+ if joined == 0 {
+ _ = pkConn.Close()
+ if lastErr == nil {
+ lastErr = fmt.Errorf("no multicast interfaces joined")
+ }
+ return nil, lastErr
+ }
+ _ = pkConn.SetMulticastHopLimit(255)
+ return pkConn, nil
+}
+
+func (r *hostRecordResponder) ifaceAllowed(ifIndex int) bool {
+ if ifIndex == 0 {
+ return true
+ }
+ for _, iface := range r.ifaces {
+ if iface.Index == ifIndex {
+ return true
+ }
+ }
+ return false
+}
+
+func (r *hostRecordResponder) ipsForInterface(ifIndex int) []string {
+ if len(r.fixedIPs) > 0 {
+ return r.fixedIPs
+ }
+ if ifIndex != 0 {
+ for _, iface := range r.ifaces {
+ if iface.Index == ifIndex {
+ addrs, err := iface.Addrs()
+ if err != nil {
+ return nil
+ }
+ return extractInterfaceAddrs(addrs)
+ }
+ }
+ if iface, err := net.InterfaceByIndex(ifIndex); err == nil && iface != nil {
+ if addrs, errAddrs := iface.Addrs(); errAddrs == nil {
+ return extractInterfaceAddrs(addrs)
+ }
+ }
+ return nil
+ }
+ return extractInterfaceIPs(r.ifaces)
+}
+
+func (r *hostRecordResponder) recv4() {
+ defer r.wg.Done()
+ buf := make([]byte, 65536)
+ for {
+ select {
+ case <-r.stopCh:
+ return
+ default:
+ n, cm, from, err := r.ipv4conn.ReadFrom(buf)
+ if err != nil {
+ continue
+ }
+ ifIndex := 0
+ isMulticastDst := false
+ if cm != nil {
+ ifIndex = cm.IfIndex
+ if len(cm.Dst) > 0 {
+ isMulticastDst = cm.Dst.IsMulticast()
+ }
+ }
+ r.handlePacketWithMeta(buf[:n], ifIndex, from, isMulticastDst)
+ }
+ }
+}
+
+func (r *hostRecordResponder) recv6() {
+ defer r.wg.Done()
+ buf := make([]byte, 65536)
+ for {
+ select {
+ case <-r.stopCh:
+ return
+ default:
+ n, cm, from, err := r.ipv6conn.ReadFrom(buf)
+ if err != nil {
+ continue
+ }
+ ifIndex := 0
+ isMulticastDst := false
+ if cm != nil {
+ ifIndex = cm.IfIndex
+ if len(cm.Dst) > 0 {
+ isMulticastDst = cm.Dst.IsMulticast()
+ }
+ }
+ r.handlePacketWithMeta(buf[:n], ifIndex, from, isMulticastDst)
+ }
+ }
+}
+
+func (r *hostRecordResponder) currentHostFQDN() string {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return r.hostFQDN
+}
+
+func (r *hostRecordResponder) goodbyePacketsLocked(hostFQDN string) map[int][]byte {
+ if hostFQDN == "" {
+ return nil
+ }
+ if len(r.fixedIPs) > 0 && r.inner != nil {
+ return nil
+ }
+ out := make(map[int][]byte, len(r.ifaces))
+ for _, iface := range r.ifaces {
+ ips := r.ipsForInterface(iface.Index)
+ if len(ips) == 0 {
+ continue
+ }
+ var pkt []byte
+ if r.svc.serviceName != "" {
+ pkt = buildServicePacket(r.svc, hostFQDN, ips, 0)
+ } else {
+ pkt = buildHostGoodbyePacket(hostFQDN, ips)
+ }
+ if len(pkt) > 0 {
+ out[iface.Index] = pkt
+ }
+ }
+ return out
+}
+
+func (r *hostRecordResponder) goodbyePacketsForHost(hostFQDN string) map[int][]byte {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return r.goodbyePacketsLocked(hostFQDN)
+}
+
+func (r *hostRecordResponder) sendGoodbyeLocked(hostFQDN string) {
+ for ifIndex, pkt := range r.goodbyePacketsLocked(hostFQDN) {
+ r.sendMulticast(pkt, ifIndex)
+ }
+}
+
+func (r *hostRecordResponder) sendGoodbyeForHost(hostFQDN string) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.sendGoodbyeLocked(hostFQDN)
+}
+
+func (r *hostRecordResponder) withdrawConflictedHost(observedFQDN string) {
+ r.mu.Lock()
+ if r.stopped || r.hostFQDN == "" || r.hostFQDN != observedFQDN {
+ r.mu.Unlock()
+ return
+ }
+ oldFQDN := r.hostFQDN
+ oldInner := r.inner
+ r.hostFQDN = ""
+ r.inner = nil
+ r.sendGoodbyeLocked(oldFQDN)
+ r.mu.Unlock()
+
+ if oldInner != nil {
+ oldInner.Shutdown()
+ }
+}
+
+func (r *hostRecordResponder) handleConflict(observedFQDN string) {
+ r.mu.Lock()
+ if r.stopped || r.inConflict || r.hostFQDN == "" || r.hostFQDN != observedFQDN {
+ r.mu.Unlock()
+ return
+ }
+ if r.attempt >= maxHostCollisionAttempts {
+ r.mu.Unlock()
+ log.Warnf("discovery: conflict attempts exhausted for %s; withdrawing host advertisement", observedFQDN)
+ r.withdrawConflictedHost(observedFQDN)
+ return
+ }
+ r.inConflict = true
+ startAttempt := r.attempt + 1
+ baseHost := r.baseHost
+ osHost := r.osHost
+ domain := r.domain
+ rebind := r.rebindInner
+ probe := r.probeConflict
+ r.mu.Unlock()
+
+ defer func() {
+ r.mu.Lock()
+ r.inConflict = false
+ r.mu.Unlock()
+ }()
+
+ localIPs := r.allLocalIPs()
+ nextHost, nextAttempt, errResolve := resolveAdvertisedHostCandidate(context.Background(), baseHost, startAttempt, osHost, domain, r.ifaces, localIPs, probe)
+ if errResolve != nil {
+ log.Warnf("discovery: unable to resolve replacement host after conflict on %s: %v; withdrawing host advertisement", observedFQDN, errResolve)
+ r.withdrawConflictedHost(observedFQDN)
+ return
+ }
+
+ var nextInner zeroconfServer
+ if rebind != nil {
+ var errRebind error
+ nextInner, errRebind = rebind(nextHost)
+ if errRebind != nil || nextInner == nil {
+ log.Warnf("discovery: failed to rebind service under replacement host %s: %v; withdrawing host advertisement", nextHost, errRebind)
+ r.withdrawConflictedHost(observedFQDN)
+ return
+ }
+ }
+
+ r.mu.Lock()
+ if r.stopped || r.hostFQDN != observedFQDN {
+ r.mu.Unlock()
+ if nextInner != nil {
+ nextInner.Shutdown()
+ }
+ return
+ }
+ oldFQDN := r.hostFQDN
+ oldInner := r.inner
+ r.attempt = nextAttempt
+ r.hostFQDN = formatHostFQDN(nextHost, domain)
+ if nextInner != nil {
+ r.inner = nextInner
+ }
+ r.sendGoodbyeLocked(oldFQDN)
+ r.mu.Unlock()
+
+ if oldInner != nil && nextInner != nil && oldInner != nextInner {
+ oldInner.Shutdown()
+ }
+ r.announceService()
+}
+
+func (r *hostRecordResponder) allLocalIPs() []string {
+ if len(r.fixedIPs) > 0 {
+ return r.fixedIPs
+ }
+ return extractInterfaceIPs(r.ifaces)
+}
+
+func hostAnswerHasForeignIP(answerIPs, localIPs []string) bool {
+ if len(answerIPs) == 0 {
+ return false
+ }
+ if len(localIPs) == 0 {
+ return true
+ }
+ localSet := make(map[string]struct{}, len(localIPs))
+ for _, raw := range localIPs {
+ if ip := net.ParseIP(strings.TrimSpace(raw)); ip != nil {
+ localSet[ip.String()] = struct{}{}
+ }
+ }
+ for _, raw := range answerIPs {
+ ip := net.ParseIP(strings.TrimSpace(raw))
+ if ip == nil {
+ continue
+ }
+ if _, ok := localSet[ip.String()]; !ok {
+ return true
+ }
+ }
+ return false
+}
+
+func isOnLinkSource(from net.Addr, ifIndex int, ifaces []net.Interface, configuredIPs []string) bool {
+ uaddr, ok := from.(*net.UDPAddr)
+ if !ok || uaddr == nil || uaddr.IP == nil {
+ return false
+ }
+ if uaddr.IP.IsLoopback() || uaddr.IP.IsLinkLocalUnicast() {
+ return true
+ }
+ checkedKernelAddrs := false
+ for _, iface := range ifaces {
+ if ifIndex != 0 && iface.Index != ifIndex {
+ continue
+ }
+ addrs, err := iface.Addrs()
+ if err != nil || len(addrs) == 0 {
+ continue
+ }
+ checkedKernelAddrs = true
+ for _, addr := range addrs {
+ switch v := addr.(type) {
+ case *net.IPNet:
+ if v.Contains(uaddr.IP) {
+ return true
+ }
+ case *net.IPAddr:
+ if v.IP.Equal(uaddr.IP) {
+ return true
+ }
+ }
+ }
+ }
+ if !checkedKernelAddrs {
+ for _, iface := range ifaces {
+ addrs, err := iface.Addrs()
+ if err != nil || len(addrs) == 0 {
+ continue
+ }
+ checkedKernelAddrs = true
+ for _, addr := range addrs {
+ switch v := addr.(type) {
+ case *net.IPNet:
+ if v.Contains(uaddr.IP) {
+ return true
+ }
+ case *net.IPAddr:
+ if v.IP.Equal(uaddr.IP) {
+ return true
+ }
+ }
+ }
+ }
+ }
+ for _, raw := range configuredIPs {
+ ip := net.ParseIP(strings.TrimSpace(raw))
+ if ip == nil || isLocalInterfaceIP(ip, ifaces) {
+ continue
+ }
+ if ip4 := ip.To4(); ip4 != nil {
+ mask := net.CIDRMask(24, 32)
+ subnet := net.IPNet{IP: ip4.Mask(mask), Mask: mask}
+ if subnet.Contains(uaddr.IP) {
+ return true
+ }
+ continue
+ }
+ if ip16 := ip.To16(); ip16 != nil {
+ mask := net.CIDRMask(64, 128)
+ subnet := net.IPNet{IP: ip16.Mask(mask), Mask: mask}
+ if subnet.Contains(uaddr.IP) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func isValidMDNSResponseSource(from net.Addr, ifIndex int, ifaces []net.Interface, configuredIPs []string) bool {
+ uaddr, ok := from.(*net.UDPAddr)
+ if !ok || uaddr == nil || uaddr.Port != mdnsPort {
+ return false
+ }
+ return isOnLinkSource(from, ifIndex, ifaces, configuredIPs)
+}
+
+func isValidMDNSResponsePeer(from net.Addr, ifIndex int, ifaces []net.Interface, configuredIPs []string, isMulticastDst bool) bool {
+ uaddr, ok := from.(*net.UDPAddr)
+ if !ok || uaddr == nil || uaddr.Port != mdnsPort || uaddr.IP == nil {
+ return false
+ }
+ if isMulticastDst {
+ return true
+ }
+ return isOnLinkSource(from, ifIndex, ifaces, configuredIPs)
+}
+
+func (r *hostRecordResponder) handlePacket(pkt []byte, ifIndex int, from net.Addr) {
+ r.handlePacketWithMeta(pkt, ifIndex, from, false)
+}
+
+type mdnsQueryReply struct {
+ payload []byte
+ unicast bool
+}
+
+func buildCombinedQueryReplies(pkt []byte, svc mdnsServiceConfig, hostFQDN string, ips []string, legacyUnicast bool) []mdnsQueryReply {
+ var out []mdnsQueryReply
+ if legacyUnicast {
+ if hostReply, hostUnicast := buildHostAddressReplyFiltered(pkt, hostFQDN, ips, true, nil); len(hostReply) > 0 {
+ out = append(out, mdnsQueryReply{payload: hostReply, unicast: hostUnicast})
+ }
+ if svc.serviceName != "" {
+ if svcReply, svcUnicast := buildServiceQueryReplyFiltered(pkt, svc, hostFQDN, ips, true, nil); len(svcReply) > 0 {
+ out = append(out, mdnsQueryReply{payload: svcReply, unicast: svcUnicast})
+ }
+ }
+ return out
+ }
+ for _, wantQU := range []bool{false, true} {
+ qu := wantQU
+ if hostReply, hostUnicast := buildHostAddressReplyFiltered(pkt, hostFQDN, ips, false, &qu); len(hostReply) > 0 {
+ out = append(out, mdnsQueryReply{payload: hostReply, unicast: hostUnicast})
+ }
+ if svc.serviceName != "" {
+ if svcReply, svcUnicast := buildServiceQueryReplyFiltered(pkt, svc, hostFQDN, ips, false, &qu); len(svcReply) > 0 {
+ out = append(out, mdnsQueryReply{payload: svcReply, unicast: svcUnicast})
+ }
+ }
+ }
+ return out
+}
+
+func (r *hostRecordResponder) handlePacketWithMeta(pkt []byte, ifIndex int, from net.Addr, isMulticastDst bool) {
+ if !r.ifaceAllowed(ifIndex) {
+ return
+ }
+ r.mu.Lock()
+ hostFQDN := r.hostFQDN
+ svc := r.svc
+ r.mu.Unlock()
+ if hostFQDN == "" {
+ return
+ }
+ ips := r.ipsForInterface(ifIndex)
+ if len(ips) == 0 {
+ return
+ }
+ allLocal := r.allLocalIPs()
+ if !isMulticastDst && from != nil && !isOnLinkSource(from, ifIndex, r.ifaces, allLocal) {
+ return
+ }
+ if answerIPs := extractHostAnswerIPs(pkt, hostFQDN); len(answerIPs) > 0 {
+ if isValidMDNSResponsePeer(from, ifIndex, r.ifaces, allLocal, isMulticastDst) && hostAnswerHasForeignIP(answerIPs, allLocal) {
+ r.handleConflict(hostFQDN)
+ }
+ return
+ }
+ if hasHostResponseRecord(pkt, hostFQDN) {
+ if isValidMDNSResponsePeer(from, ifIndex, r.ifaces, allLocal, isMulticastDst) {
+ r.handleConflict(hostFQDN)
+ }
+ return
+ }
+ legacyUnicast := false
+ if uaddr, ok := from.(*net.UDPAddr); ok && uaddr != nil && uaddr.Port != mdnsPort {
+ legacyUnicast = true
+ }
+ for _, rep := range buildCombinedQueryReplies(pkt, svc, hostFQDN, ips, legacyUnicast) {
+ _ = r.sendIfHostCurrent(hostFQDN, rep.unicast && from != nil, rep.payload, ifIndex, from)
+ }
+}
+
+func (r *hostRecordResponder) sendUnicast(buf []byte, ifIndex int, from net.Addr) {
+ uaddr, ok := from.(*net.UDPAddr)
+ if !ok || uaddr == nil {
+ return
+ }
+ if uaddr.IP.To4() != nil {
+ if r.ipv4conn == nil {
+ return
+ }
+ if ifIndex != 0 && supportsPerInterfaceAddrs(runtime.GOOS) {
+ wcm := ipv4.ControlMessage{IfIndex: ifIndex}
+ _, _ = r.ipv4conn.WriteTo(buf, &wcm, uaddr)
+ return
+ }
+ _, _ = r.ipv4conn.WriteTo(buf, nil, uaddr)
+ return
+ }
+ if r.ipv6conn == nil {
+ return
+ }
+ if ifIndex != 0 && supportsPerInterfaceAddrs(runtime.GOOS) {
+ wcm := ipv6.ControlMessage{IfIndex: ifIndex}
+ _, _ = r.ipv6conn.WriteTo(buf, &wcm, uaddr)
+ return
+ }
+ _, _ = r.ipv6conn.WriteTo(buf, nil, uaddr)
+}
+
+func (r *hostRecordResponder) sendMulticast(buf []byte, ifIndex int) {
+ if r.ipv4conn != nil {
+ if ifIndex != 0 {
+ r.writeMulticast4(buf, ifIndex)
+ } else {
+ for _, iface := range r.ifaces {
+ r.writeMulticast4(buf, iface.Index)
+ }
+ }
+ }
+ if r.ipv6conn != nil {
+ if ifIndex != 0 {
+ r.writeMulticast6(buf, ifIndex)
+ } else {
+ for _, iface := range r.ifaces {
+ r.writeMulticast6(buf, iface.Index)
+ }
+ }
+ }
+}
+
+func (r *hostRecordResponder) writeMulticast4(buf []byte, ifIndex int) {
+ if supportsPerInterfaceAddrs(runtime.GOOS) {
+ wcm := ipv4.ControlMessage{IfIndex: ifIndex}
+ _, _ = r.ipv4conn.WriteTo(buf, &wcm, mdnsGroupAddrIPv4)
+ return
+ }
+ if iface, err := net.InterfaceByIndex(ifIndex); err == nil && iface != nil {
+ _ = r.ipv4conn.SetMulticastInterface(iface)
+ }
+ _, _ = r.ipv4conn.WriteTo(buf, nil, mdnsGroupAddrIPv4)
+}
+
+func (r *hostRecordResponder) writeMulticast6(buf []byte, ifIndex int) {
+ if supportsPerInterfaceAddrs(runtime.GOOS) {
+ wcm := ipv6.ControlMessage{IfIndex: ifIndex}
+ _, _ = r.ipv6conn.WriteTo(buf, &wcm, mdnsGroupAddrIPv6)
+ return
+ }
+ if iface, err := net.InterfaceByIndex(ifIndex); err == nil && iface != nil {
+ _ = r.ipv6conn.SetMulticastInterface(iface)
+ }
+ _, _ = r.ipv6conn.WriteTo(buf, nil, mdnsGroupAddrIPv6)
+}
+
+func (r *hostRecordResponder) sendGoodbye() {
+ r.sendGoodbyeForHost(r.currentHostFQDN())
+}
+
+func (r *hostRecordResponder) Shutdown() {
+ r.shutdownOnce.Do(func() {
+ r.mu.Lock()
+ r.stopped = true
+ inner := r.inner
+ r.inner = nil
+ r.mu.Unlock()
+
+ r.sendGoodbye()
+ close(r.stopCh)
+ if r.ipv4conn != nil {
+ _ = r.ipv4conn.Close()
+ }
+ if r.ipv6conn != nil {
+ _ = r.ipv6conn.Close()
+ }
+ r.wg.Wait()
+ if inner != nil {
+ inner.Shutdown()
+ }
+ })
+}
+
// ZeroconfAdvertiser wraps libp2p/zeroconf/v2 Server with defensive lifecycle management.
type ZeroconfAdvertiser struct {
- mu sync.Mutex
- server *zeroconf.Server
- inFlight bool
- closed bool
+ mu sync.Mutex
+ server zeroconfServer
+ inFlight bool
+ closed bool
+ registerProxy registerProxyFunc
+ interfaceIPs func([]net.Interface) []string
+ osHostname func() (string, error)
+ probeHostConflict hostConflictProbeFunc
+}
+
+// NewZeroconfAdvertiser returns a new ZeroconfAdvertiser.
+func NewZeroconfAdvertiser() *ZeroconfAdvertiser {
+ return &ZeroconfAdvertiser{}
+}
+
+func cleanHostname(h string) string {
+ h = strings.TrimSpace(h)
+ h = strings.TrimSuffix(h, ".")
+ if len(h) >= 6 && strings.EqualFold(h[len(h)-6:], ".local") {
+ h = h[:len(h)-6]
+ }
+ h = strings.TrimSuffix(h, ".")
+ if h == "" {
+ return "localhost"
+ }
+ return h
+}
+
+// extractCleanHost returns bare hostname without any .local suffix
+// to prevent libp2p/zeroconf/v2 from appending a duplicate .local. (e.g. on macOS).
+func extractCleanHost() string {
+ h, err := os.Hostname()
+ if err != nil || h == "" {
+ return "localhost"
+ }
+ return cleanHostname(h)
+}
+
+func sanitizeDNSLabel(s string) string {
+ var b strings.Builder
+ prevHyphen := false
+ for i := 0; i < len(s); i++ {
+ c := s[i]
+ switch {
+ case c >= 'a' && c <= 'z', c >= '0' && c <= '9':
+ b.WriteByte(c)
+ prevHyphen = false
+ case c >= 'A' && c <= 'Z':
+ b.WriteByte(c + ('a' - 'A'))
+ prevHyphen = false
+ default:
+ if b.Len() > 0 && !prevHyphen {
+ b.WriteByte('-')
+ prevHyphen = true
+ }
+ }
+ }
+ return strings.Trim(b.String(), "-")
+}
+
+// buildAdvertisedHost derives a proxy-specific RFC 1123 host label from the
+// instance name so mDNS advertisement never claims or flushes the OS-owned
+// .local. record.
+func buildAdvertisedHost(instanceName, osHost string) string {
+ clean := sanitizeDNSLabel(instanceName)
+ var rest string
+ switch {
+ case clean == "cpa":
+ rest = "0001"
+ case strings.HasPrefix(clean, "cpa-"):
+ rest = strings.TrimPrefix(clean, "cpa-")
+ default:
+ rest = clean
+ }
+ rest = strings.Trim(rest, "-")
+ if rest == "" {
+ rest = "0001"
+ }
+
+ const prefix = "cpa-"
+ maxRest := maxDNSLabelBytes - len(prefix)
+ if len(rest) > maxRest {
+ if len(rest) > 5 && rest[len(rest)-5] == '-' && isValidHex4(rest[len(rest)-4:]) {
+ suffix := rest[len(rest)-5:]
+ head := strings.TrimRight(rest[:maxRest-len(suffix)], "-")
+ if head == "" {
+ rest = strings.TrimPrefix(suffix, "-")
+ } else {
+ rest = head + suffix
+ }
+ } else {
+ rest = strings.TrimRight(rest[:maxRest], "-")
+ }
+ if rest == "" {
+ rest = "0001"
+ }
+ }
+
+ label := prefix + rest
+ cleanOS := strings.ToLower(cleanHostname(osHost))
+ if cleanOS != "" && cleanOS != "localhost" && strings.EqualFold(label, cleanOS) {
+ const altSuffix = "-svc"
+ if len(label)+len(altSuffix) <= maxDNSLabelBytes {
+ label += altSuffix
+ } else {
+ trimmed := strings.TrimRight(label[:maxDNSLabelBytes-len(altSuffix)], "-")
+ if trimmed == "" {
+ label = "cpa-0001" + altSuffix
+ } else {
+ label = trimmed + altSuffix
+ }
+ }
+ }
+ return label
+}
+
+func appendHostCollisionSuffix(baseLabel string, attempt int) string {
+ if attempt <= 1 {
+ return baseLabel
+ }
+ suffix := fmt.Sprintf("-%d", attempt)
+ if len(baseLabel)+len(suffix) <= maxDNSLabelBytes {
+ return baseLabel + suffix
+ }
+ trimmed := strings.TrimRight(baseLabel[:maxDNSLabelBytes-len(suffix)], "-")
+ if trimmed == "" {
+ return "cpa" + suffix
+ }
+ return trimmed + suffix
+}
+
+func extractHostAnswerIPs(pkt []byte, hostFQDN string) []string {
+ normPkt := rewriteWireLabelChar(pkt, '.', dotInLabelSentinel)
+ var p dnsmessage.Parser
+ hdr, err := p.Start(normPkt)
+ if err != nil || !hdr.Response {
+ return nil
+ }
+ if errSkipQ := p.SkipAllQuestions(); errSkipQ != nil {
+ return nil
+ }
+ var ips []string
+ collect := func(nextHeader func() (dnsmessage.ResourceHeader, error), skip func() error) bool {
+ for {
+ rh, errH := nextHeader()
+ if errH != nil {
+ return errH == dnsmessage.ErrSectionDone
+ }
+ if !strings.EqualFold(rh.Name.String(), hostFQDN) || rh.TTL == 0 {
+ if errSkip := skip(); errSkip != nil {
+ return false
+ }
+ continue
+ }
+ switch rh.Type {
+ case dnsmessage.TypeA:
+ r, errA := p.AResource()
+ if errA != nil {
+ return false
+ }
+ ips = append(ips, net.IP(r.A[:]).String())
+ case dnsmessage.TypeAAAA:
+ r, errAAAA := p.AAAAResource()
+ if errAAAA != nil {
+ return false
+ }
+ ips = append(ips, net.IP(r.AAAA[:]).String())
+ default:
+ if errSkip := skip(); errSkip != nil {
+ return false
+ }
+ }
+ }
+ }
+ if !collect(p.AnswerHeader, p.SkipAnswer) {
+ return ips
+ }
+ if errSkipAuth := p.SkipAllAuthorities(); errSkipAuth != nil {
+ return ips
+ }
+ _ = collect(p.AdditionalHeader, p.SkipAdditional)
+ return ips
+}
+
+func hasHostResponseRecord(pkt []byte, hostFQDN string) bool {
+ normPkt := rewriteWireLabelChar(pkt, '.', dotInLabelSentinel)
+ var p dnsmessage.Parser
+ hdr, err := p.Start(normPkt)
+ if err != nil || !hdr.Response {
+ return false
+ }
+ if errSkipQ := p.SkipAllQuestions(); errSkipQ != nil {
+ return false
+ }
+ found := false
+ scan := func(nextHeader func() (dnsmessage.ResourceHeader, error), skip func() error) bool {
+ for {
+ rh, errH := nextHeader()
+ if errH != nil {
+ return errH == dnsmessage.ErrSectionDone
+ }
+ if errSkip := skip(); errSkip != nil {
+ return false
+ }
+ if rh.TTL > 0 && strings.EqualFold(rh.Name.String(), hostFQDN) {
+ found = true
+ return false
+ }
+ }
+ }
+ if !scan(p.AnswerHeader, p.SkipAnswer) || found {
+ return found
+ }
+ if errSkipAuth := p.SkipAllAuthorities(); errSkipAuth != nil {
+ return false
+ }
+ _ = scan(p.AdditionalHeader, p.SkipAdditional)
+ return found
+}
+
+func extractHostAuthorityIPs(pkt []byte, hostFQDN string) (uint16, []string, bool) {
+ normPkt := rewriteWireLabelChar(pkt, '.', dotInLabelSentinel)
+ var p dnsmessage.Parser
+ hdr, err := p.Start(normPkt)
+ if err != nil || hdr.Response {
+ return 0, nil, false
+ }
+ qs, errQ := p.AllQuestions()
+ if errQ != nil || len(qs) == 0 {
+ return 0, nil, false
+ }
+ matchedQ := false
+ for _, q := range qs {
+ if strings.EqualFold(q.Name.String(), hostFQDN) {
+ matchedQ = true
+ break
+ }
+ }
+ if !matchedQ {
+ return 0, nil, false
+ }
+ if errSkipAns := p.SkipAllAnswers(); errSkipAns != nil {
+ return 0, nil, false
+ }
+ var ips []string
+ hasOtherType := false
+ for {
+ rh, errH := p.AuthorityHeader()
+ if errH != nil {
+ break
+ }
+ if !strings.EqualFold(rh.Name.String(), hostFQDN) || rh.TTL == 0 {
+ if errSkip := p.SkipAuthority(); errSkip != nil {
+ break
+ }
+ continue
+ }
+ switch rh.Type {
+ case dnsmessage.TypeA:
+ r, errA := p.AResource()
+ if errA != nil {
+ return hdr.ID, ips, hasOtherType
+ }
+ ips = append(ips, net.IP(r.A[:]).String())
+ case dnsmessage.TypeAAAA:
+ r, errAAAA := p.AAAAResource()
+ if errAAAA != nil {
+ return hdr.ID, ips, hasOtherType
+ }
+ ips = append(ips, net.IP(r.AAAA[:]).String())
+ default:
+ if errSkip := p.SkipAuthority(); errSkip != nil {
+ return hdr.ID, ips, hasOtherType
+ }
+ hasOtherType = true
+ }
+ }
+ return hdr.ID, ips, hasOtherType
}
-// NewZeroconfAdvertiser returns a new ZeroconfAdvertiser.
-func NewZeroconfAdvertiser() *ZeroconfAdvertiser {
- return &ZeroconfAdvertiser{}
+func canonicalHostRecordList(ips []string) [][]byte {
+ v4, v6 := splitIPv4AndIPv6(ips)
+ records := make([][]byte, 0, len(v4)+len(v6))
+ for _, addr := range v4 {
+ rec := append([]byte{0, byte(dnsmessage.TypeA)}, addr[:]...)
+ records = append(records, rec)
+ }
+ for _, addr := range v6 {
+ rec := append([]byte{0, byte(dnsmessage.TypeAAAA)}, addr[:]...)
+ records = append(records, rec)
+ }
+ sort.Slice(records, func(i, j int) bool {
+ return bytes.Compare(records[i], records[j]) < 0
+ })
+ dedup := records[:0]
+ for _, rec := range records {
+ if len(dedup) == 0 || !bytes.Equal(dedup[len(dedup)-1], rec) {
+ dedup = append(dedup, rec)
+ }
+ }
+ return dedup
}
-// extractCleanHost returns bare hostname without any .local suffix
-// to prevent libp2p/zeroconf/v2 from appending a duplicate .local. (e.g. on macOS).
-func extractCleanHost() string {
- h, err := os.Hostname()
- if err != nil || h == "" {
- return "localhost"
+// compareHostRecordSets compares two proposed record sets lexicographically per RFC 6762 §8.2.
+// Returns -1 if localIPs < peerIPs (local loses), +1 if localIPs > peerIPs (local wins), 0 if equal.
+func compareHostRecordSets(localIPs, peerIPs []string) int {
+ ours := canonicalHostRecordList(localIPs)
+ theirs := canonicalHostRecordList(peerIPs)
+ limit := min(len(ours), len(theirs))
+ for i := 0; i < limit; i++ {
+ if cmp := bytes.Compare(ours[i], theirs[i]); cmp != 0 {
+ return cmp
+ }
}
- h = strings.TrimSpace(h)
- h = strings.TrimSuffix(h, ".")
- h = strings.TrimSuffix(h, ".local")
- h = strings.TrimSuffix(h, ".")
- if h == "" {
- return "localhost"
+ switch {
+ case len(ours) < len(theirs):
+ return -1
+ case len(ours) > len(theirs):
+ return 1
+ default:
+ return 0
}
- return h
+}
+
+func isLocalInterfaceIP(target net.IP, ifaces []net.Interface) bool {
+ if target == nil {
+ return false
+ }
+ for _, iface := range ifaces {
+ if interfaceHasIP(iface, target) {
+ return true
+ }
+ }
+ return false
+}
+
+func isOwnLoopbackProbe(pkt, ownQuery []byte, from net.Addr, ownPorts map[int]struct{}, ifaces []net.Interface) bool {
+ if len(ownQuery) == 0 || !bytes.Equal(pkt, ownQuery) {
+ return false
+ }
+ uaddr, ok := from.(*net.UDPAddr)
+ if !ok || uaddr == nil {
+ return false
+ }
+ if _, portMatch := ownPorts[uaddr.Port]; !portMatch {
+ return false
+ }
+ return isLocalInterfaceIP(uaddr.IP, ifaces)
+}
+
+func isHostProbeConflict(pkt []byte, hostFQDN string, localIPs []string, ownPort, peerPort int) bool {
+ if hasHostResponseRecord(pkt, hostFQDN) {
+ return true
+ }
+ theirID, authIPs, hasOtherType := extractHostAuthorityIPs(pkt, hostFQDN)
+ if hasOtherType {
+ return true
+ }
+ if len(authIPs) == 0 {
+ return false
+ }
+ cmp := compareHostRecordSets(localIPs, authIPs)
+ if cmp < 0 {
+ return true
+ }
+ if cmp > 0 {
+ return false
+ }
+ if ownPort > 0 && peerPort > 0 && ownPort != peerPort {
+ return ownPort < peerPort
+ }
+ return theirID != 0
+}
+
+func ifaceInList(ifIndex int, ifaces []net.Interface) bool {
+ if ifIndex <= 0 {
+ return false
+ }
+ for _, iface := range ifaces {
+ if iface.Index == ifIndex {
+ return true
+ }
+ }
+ return false
+}
+
+func isValidProbePeer(from net.Addr, ifIndex int, ifaces []net.Interface, configuredIPs []string, isMulticast, isResponse bool) bool {
+ uaddr, ok := from.(*net.UDPAddr)
+ if !ok || uaddr == nil || uaddr.IP == nil {
+ return false
+ }
+ if isResponse && uaddr.Port != mdnsPort {
+ return false
+ }
+ if isMulticast || ifaceInList(ifIndex, ifaces) {
+ return true
+ }
+ return isOnLinkSource(from, ifIndex, ifaces, configuredIPs)
+}
+
+func buildHostProbeQuery(hostFQDN string, probeID uint16, localIPs []string) ([]byte, error) {
+ qName, errName := dnsmessage.NewName(hostFQDN)
+ if errName != nil {
+ return nil, errName
+ }
+ builder := dnsmessage.NewBuilder(nil, dnsmessage.Header{ID: probeID})
+ builder.EnableCompression()
+ if errStartQ := builder.StartQuestions(); errStartQ != nil {
+ return nil, errStartQ
+ }
+ if errQ := builder.Question(dnsmessage.Question{
+ Name: qName,
+ Type: dnsmessage.TypeALL,
+ Class: dnsmessage.ClassINET,
+ }); errQ != nil {
+ return nil, errQ
+ }
+ v4, v6 := splitIPv4AndIPv6(localIPs)
+ if len(v4) > 0 || len(v6) > 0 {
+ if errAuth := builder.StartAuthorities(); errAuth != nil {
+ return nil, errAuth
+ }
+ for _, addr := range v4 {
+ if errAdd := builder.AResource(dnsmessage.ResourceHeader{
+ Name: qName,
+ Type: dnsmessage.TypeA,
+ Class: dnsmessage.ClassINET,
+ TTL: mdnsHostRecordTTL,
+ }, dnsmessage.AResource{A: addr}); errAdd != nil {
+ return nil, errAdd
+ }
+ }
+ for _, addr := range v6 {
+ if errAdd := builder.AAAAResource(dnsmessage.ResourceHeader{
+ Name: qName,
+ Type: dnsmessage.TypeAAAA,
+ Class: dnsmessage.ClassINET,
+ TTL: mdnsHostRecordTTL,
+ }, dnsmessage.AAAAResource{AAAA: addr}); errAdd != nil {
+ return nil, errAdd
+ }
+ }
+ }
+ return builder.Finish()
+}
+
+func randomProbeID() uint16 {
+ var b [2]byte
+ if _, err := rand.Read(b[:]); err == nil {
+ if id := binary.BigEndian.Uint16(b[:]); id != 0 {
+ return id
+ }
+ }
+ return uint16(os.Getpid()&0x7FFF) | 1
+}
+
+func awaitProbeConflictResult(conflictCh <-chan bool, doneCh <-chan struct{}, detected *atomic.Bool, ctx context.Context, stopReaders func()) (bool, error) {
+ select {
+ case <-conflictCh:
+ if stopReaders != nil {
+ stopReaders()
+ }
+ <-doneCh
+ return true, nil
+ case <-doneCh:
+ if detected != nil && detected.Load() {
+ return true, nil
+ }
+ select {
+ case <-conflictCh:
+ return true, nil
+ default:
+ }
+ if ctx != nil && ctx.Err() != nil {
+ return false, ctx.Err()
+ }
+ return false, nil
+ }
+}
+
+func runHostProbeSchedule(ctx context.Context, probeCount int, probeInterval time.Duration, sendProbe func() error, conflictCh <-chan bool, doneCh <-chan struct{}, detected *atomic.Bool, stopReaders func()) (bool, error) {
+ if probeCount < 1 {
+ probeCount = 1
+ }
+ var ctxDone <-chan struct{}
+ if ctx != nil {
+ ctxDone = ctx.Done()
+ }
+ for i := 0; i < probeCount; i++ {
+ if ctx != nil && ctx.Err() != nil {
+ if stopReaders != nil {
+ stopReaders()
+ }
+ <-doneCh
+ if detected != nil && detected.Load() {
+ return true, nil
+ }
+ return false, ctx.Err()
+ }
+ if errSend := sendProbe(); errSend != nil {
+ if stopReaders != nil {
+ stopReaders()
+ }
+ <-doneCh
+ return false, errSend
+ }
+ timer := time.NewTimer(probeInterval)
+ select {
+ case <-conflictCh:
+ timer.Stop()
+ if stopReaders != nil {
+ stopReaders()
+ }
+ <-doneCh
+ return true, nil
+ case <-ctxDone:
+ timer.Stop()
+ if stopReaders != nil {
+ stopReaders()
+ }
+ <-doneCh
+ if detected != nil && detected.Load() {
+ return true, nil
+ }
+ return false, ctx.Err()
+ case <-doneCh:
+ timer.Stop()
+ if detected != nil && detected.Load() {
+ return true, nil
+ }
+ select {
+ case <-conflictCh:
+ return true, nil
+ default:
+ }
+ if ctx != nil && ctx.Err() != nil {
+ return false, ctx.Err()
+ }
+ return false, fmt.Errorf("discovery: host conflict probe readers terminated before probe window completed")
+ case <-timer.C:
+ if detected != nil && detected.Load() {
+ if stopReaders != nil {
+ stopReaders()
+ }
+ <-doneCh
+ return true, nil
+ }
+ }
+ }
+ if stopReaders != nil {
+ stopReaders()
+ }
+ return awaitProbeConflictResult(conflictCh, doneCh, detected, ctx, stopReaders)
+}
+
+func probeHostConflictOnLAN(ctx context.Context, hostFQDN string, ifaces []net.Interface, localIPs []string) (bool, error) {
+ if len(ifaces) == 0 {
+ return false, fmt.Errorf("discovery: cannot probe host conflict with empty interface list")
+ }
+ if ctx != nil {
+ if errCtx := ctx.Err(); errCtx != nil {
+ return false, errCtx
+ }
+ if deadline, ok := ctx.Deadline(); ok && time.Until(deadline) < time.Duration(mdnsHostProbeCount)*mdnsHostProbeInterval {
+ return false, context.DeadlineExceeded
+ }
+ }
+ probeID := randomProbeID()
+ queryBytes, errQuery := buildHostProbeQuery(hostFQDN, probeID, localIPs)
+ if errQuery != nil {
+ return false, errQuery
+ }
+
+ type probeConn struct {
+ conn *net.UDPConn
+ pconn4 *ipv4.PacketConn
+ pconn6 *ipv6.PacketConn
+ isMulticastDst bool
+ }
+ var conns []probeConn
+ defer func() {
+ for _, c := range conns {
+ _ = c.conn.Close()
+ }
+ }()
+
+ ownPorts := make(map[int]struct{}, 2)
+ ownPort4 := 0
+ ownPort6 := 0
+ if mConn4, errM4 := net.ListenUDP("udp4", mdnsWildcardAddrIPv4); errM4 == nil {
+ mp4 := ipv4.NewPacketConn(mConn4)
+ _ = mp4.SetControlMessage(ipv4.FlagInterface|ipv4.FlagDst, true)
+ joined := 0
+ for i := range ifaces {
+ if mp4.JoinGroup(&ifaces[i], mdnsGroupAddrIPv4) == nil {
+ joined++
+ }
+ }
+ if joined > 0 {
+ _ = mp4.SetMulticastTTL(255)
+ conns = append(conns, probeConn{conn: mConn4, pconn4: mp4, isMulticastDst: true})
+ } else {
+ _ = mConn4.Close()
+ }
+ }
+ if mConn6, errM6 := net.ListenUDP("udp6", mdnsWildcardAddrIPv6); errM6 == nil {
+ mp6 := ipv6.NewPacketConn(mConn6)
+ _ = mp6.SetControlMessage(ipv6.FlagInterface|ipv6.FlagDst, true)
+ joined := 0
+ for i := range ifaces {
+ if mp6.JoinGroup(&ifaces[i], mdnsGroupAddrIPv6) == nil {
+ joined++
+ }
+ }
+ if joined > 0 {
+ _ = mp6.SetMulticastHopLimit(255)
+ conns = append(conns, probeConn{conn: mConn6, pconn6: mp6, isMulticastDst: true})
+ } else {
+ _ = mConn6.Close()
+ }
+ }
+
+ var pConn4 *ipv4.PacketConn
+ var pConn6 *ipv6.PacketConn
+ var lastInitErr error
+ if uConn4, errListen4 := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0}); errListen4 != nil {
+ lastInitErr = errListen4
+ } else {
+ pConn4 = ipv4.NewPacketConn(uConn4)
+ _ = pConn4.SetControlMessage(ipv4.FlagInterface|ipv4.FlagDst, true)
+ conns = append(conns, probeConn{conn: uConn4, pconn4: pConn4, isMulticastDst: false})
+ if laddr, ok := uConn4.LocalAddr().(*net.UDPAddr); ok && laddr != nil {
+ ownPorts[laddr.Port] = struct{}{}
+ ownPort4 = laddr.Port
+ }
+ }
+ if uConn6, errListen6 := net.ListenUDP("udp6", &net.UDPAddr{IP: net.IPv6zero, Port: 0}); errListen6 != nil {
+ lastInitErr = errListen6
+ } else {
+ pConn6 = ipv6.NewPacketConn(uConn6)
+ _ = pConn6.SetControlMessage(ipv6.FlagInterface|ipv6.FlagDst, true)
+ conns = append(conns, probeConn{conn: uConn6, pconn6: pConn6, isMulticastDst: false})
+ if laddr, ok := uConn6.LocalAddr().(*net.UDPAddr); ok && laddr != nil {
+ ownPorts[laddr.Port] = struct{}{}
+ ownPort6 = laddr.Port
+ }
+ }
+ if pConn4 == nil && pConn6 == nil {
+ if lastInitErr == nil {
+ lastInitErr = fmt.Errorf("no probe sockets available")
+ }
+ return false, fmt.Errorf("discovery: failed to initialize mDNS host conflict probe for %s: %w", hostFQDN, lastInitErr)
+ }
+
+ sendOnce := func() error {
+ var lastErr error
+ sentPerIface := make([]int, len(ifaces))
+ if pConn4 != nil {
+ for i := range ifaces {
+ if errSet := pConn4.SetMulticastInterface(&ifaces[i]); errSet != nil {
+ lastErr = errSet
+ continue
+ }
+ if _, errWrite := pConn4.WriteTo(queryBytes, nil, mdnsGroupAddrIPv4); errWrite == nil {
+ sentPerIface[i]++
+ } else {
+ lastErr = errWrite
+ }
+ }
+ }
+ if pConn6 != nil {
+ for i := range ifaces {
+ if errSet := pConn6.SetMulticastInterface(&ifaces[i]); errSet != nil {
+ lastErr = errSet
+ continue
+ }
+ if _, errWrite := pConn6.WriteTo(queryBytes, nil, mdnsGroupAddrIPv6); errWrite == nil {
+ sentPerIface[i]++
+ } else {
+ lastErr = errWrite
+ }
+ }
+ }
+ for i := range ifaces {
+ if sentPerIface[i] == 0 {
+ if lastErr == nil {
+ lastErr = fmt.Errorf("no probe packets sent")
+ }
+ return fmt.Errorf("discovery: failed to send mDNS host conflict probe for %s on interface %s: %w", hostFQDN, ifaces[i].Name, lastErr)
+ }
+ }
+ return nil
+ }
+
+ var detected atomic.Bool
+ conflictCh := make(chan bool, len(conns))
+ var wg sync.WaitGroup
+ for _, pc := range conns {
+ wg.Add(1)
+ go func(entry probeConn) {
+ defer wg.Done()
+ buf := make([]byte, 4096)
+ for {
+ if ctx != nil && ctx.Err() != nil {
+ return
+ }
+ var (
+ n int
+ ifIndex int
+ fromAddr net.Addr
+ errRead error
+ isMcast bool
+ )
+ switch {
+ case entry.pconn4 != nil:
+ var cm *ipv4.ControlMessage
+ n, cm, fromAddr, errRead = entry.pconn4.ReadFrom(buf)
+ if cm != nil {
+ ifIndex = cm.IfIndex
+ if len(cm.Dst) > 0 {
+ isMcast = cm.Dst.IsMulticast()
+ }
+ }
+ case entry.pconn6 != nil:
+ var cm *ipv6.ControlMessage
+ n, cm, fromAddr, errRead = entry.pconn6.ReadFrom(buf)
+ if cm != nil {
+ ifIndex = cm.IfIndex
+ if len(cm.Dst) > 0 {
+ isMcast = cm.Dst.IsMulticast()
+ }
+ }
+ default:
+ n, fromAddr, errRead = entry.conn.ReadFromUDP(buf)
+ }
+ if errRead != nil {
+ return
+ }
+ pkt := buf[:n]
+ if isOwnLoopbackProbe(pkt, queryBytes, fromAddr, ownPorts, ifaces) {
+ continue
+ }
+ isResp := hasHostResponseRecord(pkt, hostFQDN)
+ if !isValidProbePeer(fromAddr, ifIndex, ifaces, localIPs, isMcast, isResp) {
+ continue
+ }
+ peerPort := 0
+ ownPort := ownPort4
+ if uaddr, ok := fromAddr.(*net.UDPAddr); ok && uaddr != nil {
+ peerPort = uaddr.Port
+ if uaddr.IP != nil && uaddr.IP.To4() == nil && ownPort6 != 0 {
+ ownPort = ownPort6
+ }
+ }
+ if isHostProbeConflict(pkt, hostFQDN, localIPs, ownPort, peerPort) {
+ detected.Store(true)
+ conflictCh <- true
+ return
+ }
+ }
+ }(pc)
+ }
+
+ doneCh := make(chan struct{})
+ go func() {
+ wg.Wait()
+ close(doneCh)
+ }()
+
+ var stopOnce sync.Once
+ stopReaders := func() {
+ stopOnce.Do(func() {
+ for _, c := range conns {
+ _ = c.conn.Close()
+ }
+ })
+ }
+ return runHostProbeSchedule(ctx, mdnsHostProbeCount, mdnsHostProbeInterval, sendOnce, conflictCh, doneCh, &detected, stopReaders)
+}
+
+func resolveAdvertisedHostCandidate(ctx context.Context, baseHost string, startAttempt int, osHost, domain string, ifaces []net.Interface, localIPs []string, probeConflict hostConflictProbeFunc) (string, int, error) {
+ cleanOS := strings.ToLower(cleanHostname(osHost))
+ if probeConflict == nil {
+ probeConflict = probeHostConflictOnLAN
+ }
+ if startAttempt < 1 {
+ startAttempt = 1
+ }
+ for attempt := startAttempt; attempt <= maxHostCollisionAttempts; attempt++ {
+ if ctx != nil && ctx.Err() != nil {
+ return "", 0, ctx.Err()
+ }
+ candidate := appendHostCollisionSuffix(baseHost, attempt)
+ if cleanOS != "" && cleanOS != "localhost" && strings.EqualFold(candidate, cleanOS) {
+ continue
+ }
+ conflict, errProbe := probeConflict(ctx, formatHostFQDN(candidate, domain), ifaces, localIPs)
+ if errProbe != nil {
+ return "", 0, errProbe
+ }
+ if !conflict {
+ return candidate, attempt, nil
+ }
+ }
+ return "", 0, fmt.Errorf("discovery: unable to resolve non-conflicting mDNS host label for %q after %d attempts", baseHost, maxHostCollisionAttempts)
+}
+
+func resolveAdvertisedHost(ctx context.Context, instanceName, osHost, domain string, ifaces []net.Interface, localIPs []string, probeConflict hostConflictProbeFunc) (string, error) {
+ baseHost := buildAdvertisedHost(instanceName, osHost)
+ candidate, _, err := resolveAdvertisedHostCandidate(ctx, baseHost, 1, osHost, domain, ifaces, localIPs, probeConflict)
+ return candidate, err
+}
+
+func extractInterfaceAddrs(addrs []net.Addr) []string {
+ var v4, v6Global, v6LinkLocal []string
+ for _, addr := range addrs {
+ var ip net.IP
+ switch v := addr.(type) {
+ case *net.IPNet:
+ ip = v.IP
+ case *net.IPAddr:
+ ip = v.IP
+ }
+ if ip == nil || ip.IsLoopback() || ip.IsUnspecified() {
+ continue
+ }
+ if ip4 := ip.To4(); ip4 != nil {
+ v4 = append(v4, ip4.String())
+ continue
+ }
+ if ip16 := ip.To16(); ip16 != nil {
+ if ip16.IsGlobalUnicast() {
+ v6Global = append(v6Global, ip16.String())
+ } else if ip16.IsLinkLocalUnicast() {
+ v6LinkLocal = append(v6LinkLocal, ip16.String())
+ }
+ }
+ }
+ if len(v6Global) > 0 {
+ return append(v4, v6Global...)
+ }
+ return append(v4, v6LinkLocal...)
}
// extractInterfaceIPs collects non-loopback IP addresses from the selected interfaces.
@@ -50,23 +2450,71 @@ func extractInterfaceIPs(ifaces []net.Interface) []string {
if err != nil {
continue
}
- for _, addr := range addrs {
- var ip net.IP
- switch v := addr.(type) {
- case *net.IPNet:
- ip = v.IP
- case *net.IPAddr:
- ip = v.IP
- }
- if ip == nil || ip.IsLoopback() || ip.IsUnspecified() {
- continue
- }
- ips = append(ips, ip.String())
- }
+ ips = append(ips, extractInterfaceAddrs(addrs)...)
}
return ips
}
+func supportsPerInterfaceAddrs(goos string) bool {
+ switch goos {
+ case "darwin", "ios", "linux":
+ return true
+ default:
+ return false
+ }
+}
+
+func sameIPSet(a, b []string) bool {
+ if len(a) == 0 || len(b) == 0 {
+ return len(a) == len(b)
+ }
+ setA := make(map[string]struct{}, len(a))
+ for _, raw := range a {
+ trimmed := strings.TrimSpace(raw)
+ if ip := net.ParseIP(trimmed); ip != nil {
+ setA[ip.String()] = struct{}{}
+ } else if trimmed != "" {
+ setA[trimmed] = struct{}{}
+ }
+ }
+ setB := make(map[string]struct{}, len(b))
+ for _, raw := range b {
+ trimmed := strings.TrimSpace(raw)
+ if ip := net.ParseIP(trimmed); ip != nil {
+ setB[ip.String()] = struct{}{}
+ } else if trimmed != "" {
+ setB[trimmed] = struct{}{}
+ }
+ }
+ if len(setA) != len(setB) {
+ return false
+ }
+ for k := range setA {
+ if _, ok := setB[k]; !ok {
+ return false
+ }
+ }
+ return true
+}
+
+func resolveRegisterProxyIPs(ifaces []net.Interface, advertisedIPs []string, goos string, lookupIPs func([]net.Interface) []string) ([]string, error) {
+ if lookupIPs == nil {
+ lookupIPs = extractInterfaceIPs
+ }
+ interfaceIPs := lookupIPs(ifaces)
+ ips := advertisedIPs
+ if len(ips) == 0 {
+ ips = interfaceIPs
+ }
+ if len(ips) == 0 {
+ return nil, fmt.Errorf("discovery: no usable IP addresses found on specified interfaces")
+ }
+ if supportsPerInterfaceAddrs(goos) && len(interfaceIPs) > 0 && (len(advertisedIPs) == 0 || (len(ifaces) > 1 && sameIPSet(advertisedIPs, interfaceIPs))) {
+ return nil, nil
+ }
+ return ips, nil
+}
+
// Start registers and starts mDNS advertisement for the primary service type
// and any configured API protocol subtypes.
func (a *ZeroconfAdvertiser) Start(ctx context.Context, spec ServiceSpec) (err error) {
@@ -89,7 +2537,7 @@ func (a *ZeroconfAdvertiser) Start(ctx context.Context, spec ServiceSpec) (err e
a.closed = false
a.mu.Unlock()
- var server *zeroconf.Server
+ var server zeroconfServer
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("discovery: panic starting advertiser: %v", r)
@@ -121,19 +2569,40 @@ func (a *ZeroconfAdvertiser) Start(ctx context.Context, spec ServiceSpec) (err e
serviceType = DefaultServiceType
}
- cleanHost := extractCleanHost()
- ips := spec.AdvertisedIPs
- if len(ips) == 0 {
- ips = extractInterfaceIPs(spec.Interfaces)
- }
- if len(ips) == 0 {
- return fmt.Errorf("discovery: no usable IP addresses found on specified interfaces")
+ proxyIPs, errIPs := resolveRegisterProxyIPs(spec.Interfaces, spec.AdvertisedIPs, runtime.GOOS, a.interfaceIPs)
+ if errIPs != nil {
+ return errIPs
}
spec.InstanceName = sanitizeInstanceName(spec.InstanceName)
if spec.InstanceName == "" {
spec.InstanceName = DefaultInstancePrefix + "0001"
}
+ cleanHost := extractCleanHost()
+ if a.osHostname != nil {
+ if rawHost, errHost := a.osHostname(); errHost == nil && rawHost != "" {
+ cleanHost = cleanHostname(rawHost)
+ } else {
+ cleanHost = "localhost"
+ }
+ }
+ localIPs := spec.AdvertisedIPs
+ if len(localIPs) == 0 {
+ if a.interfaceIPs != nil {
+ localIPs = a.interfaceIPs(spec.Interfaces)
+ } else {
+ localIPs = extractInterfaceIPs(spec.Interfaces)
+ }
+ }
+ probeConflict := a.probeHostConflict
+ if probeConflict == nil && a.registerProxy != nil {
+ probeConflict = func(context.Context, string, []net.Interface, []string) (bool, error) { return false, nil }
+ }
+ baseHost := buildAdvertisedHost(spec.InstanceName, cleanHost)
+ advertisedHost, initialAttempt, errHost := resolveAdvertisedHostCandidate(ctx, baseHost, 1, cleanHost, domain, spec.Interfaces, localIPs, probeConflict)
+ if errHost != nil {
+ return errHost
+ }
if ctx != nil && ctx.Err() != nil {
return ctx.Err()
}
@@ -145,20 +2614,35 @@ func (a *ZeroconfAdvertiser) Start(ctx context.Context, spec ServiceSpec) (err e
}
}
+ registerProxy := a.registerProxy
+ if registerProxy == nil {
+ registerProxy = defaultRegisterProxy
+ }
+
var errRegister error
- server, errRegister = zeroconf.RegisterProxy(
+ server, errRegister = registerProxy(
spec.InstanceName,
primaryService,
domain,
spec.Port,
- cleanHost,
- ips,
+ advertisedHost,
+ proxyIPs,
spec.TextRecords,
spec.Interfaces,
)
if errRegister != nil {
return fmt.Errorf("discovery: failed to register primary service %s: %w", primaryService, errRegister)
}
+ if hr, ok := server.(*hostRecordResponder); ok && hr != nil {
+ hr.mu.Lock()
+ hr.baseHost = baseHost
+ hr.osHost = cleanHost
+ hr.attempt = initialAttempt
+ if probeConflict != nil {
+ hr.probeConflict = probeConflict
+ }
+ hr.mu.Unlock()
+ }
if ctx != nil && ctx.Err() != nil {
return ctx.Err()
}
diff --git a/internal/logging/gin_logger.go b/internal/logging/gin_logger.go
index a63d4189443..804d74870d6 100644
--- a/internal/logging/gin_logger.go
+++ b/internal/logging/gin_logger.go
@@ -43,6 +43,11 @@ func GinLogrusLogger() gin.HandlerFunc {
start := time.Now()
path := c.Request.URL.Path
raw := util.MaskSensitiveQuery(c.Request.URL.RawQuery)
+ // OAuth state is a callback capability; authorization codes and issued
+ // client IDs must not be included in management access logs either.
+ if strings.HasPrefix(path, "/v8/management/oauth/") {
+ raw = ""
+ }
// Only generate request ID for AI API paths
var requestID string
@@ -89,7 +94,7 @@ func GinLogrusLogger() gin.HandlerFunc {
if requestID == "" {
requestID = "--------"
}
- logLine := fmt.Sprintf("%3d | %13v | %15s | %-7s \"%s\"", statusCode, latency, clientIP, method, path)
+ logLine := fmt.Sprintf("%3d | %13v | %15s | %-7s %q", statusCode, latency, clientIP, method, path)
if creditsUsed(c) {
logLine += " [credits]"
}
diff --git a/internal/logging/gin_logger_test.go b/internal/logging/gin_logger_test.go
index a31a761184d..ff1d44eeb69 100644
--- a/internal/logging/gin_logger_test.go
+++ b/internal/logging/gin_logger_test.go
@@ -4,6 +4,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
+ "strings"
"testing"
"github.com/gin-gonic/gin"
@@ -151,6 +152,89 @@ func TestGinLogrusLoggerAddsRequestIDForCodexBackend(t *testing.T) {
}
}
+func TestGinLogrusLoggerEscapesEncodedNewlineInPath(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ logger := log.StandardLogger()
+ previousHooks := logger.ReplaceHooks(make(log.LevelHooks))
+ previousLevel := logger.GetLevel()
+ hook := logtest.NewLocal(logger)
+ logger.SetLevel(log.InfoLevel)
+ t.Cleanup(func() { logger.ReplaceHooks(previousHooks); logger.SetLevel(previousLevel) })
+
+ engine := gin.New()
+ engine.Use(GinLogrusLogger())
+
+ recorder := httptest.NewRecorder()
+ engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/x%0A200%20forged", nil))
+ if recorder.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want %d", recorder.Code, http.StatusNotFound)
+ }
+ entries := hook.AllEntries()
+ if len(entries) != 1 {
+ t.Fatalf("log entries = %d, want 1", len(entries))
+ }
+ message := entries[0].Message
+ if strings.ContainsAny(message, "\r\n") {
+ t.Errorf("access log message contains a line break: %q", message)
+ }
+ if !strings.HasSuffix(message, `GET "/x\n200 forged"`) {
+ t.Errorf("access log message = %q, want suffix %q", message, `GET "/x\n200 forged"`)
+ }
+}
+
+func TestGinLogrusLoggerKeepsMaskedQueryReadable(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ logger := log.StandardLogger()
+ previousHooks := logger.ReplaceHooks(make(log.LevelHooks))
+ previousLevel := logger.GetLevel()
+ hook := logtest.NewLocal(logger)
+ logger.SetLevel(log.InfoLevel)
+ t.Cleanup(func() { logger.ReplaceHooks(previousHooks); logger.SetLevel(previousLevel) })
+
+ engine := gin.New()
+ engine.Use(GinLogrusLogger())
+ engine.GET("/v1/models", func(c *gin.Context) { c.Status(http.StatusOK) })
+
+ recorder := httptest.NewRecorder()
+ engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1/models?key=sk-0123456789cdef&limit=5", nil))
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
+ }
+ entries := hook.AllEntries()
+ if len(entries) != 1 {
+ t.Fatalf("log entries = %d, want 1", len(entries))
+ }
+ message := entries[0].Message
+ wantSuffix := `GET "/v1/models?key=sk-0...cdef&limit=5"`
+ if !strings.HasSuffix(message, wantSuffix) {
+ t.Errorf("access log message = %q, want suffix %q", message, wantSuffix)
+ }
+}
+
+func TestGinLogrusLoggerRedactsV8OAuthQueries(t *testing.T) {
+ logger := log.StandardLogger()
+ previousHooks := logger.ReplaceHooks(make(log.LevelHooks))
+ previousLevel := logger.GetLevel()
+ hook := logtest.NewLocal(logger)
+ logger.SetLevel(log.InfoLevel)
+ t.Cleanup(func() { logger.ReplaceHooks(previousHooks); logger.SetLevel(previousLevel) })
+ engine := gin.New()
+ engine.Use(GinLogrusLogger())
+ for _, path := range []string{"/v8/management/oauth/callback", "/v8/management/oauth/status", "/v8/management/oauth/session"} {
+ engine.GET(path, func(c *gin.Context) { c.Status(http.StatusBadRequest) })
+ hook.Reset()
+ recorder := httptest.NewRecorder()
+ engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path+"?state=secret-state&code=secret-code&client_id=issued-secret&error=secret-error", nil))
+ entries := hook.AllEntries()
+ if len(entries) != 1 || !strings.Contains(entries[0].Message, path) {
+ t.Fatal("expected access log for OAuth path")
+ }
+ if strings.Contains(entries[0].Message, "secret") || strings.Contains(entries[0].Message, "?") {
+ t.Fatal("OAuth callback or state leaked into access log")
+ }
+ }
+}
+
func TestGinLogrusLoggerHealthProbeStatus(t *testing.T) {
gin.SetMode(gin.TestMode)
logger := log.StandardLogger()
diff --git a/internal/misc/antigravity_version.go b/internal/misc/antigravity_version.go
index 679b815ac20..b416c556a88 100644
--- a/internal/misc/antigravity_version.go
+++ b/internal/misc/antigravity_version.go
@@ -18,9 +18,9 @@ import (
const (
// antigravityFallbackVersion is the client version reported when the hub
// manifest has not been fetched yet or cannot be reached. Cloud Code rejects
- // newer models for clients below 2.9.0, so this floor must stay at or above
+ // newer models for clients below 2.23.0, so this floor must stay at or above
// that version.
- antigravityFallbackVersion = "2.9.1"
+ antigravityFallbackVersion = "2.23.0"
antigravityHubPlatform = "darwin/arm64"
antigravityVersionCacheTTL = 6 * time.Hour
antigravityFetchTimeout = 10 * time.Second
diff --git a/internal/misc/antigravity_version_test.go b/internal/misc/antigravity_version_test.go
index eb36b5d78b2..f20c135bbea 100644
--- a/internal/misc/antigravity_version_test.go
+++ b/internal/misc/antigravity_version_test.go
@@ -48,10 +48,10 @@ func TestAntigravityLatestVersionUsesCurrentHubFallback(t *testing.T) {
}
}
-// Cloud Code resolves newer models only for clients reporting at least 2.9.0;
+// Cloud Code resolves newer models only for clients reporting at least 2.23.0;
// older versions get 404 Requested entity was not found.
func TestAntigravityFallbackVersionMeetsBackendFloor(t *testing.T) {
- const floorMajor, floorMinor = 2, 9
+ const floorMajor, floorMinor = 2, 23
var major, minor, patch int
if _, err := fmt.Sscanf(antigravityFallbackVersion, "%d.%d.%d", &major, &minor, &patch); err != nil {
diff --git a/internal/modelconfig/model_info.go b/internal/modelconfig/model_info.go
index 5cabc6ce376..5c8c6edeac9 100644
--- a/internal/modelconfig/model_info.go
+++ b/internal/modelconfig/model_info.go
@@ -20,7 +20,13 @@ func ResolveModelInfo(name, modelType string, support *registry.ThinkingSupport)
info.ID = trimmedName
info.Type = strings.TrimSpace(modelType)
if support != nil {
- info.Thinking = NormalizeThinkingSupport(support)
+ normalized := NormalizeThinkingSupport(support)
+ // Configured levels replace the catalog entry, but the way a known model
+ // turns thinking off is an upstream API fact, not a level choice.
+ if normalized.DisableType == "" && info.Thinking != nil {
+ normalized.DisableType = info.Thinking.DisableType
+ }
+ info.Thinking = normalized
}
info.UserDefined = false
return info
diff --git a/internal/modelconfig/model_info_test.go b/internal/modelconfig/model_info_test.go
index a12a404f1a8..72ce739e82a 100644
--- a/internal/modelconfig/model_info_test.go
+++ b/internal/modelconfig/model_info_test.go
@@ -4,6 +4,9 @@ import (
"testing"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
+ _ "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking/provider/claude"
+ "github.com/tidwall/gjson"
)
func TestResolveModelInfoUsesSuffixFreeStaticCapabilities(t *testing.T) {
@@ -47,6 +50,42 @@ func TestNormalizeThinkingSupportDerivesSpecialLevelFlags(t *testing.T) {
}
}
+// A claude-api-key model entry with its own thinking levels must keep the catalog
+// way of turning thinking off: claude-sonnet-5-5 rejects "disabled".
+func TestResolveModelInfoConfiguredThinkingKeepsCatalogDisableType(t *testing.T) {
+ configured := ®istry.ThinkingSupport{Levels: []string{"low", "medium", "high", "none"}}
+ info := ResolveModelInfo("claude-sonnet-5-5", "claude", configured)
+ if info == nil || info.Thinking == nil {
+ t.Fatalf("ResolveModelInfo() = %+v, want thinking support", info)
+ }
+ if got := info.Thinking.DisableType; got != "between_tools" {
+ t.Fatalf("DisableType = %q, want catalog between_tools", got)
+ }
+ if got := info.Thinking.Levels; len(got) != 4 || got[3] != "none" {
+ t.Fatalf("levels = %v, want configured [low medium high none]", got)
+ }
+
+ body := []byte(`{"model":"claude-sonnet-5-5","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"disabled"}}`)
+ out, err := thinking.ApplyThinkingWithModelInfo(body, nil, "claude-sonnet-5-5", "claude", "claude", "claude", info)
+ if err != nil {
+ t.Fatalf("ApplyThinkingWithModelInfo: %v", err)
+ }
+ if got := gjson.GetBytes(out, "thinking").Raw; got != `{"type":"between_tools"}` {
+ t.Fatalf("thinking = %s, want {\"type\":\"between_tools\"}; body=%s", got, out)
+ }
+
+ explicit := ResolveModelInfo("claude-sonnet-5-5", "claude", ®istry.ThinkingSupport{Levels: []string{"low"}, DisableType: "disabled"})
+ if got := explicit.Thinking.DisableType; got != "disabled" {
+ t.Fatalf("explicit DisableType = %q, want configured disabled", got)
+ }
+ if other := ResolveModelInfo("claude-opus-5", "claude", configured); other.Thinking.DisableType != "" {
+ t.Fatalf("claude-opus-5 DisableType = %q, want empty", other.Thinking.DisableType)
+ }
+ if variant := ResolveModelInfo("claude-sonnet-5-5[1m]", "claude", configured); variant.Thinking.DisableType != "between_tools" {
+ t.Fatalf("claude-sonnet-5-5[1m] DisableType = %q, want catalog between_tools", variant.Thinking.DisableType)
+ }
+}
+
func TestResolveModelInfoUnknownModelKeepsMissingCapability(t *testing.T) {
info := ResolveModelInfo("unknown-configured-model", "claude", nil)
if info == nil {
diff --git a/internal/pluginhost/adapters.go b/internal/pluginhost/adapters.go
index 1406998713e..7410b88e03f 100644
--- a/internal/pluginhost/adapters.go
+++ b/internal/pluginhost/adapters.go
@@ -150,6 +150,7 @@ func pluginThinkingSupportToRegistryThinkingSupport(thinking *pluginapi.Thinking
Max: thinking.Max,
ZeroAllowed: thinking.ZeroAllowed,
DynamicAllowed: thinking.DynamicAllowed,
+ DisableType: thinking.DisableType,
Levels: cloneStringSlice(thinking.Levels),
}
}
@@ -190,6 +191,7 @@ func registryThinkingSupportToPluginThinkingSupport(thinking *registry.ThinkingS
Max: thinking.Max,
ZeroAllowed: thinking.ZeroAllowed,
DynamicAllowed: thinking.DynamicAllowed,
+ DisableType: thinking.DisableType,
Levels: cloneStringSlice(thinking.Levels),
}
}
@@ -225,6 +227,8 @@ func cloneRegistryModels(in []*registry.ModelInfo) []*registry.ModelInfo {
return out
}
+// RegisterModels collects static catalogs for executor and per-auth registration.
+// Catalog-only plugins must not advertise availability independently of credentials.
func (h *Host) RegisterModels(ctx context.Context, modelRegistry modelRegistry) {
if h == nil || modelRegistry == nil {
return
@@ -232,8 +236,6 @@ func (h *Host) RegisterModels(ctx context.Context, modelRegistry modelRegistry)
snap := h.Snapshot()
records := h.activeRecordsFromSnapshot(snap)
- registrations := make([]modelClientRegistration, 0)
- nextClients := make(map[string]struct{})
nextProviders := make(map[string]string)
nextModelRegistrations := make(map[string]pluginModelRegistration)
for _, record := range records {
@@ -288,17 +290,14 @@ func (h *Host) RegisterModels(ctx context.Context, modelRegistry modelRegistry)
hasExecutor: record.plugin.Capabilities.Executor != nil,
}
nextProviders[record.id] = provider
- if record.plugin.Capabilities.Executor == nil {
- clientID := "plugin:" + record.id + ":" + provider
- registrations = append(registrations, modelClientRegistration{
- clientID: clientID,
- provider: provider,
- models: models,
- })
- nextClients[clientID] = struct{}{}
- }
}
- h.commitModelClients(snap, modelRegistry, registrations, nextClients, nextProviders, nextModelRegistrations)
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ if h.Snapshot() != snap {
+ return
+ }
+ h.modelProviders = nextProviders
+ h.modelRegistrations = nextModelRegistrations
}
// HasAuthModelProvider reports whether an active plugin declares per-auth model
diff --git a/internal/pluginhost/adapters_disable_type_test.go b/internal/pluginhost/adapters_disable_type_test.go
new file mode 100644
index 00000000000..710eb64d760
--- /dev/null
+++ b/internal/pluginhost/adapters_disable_type_test.go
@@ -0,0 +1,56 @@
+package pluginhost
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
+ _ "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking/provider/claude"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
+ "github.com/tidwall/gjson"
+)
+
+func TestPluginThinkingSupportCarriesDisableTypeBothWays(t *testing.T) {
+ plugin := &pluginapi.ThinkingSupport{ZeroAllowed: true, Levels: []string{"low", "high"}, DisableType: "between_tools"}
+ reg := pluginThinkingSupportToRegistryThinkingSupport(plugin)
+ if reg.DisableType != "between_tools" {
+ t.Fatalf("plugin -> registry DisableType = %q, want between_tools", reg.DisableType)
+ }
+ if back := registryThinkingSupportToPluginThinkingSupport(reg); back.DisableType != "between_tools" {
+ t.Fatalf("registry -> plugin DisableType = %q, want between_tools", back.DisableType)
+ }
+ if got := registryModelInfoToPluginModelInfo(®istry.ModelInfo{ID: "m", Thinking: reg}); got.Thinking == nil || got.Thinking.DisableType != "between_tools" {
+ t.Fatalf("registry model -> plugin model DisableType = %+v, want between_tools", got.Thinking)
+ }
+}
+
+func TestPluginThinkingSupportDisableTypeJSONCompatibility(t *testing.T) {
+ raw, err := json.Marshal(pluginapi.ThinkingSupport{Levels: []string{"low"}, DisableType: "between_tools"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var decoded pluginapi.ThinkingSupport
+ if err := json.Unmarshal(raw, &decoded); err != nil || decoded.DisableType != "between_tools" {
+ t.Fatalf("round trip = %+v, err %v", decoded, err)
+ }
+ var old pluginapi.ThinkingSupport
+ if err := json.Unmarshal([]byte(`{"Levels":["low"],"ZeroAllowed":true}`), &old); err != nil || old.DisableType != "" {
+ t.Fatalf("older plugin payload = %+v, err %v", old, err)
+ }
+}
+
+func TestPluginRegisteredSonnet55TurnsThinkingOffWithBetweenTools(t *testing.T) {
+ info := pluginModelInfoToRegistryModelInfo(pluginapi.ModelInfo{
+ ID: "claude-sonnet-5-5", Type: "claude",
+ Thinking: &pluginapi.ThinkingSupport{ZeroAllowed: true, Levels: []string{"low", "medium", "high"}, DisableType: "between_tools"},
+ })
+ body := []byte(`{"model":"claude-sonnet-5-5","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"disabled"}}`)
+ out, err := thinking.ApplyThinkingWithModelInfo(body, nil, "claude-sonnet-5-5", "claude", "claude", "claude", info)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := gjson.GetBytes(out, "thinking").Raw; got != `{"type":"between_tools"}` {
+ t.Fatalf("thinking = %s, want between_tools; body=%s", got, out)
+ }
+}
diff --git a/internal/pluginhost/adapters_executors.go b/internal/pluginhost/adapters_executors.go
index f4d261dfa06..552b2600f8c 100644
--- a/internal/pluginhost/adapters_executors.go
+++ b/internal/pluginhost/adapters_executors.go
@@ -44,8 +44,21 @@ func (h *Host) RegisterExecutors(manager executorManager, modelRegistry modelPro
claimedModels := make(map[string]struct{})
claimedProviders := make(map[string]string)
for _, registration := range registrations {
- if !registration.hasExecutor {
- appendModelsForProvider(providerModels, registration.provider, registration.models)
+ if registration.hasExecutor {
+ continue
+ }
+ appendModelsForProvider(providerModels, registration.provider, registration.models)
+ // Reserve native catalog models before plugin arbitration, independently of
+ // credential publication or exclusions. This preserves native priority without
+ // publishing availability, which remains credential-bound.
+ if h.providerHasNativeExecutor(manager, registration.provider) {
+ for _, model := range registration.models {
+ if model != nil {
+ if modelID := strings.TrimSpace(model.ID); modelID != "" {
+ claimedModels[modelID] = struct{}{}
+ }
+ }
+ }
}
}
for _, record := range records {
@@ -165,6 +178,7 @@ func (h *Host) commitExecutorState(snap *Snapshot, manager executorManager, mode
}
}
h.executorModelClientIDs = nextModelClients
+ h.mu.Unlock()
for _, registration := range registrations {
if registration.adapter == nil || registration.provider == "" {
@@ -179,7 +193,6 @@ func (h *Host) commitExecutorState(snap *Snapshot, manager executorManager, mode
}
manager.UnregisterExecutor(provider)
}
- h.mu.Unlock()
if modelRegistry == nil {
return
@@ -794,7 +807,6 @@ func (a *executorAdapter) observeAndTranslateExecutorStream(ctx context.Context,
var streamErr error
var publishOnce sync.Once
var lineBuffer []byte
- const maxLineBufferSize = 64 * 1024
publishResult := func() {
publishOnce.Do(func() {
@@ -866,10 +878,6 @@ func (a *executorAdapter) observeAndTranslateExecutorStream(ctx context.Context,
lineBuffer = nil
}
}
- if len(lineBuffer) > maxLineBufferSize {
- helps.ObservePluginExecutorStreamUsage(prepared.outputFormat.String(), lineBuffer, &streamUsage)
- lineBuffer = nil
- }
}
select {
diff --git a/internal/pluginhost/adapters_executors_usage_test.go b/internal/pluginhost/adapters_executors_usage_test.go
index f6a4f812dae..f2405c0f457 100644
--- a/internal/pluginhost/adapters_executors_usage_test.go
+++ b/internal/pluginhost/adapters_executors_usage_test.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"net/http"
+ "strings"
"testing"
"time"
@@ -993,3 +994,68 @@ func TestExecutorAdapterExecuteAttributesResponsesUsageToSelectedAuth(t *testing
t.Errorf("usage = %+v, want input=34 output=499 total=533", rec.Detail)
}
}
+
+func TestExecutorAdapterExecuteStreamLargeSSELineKeepsUsage(t *testing.T) {
+ plugin := newTestUsageCapturePlugin("plugin-provider-bigline")
+ registerTestUsagePlugin(t, "test-executor-adapter-bigline-usage", plugin)
+
+ executorRecord := normalizeTestCapabilityRecord(capabilityRecord{id: "executor-plugin-bigline"})
+ host := newHostWithRecords(executorRecord)
+
+ echo := strings.Repeat("i", 70*1024)
+ terminal := `event: response.completed` + "\n" +
+ `data: {"type":"response.completed","response":{"instructions":"` + echo +
+ `","usage":{"input_tokens":7000,"output_tokens":30,"total_tokens":7030}}}` + "\n\n"
+ prefix := "event: response.created\ndata: {\"type\":\"response.created\"}\n\n"
+ whole := prefix + terminal
+
+ // The first read ends 66KB into the terminal data line: more than 64KB
+ // has accumulated and the newline has not arrived yet.
+ streamChunks := make(chan pluginapi.ExecutorStreamChunk, 4)
+ streamChunks <- pluginapi.ExecutorStreamChunk{Payload: []byte(whole[:66000])}
+ streamChunks <- pluginapi.ExecutorStreamChunk{Payload: []byte(whole[66000:])}
+ close(streamChunks)
+
+ exec := &fakeExecutor{
+ identifier: "plugin-provider-bigline",
+ executeStream: func(ctx context.Context, req pluginapi.ExecutorRequest) (pluginapi.ExecutorStreamResponse, error) {
+ return pluginapi.ExecutorStreamResponse{Chunks: streamChunks}, nil
+ },
+ }
+
+ adapter := newExecutorAdapterForRecordForTest(host, executorRecord, exec,
+ []sdktranslator.Format{sdktranslator.FormatOpenAIResponse},
+ []sdktranslator.Format{sdktranslator.FormatOpenAIResponse},
+ )
+ adapter.provider = "plugin-provider-bigline"
+
+ auth := &coreauth.Auth{ID: "auth-bigline-1", Provider: "plugin-provider-bigline"}
+ req := coreexecutor.Request{Model: "test-model", Payload: []byte(`{"model":"test-model","stream":true}`)}
+ opts := coreexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ Stream: true,
+ }
+
+ streamRes, errExecuteStream := adapter.ExecuteStream(context.Background(), auth, req, opts)
+ if errExecuteStream != nil {
+ t.Fatalf("ExecuteStream returned unexpected error: %v", errExecuteStream)
+ }
+ var got strings.Builder
+ for chunk := range streamRes.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("unexpected chunk error: %v", chunk.Err)
+ }
+ got.Write(chunk.Payload)
+ }
+ if got.String() != whole {
+ t.Fatal("payload was altered")
+ }
+
+ rec := plugin.waitRecord(t, 500*time.Millisecond)
+ if rec.Detail.InputTokens != 7000 || rec.Detail.OutputTokens != 30 || rec.Detail.TotalTokens != 7030 {
+ t.Errorf("got usage input=%d output=%d total=%d, want input=7000 output=30 total=7030: "+
+ "the terminal frame's usage was dropped",
+ rec.Detail.InputTokens, rec.Detail.OutputTokens, rec.Detail.TotalTokens)
+ }
+}
diff --git a/internal/pluginhost/adapters_interceptors.go b/internal/pluginhost/adapters_interceptors.go
index b4401dfe1ce..7ce294dc75e 100644
--- a/internal/pluginhost/adapters_interceptors.go
+++ b/internal/pluginhost/adapters_interceptors.go
@@ -392,35 +392,6 @@ func (h *Host) HasRequestInterceptors() bool {
return false
}
-func (h *Host) commitModelClients(snap *Snapshot, modelRegistry modelRegistry, registrations []modelClientRegistration, nextClients map[string]struct{}, nextProviders map[string]string, nextModelRegistrations map[string]pluginModelRegistration) {
- if h == nil || modelRegistry == nil {
- return
- }
-
- staleClients := make([]string, 0)
- h.mu.Lock()
- if h.Snapshot() != snap {
- h.mu.Unlock()
- return
- }
- for clientID := range h.modelClientIDs {
- if _, okClient := nextClients[clientID]; !okClient {
- staleClients = append(staleClients, clientID)
- }
- }
- h.modelClientIDs = nextClients
- h.modelProviders = nextProviders
- h.modelRegistrations = nextModelRegistrations
- h.mu.Unlock()
-
- for _, registration := range registrations {
- modelRegistry.RegisterClient(registration.clientID, registration.provider, registration.models)
- }
- for _, clientID := range staleClients {
- modelRegistry.UnregisterClient(clientID)
- }
-}
-
func readAndRestoreRequestBody(r *http.Request) ([]byte, error) {
if r == nil || r.Body == nil {
return nil, nil
diff --git a/internal/pluginhost/adapters_test.go b/internal/pluginhost/adapters_test.go
index 6b76d2773f0..f4dd210d5a0 100644
--- a/internal/pluginhost/adapters_test.go
+++ b/internal/pluginhost/adapters_test.go
@@ -106,7 +106,7 @@ func TestExecutorNativeStreamResponseTranslatorExistsRequiresStreamTransform(t *
}
}
-func TestRegisterModelsRegistersProviderModelsAndClientID(t *testing.T) {
+func TestRegisterModelsRetainsCatalogWithoutPublishingClient(t *testing.T) {
modelRegistry := newFakeModelRegistry()
host := newHostWithRecords(capabilityRecord{
id: "alpha",
@@ -152,10 +152,14 @@ func TestRegisterModelsRegistersProviderModelsAndClientID(t *testing.T) {
host.RegisterModels(context.Background(), modelRegistry)
- reg := modelRegistry.clients["plugin:alpha:mixedprovider"]
- if reg == nil {
- t.Fatal("plugin:alpha:mixedprovider was not registered")
+ host.RegisterExecutors(newFakeExecutorManager(), modelRegistry)
+ if len(modelRegistry.clients) != 0 {
+ t.Fatalf("registered clients = %#v, want none without a routable credential", modelRegistry.clients)
+ }
+ if models := host.ModelsForProvider("mixedprovider"); len(models) != 1 || models[0].ID != "model-1" {
+ t.Fatalf("provider catalog = %#v, want model-1 for per-auth registration", models)
}
+ reg := host.modelRegistration("alpha")
if reg.provider != "mixedprovider" {
t.Fatalf("registered provider = %q, want mixedprovider", reg.provider)
}
@@ -230,17 +234,21 @@ func TestRegisterModelsUsesModelProviderStaticModels(t *testing.T) {
if !called {
t.Fatal("ModelProvider.StaticModels was not called")
}
- reg := modelRegistry.clients["plugin:alpha:plugin-provider"]
- if reg == nil {
- t.Fatal("plugin:alpha:plugin-provider was not registered")
+ host.RegisterExecutors(newFakeExecutorManager(), modelRegistry)
+ if len(modelRegistry.clients) != 0 {
+ t.Fatalf("registered clients = %#v, want none without a routable credential", modelRegistry.clients)
}
+ if models := host.ModelsForProvider("plugin-provider"); len(models) != 1 || models[0].ID != "model-static" {
+ t.Fatalf("provider catalog = %#v, want model-static for per-auth registration", models)
+ }
+ reg := host.modelRegistration("alpha")
if reg.provider != "plugin-provider" {
t.Fatalf("registered provider = %q, want plugin-provider", reg.provider)
}
if len(reg.models) != 1 || reg.models[0].ID != "model-static" || reg.models[0].DisplayName != "Static Model" {
t.Fatalf("registered models = %#v, want static model", reg.models)
}
- if _, okLegacy := modelRegistry.clients["plugin:alpha:legacy-provider"]; okLegacy {
+ if len(host.ModelsForProvider("legacy-provider")) != 0 {
t.Fatal("legacy ModelRegistrar path was used despite ModelProvider.StaticModels")
}
}
@@ -287,9 +295,12 @@ func TestRegisterModelsSkipsErrorEmptyAndInvalidModels(t *testing.T) {
if len(modelRegistry.clients) != 0 {
t.Fatalf("registered clients = %#v, want none", modelRegistry.clients)
}
+ if registrations := host.snapshotModelRegistrations(); len(registrations) != 0 {
+ t.Fatalf("model registrations = %#v, want none", registrations)
+ }
}
-func TestRegisterModelsPrunesStaleClientAfterSnapshotChange(t *testing.T) {
+func TestRegisterModelsPrunesStaleCatalogAfterSnapshotChange(t *testing.T) {
modelRegistry := newFakeModelRegistry()
host := newHostWithRecords(capabilityRecord{
id: "alpha",
@@ -297,7 +308,12 @@ func TestRegisterModelsPrunesStaleClientAfterSnapshotChange(t *testing.T) {
ModelRegistrar: staticModelRegistrar("provider-a", "model-a"),
}},
})
+ manager := newFakeExecutorManager()
host.RegisterModels(context.Background(), modelRegistry)
+ host.RegisterExecutors(manager, modelRegistry)
+ if models := host.ModelsForProvider("provider-a"); len(models) != 1 || models[0].ID != "model-a" {
+ t.Fatalf("initial provider catalog = %#v, want model-a", models)
+ }
setHostSnapshotForTest(host, true, capabilityRecord{
id: "bravo",
@@ -307,14 +323,15 @@ func TestRegisterModelsPrunesStaleClientAfterSnapshotChange(t *testing.T) {
})
host.RegisterModels(context.Background(), modelRegistry)
- if _, okClient := modelRegistry.clients["plugin:alpha:provider-a"]; okClient {
- t.Fatal("stale alpha client is still registered")
+ host.RegisterExecutors(manager, modelRegistry)
+ if len(host.ModelsForProvider("provider-a")) != 0 || len(host.modelRegistration("alpha").models) != 0 {
+ t.Fatal("stale alpha catalog is still retained")
}
- if modelRegistry.unregisters[0] != "plugin:alpha:provider-a" {
- t.Fatalf("unregistered clients = %#v, want alpha client first", modelRegistry.unregisters)
+ if models := host.ModelsForProvider("provider-b"); len(models) != 1 || models[0].ID != "model-b" {
+ t.Fatalf("provider catalog after reload = %#v, want model-b", models)
}
- if _, okClient := modelRegistry.clients["plugin:bravo:provider-b"]; !okClient {
- t.Fatal("bravo client was not registered")
+ if len(modelRegistry.clients) != 0 || len(modelRegistry.unregisters) != 0 {
+ t.Fatalf("catalog reload modified registry clients: clients=%#v, unregisters=%#v", modelRegistry.clients, modelRegistry.unregisters)
}
}
@@ -951,6 +968,46 @@ func TestRegisterExecutorsDoesNotUnregisterStaleProviderOwnedExternally(t *testi
}
}
+func TestRegisterExecutorsCommitsManagerChangesOutsideHostLock(t *testing.T) {
+ manager := newFakeExecutorManager()
+ exec := &fakeExecutor{identifier: "fallback-provider"}
+ host := newHostWithRecords(capabilityRecord{
+ id: "alpha",
+ plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
+ Executor: exec,
+ }},
+ })
+ host.modelProviders["alpha"] = "provider-a"
+
+ assertUnlocked := func(op string) {
+ t.Helper()
+ if !host.mu.TryLock() {
+ t.Fatalf("%s called while host.mu is held", op)
+ }
+ host.mu.Unlock()
+ }
+ manager.onExecutor = func(string) {
+ assertUnlocked("manager.Executor")
+ }
+ manager.onRegister = func(coreauth.ProviderExecutor) {
+ assertUnlocked("manager.RegisterExecutor")
+ }
+ manager.onUnregister = func(string) {
+ assertUnlocked("manager.UnregisterExecutor")
+ }
+
+ host.RegisterExecutors(manager, nil)
+ host.modelProviders["alpha"] = "provider-b"
+ host.RegisterExecutors(manager, nil)
+
+ if manager.registerCalls != 2 {
+ t.Fatalf("RegisterExecutor calls = %d, want 2", manager.registerCalls)
+ }
+ if len(manager.unregisters) != 1 || manager.unregisters[0] != "provider-a" {
+ t.Fatalf("unregistered providers = %#v, want [provider-a]", manager.unregisters)
+ }
+}
+
func TestNormalizeRequestChainsByPriority(t *testing.T) {
host := newHostWithRecords(
capabilityRecord{
@@ -3780,6 +3837,9 @@ type fakeExecutorManager struct {
executors map[string]coreauth.ProviderExecutor
registerCalls int
unregisters []string
+ onExecutor func(string)
+ onRegister func(coreauth.ProviderExecutor)
+ onUnregister func(string)
}
func newFakeExecutorManager() *fakeExecutorManager {
@@ -3789,16 +3849,25 @@ func newFakeExecutorManager() *fakeExecutorManager {
}
func (m *fakeExecutorManager) Executor(provider string) (coreauth.ProviderExecutor, bool) {
+ if m.onExecutor != nil {
+ m.onExecutor(provider)
+ }
executor, okExecutor := m.executors[provider]
return executor, okExecutor
}
func (m *fakeExecutorManager) RegisterExecutor(executor coreauth.ProviderExecutor) {
+ if m.onRegister != nil {
+ m.onRegister(executor)
+ }
m.registerCalls++
m.executors[executor.Identifier()] = executor
}
func (m *fakeExecutorManager) UnregisterExecutor(provider string) {
+ if m.onUnregister != nil {
+ m.onUnregister(provider)
+ }
delete(m.executors, provider)
m.unregisters = append(m.unregisters, provider)
}
diff --git a/internal/pluginhost/auth_callbacks.go b/internal/pluginhost/auth_callbacks.go
index a17bb9f9332..c2a935e2118 100644
--- a/internal/pluginhost/auth_callbacks.go
+++ b/internal/pluginhost/auth_callbacks.go
@@ -744,26 +744,7 @@ func (h *Host) resolvedAuthDir() string {
}
func (h *Host) authIDForPath(path string) string {
- path = strings.TrimSpace(path)
- if path == "" {
- return ""
- }
- path = filepath.Clean(path)
- if !filepath.IsAbs(path) {
- if abs, errAbs := filepath.Abs(path); errAbs == nil {
- path = abs
- }
- }
- id := path
- if authDir := h.resolvedAuthDir(); authDir != "" {
- if rel, errRel := filepath.Rel(authDir, path); errRel == nil && rel != "" {
- id = rel
- }
- }
- if runtime.GOOS == "windows" {
- id = strings.ToLower(id)
- }
- return id
+ return authIDForPath(path, h.resolvedAuthDir())
}
func authEmail(auth *coreauth.Auth) string {
diff --git a/internal/pluginhost/auth_callbacks_test.go b/internal/pluginhost/auth_callbacks_test.go
index 5606f727186..96e271c5215 100644
--- a/internal/pluginhost/auth_callbacks_test.go
+++ b/internal/pluginhost/auth_callbacks_test.go
@@ -848,3 +848,91 @@ func TestHostAuthSaveCallbackUnregistersModelsForRemovedStaleSameFileAuth(t *tes
t.Fatalf("GetModelsForClient(%q) = %#v, want empty slice after stale auth removal", staleID, models)
}
}
+
+func TestHostAuthSaveRelativeAuthDirPreservesIdentity(t *testing.T) {
+ for _, relative := range []bool{false, true} {
+ name := "absolute"
+ if relative {
+ name = "relative"
+ }
+ t.Run(name, func(t *testing.T) {
+ root := t.TempDir()
+ work := filepath.Join(root, "core")
+ authDir := filepath.Join(root, "oauth")
+ for _, dir := range []string{work, authDir} {
+ if errMkdir := os.MkdirAll(dir, 0o700); errMkdir != nil {
+ t.Fatal(errMkdir)
+ }
+ }
+ t.Chdir(work)
+ if relative {
+ authDir = filepath.Join("..", "oauth")
+ }
+ const fileName = "demo-credential.json"
+ payload := []byte(`{"type":"demo","access_token":"synthetic-not-a-real-token"}`)
+ path := filepath.Join(authDir, fileName)
+ if errWrite := os.WriteFile(path, payload, 0o600); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ host := newHostWithRecords(capabilityRecord{
+ id: "demo-plugin",
+ plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
+ AuthProvider: fakeAuthProvider{
+ identifier: "demo",
+ parseAuth: func(_ context.Context, req pluginapi.AuthParseRequest) (pluginapi.AuthParseResponse, error) {
+ return pluginapi.AuthParseResponse{
+ Handled: true,
+ Auth: pluginapi.AuthData{
+ Provider: "demo",
+ FileName: req.FileName,
+ StorageJSON: req.RawJSON,
+ },
+ }, nil
+ },
+ },
+ }},
+ })
+ host.runtimeConfig = &config.Config{AuthDir: authDir}
+ ctx := context.Background()
+ initial, handled, errParse := host.ParseAuth(ctx, pluginapi.AuthParseRequest{
+ Provider: "demo",
+ Path: path,
+ FileName: fileName,
+ RawJSON: payload,
+ })
+ if errParse != nil || !handled || initial == nil || initial.ID != fileName {
+ t.Fatalf("initial parse: auth=%#v handled=%v err=%v", initial, handled, errParse)
+ }
+ manager := coreauth.NewManager(nil, nil, nil)
+ host.SetAuthManager(manager)
+ if _, errRegister := manager.Register(coreauth.WithSkipPersist(ctx), initial); errRegister != nil {
+ t.Fatal(errRegister)
+ }
+ reg := registry.GetGlobalRegistry()
+ reg.RegisterClient(initial.ID, "demo", []*registry.ModelInfo{{ID: "demo-model"}})
+ t.Cleanup(func() {
+ reg.UnregisterClient(initial.ID)
+ for _, auth := range manager.List() {
+ reg.UnregisterClient(auth.ID)
+ }
+ })
+ request, errMarshal := json.Marshal(pluginapi.HostAuthSaveRequest{Name: fileName, JSON: payload})
+ if errMarshal != nil {
+ t.Fatal(errMarshal)
+ }
+ if _, errCall := host.callFromPlugin(ctx, pluginabi.MethodHostAuthSave, request); errCall != nil {
+ t.Fatal(errCall)
+ }
+ auths := manager.List()
+ if len(auths) != 1 {
+ t.Fatalf("got %d auth records, want 1", len(auths))
+ }
+ if auths[0].ID != initial.ID {
+ t.Errorf("Core changed auth ID: before=%q after=%q", initial.ID, auths[0].ID)
+ }
+ if models := reg.GetModelsForClient(initial.ID); len(models) != 1 {
+ t.Errorf("Core removed registered models: got %d, want 1", len(models))
+ }
+ })
+ }
+}
diff --git a/internal/pluginhost/auth_provider.go b/internal/pluginhost/auth_provider.go
index 34f96aaad82..9a715c4b03b 100644
--- a/internal/pluginhost/auth_provider.go
+++ b/internal/pluginhost/auth_provider.go
@@ -94,8 +94,24 @@ func authIDForPath(path, authDir string) string {
}
id := path
if authDir = strings.TrimSpace(authDir); authDir != "" {
- if rel, errRel := filepath.Rel(authDir, path); errRel == nil && rel != "" && !strings.HasPrefix(rel, "..") {
+ cleanDir := filepath.Clean(authDir)
+ cleanPath := filepath.Clean(path)
+ if rel, errRel := filepath.Rel(cleanDir, cleanPath); isRelativeWithinAuthDir(rel, errRel) {
id = rel
+ } else {
+ if !filepath.IsAbs(cleanDir) {
+ if absDir, errAbs := filepath.Abs(cleanDir); errAbs == nil {
+ cleanDir = absDir
+ }
+ }
+ if !filepath.IsAbs(cleanPath) {
+ if absPath, errAbs := filepath.Abs(cleanPath); errAbs == nil {
+ cleanPath = absPath
+ }
+ }
+ if rel, errRel := filepath.Rel(cleanDir, cleanPath); isRelativeWithinAuthDir(rel, errRel) {
+ id = rel
+ }
}
}
id = filepath.ToSlash(filepath.Clean(id))
@@ -105,6 +121,13 @@ func authIDForPath(path, authDir string) string {
return id
}
+func isRelativeWithinAuthDir(rel string, errRel error) bool {
+ if errRel != nil || rel == "" || rel == "." || rel == ".." {
+ return false
+ }
+ return !strings.HasPrefix(rel, ".."+string(filepath.Separator))
+}
+
func (h *Host) AuthProviderIdentifiers() []string {
if h == nil {
return nil
diff --git a/internal/pluginhost/host.go b/internal/pluginhost/host.go
index a1bca7f614e..17456e7243c 100644
--- a/internal/pluginhost/host.go
+++ b/internal/pluginhost/host.go
@@ -75,7 +75,7 @@ type Host struct {
runtimeConfig *config.Config
authManager *coreauth.Manager
modelExecutor modelExecutor
- modelClientIDs map[string]struct{}
+ modelClientIDs map[string]struct{} // Reserved for future use; catalog-only plugins do not populate it.
executorModelClientIDs map[string]struct{}
modelProviders map[string]string
modelRegistrations map[string]pluginModelRegistration
@@ -163,7 +163,7 @@ func (h *Host) Snapshot() *Snapshot {
return emptySnapshot()
}
-// PluginLoaded reports whether a plugin dynamic library is still loaded by the host.
+// PluginLoaded reports whether a plugin is still active in the host runtime.
func (h *Host) PluginLoaded(id string) bool {
if h == nil {
return false
@@ -181,7 +181,7 @@ func (h *Host) PluginLoaded(id string) bool {
return len(h.retired[id]) > 0
}
-// PluginBusy reports whether a plugin dynamic library is loaded or being loaded.
+// PluginBusy reports whether a plugin is active in the host runtime or being loaded.
func (h *Host) PluginBusy(id string) bool {
if h == nil {
return false
@@ -640,7 +640,8 @@ func (h *Host) withLoadedPluginFallbacks(files []pluginFile, items map[string]ru
return files
}
-// UnloadPlugin removes one plugin from the active runtime and closes its dynamic library.
+// UnloadPlugin removes one plugin from the active runtime.
+// The plugin image itself stays mapped for the lifetime of the process.
func (h *Host) UnloadPlugin(id string) bool {
return h.UnloadPluginContext(context.Background(), id)
}
@@ -779,7 +780,8 @@ func enabledPluginIDs(files []pluginFile, items map[string]runtimeItemConfig) ma
return ids
}
-// ShutdownAll removes active plugin capabilities and closes all loaded dynamic libraries.
+// ShutdownAll removes active plugin capabilities and detaches all plugins.
+// Plugin images stay mapped for the lifetime of the process.
func (h *Host) ShutdownAll() {
h.ShutdownAllContext(context.Background())
}
diff --git a/internal/pluginhost/loader_unix.go b/internal/pluginhost/loader_unix.go
index 412aa4676d0..636413a88d9 100644
--- a/internal/pluginhost/loader_unix.go
+++ b/internal/pluginhost/loader_unix.go
@@ -52,10 +52,6 @@ static const char* cliproxy_dlerror(void) {
return dlerror();
}
-static int cliproxy_dlclose(void* handle) {
- return dlclose(handle);
-}
-
static int cliproxy_call_init(void* fn, const cliproxy_host_api* host, cliproxy_plugin_api* plugin) {
return ((cliproxy_plugin_init_fn)fn)(host, plugin);
}
@@ -99,6 +95,7 @@ type dynamicLibraryLoader struct{}
type dynamicLibraryClient struct {
handle unsafe.Pointer
+ tempPath string
hostAPI *C.cliproxy_host_api
hostCtx unsafe.Pointer
api C.cliproxy_plugin_api
@@ -112,31 +109,37 @@ func defaultPluginLoader() pluginLoader {
}
func (dynamicLibraryLoader) Open(file pluginFile, host *Host) (pluginClient, error) {
- cPath := C.CString(file.Path)
+ loadPath, errShadow := shadowCopyPlugin(file)
+ if errShadow != nil {
+ return nil, errShadow
+ }
+ cPath := C.CString(loadPath)
defer C.free(unsafe.Pointer(cPath))
handle := C.cliproxy_dlopen(cPath)
if handle == nil {
+ removeShadowPlugin(loadPath)
return nil, fmt.Errorf("dlopen %s: %s", file.Path, dlerrorString())
}
+ // From here on the image must never be unloaded: dlopen may already
+ // have started the plugin runtime, and a Go c-shared runtime cannot be
+ // removed from the host process safely. Failure paths below only detach
+ // the client logically and deliberately leak the mapped image together
+ // with any host callback state the runtime may reference.
cSymbol := C.CString("cliproxy_plugin_init")
initSymbol := C.cliproxy_dlsym(handle, cSymbol)
C.free(unsafe.Pointer(cSymbol))
if initSymbol == nil {
- C.cliproxy_dlclose(handle)
return nil, fmt.Errorf("missing cliproxy_plugin_init: %s", dlerrorString())
}
hostAPI := (*C.cliproxy_host_api)(C.malloc(C.size_t(unsafe.Sizeof(C.cliproxy_host_api{}))))
if hostAPI == nil {
- C.cliproxy_dlclose(handle)
return nil, fmt.Errorf("allocate host api")
}
hostCtx := C.malloc(C.size_t(unsafe.Sizeof(C.uintptr_t(0))))
if hostCtx == nil {
- C.free(unsafe.Pointer(hostAPI))
- C.cliproxy_dlclose(handle)
return nil, fmt.Errorf("allocate host context")
}
id := hostCallbackID.Add(1)
@@ -148,6 +151,7 @@ func (dynamicLibraryLoader) Open(file pluginFile, host *Host) (pluginClient, err
client := &dynamicLibraryClient{
handle: handle,
+ tempPath: loadPath,
hostAPI: hostAPI,
hostCtx: hostCtx,
host: host,
@@ -156,15 +160,15 @@ func (dynamicLibraryLoader) Open(file pluginFile, host *Host) (pluginClient, err
}
rc := C.cliproxy_call_init(initSymbol, hostAPI, &client.api)
if rc != 0 {
- client.Shutdown()
+ client.closeAfterOpenFailure()
return nil, fmt.Errorf("cliproxy_plugin_init returned %d", int(rc))
}
if uint32(client.api.abi_version) != pluginHostABIVersion {
- client.Shutdown()
+ client.closeAfterOpenFailure()
return nil, fmt.Errorf("plugin ABI version %d is not supported", uint32(client.api.abi_version))
}
if client.api.call == nil || client.api.free_buffer == nil {
- client.Shutdown()
+ client.closeAfterOpenFailure()
return nil, fmt.Errorf("plugin function table is incomplete")
}
return client, nil
@@ -215,6 +219,22 @@ func (c *dynamicLibraryClient) Call(ctx context.Context, method string, request
}
func (c *dynamicLibraryClient) Shutdown() {
+ c.close()
+}
+
+func (c *dynamicLibraryClient) closeAfterOpenFailure() {
+ c.close()
+}
+
+// close detaches the client logically without unloading the plugin image.
+// Go plugin libraries are not safe to hot-unload from the host process: the
+// plugin runtime, its threads and its signal handlers stay resident after
+// dlclose, so a later load in the same process forwards signals into
+// unmapped memory and spins. This also applies when initialization failed,
+// because dlopen may already have started the runtime. The plugin was
+// loaded from a shadow copy, so keeping the image mapped does not block
+// deleting or replacing the source artifact.
+func (c *dynamicLibraryClient) close() {
if c == nil {
return
}
@@ -226,19 +246,16 @@ func (c *dynamicLibraryClient) Shutdown() {
C.cliproxy_shutdown_plugin(c.api.shutdown)
c.api.shutdown = nil
}
+ c.api.call = nil
+ c.api.free_buffer = nil
if c.hostCtx != nil {
+ // Drop the callback registration so repeated reloads do not pin the
+ // host forever. The C host context and host API block themselves are
+ // deliberately leaked: the resident plugin runtime may still pass
+ // the context back into cliproxyHostCall, which reads the callback
+ // id from it before failing the lookup.
id := uintptr(*(*C.uintptr_t)(c.hostCtx))
hostCallbackEntries.Delete(id)
- C.free(c.hostCtx)
- c.hostCtx = nil
- }
- if c.hostAPI != nil {
- C.free(unsafe.Pointer(c.hostAPI))
- c.hostAPI = nil
- }
- if c.handle != nil {
- C.cliproxy_dlclose(c.handle)
- c.handle = nil
}
}
diff --git a/internal/pluginhost/loader_unix_test.go b/internal/pluginhost/loader_unix_test.go
new file mode 100644
index 00000000000..9bfd515a1af
--- /dev/null
+++ b/internal/pluginhost/loader_unix_test.go
@@ -0,0 +1,200 @@
+//go:build cgo && (linux || darwin || freebsd)
+
+package pluginhost
+
+import (
+ "context"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "testing"
+)
+
+func testHostCallbackID(t *testing.T, client *dynamicLibraryClient) uintptr {
+ t.Helper()
+ if client == nil || client.hostCtx == nil {
+ t.Fatal("plugin client host context is nil")
+ }
+ return *(*uintptr)(client.hostCtx)
+}
+
+func buildReloadTestPlugin(t *testing.T, version int, outPath string) {
+ t.Helper()
+ goTool, errGo := exec.LookPath("go")
+ if errGo != nil {
+ t.Skipf("go toolchain unavailable: %v", errGo)
+ }
+ sourceDir := filepath.Join("testdata", "loader_unix_reload_plugin")
+ cmd := exec.Command(goTool, "build", "-buildmode=c-shared", "-o", outPath, ".")
+ cmd.Dir = sourceDir
+ cflags := "-DPLUGIN_VERSION=1"
+ switch version {
+ case 2:
+ cflags = "-DPLUGIN_VERSION=2"
+ case 3:
+ cflags = "-DPLUGIN_VERSION=3"
+ }
+ cmd.Env = append(os.Environ(), "CGO_ENABLED=1", "CGO_CFLAGS="+cflags)
+ if output, errBuild := cmd.CombinedOutput(); errBuild != nil {
+ t.Fatalf("build test plugin (v%d): %v\n%s", version, errBuild, output)
+ }
+}
+
+func TestUnixPluginReloadAfterShutdownUsesReplacedFile(t *testing.T) {
+ dir := t.TempDir()
+ ext := ".so"
+ if runtime.GOOS == "darwin" {
+ ext = ".dylib"
+ }
+ pluginPath := filepath.Join(dir, "alpha"+ext)
+ buildReloadTestPlugin(t, 1, pluginPath)
+
+ host := New()
+ file := pluginFile{ID: "alpha", Path: pluginPath, Version: "1"}
+ client, errOpen := dynamicLibraryLoader{}.Open(file, host)
+ if errOpen != nil {
+ t.Fatalf("Open() v1 error = %v", errOpen)
+ }
+ first, okFirst := client.(*dynamicLibraryClient)
+ if !okFirst {
+ t.Fatalf("Open() client type = %T, want *dynamicLibraryClient", client)
+ }
+ firstHandle := first.handle
+ if firstHandle == nil {
+ t.Fatal("first client handle is nil")
+ }
+ resp, errCall := client.Call(context.Background(), "test.marker", nil)
+ if errCall != nil {
+ t.Fatalf("Call() v1 error = %v", errCall)
+ }
+ if string(resp) != "v1" {
+ t.Fatalf("Call() v1 response = %q, want v1", resp)
+ }
+ firstHostCtx := first.hostCtx
+ firstCallbackID := testHostCallbackID(t, first)
+
+ client.Shutdown()
+
+ // The Go runtime of a loaded plugin stays resident in the host process
+ // even after the logical shutdown: unloading a Go c-shared image is not
+ // safe, so the handle and host callback state must be retained while the
+ // callback instance is closed for further work.
+ if first.handle == nil || first.handle != firstHandle {
+ t.Fatalf("handle after Shutdown() = %v, want retained handle %v (library must stay mapped)", first.handle, firstHandle)
+ }
+ if first.api.call != nil || first.api.free_buffer != nil || first.api.shutdown != nil {
+ t.Fatal("plugin API table not cleared by Shutdown(); detached client could still be called")
+ }
+ if first.hostCtx != firstHostCtx {
+ t.Fatalf("hostCtx after Shutdown() = %v, want retained %v for the still-resident runtime", first.hostCtx, firstHostCtx)
+ }
+ if _, okEntry := hostCallbackEntries.Load(firstCallbackID); okEntry {
+ t.Fatal("host callback entry retained by Shutdown(); repeated reloads would pin the host forever")
+ }
+ if first.instance == nil || !first.instance.closed.Load() {
+ t.Fatal("callback instance not closed by Shutdown(); stale runtime could still perform work")
+ }
+
+ // Replace the plugin binary under the same path (update) and load again
+ // without restarting the process. It must map the new image, not reuse
+ // the previously loaded one.
+ buildReloadTestPlugin(t, 2, pluginPath)
+ file.Version = "2"
+ secondClient, errOpenSecond := dynamicLibraryLoader{}.Open(file, host)
+ if errOpenSecond != nil {
+ t.Fatalf("Open() v2 error = %v", errOpenSecond)
+ }
+ second, okSecond := secondClient.(*dynamicLibraryClient)
+ if !okSecond {
+ t.Fatalf("Open() second client type = %T, want *dynamicLibraryClient", secondClient)
+ }
+ respSecond, errCallSecond := secondClient.Call(context.Background(), "test.marker", nil)
+ if errCallSecond != nil {
+ t.Fatalf("Call() v2 error = %v", errCallSecond)
+ }
+ if string(respSecond) != "v2" {
+ t.Fatalf("Call() v2 response = %q, want v2 (replaced plugin file was not freshly mapped)", respSecond)
+ }
+ if second.handle == firstHandle {
+ t.Fatal("second handle reuses the first image after the plugin file changed")
+ }
+ baselineCalls := testShutdownCallCount(t, secondClient)
+
+ secondClient.Shutdown()
+
+ // Reopening without a content change must reuse the already resident
+ // image instead of mapping a second copy of the same runtime, and the
+ // shutdown above must have reached that image exactly once.
+ thirdClient, errOpenThird := dynamicLibraryLoader{}.Open(file, host)
+ if errOpenThird != nil {
+ t.Fatalf("Open() third error = %v", errOpenThird)
+ }
+ third, okThird := thirdClient.(*dynamicLibraryClient)
+ if !okThird {
+ t.Fatalf("Open() third client type = %T, want *dynamicLibraryClient", thirdClient)
+ }
+ if third.handle != second.handle {
+ t.Fatalf("third handle = %v, want reused resident handle %v", third.handle, second.handle)
+ }
+ respThird, errCallThird := thirdClient.Call(context.Background(), "test.marker", nil)
+ if errCallThird != nil {
+ t.Fatalf("Call() third error = %v", errCallThird)
+ }
+ if string(respThird) != "v2" {
+ t.Fatalf("Call() third response = %q, want v2", respThird)
+ }
+ if gotCalls := testShutdownCallCount(t, thirdClient); gotCalls != baselineCalls+1 {
+ t.Fatalf("plugin shutdown calls = %d, want baseline %d + 1", gotCalls, baselineCalls)
+ }
+ thirdClient.Shutdown()
+}
+
+func testShutdownCallCount(t *testing.T, client pluginClient) int {
+ t.Helper()
+ calls, errCalls := client.Call(context.Background(), "test.shutdown_calls", nil)
+ if errCalls != nil {
+ t.Fatalf("Call() shutdown_calls error = %v", errCalls)
+ }
+ count, errParse := strconv.Atoi(string(calls))
+ if errParse != nil {
+ t.Fatalf("parse shutdown calls %q: %v", calls, errParse)
+ }
+ return count
+}
+
+func TestUnixPluginOpenFailureKeepsImageUsableOnRetry(t *testing.T) {
+ dir := t.TempDir()
+ pluginPath := filepath.Join(dir, "failing"+testPluginLibraryExtension())
+ buildReloadTestPlugin(t, 3, pluginPath)
+
+ host := New()
+ file := pluginFile{ID: "failing", Path: pluginPath, Version: "3"}
+ loader := dynamicLibraryLoader{}
+ if _, errOpen := loader.Open(file, host); errOpen == nil {
+ t.Fatal("Open() with failing plugin init succeeded, want error")
+ }
+
+ // The failed load must not unload the image: retrying the same file
+ // must fail the same way instead of crashing or spinning, and loading
+ // a fixed build under the same path must succeed afterwards.
+ if _, errOpen := loader.Open(file, host); errOpen == nil {
+ t.Fatal("Open() retry with failing plugin init succeeded, want error")
+ }
+
+ buildReloadTestPlugin(t, 1, pluginPath)
+ file.Version = "1"
+ client, errOpen := loader.Open(file, host)
+ if errOpen != nil {
+ t.Fatalf("Open() fixed plugin error = %v", errOpen)
+ }
+ resp, errCall := client.Call(context.Background(), "test.marker", nil)
+ if errCall != nil {
+ t.Fatalf("Call() fixed plugin error = %v", errCall)
+ }
+ if string(resp) != "v1" {
+ t.Fatalf("Call() fixed plugin response = %q, want v1", resp)
+ }
+ client.Shutdown()
+}
diff --git a/internal/pluginhost/loader_windows.go b/internal/pluginhost/loader_windows.go
index adb7c6a7b09..bfffeed7183 100644
--- a/internal/pluginhost/loader_windows.go
+++ b/internal/pluginhost/loader_windows.go
@@ -4,15 +4,8 @@ package pluginhost
import (
"context"
- "crypto/sha256"
- "encoding/hex"
- "errors"
"fmt"
- "io"
- "os"
- "path/filepath"
"runtime"
- "strings"
"sync"
"sync/atomic"
"syscall"
@@ -27,6 +20,11 @@ type windowsBuffer struct {
len uintptr
}
+type windowsResidentHostBlocks struct {
+ hostCtx *uintptr
+ hostAPI *windowsHostAPI
+}
+
type windowsHostAPI struct {
abiVersion uint32
hostCtx uintptr
@@ -46,14 +44,13 @@ var (
windowsHostCallbackEntries sync.Map
windowsHostCallCallback = syscall.NewCallback(windowsHostCall)
windowsHostFreeCallback = syscall.NewCallback(windowsHostFree)
- shadowPluginCleanupOnce sync.Once
-)
-
-const (
- shadowPluginPrefix = "cliproxy-plugin-"
- shadowPluginTempPrefix = ".cliproxy-plugin-"
- shadowPluginProcessDirPrefix = "pid-"
- shadowPluginDigestLength = 32
+ // windowsResidentHostState pins the host context and host API blocks of
+ // every loaded plugin for the lifetime of the process. A resident
+ // plugin runtime may pass the context back into windowsHostCall at any
+ // time, long after its client was detached, and native pointers are
+ // not GC roots. Only these small blocks are pinned, never the client
+ // or the host.
+ windowsResidentHostState sync.Map
)
type dynamicLibraryLoader struct{}
@@ -85,8 +82,8 @@ func (dynamicLibraryLoader) Open(file pluginFile, host *Host) (pluginClient, err
}
proc, errProc := dll.FindProc("cliproxy_plugin_init")
if errProc != nil {
- _ = dll.Release()
- removeShadowPlugin(loadPath)
+ // Do not release the module: loading it may already have started
+ // the plugin runtime, which cannot be unloaded safely.
return nil, errProc
}
id := windowsHostCallbackID.Add(1)
@@ -94,6 +91,13 @@ func (dynamicLibraryLoader) Open(file pluginFile, host *Host) (pluginClient, err
host.registerHostCallbackInstance(file.ID, instance)
hostCtx := new(uintptr)
*hostCtx = id
+ hostAPI := &windowsHostAPI{
+ abiVersion: pluginHostABIVersion,
+ hostCtx: uintptr(unsafe.Pointer(hostCtx)),
+ call: windowsHostCallCallback,
+ freeBuffer: windowsHostFreeCallback,
+ }
+ windowsResidentHostState.Store(id, windowsResidentHostBlocks{hostCtx: hostCtx, hostAPI: hostAPI})
windowsHostCallbackEntries.Store(id, dynamicHostCallbackEntry{host: host, pluginID: file.ID, instance: instance})
client := &dynamicLibraryClient{
dll: dll,
@@ -102,12 +106,7 @@ func (dynamicLibraryLoader) Open(file pluginFile, host *Host) (pluginClient, err
host: host,
pluginID: file.ID,
instance: instance,
- hostAPI: &windowsHostAPI{
- abiVersion: pluginHostABIVersion,
- hostCtx: uintptr(unsafe.Pointer(hostCtx)),
- call: windowsHostCallCallback,
- freeBuffer: windowsHostFreeCallback,
- },
+ hostAPI: hostAPI,
}
rc, _, errCall := proc.Call(uintptr(unsafe.Pointer(client.hostAPI)), uintptr(unsafe.Pointer(&client.api)))
if rc != 0 {
@@ -125,141 +124,6 @@ func (dynamicLibraryLoader) Open(file pluginFile, host *Host) (pluginClient, err
return client, nil
}
-func shadowCopyPlugin(file pluginFile) (string, error) {
- dir, errDir := shadowPluginDir()
- if errDir != nil {
- return "", errDir
- }
- shadowPluginCleanupOnce.Do(func() {
- removeStaleShadowPlugins(dir)
- })
- return shadowCopyPluginToDir(file, dir)
-}
-
-func shadowCopyPluginToDir(file pluginFile, dir string) (string, error) {
- source := filepath.Clean(file.Path)
- tmp, errTemp := os.CreateTemp(dir, shadowPluginTempPrefix+file.ID+"-*"+filepath.Ext(source))
- if errTemp != nil {
- return "", errTemp
- }
- tmpName := tmp.Name()
- removeTemp := true
- defer func() {
- if removeTemp {
- removeShadowPlugin(tmpName)
- }
- }()
-
- in, errOpen := os.Open(source)
- if errOpen != nil {
- _ = tmp.Close()
- return "", errOpen
- }
- defer func() {
- _ = in.Close()
- }()
- hasher := sha256.New()
- size, errCopy := io.Copy(io.MultiWriter(tmp, hasher), in)
- if errCopy != nil {
- _ = tmp.Close()
- return "", errCopy
- }
- if errClose := tmp.Close(); errClose != nil {
- return "", errClose
- }
- digest := hex.EncodeToString(hasher.Sum(nil))
- target := shadowPluginPath(dir, file.ID, digest, filepath.Ext(source))
- if shadowPluginMatches(target, size, digest) {
- return target, nil
- }
- if errRemove := os.Remove(target); errRemove != nil && !errors.Is(errRemove, os.ErrNotExist) {
- if shadowPluginMatches(target, size, digest) {
- return target, nil
- }
- removeShadowPlugin(target)
- return "", fmt.Errorf("remove stale shadow plugin: %w", errRemove)
- }
- if errRename := os.Rename(tmpName, target); errRename != nil {
- if shadowPluginMatches(target, size, digest) {
- return target, nil
- }
- return "", fmt.Errorf("move shadow plugin: %w", errRename)
- }
- removeTemp = false
- return target, nil
-}
-
-func shadowPluginDir() (string, error) {
- dir := filepath.Join(os.TempDir(), "cliproxy-pluginhost", shadowPluginProcessDirName(os.Getpid()))
- if errMkdir := os.MkdirAll(dir, 0o700); errMkdir != nil {
- return "", errMkdir
- }
- return dir, nil
-}
-
-func shadowPluginProcessDirName(pid int) string {
- return fmt.Sprintf("%s%d", shadowPluginProcessDirPrefix, pid)
-}
-
-func removeShadowPlugin(path string) {
- if path == "" {
- return
- }
- if errRemove := os.Remove(path); errRemove == nil {
- return
- }
- pathPtr, errPath := windows.UTF16PtrFromString(path)
- if errPath != nil {
- return
- }
- _ = windows.MoveFileEx(pathPtr, nil, windows.MOVEFILE_DELAY_UNTIL_REBOOT)
-}
-
-func removeStaleShadowPlugins(dir string) {
- entries, errRead := os.ReadDir(dir)
- if errRead != nil {
- return
- }
- for _, entry := range entries {
- if entry == nil || entry.IsDir() {
- continue
- }
- name := entry.Name()
- if strings.HasPrefix(name, shadowPluginPrefix) || strings.HasPrefix(name, shadowPluginTempPrefix) {
- removeShadowPlugin(filepath.Join(dir, name))
- }
- }
-}
-
-func shadowPluginPath(dir string, id string, digest string, extension string) string {
- if len(digest) > shadowPluginDigestLength {
- digest = digest[:shadowPluginDigestLength]
- }
- return filepath.Join(dir, shadowPluginPrefix+id+"-"+digest+extension)
-}
-
-func shadowPluginMatches(path string, size int64, digest string) bool {
- info, errStat := os.Stat(path)
- if errStat != nil {
- return false
- }
- if !info.Mode().IsRegular() || info.Size() != size {
- return false
- }
- file, errOpen := os.Open(path)
- if errOpen != nil {
- return false
- }
- defer func() {
- _ = file.Close()
- }()
- hasher := sha256.New()
- if _, errCopy := io.Copy(hasher, file); errCopy != nil {
- return false
- }
- return hex.EncodeToString(hasher.Sum(nil)) == digest
-}
-
func (c *dynamicLibraryClient) callbackInstance() *hostCallbackInstance {
if c == nil {
return nil
@@ -327,17 +191,21 @@ func (c *dynamicLibraryClient) Call(ctx context.Context, method string, request
}
func (c *dynamicLibraryClient) Shutdown() {
- // Windows Go DLLs are not safe to hot-unload from the host process.
- // The plugin was loaded from a shadow copy, so keeping the module mapped
- // does not block deleting or replacing the source artifact.
- c.close(false)
+ c.close()
}
func (c *dynamicLibraryClient) closeAfterOpenFailure() {
- c.close(true)
+ c.close()
}
-func (c *dynamicLibraryClient) close(releaseDLL bool) {
+// close detaches the client logically without unloading the plugin module.
+// Go plugin DLLs are not safe to hot-unload from the host process: the
+// plugin runtime, its threads and its signal handlers stay resident after
+// FreeLibrary. This also applies when initialization failed, because
+// loading the module may already have started the runtime. The plugin was
+// loaded from a shadow copy, so keeping the module mapped does not block
+// deleting or replacing the source artifact.
+func (c *dynamicLibraryClient) close() {
if c == nil {
return
}
@@ -349,18 +217,15 @@ func (c *dynamicLibraryClient) close(releaseDLL bool) {
_, _, _ = syscall.SyscallN(c.api.shutdown)
c.api.shutdown = 0
}
+ c.api.call = 0
+ c.api.freeBuffer = 0
if c.hostCtx != nil {
+ // Drop the callback registration so repeated reloads do not pin
+ // the host forever. The context and host API blocks stay pinned in
+ // windowsResidentHostState because the resident plugin runtime may
+ // still dereference them on late callbacks.
windowsHostCallbackEntries.Delete(*c.hostCtx)
- c.hostCtx = nil
- }
- if c.dll != nil {
- if releaseDLL {
- _ = c.dll.Release()
- }
- c.dll = nil
}
- removeShadowPlugin(c.tempPath)
- c.tempPath = ""
}
func windowsHostCall(hostCtx uintptr, methodPtr uintptr, requestPtr uintptr, requestLen uintptr, responsePtr uintptr) uintptr {
diff --git a/internal/pluginhost/loader_windows_test.go b/internal/pluginhost/loader_windows_test.go
index 7bc2e4e6047..5453eba0d04 100644
--- a/internal/pluginhost/loader_windows_test.go
+++ b/internal/pluginhost/loader_windows_test.go
@@ -4,13 +4,7 @@ package pluginhost
import (
"context"
- "crypto/sha256"
- "encoding/hex"
- "fmt"
- "os"
- "path/filepath"
"runtime"
- "strings"
"sync/atomic"
"syscall"
"testing"
@@ -21,6 +15,74 @@ import (
var testReentrantHostCallback uintptr
+var testWindowsCloseShutdownCalls atomic.Int64
+
+func testWindowsCloseShutdown() uintptr {
+ testWindowsCloseShutdownCalls.Add(1)
+ return 0
+}
+
+func TestWindowsClosedClientKeepsResidentHostState(t *testing.T) {
+ host := New()
+ id := windowsHostCallbackID.Add(1)
+ instance := &hostCallbackInstance{}
+ hostCtx := new(uintptr)
+ *hostCtx = id
+ hostAPI := &windowsHostAPI{
+ abiVersion: pluginHostABIVersion,
+ hostCtx: uintptr(unsafe.Pointer(hostCtx)),
+ call: windowsHostCallCallback,
+ freeBuffer: windowsHostFreeCallback,
+ }
+ windowsResidentHostState.Store(id, windowsResidentHostBlocks{hostCtx: hostCtx, hostAPI: hostAPI})
+ windowsHostCallbackEntries.Store(id, dynamicHostCallbackEntry{host: host, pluginID: "alpha", instance: instance})
+ shutdownsBefore := testWindowsCloseShutdownCalls.Load()
+ client := &dynamicLibraryClient{
+ hostCtx: hostCtx,
+ hostAPI: hostAPI,
+ host: host,
+ pluginID: "alpha",
+ instance: instance,
+ api: windowsPluginAPI{
+ call: 1,
+ freeBuffer: 1,
+ shutdown: syscall.NewCallback(testWindowsCloseShutdown),
+ },
+ }
+ ctxPtr := uintptr(unsafe.Pointer(hostCtx))
+
+ client.close()
+
+ if _, okEntry := windowsHostCallbackEntries.Load(id); okEntry {
+ t.Fatal("callback registration retained after close; repeated reloads would pin the host")
+ }
+ rawBlocks, okBlocks := windowsResidentHostState.Load(id)
+ if !okBlocks {
+ t.Fatal("resident host state missing after close; late plugin callbacks could dereference freed memory")
+ }
+ blocks, okBlocks := rawBlocks.(windowsResidentHostBlocks)
+ if !okBlocks || blocks.hostCtx != hostCtx || blocks.hostAPI != hostAPI {
+ t.Fatalf("resident host blocks = %+v, want pinned hostCtx/hostAPI", rawBlocks)
+ }
+ if client.api.call != 0 || client.api.freeBuffer != 0 || client.api.shutdown != 0 {
+ t.Fatalf("plugin API table not cleared by close: %+v", client.api)
+ }
+ if gotShutdowns := testWindowsCloseShutdownCalls.Load(); gotShutdowns != shutdownsBefore+1 {
+ t.Fatalf("plugin shutdown calls = %d, want %d", gotShutdowns, shutdownsBefore+1)
+ }
+
+ // A late callback from the resident plugin runtime dereferences the
+ // pinned context, finds no registration, and fails safely.
+ methodBytes, errMethod := syscall.BytePtrFromString("host.log")
+ if errMethod != nil {
+ t.Fatalf("BytePtrFromString() error = %v", errMethod)
+ }
+ if rc := windowsHostCall(ctxPtr, uintptr(unsafe.Pointer(methodBytes)), 0, 0, 0); rc == 0 {
+ t.Fatal("windowsHostCall() with detached context succeeded, want failure")
+ }
+ windowsResidentHostState.Delete(id)
+}
+
func TestDynamicLibraryClientCallSurvivesReentrantCallbackStackGrowth(t *testing.T) {
testReentrantHostCallback = syscall.NewCallback(testGrowHostCallbackStack)
client := newGuardedPluginClient(&dynamicLibraryClient{api: windowsPluginAPI{
@@ -156,155 +218,3 @@ func testLivenessPluginCall(methodPtr, requestPtr, requestLen, responsePtr uintp
response.len = uintptr(len(raw))
return 0
}
-
-func TestShadowPluginDirIsProcessScoped(t *testing.T) {
- dir, errDir := shadowPluginDir()
- if errDir != nil {
- t.Fatalf("shadowPluginDir() error = %v", errDir)
- }
- want := filepath.Join(os.TempDir(), "cliproxy-pluginhost", fmt.Sprintf("pid-%d", os.Getpid()))
- if dir != want {
- t.Fatalf("shadowPluginDir() = %q, want %q", dir, want)
- }
-}
-
-func TestShadowCopyPluginReusesContentAddressedShadow(t *testing.T) {
- dir := t.TempDir()
- source := filepath.Join(t.TempDir(), "alpha.dll")
- content := []byte("plugin-v1")
- if errWrite := os.WriteFile(source, content, 0o644); errWrite != nil {
- t.Fatalf("WriteFile() error = %v", errWrite)
- }
- file := pluginFile{ID: "alpha", Path: source}
-
- first, errFirst := shadowCopyPluginToDir(file, dir)
- if errFirst != nil {
- t.Fatalf("shadowCopyPluginToDir() first error = %v", errFirst)
- }
- second, errSecond := shadowCopyPluginToDir(file, dir)
- if errSecond != nil {
- t.Fatalf("shadowCopyPluginToDir() second error = %v", errSecond)
- }
-
- if second != first {
- t.Fatalf("second shadow path = %q, want reused path %q", second, first)
- }
- gotContent, errRead := os.ReadFile(first)
- if errRead != nil {
- t.Fatalf("ReadFile(%s) error = %v", first, errRead)
- }
- if string(gotContent) != string(content) {
- t.Fatalf("shadow content = %q, want %q", gotContent, content)
- }
- digest := sha256.Sum256(content)
- wantDigest := hex.EncodeToString(digest[:])[:shadowPluginDigestLength]
- name := filepath.Base(first)
- if !strings.HasPrefix(name, shadowPluginPrefix+"alpha-") || !strings.Contains(name, wantDigest) {
- t.Fatalf("shadow file name = %q, want alpha content digest %s", name, wantDigest)
- }
- if count := countShadowPluginFiles(t, dir); count != 1 {
- t.Fatalf("shadow file count = %d, want 1", count)
- }
-}
-
-func TestShadowCopyPluginCreatesNewPathForChangedContent(t *testing.T) {
- dir := t.TempDir()
- source := filepath.Join(t.TempDir(), "alpha.dll")
- file := pluginFile{ID: "alpha", Path: source}
- if errWrite := os.WriteFile(source, []byte("plugin-v1"), 0o644); errWrite != nil {
- t.Fatalf("WriteFile() v1 error = %v", errWrite)
- }
- first, errFirst := shadowCopyPluginToDir(file, dir)
- if errFirst != nil {
- t.Fatalf("shadowCopyPluginToDir() v1 error = %v", errFirst)
- }
-
- if errWrite := os.WriteFile(source, []byte("plugin-v2"), 0o644); errWrite != nil {
- t.Fatalf("WriteFile() v2 error = %v", errWrite)
- }
- second, errSecond := shadowCopyPluginToDir(file, dir)
- if errSecond != nil {
- t.Fatalf("shadowCopyPluginToDir() v2 error = %v", errSecond)
- }
-
- if second == first {
- t.Fatalf("second shadow path reused %q after content changed", second)
- }
- if count := countShadowPluginFiles(t, dir); count != 2 {
- t.Fatalf("shadow file count = %d, want 2 versions", count)
- }
-}
-
-func TestShadowCopyPluginReplacesCorruptSameSizeShadow(t *testing.T) {
- dir := t.TempDir()
- source := filepath.Join(t.TempDir(), "alpha.dll")
- content := []byte("plugin-v1")
- if errWrite := os.WriteFile(source, content, 0o644); errWrite != nil {
- t.Fatalf("WriteFile() source error = %v", errWrite)
- }
- digest := sha256.Sum256(content)
- target := shadowPluginPath(dir, "alpha", hex.EncodeToString(digest[:]), ".dll")
- if errWrite := os.WriteFile(target, []byte("corrupt!!"), 0o644); errWrite != nil {
- t.Fatalf("WriteFile() corrupt shadow error = %v", errWrite)
- }
-
- gotPath, errCopy := shadowCopyPluginToDir(pluginFile{ID: "alpha", Path: source}, dir)
- if errCopy != nil {
- t.Fatalf("shadowCopyPluginToDir() error = %v", errCopy)
- }
-
- if gotPath != target {
- t.Fatalf("shadow path = %q, want %q", gotPath, target)
- }
- gotContent, errRead := os.ReadFile(target)
- if errRead != nil {
- t.Fatalf("ReadFile(%s) error = %v", target, errRead)
- }
- if string(gotContent) != string(content) {
- t.Fatalf("shadow content = %q, want %q", gotContent, content)
- }
- if count := countShadowPluginFiles(t, dir); count != 1 {
- t.Fatalf("shadow file count = %d, want 1", count)
- }
-}
-
-func TestRemoveStaleShadowPluginsOnlyRemovesShadowFiles(t *testing.T) {
- dir := t.TempDir()
- stale := filepath.Join(dir, shadowPluginPrefix+"alpha-deadbeef.dll")
- temp := filepath.Join(dir, shadowPluginTempPrefix+"alpha-temp.dll")
- keep := filepath.Join(dir, "keep.dll")
- for _, path := range []string{stale, temp, keep} {
- if errWrite := os.WriteFile(path, []byte("x"), 0o644); errWrite != nil {
- t.Fatalf("WriteFile(%s) error = %v", path, errWrite)
- }
- }
-
- removeStaleShadowPlugins(dir)
-
- for _, path := range []string{stale, temp} {
- if _, errStat := os.Stat(path); !os.IsNotExist(errStat) {
- t.Fatalf("Stat(%s) error = %v, want not exist", path, errStat)
- }
- }
- if _, errStat := os.Stat(keep); errStat != nil {
- t.Fatalf("Stat(%s) error = %v, want kept", keep, errStat)
- }
-}
-
-func countShadowPluginFiles(t *testing.T, dir string) int {
- t.Helper()
- entries, errRead := os.ReadDir(dir)
- if errRead != nil {
- t.Fatalf("ReadDir(%s) error = %v", dir, errRead)
- }
- count := 0
- for _, entry := range entries {
- if strings.HasPrefix(entry.Name(), shadowPluginPrefix) {
- count++
- }
- if strings.HasPrefix(entry.Name(), shadowPluginTempPrefix) {
- t.Fatalf("temporary shadow file was not cleaned up: %s", entry.Name())
- }
- }
- return count
-}
diff --git a/internal/pluginhost/model_router_test.go b/internal/pluginhost/model_router_test.go
index 4233d026579..e3d639a5dc4 100644
--- a/internal/pluginhost/model_router_test.go
+++ b/internal/pluginhost/model_router_test.go
@@ -296,6 +296,7 @@ func TestHostRouteModelClonesPluginMetadata(t *testing.T) {
Name: "Router",
ConfigFields: []pluginapi.ConfigField{{
Name: "mode",
+ Default: map[string]any{"tier": "safe"},
EnumValues: []string{"safe", "fast"},
}},
},
@@ -304,6 +305,7 @@ func TestHostRouteModelClonesPluginMetadata(t *testing.T) {
ModelRouter: modelRouterFunc(func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, error) {
req.Plugin.ConfigFields[0].Name = "mutated"
req.Plugin.ConfigFields[0].EnumValues[0] = "mutated"
+ req.Plugin.ConfigFields[0].Default.(map[string]any)["tier"] = "mutated"
return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetSelf}, nil
}),
}},
@@ -314,7 +316,10 @@ func TestHostRouteModelClonesPluginMetadata(t *testing.T) {
t.Fatalf("RouteModel() = %#v, %v; want router executor handled", resp, ok)
}
meta := host.Snapshot().records[0].meta
- if meta.ConfigFields[0].Name != "mode" || meta.ConfigFields[0].EnumValues[0] != "safe" {
+ defaultValue := meta.ConfigFields[0].Default.(map[string]any)
+ if meta.ConfigFields[0].Name != "mode" ||
+ meta.ConfigFields[0].EnumValues[0] != "safe" ||
+ defaultValue["tier"] != "safe" {
t.Fatalf("snapshot metadata was mutated: %#v", meta.ConfigFields[0])
}
}
diff --git a/internal/pluginhost/native_catalog_priority_test.go b/internal/pluginhost/native_catalog_priority_test.go
new file mode 100644
index 00000000000..cdd3d0214f2
--- /dev/null
+++ b/internal/pluginhost/native_catalog_priority_test.go
@@ -0,0 +1,124 @@
+package pluginhost
+
+import (
+ "context"
+ "testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
+)
+
+func TestRegisterExecutorsPreservesNativeCatalogPriority(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ nativeExecutor bool
+ extraPluginModel bool
+ }{
+ {name: "native catalog blocks competing executor", nativeExecutor: true},
+ {name: "native catalog only blocks overlapping model", nativeExecutor: true, extraPluginModel: true},
+ {name: "catalog without native executor does not reserve model"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ modelRegistry := newFakeModelRegistry()
+ manager := newFakeExecutorManager()
+ native := &fakeProviderExecutor{provider: "native-provider"}
+ if tc.nativeExecutor {
+ manager.RegisterExecutor(native)
+ }
+ // The existing credential has not yet registered the new catalog model.
+ modelRegistry.RegisterClient("native-auth", "native-provider", []*registry.ModelInfo{{ID: "native-base"}})
+ pluginModels := []pluginapi.ModelInfo{{ID: "shared-model"}}
+ if tc.extraPluginModel {
+ pluginModels = append(pluginModels, pluginapi.ModelInfo{ID: "plugin-only"})
+ }
+ catalog := capabilityRecord{
+ id: "native-catalog",
+ priority: 10,
+ plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
+ ModelRegistrar: staticModelRegistrar("native-provider", "shared-model"),
+ }},
+ }
+ competitor := capabilityRecord{
+ id: "competing-executor",
+ priority: 20,
+ plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
+ ModelRegistrar: modelRegistrarFunc(func(context.Context, pluginapi.ModelRegistrationRequest) (pluginapi.ModelRegistrationResponse, error) {
+ return pluginapi.ModelRegistrationResponse{Provider: "plugin-provider", Models: pluginModels}, nil
+ }),
+ Executor: &fakeExecutor{identifier: "plugin-provider"},
+ }},
+ }
+ host := newHostWithRecords(competitor, catalog)
+ for _, stage := range []struct {
+ name string
+ publishModel bool
+ }{
+ {name: "first load before credential refresh"},
+ {name: "reload before credential refresh"},
+ {name: "reload after credential refresh", publishModel: true},
+ {name: "reload after credential withdraws model"},
+ } {
+ nativeModels := []*registry.ModelInfo{{ID: "native-base"}}
+ if stage.publishModel && tc.nativeExecutor {
+ nativeModels = append(nativeModels, ®istry.ModelInfo{ID: "shared-model"})
+ }
+ modelRegistry.RegisterClient("native-auth", "native-provider", nativeModels)
+ host.RegisterModels(t.Context(), modelRegistry)
+ host.RegisterExecutors(manager, modelRegistry)
+
+ if tc.nativeExecutor {
+ if got, _ := manager.Executor("native-provider"); got != native {
+ t.Fatalf("%s: native executor was replaced", stage.name)
+ }
+ }
+ wantExecutor := !tc.nativeExecutor || tc.extraPluginModel
+ if _, got := manager.Executor("plugin-provider"); got != wantExecutor {
+ t.Fatalf("%s: competing executor registered = %t, want %t", stage.name, got, wantExecutor)
+ }
+ gotModels := host.ModelsForProvider("plugin-provider")
+ wantCount := 0
+ if !tc.nativeExecutor {
+ wantCount++
+ }
+ if tc.extraPluginModel {
+ wantCount++
+ }
+ if len(gotModels) != wantCount {
+ t.Fatalf("%s: competing catalog has %d models, want %d", stage.name, len(gotModels), wantCount)
+ }
+ for _, model := range gotModels {
+ if tc.nativeExecutor && model.ID == "shared-model" {
+ t.Fatalf("%s: competing catalog claimed native shared-model", stage.name)
+ }
+ }
+ if models := host.ModelsForProvider("native-provider"); len(models) != 1 || models[0].ID != "shared-model" {
+ t.Fatalf("%s: native source catalog was not retained", stage.name)
+ }
+ if _, published := modelRegistry.clients["plugin:native-catalog:native-provider"]; published {
+ t.Fatalf("%s: catalog-only plugin independently published models", stage.name)
+ }
+ providers := modelRegistry.GetModelProviders("shared-model")
+ wantProvider := ""
+ if !tc.nativeExecutor {
+ wantProvider = "plugin-provider"
+ } else if stage.publishModel {
+ wantProvider = "native-provider"
+ }
+ if wantProvider == "" && len(providers) != 0 || wantProvider != "" && (len(providers) != 1 || providers[0] != wantProvider) {
+ t.Fatalf("%s: shared-model providers = %v, want %q", stage.name, providers, wantProvider)
+ }
+ }
+
+ // Removing the catalog releases ownership on the next arbitration pass.
+ setHostSnapshotForTest(host, true, competitor)
+ host.RegisterModels(t.Context(), modelRegistry)
+ host.RegisterExecutors(manager, modelRegistry)
+ if _, ok := manager.Executor("plugin-provider"); !ok {
+ t.Fatal("competing executor was not registered after native catalog removal")
+ }
+ if models := host.ModelsForProvider("plugin-provider"); len(models) != len(pluginModels) {
+ t.Fatalf("competing models after catalog removal = %d, want %d", len(models), len(pluginModels))
+ }
+ })
+ }
+}
diff --git a/internal/pluginhost/shadow.go b/internal/pluginhost/shadow.go
new file mode 100644
index 00000000000..3884b11e25e
--- /dev/null
+++ b/internal/pluginhost/shadow.go
@@ -0,0 +1,181 @@
+//go:build windows || (cgo && (linux || darwin || freebsd))
+
+package pluginhost
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync"
+)
+
+// Plugin dynamic libraries are loaded from content-addressed shadow copies.
+// A loaded Go plugin image must stay mapped for the lifetime of the host
+// process (its runtime cannot be unloaded), so loading from a shadow copy
+// keeps the original artifact replaceable and gives every distinct plugin
+// build its own path, which dynamic loaders map as a fresh image.
+
+const (
+ shadowPluginPrefix = "cliproxy-plugin-"
+ shadowPluginTempPrefix = ".cliproxy-plugin-"
+ shadowPluginProcessDirPrefix = "pid-"
+ shadowPluginDigestLength = 32
+)
+
+var shadowPluginCleanupOnce sync.Once
+
+func shadowCopyPlugin(file pluginFile) (string, error) {
+ dir, errDir := shadowPluginDir()
+ if errDir != nil {
+ return "", errDir
+ }
+ shadowPluginCleanupOnce.Do(func() {
+ removeStaleShadowPlugins(dir)
+ removeStaleShadowPluginDirs(filepath.Dir(dir), os.Getpid())
+ })
+ return shadowCopyPluginToDir(file, dir)
+}
+
+// removeStaleShadowPluginDirs removes shadow directories left behind by
+// host processes that already exited. Directories whose owner process is
+// still alive are kept, because that process may reuse the resident plugin
+// images inside them.
+func removeStaleShadowPluginDirs(baseDir string, currentPID int) {
+ entries, errRead := os.ReadDir(baseDir)
+ if errRead != nil {
+ return
+ }
+ for _, entry := range entries {
+ if entry == nil || !entry.IsDir() {
+ continue
+ }
+ name := entry.Name()
+ if !strings.HasPrefix(name, shadowPluginProcessDirPrefix) {
+ continue
+ }
+ pid, errParse := strconv.Atoi(strings.TrimPrefix(name, shadowPluginProcessDirPrefix))
+ if errParse != nil || pid <= 0 || pid == currentPID {
+ continue
+ }
+ if shadowPluginProcessAlive(pid) {
+ continue
+ }
+ removeShadowPluginDir(filepath.Join(baseDir, name))
+ }
+}
+
+func shadowCopyPluginToDir(file pluginFile, dir string) (string, error) {
+ source := filepath.Clean(file.Path)
+ tmp, errTemp := os.CreateTemp(dir, shadowPluginTempPrefix+file.ID+"-*"+filepath.Ext(source))
+ if errTemp != nil {
+ return "", errTemp
+ }
+ tmpName := tmp.Name()
+ removeTemp := true
+ defer func() {
+ if removeTemp {
+ removeShadowPlugin(tmpName)
+ }
+ }()
+
+ in, errOpen := os.Open(source)
+ if errOpen != nil {
+ _ = tmp.Close()
+ return "", errOpen
+ }
+ defer func() {
+ _ = in.Close()
+ }()
+ hasher := sha256.New()
+ size, errCopy := io.Copy(io.MultiWriter(tmp, hasher), in)
+ if errCopy != nil {
+ _ = tmp.Close()
+ return "", errCopy
+ }
+ if errClose := tmp.Close(); errClose != nil {
+ return "", errClose
+ }
+ digest := hex.EncodeToString(hasher.Sum(nil))
+ target := shadowPluginPath(dir, file.ID, digest, filepath.Ext(source))
+ if shadowPluginMatches(target, size, digest) {
+ return target, nil
+ }
+ if errRemove := os.Remove(target); errRemove != nil && !errors.Is(errRemove, os.ErrNotExist) {
+ if shadowPluginMatches(target, size, digest) {
+ return target, nil
+ }
+ removeShadowPlugin(target)
+ return "", fmt.Errorf("remove stale shadow plugin: %w", errRemove)
+ }
+ if errRename := os.Rename(tmpName, target); errRename != nil {
+ if shadowPluginMatches(target, size, digest) {
+ return target, nil
+ }
+ return "", fmt.Errorf("move shadow plugin: %w", errRename)
+ }
+ removeTemp = false
+ return target, nil
+}
+
+func shadowPluginDir() (string, error) {
+ dir := filepath.Join(os.TempDir(), "cliproxy-pluginhost", shadowPluginProcessDirName(os.Getpid()))
+ if errMkdir := os.MkdirAll(dir, 0o700); errMkdir != nil {
+ return "", errMkdir
+ }
+ return dir, nil
+}
+
+func shadowPluginProcessDirName(pid int) string {
+ return fmt.Sprintf("%s%d", shadowPluginProcessDirPrefix, pid)
+}
+
+func removeStaleShadowPlugins(dir string) {
+ entries, errRead := os.ReadDir(dir)
+ if errRead != nil {
+ return
+ }
+ for _, entry := range entries {
+ if entry == nil || entry.IsDir() {
+ continue
+ }
+ name := entry.Name()
+ if strings.HasPrefix(name, shadowPluginPrefix) || strings.HasPrefix(name, shadowPluginTempPrefix) {
+ removeShadowPlugin(filepath.Join(dir, name))
+ }
+ }
+}
+
+func shadowPluginPath(dir string, id string, digest string, extension string) string {
+ if len(digest) > shadowPluginDigestLength {
+ digest = digest[:shadowPluginDigestLength]
+ }
+ return filepath.Join(dir, shadowPluginPrefix+id+"-"+digest+extension)
+}
+
+func shadowPluginMatches(path string, size int64, digest string) bool {
+ info, errStat := os.Stat(path)
+ if errStat != nil {
+ return false
+ }
+ if !info.Mode().IsRegular() || info.Size() != size {
+ return false
+ }
+ file, errOpen := os.Open(path)
+ if errOpen != nil {
+ return false
+ }
+ defer func() {
+ _ = file.Close()
+ }()
+ hasher := sha256.New()
+ if _, errCopy := io.Copy(hasher, file); errCopy != nil {
+ return false
+ }
+ return hex.EncodeToString(hasher.Sum(nil)) == digest
+}
diff --git a/internal/pluginhost/shadow_test.go b/internal/pluginhost/shadow_test.go
new file mode 100644
index 00000000000..42301947169
--- /dev/null
+++ b/internal/pluginhost/shadow_test.go
@@ -0,0 +1,275 @@
+//go:build windows || (cgo && (linux || darwin || freebsd))
+
+package pluginhost
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+)
+
+func testPluginLibraryExtension() string {
+ switch runtime.GOOS {
+ case "windows":
+ return ".dll"
+ case "darwin":
+ return ".dylib"
+ default:
+ return ".so"
+ }
+}
+
+func TestShadowPluginDirIsProcessScoped(t *testing.T) {
+ dir, errDir := shadowPluginDir()
+ if errDir != nil {
+ t.Fatalf("shadowPluginDir() error = %v", errDir)
+ }
+ want := filepath.Join(os.TempDir(), "cliproxy-pluginhost", fmt.Sprintf("pid-%d", os.Getpid()))
+ if dir != want {
+ t.Fatalf("shadowPluginDir() = %q, want %q", dir, want)
+ }
+}
+
+func TestShadowCopyPluginReusesContentAddressedShadow(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(t.TempDir(), "alpha"+testPluginLibraryExtension())
+ content := []byte("plugin-v1")
+ if errWrite := os.WriteFile(source, content, 0o644); errWrite != nil {
+ t.Fatalf("WriteFile() error = %v", errWrite)
+ }
+ file := pluginFile{ID: "alpha", Path: source}
+
+ first, errFirst := shadowCopyPluginToDir(file, dir)
+ if errFirst != nil {
+ t.Fatalf("shadowCopyPluginToDir() first error = %v", errFirst)
+ }
+ second, errSecond := shadowCopyPluginToDir(file, dir)
+ if errSecond != nil {
+ t.Fatalf("shadowCopyPluginToDir() second error = %v", errSecond)
+ }
+
+ if second != first {
+ t.Fatalf("second shadow path = %q, want reused path %q", second, first)
+ }
+ gotContent, errRead := os.ReadFile(first)
+ if errRead != nil {
+ t.Fatalf("ReadFile(%s) error = %v", first, errRead)
+ }
+ if string(gotContent) != string(content) {
+ t.Fatalf("shadow content = %q, want %q", gotContent, content)
+ }
+ digest := sha256.Sum256(content)
+ wantDigest := hex.EncodeToString(digest[:])[:shadowPluginDigestLength]
+ name := filepath.Base(first)
+ if !strings.HasPrefix(name, shadowPluginPrefix+"alpha-") || !strings.Contains(name, wantDigest) {
+ t.Fatalf("shadow file name = %q, want alpha content digest %s", name, wantDigest)
+ }
+ if count := countShadowPluginFiles(t, dir); count != 1 {
+ t.Fatalf("shadow file count = %d, want 1", count)
+ }
+}
+
+func TestShadowCopyPluginCreatesNewPathForChangedContent(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(t.TempDir(), "alpha"+testPluginLibraryExtension())
+ file := pluginFile{ID: "alpha", Path: source}
+ if errWrite := os.WriteFile(source, []byte("plugin-v1"), 0o644); errWrite != nil {
+ t.Fatalf("WriteFile() v1 error = %v", errWrite)
+ }
+ first, errFirst := shadowCopyPluginToDir(file, dir)
+ if errFirst != nil {
+ t.Fatalf("shadowCopyPluginToDir() v1 error = %v", errFirst)
+ }
+
+ if errWrite := os.WriteFile(source, []byte("plugin-v2"), 0o644); errWrite != nil {
+ t.Fatalf("WriteFile() v2 error = %v", errWrite)
+ }
+ second, errSecond := shadowCopyPluginToDir(file, dir)
+ if errSecond != nil {
+ t.Fatalf("shadowCopyPluginToDir() v2 error = %v", errSecond)
+ }
+
+ if second == first {
+ t.Fatalf("second shadow path reused %q after content changed", second)
+ }
+ if count := countShadowPluginFiles(t, dir); count != 2 {
+ t.Fatalf("shadow file count = %d, want 2 versions", count)
+ }
+}
+
+func TestShadowCopyPluginReplacesCorruptSameSizeShadow(t *testing.T) {
+ dir := t.TempDir()
+ source := filepath.Join(t.TempDir(), "alpha"+testPluginLibraryExtension())
+ content := []byte("plugin-v1")
+ if errWrite := os.WriteFile(source, content, 0o644); errWrite != nil {
+ t.Fatalf("WriteFile() source error = %v", errWrite)
+ }
+ digest := sha256.Sum256(content)
+ target := shadowPluginPath(dir, "alpha", hex.EncodeToString(digest[:]), testPluginLibraryExtension())
+ if errWrite := os.WriteFile(target, []byte("corrupt!!"), 0o644); errWrite != nil {
+ t.Fatalf("WriteFile() corrupt shadow error = %v", errWrite)
+ }
+
+ gotPath, errCopy := shadowCopyPluginToDir(pluginFile{ID: "alpha", Path: source}, dir)
+ if errCopy != nil {
+ t.Fatalf("shadowCopyPluginToDir() error = %v", errCopy)
+ }
+
+ if gotPath != target {
+ t.Fatalf("shadow path = %q, want %q", gotPath, target)
+ }
+ gotContent, errRead := os.ReadFile(target)
+ if errRead != nil {
+ t.Fatalf("ReadFile(%s) error = %v", target, errRead)
+ }
+ if string(gotContent) != string(content) {
+ t.Fatalf("shadow content = %q, want %q", gotContent, content)
+ }
+ if count := countShadowPluginFiles(t, dir); count != 1 {
+ t.Fatalf("shadow file count = %d, want 1", count)
+ }
+}
+
+func TestRemoveStaleShadowPluginsOnlyRemovesShadowFiles(t *testing.T) {
+ dir := t.TempDir()
+ stale := filepath.Join(dir, shadowPluginPrefix+"alpha-deadbeef"+testPluginLibraryExtension())
+ temp := filepath.Join(dir, shadowPluginTempPrefix+"alpha-temp"+testPluginLibraryExtension())
+ keep := filepath.Join(dir, "keep"+testPluginLibraryExtension())
+ for _, path := range []string{stale, temp, keep} {
+ if errWrite := os.WriteFile(path, []byte("x"), 0o644); errWrite != nil {
+ t.Fatalf("WriteFile(%s) error = %v", path, errWrite)
+ }
+ }
+
+ removeStaleShadowPlugins(dir)
+
+ for _, path := range []string{stale, temp} {
+ if _, errStat := os.Stat(path); !os.IsNotExist(errStat) {
+ t.Fatalf("Stat(%s) error = %v, want not exist", path, errStat)
+ }
+ }
+ if _, errStat := os.Stat(keep); errStat != nil {
+ t.Fatalf("Stat(%s) error = %v, want kept", keep, errStat)
+ }
+}
+
+func TestRemoveStaleShadowPluginDirsOnlyRemovesDeadProcesses(t *testing.T) {
+ base := t.TempDir()
+ currentPID := os.Getpid()
+ deadPID := startExitedProcessPID(t)
+ if deadPID == currentPID {
+ t.Skip("exited helper process reused the test pid")
+ }
+ liveDir := filepath.Join(base, shadowPluginProcessDirName(currentPID))
+ deadDir := filepath.Join(base, shadowPluginProcessDirName(deadPID))
+ otherDir := filepath.Join(base, "unrelated")
+ // A directory owned by a different live process must survive the
+ // liveness check itself, not just the current-pid shortcut.
+ liveOther, liveOtherPID := startWaitingProcess(t)
+ if liveOtherPID == currentPID || liveOtherPID == deadPID {
+ t.Skip("live helper process pid collided with another test pid")
+ }
+ liveOtherDir := filepath.Join(base, shadowPluginProcessDirName(liveOtherPID))
+ for _, dir := range []string{liveDir, deadDir, otherDir, liveOtherDir} {
+ if errMkdir := os.MkdirAll(dir, 0o755); errMkdir != nil {
+ t.Fatalf("MkdirAll(%s) error = %v", dir, errMkdir)
+ }
+ shadowFile := filepath.Join(dir, shadowPluginPrefix+"alpha-deadbeef"+testPluginLibraryExtension())
+ if errWrite := os.WriteFile(shadowFile, []byte("x"), 0o644); errWrite != nil {
+ t.Fatalf("WriteFile(%s) error = %v", shadowFile, errWrite)
+ }
+ }
+
+ removeStaleShadowPluginDirs(base, currentPID)
+
+ if _, errStat := os.Stat(deadDir); !os.IsNotExist(errStat) {
+ t.Fatalf("Stat(%s) error = %v, want not exist", deadDir, errStat)
+ }
+ for _, dir := range []string{liveDir, otherDir, liveOtherDir} {
+ if _, errStat := os.Stat(dir); errStat != nil {
+ t.Fatalf("Stat(%s) error = %v, want kept", dir, errStat)
+ }
+ }
+ stopWaitingProcess(t, liveOther)
+}
+
+func startExitedProcessPID(t *testing.T) int {
+ t.Helper()
+ cmd := exec.Command(os.Args[0], "-test.run=^TestShadowHelperProcessExits$")
+ cmd.Env = append(os.Environ(), "CLIPROXY_SHADOW_HELPER=1")
+ if errRun := cmd.Run(); errRun != nil {
+ t.Fatalf("helper process error = %v", errRun)
+ }
+ if cmd.Process == nil {
+ t.Fatal("helper process is nil")
+ }
+ return cmd.Process.Pid
+}
+
+func TestShadowHelperProcessExits(t *testing.T) {
+ if os.Getenv("CLIPROXY_SHADOW_HELPER") != "1" {
+ t.Skip("helper process entry point")
+ }
+}
+
+func startWaitingProcess(t *testing.T) (*exec.Cmd, int) {
+ t.Helper()
+ cmd := exec.Command(os.Args[0], "-test.run=^TestShadowHelperProcessWaits$")
+ cmd.Env = append(os.Environ(), "CLIPROXY_SHADOW_HELPER_WAIT=1")
+ stdin, errStdin := cmd.StdinPipe()
+ if errStdin != nil {
+ t.Fatalf("StdinPipe() error = %v", errStdin)
+ }
+ if errStart := cmd.Start(); errStart != nil {
+ t.Fatalf("start waiting helper error = %v", errStart)
+ }
+ t.Cleanup(func() {
+ _ = stdin.Close()
+ _ = cmd.Wait()
+ })
+ if cmd.Process == nil {
+ t.Fatal("waiting helper process is nil")
+ }
+ return cmd, cmd.Process.Pid
+}
+
+func stopWaitingProcess(t *testing.T, cmd *exec.Cmd) {
+ t.Helper()
+ if cmd == nil || cmd.Process == nil {
+ return
+ }
+ _ = cmd.Process.Kill()
+ _ = cmd.Wait()
+}
+
+func TestShadowHelperProcessWaits(t *testing.T) {
+ if os.Getenv("CLIPROXY_SHADOW_HELPER_WAIT") != "1" {
+ t.Skip("waiting helper process entry point")
+ }
+ buf := make([]byte, 1)
+ _, _ = os.Stdin.Read(buf)
+}
+
+func countShadowPluginFiles(t *testing.T, dir string) int {
+ t.Helper()
+ entries, errRead := os.ReadDir(dir)
+ if errRead != nil {
+ t.Fatalf("ReadDir(%s) error = %v", dir, errRead)
+ }
+ count := 0
+ for _, entry := range entries {
+ if strings.HasPrefix(entry.Name(), shadowPluginPrefix) {
+ count++
+ }
+ if strings.HasPrefix(entry.Name(), shadowPluginTempPrefix) {
+ t.Fatalf("temporary shadow file was not cleaned up: %s", entry.Name())
+ }
+ }
+ return count
+}
diff --git a/internal/pluginhost/shadow_unix.go b/internal/pluginhost/shadow_unix.go
new file mode 100644
index 00000000000..39e626e7464
--- /dev/null
+++ b/internal/pluginhost/shadow_unix.go
@@ -0,0 +1,27 @@
+//go:build cgo && (linux || darwin || freebsd)
+
+package pluginhost
+
+import (
+ "errors"
+ "os"
+ "syscall"
+)
+
+func removeShadowPlugin(path string) {
+ if path == "" {
+ return
+ }
+ _ = os.Remove(path)
+}
+
+func removeShadowPluginDir(dir string) {
+ _ = os.RemoveAll(dir)
+}
+
+func shadowPluginProcessAlive(pid int) bool {
+ // Only ESRCH proves the process is gone; any other outcome keeps the
+ // directory so a live process never loses its resident plugin images.
+ errSignal := syscall.Kill(pid, 0)
+ return !errors.Is(errSignal, syscall.ESRCH)
+}
diff --git a/internal/pluginhost/shadow_windows.go b/internal/pluginhost/shadow_windows.go
new file mode 100644
index 00000000000..5efe995f874
--- /dev/null
+++ b/internal/pluginhost/shadow_windows.go
@@ -0,0 +1,62 @@
+//go:build windows
+
+package pluginhost
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "golang.org/x/sys/windows"
+)
+
+func removeShadowPlugin(path string) {
+ if path == "" {
+ return
+ }
+ if errRemove := os.Remove(path); errRemove == nil {
+ return
+ }
+ pathPtr, errPath := windows.UTF16PtrFromString(path)
+ if errPath != nil {
+ return
+ }
+ _ = windows.MoveFileEx(pathPtr, nil, windows.MOVEFILE_DELAY_UNTIL_REBOOT)
+}
+
+func removeShadowPluginDir(dir string) {
+ if errRemove := os.RemoveAll(dir); errRemove == nil {
+ return
+ }
+ entries, errRead := os.ReadDir(dir)
+ if errRead != nil {
+ return
+ }
+ for _, entry := range entries {
+ if entry == nil || entry.IsDir() {
+ continue
+ }
+ name := entry.Name()
+ if strings.HasPrefix(name, shadowPluginPrefix) || strings.HasPrefix(name, shadowPluginTempPrefix) {
+ removeShadowPlugin(filepath.Join(dir, name))
+ }
+ }
+ _ = os.Remove(dir)
+}
+
+func shadowPluginProcessAlive(pid int) bool {
+ // Only an invalid parameter proves the process id no longer exists;
+ // any other error keeps the directory so a live process never loses
+ // its resident plugin images.
+ handle, errOpen := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid))
+ if errOpen != nil {
+ return !errors.Is(errOpen, windows.ERROR_INVALID_PARAMETER)
+ }
+ event, errWait := windows.WaitForSingleObject(handle, 0)
+ _ = windows.CloseHandle(handle)
+ if errWait != nil {
+ return true
+ }
+ return event == uint32(windows.WAIT_TIMEOUT)
+}
diff --git a/internal/pluginhost/snapshot.go b/internal/pluginhost/snapshot.go
index 601c9eb42d4..f5c7e9ad399 100644
--- a/internal/pluginhost/snapshot.go
+++ b/internal/pluginhost/snapshot.go
@@ -1,6 +1,7 @@
package pluginhost
import (
+ "reflect"
"sort"
"strings"
"sync"
@@ -171,6 +172,15 @@ func cloneConfigFields(fields []pluginapi.ConfigField) []pluginapi.ConfigField {
copy(out, fields)
for index := range out {
out[index].EnumValues = append([]string(nil), fields[index].EnumValues...)
+ out[index].Default = cloneConfigFieldValue(fields[index].Default)
}
return out
}
+
+func cloneConfigFieldValue(value any) any {
+ if value == nil {
+ return nil
+ }
+ visited := make(map[metadataCloneVisit]reflect.Value)
+ return cloneInterceptorMetadataAny(reflect.ValueOf(value), visited)
+}
diff --git a/internal/pluginhost/testdata/loader_unix_reload_plugin/go.mod b/internal/pluginhost/testdata/loader_unix_reload_plugin/go.mod
new file mode 100644
index 00000000000..5c1caeee8b7
--- /dev/null
+++ b/internal/pluginhost/testdata/loader_unix_reload_plugin/go.mod
@@ -0,0 +1,3 @@
+module loaderunixreloadplugin
+
+go 1.26
diff --git a/internal/pluginhost/testdata/loader_unix_reload_plugin/main.go b/internal/pluginhost/testdata/loader_unix_reload_plugin/main.go
new file mode 100644
index 00000000000..686ad2c6b84
--- /dev/null
+++ b/internal/pluginhost/testdata/loader_unix_reload_plugin/main.go
@@ -0,0 +1,124 @@
+package main
+
+/*
+#include
+#include
+
+typedef struct {
+ void* ptr;
+ size_t len;
+} cliproxy_buffer;
+
+typedef int (*cliproxy_host_call_fn)(void*, const char*, const uint8_t*, size_t, cliproxy_buffer*);
+typedef void (*cliproxy_host_free_fn)(void*, size_t);
+
+typedef struct {
+ uint32_t abi_version;
+ void* host_ctx;
+ cliproxy_host_call_fn call;
+ cliproxy_host_free_fn free_buffer;
+} cliproxy_host_api;
+
+typedef int (*cliproxy_plugin_call_fn)(char*, uint8_t*, size_t, cliproxy_buffer*);
+typedef void (*cliproxy_plugin_free_fn)(void*, size_t);
+typedef void (*cliproxy_plugin_shutdown_fn)(void);
+
+typedef struct {
+ uint32_t abi_version;
+ cliproxy_plugin_call_fn call;
+ cliproxy_plugin_free_fn free_buffer;
+ cliproxy_plugin_shutdown_fn shutdown;
+} cliproxy_plugin_api;
+
+extern int cliproxyPluginCall(char*, uint8_t*, size_t, cliproxy_buffer*);
+extern void cliproxyPluginFree(void*, size_t);
+extern void cliproxyPluginShutdown(void);
+
+// The marker lets the host test distinguish a freshly mapped image from a
+// reused image after the plugin file is replaced under the same path.
+#ifndef PLUGIN_VERSION
+#define PLUGIN_VERSION 1
+#endif
+
+static const char* cliproxyTestMarker(void) {
+#if PLUGIN_VERSION == 2
+ return "v2";
+#else
+ return "v1";
+#endif
+}
+
+static int cliproxyTestFailInit(void) {
+#if PLUGIN_VERSION == 3
+ return 1;
+#else
+ return 0;
+#endif
+}
+*/
+import "C"
+
+import (
+ "strconv"
+ "sync/atomic"
+ "unsafe"
+)
+
+var shutdownCalls atomic.Int64
+
+var failInit = C.cliproxyTestFailInit() != 0
+
+//export cliproxy_plugin_init
+func cliproxy_plugin_init(host *C.cliproxy_host_api, plugin *C.cliproxy_plugin_api) C.int {
+ if plugin == nil {
+ return 1
+ }
+ if failInit {
+ _ = host
+ return 7
+ }
+ plugin.abi_version = 1
+ plugin.call = C.cliproxy_plugin_call_fn(C.cliproxyPluginCall)
+ plugin.free_buffer = C.cliproxy_plugin_free_fn(C.cliproxyPluginFree)
+ plugin.shutdown = C.cliproxy_plugin_shutdown_fn(C.cliproxyPluginShutdown)
+ _ = host
+ return 0
+}
+
+//export cliproxyPluginCall
+func cliproxyPluginCall(method *C.char, request *C.uint8_t, requestLen C.size_t, response *C.cliproxy_buffer) C.int {
+ if response == nil {
+ return 1
+ }
+ response.ptr = nil
+ response.len = 0
+ var body []byte
+ if method != nil && C.GoString(method) == "test.shutdown_calls" {
+ body = []byte(strconv.FormatInt(shutdownCalls.Load(), 10))
+ } else {
+ body = []byte(C.GoString(C.cliproxyTestMarker()))
+ }
+ ptr := C.CBytes(body)
+ if ptr == nil {
+ return 1
+ }
+ response.ptr = ptr
+ response.len = C.size_t(len(body))
+ _ = request
+ _ = requestLen
+ return 0
+}
+
+//export cliproxyPluginFree
+func cliproxyPluginFree(ptr unsafe.Pointer, _ C.size_t) {
+ if ptr != nil {
+ C.free(ptr)
+ }
+}
+
+//export cliproxyPluginShutdown
+func cliproxyPluginShutdown() {
+ shutdownCalls.Add(1)
+}
+
+func main() {}
diff --git a/internal/registry/claude_family_lookup_test.go b/internal/registry/claude_family_lookup_test.go
new file mode 100644
index 00000000000..9ddbb6988df
--- /dev/null
+++ b/internal/registry/claude_family_lookup_test.go
@@ -0,0 +1,39 @@
+package registry
+
+import "testing"
+
+func TestLookupStaticModelInfoClaudeVariantsUseCatalogEntry(t *testing.T) {
+ tests := []struct {
+ id string
+ family string
+ }{
+ {id: "claude-opus-5-5[1m]", family: "claude-opus-5-5"},
+ {id: "claude-opus-5-5-20261001", family: "claude-opus-5-5"},
+ {id: "claude-opus-5-5-20261001[1m]", family: "claude-opus-5-5"},
+ {id: "claude-sonnet-5-5[1m]", family: "claude-sonnet-5-5"},
+ {id: "claude-sonnet-5-5-20261001", family: "claude-sonnet-5-5"},
+ {id: "claude-fable-5[1m]", family: "claude-fable-5"},
+ {id: "claude-fable-5-1[1m]", family: "claude-fable-5-1"},
+ {id: "claude-haiku-4-5-20251001[1m]", family: "claude-haiku-4-5-20251001"},
+ }
+ for _, tt := range tests {
+ info := LookupStaticModelInfo(tt.id)
+ if info == nil || info.ID != tt.family {
+ t.Errorf("LookupStaticModelInfo(%q) = %+v, want catalog entry %q", tt.id, info, tt.family)
+ }
+ chInfo := LookupStaticModelInfoByChannel(tt.id, "claude")
+ if chInfo == nil || chInfo.ID != tt.family {
+ t.Errorf("LookupStaticModelInfoByChannel(%q, \"claude\") = %+v, want catalog entry %q", tt.id, chInfo, tt.family)
+ }
+ }
+
+ // Exact catalog IDs keep winning, and only Claude variants are normalized.
+ if info := LookupStaticModelInfo("claude-haiku-4-5-20251001"); info == nil || info.ID != "claude-haiku-4-5-20251001" {
+ t.Errorf("exact dated ID resolved to %+v", info)
+ }
+ for _, id := range []string{"claude-unknown-20261001", "claude-opus-5-5-2026", "claude-opus-5-5[1m", "gpt-5[1m]"} {
+ if info := LookupStaticModelInfo(id); info != nil {
+ t.Errorf("LookupStaticModelInfo(%q) = %q, want nil", id, info.ID)
+ }
+ }
+}
diff --git a/internal/registry/model_definitions.go b/internal/registry/model_definitions.go
index f2dbce02e45..3d988b8b77b 100644
--- a/internal/registry/model_definitions.go
+++ b/internal/registry/model_definitions.go
@@ -545,11 +545,21 @@ func LookupStaticModelInfoByChannel(modelID, channel string) *ModelInfo {
if modelID == "" {
return nil
}
- for _, model := range GetStaticModelDefinitionsByChannel(channel) {
+ models := GetStaticModelDefinitionsByChannel(channel)
+ for _, model := range models {
if model != nil && model.ID == modelID {
return cloneModelInfo(model)
}
}
+ if strings.EqualFold(strings.TrimSpace(channel), "claude") {
+ for _, familyID := range claudeFamilyModelIDs(modelID) {
+ for _, model := range models {
+ if model != nil && model.ID == familyID {
+ return cloneModelInfo(model)
+ }
+ }
+ }
+ }
return nil
}
@@ -586,5 +596,45 @@ func LookupStaticModelInfo(modelID string) *ModelInfo {
}
}
+ // Dated and bracketed Claude variants (claude-sonnet-5-5-20261001,
+ // claude-opus-5-5[1m]) share the capabilities of their catalog entry.
+ for _, familyID := range claudeFamilyModelIDs(modelID) {
+ for _, m := range data.Claude {
+ if m != nil && m.ID == familyID {
+ return cloneModelInfo(m)
+ }
+ }
+ }
+
return nil
}
+
+// claudeFamilyModelIDs returns the catalog IDs a Claude model variant may belong
+// to: without a "[...]" context suffix, then also without a "-YYYYMMDD" date.
+func claudeFamilyModelIDs(modelID string) []string {
+ if !strings.HasPrefix(modelID, "claude-") {
+ return nil
+ }
+ var ids []string
+ base := modelID
+ if i := strings.IndexByte(base, '['); i > 0 && strings.HasSuffix(base, "]") {
+ base = base[:i]
+ ids = append(ids, base)
+ }
+ if n := len(base); n > 9 && base[n-9] == '-' && isEightDigits(base[n-8:]) {
+ ids = append(ids, base[:n-9])
+ }
+ return ids
+}
+
+func isEightDigits(s string) bool {
+ if len(s) != 8 {
+ return false
+ }
+ for _, r := range s {
+ if r < '0' || r > '9' {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/registry/model_registry.go b/internal/registry/model_registry.go
index b1c3bdee4a3..c64a5048b57 100644
--- a/internal/registry/model_registry.go
+++ b/internal/registry/model_registry.go
@@ -150,6 +150,9 @@ type ThinkingSupport struct {
ZeroAllowed bool `json:"zero_allowed,omitempty" yaml:"zero-allowed,omitempty"`
// DynamicAllowed indicates whether -1 is a valid value (dynamic thinking budget).
DynamicAllowed bool `json:"dynamic_allowed,omitempty" yaml:"dynamic-allowed,omitempty"`
+ // DisableType is the Claude thinking.type sent when thinking is turned off.
+ // Empty means "disabled"; claude-sonnet-5-5 needs "between_tools".
+ DisableType string `json:"disable_type,omitempty" yaml:"disable-type,omitempty"`
// Levels defines discrete reasoning effort levels (e.g., "low", "medium", "high").
// When set, the model uses level-based reasoning instead of token budgets.
Levels []string `json:"levels,omitempty" yaml:"levels,omitempty"`
@@ -441,6 +444,37 @@ func (r *ModelRegistry) RegisterClient(clientID, clientProvider string, models [
r.registerClientLocked(clientID, clientProvider, models, false)
}
+// RegisterClientIfEpochCurrent registers a client's models only if its
+// registration epoch still equals expectedEpoch and publish is non-nil. The
+// fence evaluation and the registration runs atomically under the registry
+// mutex. publish must be fast and must not call back into the registry or
+// block. The returned value reports whether the registration was applied.
+func (r *ModelRegistry) RegisterClientIfEpochCurrent(clientID, clientProvider string, expectedEpoch uint64, models []*ModelInfo, publish func() bool) bool {
+ r.mutex.Lock()
+ defer r.mutex.Unlock()
+ if r.clientEpochs[clientID] != expectedEpoch || publish == nil || !publish() {
+ return false
+ }
+ r.registerClientLocked(clientID, clientProvider, models, false)
+ return true
+}
+
+// UnregisterClientIfEpochCurrent removes a client only if its registration
+// epoch still equals expectedEpoch and publish is non-nil. The fence
+// evaluation and the removal runs atomically under the registry mutex.
+// publish must be fast and must not call back into the registry or block.
+// The returned value reports whether the removal was applied.
+func (r *ModelRegistry) UnregisterClientIfEpochCurrent(clientID string, expectedEpoch uint64, publish func() bool) bool {
+ r.mutex.Lock()
+ defer r.mutex.Unlock()
+ if r.clientEpochs[clientID] != expectedEpoch || publish == nil || !publish() {
+ return false
+ }
+ r.unregisterClientInternal(clientID)
+ r.invalidateAvailableModelsCacheLocked()
+ return true
+}
+
// ReplaceClientModels replaces a client's models only if its registration epoch matches.
// Epoch zero and epochs for empty catalogs are valid. A successful replacement advances
// the epoch and preserves quota, suspension state and the projection generation
diff --git a/internal/registry/models/models.json b/internal/registry/models/models.json
index 228ef1c7319..3164935aeb5 100644
--- a/internal/registry/models/models.json
+++ b/internal/registry/models/models.json
@@ -112,9 +112,8 @@
"context_length": 1000000,
"max_completion_tokens": 128000,
"thinking": {
- "min": 1024,
- "max": 128000,
"zero_allowed": true,
+ "dynamic_allowed": true,
"levels": [
"low",
"medium",
@@ -142,9 +141,8 @@
"context_length": 1000000,
"max_completion_tokens": 128000,
"thinking": {
- "min": 1024,
- "max": 128000,
"zero_allowed": true,
+ "dynamic_allowed": true,
"levels": [
"low",
"medium",
@@ -233,9 +231,8 @@
"context_length": 1000000,
"max_completion_tokens": 128000,
"thinking": {
- "min": 1024,
- "max": 128000,
- "zero_allowed": true,
+ "zero_allowed": false,
+ "dynamic_allowed": true,
"levels": [
"low",
"medium",
@@ -263,9 +260,8 @@
"context_length": 1000000,
"max_completion_tokens": 128000,
"thinking": {
- "min": 1024,
- "max": 128000,
- "zero_allowed": true,
+ "zero_allowed": false,
+ "dynamic_allowed": true,
"levels": [
"low",
"medium",
@@ -296,7 +292,7 @@
"context_length": 1000000,
"max_completion_tokens": 128000,
"thinking": {
- "zero_allowed": true,
+ "zero_allowed": false,
"dynamic_allowed": true,
"levels": [
"low",
@@ -327,6 +323,39 @@
"description": "Anthropic's agentic Sonnet model for coding, tool use, and enterprise workflows",
"context_length": 1000000,
"max_completion_tokens": 128000,
+ "thinking": {
+ "zero_allowed": true,
+ "dynamic_allowed": true,
+ "disable_type": "between_tools",
+ "levels": [
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ "max"
+ ]
+ },
+ "supportedInputModalities": [
+ "text",
+ "image"
+ ],
+ "supportedOutputModalities": [
+ "text"
+ ]
+ },
+ {
+ "id": "claude-haiku-5-5",
+ "native_capabilities": {
+ "web_search": true
+ },
+ "object": "model",
+ "created": 1790553600,
+ "owned_by": "anthropic",
+ "type": "claude",
+ "display_name": "Claude Haiku 5.5",
+ "description": "For high-volume, latency-sensitive tasks such as classification, extraction, and routing.",
+ "context_length": 1000000,
+ "max_completion_tokens": 128000,
"thinking": {
"zero_allowed": true,
"dynamic_allowed": true,
@@ -941,14 +970,14 @@
}
},
{
- "id": "gemini-3.7-flash",
+ "id": "gemini-3.8-flash",
"object": "model",
"owned_by": "google",
"type": "gemini",
- "display_name": "Gemini 3.7 Flash",
- "name": "models/gemini-3.7-flash",
- "version": "3.7",
- "description": "Gemini 3.7 Flash",
+ "display_name": "Gemini 3.8 Flash",
+ "name": "models/gemini-3.8-flash",
+ "version": "3.8",
+ "description": "Gemini 3.8 Flash",
"context_length": 1048576,
"max_completion_tokens": 65536,
"thinking": {
@@ -975,16 +1004,15 @@
}
},
{
- "id": "gemini-3.8-flash",
+ "id": "gemini-4-argon",
"object": "model",
"owned_by": "google",
"type": "gemini",
- "display_name": "Gemini 3.8 Flash",
- "name": "models/gemini-3.8-flash",
- "version": "3.8",
- "description": "Gemini 3.8 Flash",
+ "display_name": "Gemini 4 Argon",
+ "name": "models/gemini-4-argon",
+ "description": "Gemini 4 Argon",
"context_length": 1048576,
- "max_completion_tokens": 65536,
+ "max_completion_tokens": 1048576,
"thinking": {
"min": 128,
"max": 65535,
@@ -1664,13 +1692,13 @@
}
},
{
- "id": "gemini-3.7-flash",
+ "id": "gemini-3.8-flash",
"object": "model",
"owned_by": "google",
"type": "gemini",
- "display_name": "Gemini 3.7 Flash",
- "name": "gemini-3.7-flash",
- "description": "Gemini 3.7 Flash",
+ "display_name": "Gemini 3.8 Flash",
+ "name": "gemini-3.8-flash",
+ "description": "Gemini 3.8 Flash",
"context_length": 1048576,
"max_completion_tokens": 65536,
"thinking": {
@@ -1697,15 +1725,15 @@
}
},
{
- "id": "gemini-3.8-flash",
+ "id": "gemini-4-argon",
"object": "model",
"owned_by": "google",
"type": "gemini",
- "display_name": "Gemini 3.8 Flash",
- "name": "gemini-3.8-flash",
- "description": "Gemini 3.8 Flash",
+ "display_name": "Gemini 4 Argon",
+ "name": "gemini-4-argon",
+ "description": "Gemini 4 Argon",
"context_length": 1048576,
- "max_completion_tokens": 65536,
+ "max_completion_tokens": 1048576,
"thinking": {
"min": 128,
"max": 65535,
@@ -2529,40 +2557,6 @@
"web_search": true
}
},
- {
- "id": "gemini-3.7-flash",
- "object": "model",
- "owned_by": "google",
- "type": "gemini",
- "display_name": "Gemini 3.7 Flash",
- "name": "models/gemini-3.7-flash",
- "version": "3.7",
- "description": "Gemini 3.7 Flash",
- "context_length": 1048576,
- "max_completion_tokens": 65536,
- "thinking": {
- "min": 128,
- "max": 65535,
- "dynamic_allowed": true,
- "levels": [
- "low",
- "medium",
- "high"
- ]
- },
- "supportedInputModalities": [
- "text",
- "image",
- "audio",
- "video"
- ],
- "supportedOutputModalities": [
- "text"
- ],
- "native_capabilities": {
- "web_search": true
- }
- },
{
"id": "gemini-3.8-flash",
"object": "model",
@@ -4075,51 +4069,16 @@
]
},
{
- "id": "gemini-3.6-flash-high",
+ "id": "gemini-4-argon-high",
"object": "model",
"owned_by": "antigravity",
"type": "antigravity",
"supports_web_search": true,
- "display_name": "Gemini 3.6 Flash",
- "name": "gemini-3.6-flash-high",
- "description": "Gemini 3.6 Flash (High)",
+ "display_name": "Gemini 4 Argon",
+ "name": "gemini-4-argon-high",
+ "description": "Gemini 4 Argon (High)",
"context_length": 1048576,
- "max_completion_tokens": 65536,
- "thinking": {
- "min": 1,
- "max": 65535,
- "dynamic_allowed": true,
- "levels": [
- "minimal",
- "low",
- "medium",
- "high"
- ]
- },
- "supportedInputModalities": [
- "text",
- "image",
- "audio",
- "video"
- ],
- "supportedOutputModalities": [
- "text"
- ],
- "native_capabilities": {
- "web_search": true
- }
- },
- {
- "id": "gemini-3.7-flash-high",
- "object": "model",
- "owned_by": "antigravity",
- "type": "antigravity",
- "supports_web_search": true,
- "display_name": "Gemini 3.7 Flash",
- "name": "gemini-3.7-flash-high",
- "description": "Gemini 3.7 Flash (High)",
- "context_length": 1048576,
- "max_completion_tokens": 65536,
+ "max_completion_tokens": 1048576,
"thinking": {
"min": 1,
"max": 65535,
@@ -4242,74 +4201,6 @@
"web_search": true
}
},
- {
- "id": "gemini-pro-agent",
- "object": "model",
- "owned_by": "antigravity",
- "type": "antigravity",
- "supports_web_search": true,
- "display_name": "Gemini 3.1 Pro (High)",
- "name": "gemini-pro-agent",
- "description": "Gemini 3.1 Pro (High)",
- "context_length": 1048576,
- "max_completion_tokens": 65535,
- "thinking": {
- "min": 1,
- "max": 65535,
- "dynamic_allowed": true,
- "levels": [
- "low",
- "medium",
- "high"
- ]
- },
- "supportedInputModalities": [
- "text",
- "image",
- "audio",
- "video"
- ],
- "supportedOutputModalities": [
- "text"
- ],
- "native_capabilities": {
- "web_search": true
- }
- },
- {
- "id": "gemini-3.1-pro-low",
- "object": "model",
- "owned_by": "antigravity",
- "type": "antigravity",
- "supports_web_search": true,
- "display_name": "Gemini 3.1 Pro (Low)",
- "name": "gemini-3.1-pro-low",
- "description": "Gemini 3.1 Pro (Low)",
- "context_length": 1048576,
- "max_completion_tokens": 65535,
- "thinking": {
- "min": 1,
- "max": 65535,
- "dynamic_allowed": true,
- "levels": [
- "low",
- "medium",
- "high"
- ]
- },
- "supportedInputModalities": [
- "text",
- "image",
- "audio",
- "video"
- ],
- "supportedOutputModalities": [
- "text"
- ],
- "native_capabilities": {
- "web_search": true
- }
- },
{
"id": "gpt-oss-120b-medium",
"object": "model",
@@ -4393,7 +4284,10 @@
],
"supportedOutputModalities": [
"text"
- ]
+ ],
+ "native_capabilities": {
+ "web_search": true
+ }
}
],
"xai": [
@@ -4692,6 +4586,130 @@
}
],
"meta": [
+ {
+ "id": "muse-wonder-1.0",
+ "object": "model",
+ "created": 1791763200,
+ "owned_by": "meta",
+ "type": "meta",
+ "display_name": "Muse Wonder 1.0",
+ "name": "muse-wonder-1.0",
+ "description": "Meta's most capable model, built for complex software engineering and long-horizon agentic work.",
+ "context_length": 1007997,
+ "max_completion_tokens": 128000,
+ "thinking": {
+ "levels": [
+ "minimal",
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ "max"
+ ]
+ },
+ "supportedInputModalities": [
+ "text",
+ "image",
+ "video",
+ "audio"
+ ],
+ "supportedOutputModalities": [
+ "text"
+ ]
+ },
+ {
+ "id": "muse-wonder-1.0-contributor",
+ "object": "model",
+ "created": 1791763200,
+ "owned_by": "meta",
+ "type": "meta",
+ "display_name": "Muse Wonder 1.0 Contributor",
+ "name": "muse-wonder-1.0-contributor",
+ "description": "Meta's most capable model, built for complex software engineering and long-horizon agentic work.",
+ "context_length": 1007997,
+ "max_completion_tokens": 128000,
+ "thinking": {
+ "levels": [
+ "minimal",
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ "max"
+ ]
+ },
+ "supportedInputModalities": [
+ "text",
+ "image",
+ "video",
+ "audio"
+ ],
+ "supportedOutputModalities": [
+ "text"
+ ]
+ },
+ {
+ "id": "muse-wonder-full-1.0",
+ "object": "model",
+ "created": 1791763200,
+ "owned_by": "meta",
+ "type": "meta",
+ "display_name": "Muse Wonder Full 1.0",
+ "name": "muse-wonder-full-1.0",
+ "description": "Meta's most capable model, built for complex software engineering and long-horizon agentic work.",
+ "context_length": 1007997,
+ "max_completion_tokens": 128000,
+ "thinking": {
+ "levels": [
+ "minimal",
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ "max"
+ ]
+ },
+ "supportedInputModalities": [
+ "text",
+ "image",
+ "video",
+ "audio"
+ ],
+ "supportedOutputModalities": [
+ "text"
+ ]
+ },
+ {
+ "id": "muse-wonder-full-1.0-contributor",
+ "object": "model",
+ "created": 1791763200,
+ "owned_by": "meta",
+ "type": "meta",
+ "display_name": "Muse Wonder Full 1.0 Contributor",
+ "name": "muse-wonder-full-1.0-contributor",
+ "description": "Meta's most capable model, built for complex software engineering and long-horizon agentic work.",
+ "context_length": 1007997,
+ "max_completion_tokens": 128000,
+ "thinking": {
+ "levels": [
+ "minimal",
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ "max"
+ ]
+ },
+ "supportedInputModalities": [
+ "text",
+ "image",
+ "video",
+ "audio"
+ ],
+ "supportedOutputModalities": [
+ "text"
+ ]
+ },
{
"id": "muse-spark-1.3",
"object": "model",
diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go
index 182bc8f22d3..ad42c95d600 100644
--- a/internal/runtime/executor/aistudio_executor.go
+++ b/internal/runtime/executor/aistudio_executor.go
@@ -195,6 +195,11 @@ func (e *AIStudioExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth,
if helps.ApplyPatchTranslationError(param) != nil || len(out) == 0 {
return cliproxyexecutor.Response{}, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}
}
+ if errPayload, okErr := helps.TranslatedClaudeErrorPayload(responseFormat, out); okErr {
+ err = statusErr{code: http.StatusBadGateway, msg: errPayload}
+ reporter.PublishFailureWithDetail(ctx, helps.ParseGeminiUsage(wsResp.Body), err)
+ return cliproxyexecutor.Response{}, err
+ }
reporter.Publish(ctx, helps.ParseGeminiUsage(wsResp.Body))
if responseFormat == sdktranslator.FormatOpenAIResponse {
out = helps.EnsureResponsesUsageDetails(out)
@@ -310,7 +315,7 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
}
return nil, statusErr{code: firstEvent.Status, msg: body.String()}
}
- out := make(chan cliproxyexecutor.StreamChunk)
+ out := make(chan cliproxyexecutor.StreamChunk, 1)
go func(first wsrelay.StreamEvent) {
defer close(out)
defer cancelStream()
@@ -323,15 +328,28 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
claudeInputTokens := helps.NewClaudeInputTokenState(opts.SourceFormat, body.toFormat, responseFormat, originalRequest)
var streamUsage helps.StreamUsageBuffer
defer streamUsage.Publish(ctx, reporter)
+ var streamErr error
+ defer func() {
+ _ = helps.StopTranslatedClaudeStream(ctx, &streamErr, reporter, &streamUsage, out)
+ }()
var param any
helps.InitializeApplyPatchStream(ctx, body.toFormat, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translatedReq, ¶m)
metadataLogged := false
finishStream := func() {
- if helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) || ctx.Err() != nil {
+ if helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) {
+ return
+ }
+ if helps.StopTranslatedClaudeStream(ctx, &streamErr, reporter, &streamUsage, out) {
+ return
+ }
+ if ctx.Err() != nil {
return
}
lines := helps.TranslateStreamWithClaudeInputTokens(ctx, body.toFormat, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translatedReq, []byte("[DONE]"), ¶m, claudeInputTokens)
helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage})
+ helps.CaptureTranslatedClaudeStreamError(responseFormat, lines, &streamErr, func(msg string) error {
+ return statusErr{code: http.StatusBadGateway, msg: msg}
+ })
for _, line := range lines {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: ensureColonSpacedJSON(line)}:
@@ -340,9 +358,13 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
}
}
_ = helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage})
+ _ = helps.StopTranslatedClaudeStream(ctx, &streamErr, reporter, &streamUsage, out)
}
processEvent := func(event wsrelay.StreamEvent) bool {
if event.Err != nil {
+ if helps.StopTranslatedClaudeStream(ctx, &streamErr, reporter, &streamUsage, out) {
+ return false
+ }
helps.RecordAPIResponseError(ctx, e.cfg, event.Err)
reporter.PublishFailure(ctx, event.Err)
select {
@@ -363,12 +385,15 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
reporter.MarkFirstResponseByte()
helps.AppendAPIResponseChunk(ctx, e.cfg, event.Payload)
reporter.ObserveResponseModel(event.Payload)
- filtered := helps.FilterSSEUsageMetadata(event.Payload)
- if detail, ok := helps.ParseGeminiStreamUsage(filtered); ok {
+ if detail, ok := helps.ParseGeminiStreamUsage(event.Payload); ok {
streamUsage.Observe(detail, true)
}
+ filtered := helps.FilterSSEUsageMetadata(event.Payload)
lines := helps.TranslateStreamWithClaudeInputTokens(ctx, body.toFormat, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translatedReq, filtered, ¶m, claudeInputTokens)
helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage})
+ helps.CaptureTranslatedClaudeStreamError(responseFormat, lines, &streamErr, func(msg string) error {
+ return statusErr{code: http.StatusBadGateway, msg: msg}
+ })
for i := range lines {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: ensureColonSpacedJSON(lines[i])}:
@@ -404,8 +429,13 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
}
return false
}
+ reporter.ObserveResponseModel(event.Payload)
+ streamUsage.Observe(helps.ParseGeminiUsage(event.Payload), true)
lines := helps.TranslateStreamWithClaudeInputTokens(ctx, body.toFormat, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translatedReq, event.Payload, ¶m, claudeInputTokens)
helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage})
+ helps.CaptureTranslatedClaudeStreamError(responseFormat, lines, &streamErr, func(msg string) error {
+ return statusErr{code: http.StatusBadGateway, msg: msg}
+ })
for i := range lines {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: ensureColonSpacedJSON(lines[i])}:
@@ -417,10 +447,11 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
return false
}
finishStream()
- reporter.ObserveResponseModel(event.Payload)
- streamUsage.Observe(helps.ParseGeminiUsage(event.Payload), true)
return false
case wsrelay.MessageTypeError:
+ if helps.StopTranslatedClaudeStream(ctx, &streamErr, reporter, &streamUsage, out) {
+ return false
+ }
helps.RecordAPIResponseError(ctx, e.cfg, event.Err)
reporter.PublishFailure(ctx, event.Err)
select {
@@ -440,6 +471,7 @@ func (e *AIStudioExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth
}
}
_ = helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage})
+ _ = helps.StopTranslatedClaudeStream(ctx, &streamErr, reporter, &streamUsage, out)
}(firstEvent)
return &cliproxyexecutor.StreamResult{Headers: firstEvent.Headers.Clone(), Chunks: out}, nil
}
diff --git a/internal/runtime/executor/aistudio_executor_test.go b/internal/runtime/executor/aistudio_executor_test.go
index 8606d7e1eb9..755fc5f1889 100644
--- a/internal/runtime/executor/aistudio_executor_test.go
+++ b/internal/runtime/executor/aistudio_executor_test.go
@@ -1,6 +1,7 @@
package executor
import (
+ "bytes"
"context"
"fmt"
"net/http"
@@ -12,6 +13,7 @@ import (
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/internal/wsrelay"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
@@ -42,10 +44,10 @@ func TestAIStudioTranslateRequestPreservesSummaryFromOriginalRequest(t *testing.
func TestAIStudioTranslateRequestNormalizesThinkingLevel(t *testing.T) {
executor := NewAIStudioExecutor(&config.Config{}, "aistudio", nil)
req := cliproxyexecutor.Request{
- Model: "gemini-3.7-flash",
+ Model: "gemini-3.8-flash",
Payload: []byte(`{
"contents":[{"role":"user","parts":[{"text":"Human: Hi"}]}],
- "model":"gemini-3.7-flash",
+ "model":"gemini-3.8-flash",
"generationConfig":{"thinkingConfig":{"thinkingLevel":"high","includeThoughts":true}}
}`),
}
@@ -554,3 +556,256 @@ func TestApplyPatchAIStudioHTTPGatewayErrorMatrix(t *testing.T) {
t.Run(mode, func(t *testing.T) { task6AIStudioFailure(t, mode, true) })
}
}
+
+func TestAIStudioExecutorClaudeMalformedFunctionCallReturnsRetryableError(t *testing.T) {
+ malformedBody := `{"candidates":[{"content":{"role":"model","parts":[]},"finishReason":"MALFORMED_FUNCTION_CALL"}],"usageMetadata":{"promptTokenCount":120,"candidatesTokenCount":10,"thoughtsTokenCount":313,"totalTokenCount":443},"modelVersion":"gemini-4-argon"}`
+ authID := "aistudio-" + t.Name()
+ connected := make(chan struct{})
+ relay := wsrelay.NewManager(wsrelay.Options{
+ ProviderFactory: func(*http.Request) (string, error) { return authID, nil },
+ OnConnected: func(string) { close(connected) },
+ })
+ server := httptest.NewServer(relay.Handler())
+ defer server.Close()
+ defer func() {
+ if errStop := relay.Stop(context.Background()); errStop != nil {
+ t.Error(errStop)
+ }
+ }()
+ conn, _, errDial := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http")+relay.Path(), nil)
+ if errDial != nil {
+ t.Fatal(errDial)
+ }
+ defer func() {
+ if errClose := conn.Close(); errClose != nil {
+ t.Error(errClose)
+ }
+ }()
+ <-connected
+
+ clientDone := make(chan error, 1)
+ go func() {
+ for i := 0; i < 3; i++ {
+ var msg wsrelay.Message
+ if errReadJSON := conn.ReadJSON(&msg); errReadJSON != nil {
+ clientDone <- errReadJSON
+ return
+ }
+ var responses []wsrelay.Message
+ switch i {
+ case 0, 2:
+ responses = []wsrelay.Message{{
+ ID: msg.ID,
+ Type: wsrelay.MessageTypeHTTPResp,
+ Payload: map[string]any{
+ "status": float64(http.StatusOK),
+ "headers": map[string]any{"Content-Type": "application/json"},
+ "body": malformedBody,
+ },
+ }}
+ case 1:
+ responses = []wsrelay.Message{
+ {ID: msg.ID, Type: wsrelay.MessageTypeStreamStart, Payload: map[string]any{"status": 200}},
+ {ID: msg.ID, Type: wsrelay.MessageTypeStreamChunk, Payload: map[string]any{"data": "data: " + malformedBody + "\n\n"}},
+ {ID: msg.ID, Type: wsrelay.MessageTypeStreamEnd},
+ }
+ }
+ for _, response := range responses {
+ if errWriteJSON := conn.WriteJSON(response); errWriteJSON != nil {
+ clientDone <- errWriteJSON
+ return
+ }
+ }
+ }
+ clientDone <- nil
+ }()
+
+ capture := &malformedFunctionCallUsageCapture{authID: authID, records: make(chan usage.Record, 3)}
+ usage.RegisterNamedPlugin(t.Name(), capture)
+ t.Cleanup(func() {
+ usage.RegisterNamedPlugin(t.Name(), malformedFunctionCallUsageNoop{})
+ })
+
+ exec := NewAIStudioExecutor(&config.Config{}, "aistudio", relay)
+ auth := &cliproxyauth.Auth{ID: authID, Provider: "aistudio"}
+ req := cliproxyexecutor.Request{
+ Model: "gemini-4-argon",
+ Payload: []byte(`{"model":"gemini-4-argon","max_tokens":1024,"messages":[{"role":"user","content":"hi"}]}`),
+ }
+ opts := cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatClaude,
+ ResponseFormat: sdktranslator.FormatClaude,
+ }
+
+ _, errExec := exec.Execute(context.Background(), auth, req, opts)
+ if errExec == nil {
+ t.Fatal("Execute() error = nil, want retryable 502 error for MALFORMED_FUNCTION_CALL")
+ }
+ status, okStatus := errExec.(interface{ StatusCode() int })
+ if !okStatus || status.StatusCode() != http.StatusBadGateway {
+ t.Fatalf("Execute() error = %T %v, want status 502", errExec, errExec)
+ }
+ if got := gjson.Get(errExec.Error(), "error.type").String(); got != "api_error" {
+ t.Fatalf("Execute() error.type = %q, want api_error. Error: %s", got, errExec.Error())
+ }
+ assertMalformedFunctionCallFailedUsage(t, capture.records)
+
+ for _, mode := range []string{"stream_chunk", "http_resp"} {
+ streamRes, errStream := exec.ExecuteStream(context.Background(), auth, req, opts)
+ if errStream != nil {
+ t.Fatalf("%s ExecuteStream() error = %v", mode, errStream)
+ }
+ var combined bytes.Buffer
+ var streamErr error
+ for chunk := range streamRes.Chunks {
+ if chunk.Err != nil {
+ streamErr = chunk.Err
+ continue
+ }
+ combined.Write(chunk.Payload)
+ }
+ if streamErr == nil {
+ t.Fatalf("%s ExecuteStream() chunk.Err = nil, want 502 error propagated", mode)
+ }
+ streamText := combined.String()
+ if strings.Contains(streamText, `"stop_reason":"refusal"`) {
+ t.Fatalf("%s stream unexpectedly emitted stop_reason refusal:\n%s", mode, streamText)
+ }
+ if !strings.Contains(streamText, "event: error\n") || !strings.Contains(streamText, `"type": "api_error"`) && !strings.Contains(streamText, `"type":"api_error"`) {
+ t.Fatalf("%s stream missing retryable api_error event:\n%s", mode, streamText)
+ }
+ assertMalformedFunctionCallFailedUsage(t, capture.records)
+ }
+
+ if errClient := <-clientDone; errClient != nil {
+ t.Fatal(errClient)
+ }
+}
+
+func TestAIStudioExecutorClaudeMalformedFunctionCallCancelAfterErrorRecordsConductorFailure(t *testing.T) {
+ malformedBody := `{"candidates":[{"content":{"role":"model","parts":[]},"finishReason":"MALFORMED_FUNCTION_CALL"}],"usageMetadata":{"promptTokenCount":120,"candidatesTokenCount":10,"thoughtsTokenCount":313,"totalTokenCount":443},"modelVersion":"gemini-4-argon"}`
+ for _, mode := range []string{"stream_end", "http_resp"} {
+ t.Run(mode, func(t *testing.T) {
+ authID := "aistudio-cancel-" + mode
+ connected := make(chan struct{})
+ canceled := make(chan struct{})
+ relay := wsrelay.NewManager(wsrelay.Options{
+ ProviderFactory: func(*http.Request) (string, error) { return authID, nil },
+ OnConnected: func(string) { close(connected) },
+ })
+ server := httptest.NewServer(relay.Handler())
+ defer server.Close()
+ defer func() {
+ if errStop := relay.Stop(context.Background()); errStop != nil {
+ t.Error(errStop)
+ }
+ }()
+ conn, _, errDial := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http")+relay.Path(), nil)
+ if errDial != nil {
+ t.Fatal(errDial)
+ }
+ defer func() {
+ if errClose := conn.Close(); errClose != nil {
+ t.Error(errClose)
+ }
+ }()
+ <-connected
+
+ clientDone := make(chan error, 1)
+ go func() {
+ var msg wsrelay.Message
+ if errReadJSON := conn.ReadJSON(&msg); errReadJSON != nil {
+ clientDone <- errReadJSON
+ return
+ }
+ if mode == "http_resp" {
+ if errWriteJSON := conn.WriteJSON(wsrelay.Message{
+ ID: msg.ID,
+ Type: wsrelay.MessageTypeHTTPResp,
+ Payload: map[string]any{
+ "status": float64(http.StatusOK),
+ "headers": map[string]any{"Content-Type": "application/json"},
+ "body": malformedBody,
+ },
+ }); errWriteJSON != nil {
+ clientDone <- errWriteJSON
+ return
+ }
+ clientDone <- nil
+ return
+ }
+ for _, response := range []wsrelay.Message{
+ {ID: msg.ID, Type: wsrelay.MessageTypeStreamStart, Payload: map[string]any{"status": 200}},
+ {ID: msg.ID, Type: wsrelay.MessageTypeStreamChunk, Payload: map[string]any{"data": "data: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"thought\":true,\"text\":\"planning\"}]}}],\"modelVersion\":\"gemini-4-argon\"}\n\n"}},
+ {ID: msg.ID, Type: wsrelay.MessageTypeStreamChunk, Payload: map[string]any{"data": "data: " + malformedBody + "\n\n"}},
+ } {
+ if errWriteJSON := conn.WriteJSON(response); errWriteJSON != nil {
+ clientDone <- errWriteJSON
+ return
+ }
+ }
+ <-canceled
+ if errWriteJSON := conn.WriteJSON(wsrelay.Message{ID: msg.ID, Type: wsrelay.MessageTypeStreamEnd}); errWriteJSON != nil {
+ clientDone <- errWriteJSON
+ return
+ }
+ clientDone <- nil
+ }()
+
+ capture := &malformedFunctionCallUsageCapture{authID: authID, records: make(chan usage.Record, 1)}
+ usage.RegisterNamedPlugin(t.Name(), capture)
+ t.Cleanup(func() {
+ usage.RegisterNamedPlugin(t.Name(), malformedFunctionCallUsageNoop{})
+ })
+
+ exec := NewAIStudioExecutor(&config.Config{}, "aistudio", relay)
+ auth := &cliproxyauth.Auth{ID: authID, Provider: "aistudio", Status: cliproxyauth.StatusActive}
+ manager := cliproxyauth.NewManager(nil, nil, nil)
+ manager.RegisterExecutor(exec)
+ if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
+ t.Fatalf("Register() error = %v", errRegister)
+ }
+ registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: "gemini-4-argon"}})
+ t.Cleanup(func() {
+ registry.GetGlobalRegistry().UnregisterClient(auth.ID)
+ })
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ req := cliproxyexecutor.Request{
+ Model: "gemini-4-argon",
+ Payload: []byte(`{"model":"gemini-4-argon","max_tokens":1024,"messages":[{"role":"user","content":"hi"}]}`),
+ }
+ opts := cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatClaude,
+ ResponseFormat: sdktranslator.FormatClaude,
+ Stream: true,
+ }
+
+ streamRes, errStream := manager.ExecuteStream(ctx, []string{"aistudio"}, req, opts)
+ if errStream != nil {
+ t.Fatalf("manager.ExecuteStream() error = %v", errStream)
+ }
+ var cancelOnce sync.Once
+ for chunk := range streamRes.Chunks {
+ if strings.Contains(string(chunk.Payload), "event: error") {
+ cancelOnce.Do(func() {
+ cancel()
+ close(canceled)
+ })
+ }
+ }
+ assertMalformedFunctionCallFailedUsage(t, capture.records)
+ updated, okAuth := manager.GetByID(auth.ID)
+ if !okAuth || updated.ModelStates["gemini-4-argon"] == nil || updated.ModelStates["gemini-4-argon"].LastError == nil {
+ t.Fatalf("expected conductor to record failure on model state after client cancel on error event, got %+v", updated)
+ }
+ if updated.ModelStates["gemini-4-argon"].LastError.HTTPStatus != http.StatusBadGateway {
+ t.Fatalf("LastError.HTTPStatus = %d, want 502; err=%+v", updated.ModelStates["gemini-4-argon"].LastError.HTTPStatus, updated.ModelStates["gemini-4-argon"].LastError)
+ }
+ if errClient := <-clientDone; errClient != nil {
+ t.Fatal(errClient)
+ }
+ })
+ }
+}
diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go
index d28ad028903..25118cfe66b 100644
--- a/internal/runtime/executor/antigravity_executor.go
+++ b/internal/runtime/executor/antigravity_executor.go
@@ -23,6 +23,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/httptransport"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/proxyutil"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
@@ -41,7 +42,7 @@ const (
antigravityClientSecret = "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"
antigravityAuthType = "antigravity"
antigravityRequestTokenSafetyWindow = 5 * time.Minute
- antigravityCreditsHintRefreshInterval = 10 * time.Minute
+ antigravityCreditsHintRefreshInterval = cliproxyauth.AntigravityCreditsHintRefreshInterval
antigravityCreditsHintRefreshTimeout = 5 * time.Second
antigravityShortQuotaCooldownThreshold = 5 * time.Minute
antigravityInstantRetryThreshold = 3 * time.Second
@@ -436,7 +437,8 @@ func antigravityCredentialScope(prefix, secret string) string {
// negotiates TLS 1.3 without advertising an ALPN protocol and therefore never uses h2.
// The underlying Transport is always shared so keep-alive connections survive across
// requests instead of forcing a fresh TCP + TLS handshake every time.
-func newAntigravityHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) *http.Client {
+func newAntigravityHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) (clientOut *http.Client) {
+ defer func() { clientOut = httptransport.Decorate(ctx, clientOut) }()
// Native Antigravity reuses one transport across requests. Opt into a
// credential-scoped proxy transport only here so other providers keep their
// existing lifecycle and different OAuth identities remain isolated.
@@ -448,7 +450,7 @@ func newAntigravityHTTPClient(ctx context.Context, cfg *config.Config, auth *cli
// context transport fallback, preserving the previous behavior.
}
- client := helps.NewProxyAwareHTTPClient(ctx, cfg, auth, timeout)
+ client := helps.NewProxyAwareHTTPClient(httptransport.WithoutDecorator(ctx), cfg, auth, timeout)
// Direct requests share an HTTP/1.1 pool only within the selected credential.
if client.Transport == nil {
client.Transport = antigravityHTTP11Transport(auth, antigravityBaseTransport, cfg)
diff --git a/internal/runtime/executor/antigravity_executor_credits.go b/internal/runtime/executor/antigravity_executor_credits.go
index 51645397171..531dca1b4e7 100644
--- a/internal/runtime/executor/antigravity_executor_credits.go
+++ b/internal/runtime/executor/antigravity_executor_credits.go
@@ -247,7 +247,7 @@ func decideAntigravity429(body []byte) antigravity429Decision {
reason := strings.TrimSpace(detail.Get("reason").String())
decision.reason = reason
switch {
- case strings.EqualFold(reason, "QUOTA_EXHAUSTED"):
+ case strings.EqualFold(reason, "QUOTA_EXHAUSTED"), strings.EqualFold(reason, "INSUFFICIENT_G1_CREDITS_BALANCE"):
decision.kind = antigravity429DecisionFullQuotaExhausted
return decision
case strings.EqualFold(reason, "RATE_LIMIT_EXCEEDED"):
@@ -289,7 +289,13 @@ func clearAntigravityCreditsFailureState(auth *cliproxyauth.Auth) {
if auth == nil || strings.TrimSpace(auth.ID) == "" {
return
}
- antigravityCreditsFailureByAuth.Delete(strings.TrimSpace(auth.ID))
+ authID := strings.TrimSpace(auth.ID)
+ _, hadFailure := antigravityCreditsFailureByAuth.LoadAndDelete(authID)
+ if hint, ok := cliproxyauth.GetAntigravityCreditsHint(authID); ok && hint.Known && !hint.Available {
+ if hadFailure || !cliproxyauth.IsAntigravityCreditsHintFresh(hint, time.Now()) {
+ cliproxyauth.SetAntigravityCreditsHint(authID, cliproxyauth.AntigravityCreditsHint{})
+ }
+ }
}
func markAntigravityCreditsPermanentlyDisabled(auth *cliproxyauth.Auth) {
if auth == nil || strings.TrimSpace(auth.ID) == "" || antigravityCoolingDisabled(auth, nil) {
@@ -363,7 +369,7 @@ func (e *AntigravityExecutor) maybeRefreshAntigravityCreditsHint(ctx context.Con
if authID == "" {
return
}
- if hint, ok := cliproxyauth.GetAntigravityCreditsHint(authID); ok && hint.Known {
+ if hint, ok := cliproxyauth.GetAntigravityCreditsHint(authID); ok && cliproxyauth.IsAntigravityCreditsHintFresh(hint, time.Now()) {
return
}
if strings.TrimSpace(accessToken) == "" {
diff --git a/internal/runtime/executor/antigravity_executor_credits_test.go b/internal/runtime/executor/antigravity_executor_credits_test.go
index 8b0bb9eeba1..0fb64b035ab 100644
--- a/internal/runtime/executor/antigravity_executor_credits_test.go
+++ b/internal/runtime/executor/antigravity_executor_credits_test.go
@@ -10,11 +10,13 @@ import (
"net/http/httptest"
"strings"
"sync"
+ "sync/atomic"
"testing"
"time"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
homekv "github.com/router-for-me/CLIProxyAPI/v8/internal/home"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
@@ -670,6 +672,282 @@ func TestEnsureAccessToken_WarmTokenLoadsCreditsHint(t *testing.T) {
}
}
+func TestMaybeRefreshAntigravityCreditsHint_RefreshesExpiredHintAndRecoversCredits(t *testing.T) {
+ resetAntigravityCreditsRetryState()
+ t.Cleanup(resetAntigravityCreditsRetryState)
+
+ exec := NewAntigravityExecutor(&config.Config{
+ QuotaExceeded: config.QuotaExceeded{AntigravityCredits: true},
+ })
+ auth := &cliproxyauth.Auth{
+ ID: fmt.Sprintf("auth-expired-credits-hint-%d", time.Now().UnixNano()),
+ Metadata: map[string]any{
+ "access_token": "token",
+ "expired": time.Now().Add(1 * time.Hour).Format(time.RFC3339),
+ },
+ }
+ t.Cleanup(func() {
+ cliproxyauth.SetAntigravityCreditsHint(auth.ID, cliproxyauth.AntigravityCreditsHint{})
+ })
+
+ markAntigravityCreditsPermanentlyDisabled(auth)
+ if _, failed := antigravityCreditsFailureByAuth.Load(auth.ID); !failed {
+ t.Fatal("expected auth to be marked permanently disabled for credits")
+ }
+
+ var loadCodeAssistCalls int
+ ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", roundTripperFunc(func(req *http.Request) (*http.Response, error) {
+ if req.URL.String() != "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" {
+ t.Fatalf("unexpected request url %s", req.URL.String())
+ }
+ loadCodeAssistCalls++
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader(`{"paidTier":{"id":"tier-1","availableCredits":[{"creditType":"GOOGLE_ONE_AI","creditAmount":"2516","minimumCreditAmountForUsage":"50"}]}}`)),
+ }, nil
+ }))
+
+ // Fresh hint (< antigravityCreditsHintRefreshInterval) must not trigger a refresh.
+ exec.maybeRefreshAntigravityCreditsHint(ctx, auth, "token")
+ if loadCodeAssistCalls != 0 {
+ t.Fatalf("loadCodeAssist calls for fresh hint = %d, want 0", loadCodeAssistCalls)
+ }
+
+ // Once hint.UpdatedAt is older than antigravityCreditsHintRefreshInterval,
+ // maybeRefreshAntigravityCreditsHint must re-query loadCodeAssist and recover credits.
+ cliproxyauth.SetAntigravityCreditsHint(auth.ID, cliproxyauth.AntigravityCreditsHint{
+ Known: true,
+ Available: false,
+ CreditAmount: 0,
+ MinCreditAmount: 1,
+ UpdatedAt: time.Now().Add(-antigravityCreditsHintRefreshInterval - time.Second),
+ })
+
+ exec.maybeRefreshAntigravityCreditsHint(ctx, auth, "token")
+ if value, ok := antigravityCreditsHintRefreshByID.Load(auth.ID); ok {
+ if state, ok := value.(*antigravityCreditsHintRefreshState); ok && state != nil {
+ waitForAntigravityCreditsRefresh(t, state)
+ }
+ }
+
+ if loadCodeAssistCalls != 1 {
+ t.Fatalf("loadCodeAssist calls for expired hint = %d, want 1", loadCodeAssistCalls)
+ }
+ hint, ok := cliproxyauth.GetAntigravityCreditsHint(auth.ID)
+ if !ok || !hint.Known || !hint.Available {
+ t.Fatalf("credits hint after refresh = %#v (ok=%v), want Known=true Available=true", hint, ok)
+ }
+ if hint.CreditAmount != 2516 || hint.MinCreditAmount != 50 {
+ t.Fatalf("hint amounts = (%v, %v), want (2516, 50)", hint.CreditAmount, hint.MinCreditAmount)
+ }
+ if _, failed := antigravityCreditsFailureByAuth.Load(auth.ID); failed {
+ t.Fatal("expected permanently disabled credits state to be cleared after recovery")
+ }
+}
+
+func TestAntigravityManagerExecuteAndStream_ToppedUpCreditsRecoverDuringQuotaCooldown(t *testing.T) {
+ for _, stream := range []bool{false, true} {
+ mode := "execute"
+ if stream {
+ mode = "execute_stream"
+ }
+ t.Run(mode, func(t *testing.T) {
+ resetAntigravityCreditsRetryState()
+ t.Cleanup(resetAntigravityCreditsRetryState)
+
+ const model = "claude-sonnet-4-6"
+ var toppedUp atomic.Bool
+ var loadCodeAssistCalls atomic.Int32
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ _ = r.Body.Close()
+
+ if r.URL.Path == "/v1internal:loadCodeAssist" {
+ callNum := loadCodeAssistCalls.Add(1)
+ w.Header().Set("Content-Type", "application/json")
+ if toppedUp.Load() {
+ _, _ = w.Write([]byte(`{"paidTier":{"id":"tier-1","availableCredits":[{"creditType":"GOOGLE_ONE_AI","creditAmount":"2516","minimumCreditAmountForUsage":"50"}]}}`))
+ } else if callNum == 1 {
+ _, _ = w.Write([]byte(`{"paidTier":{"id":"tier-1","availableCredits":[{"creditType":"GOOGLE_ONE_AI","creditAmount":"100","minimumCreditAmountForUsage":"50"}]}}`))
+ } else {
+ _, _ = w.Write([]byte(`{"paidTier":{"id":"tier-1","availableCredits":[{"creditType":"GOOGLE_ONE_AI","creditAmount":"0","minimumCreditAmountForUsage":"50"}]}}`))
+ }
+ return
+ }
+
+ hasCreditsFlag := strings.Contains(string(body), `"enabledCreditTypes":["GOOGLE_ONE_AI"]`)
+ if !hasCreditsFlag {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusTooManyRequests)
+ _, _ = w.Write([]byte(`{
+ "error": {
+ "code": 429,
+ "message": "You have exhausted your capacity on this model. Your quota will reset after 1h0m0s.",
+ "status": "RESOURCE_EXHAUSTED",
+ "details": [
+ {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "reason": "QUOTA_EXHAUSTED"},
+ {"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "3600s"}
+ ]
+ }
+ }`))
+ return
+ }
+
+ if !toppedUp.Load() {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusTooManyRequests)
+ _, _ = w.Write([]byte(`{
+ "error": {
+ "code": 429,
+ "message": "Insufficient Google One AI credits balance.",
+ "status": "RESOURCE_EXHAUSTED",
+ "details": [
+ {"@type": "type.googleapis.com/google.rpc.ErrorInfo", "reason": "INSUFFICIENT_G1_CREDITS_BALANCE"}
+ ]
+ }
+ }`))
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte("data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"recovered\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":1,\"candidatesTokenCount\":1,\"totalTokenCount\":2}}}\n\n"))
+ }))
+ defer server.Close()
+
+ cfg := &config.Config{
+ QuotaExceeded: config.QuotaExceeded{AntigravityCredits: true},
+ }
+ exec := NewAntigravityExecutor(cfg)
+ manager := cliproxyauth.NewManager(nil, nil, nil)
+ manager.SetConfig(cfg)
+ manager.RegisterExecutor(exec)
+
+ authID := fmt.Sprintf("ag-topup-recovery-%s-%d", mode, time.Now().UnixNano())
+ registry.GetGlobalRegistry().RegisterClient(authID, "antigravity", []*registry.ModelInfo{{ID: model}})
+ t.Cleanup(func() {
+ registry.GetGlobalRegistry().UnregisterClient(authID)
+ cliproxyauth.SetAntigravityCreditsHint(authID, cliproxyauth.AntigravityCreditsHint{})
+ })
+
+ if _, errRegister := manager.Register(context.Background(), &cliproxyauth.Auth{
+ ID: authID,
+ Provider: "antigravity",
+ Attributes: map[string]string{
+ "base_url": server.URL,
+ },
+ Metadata: map[string]any{
+ "access_token": "token",
+ "project_id": "project-1",
+ "expired": time.Now().Add(1 * time.Hour).Format(time.RFC3339),
+ },
+ }); errRegister != nil {
+ t.Fatalf("register auth: %v", errRegister)
+ }
+
+ req := cliproxyexecutor.Request{
+ Model: model,
+ Payload: []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`),
+ }
+ opts := cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatAntigravity,
+ ResponseFormat: sdktranslator.FormatAntigravity,
+ }
+
+ // Step 1: Exhaust weekly quota (QUOTA_EXHAUSTED) and credits (INSUFFICIENT_G1_CREDITS_BALANCE).
+ cliproxyauth.SetAntigravityCreditsHint(authID, cliproxyauth.AntigravityCreditsHint{
+ Known: true,
+ Available: true,
+ CreditAmount: 100,
+ MinCreditAmount: 50,
+ UpdatedAt: time.Now(),
+ })
+ if stream {
+ if _, errStream := manager.ExecuteStream(context.Background(), []string{"antigravity"}, req, opts); errStream == nil {
+ t.Fatal("initial ExecuteStream() succeeded, want 429 after quota and credits exhaustion")
+ }
+ } else {
+ if _, errExec := manager.Execute(context.Background(), []string{"antigravity"}, req, opts); errExec == nil {
+ t.Fatal("initial Execute() succeeded, want 429 after quota and credits exhaustion")
+ }
+ }
+ if value, ok := antigravityCreditsHintRefreshByID.Load(authID); ok {
+ if state, ok := value.(*antigravityCreditsHintRefreshState); ok && state != nil {
+ waitForAntigravityCreditsRefresh(t, state)
+ }
+ }
+ if _, failed := antigravityCreditsFailureByAuth.Load(authID); !failed {
+ t.Fatal("expected auth to be marked permanently disabled after INSUFFICIENT_G1_CREDITS_BALANCE")
+ }
+ hint, ok := cliproxyauth.GetAntigravityCreditsHint(authID)
+ if !ok || !hint.Known || hint.Available {
+ t.Fatalf("hint after exhaustion = %#v (ok=%v), want Known=true Available=false", hint, ok)
+ }
+
+ // Step 2: Top up credits and expire the cached hint/throttle window while model remains in 1h quota cooldown.
+ toppedUp.Store(true)
+ callsBeforeTopUp := loadCodeAssistCalls.Load()
+ if value, ok := antigravityCreditsHintRefreshByID.Load(authID); ok {
+ if state, ok := value.(*antigravityCreditsHintRefreshState); ok && state != nil {
+ state.mu.Lock()
+ state.lastAttempt = time.Now().Add(-antigravityCreditsHintRefreshInterval - time.Second)
+ state.mu.Unlock()
+ }
+ }
+ cliproxyauth.SetAntigravityCreditsHint(authID, cliproxyauth.AntigravityCreditsHint{
+ Known: true,
+ Available: false,
+ CreditAmount: 0,
+ MinCreditAmount: 1,
+ UpdatedAt: time.Now().Add(-antigravityCreditsHintRefreshInterval - time.Second),
+ })
+
+ // Step 3: Request recovers via credits fallback while model cooldown is active and triggers loadCodeAssist refresh.
+ if stream {
+ streamResult, errStream := manager.ExecuteStream(context.Background(), []string{"antigravity"}, req, opts)
+ if errStream != nil {
+ t.Fatalf("ExecuteStream() after top-up error = %v", errStream)
+ }
+ var streamed strings.Builder
+ for chunk := range streamResult.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("unexpected stream chunk error: %v", chunk.Err)
+ }
+ streamed.Write(chunk.Payload)
+ }
+ if !strings.Contains(streamed.String(), "recovered") {
+ t.Fatalf("ExecuteStream() payload = %s, want recovered", streamed.String())
+ }
+ } else {
+ resp, errExec := manager.Execute(context.Background(), []string{"antigravity"}, req, opts)
+ if errExec != nil {
+ t.Fatalf("Execute() after top-up error = %v", errExec)
+ }
+ if !strings.Contains(string(resp.Payload), "recovered") {
+ t.Fatalf("Execute() payload = %s, want recovered", string(resp.Payload))
+ }
+ }
+
+ if value, ok := antigravityCreditsHintRefreshByID.Load(authID); ok {
+ if state, ok := value.(*antigravityCreditsHintRefreshState); ok && state != nil {
+ waitForAntigravityCreditsRefresh(t, state)
+ }
+ }
+ if loadCodeAssistCalls.Load() <= callsBeforeTopUp {
+ t.Fatalf("loadCodeAssist calls after top-up = %d, want > %d", loadCodeAssistCalls.Load(), callsBeforeTopUp)
+ }
+ hint, ok = cliproxyauth.GetAntigravityCreditsHint(authID)
+ if !ok || !hint.Known || !hint.Available || hint.CreditAmount != 2516 {
+ t.Fatalf("hint after recovery = %#v (ok=%v), want Known=true Available=true CreditAmount=2516", hint, ok)
+ }
+ if _, failed := antigravityCreditsFailureByAuth.Load(authID); failed {
+ t.Fatal("expected permanently disabled credits state to be cleared after recovery")
+ }
+ })
+ }
+}
+
func TestUpdateAntigravityCreditsBalance_LoadCodeAssistUserAgent(t *testing.T) {
resetAntigravityCreditsRetryState()
t.Cleanup(resetAntigravityCreditsRetryState)
diff --git a/internal/runtime/executor/antigravity_executor_interactions_test.go b/internal/runtime/executor/antigravity_executor_interactions_test.go
index 061893e8d46..cc492da7774 100644
--- a/internal/runtime/executor/antigravity_executor_interactions_test.go
+++ b/internal/runtime/executor/antigravity_executor_interactions_test.go
@@ -48,9 +48,9 @@ func TestAntigravityExecutorExecuteStreamTranslatesInteractionsRequest(t *testin
"expired": time.Now().Add(time.Hour).Format(time.RFC3339),
},
}
- payload := []byte(`{"model":"gemini-3.1-pro-low","input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]}],"tools":[{"name":"get_weather","description":"weather","type":"function","parameters":{"type":"object","properties":{"location":{"type":"string"}},"required":["location"]}}],"generation_config":{"tool_choice":"auto","thinking_level":"high","thinking_summaries":"auto"},"stream":true,"store":false}`)
+ payload := []byte(`{"model":"gemini-3.8-flash-high","input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]}],"tools":[{"name":"get_weather","description":"weather","type":"function","parameters":{"type":"object","properties":{"location":{"type":"string"}},"required":["location"]}}],"generation_config":{"tool_choice":"auto","thinking_level":"high","thinking_summaries":"auto"},"stream":true,"store":false}`)
result, errExecute := exec.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
- Model: "gemini-3.1-pro-low",
+ Model: "gemini-3.8-flash-high",
Payload: payload,
}, cliproxyexecutor.Options{
SourceFormat: sdktranslator.FormatInteractions,
diff --git a/internal/runtime/executor/antigravity_executor_request.go b/internal/runtime/executor/antigravity_executor_request.go
index 9c51a68acd2..51c396f741e 100644
--- a/internal/runtime/executor/antigravity_executor_request.go
+++ b/internal/runtime/executor/antigravity_executor_request.go
@@ -67,13 +67,14 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau
}
}
- useAntigravitySchema := strings.Contains(modelName, "claude") || strings.Contains(modelName, "gemini-3-pro") || strings.Contains(modelName, "gemini-3.1-pro")
+ isClaudeModel := strings.Contains(modelName, "claude")
+ useAntigravitySchema := isClaudeModel || strings.Contains(modelName, "gemini-3-pro") || strings.Contains(modelName, "gemini-3.1-pro")
var (
bodyReader io.Reader
payloadLog []byte
)
if antigravityRequestNeedsSchemaSanitization(payload) {
- payloadStr := sanitizeAntigravityRequestSchemas(string(payload), useAntigravitySchema)
+ payloadStr := sanitizeAntigravityRequestSchemasForModel(string(payload), useAntigravitySchema, isClaudeModel)
if strings.Contains(modelName, "claude") {
updated, _ := sjson.SetBytes([]byte(payloadStr), "request.toolConfig.functionCallingConfig.mode", "VALIDATED")
@@ -164,8 +165,19 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau
// data keys inside functionCall arguments replayed from conversation history. Running it over the
// whole document silently mutated that history, so tools lost required argument fields and the
// model imitated the corrupted examples on later turns.
+// sanitizeAntigravityRequestSchemas cleans with union-preserving (Gemini) tool semantics.
+// Claude tool unions are flattened only when the caller supplies the model flag via
+// sanitizeAntigravityRequestSchemasForModel; the placeholder group alone must not decide it,
+// because gemini-3-pro models share that group and accept preserved unions.
func sanitizeAntigravityRequestSchemas(payloadStr string, useAntigravitySchema bool) string {
- payloadStr = sanitizeAntigravityToolSchemas(payloadStr, useAntigravitySchema)
+ return sanitizeAntigravityRequestSchemasForModel(payloadStr, useAntigravitySchema, false)
+}
+
+// sanitizeAntigravityRequestSchemasForModel applies sanitizeAntigravityRequestSchemas with an
+// explicit Antigravity Claude flag. Antigravity Claude tool schemas additionally flatten
+// multi-branch anyOf/oneOf unions, which the Claude-side upstream rejects (Issue 3662).
+func sanitizeAntigravityRequestSchemasForModel(payloadStr string, useAntigravitySchema, isClaudeModel bool) string {
+ payloadStr = sanitizeAntigravityToolSchemasForModel(payloadStr, useAntigravitySchema, isClaudeModel)
return sanitizeAntigravityGenerationSchemas(payloadStr)
}
@@ -174,13 +186,17 @@ func sanitizeAntigravityRequestSchemas(payloadStr string, useAntigravitySchema b
// once. This preserves rewrite order and bytes without copying the full request
// for every declaration schema.
func sanitizeAntigravityToolSchemas(payloadStr string, useAntigravitySchema bool) string {
+ return sanitizeAntigravityToolSchemasForModel(payloadStr, useAntigravitySchema, false)
+}
+
+func sanitizeAntigravityToolSchemasForModel(payloadStr string, useAntigravitySchema, isClaudeModel bool) string {
tools := gjson.Get(payloadStr, "request.tools")
if !tools.IsArray() {
return payloadStr
}
toolDocument := `{"request":{"tools":` + tools.Raw + `}}`
- toolDocument = sanitizeAntigravityToolSchemaDocument(toolDocument, useAntigravitySchema)
+ toolDocument = sanitizeAntigravityToolSchemaDocumentForModel(toolDocument, useAntigravitySchema, isClaudeModel)
cleanedTools := gjson.Get(toolDocument, "request.tools")
if !cleanedTools.IsArray() || cleanedTools.Raw == tools.Raw {
return payloadStr
@@ -194,30 +210,194 @@ func sanitizeAntigravityToolSchemas(payloadStr string, useAntigravitySchema bool
}
func sanitizeAntigravityToolSchemaDocument(payloadStr string, useAntigravitySchema bool) string {
- for _, base := range antigravityFunctionDeclarationPaths(payloadStr) {
- oldPath := base + ".parametersJsonSchema"
- if !gjson.Get(payloadStr, oldPath).Exists() {
- continue
+ return sanitizeAntigravityToolSchemaDocumentForModel(payloadStr, useAntigravitySchema, false)
+}
+
+// antigravityDeclarationContainers lists both spellings of the declarations
+// array a tool may carry.
+var antigravityDeclarationContainers = []string{"functionDeclarations", "function_declarations"}
+
+// sanitizeAntigravityToolSchemaDocumentForModel renames and cleans every
+// declaration schema in the tools-only document. Each declaration is edited
+// on its own raw bytes and the results are spliced back into the tools array
+// in a single pass, so the cost stays linear in the document size instead of
+// scaling with declarations x document size (Issue 6714). The emitted bytes
+// match the previous whole-document edits: changed elements are spliced at
+// their original offsets, so untouched bytes (including whitespace around
+// array elements) survive verbatim, and changed nodes go through the same
+// sjson edits, scoped to one declaration.
+func sanitizeAntigravityToolSchemaDocumentForModel(payloadStr string, useAntigravitySchema, isClaudeModel bool) string {
+ toolSchemaCleaner := func(schema string, isParameterSchema bool) string {
+ // Only declaration parameter schemas become Claude custom.input_schema upstream,
+ // so only they need Claude union flattening. Function return schemas keep the
+ // union-preserving cleaning like every other response-side schema.
+ if isClaudeModel && isParameterSchema {
+ return util.CleanJSONSchemaForAntigravityClaudeTool(schema, useAntigravitySchema)
}
- renamed, errRename := util.RenameKey(payloadStr, oldPath, base+".parameters")
- if errRename != nil {
- log.Debugf("antigravity: failed to rename %s: %v", oldPath, errRename)
- continue
+ return util.CleanJSONSchemaForAntigravityTool(schema, useAntigravitySchema)
+ }
+ cleanNestedToolSchema := func(schemaRaw string, isParameterSchema bool) string {
+ return cleanNestedSchema(func(schema string) string {
+ return toolSchemaCleaner(schema, isParameterSchema)
+ }, schemaRaw)
+ }
+
+ tools := gjson.Parse(payloadStr).Get("request.tools")
+ if !tools.IsArray() {
+ return payloadStr
+ }
+ elements := make([]gjson.Result, 0)
+ edits := make([]antigravityElementEdit, 0)
+ tools.ForEach(func(_, tool gjson.Result) bool {
+ elements = append(elements, tool)
+ if !tool.IsObject() {
+ return true
+ }
+ toolRaw := tool.Raw
+ for _, declKey := range antigravityDeclarationContainers {
+ decls := tool.Get(declKey)
+ if !decls.IsArray() {
+ continue
+ }
+ cleanedDecls, declsChanged := sanitizeAntigravityDeclarations(decls, cleanNestedToolSchema)
+ if !declsChanged {
+ continue
+ }
+ updated, errSet := sjson.SetRawBytes([]byte(toolRaw), declKey, []byte(cleanedDecls))
+ if errSet != nil {
+ log.Debugf("antigravity: failed to write cleaned %s: %v", declKey, errSet)
+ continue
+ }
+ toolRaw = string(updated)
+ }
+ if toolRaw != tool.Raw {
+ edits = append(edits, antigravityElementEdit{offset: tool.Index - tools.Index, raw: tool.Raw, replacement: toolRaw})
+ }
+ return true
+ })
+ if len(edits) == 0 {
+ return payloadStr
+ }
+ updated, errSet := sjson.SetRawBytes([]byte(payloadStr), "request.tools", []byte(applyAntigravityElementEdits(tools.Raw, tools.Index, elements, edits)))
+ if errSet != nil {
+ log.Debugf("antigravity: failed to write cleaned request.tools: %v", errSet)
+ return payloadStr
+ }
+ return string(updated)
+}
+
+// antigravityElementEdit replaces one array element (raw, at offset within
+// the array's raw bytes) with replacement.
+type antigravityElementEdit struct {
+ offset int
+ raw string
+ replacement string
+}
+
+// applyAntigravityElementEdits splices element replacements into an array's
+// raw bytes in offset order, keeping every other byte (commas, whitespace,
+// unchanged elements) verbatim. Edits whose offsets do not line up with the
+// array bytes fall back to joining the elements with commas: still valid
+// and semantically identical, just without the original separators. gjson
+// reports element offsets into the parsed document, so offsets here are
+// relative to the array start.
+func applyAntigravityElementEdits(arrayRaw string, arrayIndex int, elements []gjson.Result, edits []antigravityElementEdit) string {
+ var rebuilt strings.Builder
+ rebuilt.Grow(len(arrayRaw))
+ cursor := 0
+ for _, edit := range edits {
+ if edit.offset < cursor || edit.offset+len(edit.raw) > len(arrayRaw) || arrayRaw[edit.offset:edit.offset+len(edit.raw)] != edit.raw {
+ return joinAntigravityElements(arrayIndex, elements, edits)
}
- payloadStr = renamed
+ rebuilt.WriteString(arrayRaw[cursor:edit.offset])
+ rebuilt.WriteString(edit.replacement)
+ cursor = edit.offset + len(edit.raw)
}
+ rebuilt.WriteString(arrayRaw[cursor:])
+ return rebuilt.String()
+}
- toolSchemaCleaner := func(schema string) string {
- return util.CleanJSONSchemaForAntigravityTool(schema, useAntigravitySchema)
+// joinAntigravityElements rebuilds an array by joining element bytes with
+// commas, substituting replacements where recorded. It is the fallback for
+// applyAntigravityElementEdits when splice offsets cannot be trusted.
+func joinAntigravityElements(arrayIndex int, elements []gjson.Result, edits []antigravityElementEdit) string {
+ replacements := make(map[int]string, len(edits))
+ for _, edit := range edits {
+ replacements[edit.offset] = edit.replacement
+ }
+ var rebuilt strings.Builder
+ rebuilt.WriteByte('[')
+ for i, element := range elements {
+ if i > 0 {
+ rebuilt.WriteByte(',')
+ }
+ if replacement, ok := replacements[element.Index-arrayIndex]; ok {
+ rebuilt.WriteString(replacement)
+ } else {
+ rebuilt.WriteString(element.Raw)
+ }
}
- cleanNestedToolSchema := func(schemaRaw string) string {
- return cleanNestedSchema(toolSchemaCleaner, schemaRaw)
+ rebuilt.WriteByte(']')
+ return rebuilt.String()
+}
+
+// sanitizeAntigravityDeclarations rebuilds one declarations array, applying
+// the rename and schema cleaning to each declaration in isolation. It
+// reports whether any declaration actually changed so callers can keep the
+// original bytes when the array is already clean.
+func sanitizeAntigravityDeclarations(decls gjson.Result, clean func(schemaRaw string, isParameterSchema bool) string) (string, bool) {
+ elements := make([]gjson.Result, 0)
+ edits := make([]antigravityElementEdit, 0)
+ decls.ForEach(func(_, decl gjson.Result) bool {
+ elements = append(elements, decl)
+ if !decl.IsObject() {
+ return true
+ }
+ cleanedDecl := sanitizeAntigravityDeclaration(decl, clean)
+ if cleanedDecl != decl.Raw {
+ edits = append(edits, antigravityElementEdit{offset: decl.Index - decls.Index, raw: decl.Raw, replacement: cleanedDecl})
+ }
+ return true
+ })
+ if len(edits) == 0 {
+ return "", false
}
- return cleanAntigravitySchemasAtPaths(
- payloadStr,
- antigravityDeclarationSchemaPaths(payloadStr),
- cleanNestedToolSchema,
- )
+ return applyAntigravityElementEdits(decls.Raw, decls.Index, elements, edits), true
+}
+
+// sanitizeAntigravityDeclaration applies the declaration-level edits that
+// used to run against the whole tools document: parametersJsonSchema is
+// renamed onto parameters first, then every object schema is cleaned in
+// place. Failed edits keep the declaration as-is and are only logged.
+func sanitizeAntigravityDeclaration(decl gjson.Result, clean func(schemaRaw string, isParameterSchema bool) string) string {
+ declRaw := decl.Raw
+ if decl.Get("parametersJsonSchema").Exists() {
+ renamed, errRename := util.RenameKey(declRaw, "parametersJsonSchema", "parameters")
+ if errRename != nil {
+ log.Debugf("antigravity: failed to rename parametersJsonSchema: %v", errRename)
+ } else {
+ declRaw = renamed
+ decl = gjson.Parse(declRaw)
+ }
+ }
+ for _, key := range antigravityDeclarationSchemaKeys {
+ schema := decl.Get(key)
+ if !schema.IsObject() {
+ continue
+ }
+ cleanedSchema := clean(schema.Raw, isParameterSchemaPath(key))
+ if cleanedSchema == schema.Raw {
+ continue
+ }
+ updated, errSet := sjson.SetRawBytes([]byte(declRaw), key, []byte(cleanedSchema))
+ if errSet != nil {
+ log.Debugf("antigravity: failed to write cleaned schema at %s: %v", key, errSet)
+ continue
+ }
+ declRaw = string(updated)
+ decl = gjson.Parse(declRaw)
+ }
+ return declRaw
}
// sanitizeAntigravityGenerationSchemas batches every schema edit within one
@@ -258,6 +438,16 @@ func sanitizeAntigravityGenerationSchemas(payloadStr string) string {
return payloadStr
}
+// isParameterSchemaPath reports whether a declaration schema path holds tool input
+// parameters rather than a function return schema.
+func isParameterSchemaPath(schemaPath string) bool {
+ key := schemaPath
+ if idx := strings.LastIndex(schemaPath, "."); idx >= 0 {
+ key = schemaPath[idx+1:]
+ }
+ return strings.HasPrefix(key, "parameters")
+}
+
func cleanAntigravitySchemasAtPaths(payloadStr string, schemaPaths []string, clean func(string) string) string {
for _, schemaPath := range schemaPaths {
schema := gjson.Get(payloadStr, schemaPath)
diff --git a/internal/runtime/executor/antigravity_schema_sanitize_test.go b/internal/runtime/executor/antigravity_schema_sanitize_test.go
index aa8b43f96e9..e7736478a88 100644
--- a/internal/runtime/executor/antigravity_schema_sanitize_test.go
+++ b/internal/runtime/executor/antigravity_schema_sanitize_test.go
@@ -2,9 +2,12 @@ package executor
import (
"encoding/json"
+ "fmt"
+ "runtime"
"strings"
"testing"
+ antigravityclaude "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/antigravity/claude"
antigravitychat "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/antigravity/openai/chat-completions"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
"github.com/tidwall/gjson"
@@ -275,6 +278,15 @@ func TestSanitizeAntigravityRequestSchemasProjectsUnionsAndPreservesEnumTypes(t
payload := `{"request":{
"tools":[{"functionDeclarations":[{"name":"tool","parameters":{"type":"object","properties":{
"choice":{"anyOf":[{"type":"string"},{"type":"null"}]},
+ "item":{"anyOf":[
+ {"type":"string"},
+ {"type":"object","properties":{"image":{"type":"string"}},"required":["image"]},
+ {"type":"object","properties":{"audio":{"type":"string"}},"required":["audio"]}
+ ]},
+ "variant":{"oneOf":[
+ {"type":"object","properties":{"image":{"type":"string"}},"required":["image"]},
+ {"type":"object","properties":{"audio":{"type":"string"}},"required":["audio"]}
+ ]},
"level":{"type":"number","enum":[1,2]}
}}}]}],
"generationConfig":{"responseSchema":{"type":"object","properties":{
@@ -304,7 +316,22 @@ func TestSanitizeAntigravityRequestSchemasProjectsUnionsAndPreservesEnumTypes(t
toolSchema := gjson.Get(got, "request.tools.0.functionDeclarations.0.parameters")
if toolSchema.Get("properties.choice.anyOf").Exists() {
- t.Errorf("tool anyOf union was not flattened: %s", toolSchema.Raw)
+ t.Errorf("tool nullable single-branch anyOf union was not flattened: %s", toolSchema.Raw)
+ }
+ if gotBranches := len(toolSchema.Get("properties.item.anyOf").Array()); gotBranches != 3 {
+ t.Errorf("tool multi-branch anyOf count = %d, want 3: %s", gotBranches, toolSchema.Raw)
+ }
+ if !toolSchema.Get("properties.item.anyOf.2.properties.audio").Exists() {
+ t.Errorf("tool multi-branch anyOf lost audio branch: %s", toolSchema.Raw)
+ }
+ if toolSchema.Get("properties.variant.oneOf").Exists() {
+ t.Errorf("tool multi-branch oneOf should be converted to anyOf for parameters Schema carrier: %s", toolSchema.Raw)
+ }
+ if gotVariantBranches := len(toolSchema.Get("properties.variant.anyOf").Array()); gotVariantBranches != 2 {
+ t.Errorf("tool multi-branch variant.anyOf count = %d, want 2: %s", gotVariantBranches, toolSchema.Raw)
+ }
+ if !toolSchema.Get("properties.variant.anyOf.1.properties.audio").Exists() {
+ t.Errorf("tool multi-branch variant.anyOf lost audio branch: %s", toolSchema.Raw)
}
if gotType := toolSchema.Get("properties.level.type").String(); gotType != "number" {
t.Errorf("tool enum type = %q, want number: %s", gotType, toolSchema.Raw)
@@ -687,3 +714,287 @@ func TestSanitizeAntigravityRequestSchemasNormalizesTrueBooleanSubschemas(t *tes
}
}
}
+
+// Issue 3662 regression: the original fix preserved multi-branch tool unions for
+// every Antigravity model, but the Antigravity Claude backend converts Gemini
+// function declarations into Claude custom.input_schema and rejects nested
+// multi-branch anyOf/oneOf unions with HTTP 400 ("JSON schema is invalid").
+// Tool schemas for Claude models must therefore have nested multi-branch unions
+// flattened to a single strongest branch before they are sent upstream, while
+// Gemini models keep the preserved-union behavior from the original fix.
+func TestAntigravityBuildRequestFlattensToolUnionsForClaude_Issue3662Regression(t *testing.T) {
+ // Reduced reproduction from the regression report: a nested boolean/object/array
+ // union under properties.value, plus a two-string-branch nested union. Merely
+ // declaring these tools must not poison the whole Claude request.
+ payload := []byte(`{"request":{"tools":[{"functionDeclarations":[{"name":"diagnostic_union","parametersJsonSchema":{` +
+ `"type":"object",` +
+ `"properties":{` +
+ `"value":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"}},"required":["enabled"]},{"type":"array","items":{"type":"string"}}]},` +
+ `"duo":{"anyOf":[{"type":"string"},{"type":"string"}]}` +
+ `},` +
+ `"required":["value"]` +
+ `}}]}]}}`)
+
+ declParams := func(t *testing.T, modelName string) gjson.Result {
+ t.Helper()
+ body := buildRequestBodyFromRawPayload(t, modelName, payload)
+ encoded, errMarshal := json.Marshal(body)
+ if errMarshal != nil {
+ t.Fatal(errMarshal)
+ }
+ decl := gjson.GetBytes(encoded, "request.tools.0.functionDeclarations.0")
+ if !decl.Exists() {
+ t.Fatalf("model %s: function declaration missing: %s", modelName, encoded)
+ }
+ if decl.Get("parametersJsonSchema").Exists() {
+ t.Fatalf("model %s: parametersJsonSchema was not renamed for Antigravity: %s", modelName, decl.Raw)
+ }
+ params := decl.Get("parameters")
+ if !params.Exists() {
+ t.Fatalf("model %s: parameters missing after sanitization: %s", modelName, decl.Raw)
+ }
+ return params
+ }
+
+ t.Run("claude flattens nested tool unions", func(t *testing.T) {
+ params := declParams(t, "claude-opus-5-5-high")
+
+ value := params.Get("properties.value")
+ if value.Get("anyOf").Exists() || value.Get("oneOf").Exists() {
+ t.Fatalf("claude tool union survived sanitization; Antigravity Claude rejects nested multi-branch unions: %s", params.Raw)
+ }
+ // The pre-regression behavior selected the strongest (object) branch.
+ if got := value.Get("type").String(); got != "object" {
+ t.Fatalf("claude flattened value type = %q, want object (strongest branch): %s", got, params.Raw)
+ }
+ if !value.Get("properties.enabled").Exists() {
+ t.Fatalf("claude flattened value lost the object branch properties: %s", params.Raw)
+ }
+ if desc := value.Get("description").String(); !strings.Contains(desc, "Accepts:") || !strings.Contains(desc, "boolean") || !strings.Contains(desc, "array") {
+ t.Fatalf("claude flattened value lost the dropped-branch hint: %s", params.Raw)
+ }
+
+ duo := params.Get("properties.duo")
+ if duo.Get("anyOf").Exists() || duo.Get("oneOf").Exists() {
+ t.Fatalf("claude two-string-branch union survived sanitization: %s", params.Raw)
+ }
+ if got := duo.Get("type").String(); got != "string" {
+ t.Fatalf("claude flattened duo type = %q, want string: %s", got, params.Raw)
+ }
+ })
+
+ // The Claude-request translator serves whichever Antigravity model the client
+ // selected, Gemini included: its union handling must stay model-unaware and
+ // preserve multi-branch unions, because only the executor knows the target
+ // model. Flattening in the translator would lose non-first branches (audio)
+ // before the executor's Gemini path could keep them.
+ // Function return schemas are not the rejected custom.input_schema: even for
+ // Claude models their multi-branch unions must survive cleaning, like any other
+ // response-side schema. Only declaration parameter schemas are flattened.
+ t.Run("claude keeps unions in function return schemas", func(t *testing.T) {
+ unionSchema := `{"type":"object","properties":{"item":{"anyOf":[{"type":"string"},{"type":"object","properties":{"image":{"type":"string"}},"required":["image"]},{"type":"object","properties":{"audio":{"type":"string"}},"required":["audio"]}]}},"required":["item"]}`
+ decl := `{"name":"diagnostic_union"` +
+ `,"parametersJsonSchema":` + unionSchema +
+ `,"response":` + unionSchema +
+ `,"responseJsonSchema":` + unionSchema +
+ `,"response_json_schema":` + unionSchema + `}`
+ returnPayload := []byte(`{"request":{"tools":[{"functionDeclarations":[` + decl + `]}]}}`)
+
+ body := buildRequestBodyFromRawPayload(t, "claude-opus-5-5-high", returnPayload)
+ encoded, errMarshal := json.Marshal(body)
+ if errMarshal != nil {
+ t.Fatal(errMarshal)
+ }
+ gotDecl := gjson.GetBytes(encoded, "request.tools.0.functionDeclarations.0")
+ params := gotDecl.Get("parameters")
+ if params.Get("properties.item.anyOf").Exists() || params.Get("properties.item.type").String() != "object" || !params.Get("properties.item.properties.image").Exists() {
+ t.Fatalf("claude parameter schema union was not flattened: %s", params.Raw)
+ }
+ for _, key := range []string{"response", "responseJsonSchema", "response_json_schema"} {
+ ret := gotDecl.Get(key)
+ if !ret.Exists() {
+ t.Fatalf("claude return schema %q missing after cleaning: %s", key, gotDecl.Raw)
+ }
+ if got := len(ret.Get("properties.item.anyOf").Array()); got != 3 {
+ t.Fatalf("claude return schema %q anyOf branch count = %d, want 3 preserved: %s", key, got, ret.Raw)
+ }
+ if !ret.Get("properties.item.anyOf.2.properties.audio").Exists() {
+ t.Fatalf("claude return schema %q lost the audio branch: %s", key, ret.Raw)
+ }
+ }
+ })
+
+ // A nullable multi-branch union cleaned by the Claude-request translator first
+ // (which strips the null branch and sets nullable:true on the parent) must keep
+ // that nullability when the executor flattens it for a Claude model, matching
+ // what direct Claude cleaning produces.
+ t.Run("claude chain keeps nullable marker after flattening", func(t *testing.T) {
+ input := []byte(`{"model":"claude-opus-5-5-high","max_tokens":1024,"messages":[{"role":"user","content":"hi"}],"tools":[{"name":"diagnostic_union","input_schema":{"type":"object","properties":{"value":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"null"}]}},"required":["value"]}}]}`)
+ translated, errConv := antigravityclaude.ConvertClaudeRequestToAntigravity("claude-opus-5-5-high", input, false)
+ if errConv != nil {
+ t.Fatalf("convert error: %v", errConv)
+ }
+ translatedValue := gjson.GetBytes(translated, "request.tools.0.functionDeclarations.0.parametersJsonSchema.properties.value")
+ if len(translatedValue.Get("anyOf").Array()) != 2 || !translatedValue.Get("nullable").Bool() {
+ t.Fatalf("translator must preserve the union and set nullable:true: %s", translatedValue.Raw)
+ }
+
+ body := buildRequestBodyFromRawPayload(t, "claude-opus-5-5-high", translated)
+ encoded, errMarshal := json.Marshal(body)
+ if errMarshal != nil {
+ t.Fatal(errMarshal)
+ }
+ finalValue := gjson.GetBytes(encoded, "request.tools.0.functionDeclarations.0.parameters.properties.value")
+ if finalValue.Get("anyOf").Exists() || finalValue.Get("type").String() != "string" {
+ t.Fatalf("executor must flatten the union for Claude: %s", finalValue.Raw)
+ }
+ if !finalValue.Get("nullable").Bool() {
+ t.Fatalf("executor dropped nullable:true while flattening: %s", finalValue.Raw)
+ }
+ })
+
+ t.Run("claude translator then executor honors the selected model", func(t *testing.T) {
+ for _, modelName := range []string{"gemini-3.6-flash-high", "claude-opus-5-5-high"} {
+ input := []byte(`{"model":"` + modelName + `","max_tokens":1024,"messages":[{"role":"user","content":"hi"}],"tools":[{"name":"diagnostic_union","input_schema":{"type":"object","properties":{"item":{"anyOf":[{"type":"string"},{"type":"object","properties":{"image":{"type":"string"}},"required":["image"]},{"type":"object","properties":{"audio":{"type":"string"}},"required":["audio"]}]}},"required":["item"]}}]}`)
+ translated, errConv := antigravityclaude.ConvertClaudeRequestToAntigravity(modelName, input, false)
+ if errConv != nil {
+ t.Fatalf("model %s: convert error: %v", modelName, errConv)
+ }
+ translatedParams := gjson.GetBytes(translated, "request.tools.0.functionDeclarations.0.parametersJsonSchema")
+ if got := len(translatedParams.Get("properties.item.anyOf").Array()); got != 3 {
+ t.Fatalf("model %s: translator anyOf branch count = %d, want 3 (flattening belongs to the executor, not the translator): %s", modelName, got, translatedParams.Raw)
+ }
+ if !translatedParams.Get("properties.item.anyOf.2.properties.audio").Exists() {
+ t.Fatalf("model %s: translator lost the audio branch: %s", modelName, translatedParams.Raw)
+ }
+
+ body := buildRequestBodyFromRawPayload(t, modelName, translated)
+ encoded, errMarshal := json.Marshal(body)
+ if errMarshal != nil {
+ t.Fatal(errMarshal)
+ }
+ finalParams := gjson.GetBytes(encoded, "request.tools.0.functionDeclarations.0.parameters")
+ if !finalParams.Exists() {
+ t.Fatalf("model %s: final parameters missing: %s", modelName, encoded)
+ }
+ if strings.Contains(modelName, "claude") {
+ if finalParams.Get("properties.item.anyOf").Exists() || finalParams.Get("properties.item.type").String() != "object" || !finalParams.Get("properties.item.properties.image").Exists() {
+ t.Fatalf("model %s: executor must flatten the union for Claude: %s", modelName, finalParams.Raw)
+ }
+ } else if got := len(finalParams.Get("properties.item.anyOf").Array()); got != 3 || !finalParams.Get("properties.item.anyOf.2.properties.audio").Exists() {
+ t.Fatalf("model %s: executor must keep 3 preserved branches incl. audio for Gemini, got %d: %s", modelName, got, finalParams.Raw)
+ }
+ }
+ })
+
+ t.Run("gemini keeps preserved tool unions", func(t *testing.T) {
+ // One model per existing useAntigravitySchema group: gemini-3.1-pro shares the
+ // Claude VALIDATED placeholder group, gemini-3.6-flash does not. Both are Gemini
+ // and must keep the preserved unions from the original Issue 3662 fix.
+ for _, modelName := range []string{"gemini-3.1-pro-low", "gemini-3.6-flash-high"} {
+ params := declParams(t, modelName)
+
+ if got := len(params.Get("properties.value.anyOf").Array()); got != 3 {
+ t.Fatalf("model %s: preserved anyOf branch count = %d, want 3: %s", modelName, got, params.Raw)
+ }
+ if !params.Get("properties.value.anyOf.1.properties.enabled").Exists() {
+ t.Fatalf("model %s: preserved anyOf lost the object branch: %s", modelName, params.Raw)
+ }
+ if got := len(params.Get("properties.duo.anyOf").Array()); got != 2 {
+ t.Fatalf("model %s: preserved two-branch anyOf count = %d, want 2: %s", modelName, got, params.Raw)
+ }
+ }
+ })
+}
+
+// TestSanitizeAntigravityRequestSchemasScalesLinearlyWithDeclarations is the
+// regression test for the quadratic tool sanitizing reported in Issue 6714:
+// sanitizing must not rewrite the whole tools document once per function
+// declaration. The allocation bound is asserted directly in the test (not via
+// a benchmark) so a return to per-declaration document rewrites fails loudly.
+func TestSanitizeAntigravityRequestSchemasScalesLinearlyWithDeclarations(t *testing.T) {
+ const declCount = 256
+
+ var input, want strings.Builder
+ input.WriteString(`{"request":{"tools":[{"functionDeclarations":[`)
+ want.WriteString(`{"request":{"tools":[{"functionDeclarations":[`)
+ for i := 0; i < declCount; i++ {
+ if i > 0 {
+ input.WriteByte(',')
+ want.WriteByte(',')
+ }
+ // parametersJsonSchema forces the rename onto parameters and $id forces
+ // a cleaner rewrite of the schema. The padded description sits on the
+ // declaration, outside the schema, so whole-document copies must pay for
+ // it while per-declaration edits do not; the bound below then measures
+ // document-rewrite cost only, not the cleaner's per-schema cost (Issue 6713).
+ fmt.Fprintf(&input, `{"name":"tool_%d","description":"%s","parametersJsonSchema":{"type":"object","$id":"drop","properties":{"q":{"type":"string"}}}}`, i, strings.Repeat("x", 4096))
+ // The schema has no required properties, so the Claude VALIDATED
+ // placeholder is inserted even on the Gemini path (antigravity=true).
+ fmt.Fprintf(&want, `{"name":"tool_%d","description":"%s","parameters":{"type":"object","properties":{"q":{"type":"string"},"_":{"type":"boolean"}},"required":["_"]}}`, i, strings.Repeat("x", 4096))
+ }
+ input.WriteString(`]}]}}`)
+ want.WriteString(`]}]}}`)
+
+ payload := input.String()
+ var before, after runtime.MemStats
+ runtime.GC()
+ runtime.ReadMemStats(&before)
+ got := sanitizeAntigravityRequestSchemasForModel(payload, true, false)
+ runtime.ReadMemStats(&after)
+
+ if got != want.String() {
+ t.Fatalf("sanitized tools diverged from the expected bytes\nfirst declaration want: %s\nfirst declaration got: %s",
+ gjson.Get(want.String(), "request.tools.0.functionDeclarations.0").Raw,
+ gjson.Get(got, "request.tools.0.functionDeclarations.0").Raw)
+ }
+
+ // Whole-document work is bounded by a constant number of passes: extracting
+ // request.tools, rebuilding it, and writing it back. Everything beyond a
+ // small multiple of the payload size means per-declaration document rewrites
+ // are back; the pre-fix implementation allocated O(declCount x payload).
+ const allowedCopies = 32
+ if allocated := after.TotalAlloc - before.TotalAlloc; allocated > uint64(allowedCopies*len(payload)) {
+ t.Fatalf("sanitizing %d declarations allocated %d bytes for a %d byte payload, want at most %d (cost must be linear in the tools size, not per declaration)",
+ declCount, allocated, len(payload), allowedCopies*len(payload))
+ }
+}
+
+// TestSanitizeAntigravityRequestSchemasPreservesToolArrayWhitespace pins the
+// byte-equivalence claim of the per-declaration rewrite: rebuilding the tools
+// and declarations arrays must keep the whitespace around array elements, so
+// a pretty-printed payload only changes inside the cleaned declarations. The
+// expected value is the input with only the affected schema bytes replaced,
+// exactly what the previous whole-document sjson edits produced.
+func TestSanitizeAntigravityRequestSchemasPreservesToolArrayWhitespace(t *testing.T) {
+ input := "{\n" +
+ " \"request\": {\n" +
+ " \"tools\": [\n" +
+ " {\"functionDeclarations\": [\n" +
+ " {\"name\": \"a\", \"parametersJsonSchema\": {\"type\": \"object\", \"$id\": \"drop\", \"properties\": {\"q\": {\"type\": \"string\"}}}} ,\n" +
+ " {\"name\": \"b\", \"parameters\": {\"type\": \"object\", \"required\": [\"q\"], \"properties\": {\"q\": {\"type\": \"string\"}}}} ,\n" +
+ " {\"name\": \"c\"}\n" +
+ " ]} ,\n" +
+ " {\"googleSearch\": {}}\n" +
+ " ]\n" +
+ " }\n" +
+ "}\n"
+
+ // Declaration a is renamed and cleaned in place (sjson compacts the
+ // replacement gaps inside the edited node, as the whole-document edits
+ // did). Declaration b is already clean, and declarations b and c and
+ // the googleSearch tool are untouched, so every other byte (including the
+ // whitespace around commas and brackets) must survive verbatim. The odd
+ // "}} ," separator is the declaration's own closing bytes, one space of
+ // inter-element whitespace, the comma, and the newline.
+ want := strings.Replace(input,
+ `{"name": "a", "parametersJsonSchema": {"type": "object", "$id": "drop", "properties": {"q": {"type": "string"}}}}`,
+ `{"name": "a","parameters":{"type": "object", "properties": {"q": {"type": "string"},"_":{"type":"boolean"}},"required":["_"]}}`, 1)
+ if want == input {
+ t.Fatal("test fixture is broken: declaration a was not replaced in the expected value")
+ }
+
+ if got := sanitizeAntigravityRequestSchemasForModel(input, true, false); got != want {
+ t.Fatalf("pretty-printed tools lost whitespace outside the cleaned declaration\nwant:\n%s\ngot:\n%s", want, got)
+ }
+}
diff --git a/internal/runtime/executor/antigravity_transport_decorator_test.go b/internal/runtime/executor/antigravity_transport_decorator_test.go
new file mode 100644
index 00000000000..a8206212959
--- /dev/null
+++ b/internal/runtime/executor/antigravity_transport_decorator_test.go
@@ -0,0 +1,153 @@
+package executor
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/httptransport"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
+ "github.com/tidwall/gjson"
+)
+
+type auditTestTransport struct {
+ http.RoundTripper
+}
+
+func TestAntigravityDecoratorRunsAfterCredentialHTTP11PoolSelection(t *testing.T) {
+ for _, proxy := range []string{"", "http://127.0.0.1:12345"} {
+ t.Run("proxy_"+proxy, func(t *testing.T) {
+ calls := 0
+ var selected http.RoundTripper
+ ctx := httptransport.WithDecorator(context.Background(), func(next http.RoundTripper) http.RoundTripper {
+ calls++
+ selected = next
+ tr, ok := next.(*http.Transport)
+ if !ok || tr.ForceAttemptHTTP2 || len(tr.TLSNextProto) != 0 || tr.TLSClientConfig == nil || len(tr.TLSClientConfig.NextProtos) != 0 {
+ t.Fatal("decorator preceded HTTP/1.1 selection")
+ }
+ return &auditTestTransport{RoundTripper: next}
+ })
+ auth := &coreauth.Auth{ID: "decorator-fixture", ProxyURL: proxy}
+ c := newAntigravityHTTPClient(ctx, &config.Config{}, auth, 0)
+ plain := newAntigravityHTTPClient(context.Background(), &config.Config{}, auth, 0)
+ if calls != 1 {
+ t.Fatalf("decorator calls = %d, want 1", calls)
+ }
+ wrapped, ok := c.Transport.(*auditTestTransport)
+ if !ok || wrapped.RoundTripper != selected || plain.Transport != selected {
+ t.Fatal("pool replaced or helper decorated twice")
+ }
+ })
+ }
+}
+
+func TestAntigravityDecoratorConcurrentIsolation(t *testing.T) {
+ auth := &coreauth.Auth{ID: "decorator-race-auth"}
+ cfg := &config.Config{}
+ baseClient := newAntigravityHTTPClient(context.Background(), cfg, auth, 0)
+
+ var wg sync.WaitGroup
+ for i := 0; i < 8; i++ {
+ wg.Add(2)
+ go func() {
+ defer wg.Done()
+ ctx := httptransport.WithDecorator(context.Background(), func(next http.RoundTripper) http.RoundTripper {
+ return &auditTestTransport{RoundTripper: next}
+ })
+ c := newAntigravityHTTPClient(ctx, cfg, auth, 0)
+ wrapped, ok := c.Transport.(*auditTestTransport)
+ if !ok || wrapped.RoundTripper != baseClient.Transport {
+ t.Errorf("expected wrapped transport around shared pool %T, got %T", baseClient.Transport, c.Transport)
+ }
+ }()
+ go func() {
+ defer wg.Done()
+ c := newAntigravityHTTPClient(context.Background(), cfg, auth, 0)
+ if c.Transport != baseClient.Transport {
+ t.Errorf("undecorated client transport = %p, want %p", c.Transport, baseClient.Transport)
+ }
+ }()
+ }
+ wg.Wait()
+}
+
+func TestExecutorAttemptDecoratorObservesTranslatedPayloadAndStatusWithoutRequestLog(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"id":"chatcmpl-1","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`))
+ }))
+ defer srv.Close()
+
+ cfg := &config.Config{
+ Payload: config.PayloadConfig{
+ Override: []config.PayloadRule{
+ {
+ Models: []config.PayloadModelRule{{Name: "gpt-4o-mini"}},
+ Params: map[string]any{"temperature": 0.25},
+ },
+ },
+ },
+ }
+ exec := NewOpenAICompatExecutor("openai-compat", cfg)
+ auth := &coreauth.Auth{
+ ID: "compat-decorator-test",
+ Provider: "openai-compat",
+ Attributes: map[string]string{
+ "base_url": srv.URL,
+ "api_key": "secret-test-key",
+ },
+ }
+
+ var observedModel string
+ var observedTemp float64
+ var observedStatus int
+ var attempts int
+ ctx := httptransport.WithDecorator(context.Background(), func(next http.RoundTripper) http.RoundTripper {
+ return roundTripperFunc(func(req *http.Request) (*http.Response, error) {
+ attempts++
+ if req.Body != nil {
+ bodyBytes, errRead := io.ReadAll(req.Body)
+ if errRead != nil {
+ return nil, errRead
+ }
+ observedModel = gjson.GetBytes(bodyBytes, "model").String()
+ observedTemp = gjson.GetBytes(bodyBytes, "temperature").Float()
+ req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
+ }
+ resp, errRT := next.RoundTrip(req)
+ if resp != nil {
+ observedStatus = resp.StatusCode
+ }
+ return resp, errRT
+ })
+ })
+
+ resp, errExec := exec.Execute(ctx, auth, cliproxyexecutor.Request{
+ Model: "gpt-4o-mini",
+ Payload: []byte(`{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hello"}]}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAI,
+ })
+ if errExec != nil {
+ t.Fatalf("Execute: %v", errExec)
+ }
+ if !strings.Contains(string(resp.Payload), `"content":"ok"`) {
+ t.Fatalf("unexpected response payload: %s", string(resp.Payload))
+ }
+ if cfg.RequestLog {
+ t.Fatal("RequestLog must remain disabled")
+ }
+ if attempts != 1 || observedModel != "gpt-4o-mini" || observedTemp != 0.25 || observedStatus != http.StatusOK {
+ t.Fatalf("observed attempts=%d model=%q temp=%v status=%d", attempts, observedModel, observedTemp, observedStatus)
+ }
+}
diff --git a/internal/runtime/executor/claude_cloaked_date_pin_test.go b/internal/runtime/executor/claude_cloaked_date_pin_test.go
index 4945e96ff63..32b3c9732c4 100644
--- a/internal/runtime/executor/claude_cloaked_date_pin_test.go
+++ b/internal/runtime/executor/claude_cloaked_date_pin_test.go
@@ -80,7 +80,7 @@ func TestClaudeCloakedDateReminderPinnedToSessionAcrossMidnight(t *testing.T) {
if nextDay == cCtx1.PinnedDate {
t.Fatalf("test setup: anchor %q must differ from the simulated next day", cCtx1.PinnedDate)
}
- resolved := helps.PinClaudeSessionDate(cCtx1.Key, nextDay)
+ resolved := helps.PinClaudeSessionDate(cCtx1.Key, "sess-midnight", nextDay, 0)
if resolved != cCtx1.PinnedDate {
t.Fatalf("second request resolved date = %q, want session-pinned %q (local midnight flip busts the cached prefix)", resolved, cCtx1.PinnedDate)
}
diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go
index 4b0d60a00b2..3643a719cf4 100644
--- a/internal/runtime/executor/claude_executor.go
+++ b/internal/runtime/executor/claude_executor.go
@@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"strings"
+ "time"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
@@ -143,12 +144,66 @@ func logClaudeSignatureSanitizeReport(ctx context.Context, baseModel string, rep
// omit max_tokens. Prefer registered model metadata before using a fallback.
const defaultModelMaxTokens = 1024
+// stripTranslatedDefaultMaxTokens removes the translator-synthesized default
+// max_tokens from originalTranslated when the caller's original request did not
+// explicitly supply an output token limit in the source protocol, allowing
+// payload.default rules targeting max_tokens to take effect.
+func stripTranslatedDefaultMaxTokens(from sdktranslator.Format, originalPayload, originalTranslated []byte) []byte {
+ if len(originalTranslated) == 0 || from == sdktranslator.FormatClaude {
+ return originalTranslated
+ }
+ hasExplicitMaxTokens := func(paths ...string) bool {
+ for _, path := range paths {
+ if v := gjson.GetBytes(originalPayload, path); v.Exists() && v.Type != gjson.Null {
+ return true
+ }
+ }
+ return false
+ }
+ switch from {
+ case sdktranslator.FormatOpenAI:
+ if hasExplicitMaxTokens("max_tokens", "max_completion_tokens") {
+ return originalTranslated
+ }
+ case sdktranslator.FormatOpenAIResponse:
+ if hasExplicitMaxTokens("max_output_tokens") {
+ return originalTranslated
+ }
+ case sdktranslator.FormatGemini:
+ if hasExplicitMaxTokens("generationConfig.maxOutputTokens") {
+ return originalTranslated
+ }
+ default:
+ return originalTranslated
+ }
+ stripped, errDelete := sjson.DeleteBytes(originalTranslated, "max_tokens")
+ if errDelete != nil {
+ return originalTranslated
+ }
+ return stripped
+}
+
func NewClaudeExecutor(cfg *config.Config) *ClaudeExecutor {
return &ClaudeExecutor{cfg: cfg, oauthToolAliases: &claudeOAuthToolAliasStore{}}
}
func (e *ClaudeExecutor) Identifier() string { return "claude" }
+func (e *ClaudeExecutor) claudeWebSearchResultCacheScope(ctx context.Context, from sdktranslator.Format, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, originalPayload []byte) (string, time.Duration, int) {
+ if e == nil || e.cfg == nil || !e.cfg.Client.Codex.WebSearchResultCache.Enabled {
+ return "", 0, 0
+ }
+ if !sourceFormatEqual(from, sdktranslator.FormatOpenAIResponse) {
+ return "", 0, 0
+ }
+ sessionKey := xaiReasoningReplayIsolateSessionKey(ctx, codexReasoningReplaySessionKey(ctx, from, req, opts, originalPayload))
+ if sessionKey == "" {
+ return "", 0, 0
+ }
+ cacheCfg := e.cfg.Client.Codex.WebSearchResultCache
+ return sessionKey, cacheCfg.EffectiveTTL(), cacheCfg.EffectiveLimit()
+}
+
var _ cliproxyauth.NativeToolSearchSupport = (*ClaudeExecutor)(nil)
// SupportsNativeToolSearch reports whether a credential can receive Codex
diff --git a/internal/runtime/executor/claude_executor_beta_policy_test.go b/internal/runtime/executor/claude_executor_beta_policy_test.go
index db3e000906e..98608849255 100644
--- a/internal/runtime/executor/claude_executor_beta_policy_test.go
+++ b/internal/runtime/executor/claude_executor_beta_policy_test.go
@@ -523,3 +523,20 @@ func TestApplyClaudeHeaders_StructuredHelperBetaOrderPreservedWithAdvisor(t *tes
t.Fatalf("helper Anthropic-Beta =\n got: %q\n want: %q", got, helperBeta)
}
}
+
+// A body that carries thinking.block_binding is rejected upstream unless the
+// thinking-binding-controls beta travels with it, so even caller-owned
+// (default API-key) and confirmed-passthrough modes must emit that beta.
+func TestApplyClaudeHeaders_BlockBindingBodyAlwaysGetsBindingBeta(t *testing.T) {
+ body := []byte(`{"model":"claude-haiku-5-5","thinking":{"type":"adaptive","block_binding":{"prefix_mismatch_behavior":"drop_block"}},"messages":[{"role":"user","content":"hi"}]}`)
+ for _, confirmed := range []bool{false, true} {
+ auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-binding-beta"}}
+ req := newClaudeHeaderTestRequest(t, nil)
+ if err := applyClaudeHeaders(req, auth, "key-binding-beta", false, nil, body, nil, nil, confirmed); err != nil {
+ t.Fatalf("confirmed=%v: applyClaudeHeaders() error = %v", confirmed, err)
+ }
+ if got := req.Header.Get("Anthropic-Beta"); !strings.Contains(got, claudeThinkingBindingBeta) {
+ t.Fatalf("confirmed=%v: Anthropic-Beta = %q, want %s for a block_binding body", confirmed, got, claudeThinkingBindingBeta)
+ }
+ }
+}
diff --git a/internal/runtime/executor/claude_executor_cloaking.go b/internal/runtime/executor/claude_executor_cloaking.go
index a69ba7908ae..42c090762e2 100644
--- a/internal/runtime/executor/claude_executor_cloaking.go
+++ b/internal/runtime/executor/claude_executor_cloaking.go
@@ -217,7 +217,7 @@ func resolveClaudeContinuityTags(
// The currentDate reminder is pinned to the session on its first request so
// a local-midnight flip between requests cannot rewrite it and invalidate
// the prompt-cache prefix in front of the whole conversation.
- pinnedDate := helps.PinClaudeSessionDate(continuityKey, claudeCodeLocalDate(claudeCodeCurrentTime(cfg, auth)))
+ pinnedDate := resolveClaudeSessionPinnedDate(cfg, auth, payload, continuityKey, sessionID)
if existingPromptID != "" {
promptID = existingPromptID
@@ -1301,8 +1301,121 @@ func isClaudeCodeContextReminder(text string) bool {
return strings.HasPrefix(text, "") && strings.Contains(text, " ")
}
+const claudeCodeCurrentDateReminderPrefix = "\nAs you answer the user's questions, you can use the following context:\n# currentDate\nToday's date is "
+
func isClaudeCodeCurrentDateReminder(text string) bool {
- return strings.HasPrefix(text, "\nAs you answer the user's questions, you can use the following context:\n# currentDate\nToday's date is ")
+ return strings.HasPrefix(text, claudeCodeCurrentDateReminderPrefix)
+}
+
+// claudeCodeCurrentDateReminderDate extracts the YYYY-MM-DD date from a
+// currentDate reminder, or "" when the reminder date is not a valid date.
+func claudeCodeCurrentDateReminderDate(text string) string {
+ rest, found := strings.CutPrefix(text, claudeCodeCurrentDateReminderPrefix)
+ if !found || len(rest) < len("2006-01-02.") || rest[len("2006-01-02")] != '.' {
+ return ""
+ }
+ date := rest[:len("2006-01-02")]
+ if _, errParse := time.Parse("2006-01-02", date); errParse != nil {
+ return ""
+ }
+ return date
+}
+
+func claudeSessionDatePinRetention(cfg *config.Config) time.Duration {
+ if cfg == nil {
+ return 0
+ }
+ return cfg.Claude.SessionDatePinRetention()
+}
+
+// claudeCallerCurrentDateReminderDate returns the date of the first valid
+// caller-supplied currentDate reminder in the first user message.
+func claudeCallerCurrentDateReminderDate(payload []byte) string {
+ firstUserIdx := firstClaudeUserMessageIndex(payload)
+ if firstUserIdx < 0 {
+ return ""
+ }
+ content := gjson.GetBytes(payload, fmt.Sprintf("messages.%d.content", firstUserIdx))
+ if content.Type == gjson.String {
+ return claudeCodeCurrentDateReminderDate(content.String())
+ }
+ if !content.IsArray() {
+ return ""
+ }
+ for _, block := range content.Array() {
+ if block.Get("type").String() != "text" {
+ continue
+ }
+ if date := claudeCodeCurrentDateReminderDate(block.Get("text").String()); date != "" {
+ return date
+ }
+ }
+ return ""
+}
+
+// stripClaudeCodeCurrentDateReminder removes a leading caller currentDate
+// reminder from string content so the injection path replaces it with the
+// canonical reminder instead of duplicating it.
+func stripClaudeCodeCurrentDateReminder(text string) string {
+ if claudeCodeCurrentDateReminderDate(text) == "" {
+ return text
+ }
+ end := strings.Index(text, " ")
+ if end < 0 {
+ return text
+ }
+ return strings.TrimLeft(text[end+len(" "):], "\n")
+}
+
+// stripFirstUserClaudeCodeCurrentDateReminder strips a leading caller
+// currentDate reminder from a string-form first user message before the
+// cloaking pipeline converts the content into blocks, preserving user text.
+func stripFirstUserClaudeCodeCurrentDateReminder(payload []byte) []byte {
+ firstUserIdx := firstClaudeUserMessageIndex(payload)
+ if firstUserIdx < 0 {
+ return payload
+ }
+ contentPath := fmt.Sprintf("messages.%d.content", firstUserIdx)
+ content := gjson.GetBytes(payload, contentPath)
+ if content.Type != gjson.String {
+ return payload
+ }
+ stripped := stripClaudeCodeCurrentDateReminder(content.String())
+ if stripped == content.String() {
+ return payload
+ }
+ updated, errSet := sjson.SetBytes(payload, contentPath, stripped)
+ if errSet != nil {
+ return payload
+ }
+ return updated
+}
+
+// resolveClaudeSessionPinnedDate resolves the session date pin. With a
+// configured retention, an existing pin always wins; a caller-supplied
+// currentDate reminder only seeds the first anchor, so a later caller date
+// cannot rewrite the established conversation prefix.
+func resolveClaudeSessionPinnedDate(
+ cfg *config.Config,
+ auth *cliproxyauth.Auth,
+ payload []byte,
+ continuityKey string,
+ sessionID string,
+) string {
+ retention := claudeSessionDatePinRetention(cfg)
+ candidate := claudeCodeLocalDate(claudeCodeCurrentTime(cfg, auth))
+ if retention <= 0 {
+ return helps.PinClaudeSessionDate(continuityKey, sessionID, candidate, retention)
+ }
+ if existing := helps.LookupClaudeSessionDatePin(sessionID, retention); existing != "" {
+ // Re-pin the anchored date so the sliding retention refreshes
+ // without ever changing the established prefix.
+ return helps.PinClaudeSessionDate(continuityKey, sessionID, existing, retention)
+ }
+ if callerDate := claudeCallerCurrentDateReminderDate(payload); callerDate != "" {
+ candidate = callerDate
+ }
+ return helps.PinClaudeSessionDate(continuityKey, sessionID, candidate, retention)
}
// injectClaudeCodeCurrentDate stamps the currentDate reminder into the first
@@ -1471,6 +1584,7 @@ type claudeWirePolicy struct {
ProfileClaudeCodeCLI bool // request fingerprint looks like Claude Code CLI
ConfirmedClaudeCode bool
Cloak bool
+ Uncloak bool
}
type claudeCloakSettings struct {
@@ -1479,6 +1593,79 @@ type claudeCloakSettings struct {
cacheUserID bool
}
+func isClaudeCodeIdentitySystemText(text string) bool {
+ trimmed := strings.TrimSpace(text)
+ return trimmed == claudeCodeCLIIdentity ||
+ trimmed == "You are a Claude agent, built on Anthropic's Claude Agent SDK."
+}
+
+func stripClaudeCodeSystemBlocks(payload []byte) []byte {
+ payload = util.StripClaudeCodeAttributionSystem(payload)
+ system := gjson.GetBytes(payload, "system")
+ if !system.Exists() {
+ return payload
+ }
+ if system.Type == gjson.String {
+ if !isClaudeCodeIdentitySystemText(system.String()) {
+ return payload
+ }
+ if updated, errDelete := sjson.DeleteBytes(payload, "system"); errDelete == nil {
+ return updated
+ }
+ return payload
+ }
+ if !system.IsArray() {
+ return payload
+ }
+ kept := make([]string, 0, len(system.Array()))
+ removed := false
+ system.ForEach(func(_, block gjson.Result) bool {
+ if block.Get("type").String() == "text" && isClaudeCodeIdentitySystemText(block.Get("text").String()) {
+ removed = true
+ return true
+ }
+ if block.Raw != "" {
+ kept = append(kept, block.Raw)
+ }
+ return true
+ })
+ if !removed {
+ return payload
+ }
+ if len(kept) == 0 {
+ if updated, errDelete := sjson.DeleteBytes(payload, "system"); errDelete == nil {
+ return updated
+ }
+ return payload
+ }
+ if updated, errSet := sjson.SetRawBytes(payload, "system", []byte("["+strings.Join(kept, ",")+"]")); errSet == nil {
+ return updated
+ }
+ return payload
+}
+
+func uncloakClaudeCodePayload(payload []byte, confirmedClaudeCode bool) []byte {
+ payload = stripClaudeCodeSystemBlocks(payload)
+ if metadata := gjson.GetBytes(payload, "metadata"); metadata.Exists() && metadata.IsObject() {
+ if userID := metadata.Get("user_id"); userID.Exists() && (confirmedClaudeCode || helps.IsValidUserID(userID.String())) {
+ if updated, errDelete := sjson.DeleteBytes(payload, "metadata.user_id"); errDelete == nil {
+ payload = updated
+ if remaining := gjson.GetBytes(payload, "metadata"); remaining.Exists() && remaining.IsObject() && len(remaining.Map()) == 0 {
+ if updatedMeta, errDeleteMeta := sjson.DeleteBytes(payload, "metadata"); errDeleteMeta == nil {
+ payload = updatedMeta
+ }
+ }
+ }
+ }
+ }
+ if diag := gjson.GetBytes(payload, "diagnostics"); diag.Exists() && (confirmedClaudeCode || diag.Get("previous_message_id").Exists()) {
+ if updated, errDelete := sjson.DeleteBytes(payload, "diagnostics"); errDelete == nil {
+ payload = updated
+ }
+ }
+ return payload
+}
+
func resolveClaudeWirePolicy(cfg *config.Config, auth *cliproxyauth.Auth, apiKey string, confirmedClaudeCode bool) (claudeWirePolicy, claudeCloakSettings) {
if auth != nil && auth.AuthKind() == cliproxyauth.AuthKindAPIKey {
cfg = cfg.ForAPIKey()
@@ -1495,12 +1682,14 @@ func resolveClaudeWirePolicy(cfg *config.Config, auth *cliproxyauth.Auth, apiKey
sensitiveWords: attrWords,
cacheUserID: attrCache,
}
+ explicitMode := strings.ToLower(strings.TrimSpace(attrMode))
if attrMode != "" {
cloakMode = attrMode
}
if cloakCfg != nil {
if mode := strings.TrimSpace(cloakCfg.Mode); mode != "" {
cloakMode = mode
+ explicitMode = strings.ToLower(mode)
}
if cloakCfg.StrictMode {
settings.strictMode = true
@@ -1515,23 +1704,25 @@ func resolveClaudeWirePolicy(cfg *config.Config, auth *cliproxyauth.Auth, apiKey
fp := resolveClaudeFingerprintPolicy(cfg, auth, apiKey)
cloakConfigured := cloakCfg != nil || attrMode != "" || attrStrict || len(attrWords) > 0 || attrCache
+ trimmedMode := strings.ToLower(strings.TrimSpace(cloakMode))
+ uncloak := !fp.ProfileClaudeCodeCLI && (explicitMode == "never" || explicitMode == "uncloak")
policy := claudeWirePolicy{
OAuth: fp.AuthIsOAuthToken,
ProfileClaudeCodeCLI: fp.ProfileClaudeCodeCLI,
ConfirmedClaudeCode: confirmedClaudeCode,
- Cloak: (fp.ProfileClaudeCodeCLI || cloakConfigured) && !confirmedClaudeCode,
+ Cloak: (fp.ProfileClaudeCodeCLI || cloakConfigured) && !confirmedClaudeCode && !uncloak,
+ Uncloak: uncloak,
}
if confirmedClaudeCode {
- // Native Claude Code is always a passthrough client. An operator-level
- // "always" mode may cloak unknown callers, but must not overwrite a
- // strongly confirmed CLI, sdk-cli, or claude-vscode fingerprint.
+ // Native Claude Code is always a passthrough client unless the
+ // credential explicitly opts out of Claude Code identity via never/uncloak.
policy.Cloak = false
return policy, settings
}
- switch strings.ToLower(strings.TrimSpace(cloakMode)) {
+ switch trimmedMode {
case "always":
policy.Cloak = true
- case "never":
+ case "never", "uncloak":
policy.Cloak = false
default:
// Auto applies the CLI cloak only to real Claude OAuth credentials,
@@ -1568,6 +1759,9 @@ func applyCloakingInternal(
) ([]byte, bool, error) {
policy, settings := resolveClaudeWirePolicy(cfg, auth, apiKey, confirmedClaudeCode)
if !policy.Cloak {
+ if policy.Uncloak {
+ payload = uncloakClaudeCodePayload(payload, confirmedClaudeCode)
+ }
return payload, false, nil
}
isExplicit := (len(explicitCacheMode) > 0 && explicitCacheMode[0]) || isExplicitPromptCacheModeContext(ctx)
@@ -1608,6 +1802,11 @@ func applyCloakingInternal(
if pinnedDate == "" {
pinnedDate = claudeCodeLocalDate(claudeCodeCurrentTime(cfg, auth))
}
+ if !isProbeOrHelper && claudeSessionDatePinRetention(cfg) > 0 {
+ // Strip a string-form caller reminder before the pipeline turns the
+ // content into blocks, so user text is never dropped with it.
+ payload = stripFirstUserClaudeCodeCurrentDateReminder(payload)
+ }
turnOrigin := ""
if !isProbeOrHelper && !isSubagent {
diff --git a/internal/runtime/executor/claude_executor_compaction.go b/internal/runtime/executor/claude_executor_compaction.go
index f5a90c3747b..7afb97138f8 100644
--- a/internal/runtime/executor/claude_executor_compaction.go
+++ b/internal/runtime/executor/claude_executor_compaction.go
@@ -12,7 +12,6 @@ import (
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@@ -22,66 +21,19 @@ type claudeCompactionSummaryContextKey struct{}
// expandClaudeResponsesCompaction rewrites sealed compaction items into context
// before translation. Unrecognized capsules fail the request instead of being dropped.
func expandClaudeResponsesCompaction(req *cliproxyexecutor.Request, opts *cliproxyexecutor.Options) error {
- if req != nil {
- req.Payload = dropForeignClaudeCompactionItems(req.Payload)
- if helps.HasResponsesCompactionItem(req.Payload) {
- expanded, errExpand := helps.ExpandAntigravityCompactionCapsules(req.Payload)
- if errExpand != nil {
- return errExpand
- }
- req.Payload = expanded
- }
- }
- if opts != nil {
- opts.OriginalRequest = dropForeignClaudeCompactionItems(opts.OriginalRequest)
- if helps.HasResponsesCompactionItem(opts.OriginalRequest) {
- expanded, errExpand := helps.ExpandAntigravityCompactionCapsules(opts.OriginalRequest)
- if errExpand != nil {
- return errExpand
- }
- opts.OriginalRequest = expanded
- }
- }
- return nil
+ return helps.ExpandResponsesCompactionCapsules(req, opts, "claude")
}
func dropForeignClaudeCompactionItems(payload []byte) []byte {
- input := gjson.GetBytes(payload, "input")
- if !input.IsArray() {
- return payload
- }
- dropped := 0
- items := make([]string, 0, len(input.Array()))
- for _, item := range input.Array() {
- if item.Get("type").String() == "compaction" && !helps.RecognizedAntigravityCompactionCapsule(item.Get("encrypted_content").String()) {
- dropped++
- continue
- }
- items = append(items, item.Raw)
- }
- if dropped == 0 {
- return payload
- }
- log.Warnf("claude compaction: dropped %d non-CPA compaction item(s)", dropped)
- out, _ := sjson.SetRawBytes(payload, "input", []byte("["+strings.Join(items, ",")+"]"))
- return out
+ return helps.DropForeignResponsesCompactionItems(payload, "claude")
}
func claudeResponsesCompactionRequested(req cliproxyexecutor.Request, opts cliproxyexecutor.Options) bool {
- return opts.Alt == "responses/compact" ||
- helps.HasResponsesCompactionTrigger(req.Payload) ||
- helps.HasResponsesCompactionTrigger(opts.OriginalRequest)
+ return opts.Alt == "responses/compact" || helps.HasResponsesCompactionTriggerRequest(req, opts)
}
func claudeCompactionSourcePayload(req cliproxyexecutor.Request, opts cliproxyexecutor.Options) []byte {
- payload := req.Payload
- if !helps.HasResponsesCompactionTrigger(payload) && helps.HasResponsesCompactionTrigger(opts.OriginalRequest) {
- payload = opts.OriginalRequest
- }
- if len(payload) == 0 && len(opts.OriginalRequest) > 0 {
- payload = opts.OriginalRequest
- }
- return payload
+ return helps.ResponsesCompactionSourcePayload(req, opts)
}
func (e *ClaudeExecutor) executeClaudeCompaction(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
diff --git a/internal/runtime/executor/claude_executor_diagnostics.go b/internal/runtime/executor/claude_executor_diagnostics.go
index e448be19bca..87e8943f455 100644
--- a/internal/runtime/executor/claude_executor_diagnostics.go
+++ b/internal/runtime/executor/claude_executor_diagnostics.go
@@ -92,15 +92,7 @@ func claudeMessageIDFromResponse(data []byte) string {
return strings.TrimSpace(gjson.GetBytes(data, "id").String())
}
-func observeClaudeStreamLine(line []byte, messageID *string, completed *bool) {
- line = bytes.TrimSpace(line)
- if !bytes.HasPrefix(line, []byte("data:")) {
- return
- }
- payload := bytes.TrimSpace(line[len("data:"):])
- if !gjson.ValidBytes(payload) {
- return
- }
+func observeClaudeStreamJSONPayload(payload []byte, hasDataPrefix bool, messageID *string, completed *bool) {
root := gjson.ParseBytes(payload)
switch root.Get("type").String() {
case "message_start":
@@ -109,14 +101,59 @@ func observeClaudeStreamLine(line []byte, messageID *string, completed *bool) {
}
case "message_stop":
*completed = true
+ case "message":
+ if !hasDataPrefix && strings.TrimSpace(root.Get("stop_reason").String()) != "" {
+ if id := strings.TrimSpace(root.Get("id").String()); id != "" {
+ *messageID = id
+ }
+ *completed = true
+ }
+ }
+}
+
+func observeClaudeSSEEvent(rawEvent []byte, messageID *string, completed *bool, streamErr *error) {
+ var dataLines [][]byte
+ for _, line := range bytes.Split(rawEvent, []byte("\n")) {
+ trimmed := bytes.TrimSpace(line)
+ if !bytes.HasPrefix(trimmed, []byte("data:")) {
+ continue
+ }
+ payload := bytes.TrimSpace(trimmed[len("data:"):])
+ if len(payload) > 0 {
+ dataLines = append(dataLines, payload)
+ }
+ }
+ if len(dataLines) == 0 {
+ trimmed := bytes.TrimSpace(rawEvent)
+ if bytes.HasPrefix(trimmed, []byte("{")) && gjson.ValidBytes(trimmed) {
+ observeClaudeStreamJSONPayload(trimmed, false, messageID, completed)
+ }
+ return
+ }
+ joined := bytes.TrimSpace(bytes.Join(dataLines, []byte("\n")))
+ if !gjson.ValidBytes(joined) {
+ return
+ }
+ observeClaudeStreamJSONPayload(joined, true, messageID, completed)
+ if streamErr != nil && *streamErr == nil && gjson.ParseBytes(joined).Get("type").String() == "error" {
+ *streamErr = statusErr{code: 502, msg: string(joined)}
}
}
func claudeMessageIDFromSSE(data []byte) string {
var messageID string
completed := false
+ var eventBuf bytes.Buffer
for _, line := range bytes.Split(data, []byte("\n")) {
- observeClaudeStreamLine(line, &messageID, &completed)
+ eventBuf.Write(line)
+ eventBuf.WriteByte('\n')
+ if len(bytes.TrimSpace(line)) == 0 {
+ observeClaudeSSEEvent(eventBuf.Bytes(), &messageID, &completed, nil)
+ eventBuf.Reset()
+ }
+ }
+ if eventBuf.Len() > 0 {
+ observeClaudeSSEEvent(eventBuf.Bytes(), &messageID, &completed, nil)
}
if !completed {
return ""
diff --git a/internal/runtime/executor/claude_executor_execute.go b/internal/runtime/executor/claude_executor_execute.go
index a9bc2b2d586..3d0806d43a7 100644
--- a/internal/runtime/executor/claude_executor_execute.go
+++ b/internal/runtime/executor/claude_executor_execute.go
@@ -90,6 +90,8 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
if err != nil {
return resp, err
}
+ webSearchSessionKey, webSearchTTL, webSearchLimit := e.claudeWebSearchResultCacheScope(ctx, from, req, opts, originalPayload)
+ body = helps.RestoreClaudeWebSearchResultsContext(ctx, body, webSearchSessionKey, webSearchTTL)
if rebuildMidSystemMessageEnabled(e.cfg, auth) {
body = rebuildMidSystemMessagesToTopLevel(body)
}
@@ -240,10 +242,11 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
if claudeCompactionSummaryFromContext(ctx) {
bodyForUpstream = finalizeClaudeCompactionSummaryBody(bodyForUpstream)
}
+ bodyForUpstream = applyClaudePreservedThinkingBinding(bodyForUpstream)
// User rules match the fully prepared business body and are applied only once.
var touchedPayloadPaths map[string]bool
bodyForUpstream, touchedPayloadPaths = helps.ApplyPayloadConfigWithTrackedPaths(
- e.cfg, baseModel, to.String(), from.String(), "", bodyForUpstream, originalTranslated,
+ e.cfg, baseModel, to.String(), from.String(), "", bodyForUpstream, stripTranslatedDefaultMaxTokens(from, originalPayload, originalTranslated),
helps.PayloadRequestedModel(opts, req.Model), helps.PayloadRequestPath(opts), opts.Headers,
"diagnostics",
)
@@ -393,6 +396,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
}
}
data = e.restoreResponseModel(data, req.Model)
+ helps.RecordClaudeWebSearchResultsContext(ctx, webSearchSessionKey, data, webSearchTTL, webSearchLimit)
cacheClaudeThinkingReplayResponse(ctx, replayScope, data)
var param any
out := sdktranslator.TranslateNonStream(
diff --git a/internal/runtime/executor/claude_executor_fast_error.go b/internal/runtime/executor/claude_executor_fast_error.go
index 27d66fc9ca7..53a68556954 100644
--- a/internal/runtime/executor/claude_executor_fast_error.go
+++ b/internal/runtime/executor/claude_executor_fast_error.go
@@ -146,7 +146,7 @@ func newClaudeFastDirectResponseError(resp *http.Response, body []byte) error {
var retryAfter *time.Duration
credentialScoped := false
if resp.StatusCode == http.StatusTooManyRequests {
- retryAfter = helps.ParseClaudeRateLimitReset(resp.Header, time.Now())
+ retryAfter = helps.ParseClaudeRateLimitResetForError(resp.Header, body, time.Now())
if helps.ClaudeHeadersIndicateUnifiedRateLimitRejection(resp.Header) {
credentialScoped = true
}
diff --git a/internal/runtime/executor/claude_executor_request.go b/internal/runtime/executor/claude_executor_request.go
index 054f13770e8..dd9f4b58d27 100644
--- a/internal/runtime/executor/claude_executor_request.go
+++ b/internal/runtime/executor/claude_executor_request.go
@@ -315,6 +315,105 @@ func isClaudeSonnet5Model(model string) bool {
return model == "claude-sonnet-5" || strings.HasPrefix(model, "claude-sonnet-5-") || strings.HasPrefix(model, "claude-sonnet-5[")
}
+func isClaudeHaiku55Model(model string) bool {
+ model = claudeCanonicalModel(model)
+ return model == "claude-haiku-5-5" || strings.HasPrefix(model, "claude-haiku-5-5-") || strings.HasPrefix(model, "claude-haiku-5-5[") || model == "claude-haiku-5.5" || strings.HasPrefix(model, "claude-haiku-5.5-") || strings.HasPrefix(model, "claude-haiku-5.5[")
+}
+
+// isClaudeFable51OnlyModel reports Fable 5.1 specifically, excluding Mythos
+// 5.1, which shares the fable family checks elsewhere but does not run the
+// preserved-thinking prefix check.
+func isClaudeFable51OnlyModel(model string) bool {
+ m := claudeCanonicalModel(model)
+ for _, target := range []string{"fable-5-1", "fable-5.1"} {
+ idx := strings.Index(m, target)
+ if idx != -1 {
+ nextIdx := idx + len(target)
+ if nextIdx >= len(m) || m[nextIdx] < '0' || m[nextIdx] > '9' {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// isClaudePreservedThinkingModel reports the models whose signed thinking
+// blocks Anthropic binds to the creation-time conversation prefix (system,
+// tools, and every earlier message). Replaying those blocks under a reshaped
+// prefix fails binding validation unless the request opts into dropping them.
+// See https://platform.claude.com/docs/en/build-with-claude/preserved-thinking
+func isClaudePreservedThinkingModel(model string) bool {
+ return isClaudeOpus55Model(model) || isClaudeSonnet55Model(model) || isClaudeHaiku55Model(model) || isClaudeFable51OnlyModel(model)
+}
+
+// claudeThinkingTypeAcceptsBlockBinding reports whether the body's thinking
+// mode may carry block_binding on this model. Haiku 5.5 and Sonnet 5.5 accept
+// it only with adaptive thinking (disabled/between_tools return 400); the
+// other preserved-thinking models also accept manual enabled thinking.
+func claudeThinkingTypeAcceptsBlockBinding(model, thinkingType string) bool {
+ switch strings.ToLower(strings.TrimSpace(thinkingType)) {
+ case "adaptive":
+ return true
+ case "enabled":
+ return !isClaudeHaiku55Model(model) && !isClaudeSonnet55Model(model)
+ default:
+ return false
+ }
+}
+
+// claudeBodyHasSignedThinkingBlock reports whether any assistant message
+// carries a replayed thinking block with a signature or a redacted_thinking
+// block, i.e. history Anthropic subjects to prefix binding validation.
+func claudeBodyHasSignedThinkingBlock(body []byte) bool {
+ found := false
+ gjson.GetBytes(body, "messages").ForEach(func(_, msg gjson.Result) bool {
+ msg.Get("content").ForEach(func(_, block gjson.Result) bool {
+ switch strings.TrimSpace(block.Get("type").String()) {
+ case "thinking":
+ found = strings.TrimSpace(block.Get("signature").String()) != ""
+ case "redacted_thinking":
+ found = strings.TrimSpace(block.Get("data").String()) != ""
+ }
+ return !found
+ })
+ return !found
+ })
+ return found
+}
+
+// applyClaudePreservedThinkingBinding makes relayed or reshaped histories
+// survivable on preserved-thinking models: when the request replays signed
+// thinking blocks, default thinking.block_binding.prefix_mismatch_behavior to
+// drop_block so a prefix mismatch (client reconnect resending history, a
+// changed tools list, reconstructed server-tool blocks) drops the bound
+// blocks instead of failing the whole request with a 400. On preserved-thinking
+// models, an omitted thinking field runs adaptive thinking and is equivalent to
+// {"type":"adaptive"}, so synthesize that adaptive thinking object unless
+// tool_choice forces tool use. A thinking object without a type and any
+// caller-supplied prefix_mismatch_behavior stay untouched. This must run before
+// user payload rules, which stay the final semantic barrier and may override or
+// filter the injected value.
+func applyClaudePreservedThinkingBinding(body []byte) []byte {
+ model := gjson.GetBytes(body, "model").String()
+ if len(body) == 0 || !isClaudePreservedThinkingModel(model) || claudeToolChoiceForcesToolUse(body) {
+ return body
+ }
+ thinking := gjson.GetBytes(body, "thinking")
+ if thinking.Exists() {
+ if !claudeThinkingTypeAcceptsBlockBinding(model, thinking.Get("type").String()) || thinking.Get("block_binding.prefix_mismatch_behavior").Exists() {
+ return body
+ }
+ }
+ if !claudeBodyHasSignedThinkingBlock(body) {
+ return body
+ }
+ if !thinking.Exists() {
+ body, _ = sjson.SetBytes(body, "thinking.type", "adaptive")
+ }
+ body, _ = sjson.SetBytes(body, "thinking.block_binding.prefix_mismatch_behavior", "drop_block")
+ return body
+}
+
// claudeModelUsesProgressDisplay reports the 2.1.280 interactive CLI models that
// send thinking.display=updates unless the caller already chose a display mode.
func claudeModelUsesProgressDisplay(model string) bool {
@@ -600,6 +699,23 @@ func withoutClaudeBeta(betas, removeBeta string) string {
return strings.Join(res, ",")
}
+// withClaudeBetaAppended appends beta at the end unless it is already present.
+func withClaudeBetaAppended(betas, beta string) string {
+ beta = strings.TrimSpace(beta)
+ if beta == "" {
+ return betas
+ }
+ for _, existing := range strings.Split(betas, ",") {
+ if strings.TrimSpace(existing) == beta {
+ return betas
+ }
+ }
+ if strings.TrimSpace(betas) == "" {
+ return beta
+ }
+ return betas + "," + beta
+}
+
func withClaudeExtendedCacheTTLBeta(betas string) string {
parts := make([]string, 0, 16)
seen := make(map[string]bool)
@@ -698,7 +814,7 @@ func classifyClaudeUpstreamError(statusCode int, headers http.Header, body []byt
func classifyClaudeUpstreamErrorWithCooling(statusCode int, headers http.Header, body []byte, modelLevelCooling bool) error {
var retryAfter *time.Duration
if statusCode == http.StatusTooManyRequests || (statusCode >= 400 && statusCode < 600) {
- retryAfter = helps.ParseClaudeRateLimitReset(headers, time.Now())
+ retryAfter = helps.ParseClaudeRateLimitResetForError(headers, body, time.Now())
}
err := statusErr{code: statusCode, msg: string(body), retryAfter: retryAfter}
if statusCode == http.StatusTooManyRequests {
@@ -785,13 +901,17 @@ func extractAndRemoveBetas(body []byte) ([]string, []byte) {
return betas, body
}
+func claudeToolChoiceForcesToolUse(body []byte) bool {
+ toolChoiceType := gjson.GetBytes(body, "tool_choice.type").String()
+ return toolChoiceType == "any" || toolChoiceType == "tool"
+}
+
// disableThinkingIfToolChoiceForced checks if tool_choice forces tool use and disables thinking.
// Anthropic API does not allow thinking when tool_choice is set to "any" or a specific tool.
// See: https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations
func disableThinkingIfToolChoiceForced(body []byte) []byte {
- toolChoiceType := gjson.GetBytes(body, "tool_choice.type").String()
// "auto" is allowed with thinking, but "any" or "tool" (specific tool) are not
- if toolChoiceType == "any" || toolChoiceType == "tool" {
+ if claudeToolChoiceForcesToolUse(body) {
// Remove thinking configuration entirely to avoid API error
body, _ = sjson.DeleteBytes(body, "thinking")
// Adaptive thinking may also set output_config.effort; remove it to avoid
@@ -1024,7 +1144,7 @@ func claudeCredentialUsesOAuth(auth *cliproxyauth.Auth, apiKey string) bool {
return !hasAPIKeyAttr
}
-func copyClaudeCallerFingerprintHeaders(dst, src http.Header, confirmedClaudeCode bool) {
+func copyClaudeCallerFingerprintHeaders(dst, src http.Header, confirmedClaudeCode, uncloak bool) {
if dst == nil || src == nil {
return
}
@@ -1043,6 +1163,21 @@ func copyClaudeCallerFingerprintHeaders(dst, src http.Header, confirmedClaudeCod
if !confirmedClaudeCode && (strings.HasPrefix(lowerName, "x-claude-code-") || strings.HasPrefix(lowerName, "x-claude-remote-")) {
continue
}
+ if uncloak {
+ if lowerName == "x-app" ||
+ lowerName == "x-client-request-id" ||
+ lowerName == "x-client-app" ||
+ lowerName == "x-anthropic-additional-protection" ||
+ lowerName == "anthropic-dangerous-direct-browser-access" ||
+ strings.HasPrefix(lowerName, "x-stainless-") ||
+ strings.HasPrefix(lowerName, "x-claude-code-") ||
+ strings.HasPrefix(lowerName, "x-claude-remote-") {
+ continue
+ }
+ if lowerName == "user-agent" && len(values) > 0 && helps.IsClaudeCodeUserAgent(values[0]) {
+ continue
+ }
+ }
dst.Del(name)
for _, value := range values {
dst.Add(name, value)
@@ -1101,6 +1236,11 @@ func applyClaudeHeadersWithNativeProfile(
useAPIKey := !credentialUsesBearer
fp := resolveClaudeFingerprintPolicy(cfg, auth, apiKey)
wirePolicy, _ := resolveClaudeWirePolicy(cfg, auth, apiKey, confirmedClaudeCode)
+ if wirePolicy.Uncloak {
+ confirmedClaudeCode = false
+ helperProfile = false
+ sessionIDs = nil
+ }
applyCLIFingerprint := fp.ProfileClaudeCodeCLI || wirePolicy.Cloak
preserveCallerFingerprint := !applyCLIFingerprint && !confirmedClaudeCode
useOAuthBetas := fp.UseOAuthBetas
@@ -1236,6 +1376,21 @@ func applyClaudeHeadersWithNativeProfile(
}
}
applyBetaHeader := func() {
+ if wirePolicy.Uncloak {
+ baseBetas = withoutClaudeBeta(baseBetas, claudeCodeBeta)
+ baseBetas = withoutClaudeBeta(baseBetas, claudeOAuthBeta)
+ baseBetas = withoutClaudeBeta(baseBetas, claudeRedactThinkingBeta)
+ baseBetas = withoutClaudeBeta(baseBetas, claudeAFKModeBeta)
+ if !gjson.GetBytes(body, "diagnostics").Exists() {
+ baseBetas = withoutClaudeBeta(baseBetas, claudeCacheDiagnosisBeta)
+ }
+ if !gjson.GetBytes(body, "fallback_credit_token").Exists() {
+ baseBetas = withoutClaudeBeta(baseBetas, claudeFallbackCreditBeta)
+ }
+ if !helps.ClaudePayloadHas1hTTL(body) {
+ baseBetas = withoutClaudeBeta(baseBetas, claudeExtendedCacheTTLBeta)
+ }
+ }
// Enforce strict native Claude Code 2.1.280 model & turn beta gating:
if !claudeRequestSupportsEffort(body, nil) {
baseBetas = withoutClaudeBeta(baseBetas, claudeEffortBeta)
@@ -1260,12 +1415,28 @@ func applyClaudeHeadersWithNativeProfile(
if isClaudeHaikuModel(reqModel) && !gjson.GetBytes(body, "fallbacks").Exists() && !helperProfile {
baseBetas = withoutClaudeBeta(baseBetas, claudeServerSideFallbackBeta)
}
+ // A body carrying thinking.block_binding is rejected upstream unless
+ // the binding-controls beta travels with it, in every fingerprint
+ // mode (cloaked, caller-owned, and confirmed passthrough alike).
+ if gjson.GetBytes(body, "thinking.block_binding").Exists() {
+ baseBetas = withClaudeBetaAppended(baseBetas, claudeThinkingBindingBeta)
+ }
+ // Operator overrides get the last word over the derived and caller betas:
+ // strip filters without re-sorting, append adds missing names at the end.
+ // They only shape the emitted value, so re-applying the header stays stable.
+ emittedBetas := baseBetas
+ for _, beta := range hd.AnthropicBetaStrip {
+ emittedBetas = withoutClaudeBeta(emittedBetas, beta)
+ }
+ for _, beta := range hd.AnthropicBetaAppend {
+ emittedBetas = withClaudeBetaAppended(emittedBetas, beta)
+ }
- if strings.TrimSpace(baseBetas) == "" {
+ if strings.TrimSpace(emittedBetas) == "" {
r.Header.Del("Anthropic-Beta")
return
}
- r.Header.Set("Anthropic-Beta", baseBetas)
+ r.Header.Set("Anthropic-Beta", emittedBetas)
}
applyBetaHeader()
@@ -1276,7 +1447,10 @@ func applyClaudeHeadersWithNativeProfile(
defaultAccept = "text/event-stream"
defaultAcceptEncoding = "identity"
}
- copyClaudeCallerFingerprintHeaders(r.Header, incomingHeaders, confirmedClaudeCode)
+ copyClaudeCallerFingerprintHeaders(r.Header, incomingHeaders, confirmedClaudeCode, wirePolicy.Uncloak)
+ if hd.ClientRequestID == config.ClaudeClientRequestIDOff {
+ r.Header.Del("X-Client-Request-Id")
+ }
misc.EnsureHeader(r.Header, incomingHeaders, "Anthropic-Version", "2023-06-01")
misc.EnsureHeader(r.Header, incomingHeaders, "Accept", defaultAccept)
misc.EnsureHeader(r.Header, incomingHeaders, "Accept-Encoding", defaultAcceptEncoding)
@@ -1284,7 +1458,11 @@ func applyClaudeHeadersWithNativeProfile(
// sent none must not fall through to Go's transport default
// ("Go-http-client/1.1"), which upstreams read as a bot signature. Identify
// as CPA instead: honest about the hop, and not a fabricated client.
- misc.EnsureHeader(r.Header, incomingHeaders, "User-Agent", "CLIProxyAPI/"+buildinfo.Version)
+ uaSourceHeaders := incomingHeaders
+ if wirePolicy.Uncloak && helps.IsClaudeCodeUserAgent(helps.HeaderValueCaseInsensitive(incomingHeaders, "User-Agent")) {
+ uaSourceHeaders = nil
+ }
+ misc.EnsureHeader(r.Header, uaSourceHeaders, "User-Agent", "CLIProxyAPI/"+buildinfo.Version)
applyBetaHeader()
sessionID := ""
for _, candidate := range sessionIDs {
@@ -1410,8 +1588,15 @@ func applyClaudeHeadersWithNativeProfile(
// request ID they already sent: 2.1.258 attaches the header just for a first-party
// base URL, so a helper that arrived without one keeps it absent upstream rather
// than gaining a synthesized ID the real client would not have sent.
- if isAnthropicBase || (helperProfile && helps.HeaderValueCaseInsensitive(incomingHeaders, "x-client-request-id") != "") {
- identityHeader("x-client-request-id", uuid.New().String())
+ // header-defaults.client-request-id=passthrough forwards only the caller's own
+ // value and never synthesizes one; off never sends the header.
+ incomingClientRequestID := helps.HeaderValueCaseInsensitive(incomingHeaders, "x-client-request-id")
+ if hd.ClientRequestID != config.ClaudeClientRequestIDOff && (isAnthropicBase || (helperProfile && incomingClientRequestID != "")) {
+ if hd.ClientRequestID != config.ClaudeClientRequestIDPassthrough {
+ identityHeader("x-client-request-id", uuid.New().String())
+ } else if incomingClientRequestID != "" {
+ r.Header.Set("x-client-request-id", incomingClientRequestID)
+ }
}
r.Header.Set("Connection", "keep-alive")
// Regular Claude Code requests negotiate transport identically for streaming
@@ -1460,7 +1645,7 @@ func applyClaudeHeadersWithNativeProfile(
// overridden Accept-Encoding contradicts the negotiated transport. Both were
// reachable because this ran after the whole header set was assembled.
if isAnthropicBase {
- r.Header.Set("Anthropic-Beta", baseBetas)
+ applyBetaHeader()
applyTransportNegotiation()
} else if stream {
// Elsewhere only streaming is protected, so an Accept override cannot
@@ -2293,6 +2478,20 @@ func newClaudeMCPAliasResolver(reverseMap map[string]string) claudeMCPAliasResol
return resolver
}
+// restorePassthroughWrapped maps a tail that is exactly a declared caller-owned MCP
+// tool name (mcp__server__tool) back to that tool.
+func (resolver claudeMCPAliasResolver) restorePassthroughWrapped(tail string) (string, bool) {
+ if tail == "" {
+ return "", false
+ }
+ for _, original := range resolver.passthroughs {
+ if original == tail {
+ return original, true
+ }
+ }
+ return "", false
+}
+
func parseClaudeMCPAlias(name string) (claudeMCPAliasParts, bool) {
if !helps.IsClaudeMCPToolName(name) {
return claudeMCPAliasParts{}, false
@@ -2439,6 +2638,14 @@ func (resolver claudeMCPAliasResolver) resolve(name string) (string, bool, error
}
if len(resolver.passthroughs) > 0 {
+ // Recovery 0: The model kept the caller's full tool name, mcp__ prefix included,
+ // under the virtual server (e.g. "mcp____mcp____").
+ // Recoveries 1 and 2 below only cover the __ and tails.
+ if original, ok := resolver.restorePassthroughWrapped(suffix); ok {
+ log.Debugf("claude oauth mcp alias: recovered %q as caller-owned tool %q", name, original)
+ return original, true, nil
+ }
+
// Recovery 1: The model prepended the virtual server to the full caller MCP tool name
// (e.g. "mcp______"). After stripping the virtual server prefix,
// re-prefixing suffix with "mcp__" produces the original caller tool name.
diff --git a/internal/runtime/executor/claude_executor_request_remap_test.go b/internal/runtime/executor/claude_executor_request_remap_test.go
index e4e3bb69ab8..98bb88dd6f3 100644
--- a/internal/runtime/executor/claude_executor_request_remap_test.go
+++ b/internal/runtime/executor/claude_executor_request_remap_test.go
@@ -1105,3 +1105,59 @@ func TestReverseRemapOAuthToolNames_UndeclaredToolFailsOpen(t *testing.T) {
t.Fatalf("restoredLine = %s, want %s forwarded unchanged", string(restoredLine), string(line))
}
}
+
+// The model sometimes wraps a caller-owned MCP tool in the request's virtual
+// server, either by tool name (mcp____) or by its full name
+// (mcp____mcp____). Both must restore to the declared tool.
+func TestReverseRemapRestoresCallerMCPToolWrappedInVirtualServer(t *testing.T) {
+ const recall = "mcp__docs-search__search-pages"
+ body := []byte(`{"tools":[{"name":"Bash"},{"name":"` + recall + `"},{"name":"mcp__docs-search__search-issues"}]}`)
+ _, reverseMap := remapOAuthToolNamesWithOptions(body, claudeMCPAliasOptions{secret: "wrapped-passthrough-caller"})
+ var server string
+ for alias, original := range reverseMap {
+ if original == "Bash" {
+ server = claudeMCPAliasServer(alias)
+ }
+ }
+ if server == "" {
+ t.Fatalf("no virtual server allocated: %v", reverseMap)
+ }
+ for _, wrapped := range []string{
+ "mcp__" + server + "__search-pages",
+ "mcp__" + server + "__" + recall,
+ } {
+ t.Run(wrapped, func(t *testing.T) {
+ response := []byte(fmt.Sprintf(`{"content":[{"type":"tool_use","id":"toolu_1","name":%q,"input":{}}]}`, wrapped))
+ restored, errReverse := reverseRemapOAuthToolNames(response, reverseMap)
+ if errReverse != nil {
+ t.Fatalf("reverseRemapOAuthToolNames() error = %v", errReverse)
+ }
+ if got := gjson.GetBytes(restored, "content.0.name").String(); got != recall {
+ t.Fatalf("wrapped caller MCP tool restored to %q, want %q", got, recall)
+ }
+ line := []byte(fmt.Sprintf(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":%q,"input":{}}}`, wrapped))
+ restoredLine, errStream := reverseRemapOAuthToolNamesFromStreamLine(line, reverseMap)
+ if errStream != nil {
+ t.Fatalf("reverseRemapOAuthToolNamesFromStreamLine() error = %v", errStream)
+ }
+ if got := gjson.GetBytes(helps.JSONPayload(restoredLine), "content_block.name").String(); got != recall {
+ t.Fatalf("stream wrapped caller MCP tool restored to %q, want %q", got, recall)
+ }
+ })
+ }
+}
+
+func TestReverseRemapWrappedCallerMCPToolStaysAnErrorWhenAmbiguous(t *testing.T) {
+ body := []byte(`{"tools":[{"name":"Bash"},{"name":"mcp__a__query"},{"name":"mcp__b__query"}]}`)
+ _, reverseMap := remapOAuthToolNamesWithOptions(body, claudeMCPAliasOptions{secret: "wrapped-ambiguous-caller"})
+ var server string
+ for alias, original := range reverseMap {
+ if original == "Bash" {
+ server = claudeMCPAliasServer(alias)
+ }
+ }
+ response := []byte(fmt.Sprintf(`{"content":[{"type":"tool_use","id":"toolu_1","name":%q,"input":{}}]}`, "mcp__"+server+"__query"))
+ if _, errReverse := reverseRemapOAuthToolNames(response, reverseMap); errReverse == nil || !strings.Contains(errReverse.Error(), "matches multiple declared tools") {
+ t.Fatalf("ambiguous wrapped tool error = %v, want matches multiple declared tools", errReverse)
+ }
+}
diff --git a/internal/runtime/executor/claude_executor_stream.go b/internal/runtime/executor/claude_executor_stream.go
index b7ada90dd6d..5f8b0cdc9ef 100644
--- a/internal/runtime/executor/claude_executor_stream.go
+++ b/internal/runtime/executor/claude_executor_stream.go
@@ -93,6 +93,8 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
if err != nil {
return nil, err
}
+ webSearchSessionKey, webSearchTTL, webSearchLimit := e.claudeWebSearchResultCacheScope(ctx, from, req, opts, originalPayload)
+ body = helps.RestoreClaudeWebSearchResultsContext(ctx, body, webSearchSessionKey, webSearchTTL)
if rebuildMidSystemMessageEnabled(e.cfg, auth) {
body = rebuildMidSystemMessagesToTopLevel(body)
}
@@ -232,10 +234,11 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
}
}
bodyForUpstream = stripDefaultKimiClaudeCodeAttribution(auth, url, fp.ProfileClaudeCodeCLI, bodyForUpstream)
+ bodyForUpstream = applyClaudePreservedThinkingBinding(bodyForUpstream)
// User rules match the fully prepared business body and are applied only once.
var touchedPayloadPaths map[string]bool
bodyForUpstream, touchedPayloadPaths = helps.ApplyPayloadConfigWithTrackedPaths(
- e.cfg, baseModel, to.String(), from.String(), "", bodyForUpstream, originalTranslated,
+ e.cfg, baseModel, to.String(), from.String(), "", bodyForUpstream, stripTranslatedDefaultMaxTokens(from, originalPayload, originalTranslated),
helps.PayloadRequestedModel(opts, req.Model), helps.PayloadRequestPath(opts), opts.Headers,
"diagnostics",
)
@@ -375,11 +378,13 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
scanner.Buffer(nil, 52_428_800) // 50MB
var event bytes.Buffer
var upstreamMessageID string
+ var upstreamStreamErr error
upstreamCompleted := false
flushEvent := func() bool {
if event.Len() == 0 {
return true
}
+ observeClaudeSSEEvent(event.Bytes(), &upstreamMessageID, &upstreamCompleted, &upstreamStreamErr)
cloned := bytes.Clone(event.Bytes())
event.Reset()
select {
@@ -391,7 +396,6 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
}
for scanner.Scan() {
line := scanner.Bytes()
- observeClaudeStreamLine(line, &upstreamMessageID, &upstreamCompleted)
helps.AppendAPIResponseChunk(ctx, e.cfg, line)
reporter.ObserveResponseModel(line)
streamUsage.ObserveClaudeStream(line)
@@ -408,7 +412,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
emitCancellation(ctx.Err())
return
}
- if upstreamCompleted {
+ if upstreamCompleted || upstreamStreamErr != nil {
break
}
}
@@ -421,7 +425,14 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
if emitCancellation(scanner.Err()) {
return
}
- if errScan := scanner.Err(); errScan != nil {
+ errScan := scanner.Err()
+ if errScan == nil {
+ errScan = upstreamStreamErr
+ }
+ if errScan == nil && ctx.Err() == nil {
+ errScan = statusErr{code: http.StatusBadGateway, msg: "upstream stream closed before message_stop"}
+ }
+ if errScan != nil {
errScan = wrapClaudeFastRequestError(fastRequest, httpResp.StatusCode, errScan)
helps.RecordAPIResponseError(ctx, e.cfg, errScan)
streamUsage.PublishFailure(ctx, reporter, errScan)
@@ -445,10 +456,17 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
var param any
helps.InitializeApplyPatchStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), bodyForTranslation, ¶m)
var upstreamMessageID string
+ var upstreamStreamErr error
+ var eventBuf bytes.Buffer
upstreamCompleted := false
for scanner.Scan() {
line := scanner.Bytes()
- observeClaudeStreamLine(line, &upstreamMessageID, &upstreamCompleted)
+ eventBuf.Write(line)
+ eventBuf.WriteByte('\n')
+ if len(bytes.TrimSpace(line)) == 0 {
+ observeClaudeSSEEvent(eventBuf.Bytes(), &upstreamMessageID, &upstreamCompleted, &upstreamStreamErr)
+ eventBuf.Reset()
+ }
helps.AppendAPIResponseChunk(ctx, e.cfg, line)
reporter.ObserveResponseModel(line)
streamUsage.ObserveClaudeStream(line)
@@ -458,6 +476,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
return
}
line = e.restoreResponseModel(restoredLine, req.Model)
+ helps.RecordClaudeWebSearchResultsContext(ctx, webSearchSessionKey, line, webSearchTTL, webSearchLimit)
chunks := sdktranslator.TranslateStream(
ctx,
to,
@@ -485,10 +504,14 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) {
return
}
- if upstreamCompleted {
+ if upstreamCompleted || upstreamStreamErr != nil {
break
}
}
+ if eventBuf.Len() > 0 {
+ observeClaudeSSEEvent(eventBuf.Bytes(), &upstreamMessageID, &upstreamCompleted, &upstreamStreamErr)
+ eventBuf.Reset()
+ }
if helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) {
return
}
@@ -496,7 +519,14 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
if emitCancellation(scanner.Err()) {
return
}
- if errScan := scanner.Err(); errScan != nil {
+ errScan := scanner.Err()
+ if errScan == nil {
+ errScan = upstreamStreamErr
+ }
+ if errScan == nil && ctx.Err() == nil {
+ errScan = statusErr{code: http.StatusBadGateway, msg: "upstream stream closed before message_stop"}
+ }
+ if errScan != nil {
errScan = wrapClaudeFastRequestError(fastRequest, httpResp.StatusCode, errScan)
helps.RecordAPIResponseError(ctx, e.cfg, errScan)
streamUsage.PublishFailure(ctx, reporter, errScan)
diff --git a/internal/runtime/executor/claude_executor_stream_truncation_test.go b/internal/runtime/executor/claude_executor_stream_truncation_test.go
new file mode 100644
index 00000000000..8a21bbb6e89
--- /dev/null
+++ b/internal/runtime/executor/claude_executor_stream_truncation_test.go
@@ -0,0 +1,937 @@
+package executor
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
+)
+
+// Upstream closes the SSE stream after a partial message without ever sending
+// message_stop. The executor must surface a truncation error and record failed usage
+// instead of closing the channel silently.
+func TestClaudeExecutorStreamTruncationReportsError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ flusher, _ := w.(http.Flusher)
+ _, _ = fmt.Fprint(w, "event: message_start\n")
+ _, _ = fmt.Fprint(w, "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-opus-5\",\"stop_reason\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":1}}}\n\n")
+ _, _ = fmt.Fprint(w, "event: content_block_delta\n")
+ _, _ = fmt.Fprint(w, "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello\"}}\n\n")
+ if flusher != nil {
+ flusher.Flush()
+ }
+ // Abrupt clean EOF: no message_stop, no error event.
+ }))
+ defer server.Close()
+
+ pluginName := "test-claude-passthrough-truncation"
+ plugin := &captureClaudeUsagePlugin{
+ records: make(chan usage.Record, 4),
+ }
+ usage.RegisterNamedPlugin(pluginName, plugin)
+ t.Cleanup(func() {
+ usage.RegisterNamedPlugin(pluginName, noopClaudeUsagePlugin{})
+ })
+
+ exec := NewClaudeExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{Attributes: map[string]string{
+ "api_key": "key-123",
+ "base_url": server.URL,
+ }}
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ result, err := exec.ExecuteStream(ctx, auth, cliproxyexecutor.Request{
+ Model: "claude-opus-5",
+ Payload: []byte(`{"model":"claude-opus-5","messages":[{"role":"user","content":"hi"}],"stream":true}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatClaude,
+ ResponseFormat: sdktranslator.FormatClaude,
+ })
+ if err != nil {
+ t.Fatalf("ExecuteStream() error = %v", err)
+ }
+
+ var chunkErr error
+ var sawMessageStop bool
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ chunkErr = chunk.Err
+ continue
+ }
+ if strings.Contains(string(chunk.Payload), "message_stop") {
+ sawMessageStop = true
+ }
+ }
+
+ if chunkErr == nil {
+ t.Fatalf("expected a truncation error chunk when upstream closes before message_stop, got none (sawMessageStop=%v)", sawMessageStop)
+ }
+ if !strings.Contains(chunkErr.Error(), "message_stop") {
+ t.Errorf("truncation error = %q, want it to mention message_stop", chunkErr.Error())
+ }
+
+ select {
+ case record := <-plugin.records:
+ if !record.Failed {
+ t.Fatalf("expected usage record to be marked failed on truncated stream")
+ }
+ case <-time.After(3 * time.Second):
+ t.Fatal("timed out waiting for failed usage record")
+ }
+}
+
+// Translated stream path must also report a truncation error when upstream closes
+// before message_stop.
+func TestClaudeExecutorTranslatedStreamTruncationReportsError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ flusher, _ := w.(http.Flusher)
+ _, _ = fmt.Fprint(w, "event: message_start\n")
+ _, _ = fmt.Fprint(w, "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-opus-5\",\"stop_reason\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":1}}}\n\n")
+ _, _ = fmt.Fprint(w, "event: content_block_start\n")
+ _, _ = fmt.Fprint(w, "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n")
+ _, _ = fmt.Fprint(w, "event: content_block_delta\n")
+ _, _ = fmt.Fprint(w, "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello\"}}\n\n")
+ if flusher != nil {
+ flusher.Flush()
+ }
+ }))
+ defer server.Close()
+
+ pluginName := "test-claude-translated-truncation"
+ plugin := &captureClaudeUsagePlugin{
+ records: make(chan usage.Record, 4),
+ }
+ usage.RegisterNamedPlugin(pluginName, plugin)
+ t.Cleanup(func() {
+ usage.RegisterNamedPlugin(pluginName, noopClaudeUsagePlugin{})
+ })
+
+ exec := NewClaudeExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{Attributes: map[string]string{
+ "api_key": "key-123",
+ "base_url": server.URL,
+ }}
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ result, err := exec.ExecuteStream(ctx, auth, cliproxyexecutor.Request{
+ Model: "claude-opus-5",
+ Payload: []byte(`{"model":"claude-opus-5","messages":[{"role":"user","content":"hi"}],"stream":true}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAI,
+ ResponseFormat: sdktranslator.FormatOpenAI,
+ })
+ if err != nil {
+ t.Fatalf("ExecuteStream() error = %v", err)
+ }
+
+ var chunkErr error
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ chunkErr = chunk.Err
+ }
+ }
+
+ if chunkErr == nil {
+ t.Fatal("expected a truncation error chunk on translated path when upstream closes before message_stop, got none")
+ }
+ if !strings.Contains(chunkErr.Error(), "message_stop") {
+ t.Errorf("truncation error = %q, want it to mention message_stop", chunkErr.Error())
+ }
+
+ select {
+ case record := <-plugin.records:
+ if !record.Failed {
+ t.Fatalf("expected usage record to be marked failed on truncated translated stream")
+ }
+ case <-time.After(3 * time.Second):
+ t.Fatal("timed out waiting for failed usage record")
+ }
+}
+
+// A complete stream that ends with message_stop must not report any error.
+func TestClaudeExecutorStreamCompleteNoError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ flusher, _ := w.(http.Flusher)
+ _, _ = fmt.Fprint(w, "event: message_start\n")
+ _, _ = fmt.Fprint(w, "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-opus-5\",\"stop_reason\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":1}}}\n\n")
+ _, _ = fmt.Fprint(w, "event: content_block_delta\n")
+ _, _ = fmt.Fprint(w, "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello\"}}\n\n")
+ _, _ = fmt.Fprint(w, "event: message_stop\n")
+ _, _ = fmt.Fprint(w, "data: {\"type\":\"message_stop\"}\n\n")
+ if flusher != nil {
+ flusher.Flush()
+ }
+ }))
+ defer server.Close()
+
+ exec := NewClaudeExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{Attributes: map[string]string{
+ "api_key": "key-123",
+ "base_url": server.URL,
+ }}
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ result, err := exec.ExecuteStream(ctx, auth, cliproxyexecutor.Request{
+ Model: "claude-opus-5",
+ Payload: []byte(`{"model":"claude-opus-5","messages":[{"role":"user","content":"hi"}],"stream":true}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatClaude,
+ ResponseFormat: sdktranslator.FormatClaude,
+ })
+ if err != nil {
+ t.Fatalf("ExecuteStream() error = %v", err)
+ }
+
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("unexpected error chunk in complete stream: %v", chunk.Err)
+ }
+ }
+}
+
+// OpenAICompatExecutor with ResponseFormat: FormatClaude must fail the stream on clean EOF
+// when no chunk carried finish_reason, rather than synthesizing [DONE] and emitting a fake message_stop.
+func TestOpenAICompatExecutorClaudeEOFBeforeFinishReasonFailsStream(t *testing.T) {
+ reply := "data: {\"id\":\"chatcmpl-truncated\",\"object\":\"chat.completion.chunk\",\"created\":1773896263,\"model\":\"test\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"partial\"},\"finish_reason\":null}]}\n\n"
+ exec, auth, _ := newApplyPatchCompatTestExecutor(t, reply)
+ request := []byte(`{"model":"test","max_tokens":64,"messages":[{"role":"user","content":"hello"}],"stream":true}`)
+
+ result, errExecuteStream := exec.ExecuteStream(t.Context(), auth, cliproxyexecutor.Request{Model: "test", Payload: request}, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatClaude,
+ ResponseFormat: sdktranslator.FormatClaude,
+ OriginalRequest: request,
+ Stream: true,
+ })
+ if errExecuteStream != nil {
+ t.Fatal(errExecuteStream)
+ }
+
+ var output bytes.Buffer
+ var streamErr error
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ streamErr = chunk.Err
+ continue
+ }
+ output.Write(chunk.Payload)
+ }
+ if !strings.Contains(output.String(), `"type":"content_block_delta"`) {
+ t.Fatalf("truncated Claude stream did not deliver its valid content frame: %q", output.String())
+ }
+ if streamErr == nil {
+ t.Fatalf("expected error chunk on truncated OpenAI-to-Claude stream missing finish_reason, got nil; output=%q", output.String())
+ }
+ if strings.Contains(output.String(), "message_stop") {
+ t.Fatalf("truncated OpenAI-to-Claude stream must not synthesize message_stop: %q", output.String())
+ }
+}
+
+// OpenAICompatExecutor with ResponseFormat: FormatClaude must still finalize the stream on clean EOF
+// when a chunk already carried a non-empty finish_reason (compatibility with providers omitting [DONE]).
+func TestOpenAICompatExecutorClaudeEOFAfterFinishReasonCompletesStream(t *testing.T) {
+ reply := strings.Join([]string{
+ `data: {"id":"chatcmpl-eof","object":"chat.completion.chunk","created":1773896263,"model":"test","choices":[{"index":0,"delta":{"role":"assistant","content":"done"},"finish_reason":null}]}`,
+ "",
+ `data: {"id":"chatcmpl-eof","object":"chat.completion.chunk","created":1773896263,"model":"test","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`,
+ "",
+ }, "\n")
+ exec, auth, _ := newApplyPatchCompatTestExecutor(t, reply)
+ request := []byte(`{"model":"test","max_tokens":64,"messages":[{"role":"user","content":"hello"}],"stream":true}`)
+
+ result, errExecuteStream := exec.ExecuteStream(t.Context(), auth, cliproxyexecutor.Request{Model: "test", Payload: request}, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatClaude,
+ ResponseFormat: sdktranslator.FormatClaude,
+ OriginalRequest: request,
+ Stream: true,
+ })
+ if errExecuteStream != nil {
+ t.Fatal(errExecuteStream)
+ }
+
+ var output bytes.Buffer
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("unexpected stream error after finish_reason was received: %v", chunk.Err)
+ }
+ output.Write(chunk.Payload)
+ }
+ if !strings.Contains(output.String(), "message_stop") {
+ t.Fatalf("expected message_stop when finish_reason was received before clean EOF: %q", output.String())
+ }
+}
+
+// GeminiExecutor with ResponseFormat: FormatClaude must fail the stream on clean EOF
+// when no chunk carried a finishReason, instead of synthesizing [DONE] and emitting a fake message_stop.
+func TestGeminiExecutorClaudeStreamTruncationReportsError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = fmt.Fprint(w, "data: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"partial gemini\"}]}}]}\n\n")
+ }))
+ defer server.Close()
+
+ exec := NewGeminiExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{
+ Provider: "gemini",
+ Attributes: map[string]string{
+ "api_key": "test-gemini-key",
+ "base_url": server.URL,
+ },
+ }
+ req := cliproxyexecutor.Request{
+ Model: "gemini-2.5-flash",
+ Payload: []byte(`{"model":"gemini-2.5-flash","max_tokens":64,"messages":[{"role":"user","content":"hi"}],"stream":true}`),
+ }
+
+ result, err := exec.ExecuteStream(context.Background(), auth, req, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatClaude,
+ ResponseFormat: sdktranslator.FormatClaude,
+ Stream: true,
+ })
+ if err != nil {
+ t.Fatalf("ExecuteStream() error = %v", err)
+ }
+
+ var output bytes.Buffer
+ var streamErr error
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ streamErr = chunk.Err
+ continue
+ }
+ output.Write(chunk.Payload)
+ }
+ if streamErr == nil {
+ t.Fatalf("expected error chunk on truncated Gemini-to-Claude stream missing finishReason, got nil; output=%q", output.String())
+ }
+ if strings.Contains(output.String(), "message_stop") {
+ t.Fatalf("truncated Gemini-to-Claude stream must not synthesize message_stop: %q", output.String())
+ }
+}
+
+// GeminiVertexExecutor with ResponseFormat: FormatClaude must fail the stream on clean EOF
+// when no chunk carried a finishReason, instead of synthesizing [DONE] and emitting a fake message_stop.
+func TestGeminiVertexExecutorClaudeStreamTruncationReportsError(t *testing.T) {
+ for _, authMode := range []string{"api_key", "service_account"} {
+ t.Run(authMode, func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/token" {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = io.WriteString(w, `{"access_token":"local-token","token_type":"Bearer","expires_in":3600}`)
+ return
+ }
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = fmt.Fprint(w, "data: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"partial vertex\"}]}}]}\n\n")
+ }))
+ defer server.Close()
+
+ ctx := context.Background()
+ auth := &cliproxyauth.Auth{Provider: "vertex"}
+ if authMode == "api_key" {
+ auth.Attributes = map[string]string{
+ "api_key": "test-vertex-key",
+ "base_url": server.URL,
+ }
+ } else {
+ var serviceAccount map[string]any
+ if errUnmarshal := json.Unmarshal(testVertexServiceAccountJSON(t, server.URL+"/token"), &serviceAccount); errUnmarshal != nil {
+ t.Fatal(errUnmarshal)
+ }
+ auth.Metadata = map[string]any{
+ "project_id": "proxy-test",
+ "location": "global",
+ "service_account": serviceAccount,
+ }
+ target, errParse := parseTestURL(t, server.URL)
+ if errParse != nil {
+ t.Fatal(errParse)
+ }
+ transport := http.DefaultTransport.(*http.Transport).Clone()
+ transport.Proxy = nil
+ t.Cleanup(transport.CloseIdleConnections)
+ ctx = context.WithValue(ctx, "cliproxy.roundtripper", issue6258RedirectTransport{target: target, base: transport})
+ }
+
+ exec := NewGeminiVertexExecutor(&config.Config{})
+ req := cliproxyexecutor.Request{
+ Model: "gemini-2.5-flash",
+ Payload: []byte(`{"model":"gemini-2.5-flash","max_tokens":64,"messages":[{"role":"user","content":"hi"}],"stream":true}`),
+ }
+
+ result, err := exec.ExecuteStream(ctx, auth, req, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatClaude,
+ ResponseFormat: sdktranslator.FormatClaude,
+ Stream: true,
+ })
+ if err != nil {
+ t.Fatalf("ExecuteStream() error = %v", err)
+ }
+
+ var output bytes.Buffer
+ var streamErr error
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ streamErr = chunk.Err
+ continue
+ }
+ output.Write(chunk.Payload)
+ }
+ if streamErr == nil {
+ t.Fatalf("expected error chunk on truncated Vertex-to-Claude stream missing finishReason, got nil; output=%q", output.String())
+ }
+ if strings.Contains(output.String(), "message_stop") {
+ t.Fatalf("truncated Vertex-to-Claude stream must not synthesize message_stop: %q", output.String())
+ }
+ })
+ }
+}
+
+// Upstream SSE error event after message_start must not be treated as a completed
+// message for continuity/usage accounting: claudeMessageIDFromSSE must return empty
+// and ExecuteStream must surface a terminal chunk error and failed usage record.
+func TestClaudeExecutorStreamErrorEventDoesNotCommitContinuityAndMarksFailed(t *testing.T) {
+ sseWithError := "event: message_start\n" +
+ "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_err_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-opus-5\",\"stop_reason\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":1}}}\n\n" +
+ "event: error\n" +
+ "data: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"Overloaded\"}}\n\n"
+
+ if gotID := claudeMessageIDFromSSE([]byte(sseWithError)); gotID != "" {
+ t.Fatalf("claudeMessageIDFromSSE() on error stream = %q, want empty string so failed turn is not committed", gotID)
+ }
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = fmt.Fprint(w, sseWithError)
+ }))
+ defer server.Close()
+
+ pluginName := "test-claude-error-event-usage"
+ plugin := &captureClaudeUsagePlugin{
+ records: make(chan usage.Record, 4),
+ }
+ usage.RegisterNamedPlugin(pluginName, plugin)
+ t.Cleanup(func() {
+ usage.RegisterNamedPlugin(pluginName, noopClaudeUsagePlugin{})
+ })
+
+ exec := NewClaudeExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{Attributes: map[string]string{
+ "api_key": "key-123",
+ "base_url": server.URL,
+ }}
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ result, err := exec.ExecuteStream(ctx, auth, cliproxyexecutor.Request{
+ Model: "claude-opus-5",
+ Payload: []byte(`{"model":"claude-opus-5","messages":[{"role":"user","content":"hi"}],"stream":true}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatClaude,
+ ResponseFormat: sdktranslator.FormatClaude,
+ })
+ if err != nil {
+ t.Fatalf("ExecuteStream() error = %v", err)
+ }
+
+ var chunkErr error
+ var output bytes.Buffer
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ chunkErr = chunk.Err
+ continue
+ }
+ output.Write(chunk.Payload)
+ }
+
+ if !strings.Contains(output.String(), "overloaded_error") {
+ t.Fatalf("expected upstream error event payload to be forwarded, got: %q", output.String())
+ }
+ if chunkErr == nil {
+ t.Fatal("expected terminal chunk error when upstream emits SSE error event without message_stop, got nil")
+ }
+
+ select {
+ case record := <-plugin.records:
+ if !record.Failed {
+ t.Fatal("expected usage record to be marked failed when upstream stream ends with error event")
+ }
+ case <-time.After(3 * time.Second):
+ t.Fatal("timed out waiting for failed usage record")
+ }
+}
+
+type failingTailReader struct {
+ prefix io.Reader
+ err error
+}
+
+func (r *failingTailReader) Read(p []byte) (int, error) {
+ if r.prefix != nil {
+ n, err := r.prefix.Read(p)
+ if err == io.EOF {
+ r.prefix = nil
+ if n > 0 {
+ return n, nil
+ }
+ return 0, r.err
+ }
+ return n, err
+ }
+ return 0, r.err
+}
+
+func (r *failingTailReader) Close() error { return nil }
+
+// KimiExecutor.ExecuteStream must check scanner.Err() before synthesizing [DONE]
+// so a mid-stream read error does not emit a synthetic terminal completion chunk first.
+func TestKimiExecutorStreamReadErrorDoesNotSynthesizeDoneBeforeScanError(t *testing.T) {
+ ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", kimiRoundTripperFunc(func(req *http.Request) (*http.Response, error) {
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
+ Body: &failingTailReader{
+ prefix: strings.NewReader("data: {\"id\":\"chatcmpl-kimi\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"kimi-k2.5\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"part\"},\"finish_reason\":null}]}\n\n"),
+ err: io.ErrUnexpectedEOF,
+ },
+ }, nil
+ }))
+
+ exec := NewKimiExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{
+ Attributes: map[string]string{},
+ Metadata: map[string]any{"access_token": "test-kimi-key"},
+ }
+ req := cliproxyexecutor.Request{
+ Model: "kimi-k2.5",
+ Payload: []byte(`{"model":"kimi-k2.5","messages":[{"role":"user","content":"hi"}],"stream":true}`),
+ }
+
+ result, err := exec.ExecuteStream(ctx, auth, req, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAI,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ Stream: true,
+ })
+ if err != nil {
+ t.Fatalf("ExecuteStream() error = %v", err)
+ }
+
+ var output bytes.Buffer
+ var streamErr error
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ streamErr = chunk.Err
+ continue
+ }
+ output.Write(chunk.Payload)
+ }
+ if streamErr == nil {
+ t.Fatalf("expected scan error from KimiExecutor stream, got nil; output=%q", output.String())
+ }
+ if strings.Contains(output.String(), "response.completed") {
+ t.Fatalf("KimiExecutor must not synthesize [DONE] (response.completed) when scanner.Err() != nil; output=%q", output.String())
+ }
+}
+
+// GeminiExecutor and GeminiVertexExecutor must still complete FormatClaude streams
+// when an upstream chunk carries finishReason or promptFeedback.blockReason.
+func TestGeminiAndVertexClaudeStreamWithFinishSignalCompletes(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ chunk string
+ }{
+ {
+ name: "finishReason STOP",
+ chunk: "data: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"ok\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":3,\"candidatesTokenCount\":1}}\n\n",
+ },
+ {
+ name: "promptFeedback blockReason",
+ chunk: "data: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"blocked\"}]}}],\"promptFeedback\":{\"blockReason\":\"SAFETY\"}}\n\n",
+ },
+ } {
+ t.Run("gemini/"+tc.name, func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = fmt.Fprint(w, tc.chunk)
+ }))
+ defer server.Close()
+
+ exec := NewGeminiExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{
+ Provider: "gemini",
+ Attributes: map[string]string{
+ "api_key": "test-gemini-key",
+ "base_url": server.URL,
+ },
+ }
+ result, err := exec.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "gemini-2.5-flash",
+ Payload: []byte(`{"model":"gemini-2.5-flash","max_tokens":64,"messages":[{"role":"user","content":"hi"}],"stream":true}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatClaude,
+ ResponseFormat: sdktranslator.FormatClaude,
+ Stream: true,
+ })
+ if err != nil {
+ t.Fatalf("ExecuteStream() error = %v", err)
+ }
+ var output bytes.Buffer
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("unexpected error chunk: %v", chunk.Err)
+ }
+ output.Write(chunk.Payload)
+ }
+ if !strings.Contains(output.String(), "message_stop") {
+ t.Fatalf("expected message_stop on completed Gemini stream; output=%q", output.String())
+ }
+ })
+
+ t.Run("vertex/"+tc.name, func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = fmt.Fprint(w, tc.chunk)
+ }))
+ defer server.Close()
+
+ exec := NewGeminiVertexExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{
+ Provider: "vertex",
+ Attributes: map[string]string{
+ "api_key": "test-vertex-key",
+ "base_url": server.URL,
+ },
+ }
+ result, err := exec.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "gemini-2.5-flash",
+ Payload: []byte(`{"model":"gemini-2.5-flash","max_tokens":64,"messages":[{"role":"user","content":"hi"}],"stream":true}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatClaude,
+ ResponseFormat: sdktranslator.FormatClaude,
+ Stream: true,
+ })
+ if err != nil {
+ t.Fatalf("ExecuteStream() error = %v", err)
+ }
+ var output bytes.Buffer
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("unexpected error chunk: %v", chunk.Err)
+ }
+ output.Write(chunk.Payload)
+ }
+ if !strings.Contains(output.String(), "message_stop") {
+ t.Fatalf("expected message_stop on completed Vertex stream; output=%q", output.String())
+ }
+ })
+
+ t.Run("vertex_service_account/"+tc.name, func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/token" {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = io.WriteString(w, `{"access_token":"local-token","token_type":"Bearer","expires_in":3600}`)
+ return
+ }
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = fmt.Fprint(w, tc.chunk)
+ }))
+ defer server.Close()
+
+ var serviceAccount map[string]any
+ if errUnmarshal := json.Unmarshal(testVertexServiceAccountJSON(t, server.URL+"/token"), &serviceAccount); errUnmarshal != nil {
+ t.Fatal(errUnmarshal)
+ }
+ auth := &cliproxyauth.Auth{
+ Provider: "vertex",
+ Metadata: map[string]any{
+ "project_id": "proxy-test",
+ "location": "global",
+ "service_account": serviceAccount,
+ },
+ }
+ target, errParse := parseTestURL(t, server.URL)
+ if errParse != nil {
+ t.Fatal(errParse)
+ }
+ transport := http.DefaultTransport.(*http.Transport).Clone()
+ transport.Proxy = nil
+ t.Cleanup(transport.CloseIdleConnections)
+ ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", issue6258RedirectTransport{target: target, base: transport})
+
+ exec := NewGeminiVertexExecutor(&config.Config{})
+ result, err := exec.ExecuteStream(ctx, auth, cliproxyexecutor.Request{
+ Model: "gemini-2.5-flash",
+ Payload: []byte(`{"model":"gemini-2.5-flash","max_tokens":64,"messages":[{"role":"user","content":"hi"}],"stream":true}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatClaude,
+ ResponseFormat: sdktranslator.FormatClaude,
+ Stream: true,
+ })
+ if err != nil {
+ t.Fatalf("ExecuteStream() error = %v", err)
+ }
+ var output bytes.Buffer
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("unexpected error chunk: %v", chunk.Err)
+ }
+ output.Write(chunk.Payload)
+ }
+ if !strings.Contains(output.String(), "message_stop") {
+ t.Fatalf("expected message_stop on completed Vertex service_account stream; output=%q", output.String())
+ }
+ })
+ }
+}
+
+func TestClaudeExecutorMultiLineDataMessageStopAndErrorEvents(t *testing.T) {
+ multiLineSuccess := "event: message_start\n" +
+ "data: {\"type\":\"message_start\",\n" +
+ "data: \"message\":{\"id\":\"msg_multi_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-opus-5\",\"stop_reason\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":1}}}\n\n" +
+ "event: message_stop\n" +
+ "data: {\"type\":\n" +
+ "data: \"message_stop\"}\n\n"
+
+ if gotID := claudeMessageIDFromSSE([]byte(multiLineSuccess)); gotID != "msg_multi_1" {
+ t.Fatalf("claudeMessageIDFromSSE() on multi-line SSE = %q, want %q", gotID, "msg_multi_1")
+ }
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = fmt.Fprint(w, multiLineSuccess)
+ }))
+ defer server.Close()
+
+ exec := NewClaudeExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{Attributes: map[string]string{
+ "api_key": "key-123",
+ "base_url": server.URL,
+ }}
+ result, err := exec.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "claude-opus-5",
+ Payload: []byte(`{"model":"claude-opus-5","messages":[{"role":"user","content":"hi"}],"stream":true}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatClaude,
+ ResponseFormat: sdktranslator.FormatClaude,
+ })
+ if err != nil {
+ t.Fatalf("ExecuteStream() error = %v", err)
+ }
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("unexpected error on multi-line message_stop stream: %v", chunk.Err)
+ }
+ }
+
+ nestedMultiLineTruncated := "event: message_start\n" +
+ "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_nested_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-opus-5\",\"stop_reason\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":1}}}\n\n" +
+ "event: content_block_start\n" +
+ "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_1\",\"name\":\"emit\",\"input\":\n" +
+ "data: {\"type\":\"message_stop\"}\n" +
+ "data: }}\n\n"
+
+ if gotID := claudeMessageIDFromSSE([]byte(nestedMultiLineTruncated)); gotID != "" {
+ t.Fatalf("claudeMessageIDFromSSE() on truncated stream with nested message_stop line = %q, want empty string", gotID)
+ }
+
+ truncServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = fmt.Fprint(w, nestedMultiLineTruncated)
+ }))
+ defer truncServer.Close()
+
+ truncResult, err := exec.ExecuteStream(context.Background(), &cliproxyauth.Auth{Attributes: map[string]string{
+ "api_key": "key-123",
+ "base_url": truncServer.URL,
+ }}, cliproxyexecutor.Request{
+ Model: "claude-opus-5",
+ Payload: []byte(`{"model":"claude-opus-5","messages":[{"role":"user","content":"hi"}],"stream":true}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatClaude,
+ ResponseFormat: sdktranslator.FormatClaude,
+ })
+ if err != nil {
+ t.Fatalf("ExecuteStream() error = %v", err)
+ }
+ var nestedChunkErr error
+ for chunk := range truncResult.Chunks {
+ if chunk.Err != nil {
+ nestedChunkErr = chunk.Err
+ }
+ }
+ if nestedChunkErr == nil {
+ t.Fatal("expected truncation error when stream ends after multiline tool_use with nested message_stop line, got nil")
+ }
+}
+
+type captureAnyProviderUsagePlugin struct {
+ provider string
+ records chan usage.Record
+}
+
+func (p *captureAnyProviderUsagePlugin) HandleUsage(_ context.Context, record usage.Record) {
+ if p == nil || (p.provider != "" && record.Provider != p.provider) {
+ return
+ }
+ select {
+ case p.records <- record:
+ default:
+ }
+}
+
+func TestTranslatedClaudeStreamTruncationPreservesBufferedUsageOnFailure(t *testing.T) {
+ t.Run("gemini", func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = fmt.Fprint(w, "data: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"partial\"}]}}],\"usageMetadata\":{\"promptTokenCount\":12,\"candidatesTokenCount\":4,\"totalTokenCount\":16}}\n\n")
+ }))
+ defer server.Close()
+
+ pluginName := "test-gemini-claude-truncation-usage"
+ plugin := &captureAnyProviderUsagePlugin{provider: "gemini", records: make(chan usage.Record, 4)}
+ usage.RegisterNamedPlugin(pluginName, plugin)
+ t.Cleanup(func() {
+ usage.RegisterNamedPlugin(pluginName, noopClaudeUsagePlugin{})
+ })
+
+ exec := NewGeminiExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{
+ Provider: "gemini",
+ Attributes: map[string]string{
+ "api_key": "test-gemini-key",
+ "base_url": server.URL,
+ },
+ }
+ result, err := exec.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "gemini-2.5-flash",
+ Payload: []byte(`{"model":"gemini-2.5-flash","max_tokens":64,"messages":[{"role":"user","content":"hi"}],"stream":true}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatClaude,
+ ResponseFormat: sdktranslator.FormatClaude,
+ Stream: true,
+ })
+ if err != nil {
+ t.Fatalf("ExecuteStream() error = %v", err)
+ }
+ for range result.Chunks {
+ }
+
+ select {
+ case record := <-plugin.records:
+ if !record.Failed {
+ t.Fatalf("expected failed usage record")
+ }
+ if record.Detail.InputTokens != 12 || record.Detail.OutputTokens != 4 {
+ t.Fatalf("usage tokens = (%d, %d), want (12, 4)", record.Detail.InputTokens, record.Detail.OutputTokens)
+ }
+ case <-time.After(3 * time.Second):
+ t.Fatal("timed out waiting for Gemini failed usage record")
+ }
+ })
+
+ t.Run("vertex", func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = fmt.Fprint(w, "data: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"partial\"}]}}],\"usageMetadata\":{\"promptTokenCount\":15,\"candidatesTokenCount\":5,\"totalTokenCount\":20}}\n\n")
+ }))
+ defer server.Close()
+
+ pluginName := "test-vertex-claude-truncation-usage"
+ plugin := &captureAnyProviderUsagePlugin{provider: "vertex", records: make(chan usage.Record, 4)}
+ usage.RegisterNamedPlugin(pluginName, plugin)
+ t.Cleanup(func() {
+ usage.RegisterNamedPlugin(pluginName, noopClaudeUsagePlugin{})
+ })
+
+ exec := NewGeminiVertexExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{
+ Provider: "vertex",
+ Attributes: map[string]string{
+ "api_key": "test-vertex-key",
+ "base_url": server.URL,
+ },
+ }
+ result, err := exec.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "gemini-2.5-flash",
+ Payload: []byte(`{"model":"gemini-2.5-flash","max_tokens":64,"messages":[{"role":"user","content":"hi"}],"stream":true}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatClaude,
+ ResponseFormat: sdktranslator.FormatClaude,
+ Stream: true,
+ })
+ if err != nil {
+ t.Fatalf("ExecuteStream() error = %v", err)
+ }
+ for range result.Chunks {
+ }
+
+ select {
+ case record := <-plugin.records:
+ if !record.Failed {
+ t.Fatalf("expected failed usage record")
+ }
+ if record.Detail.InputTokens != 15 || record.Detail.OutputTokens != 5 {
+ t.Fatalf("usage tokens = (%d, %d), want (15, 5)", record.Detail.InputTokens, record.Detail.OutputTokens)
+ }
+ case <-time.After(3 * time.Second):
+ t.Fatal("timed out waiting for Vertex failed usage record")
+ }
+ })
+
+ t.Run("openai-compatibility", func(t *testing.T) {
+ reply := "data: {\"id\":\"chatcmpl-trunc-usage\",\"object\":\"chat.completion.chunk\",\"created\":1773896263,\"model\":\"test\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"partial\"},\"finish_reason\":null}],\"usage\":{\"prompt_tokens\":9,\"completion_tokens\":3,\"total_tokens\":12}}\n\n"
+ exec, auth, _ := newApplyPatchCompatTestExecutor(t, reply)
+ request := []byte(`{"model":"test","max_tokens":64,"messages":[{"role":"user","content":"hello"}],"stream":true}`)
+
+ pluginName := "test-openai-compat-claude-truncation-usage"
+ plugin := &captureAnyProviderUsagePlugin{provider: exec.Identifier(), records: make(chan usage.Record, 4)}
+ usage.RegisterNamedPlugin(pluginName, plugin)
+ t.Cleanup(func() {
+ usage.RegisterNamedPlugin(pluginName, noopClaudeUsagePlugin{})
+ })
+
+ result, err := exec.ExecuteStream(t.Context(), auth, cliproxyexecutor.Request{Model: "test", Payload: request}, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatClaude,
+ ResponseFormat: sdktranslator.FormatClaude,
+ OriginalRequest: request,
+ Stream: true,
+ })
+ if err != nil {
+ t.Fatalf("ExecuteStream() error = %v", err)
+ }
+ for range result.Chunks {
+ }
+
+ select {
+ case record := <-plugin.records:
+ if !record.Failed {
+ t.Fatalf("expected failed usage record")
+ }
+ if record.Detail.InputTokens != 9 || record.Detail.OutputTokens != 3 {
+ t.Fatalf("usage tokens = (%d, %d), want (9, 3)", record.Detail.InputTokens, record.Detail.OutputTokens)
+ }
+ case <-time.After(3 * time.Second):
+ t.Fatal("timed out waiting for OpenAICompat failed usage record")
+ }
+ })
+}
diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go
index 3baeda4a714..ce3c2fbcf6a 100644
--- a/internal/runtime/executor/claude_executor_test.go
+++ b/internal/runtime/executor/claude_executor_test.go
@@ -4160,6 +4160,119 @@ func TestInjectClaudeCodeCurrentDateIsIdempotentAndAlignsFirstUserCache(t *testi
assertEphemeralUserTextBlock(t, content[1], "hello", "")
}
+func TestClaudeCallerCurrentDateSeedsOnlyFirstAnchor(t *testing.T) {
+ helps.ResetClaudeDiagnosticsForTest()
+ t.Cleanup(helps.ResetClaudeDiagnosticsForTest)
+
+ days7 := 7
+ cfg := &config.Config{Claude: config.ClaudeConfig{SessionDatePinDays: &days7}}
+ auth := &cliproxyauth.Auth{ID: "caller-date-cred"}
+ callerPayload := func(date string) []byte {
+ return []byte(`{"messages":[{"role":"user","content":[` +
+ buildTextBlock(claudeCodeCurrentDateReminder(date), nil) + `,` +
+ `{"type":"text","text":"hello"}]}]}`)
+ }
+
+ claudeCodeCurrentTimeFunc = func(cfg *config.Config, auth *cliproxyauth.Auth) time.Time {
+ return time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
+ }
+ t.Cleanup(func() { claudeCodeCurrentTimeFunc = nil })
+
+ keyA, _, _ := helps.BeginClaudeDiagnostics("id:caller-date-cred", "caller-date-session")
+ if got := resolveClaudeSessionPinnedDate(cfg, auth, callerPayload("2026-08-01"), keyA, "caller-date-session"); got != "2026-08-01" {
+ t.Fatalf("first anchor = %q, want caller date 2026-08-01", got)
+ }
+ keyB, _, _ := helps.BeginClaudeDiagnostics("id:other-cred", "caller-date-session")
+ if got := resolveClaudeSessionPinnedDate(cfg, auth, callerPayload("2026-08-02"), keyB, "caller-date-session"); got != "2026-08-01" {
+ t.Fatalf("later caller date = %q, want established anchor 2026-08-01", got)
+ }
+
+ // The resolver refreshes the sliding retention on every hit: shrink the
+ // pin expiry to ~now+1s, resolve once, and prove the expiry grew back to
+ // the full retention. Without the refresh the timestamp stays shrunk.
+ nearExpiry := time.Second
+ helps.ExpireClaudeDiagnosticsForTest(true, &nearExpiry)
+ before := helps.ClaudeSessionDatePinExpiryForTest("caller-date-session")
+ if before.IsZero() || time.Until(before) > 2*time.Second {
+ t.Fatalf("pin expiry before refresh = %v, want ~now+1s", before)
+ }
+ keyC, _, _ := helps.BeginClaudeDiagnostics("id:caller-date-cred", "caller-date-session")
+ if got := resolveClaudeSessionPinnedDate(cfg, auth, callerPayload("2026-08-02"), keyC, "caller-date-session"); got != "2026-08-01" {
+ t.Fatalf("post-continuity-expiry anchor = %q, want refreshed 2026-08-01", got)
+ }
+ after := helps.ClaudeSessionDatePinExpiryForTest("caller-date-session")
+ if time.Until(after) < 6*24*time.Hour {
+ t.Fatalf("pin expiry after refresh = %v, want ~now+7d (sliding retention was not refreshed)", after)
+ }
+}
+
+func TestInjectClaudeCodeCurrentDateStripsCallerReminderFromStringContent(t *testing.T) {
+ payload := []byte(`{"messages":[{"role":"user","content":""}]}`)
+ payload, _ = sjson.SetBytes(payload, "messages.0.content", claudeCodeCurrentDateReminder("2026-08-01")+"hello")
+
+ if got := claudeCallerCurrentDateReminderDate(payload); got != "2026-08-01" {
+ t.Fatalf("string caller date = %q, want 2026-08-01", got)
+ }
+ // Default injection keeps caller text verbatim (duplicate reminders),
+ // matching the pre-retention behavior byte for byte.
+ legacy := injectClaudeCodeCurrentDate(payload, "2026-08-01")
+ if got := strings.Count(string(legacy), "Today's date is"); got != 2 {
+ t.Fatalf("legacy payload has %d date reminders, want 2: %s", got, legacy)
+ }
+ // Pre-stripping the caller reminder lets one injection leave a single
+ // canonical reminder plus the untouched user text.
+ stripped := injectClaudeCodeCurrentDate(stripFirstUserClaudeCodeCurrentDateReminder(payload), "2026-08-01")
+ if got := strings.Count(string(stripped), "Today's date is"); got != 1 {
+ t.Fatalf("stripped payload has %d date reminders, want 1: %s", got, stripped)
+ }
+ if got := gjson.GetBytes(stripped, "messages.0.content.1.text").String(); got != "hello" {
+ t.Fatalf("user text after strip = %q, want hello: %s", got, stripped)
+ }
+ // A caller reminder without the trailing blank lines is still stripped.
+ noTrailer := []byte(`{"messages":[{"role":"user","content":""}]}`)
+ noTrailer, _ = sjson.SetBytes(noTrailer, "messages.0.content", strings.TrimRight(claudeCodeCurrentDateReminder("2026-08-01"), "\n")+"hello")
+ strippedNoTrailer := injectClaudeCodeCurrentDate(stripFirstUserClaudeCodeCurrentDateReminder(noTrailer), "2026-08-01")
+ if got := strings.Count(string(strippedNoTrailer), "Today's date is"); got != 1 {
+ t.Fatalf("no-trailer payload has %d date reminders, want 1: %s", got, strippedNoTrailer)
+ }
+ if got := gjson.GetBytes(strippedNoTrailer, "messages.0.content.1.text").String(); got != "hello" {
+ t.Fatalf("user text after no-trailer strip = %q, want hello: %s", got, strippedNoTrailer)
+ }
+}
+
+func TestApplyCloakingStringCallerReminderPreservesUserText(t *testing.T) {
+ helps.ResetClaudeDiagnosticsForTest()
+ t.Cleanup(helps.ResetClaudeDiagnosticsForTest)
+
+ days7 := 7
+ cfg := &config.Config{Claude: config.ClaudeConfig{SessionDatePinDays: &days7}}
+ auth := &cliproxyauth.Auth{ID: "apply-cloaking-string-reminder", Metadata: map[string]any{"cloak_mode": "always"}}
+ ctx := helps.WithClaudeSessionID(context.Background(), "apply-cloaking-string-session")
+
+ payload := []byte(`{"model":"claude-opus-5","messages":[{"role":"user","content":""}]}`)
+ payload, _ = sjson.SetBytes(payload, "messages.0.content", claudeCodeCurrentDateReminder("2026-08-01")+"hello")
+
+ out, didCloak, errCloaking := applyCloaking(ctx, cfg, auth, payload, "sk-ant-oat-test", false, false)
+ if errCloaking != nil {
+ t.Fatalf("applyCloaking() error = %v", errCloaking)
+ }
+ if !didCloak {
+ t.Fatal("applyCloaking() cloaked = false, want true")
+ }
+ if got := strings.Count(string(out), "Today's date is"); got != 1 {
+ t.Fatalf("cloaked payload has %d date reminders, want 1: %s", got, out)
+ }
+ foundHello := false
+ for _, block := range gjson.GetBytes(out, "messages.0.content").Array() {
+ if block.Get("text").String() == "hello" {
+ foundHello = true
+ }
+ }
+ if !foundHello {
+ t.Fatalf("user text 'hello' lost in cloaked payload: %s", out)
+ }
+}
+
func TestInjectClaudeCodeCurrentDateMovesExistingCopyToFirstBlock(t *testing.T) {
fixed := time.Date(2026, time.August, 1, 9, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60))
dateBlock := buildTextBlock(claudeCodeCurrentDateReminder(claudeCodeLocalDate(fixed)), nil)
@@ -9797,3 +9910,401 @@ func TestClaudeExecutor_CloakModePrefersStoredPrevReqOverCallerFake(t *testing.T
t.Fatalf("CPA must not use fake caller cc_prev_req, got: %s", turn2System)
}
}
+
+func TestClaudeExecutor_ChatCompletionsDefaultMaxTokensAndPayloadDefaultRule(t *testing.T) {
+ var seenBody []byte
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ seenBody, _ = io.ReadAll(r.Body)
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte("event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-opus-5-5\",\"content\":[],\"stop_reason\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":1}}}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"output_tokens\":1}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"))
+ }))
+ defer server.Close()
+
+ auth := &cliproxyauth.Auth{
+ Attributes: map[string]string{
+ "api_key": "sk-ant-api-test",
+ "base_url": server.URL,
+ },
+ }
+
+ t.Run("omitted max_tokens uses registry 128k limit", func(t *testing.T) {
+ executor := NewClaudeExecutor(&config.Config{})
+ payload := []byte(`{"model":"claude-opus-5-5","reasoning_effort":"high","messages":[{"role":"user","content":"hi"}]}`)
+ _, errExec := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "claude-opus-5-5",
+ Payload: payload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAI,
+ ResponseFormat: sdktranslator.FormatOpenAI,
+ OriginalRequest: payload,
+ })
+ if errExec != nil {
+ t.Fatalf("Execute() error = %v", errExec)
+ }
+ if got := gjson.GetBytes(seenBody, "max_tokens").Int(); got != 128000 {
+ t.Fatalf("Execute max_tokens = %d, want 128000; body=%s", got, seenBody)
+ }
+ })
+
+ t.Run("payload.default applies when chat completions omits max_tokens and preserves explicit client limit", func(t *testing.T) {
+ executor := NewClaudeExecutor(&config.Config{
+ Payload: config.PayloadConfig{
+ Default: []config.PayloadRule{{
+ Models: []config.PayloadModelRule{{Name: "claude-opus-5-5", Protocol: "claude"}},
+ Params: map[string]any{"max_tokens": 16384},
+ }},
+ },
+ })
+
+ omittedPayload := []byte(`{"model":"claude-opus-5-5","messages":[{"role":"user","content":"hi"}]}`)
+ _, errExec := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "claude-opus-5-5",
+ Payload: omittedPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAI,
+ ResponseFormat: sdktranslator.FormatOpenAI,
+ OriginalRequest: omittedPayload,
+ })
+ if errExec != nil {
+ t.Fatalf("Execute() omitted error = %v", errExec)
+ }
+ if got := gjson.GetBytes(seenBody, "max_tokens").Int(); got != 16384 {
+ t.Fatalf("Execute omitted max_tokens = %d, want payload.default 16384; body=%s", got, seenBody)
+ }
+
+ streamResult, errStream := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "claude-opus-5-5",
+ Payload: omittedPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAI,
+ ResponseFormat: sdktranslator.FormatOpenAI,
+ OriginalRequest: omittedPayload,
+ Stream: true,
+ })
+ if errStream != nil {
+ t.Fatalf("ExecuteStream() omitted error = %v", errStream)
+ }
+ for chunk := range streamResult.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("ExecuteStream() chunk error = %v", chunk.Err)
+ }
+ }
+ if got := gjson.GetBytes(seenBody, "max_tokens").Int(); got != 16384 {
+ t.Fatalf("ExecuteStream omitted max_tokens = %d, want payload.default 16384; body=%s", got, seenBody)
+ }
+
+ protocolCases := []struct {
+ name string
+ format sdktranslator.Format
+ payload string
+ wantLimit int64
+ }{
+ {
+ name: "chat completions explicit max_completion_tokens preserved",
+ format: sdktranslator.FormatOpenAI,
+ payload: `{"model":"claude-opus-5-5","max_completion_tokens":4096,"messages":[{"role":"user","content":"hi"}]}`,
+ wantLimit: 4096,
+ },
+ {
+ name: "responses omitted max_output_tokens uses payload.default",
+ format: sdktranslator.FormatOpenAIResponse,
+ payload: `{"model":"claude-opus-5-5","input":"hi"}`,
+ wantLimit: 16384,
+ },
+ {
+ name: "responses explicit max_output_tokens preserved",
+ format: sdktranslator.FormatOpenAIResponse,
+ payload: `{"model":"claude-opus-5-5","max_output_tokens":4096,"input":"hi"}`,
+ wantLimit: 4096,
+ },
+ {
+ name: "gemini omitted maxOutputTokens uses payload.default",
+ format: sdktranslator.FormatGemini,
+ payload: `{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`,
+ wantLimit: 16384,
+ },
+ {
+ name: "gemini explicit maxOutputTokens preserved",
+ format: sdktranslator.FormatGemini,
+ payload: `{"contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"maxOutputTokens":4096}}`,
+ wantLimit: 4096,
+ },
+ }
+
+ for _, pc := range protocolCases {
+ raw := []byte(pc.payload)
+ _, errCase := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "claude-opus-5-5",
+ Payload: raw,
+ }, cliproxyexecutor.Options{
+ SourceFormat: pc.format,
+ ResponseFormat: pc.format,
+ OriginalRequest: raw,
+ })
+ if errCase != nil {
+ t.Fatalf("%s: Execute() error = %v", pc.name, errCase)
+ }
+ if got := gjson.GetBytes(seenBody, "max_tokens").Int(); got != pc.wantLimit {
+ t.Fatalf("%s: max_tokens = %d, want %d; body=%s", pc.name, got, pc.wantLimit, seenBody)
+ }
+ }
+ })
+}
+
+// preservedThinkingTestSignature is a syntactically valid Claude thinking
+// signature sample that survives signature sanitization, so executor-level
+// tests can exercise replayed-history handling without a real upstream.
+const preservedThinkingTestSignature = "CAISqwIKiAEIEBgCKkBHRlRBsNiptQUWfPoOhuQKwi5LnncZVO9bB5jqOs76D7uBtgktML0zqJtNmLHXHHcgD6lk4MQu4QBXzFd1lbC3Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okZDk3NDM5NzUtNGJiMC00OTM2LTllMjgtZDViMGQyMWJkYzQ4EgxCGh+XVFFFeySAjtAaDL/A1LltGu6MMJ+eXSIwsN0oBpDrqLv22UBfkMnTotnIbkvkOyb9xZHgigG6OZVHaI3gThm+maLKmgO5PrFLKlDFYp+YZksy/wKwszJlnLTPzAK+NUlfzagOE1ymtZTXhAYK260XyFYmg/te/C231+Fr/hoX+EJoUBnrn0gD7hqMISOT+TaFEuOXYsN517GfaxgB"
+
+func preservedThinkingReplayPayload(model string) []byte {
+ return []byte(`{
+ "model": "` + model + `",
+ "thinking": {"type": "adaptive"},
+ "messages": [
+ {"role":"user","content":[{"type":"text","text":"hi"}]},
+ {"role":"assistant","content":[
+ {"type":"thinking","thinking":"earlier reasoning","signature":"` + preservedThinkingTestSignature + `"},
+ {"type":"text","text":"Answer"}
+ ]},
+ {"role":"user","content":[{"type":"text","text":"continue"}]}
+ ]
+ }`)
+}
+
+func runClaudeExecutorCapture(t *testing.T, cfg *config.Config, cloakMode string, stream bool, model string, payload []byte) ([]byte, http.Header) {
+ t.Helper()
+ var seenBody []byte
+ var seenHeaders http.Header
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ seenBody = bytes.Clone(body)
+ seenHeaders = r.Header.Clone()
+ if stream {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte("data: {\"type\":\"message_stop\"}\n\n"))
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"id":"msg_1","type":"message","model":"ok","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`))
+ }))
+ defer server.Close()
+ if cfg == nil {
+ cfg = &config.Config{}
+ }
+ attrs := map[string]string{"api_key": "key-preserved-thinking", "base_url": server.URL}
+ if cloakMode != "" {
+ attrs["cloak_mode"] = cloakMode
+ }
+ executor := NewClaudeExecutor(cfg)
+ auth := &cliproxyauth.Auth{Attributes: attrs}
+ req := cliproxyexecutor.Request{Model: model, Payload: payload}
+ opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}
+ if stream {
+ result, errStream := executor.ExecuteStream(context.Background(), auth, req, opts)
+ if errStream != nil {
+ t.Fatalf("ExecuteStream() error = %v", errStream)
+ }
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("unexpected stream chunk error: %v", chunk.Err)
+ }
+ }
+ } else {
+ if _, errExecute := executor.Execute(context.Background(), auth, req, opts); errExecute != nil {
+ t.Fatalf("Execute() error = %v", errExecute)
+ }
+ }
+ if len(seenBody) == 0 {
+ t.Fatal("expected request body to be captured")
+ }
+ return seenBody, seenHeaders
+}
+
+func TestClaudeExecutor_PreservedThinkingReplayInjectsDropBlock(t *testing.T) {
+ for _, model := range []string{"claude-haiku-5-5", "claude-opus-5-5", "claude-sonnet-5-5", "claude-fable-5-1"} {
+ t.Run(model, func(t *testing.T) {
+ seenBody, seenHeaders := runClaudeExecutorCapture(t, nil, "", false, model, preservedThinkingReplayPayload(model))
+ if got := gjson.GetBytes(seenBody, "thinking.block_binding.prefix_mismatch_behavior").String(); got != "drop_block" {
+ t.Fatalf("thinking.block_binding.prefix_mismatch_behavior = %q, want drop_block; body=%s", got, seenBody)
+ }
+ if betas := seenHeaders.Get("Anthropic-Beta"); !strings.Contains(betas, claudeThinkingBindingBeta) {
+ t.Fatalf("Anthropic-Beta = %q, want %s", betas, claudeThinkingBindingBeta)
+ }
+ if !strings.Contains(string(seenBody), preservedThinkingTestSignature) {
+ t.Fatalf("replayed thinking signature must survive injection: %s", seenBody)
+ }
+ })
+ }
+}
+
+func TestClaudeExecutor_PreservedThinkingReplayInjectsDropBlockOnStream(t *testing.T) {
+ seenBody, seenHeaders := runClaudeExecutorCapture(t, nil, "", true, "claude-haiku-5-5", preservedThinkingReplayPayload("claude-haiku-5-5"))
+ if got := gjson.GetBytes(seenBody, "thinking.block_binding.prefix_mismatch_behavior").String(); got != "drop_block" {
+ t.Fatalf("stream thinking.block_binding.prefix_mismatch_behavior = %q, want drop_block; body=%s", got, seenBody)
+ }
+ if betas := seenHeaders.Get("Anthropic-Beta"); !strings.Contains(betas, claudeThinkingBindingBeta) {
+ t.Fatalf("stream Anthropic-Beta = %q, want %s", betas, claudeThinkingBindingBeta)
+ }
+}
+
+func TestClaudeExecutor_PreservedThinkingInjectionSkipsNonPreservedModel(t *testing.T) {
+ seenBody, _ := runClaudeExecutorCapture(t, nil, "", false, "claude-opus-5", preservedThinkingReplayPayload("claude-opus-5"))
+ if gjson.GetBytes(seenBody, "thinking.block_binding").Exists() {
+ t.Fatalf("non-preserved-thinking model must not gain block_binding: %s", seenBody)
+ }
+}
+
+func TestClaudeExecutor_PreservedThinkingRequiresSignedBlock(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ payload string
+ }{
+ {
+ name: "explicit adaptive thinking",
+ payload: `{"model":"claude-haiku-5-5","thinking":{"type":"adaptive"},"messages":[{"role":"user","content":"hi"}]}`,
+ },
+ {
+ name: "omitted thinking",
+ payload: `{"model":"claude-haiku-5-5","messages":[{"role":"user","content":"hi"}]}`,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ seenBody, _ := runClaudeExecutorCapture(t, nil, "", false, "claude-haiku-5-5", []byte(tc.payload))
+ if gjson.GetBytes(seenBody, "thinking.block_binding").Exists() {
+ t.Fatalf("request without replayed signed thinking must not gain block_binding: %s", seenBody)
+ }
+ })
+ }
+}
+
+func TestClaudeExecutor_PreservedThinkingOmittedThinkingSynthesizesAdaptiveBinding(t *testing.T) {
+ for _, model := range []string{"claude-haiku-5-5", "claude-opus-5-5", "claude-sonnet-5-5", "claude-fable-5-1"} {
+ for _, tc := range []struct {
+ name string
+ block string
+ }{
+ {
+ name: "signed_thinking",
+ block: `{"type":"thinking","thinking":"earlier","signature":"` + preservedThinkingTestSignature + `"}`,
+ },
+ {
+ name: "redacted_thinking",
+ block: `{"type":"redacted_thinking","data":"redacted-payload"}`,
+ },
+ } {
+ t.Run(model+"_"+tc.name, func(t *testing.T) {
+ payload := []byte(`{"model":"` + model + `","messages":[{"role":"user","content":"hi"},{"role":"assistant","content":[` + tc.block + `,{"type":"text","text":"Answer"}]},{"role":"user","content":"continue"}]}`)
+ seenBody, seenHeaders := runClaudeExecutorCapture(t, nil, "", false, model, payload)
+ if got := gjson.GetBytes(seenBody, "thinking.type").String(); got != "adaptive" {
+ t.Fatalf("thinking.type = %q, want adaptive; body=%s", got, seenBody)
+ }
+ if got := gjson.GetBytes(seenBody, "thinking.block_binding.prefix_mismatch_behavior").String(); got != "drop_block" {
+ t.Fatalf("thinking.block_binding.prefix_mismatch_behavior = %q, want drop_block; body=%s", got, seenBody)
+ }
+ if betas := seenHeaders.Get("Anthropic-Beta"); !strings.Contains(betas, claudeThinkingBindingBeta) {
+ t.Fatalf("Anthropic-Beta = %q, want %s", betas, claudeThinkingBindingBeta)
+ }
+ })
+ }
+ }
+}
+
+func TestClaudeExecutor_PreservedThinkingTypelessThinkingObjectUntouched(t *testing.T) {
+ payload := []byte(`{"model":"claude-haiku-5-5","thinking":{},"messages":[{"role":"user","content":"hi"},{"role":"assistant","content":[{"type":"thinking","thinking":"earlier","signature":"` + preservedThinkingTestSignature + `"}]},{"role":"user","content":"continue"}]}`)
+ seenBody, _ := runClaudeExecutorCapture(t, nil, "", false, "claude-haiku-5-5", payload)
+ if gjson.GetBytes(seenBody, "thinking.type").Exists() || gjson.GetBytes(seenBody, "thinking.block_binding").Exists() {
+ t.Fatalf("thinking object without type must stay untouched: %s", seenBody)
+ }
+}
+
+func TestClaudeExecutor_PreservedThinkingForcedToolChoiceKeepsThinkingOff(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ thinking string
+ toolChoice string
+ }{
+ {
+ name: "omitted thinking with tool_choice any",
+ toolChoice: `{"type":"any"}`,
+ },
+ {
+ name: "omitted thinking with tool_choice tool",
+ toolChoice: `{"type":"tool","name":"lookup"}`,
+ },
+ {
+ name: "explicit adaptive thinking stripped by tool_choice any",
+ thinking: `"thinking":{"type":"adaptive"},`,
+ toolChoice: `{"type":"any"}`,
+ },
+ {
+ name: "explicit adaptive thinking stripped by tool_choice tool",
+ thinking: `"thinking":{"type":"adaptive"},`,
+ toolChoice: `{"type":"tool","name":"lookup"}`,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ payload := []byte(`{"model":"claude-haiku-5-5",` + tc.thinking + `"tools":[{"name":"lookup","input_schema":{"type":"object"}}],"tool_choice":` + tc.toolChoice + `,"messages":[{"role":"user","content":"hi"},{"role":"assistant","content":[{"type":"thinking","thinking":"earlier","signature":"` + preservedThinkingTestSignature + `"}]},{"role":"user","content":"continue"}]}`)
+ seenBody, _ := runClaudeExecutorCapture(t, nil, "", false, "claude-haiku-5-5", payload)
+ if gjson.GetBytes(seenBody, "thinking").Exists() {
+ t.Fatalf("forced tool_choice must keep thinking off: %s", seenBody)
+ }
+ })
+ }
+}
+
+func TestClaudeExecutor_PreservedThinkingInjectionKeepsCallerError(t *testing.T) {
+ payload := []byte(`{"model":"claude-haiku-5-5","thinking":{"type":"adaptive","block_binding":{"prefix_mismatch_behavior":"error"}},"messages":[{"role":"user","content":"hi"},{"role":"assistant","content":[{"type":"thinking","thinking":"earlier","signature":"` + preservedThinkingTestSignature + `"}]},{"role":"user","content":"continue"}]}`)
+ seenBody, _ := runClaudeExecutorCapture(t, nil, "", false, "claude-haiku-5-5", payload)
+ if got := gjson.GetBytes(seenBody, "thinking.block_binding.prefix_mismatch_behavior").String(); got != "error" {
+ t.Fatalf("caller prefix_mismatch_behavior = %q, want error preserved; body=%s", got, seenBody)
+ }
+}
+
+func TestClaudeExecutor_PayloadOverrideWinsOverPreservedThinkingInjection(t *testing.T) {
+ cfg := &config.Config{Payload: config.PayloadConfig{
+ Override: []config.PayloadRule{{
+ Models: []config.PayloadModelRule{{Name: "claude-haiku-5-5"}},
+ Params: map[string]any{"thinking.block_binding.prefix_mismatch_behavior": "error"},
+ }},
+ }}
+ seenBody, _ := runClaudeExecutorCapture(t, cfg, "", false, "claude-haiku-5-5", preservedThinkingReplayPayload("claude-haiku-5-5"))
+ if got := gjson.GetBytes(seenBody, "thinking.block_binding.prefix_mismatch_behavior").String(); got != "error" {
+ t.Fatalf("payload override must be the final barrier, got %q; body=%s", got, seenBody)
+ }
+}
+
+func TestClaudeExecutor_PreservedThinkingInjectionSkipsDisabledHaikuThinking(t *testing.T) {
+ payload := []byte(`{"model":"claude-haiku-5-5","thinking":{"type":"disabled"},"messages":[{"role":"user","content":"hi"},{"role":"assistant","content":[{"type":"thinking","thinking":"earlier","signature":"` + preservedThinkingTestSignature + `"}]},{"role":"user","content":"continue"}]}`)
+ seenBody, _ := runClaudeExecutorCapture(t, nil, "", false, "claude-haiku-5-5", payload)
+ if gjson.GetBytes(seenBody, "thinking.block_binding").Exists() {
+ t.Fatalf("block_binding with disabled thinking is rejected upstream on haiku-5-5: %s", seenBody)
+ }
+}
+
+func TestClaudeExecutor_PreservedThinkingInjectionSkipsBetweenToolsSonnetThinking(t *testing.T) {
+ payload := []byte(`{"model":"claude-sonnet-5-5","thinking":{"type":"between_tools"},"messages":[{"role":"user","content":"hi"},{"role":"assistant","content":[{"type":"thinking","thinking":"earlier","signature":"` + preservedThinkingTestSignature + `"}]},{"role":"user","content":"continue"}]}`)
+ seenBody, _ := runClaudeExecutorCapture(t, nil, "", false, "claude-sonnet-5-5", payload)
+ if gjson.GetBytes(seenBody, "thinking.block_binding").Exists() {
+ t.Fatalf("block_binding with between_tools thinking is rejected upstream on sonnet-5-5: %s", seenBody)
+ }
+}
+
+func TestClaudeExecutor_PreservedThinkingInjectionSkipsMythosModel(t *testing.T) {
+ seenBody, _ := runClaudeExecutorCapture(t, nil, "", false, "claude-mythos-5-1", preservedThinkingReplayPayload("claude-mythos-5-1"))
+ if gjson.GetBytes(seenBody, "thinking.block_binding").Exists() {
+ t.Fatalf("mythos-5-1 does not run the prefix check and must not gain block_binding: %s", seenBody)
+ }
+}
+
+func TestClaudeExecutor_PayloadFilterRemovesPreservedThinkingInjection(t *testing.T) {
+ cfg := &config.Config{Payload: config.PayloadConfig{
+ Filter: []config.PayloadFilterRule{{
+ Models: []config.PayloadModelRule{{Name: "claude-haiku-5-5"}},
+ Params: []string{"thinking.block_binding"},
+ }},
+ }}
+ seenBody, _ := runClaudeExecutorCapture(t, cfg, "", false, "claude-haiku-5-5", preservedThinkingReplayPayload("claude-haiku-5-5"))
+ if gjson.GetBytes(seenBody, "thinking.block_binding").Exists() {
+ t.Fatalf("payload filter must remove injected block_binding: %s", seenBody)
+ }
+}
diff --git a/internal/runtime/executor/claude_executor_tokens.go b/internal/runtime/executor/claude_executor_tokens.go
index b0060a41dd3..29eda087a01 100644
--- a/internal/runtime/executor/claude_executor_tokens.go
+++ b/internal/runtime/executor/claude_executor_tokens.go
@@ -52,10 +52,17 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
if errThinking != nil {
return cliproxyexecutor.Response{}, errThinking
}
+ originalPayload := req.Payload
+ if len(opts.OriginalRequest) > 0 {
+ originalPayload = opts.OriginalRequest
+ }
+ webSearchSessionKey, webSearchTTL, _ := e.claudeWebSearchResultCacheScope(ctx, from, req, opts, originalPayload)
+ body = helps.RestoreClaudeWebSearchResultsContext(ctx, body, webSearchSessionKey, webSearchTTL)
if rebuildMidSystemMessageEnabled(e.cfg, auth) {
body = rebuildMidSystemMessagesToTopLevel(body)
}
body = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, body, baseModel, helps.APIKeyModelIsCompat(req))
+ body = applyClaudePreservedThinkingBinding(body)
body = helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslatedForPayload, req, opts)(body)
body = stripPromptCacheOptions(body)
if errValidate := validateClaudeTokenCountRequest(body); errValidate != nil {
@@ -168,6 +175,8 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy
if errThinking != nil {
return cliproxyexecutor.Response{}, errThinking
}
+ webSearchSessionKey, webSearchTTL, _ := e.claudeWebSearchResultCacheScope(ctx, from, req, opts, originalPayload)
+ body = helps.RestoreClaudeWebSearchResultsContext(ctx, body, webSearchSessionKey, webSearchTTL)
if rebuildMidSystemMessageEnabled(e.cfg, auth) {
body = rebuildMidSystemMessagesToTopLevel(body)
}
@@ -191,6 +200,8 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy
if len(settings.sensitiveWords) > 0 {
body = helps.ObfuscateSensitiveWords(body, helps.BuildSensitiveWordMatcher(settings.sensitiveWords))
}
+ } else if policy.Uncloak {
+ body = uncloakClaudeCodePayload(body, confirmedClaudeCode)
}
// Keep count_tokens requests compatible with Anthropic cache-control constraints too.
@@ -209,6 +220,7 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy
body, _ = prepareClaudeOAuthToolNamesForUpstream(body, mcpAliases)
}
body = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, body, baseModel, helps.APIKeyModelIsCompat(req))
+ body = applyClaudePreservedThinkingBinding(body)
body = stripPromptCacheOptions(body)
// Two different reasons converge on the same deletions, and they must stay
// separable.
diff --git a/internal/runtime/executor/claude_executor_tool_search_test.go b/internal/runtime/executor/claude_executor_tool_search_test.go
index ab7cf4edc90..d5f1037ad6e 100644
--- a/internal/runtime/executor/claude_executor_tool_search_test.go
+++ b/internal/runtime/executor/claude_executor_tool_search_test.go
@@ -3,6 +3,7 @@ package executor
import (
"bytes"
"context"
+ "strings"
"testing"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
@@ -229,16 +230,17 @@ func TestClaudeExecutor_NativeToolSearchCountTokensLocalFallback(t *testing.T) {
func TestClaudeExecutor_NativeToolSearchPayloadRuleIsFinal(t *testing.T) {
enableClaudeToolSearchForTest(t, true)
cfg := &config.Config{}
+ // tools: exec_command, tool_search, the constant deferred anchor, then the discovered calendar.
cfg.Payload.Override = []config.PayloadRule{{
Models: []config.PayloadModelRule{{Name: "*"}},
- Params: map[string]any{"tools.2.defer_loading": false},
+ Params: map[string]any{"tools.3.defer_loading": false},
}}
body := captureNativeToolSearchBody(t, cfg, sendNativeToolSearchExecute, nativeToolSearchAuth(nil), "claude-sonnet-4-5", []byte(nativeToolSearchResponsesPayload), nil)
- if got := gjson.GetBytes(body, "tools.2.name").String(); got != nativeToolSearchCalendar {
- t.Fatalf("tools.2.name = %q, want %s: %s", got, nativeToolSearchCalendar, gjson.GetBytes(body, "tools").Raw)
+ if got := gjson.GetBytes(body, "tools.3.name").String(); got != nativeToolSearchCalendar {
+ t.Fatalf("tools.3.name = %q, want %s: %s", got, nativeToolSearchCalendar, gjson.GetBytes(body, "tools").Raw)
}
- if deferred := gjson.GetBytes(body, "tools.2.defer_loading"); !deferred.Exists() || deferred.Bool() {
- t.Fatalf("payload rule did not win over native tool search: %s", gjson.GetBytes(body, "tools.2").Raw)
+ if deferred := gjson.GetBytes(body, "tools.3.defer_loading"); !deferred.Exists() || deferred.Bool() {
+ t.Fatalf("payload rule did not win over native tool search: %s", gjson.GetBytes(body, "tools.3").Raw)
}
}
@@ -298,7 +300,8 @@ func TestClaudeExecutor_NativeToolSearchOAuthAliasesRenamedReferences(t *testing
}
var deferred []string
gjson.GetBytes(body, "tools").ForEach(func(_, tool gjson.Result) bool {
- if tool.Get("defer_loading").Bool() {
+ // The constant deferred anchor is aliased too but never referenced.
+ if tool.Get("defer_loading").Bool() && !strings.HasSuffix(tool.Get("name").String(), "tool_search_deferred_anchor") {
deferred = append(deferred, tool.Get("name").String())
}
return true
diff --git a/internal/runtime/executor/claude_executor_web_search_results_test.go b/internal/runtime/executor/claude_executor_web_search_results_test.go
new file mode 100644
index 00000000000..1377b8d6aa2
--- /dev/null
+++ b/internal/runtime/executor/claude_executor_web_search_results_test.go
@@ -0,0 +1,325 @@
+package executor
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
+ "github.com/tidwall/gjson"
+)
+
+const claudeWebSearchExecutorResults = `[{"type":"web_search_result","url":"https://example.com/a","title":"A","encrypted_content":"enc-a","page_age":null},{"type":"web_search_result","url":"https://example.com/b","title":"B","encrypted_content":"enc-b","page_age":null}]`
+
+// claudeWebSearchUpstream answers the first request with a native web search
+// and every later one with plain text, recording each request body.
+func claudeWebSearchUpstream(t *testing.T, bodies *[][]byte, mu *sync.Mutex) http.Handler {
+ t.Helper()
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, errRead := io.ReadAll(r.Body)
+ if errRead != nil {
+ t.Errorf("read upstream body: %v", errRead)
+ http.Error(w, "read", http.StatusBadRequest)
+ return
+ }
+ mu.Lock()
+ *bodies = append(*bodies, body)
+ mu.Unlock()
+
+ blocks := []string{`{"type":"text","text":"done"}`}
+ if !strings.Contains(string(body), `"web_search_tool_result"`) {
+ blocks = []string{
+ `{"type":"server_tool_use","id":"srvtoolu_01abc","name":"web_search","input":{"query":"q"}}`,
+ `{"type":"web_search_tool_result","tool_use_id":"srvtoolu_01abc","content":` + claudeWebSearchExecutorResults + `}`,
+ `{"type":"text","text":"found two pages"}`,
+ }
+ }
+ if !gjson.GetBytes(body, "stream").Bool() {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"id":"msg_ws","type":"message","role":"assistant","model":"claude-opus-5-5","content":[` + strings.Join(blocks, ",") + `],"stop_reason":"end_turn","usage":{"input_tokens":5,"output_tokens":5}}`))
+ return
+ }
+ events := []string{
+ "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_ws\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-opus-5-5\",\"content\":[],\"usage\":{\"input_tokens\":5,\"output_tokens\":0}}}",
+ }
+ for i, block := range blocks {
+ index := string(rune('0' + i))
+ start := block
+ if strings.Contains(block, `"type":"text"`) {
+ start = `{"type":"text","text":""}`
+ }
+ events = append(events, "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":"+index+",\"content_block\":"+start+"}")
+ if strings.Contains(block, `"type":"text"`) {
+ events = append(events, "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":"+index+",\"delta\":{\"type\":\"text_delta\",\"text\":"+gjson.Get(block, "text").Raw+"}}")
+ }
+ events = append(events, "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":"+index+"}")
+ }
+ events = append(events,
+ "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}",
+ "event: message_stop\ndata: {\"type\":\"message_stop\"}",
+ )
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte(strings.Join(events, "\n\n") + "\n\n"))
+ })
+}
+
+type claudeWebSearchExecutorRun struct {
+ sessionID string
+ input string
+}
+
+func runClaudeWebSearchScenario(t *testing.T, cfg *config.Config, stream bool, runs []claudeWebSearchExecutorRun) [][]byte {
+ t.Helper()
+ cache.ClearClaudeWebSearchResultCache()
+ t.Cleanup(cache.ClearClaudeWebSearchResultCache)
+ var (
+ bodies [][]byte
+ mu sync.Mutex
+ )
+ server := httptest.NewServer(claudeWebSearchUpstream(t, &bodies, &mu))
+ defer server.Close()
+
+ executor := NewClaudeExecutor(cfg)
+ auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-web-search", "base_url": server.URL}}
+ streamJSON := "false"
+ if stream {
+ streamJSON = "true"
+ }
+ for _, run := range runs {
+ payload := []byte(`{"model":"claude-opus-5-5","stream":` + streamJSON + `,"input":[` + run.input + `]}`)
+ req := cliproxyexecutor.Request{Model: "claude-opus-5-5", Payload: payload}
+ opts := cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ Stream: stream,
+ OriginalRequest: payload,
+ }
+ if run.sessionID != "" {
+ opts.Metadata = map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: run.sessionID}
+ }
+ if !stream {
+ if _, errExecute := executor.Execute(context.Background(), auth, req, opts); errExecute != nil {
+ t.Fatalf("Execute() error = %v", errExecute)
+ }
+ continue
+ }
+ result, errStream := executor.ExecuteStream(context.Background(), auth, req, opts)
+ if errStream != nil {
+ t.Fatalf("ExecuteStream() error = %v", errStream)
+ }
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("chunk error: %v", chunk.Err)
+ }
+ }
+ }
+ mu.Lock()
+ defer mu.Unlock()
+ return append([][]byte(nil), bodies...)
+}
+
+const (
+ claudeWebSearchExecutorUser = `{"type":"message","role":"user","content":[{"type":"input_text","text":"search for q"}]}`
+ // The history as Codex stores it: the web_search_call has no results.
+ claudeWebSearchExecutorHistory = claudeWebSearchExecutorUser +
+ `,{"type":"web_search_call","id":"ws_srvtoolu_01abc","status":"completed","action":{"type":"search","query":"q"}},` +
+ `{"type":"message","role":"assistant","content":[{"type":"output_text","text":"found two pages"}]},` +
+ `{"type":"message","role":"user","content":[{"type":"input_text","text":"how many results?"}]}`
+)
+
+func enabledClaudeWebSearchCacheConfig() *config.Config {
+ return &config.Config{
+ SDKConfig: config.SDKConfig{
+ Client: config.ClientConfig{
+ Codex: config.CodexClientConfig{
+ WebSearchResultCache: config.CodexWebSearchResultCacheConfig{
+ Enabled: true,
+ TTL: "30m",
+ Limit: 16,
+ },
+ },
+ },
+ },
+ }
+}
+
+func TestClaudeExecutor_RestoresWebSearchResultsDroppedByResponsesClient(t *testing.T) {
+ cfg, errParse := config.ParseConfigBytes([]byte("client:\n codex:\n web-search-result-cache:\n enabled: true\n ttl: 30m\n limit: 16\n"))
+ if errParse != nil {
+ t.Fatalf("ParseConfigBytes() error = %v", errParse)
+ }
+ for _, stream := range []bool{false, true} {
+ t.Run(map[bool]string{false: "non-stream", true: "stream"}[stream], func(t *testing.T) {
+ bodies := runClaudeWebSearchScenario(t, cfg, stream, []claudeWebSearchExecutorRun{
+ {sessionID: "session-ws", input: claudeWebSearchExecutorUser},
+ {sessionID: "session-ws", input: claudeWebSearchExecutorHistory},
+ {sessionID: "other-session", input: claudeWebSearchExecutorHistory},
+ {sessionID: "", input: claudeWebSearchExecutorHistory},
+ })
+ if len(bodies) != 4 {
+ t.Fatalf("upstream requests = %d, want 4", len(bodies))
+ }
+ if got := claudeWebSearchResultContent(t, bodies[1]); got != claudeWebSearchExecutorResults {
+ t.Fatalf("same session: web_search_tool_result content = %s, want original results", got)
+ }
+ if got := claudeWebSearchResultContent(t, bodies[2]); got != "[]" {
+ t.Fatalf("other session: web_search_tool_result content = %s, want []", got)
+ }
+ if got := claudeWebSearchResultContent(t, bodies[3]); got != "[]" {
+ t.Fatalf("no session identity: web_search_tool_result content = %s, want []", got)
+ }
+ })
+ }
+}
+
+func TestClaudeExecutor_WebSearchResultCacheDisabledByDefault(t *testing.T) {
+ for _, stream := range []bool{false, true} {
+ t.Run(map[bool]string{false: "non-stream", true: "stream"}[stream], func(t *testing.T) {
+ bodies := runClaudeWebSearchScenario(t, &config.Config{}, stream, []claudeWebSearchExecutorRun{
+ {sessionID: "session-ws", input: claudeWebSearchExecutorUser},
+ {sessionID: "session-ws", input: claudeWebSearchExecutorHistory},
+ })
+ if len(bodies) != 2 {
+ t.Fatalf("upstream requests = %d, want 2", len(bodies))
+ }
+ if got := claudeWebSearchResultContent(t, bodies[1]); got != "[]" {
+ t.Fatalf("disabled cache: web_search_tool_result content = %s, want []", got)
+ }
+ })
+ }
+}
+
+// User payload rules stay the final barrier: a filter rule that removes the
+// result content must not be undone by the restore.
+func TestClaudeExecutor_WebSearchResultRestoreRunsBeforePayloadRules(t *testing.T) {
+ cfg := enabledClaudeWebSearchCacheConfig()
+ cfg.Payload = config.PayloadConfig{Filter: []config.PayloadFilterRule{{
+ Models: []config.PayloadModelRule{{Name: "claude-opus-5-5"}},
+ Params: []string{"messages.1.content.1.content"},
+ }}}
+ for _, stream := range []bool{false, true} {
+ t.Run(map[bool]string{false: "non-stream", true: "stream"}[stream], func(t *testing.T) {
+ bodies := runClaudeWebSearchScenario(t, cfg, stream, []claudeWebSearchExecutorRun{
+ {sessionID: "session-ws", input: claudeWebSearchExecutorUser},
+ {sessionID: "session-ws", input: claudeWebSearchExecutorHistory},
+ })
+ if len(bodies) != 2 {
+ t.Fatalf("upstream requests = %d, want 2", len(bodies))
+ }
+ block := gjson.GetBytes(bodies[1], "messages.1.content.1")
+ if block.Get("type").String() != "web_search_tool_result" {
+ t.Fatalf("messages.1.content.1 = %s, want the web_search_tool_result block", block.Raw)
+ }
+ if content := block.Get("content"); content.Exists() {
+ t.Fatalf("filtered content came back: %s", content.Raw)
+ }
+ })
+ }
+}
+
+func TestClaudeExecutor_WebSearchResultCacheHonorsConfiguredLimit(t *testing.T) {
+ cfg := &config.Config{
+ SDKConfig: config.SDKConfig{
+ Client: config.ClientConfig{
+ Codex: config.CodexClientConfig{
+ WebSearchResultCache: config.CodexWebSearchResultCacheConfig{
+ Enabled: true,
+ TTL: "1800",
+ Limit: 1,
+ },
+ },
+ },
+ },
+ }
+ bodies := runClaudeWebSearchScenario(t, cfg, false, []claudeWebSearchExecutorRun{
+ {sessionID: "session-1", input: claudeWebSearchExecutorUser},
+ {sessionID: "session-2", input: claudeWebSearchExecutorUser},
+ {sessionID: "session-1", input: claudeWebSearchExecutorHistory},
+ {sessionID: "session-2", input: claudeWebSearchExecutorHistory},
+ })
+ if len(bodies) != 4 {
+ t.Fatalf("upstream requests = %d, want 4", len(bodies))
+ }
+ // Limit is 1, so session-2's search result evicted session-1's entry.
+ if got := claudeWebSearchResultContent(t, bodies[2]); got != "[]" {
+ t.Fatalf("evicted session-1: web_search_tool_result content = %s, want []", got)
+ }
+ if got := claudeWebSearchResultContent(t, bodies[3]); got != claudeWebSearchExecutorResults {
+ t.Fatalf("retained session-2: web_search_tool_result content = %s, want original results", got)
+ }
+}
+
+func TestClaudeExecutor_CountTokensRestoresWebSearchResultsWhenEnabled(t *testing.T) {
+ cache.ClearClaudeWebSearchResultCache()
+ t.Cleanup(cache.ClearClaudeWebSearchResultCache)
+
+ var bodies [][]byte
+ var mu sync.Mutex
+ server := httptest.NewServer(claudeWebSearchUpstream(t, &bodies, &mu))
+ defer server.Close()
+
+ auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-web-search", "base_url": server.URL}}
+ enabledExec := NewClaudeExecutor(enabledClaudeWebSearchCacheConfig())
+ disabledExec := NewClaudeExecutor(&config.Config{})
+
+ firstPayload := []byte(`{"model":"claude-opus-5-5","stream":false,"input":[` + claudeWebSearchExecutorUser + `]}`)
+ firstReq := cliproxyexecutor.Request{Model: "claude-opus-5-5", Payload: firstPayload}
+ firstOpts := cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ OriginalRequest: firstPayload,
+ Metadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: "session-count"},
+ }
+ if _, errExec := enabledExec.Execute(context.Background(), auth, firstReq, firstOpts); errExec != nil {
+ t.Fatalf("Execute() error = %v", errExec)
+ }
+
+ historyPayload := []byte(`{"model":"claude-opus-5-5","stream":false,"input":[` + claudeWebSearchExecutorHistory + `]}`)
+ historyReq := cliproxyexecutor.Request{Model: "claude-opus-5-5", Payload: historyPayload}
+ historyOpts := cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatClaude,
+ OriginalRequest: historyPayload,
+ Metadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: "session-count"},
+ }
+ enabledCountResp, errEnabledCount := enabledExec.CountTokens(context.Background(), auth, historyReq, historyOpts)
+ if errEnabledCount != nil {
+ t.Fatalf("enabled CountTokens() error = %v", errEnabledCount)
+ }
+ disabledCountResp, errDisabledCount := disabledExec.CountTokens(context.Background(), auth, historyReq, historyOpts)
+ if errDisabledCount != nil {
+ t.Fatalf("disabled CountTokens() error = %v", errDisabledCount)
+ }
+ enabledTokens := gjson.GetBytes(enabledCountResp.Payload, "input_tokens").Int()
+ disabledTokens := gjson.GetBytes(disabledCountResp.Payload, "input_tokens").Int()
+ if enabledTokens <= disabledTokens {
+ t.Fatalf("enabled input_tokens (%d) <= disabled input_tokens (%d); want restored search results counted", enabledTokens, disabledTokens)
+ }
+}
+
+func claudeWebSearchResultContent(t *testing.T, body []byte) string {
+ t.Helper()
+ var found string
+ gjson.GetBytes(body, "messages").ForEach(func(_, message gjson.Result) bool {
+ message.Get("content").ForEach(func(_, block gjson.Result) bool {
+ if block.Get("type").String() == "web_search_tool_result" {
+ found = block.Get("content").Raw
+ return false
+ }
+ return true
+ })
+ return found == ""
+ })
+ if found == "" {
+ t.Fatalf("no web_search_tool_result block in upstream body: %s", body)
+ }
+ return found
+}
diff --git a/internal/runtime/executor/claude_fingerprint_policy.go b/internal/runtime/executor/claude_fingerprint_policy.go
index 6f41319d52e..05e8e11001e 100644
--- a/internal/runtime/executor/claude_fingerprint_policy.go
+++ b/internal/runtime/executor/claude_fingerprint_policy.go
@@ -99,6 +99,12 @@ func resolveClaudeFingerprintPolicy(cfg *config.Config, auth *cliproxyauth.Auth,
authIsOAuth := isClaudeOAuthToken(apiKey)
profile := claudeFingerprintProfileFromConfig(cfg, auth)
profileClaudeCodeCLI := authIsOAuth || profile == claudeFingerprintProfileClaudeCodeCLI
+ // header-defaults.inject-diagnostics=false opts out of the injected
+ // diagnostics field, and with it the body-derived cache-diagnosis beta.
+ injectDiagnostics := profileClaudeCodeCLI
+ if cfg != nil && cfg.ClaudeHeaderDefaults.InjectDiagnostics != nil && !*cfg.ClaudeHeaderDefaults.InjectDiagnostics {
+ injectDiagnostics = false
+ }
return claudeFingerprintPolicy{
AuthIsOAuthToken: authIsOAuth,
@@ -107,7 +113,7 @@ func resolveClaudeFingerprintPolicy(cfg *config.Config, auth *cliproxyauth.Auth,
ApplyCLIIdentity: profileClaudeCodeCLI,
SynthesizeIdentity: profileClaudeCodeCLI && !authIsOAuth,
MCPAlias: profileClaudeCodeCLI,
- InjectDiagnostics: profileClaudeCodeCLI,
+ InjectDiagnostics: injectDiagnostics,
OAuthCancellation: authIsOAuth,
}
}
diff --git a/internal/runtime/executor/claude_fingerprint_policy_test.go b/internal/runtime/executor/claude_fingerprint_policy_test.go
index c56fcc9ff2f..b071dda46bd 100644
--- a/internal/runtime/executor/claude_fingerprint_policy_test.go
+++ b/internal/runtime/executor/claude_fingerprint_policy_test.go
@@ -1243,3 +1243,294 @@ func claudeFingerprintHeaderValue(headers http.Header, name string) string {
}
return ""
}
+
+func TestClaudeExecutor_OfficialAPIKeyCloakNeverStripsClaudeCodeIdentity(t *testing.T) {
+ const sessionID = "11111111-2222-4333-8444-555555555555"
+ const userID = `{"device_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","account_uuid":"11111111-2222-4333-8444-555555555555","session_id":"11111111-2222-4333-8444-555555555555"}`
+ const billing = "x-anthropic-billing-header: cc_version=2.1.280.d7b; cc_entrypoint=cli; cch=00000; cc_turn_origin=human;"
+ incoming := http.Header{
+ "User-Agent": {"claude-cli/2.1.280 (external, cli)"},
+ "X-App": {"cli"},
+ "Anthropic-Beta": {"claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,effort-2025-11-24,cache-diagnosis-2026-04-07"},
+ "Anthropic-Dangerous-Direct-Browser-Access": {"true"},
+ "X-Claude-Code-Session-Id": {sessionID},
+ "X-Claude-Code-Request-Class": {"main"},
+ "X-Stainless-Package-Version": {"0.112.1"},
+ "X-Stainless-Runtime-Version": {"v26.3.0"},
+ "X-Stainless-Os": {"MacOS"},
+ "X-Stainless-Arch": {"arm64"},
+ }
+ payload := []byte(
+ `{"model":"claude-opus-4-6","max_tokens":64,"system":[{"type":"text","text":"` + billing + `"},{"type":"text","text":"You are Claude Code, Anthropic's official CLI for Claude.","cache_control":{"type":"ephemeral"}},{"type":"text","text":"You are an interactive CLI tool that helps users with software engineering tasks.","cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":"hello"}],"metadata":{"user_id":` + `"` + strings.ReplaceAll(userID, `"`, `\"`) + `"` + `},"diagnostics":{"previous_message_id":null},"thinking":{"type":"adaptive"}}`,
+ )
+ if detection := helps.DetectClaudeCodeRequest(incoming, payload, false); !detection.Confirmed {
+ t.Fatal("fixture must be classified as a confirmed native Claude Code request")
+ }
+
+ for _, tc := range []struct {
+ name string
+ mode string
+ customUA string
+ stream bool
+ countTokens bool
+ wantUserAgent string
+ wantBetaSubstr string
+ }{
+ {
+ name: "execute cloak never default user-agent",
+ mode: "never",
+ wantUserAgent: "CLIProxyAPI/",
+ wantBetaSubstr: "interleaved-thinking-2025-05-14",
+ },
+ {
+ name: "execute cloak never custom user-agent override",
+ mode: "never",
+ customUA: "my-api-client/1.0",
+ wantUserAgent: "my-api-client/1.0",
+ wantBetaSubstr: "interleaved-thinking-2025-05-14",
+ },
+ {
+ name: "stream cloak never custom user-agent override",
+ mode: "never",
+ customUA: "my-api-client/1.0",
+ stream: true,
+ wantUserAgent: "my-api-client/1.0",
+ wantBetaSubstr: "interleaved-thinking-2025-05-14",
+ },
+ {
+ name: "execute cloak uncloak mode",
+ mode: "uncloak",
+ customUA: "my-api-client/1.0",
+ wantUserAgent: "my-api-client/1.0",
+ wantBetaSubstr: "interleaved-thinking-2025-05-14",
+ },
+ {
+ name: "count_tokens cloak never",
+ mode: "never",
+ customUA: "my-api-client/1.0",
+ countTokens: true,
+ wantUserAgent: "my-api-client/1.0",
+ wantBetaSubstr: "token-counting-2024-11-01",
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ var seenBody []byte
+ var seenHeaders http.Header
+ transport := claudeFingerprintRoundTripperFunc(func(req *http.Request) (*http.Response, error) {
+ seenBody, _ = io.ReadAll(req.Body)
+ seenHeaders = req.Header.Clone()
+ if tc.countTokens {
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ Body: io.NopCloser(strings.NewReader(`{"input_tokens":12}`)),
+ }, nil
+ }
+ if tc.stream {
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
+ Body: io.NopCloser(strings.NewReader(
+ "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n",
+ )),
+ }, nil
+ }
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ Body: io.NopCloser(strings.NewReader(
+ `{"id":"msg_1","type":"message","model":"claude-opus-4-6","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`,
+ )),
+ }, nil
+ })
+ ctx := context.WithValue(
+ context.Background(),
+ "cliproxy.roundtripper",
+ http.RoundTripper(transport),
+ )
+
+ var customHeaders map[string]string
+ attrs := map[string]string{
+ "api_key": "sk-ant-api03-promotional-key",
+ }
+ if tc.customUA != "" {
+ customHeaders = map[string]string{"User-Agent": tc.customUA}
+ attrs["header:User-Agent"] = tc.customUA
+ }
+ cfg := &config.Config{ClaudeKey: []config.ClaudeKey{{
+ APIKey: "sk-ant-api03-promotional-key",
+ Cloak: &config.CloakConfig{Mode: tc.mode},
+ Headers: customHeaders,
+ }}}
+ auth := &cliproxyauth.Auth{
+ ID: "official-promotional-key",
+ Attributes: attrs,
+ }
+ exec := NewClaudeExecutor(cfg)
+ req := cliproxyexecutor.Request{
+ Model: "claude-opus-4-6",
+ Payload: payload,
+ }
+ opts := cliproxyexecutor.Options{
+ Stream: tc.stream,
+ SourceFormat: sdktranslator.FormatClaude,
+ OriginalRequest: payload,
+ Headers: incoming.Clone(),
+ }
+
+ if tc.countTokens {
+ if _, errCount := exec.CountTokens(ctx, auth, req, opts); errCount != nil {
+ t.Fatalf("CountTokens() error = %v", errCount)
+ }
+ } else if tc.stream {
+ res, errStream := exec.ExecuteStream(ctx, auth, req, opts)
+ if errStream != nil {
+ t.Fatalf("ExecuteStream() error = %v", errStream)
+ }
+ for chunk := range res.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("stream chunk error = %v", chunk.Err)
+ }
+ }
+ } else {
+ if _, errExec := exec.Execute(ctx, auth, req, opts); errExec != nil {
+ t.Fatalf("Execute() error = %v", errExec)
+ }
+ }
+
+ if strings.Contains(string(seenBody), "x-anthropic-billing-header:") || strings.Contains(string(seenBody), "cch=") {
+ t.Fatalf("upstream body still contains Claude Code billing header: %s", seenBody)
+ }
+ if strings.Contains(string(seenBody), claudeCodeCLIIdentity) {
+ t.Fatalf("upstream body still contains Claude Code CLI identity block: %s", seenBody)
+ }
+ if got := gjson.GetBytes(seenBody, "system.#").Int(); got != 1 {
+ t.Fatalf("system block count = %d, want 1 preserved instruction block: %s", got, seenBody)
+ }
+ if got := gjson.GetBytes(seenBody, "system.0.text").String(); got != "You are an interactive CLI tool that helps users with software engineering tasks." {
+ t.Fatalf("system.0.text = %q, want preserved caller instructions", got)
+ }
+ if gjson.GetBytes(seenBody, "metadata").Exists() {
+ t.Fatalf("metadata = %s, want Claude Code metadata stripped", gjson.GetBytes(seenBody, "metadata").Raw)
+ }
+ if gjson.GetBytes(seenBody, "diagnostics").Exists() {
+ t.Fatalf("diagnostics = %s, want Claude Code diagnostics stripped", gjson.GetBytes(seenBody, "diagnostics").Raw)
+ }
+
+ ua := claudeFingerprintHeaderValue(seenHeaders, "User-Agent")
+ if strings.HasPrefix(strings.ToLower(ua), "claude-cli/") {
+ t.Fatalf("User-Agent = %q, must not forward claude-cli user-agent", ua)
+ }
+ if !strings.HasPrefix(ua, tc.wantUserAgent) {
+ t.Fatalf("User-Agent = %q, want prefix %q", ua, tc.wantUserAgent)
+ }
+ for _, forbiddenHdr := range []string{
+ "X-App",
+ "Anthropic-Dangerous-Direct-Browser-Access",
+ "X-Claude-Code-Session-Id",
+ "X-Claude-Code-Request-Class",
+ "X-Stainless-Runtime",
+ "X-Stainless-Lang",
+ "X-Stainless-Package-Version",
+ } {
+ if got := claudeFingerprintHeaderValue(seenHeaders, forbiddenHdr); got != "" {
+ t.Fatalf("header %s = %q, want stripped", forbiddenHdr, got)
+ }
+ }
+
+ betas := claudeFingerprintHeaderValue(seenHeaders, "Anthropic-Beta")
+ for _, forbiddenBeta := range []string{
+ "claude-code-20250219",
+ "oauth-2025-04-20",
+ "redact-thinking-2026-02-12",
+ "cache-diagnosis-2026-04-07",
+ } {
+ if strings.Contains(betas, forbiddenBeta) {
+ t.Fatalf("Anthropic-Beta = %q, want %q stripped", betas, forbiddenBeta)
+ }
+ }
+ if !strings.Contains(betas, tc.wantBetaSubstr) {
+ t.Fatalf("Anthropic-Beta = %q, want capability beta %q preserved", betas, tc.wantBetaSubstr)
+ }
+ })
+ }
+}
+
+func TestClaudeExecutor_GlobalDisableClaudeCloakModePreservesConfirmedCallerIdentityWhenKeyUnconfigured(t *testing.T) {
+ const sessionID = "11111111-2222-4333-8444-555555555555"
+ const userID = `{"device_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","account_uuid":"11111111-2222-4333-8444-555555555555","session_id":"11111111-2222-4333-8444-555555555555"}`
+ const billing = "x-anthropic-billing-header: cc_version=2.1.280.d7b; cc_entrypoint=cli; cch=00000; cc_turn_origin=human;"
+ incoming := http.Header{
+ "User-Agent": {"claude-cli/2.1.280 (external, cli)"},
+ "X-App": {"cli"},
+ "Anthropic-Beta": {"claude-code-20250219,interleaved-thinking-2025-05-14"},
+ "Anthropic-Dangerous-Direct-Browser-Access": {"true"},
+ "X-Claude-Code-Session-Id": {sessionID},
+ "X-Stainless-Package-Version": {"0.112.1"},
+ "X-Stainless-Runtime-Version": {"v26.3.0"},
+ "X-Stainless-Os": {"MacOS"},
+ "X-Stainless-Arch": {"arm64"},
+ }
+ payload := []byte(
+ `{"model":"claude-opus-4-6","max_tokens":64,"system":[{"type":"text","text":"` + billing + `"},{"type":"text","text":"You are Claude Code, Anthropic's official CLI for Claude.","cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":"hello"}],"metadata":{"user_id":` + `"` + strings.ReplaceAll(userID, `"`, `\"`) + `"` + `}}`,
+ )
+
+ var seenBody []byte
+ var seenHeaders http.Header
+ transport := claudeFingerprintRoundTripperFunc(func(req *http.Request) (*http.Response, error) {
+ seenBody, _ = io.ReadAll(req.Body)
+ seenHeaders = req.Header.Clone()
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ Body: io.NopCloser(strings.NewReader(
+ `{"id":"msg_1","type":"message","model":"claude-opus-4-6","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`,
+ )),
+ }, nil
+ })
+ ctx := context.WithValue(
+ context.Background(),
+ "cliproxy.roundtripper",
+ http.RoundTripper(transport),
+ )
+
+ cfg := &config.Config{
+ DisableClaudeCloakMode: true,
+ ClaudeKey: []config.ClaudeKey{{
+ APIKey: "sk-ant-api03-global-disable-passthrough",
+ }},
+ }
+ auth := &cliproxyauth.Auth{
+ ID: "official-global-disable-passthrough",
+ Attributes: map[string]string{
+ "api_key": "sk-ant-api03-global-disable-passthrough",
+ },
+ }
+
+ if _, errExec := NewClaudeExecutor(cfg).Execute(ctx, auth, cliproxyexecutor.Request{
+ Model: "claude-opus-4-6",
+ Payload: payload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatClaude,
+ OriginalRequest: payload,
+ Headers: incoming,
+ }); errExec != nil {
+ t.Fatalf("Execute() error = %v", errExec)
+ }
+
+ if got := gjson.GetBytes(seenBody, "system.0.text").String(); got != billing {
+ t.Fatalf("system.0.text = %q, want preserved caller billing header %q", got, billing)
+ }
+ if got := gjson.GetBytes(seenBody, "system.1.text").String(); got != claudeCodeCLIIdentity {
+ t.Fatalf("system.1.text = %q, want preserved caller CLI identity", got)
+ }
+ if got := gjson.GetBytes(seenBody, "metadata.user_id").String(); got != userID {
+ t.Fatalf("metadata.user_id = %q, want preserved %q", got, userID)
+ }
+ if got := claudeFingerprintHeaderValue(seenHeaders, "X-App"); got != "cli" {
+ t.Fatalf("X-App = %q, want preserved cli", got)
+ }
+ if got := claudeFingerprintHeaderValue(seenHeaders, "Anthropic-Beta"); !strings.Contains(got, "claude-code-20250219") {
+ t.Fatalf("Anthropic-Beta = %q, want preserved claude-code-20250219", got)
+ }
+}
diff --git a/internal/runtime/executor/claude_header_overrides_issue5093_test.go b/internal/runtime/executor/claude_header_overrides_issue5093_test.go
new file mode 100644
index 00000000000..c100661cb9c
--- /dev/null
+++ b/internal/runtime/executor/claude_header_overrides_issue5093_test.go
@@ -0,0 +1,341 @@
+package executor
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/google/uuid"
+ claudeauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/claude"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
+ "github.com/tidwall/gjson"
+)
+
+func parseIssue5093Config(t *testing.T, raw string) *config.Config {
+ t.Helper()
+ cfg, errParse := config.ParseConfigBytes([]byte(raw))
+ if errParse != nil {
+ t.Fatalf("ParseConfigBytes() error = %v", errParse)
+ }
+ return cfg
+}
+
+// Issue #5093: operators need exact strip/append levers applied after the
+// rule-derived Anthropic-Beta set. Strip filters without re-sorting, and append
+// adds names at the end without duplicating names that are already present.
+func TestApplyClaudeHeaders_OperatorBetaStripAndAppend(t *testing.T) {
+ cfg := parseIssue5093Config(t, `upstream:
+ claude:
+ header-defaults:
+ anthropic-beta-strip: [" extended-cache-ttl-2025-04-11 ", "not-emitted-2099-01-01"]
+ anthropic-beta-append: ["context-1m-2025-08-07", "advisor-tool-2026-03-01", "claude-code-20250219", "context-1m-2025-08-07"]
+`)
+ body := []byte(`{"model":"claude-opus-5","messages":[{"role":"user","content":"hi"}]}`)
+ for _, stream := range []bool{false, true} {
+ baseline := newClaudeHeaderTestRequest(t, nil)
+ if err := applyClaudeHeaders(baseline, claudeOAuthAuthForBetaPolicy(), claudeRaceProbeOAuthKey, stream, nil,
+ body, nil, nil, false); err != nil {
+ t.Fatalf("baseline applyClaudeHeaders(stream=%v) error = %v", stream, err)
+ }
+ derived := baseline.Header.Get("Anthropic-Beta")
+ if !strings.Contains(derived, claudeExtendedCacheTTLBeta) ||
+ strings.Contains(derived, claudeContext1MBeta) ||
+ strings.Contains(derived, claudeAdvisorToolBeta) {
+ t.Fatalf("stream=%v: unexpected derived baseline %q", stream, derived)
+ }
+ want := withoutClaudeBeta(derived, claudeExtendedCacheTTLBeta) + "," + claudeContext1MBeta + "," + claudeAdvisorToolBeta
+
+ req := newClaudeHeaderTestRequest(t, nil)
+ if err := applyClaudeHeaders(req, claudeOAuthAuthForBetaPolicy(), claudeRaceProbeOAuthKey, stream, nil,
+ body, cfg, nil, false); err != nil {
+ t.Fatalf("applyClaudeHeaders(stream=%v) error = %v", stream, err)
+ }
+ if got := req.Header.Get("Anthropic-Beta"); got != want {
+ t.Fatalf("stream=%v: Anthropic-Beta = %q, want %q", stream, got, want)
+ }
+ }
+}
+
+// The header is assembled more than once per request; re-applying overrides must
+// not reorder names that are both stripped and appended.
+func TestApplyClaudeHeaders_OperatorBetaOverridesStableAcrossReapplication(t *testing.T) {
+ cfg := parseIssue5093Config(t, `upstream:
+ claude:
+ header-defaults:
+ anthropic-beta-strip: ["operator-a-2099-01-01"]
+ anthropic-beta-append: ["operator-a-2099-01-01", "operator-b-2099-01-01"]
+`)
+ const wantSuffix = ",operator-a-2099-01-01,operator-b-2099-01-01"
+ body := []byte(`{"model":"claude-opus-5","messages":[{"role":"user","content":"hi"}]}`)
+
+ cliReq := newClaudeHeaderTestRequest(t, nil)
+ if err := applyClaudeHeaders(cliReq, claudeOAuthAuthForBetaPolicy(), claudeRaceProbeOAuthKey, false, nil,
+ body, cfg, nil, false); err != nil {
+ t.Fatalf("CLI applyClaudeHeaders() error = %v", err)
+ }
+ if got := cliReq.Header.Get("Anthropic-Beta"); !strings.HasSuffix(got, wantSuffix) {
+ t.Fatalf("CLI Anthropic-Beta = %q, want suffix %q", got, wantSuffix)
+ }
+
+ incoming := http.Header{}
+ incoming.Set("Anthropic-Beta", claudeCodeBeta)
+ callerReq := newClaudeHeaderTestRequest(t, incoming)
+ auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-caller-owned"}}
+ if err := applyClaudeHeaders(callerReq, auth, "key-caller-owned", false, nil,
+ []byte(`{"model":"claude-opus-5"}`), cfg, incoming, false); err != nil {
+ t.Fatalf("caller-owned applyClaudeHeaders() error = %v", err)
+ }
+ if got, want := callerReq.Header.Get("Anthropic-Beta"), claudeCodeBeta+wantSuffix; got != want {
+ t.Fatalf("caller-owned Anthropic-Beta = %q, want %q", got, want)
+ }
+}
+
+// Per-credential custom headers are more specific than the global header-defaults
+// overrides: they keep the last word on custom gateways, while api.anthropic.com
+// still reclaims Anthropic-Beta and applies the overrides there.
+func TestApplyClaudeHeaders_CredentialHeadersKeepPrecedenceOverOperatorDefaults(t *testing.T) {
+ cfg := parseIssue5093Config(t, `upstream:
+ claude:
+ header-defaults:
+ anthropic-beta-strip: ["credential-beta-2099-01-01"]
+ anthropic-beta-append: ["operator-beta-2099-01-01"]
+ client-request-id: "off"
+`)
+ auth := &cliproxyauth.Auth{Attributes: map[string]string{
+ "api_key": "key-credential-headers",
+ "header:Anthropic-Beta": "credential-beta-2099-01-01",
+ "header:X-Client-Request-Id": "credential-request-id",
+ }}
+ for _, tc := range []struct {
+ name string
+ url string
+ wantBetas string
+ }{
+ {name: "custom gateway", url: "https://gateway.example.com/v1/messages", wantBetas: "credential-beta-2099-01-01"},
+ {name: "anthropic", url: "https://api.anthropic.com/v1/messages", wantBetas: "operator-beta-2099-01-01"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ req, errNew := http.NewRequest(http.MethodPost, tc.url, nil)
+ if errNew != nil {
+ t.Fatalf("http.NewRequest() error = %v", errNew)
+ }
+ if err := applyClaudeHeaders(req, auth, "key-credential-headers", false, nil,
+ []byte(`{"model":"claude-opus-5"}`), cfg, http.Header{}, false); err != nil {
+ t.Fatalf("applyClaudeHeaders() error = %v", err)
+ }
+ if got := req.Header.Get("Anthropic-Beta"); got != tc.wantBetas {
+ t.Fatalf("Anthropic-Beta = %q, want %q", got, tc.wantBetas)
+ }
+ if got := req.Header.Get("X-Client-Request-Id"); got != "credential-request-id" {
+ t.Fatalf("X-Client-Request-Id = %q, want credential header value", got)
+ }
+ })
+ }
+}
+
+// Strip also filters caller-supplied betas in caller-owned passthrough mode,
+// which is the gateway use case referenced by the issue (#4750).
+func TestApplyClaudeHeaders_OperatorBetaStripAppliesToCallerOwnedBetas(t *testing.T) {
+ cfg := parseIssue5093Config(t, `upstream:
+ claude:
+ header-defaults:
+ anthropic-beta-strip: ["fine-grained-tool-streaming-2025-05-14"]
+`)
+ incoming := http.Header{}
+ incoming.Set("Anthropic-Beta", claudeCodeBeta+",fine-grained-tool-streaming-2025-05-14")
+ auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-caller-owned"}}
+ req := newClaudeHeaderTestRequest(t, incoming)
+ if err := applyClaudeHeaders(req, auth, "key-caller-owned", false, nil,
+ []byte(`{"model":"claude-opus-5"}`), cfg, incoming, false); err != nil {
+ t.Fatalf("applyClaudeHeaders() error = %v", err)
+ }
+ if got := req.Header.Get("Anthropic-Beta"); got != claudeCodeBeta {
+ t.Fatalf("Anthropic-Beta = %q, want %q", got, claudeCodeBeta)
+ }
+}
+
+// Stripping every derived beta must remove the header instead of sending an
+// empty Anthropic-Beta value to api.anthropic.com.
+func TestApplyClaudeHeaders_OperatorBetaStripAllRemovesHeader(t *testing.T) {
+ body := []byte(`{"model":"claude-opus-5","messages":[{"role":"user","content":"hi"}]}`)
+ baseline := newClaudeHeaderTestRequest(t, nil)
+ if err := applyClaudeHeaders(baseline, claudeOAuthAuthForBetaPolicy(), claudeRaceProbeOAuthKey, false, nil,
+ body, nil, nil, false); err != nil {
+ t.Fatalf("baseline applyClaudeHeaders() error = %v", err)
+ }
+ stripAll, errMarshal := json.Marshal(strings.Split(baseline.Header.Get("Anthropic-Beta"), ","))
+ if errMarshal != nil {
+ t.Fatalf("json.Marshal() error = %v", errMarshal)
+ }
+ cfg := parseIssue5093Config(t, "upstream: {claude: {header-defaults: {anthropic-beta-strip: "+string(stripAll)+"}}}\n")
+
+ req := newClaudeHeaderTestRequest(t, nil)
+ if err := applyClaudeHeaders(req, claudeOAuthAuthForBetaPolicy(), claudeRaceProbeOAuthKey, false, nil,
+ body, cfg, nil, false); err != nil {
+ t.Fatalf("applyClaudeHeaders() error = %v", err)
+ }
+ if values, ok := req.Header["Anthropic-Beta"]; ok {
+ t.Fatalf("Anthropic-Beta = %q, want header removed", values)
+ }
+}
+
+// inject-diagnostics=false keeps the cloaked body free of the CPA-injected
+// diagnostics field, so cache-diagnosis-2026-04-07 is no longer derived from it.
+func TestClaudeExecutor_InjectDiagnosticsDisabledOmitsDiagnosticsAndCacheDiagnosis(t *testing.T) {
+ disabled := parseIssue5093Config(t, "upstream: {claude: {header-defaults: {inject-diagnostics: false}}}\n")
+ for _, tc := range []struct {
+ name string
+ cfg *config.Config
+ stream bool
+ inject bool
+ }{
+ {name: "default execute", cfg: &config.Config{}, stream: false, inject: true},
+ {name: "default stream", cfg: &config.Config{}, stream: true, inject: true},
+ {name: "disabled execute", cfg: disabled, stream: false, inject: false},
+ {name: "disabled stream", cfg: disabled, stream: true, inject: false},
+ } {
+ cfg, stream := tc.cfg, tc.stream
+ var upstreamBody []byte
+ var upstreamBetas string
+ transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
+ body, errRead := io.ReadAll(req.Body)
+ if errRead != nil {
+ t.Fatal(errRead)
+ }
+ upstreamBody = body
+ upstreamBetas = req.Header.Get("Anthropic-Beta")
+ if upstreamBetas == "" {
+ upstreamBetas = strings.Join(req.Header["anthropic-beta"], ",")
+ }
+ if stream {
+ sse := "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_5093\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-opus-5\",\"content\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\n" +
+ "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
+ return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(sse)), Request: req}, nil
+ }
+ response := `{"id":"msg_5093","type":"message","model":"claude-opus-5","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`
+ return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(response)), Request: req}, nil
+ })
+ ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", http.RoundTripper(transport))
+ testID := uuid.NewString()
+ auth := &cliproxyauth.Auth{
+ ID: "diagnostics-disabled-" + testID,
+ Attributes: map[string]string{"api_key": "sk-ant-oat-diagnostics-disabled", "cloak_mode": "always"},
+ Metadata: map[string]any{
+ "account_uuid": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+ claudeauth.ClaudeDeviceIDsMetadataKey: []string{"0000000000000000000000000000000000000000000000000000000000000000"},
+ },
+ }
+ executor := NewClaudeExecutor(cfg)
+ request := cliproxyexecutor.Request{Model: "claude-opus-5", Payload: []byte(`{"model":"claude-opus-5","messages":[{"role":"user","content":"x"}],"max_tokens":16}`)}
+ options := cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatClaude,
+ Stream: stream,
+ Metadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: "diagnostics-disabled-" + testID},
+ }
+ if stream {
+ result, errStream := executor.ExecuteStream(ctx, auth, request, options)
+ if errStream != nil {
+ t.Fatalf("ExecuteStream() error = %v", errStream)
+ }
+ for range result.Chunks {
+ }
+ } else if _, errExecute := executor.Execute(ctx, auth, request, options); errExecute != nil {
+ t.Fatalf("Execute() error = %v", errExecute)
+ }
+
+ if len(upstreamBody) == 0 {
+ t.Fatalf("%s: upstream request was not captured", tc.name)
+ }
+ if got := gjson.GetBytes(upstreamBody, "diagnostics").Exists(); got != tc.inject {
+ t.Fatalf("%s: diagnostics present = %v, want %v; body = %s", tc.name, got, tc.inject, upstreamBody)
+ }
+ if got := strings.Contains(upstreamBetas, claudeCacheDiagnosisBeta); got != tc.inject {
+ t.Fatalf("%s: Anthropic-Beta = %q, %s present = %v, want %v", tc.name, upstreamBetas, claudeCacheDiagnosisBeta, got, tc.inject)
+ }
+ }
+ if resolveClaudeFingerprintPolicy(disabled, nil, claudeRaceProbeOAuthKey).InjectDiagnostics {
+ t.Fatal("InjectDiagnostics = true, want false when inject-diagnostics is disabled")
+ }
+}
+
+// client-request-id selects whether first-party requests synthesize a fresh
+// x-client-request-id (on, the default), forward only the caller's value
+// (passthrough), or never send one (off).
+func TestApplyClaudeHeaders_ClientRequestIDModes(t *testing.T) {
+ const callerRequestID = "caller-request-id-5093"
+ withCallerID := http.Header{}
+ withCallerID.Set("X-Client-Request-Id", callerRequestID)
+ apiKeyAuth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-caller-owned"}}
+
+ tests := []struct {
+ name string
+ mode string
+ auth *cliproxyauth.Auth
+ apiKey string
+ incoming http.Header
+ check func(t *testing.T, got string, present bool)
+ }{
+ {
+ name: "default synthesizes", mode: "", auth: claudeOAuthAuthForBetaPolicy(), apiKey: claudeRaceProbeOAuthKey, incoming: withCallerID,
+ check: func(t *testing.T, got string, present bool) {
+ if _, errParse := uuid.Parse(got); !present || errParse != nil {
+ t.Fatalf("x-client-request-id = %q, want a synthesized UUID", got)
+ }
+ },
+ },
+ {
+ name: "off omits header", mode: "off", auth: claudeOAuthAuthForBetaPolicy(), apiKey: claudeRaceProbeOAuthKey, incoming: withCallerID,
+ check: func(t *testing.T, got string, present bool) {
+ if present {
+ t.Fatalf("x-client-request-id = %q, want header omitted", got)
+ }
+ },
+ },
+ {
+ name: "passthrough forwards caller value", mode: "passthrough", auth: claudeOAuthAuthForBetaPolicy(), apiKey: claudeRaceProbeOAuthKey, incoming: withCallerID,
+ check: func(t *testing.T, got string, present bool) {
+ if !present || got != callerRequestID {
+ t.Fatalf("x-client-request-id = %q, want caller value %q", got, callerRequestID)
+ }
+ },
+ },
+ {
+ name: "passthrough never synthesizes", mode: "passthrough", auth: claudeOAuthAuthForBetaPolicy(), apiKey: claudeRaceProbeOAuthKey, incoming: nil,
+ check: func(t *testing.T, got string, present bool) {
+ if present {
+ t.Fatalf("x-client-request-id = %q, want header omitted without a caller value", got)
+ }
+ },
+ },
+ {
+ name: "off removes caller-owned value", mode: "off", auth: apiKeyAuth, apiKey: "key-caller-owned", incoming: withCallerID,
+ check: func(t *testing.T, got string, present bool) {
+ if present {
+ t.Fatalf("x-client-request-id = %q, want caller-owned value removed", got)
+ }
+ },
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ var cfg *config.Config
+ if tc.mode != "" {
+ cfg = parseIssue5093Config(t, "upstream: {claude: {header-defaults: {client-request-id: "+tc.mode+"}}}\n")
+ }
+ req := newClaudeHeaderTestRequest(t, tc.incoming)
+ if err := applyClaudeHeaders(req, tc.auth, tc.apiKey, false, nil,
+ []byte(`{"model":"claude-opus-5"}`), cfg, tc.incoming, false); err != nil {
+ t.Fatalf("applyClaudeHeaders() error = %v", err)
+ }
+ values, present := req.Header["X-Client-Request-Id"]
+ got := strings.Join(values, ",")
+ tc.check(t, got, present)
+ })
+ }
+}
diff --git a/internal/runtime/executor/claude_issue_6698_test.go b/internal/runtime/executor/claude_issue_6698_test.go
new file mode 100644
index 00000000000..00de95ce50f
--- /dev/null
+++ b/internal/runtime/executor/claude_issue_6698_test.go
@@ -0,0 +1,196 @@
+package executor
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
+ "github.com/tidwall/gjson"
+)
+
+const claudeIssue6698SSE = "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_test\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-opus-5-5\",\"content\":[],\"stop_reason\":null,\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\n" +
+ "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n" +
+ "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}\n\n" +
+ "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n" +
+ "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"output_tokens\":1}}\n\n" +
+ "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
+
+func TestClaudeExecutor_Sonnet55ThinkingOffSendsBetweenTools(t *testing.T) {
+ sources := []struct {
+ name string
+ format sdktranslator.Format
+ payload string
+ }{
+ {name: "claude", format: sdktranslator.FormatClaude, payload: `{"model":"MODEL","max_tokens":64,"messages":[{"role":"user","content":"hi"}],"thinking":{"type":"disabled","display":"summarized","block_binding":{"prefix_mismatch_behavior":"drop_block"}}}`},
+ {name: "openai chat", format: sdktranslator.FormatOpenAI, payload: `{"model":"MODEL","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"none"}`},
+ {name: "openai responses", format: sdktranslator.FormatOpenAIResponse, payload: `{"model":"MODEL","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}],"reasoning":{"effort":"none"}}`},
+ }
+ for _, model := range []string{"claude-sonnet-5-5", "claude-sonnet-5-5[1m]"} {
+ for _, source := range sources {
+ for _, stream := range []bool{false, true} {
+ name := model + " " + source.name
+ if stream {
+ name += " stream"
+ }
+ t.Run(name, func(t *testing.T) {
+ var upstreamBody []byte
+ transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
+ upstreamBody, _ = io.ReadAll(req.Body)
+ contentType := "application/json"
+ body := `{"id":"msg_test","type":"message","role":"assistant","model":"claude-sonnet-5-5","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`
+ if gjson.GetBytes(upstreamBody, "stream").Bool() {
+ contentType = "text/event-stream"
+ body = claudeIssue6698SSE
+ }
+ return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{contentType}}, Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
+ })
+ ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", http.RoundTripper(transport))
+ executor := NewClaudeExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-between-tools", "cloak_mode": "always"}}
+ request := cliproxyexecutor.Request{Model: model, Payload: []byte(strings.ReplaceAll(source.payload, "MODEL", model))}
+ options := cliproxyexecutor.Options{SourceFormat: source.format, Stream: stream}
+ if stream {
+ result, err := executor.ExecuteStream(ctx, auth, request, options)
+ if err != nil {
+ t.Fatalf("ExecuteStream() error = %v", err)
+ }
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("stream chunk error = %v", chunk.Err)
+ }
+ }
+ } else if _, err := executor.Execute(ctx, auth, request, options); err != nil {
+ t.Fatalf("Execute() error = %v", err)
+ }
+
+ if got := gjson.GetBytes(upstreamBody, "thinking").Raw; got != `{"type":"between_tools"}` {
+ t.Fatalf("upstream thinking = %s, want {\"type\":\"between_tools\"}; body=%s", got, upstreamBody)
+ }
+ if got := gjson.GetBytes(upstreamBody, "output_config.effort"); got.Exists() {
+ t.Errorf("upstream output_config.effort = %s, want absent", got.Raw)
+ }
+ if got := gjson.GetBytes(upstreamBody, "context_management"); got.Exists() {
+ t.Errorf("upstream context_management = %s, want absent with between_tools", got.Raw)
+ }
+ })
+ }
+ }
+ }
+}
+
+func TestClaudeExecutor_AdaptiveOnlyThinkingOffKeepsThinkingHidden(t *testing.T) {
+ sources := []struct {
+ name string
+ format sdktranslator.Format
+ payload string
+ }{
+ {name: "claude", format: sdktranslator.FormatClaude, payload: `{"model":"MODEL","max_tokens":64,"messages":[{"role":"user","content":"hi"}],"thinking":{"type":"disabled"}}`},
+ {name: "openai chat", format: sdktranslator.FormatOpenAI, payload: `{"model":"MODEL","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"none"}`},
+ {name: "openai responses", format: sdktranslator.FormatOpenAIResponse, payload: `{"model":"MODEL","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}],"reasoning":{"effort":"none"}}`},
+ }
+ for _, model := range []string{"claude-opus-5-5", "claude-opus-5-5[1m]", "claude-fable-5", "claude-fable-5-1"} {
+ for _, source := range sources {
+ t.Run(model+" "+source.name, func(t *testing.T) {
+ var upstreamBody []byte
+ transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
+ upstreamBody, _ = io.ReadAll(req.Body)
+ contentType := "application/json"
+ body := `{"id":"msg_test","type":"message","role":"assistant","model":"claude-opus-5-5","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`
+ if gjson.GetBytes(upstreamBody, "stream").Bool() {
+ contentType = "text/event-stream"
+ body = claudeIssue6698SSE
+ }
+ return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{contentType}}, Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
+ })
+ ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", http.RoundTripper(transport))
+ executor := NewClaudeExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-disabled-fallback", "cloak_mode": "always"}}
+ payload := strings.ReplaceAll(source.payload, "MODEL", model)
+ request := cliproxyexecutor.Request{Model: model, Payload: []byte(payload)}
+ if _, err := executor.Execute(ctx, auth, request, cliproxyexecutor.Options{SourceFormat: source.format}); err != nil {
+ t.Fatalf("Execute() error = %v", err)
+ }
+
+ if got := gjson.GetBytes(upstreamBody, "thinking.type").String(); got != "adaptive" {
+ t.Fatalf("upstream thinking.type = %q, want adaptive; body=%s", got, upstreamBody)
+ }
+ if got := gjson.GetBytes(upstreamBody, "output_config.effort").String(); got != "low" {
+ t.Errorf("upstream output_config.effort = %q, want low", got)
+ }
+ if got := gjson.GetBytes(upstreamBody, "thinking.display").String(); got != "omitted" {
+ t.Errorf("upstream thinking.display = %q, want omitted; body=%s", got, upstreamBody)
+ }
+ })
+ }
+ }
+}
+
+func TestClaudeExecutor_AdaptiveOnlyGeminiBudgetAndAuto(t *testing.T) {
+ for _, model := range []string{"claude-opus-4-7", "claude-opus-4-8", "claude-fable-5", "claude-fable-5-1"} {
+ t.Run(model+" gemini budget 64000 maps to xhigh", func(t *testing.T) {
+ var upstreamBody []byte
+ transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
+ upstreamBody, _ = io.ReadAll(req.Body)
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
+ Body: io.NopCloser(strings.NewReader(claudeIssue6698SSE)),
+ Request: req,
+ }, nil
+ })
+ ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", http.RoundTripper(transport))
+ executor := NewClaudeExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-gemini-budget", "cloak_mode": "always"}}
+ payload := `{"model":"` + model + `","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":64000}}}`
+ request := cliproxyexecutor.Request{Model: model, Payload: []byte(payload)}
+ if _, err := executor.Execute(ctx, auth, request, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatGemini}); err != nil {
+ t.Fatalf("Execute() error = %v", err)
+ }
+ if got := gjson.GetBytes(upstreamBody, "thinking.type").String(); got != "adaptive" {
+ t.Fatalf("upstream thinking.type = %q, want adaptive; body=%s", got, upstreamBody)
+ }
+ if gjson.GetBytes(upstreamBody, "thinking.budget_tokens").Exists() {
+ t.Fatalf("upstream thinking.budget_tokens still present: %s", upstreamBody)
+ }
+ if got := gjson.GetBytes(upstreamBody, "output_config.effort").String(); got != "xhigh" {
+ t.Fatalf("upstream output_config.effort = %q, want xhigh; body=%s", got, upstreamBody)
+ }
+ })
+
+ t.Run(model+" gemini auto budget -1 stays plain adaptive", func(t *testing.T) {
+ var upstreamBody []byte
+ transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
+ upstreamBody, _ = io.ReadAll(req.Body)
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
+ Body: io.NopCloser(strings.NewReader(claudeIssue6698SSE)),
+ Request: req,
+ }, nil
+ })
+ ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", http.RoundTripper(transport))
+ executor := NewClaudeExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-gemini-auto", "cloak_mode": "always"}}
+ payload := `{"model":"` + model + `","contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"thinkingConfig":{"thinkingBudget":-1}}}`
+ request := cliproxyexecutor.Request{Model: model, Payload: []byte(payload)}
+ if _, err := executor.Execute(ctx, auth, request, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatGemini}); err != nil {
+ t.Fatalf("Execute() error = %v", err)
+ }
+ if got := gjson.GetBytes(upstreamBody, "thinking.type").String(); got != "adaptive" {
+ t.Fatalf("upstream thinking.type = %q, want adaptive; body=%s", got, upstreamBody)
+ }
+ if gjson.GetBytes(upstreamBody, "thinking.budget_tokens").Exists() {
+ t.Fatalf("upstream thinking.budget_tokens still present: %s", upstreamBody)
+ }
+ if got := gjson.GetBytes(upstreamBody, "output_config.effort"); got.Exists() {
+ t.Fatalf("upstream output_config.effort = %s, want absent; body=%s", got.Raw, upstreamBody)
+ }
+ })
+ }
+}
diff --git a/internal/runtime/executor/claude_request_rate_test.go b/internal/runtime/executor/claude_request_rate_test.go
new file mode 100644
index 00000000000..937dbaa7dca
--- /dev/null
+++ b/internal/runtime/executor/claude_request_rate_test.go
@@ -0,0 +1,134 @@
+package executor
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+ "testing"
+ "time"
+)
+
+// Issue #6720: an explicit per-minute rate-limit error carrying a short
+// Retry-After must not inherit a later unclassified unified reset when no
+// status header declares a quota window. Fixtures are synthetic.
+
+var claudePerMinuteMessages = []struct {
+ name string
+ resource string
+}{
+ {name: "requests", resource: "requests"},
+ {name: "input_tokens", resource: "input tokens"},
+ {name: "output_tokens", resource: "output tokens"},
+}
+
+func claudePerMinuteBody(resource string) []byte {
+ return []byte(fmt.Sprintf(
+ `{"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed the %s per minute rate limit. Please try again later."}}`,
+ resource,
+ ))
+}
+
+func classifyClaudeRateTestError(t *testing.T, fast bool, headers http.Header, body []byte) time.Duration {
+ t.Helper()
+ var err error
+ if fast {
+ err = newClaudeFastDirectResponseError(&http.Response{StatusCode: http.StatusTooManyRequests, Header: headers}, body)
+ } else {
+ err = classifyClaudeUpstreamError(http.StatusTooManyRequests, headers, body)
+ }
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ var rap retryAfterProvider
+ if !errors.As(err, &rap) || rap.RetryAfter() == nil {
+ t.Fatalf("expected non-nil RetryAfter, got %v", err)
+ }
+ return *rap.RetryAfter()
+}
+
+func TestClaudePerMinuteRateLimitHonorsShortRetryAfter_Issue6720(t *testing.T) {
+ for _, msg := range claudePerMinuteMessages {
+ for _, fast := range []bool{false, true} {
+ path := "standard"
+ if fast {
+ path = "fast_direct"
+ }
+ t.Run(msg.name+"/"+path, func(t *testing.T) {
+ headers := http.Header{}
+ headers.Set("Retry-After", "30")
+ headers.Set("Anthropic-Ratelimit-Unified-Reset", strconv.FormatInt(time.Now().Add(4*time.Hour).Unix(), 10))
+
+ got := classifyClaudeRateTestError(t, fast, headers, claudePerMinuteBody(msg.resource))
+ // 30s hint plus the existing 1-30s bounded grace; allow clock slack.
+ if got < 29*time.Second || got > 60*time.Second {
+ t.Fatalf("RetryAfter = %v, want between 29s and 60s (must not inherit the 4h unified reset)", got)
+ }
+ })
+ }
+ }
+}
+
+func TestClaudePerMinuteRateLimitKeepsConservativeQuota_Issue6720(t *testing.T) {
+ tests := []struct {
+ name string
+ body []byte
+ mutate func(h http.Header)
+ }{
+ {
+ name: "explicit unified status",
+ body: claudePerMinuteBody("requests"),
+ mutate: func(h http.Header) {
+ h.Set("Anthropic-Ratelimit-Unified-Status", "rejected")
+ },
+ },
+ {
+ name: "unknown message wording",
+ body: []byte(`{"type":"error","error":{"type":"rate_limit_error","message":"The requests per minute limit is mentioned for information only."}}`),
+ },
+ {
+ name: "mixed quota message",
+ body: []byte(`{"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed the requests per minute rate limit. Please try again later. Your weekly usage quota is exhausted."}}`),
+ },
+ {
+ name: "repeated retry-after values",
+ body: claudePerMinuteBody("requests"),
+ mutate: func(h http.Header) {
+ h["Retry-After"] = []string{"30", "14400"}
+ },
+ },
+ {
+ name: "missing retry-after",
+ body: claudePerMinuteBody("requests"),
+ mutate: func(h http.Header) {
+ h.Del("Retry-After")
+ },
+ },
+ {
+ name: "malformed body",
+ body: []byte(`{"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed the requests per minute rate limit. Please try again later."}`),
+ },
+ }
+
+ for _, tt := range tests {
+ for _, fast := range []bool{false, true} {
+ path := "standard"
+ if fast {
+ path = "fast_direct"
+ }
+ t.Run(tt.name+"/"+path, func(t *testing.T) {
+ headers := http.Header{}
+ headers.Set("Retry-After", "30")
+ headers.Set("Anthropic-Ratelimit-Unified-Reset", strconv.FormatInt(time.Now().Add(4*time.Hour).Unix(), 10))
+ if tt.mutate != nil {
+ tt.mutate(headers)
+ }
+
+ got := classifyClaudeRateTestError(t, fast, headers, tt.body)
+ if got < 4*time.Hour-2*time.Second || got > 4*time.Hour+30*time.Second {
+ t.Fatalf("RetryAfter = %v, want ~4h conservative unified cooldown", got)
+ }
+ })
+ }
+ }
+}
diff --git a/internal/runtime/executor/claude_thinking_replay_test.go b/internal/runtime/executor/claude_thinking_replay_test.go
index 553be9c2b2c..7f04adc21f6 100644
--- a/internal/runtime/executor/claude_thinking_replay_test.go
+++ b/internal/runtime/executor/claude_thinking_replay_test.go
@@ -33,17 +33,17 @@ func claudeReplayTestAuth(baseURL string) *cliproxyauth.Auth {
func claudeReplayTestRequest(payload []byte, sessionID string, isCompat bool, source sdktranslator.Format) (cliproxyexecutor.Request, cliproxyexecutor.Options) {
return cliproxyexecutor.Request{
- Model: "claude-synthetic-4772",
- Payload: payload,
- Metadata: map[string]any{
- claudeReplayResolvedModelInfoKey: ®istry.ModelInfo{IsCompat: isCompat},
- },
- }, cliproxyexecutor.Options{
- SourceFormat: source,
- Metadata: map[string]any{
- cliproxyexecutor.ExecutionSessionMetadataKey: sessionID,
- },
- }
+ Model: "claude-synthetic-4772",
+ Payload: payload,
+ Metadata: map[string]any{
+ claudeReplayResolvedModelInfoKey: ®istry.ModelInfo{IsCompat: isCompat},
+ },
+ }, cliproxyexecutor.Options{
+ SourceFormat: source,
+ Metadata: map[string]any{
+ cliproxyexecutor.ExecutionSessionMetadataKey: sessionID,
+ },
+ }
}
func TestClaudeThinkingReplayEnabledRequiresCompatClaudeAPIKey(t *testing.T) {
diff --git a/internal/runtime/executor/claude_tls_handshake_retry_test.go b/internal/runtime/executor/claude_tls_handshake_retry_test.go
new file mode 100644
index 00000000000..1f4da21b8ec
--- /dev/null
+++ b/internal/runtime/executor/claude_tls_handshake_retry_test.go
@@ -0,0 +1,262 @@
+package executor
+
+import (
+ "bufio"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
+)
+
+// silentConnectProxy reproduces the production failure in Issue #6615: it
+// accepts HTTP CONNECT, returns a successful tunnel response, drains the
+// TLS ClientHello, and then sends no TLS bytes. closed is signaled only
+// after the drain observes EOF, proving the client released that socket.
+type silentConnectProxy struct {
+ attempts atomic.Int32
+ closed chan struct{}
+ errs chan error
+
+ mu sync.Mutex
+ conns map[net.Conn]struct{}
+ stopped bool
+}
+
+func startSilentConnectProxy(t *testing.T) (*silentConnectProxy, string) {
+ t.Helper()
+ listener, errListen := net.Listen("tcp", "127.0.0.1:0")
+ if errListen != nil {
+ t.Fatalf("listen for silent CONNECT proxy: %v", errListen)
+ }
+ proxy := &silentConnectProxy{
+ closed: make(chan struct{}, 16),
+ errs: make(chan error, 16),
+ conns: make(map[net.Conn]struct{}),
+ }
+ var handlers sync.WaitGroup
+ acceptDone := make(chan struct{})
+ t.Cleanup(func() {
+ if errClose := listener.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) {
+ t.Errorf("close silent CONNECT proxy listener: %v", errClose)
+ }
+ <-acceptDone
+ // A regression could leave a handshake blocked forever. Closing
+ // tracked accepted sockets releases those goroutines so cleanup
+ // reports the original test failure instead of hanging until the
+ // whole package times out.
+ proxy.closeTrackedConnections(t)
+ handlers.Wait()
+ proxy.mu.Lock()
+ proxy.stopped = true
+ proxy.mu.Unlock()
+ close(proxy.errs)
+ for errProxy := range proxy.errs {
+ if errProxy != nil {
+ t.Errorf("silent CONNECT proxy: %v", errProxy)
+ }
+ }
+ })
+ go func() {
+ defer close(acceptDone)
+ for {
+ conn, errAccept := listener.Accept()
+ if errAccept != nil {
+ if !errors.Is(errAccept, net.ErrClosed) {
+ proxy.reportError(fmt.Errorf("accept connection: %w", errAccept))
+ }
+ return
+ }
+ if !proxy.trackConnection(conn) {
+ if errClose := conn.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) {
+ proxy.reportError(fmt.Errorf("close untracked proxy connection: %w", errClose))
+ }
+ continue
+ }
+ handlers.Add(1)
+ go func() {
+ defer handlers.Done()
+ proxy.handle(conn)
+ }()
+ }
+ }()
+ return proxy, "http://" + listener.Addr().String()
+}
+
+func (p *silentConnectProxy) trackConnection(conn net.Conn) bool {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if p.stopped {
+ return false
+ }
+ p.conns[conn] = struct{}{}
+ return true
+}
+
+func (p *silentConnectProxy) untrackConnection(conn net.Conn) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ delete(p.conns, conn)
+}
+
+func (p *silentConnectProxy) closeTrackedConnections(t *testing.T) {
+ t.Helper()
+ p.mu.Lock()
+ conns := make([]net.Conn, 0, len(p.conns))
+ for conn := range p.conns {
+ conns = append(conns, conn)
+ }
+ p.mu.Unlock()
+ for _, conn := range conns {
+ if errClose := conn.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) {
+ t.Errorf("close tracked silent CONNECT proxy connection during cleanup: %v", errClose)
+ }
+ }
+}
+
+func (p *silentConnectProxy) reportError(err error) {
+ // Hold mu through the non-blocking send so cleanup cannot close errs
+ // between the stopped check and a late handler report.
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if p.stopped {
+ return
+ }
+ select {
+ case p.errs <- err:
+ default:
+ }
+}
+
+func (p *silentConnectProxy) handle(conn net.Conn) {
+ defer func() {
+ p.untrackConnection(conn)
+ if errClose := conn.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) {
+ p.reportError(fmt.Errorf("close proxy connection: %w", errClose))
+ }
+ }()
+ reader := bufio.NewReader(conn)
+ request, errRead := http.ReadRequest(reader)
+ if errRead != nil {
+ p.reportError(fmt.Errorf("read CONNECT request: %w", errRead))
+ return
+ }
+ if request.Method != http.MethodConnect {
+ p.reportError(fmt.Errorf("proxy method = %q, want CONNECT", request.Method))
+ return
+ }
+ if _, errWrite := io.WriteString(conn, "HTTP/1.1 200 Connection established\r\n\r\n"); errWrite != nil {
+ p.reportError(fmt.Errorf("write CONNECT response: %w", errWrite))
+ return
+ }
+ header := make([]byte, 5)
+ if _, errRead := io.ReadFull(reader, header); errRead != nil {
+ p.reportError(fmt.Errorf("read ClientHello header: %w", errRead))
+ return
+ }
+ if header[0] != 22 {
+ p.reportError(fmt.Errorf("first TLS record type = %d, want handshake (22)", header[0]))
+ return
+ }
+ recordLength := int(header[3])<<8 | int(header[4])
+ if _, errRead := io.ReadFull(reader, make([]byte, recordLength)); errRead != nil {
+ p.reportError(fmt.Errorf("read ClientHello body: %w", errRead))
+ return
+ }
+ p.attempts.Add(1)
+ if _, errCopy := io.Copy(io.Discard, reader); errCopy != nil && !errors.Is(errCopy, net.ErrClosed) {
+ p.reportError(fmt.Errorf("drain silent TLS connection: %w", errCopy))
+ return
+ }
+ p.closed <- struct{}{}
+}
+
+// TestClaudeExecutorManagerRetriesSilentTLSHandshakeThroughConnectProxy is
+// the end-to-end Issue #6615 regression: a real CONNECT proxy stays silent
+// after ClientHello, the real Claude executor and shared uTLS client fail
+// each handshake at the configured one-second bound, and Manager performs
+// the initial attempt plus all three request-retry rounds on one credential.
+func TestClaudeExecutorManagerRetriesSilentTLSHandshakeThroughConnectProxy(t *testing.T) {
+ proxy, proxyURL := startSilentConnectProxy(t)
+
+ cfg := &config.Config{}
+ cfg.ProxyURL = proxyURL
+ cfg.TLSHandshakeTimeoutSeconds = 1
+
+ manager := cliproxyauth.NewManager(nil, nil, nil)
+ manager.SetRetryConfig(3, 0, 0)
+ manager.RegisterExecutor(NewClaudeExecutor(cfg))
+
+ authID := uuid.NewString() + "-silent-tls-handshake"
+ model := "claude-sonnet-4-6"
+ auth := &cliproxyauth.Auth{
+ ID: authID,
+ Provider: "claude",
+ Attributes: map[string]string{
+ "api_key": "sk-ant-api03-silent-handshake-test",
+ "base_url": "https://api.anthropic.com",
+ },
+ }
+ reg := registry.GetGlobalRegistry()
+ reg.RegisterClient(authID, "claude", []*registry.ModelInfo{{ID: model}})
+ t.Cleanup(func() { reg.UnregisterClient(authID) })
+ if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
+ t.Fatalf("register auth: %v", errRegister)
+ }
+
+ payload := []byte(`{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"hello"}],"max_tokens":8}`)
+ executeDone := make(chan error, 1)
+ go func() {
+ _, errExecute := manager.Execute(context.Background(), []string{"claude"}, cliproxyexecutor.Request{
+ Model: model,
+ Payload: payload,
+ }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude})
+ executeDone <- errExecute
+ }()
+
+ var errExecute error
+ select {
+ case errExecute = <-executeDone:
+ case <-time.After(15 * time.Second):
+ t.Fatal("Manager.Execute did not finish after four bounded TLS handshakes")
+ }
+ if errExecute == nil {
+ t.Fatal("Execute() error = nil, want final configured TLS handshake timeout")
+ }
+ if !cliproxyexecutor.IsConfiguredTLSHandshakeTimeoutError(errExecute) {
+ t.Fatalf("Execute() error = %v, want typed configured TLS handshake timeout", errExecute)
+ }
+ if attempts := proxy.attempts.Load(); attempts != 4 {
+ t.Fatalf("silent proxy handshake attempts = %d, want 4", attempts)
+ }
+ for attempt := 1; attempt <= 4; attempt++ {
+ select {
+ case <-proxy.closed:
+ case <-time.After(5 * time.Second):
+ t.Fatalf("silent proxy did not observe failed socket %d being closed", attempt)
+ }
+ }
+
+ updatedAuth, ok := manager.GetByID(authID)
+ if !ok || updatedAuth == nil {
+ t.Fatal("registered auth not found after handshake retries")
+ }
+ if updatedAuth.Unavailable || !updatedAuth.NextRetryAfter.IsZero() {
+ t.Fatalf("configured handshake timeout cooled the credential: unavailable=%t next_retry_after=%v", updatedAuth.Unavailable, updatedAuth.NextRetryAfter)
+ }
+ if state := updatedAuth.ModelStates[model]; state != nil && (state.Unavailable || !state.NextRetryAfter.IsZero()) {
+ t.Fatalf("configured handshake timeout cooled the model: %#v", state)
+ }
+}
diff --git a/internal/runtime/executor/codex_executor_auth.go b/internal/runtime/executor/codex_executor_auth.go
index 930ab968fc3..687c57217bc 100644
--- a/internal/runtime/executor/codex_executor_auth.go
+++ b/internal/runtime/executor/codex_executor_auth.go
@@ -253,7 +253,7 @@ func (e *CodexExecutor) refreshLocked(ctx context.Context, auth *cliproxyauth.Au
needOuter = false
} else if homeEnabled && !hasOuter {
hasOuter = true
- if outerExp, okExp := auth.OuterExpirationTime(); okExp && !outerExp.IsZero() && !outerExp.After(now.Add(24*time.Hour)) {
+ if outerExp, okExp := auth.OuterExpirationTime(); okExp && !outerExp.IsZero() && !outerExp.After(now.Add(cliproxyauth.CodexOuterRefreshLead)) {
needOuter = true
}
}
diff --git a/internal/runtime/executor/codex_executor_compact_test.go b/internal/runtime/executor/codex_executor_compact_test.go
index f5bb2364777..c374ed98f19 100644
--- a/internal/runtime/executor/codex_executor_compact_test.go
+++ b/internal/runtime/executor/codex_executor_compact_test.go
@@ -152,3 +152,284 @@ func TestCodexExecutorCompactUseSIWCAppliesSIWCRulesBeforePayloadFinalizer(t *te
t.Fatalf("PayloadFinalizer temperature override = %v, want 0.25; body=%s", gotTemp, gotBody)
}
}
+
+func TestCodexExecutorReplayedWebSearchCallHistoryEnsuresWebSearchTool_Issue6770(t *testing.T) {
+ hasWebSearchCallInInput := func(body []byte) bool {
+ for _, item := range gjson.GetBytes(body, "input").Array() {
+ if item.Get("type").String() == "web_search_call" {
+ return true
+ }
+ }
+ return false
+ }
+ countWebSearchTools := func(body []byte) int {
+ count := 0
+ for _, tool := range gjson.GetBytes(body, "tools").Array() {
+ if tool.Get("type").String() == "web_search" {
+ count++
+ }
+ }
+ return count
+ }
+
+ compactionPayloadWithEmptyTools := `{"model":"gpt-6.1-sol","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"search docs"}]},{"id":"ws_1","type":"web_search_call","status":"completed","action":{"type":"search","query":"openai responses web search"}},{"id":"ws_2","type":"web_search_call","status":"completed","action":{"type":"open_page","url":"https://platform.openai.com/docs"}},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Found docs."}]},{"type":"message","role":"user","content":[{"type":"input_text","text":"Summarize conversation"}]}],"tools":[],"tool_choice":"auto"}`
+
+ t.Run("ExecuteStream with empty tools injects web_search and avoids 502 response protection error", func(t *testing.T) {
+ var gotBody []byte
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ gotBody = body
+ w.Header().Set("Content-Type", "text/event-stream")
+ if hasWebSearchCallInInput(body) && countWebSearchTools(body) == 0 {
+ _, _ = w.Write([]byte("data: {\"type\":\"error\",\"error\":{\"message\":\"response protection is unavailable\",\"type\":\"internal_error\",\"param\":null,\"code\":null}}\n\n"))
+ return
+ }
+ _, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"status\":\"completed\",\"output\":[{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"summary\"}]}],\"usage\":{\"input_tokens\":10,\"output_tokens\":5,\"total_tokens\":15}}}\n\n"))
+ }))
+ defer server.Close()
+
+ executor := NewCodexExecutor(&config.Config{Codex: config.CodexConfig{StreamBootstrapBuffering: true}})
+ auth := &cliproxyauth.Auth{
+ Provider: "codex",
+ Attributes: map[string]string{
+ "base_url": server.URL,
+ "api_key": "test",
+ "plan_type": "plus",
+ },
+ }
+
+ streamResult, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "gpt-6.1-sol",
+ Payload: []byte(compactionPayloadWithEmptyTools),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("openai-response"),
+ Stream: true,
+ })
+ if err != nil {
+ t.Fatalf("ExecuteStream() error = %v", err)
+ }
+ for chunk := range streamResult.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("stream chunk error = %v", chunk.Err)
+ }
+ }
+ if got := countWebSearchTools(gotBody); got != 1 {
+ t.Fatalf("web_search tool count = %d, want 1; body=%s", got, gotBody)
+ }
+ })
+
+ t.Run("Execute non-stream with image generation disabled still injects web_search", func(t *testing.T) {
+ var gotBody []byte
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ gotBody = body
+ w.Header().Set("Content-Type", "text/event-stream")
+ if hasWebSearchCallInInput(body) && countWebSearchTools(body) == 0 {
+ _, _ = w.Write([]byte("data: {\"type\":\"error\",\"error\":{\"message\":\"response protection is unavailable\",\"type\":\"internal_error\",\"param\":null,\"code\":null}}\n\n"))
+ return
+ }
+ _, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":10,\"output_tokens\":5,\"total_tokens\":15}}}\n\n"))
+ }))
+ defer server.Close()
+
+ executor := NewCodexExecutor(&config.Config{SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll}})
+ auth := &cliproxyauth.Auth{
+ Provider: "codex",
+ Attributes: map[string]string{
+ "base_url": server.URL,
+ "api_key": "test",
+ },
+ }
+
+ _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "gpt-6.1-sol",
+ Payload: []byte(compactionPayloadWithEmptyTools),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("openai-response"),
+ })
+ if err != nil {
+ t.Fatalf("Execute() error = %v", err)
+ }
+ if got := countWebSearchTools(gotBody); got != 1 {
+ t.Fatalf("web_search tool count = %d, want 1; body=%s", got, gotBody)
+ }
+ if got := len(gjson.GetBytes(gotBody, "tools").Array()); got != 1 {
+ t.Fatalf("tools len = %d, want 1; body=%s", got, gotBody)
+ }
+ })
+
+ t.Run("Execute compact endpoint injects web_search when input replays web_search_call", func(t *testing.T) {
+ var gotBody []byte
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ gotBody = body
+ if hasWebSearchCallInInput(body) && countWebSearchTools(body) == 0 {
+ w.WriteHeader(http.StatusBadGateway)
+ _, _ = w.Write([]byte(`{"error":{"message":"response protection is unavailable","type":"internal_error","param":null,"code":null}}`))
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"id":"resp_compact_1","object":"response.compaction","usage":{"input_tokens":10,"output_tokens":2,"total_tokens":12}}`))
+ }))
+ defer server.Close()
+
+ executor := NewCodexExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{
+ Provider: "codex",
+ Attributes: map[string]string{
+ "base_url": server.URL,
+ "api_key": "test",
+ },
+ }
+
+ compactPayload := `{"model":"gpt-6.1-sol","input":[{"type":"message","role":"user","content":"history"},{"id":"ws_1","type":"web_search_call","status":"completed","action":{"type":"search","query":"q"}},{"type":"compaction_trigger"}]}`
+ _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "gpt-6.1-sol",
+ Payload: []byte(compactPayload),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("openai-response"),
+ Alt: "responses/compact",
+ })
+ if err != nil {
+ t.Fatalf("Execute compact error = %v", err)
+ }
+ if got := countWebSearchTools(gotBody); got != 1 {
+ t.Fatalf("compact web_search tool count = %d, want 1; body=%s", got, gotBody)
+ }
+ })
+
+ t.Run("prepareCodexWebsocketStream injects web_search when input replays web_search_call", func(t *testing.T) {
+ wsExecutor := NewCodexWebsocketsExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{
+ Provider: "codex",
+ Attributes: map[string]string{
+ "base_url": "http://127.0.0.1:1",
+ "api_key": "test",
+ "plan_type": "plus",
+ },
+ }
+ prepared, err := wsExecutor.prepareCodexWebsocketStream(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "gpt-6.1-sol",
+ Payload: []byte(compactionPayloadWithEmptyTools),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("openai-response"),
+ Stream: true,
+ })
+ if err != nil {
+ t.Fatalf("prepareCodexWebsocketStream() error = %v", err)
+ }
+ if got := countWebSearchTools(prepared.clientBody); got != 1 {
+ t.Fatalf("websocket clientBody web_search tool count = %d, want 1; body=%s", got, prepared.clientBody)
+ }
+ })
+
+ t.Run("existing web_search tool declaration is preserved without duplication", func(t *testing.T) {
+ payloadWithWebSearch := `{"model":"gpt-6.1-sol","input":[{"id":"ws_1","type":"web_search_call","status":"completed","action":{"type":"search","query":"q"}}],"tools":[{"type":"web_search","external_web_access":true}]}`
+ wsExecutor := NewCodexWebsocketsExecutor(&config.Config{SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll}})
+ auth := &cliproxyauth.Auth{
+ Provider: "codex",
+ Attributes: map[string]string{
+ "base_url": "http://127.0.0.1:1",
+ "api_key": "test",
+ },
+ }
+ prepared, err := wsExecutor.prepareCodexWebsocketStream(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "gpt-6.1-sol",
+ Payload: []byte(payloadWithWebSearch),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("openai-response"),
+ Stream: true,
+ })
+ if err != nil {
+ t.Fatalf("prepareCodexWebsocketStream() error = %v", err)
+ }
+ if got := countWebSearchTools(prepared.clientBody); got != 1 {
+ t.Fatalf("web_search tool count = %d, want 1; body=%s", got, prepared.clientBody)
+ }
+ if !gjson.GetBytes(prepared.clientBody, "tools.0.external_web_access").Bool() {
+ t.Fatalf("existing web_search options not preserved: %s", prepared.clientBody)
+ }
+ })
+
+ t.Run("Responses Lite request strips replayed web_search_call items without injecting top-level tools", func(t *testing.T) {
+ litePayload := `{"model":"gpt-6.1-sol","input":[{"type":"additional_tools","role":"developer","tools":[{"type":"namespace","name":"functions","description":"","tools":[{"type":"custom","name":"exec"}]}]},{"type":"message","role":"user","content":[{"type":"input_text","text":"search"}]},{"id":"ws_1","type":"web_search_call","status":"completed","action":{"type":"search","query":"q"}},{"id":"rs_1","type":"reasoning","summary":[]},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]},{"type":"compaction_trigger"}],"client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"}}`
+ wsExecutor := NewCodexWebsocketsExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{
+ Provider: "codex",
+ Attributes: map[string]string{
+ "base_url": "http://127.0.0.1:1",
+ "api_key": "test",
+ },
+ }
+ headers := make(http.Header)
+ headers.Set(codexResponsesLiteHeader, "true")
+
+ prepared, err := wsExecutor.prepareCodexWebsocketStream(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "gpt-6.1-sol",
+ Payload: []byte(litePayload),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("openai-response"),
+ Headers: headers,
+ Stream: true,
+ })
+ if err != nil {
+ t.Fatalf("prepareCodexWebsocketStream() error = %v", err)
+ }
+ if gjson.GetBytes(prepared.clientBody, "tools").Exists() {
+ t.Fatalf("Responses Lite request must not inject top-level tools: %s", prepared.clientBody)
+ }
+ if hasWebSearchCallInInput(prepared.clientBody) {
+ t.Fatalf("Responses Lite request must strip replayed web_search_call items: %s", prepared.clientBody)
+ }
+ inputItems := gjson.GetBytes(prepared.clientBody, "input").Array()
+ if len(inputItems) != 5 {
+ t.Fatalf("input len = %d, want 5; body=%s", len(inputItems), prepared.clientBody)
+ }
+ })
+
+ t.Run("PayloadFinalizer runs after ensureWebSearchToolForHistory", func(t *testing.T) {
+ var gotBody []byte
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ gotBody = body
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":10,\"output_tokens\":5,\"total_tokens\":15}}}\n\n"))
+ }))
+ defer server.Close()
+
+ cfg := &config.Config{
+ SDKConfig: config.SDKConfig{
+ DisableImageGeneration: config.DisableImageGenerationAll,
+ },
+ Payload: config.PayloadConfig{
+ Filter: []config.PayloadFilterRule{
+ {
+ Models: []config.PayloadModelRule{{Name: "gpt-6.1-sol", Protocol: "codex"}},
+ Params: []string{"tools"},
+ },
+ },
+ },
+ }
+ executor := NewCodexExecutor(cfg)
+ auth := &cliproxyauth.Auth{
+ Provider: "codex",
+ Attributes: map[string]string{
+ "base_url": server.URL,
+ "api_key": "test",
+ },
+ }
+
+ _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "gpt-6.1-sol",
+ Payload: []byte(compactionPayloadWithEmptyTools),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("openai-response"),
+ })
+ if err != nil {
+ t.Fatalf("Execute() error = %v", err)
+ }
+ if gjson.GetBytes(gotBody, "tools").Exists() {
+ t.Fatalf("expected user payload filter rule to remove tools after built-in injection, got body=%s", gotBody)
+ }
+ })
+}
diff --git a/internal/runtime/executor/codex_executor_execute.go b/internal/runtime/executor/codex_executor_execute.go
index b738890c3f7..635fafed33e 100644
--- a/internal/runtime/executor/codex_executor_execute.go
+++ b/internal/runtime/executor/codex_executor_execute.go
@@ -48,10 +48,13 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
}
originalPayload := originalPayloadSource
isCompat := e.resolveCodexModelIsCompat(auth, req, baseModel)
- originalTranslated, body, updatesChanged, err := translateCodexRequestPairWithUpdateIntent(from, to, baseModel, originalPayload, req.Payload, false, isCompat)
+ targetModel := resolveResponsesSignatureTargetModel(baseModel, req, opts)
+ originalTranslated, body, updatesChanged, err := translateCodexRequestPairWithUpdateIntent(from, to, targetModel, originalPayload, req.Payload, false, isCompat)
if err != nil {
return resp, err
}
+ originalTranslated = helps.SetStringIfDifferent(originalTranslated, "model", baseModel)
+ body = helps.SetStringIfDifferent(body, "model", baseModel)
body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged)
if err != nil {
@@ -68,15 +71,15 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
body, _ = sjson.DeleteBytes(body, "stream_options")
useSIWC := codexUseSIWC(auth)
hasExplicitInstructions := gjson.GetBytes(req.Payload, "instructions").Exists() && strings.TrimSpace(gjson.GetBytes(req.Payload, "instructions").String()) != ""
+ body = ensureWebSearchToolForHistory(body, opts.Headers)
if !useSIWC {
body = normalizeCodexInstructions(body, helps.IsNativeCodexRequest(req.Payload, opts))
if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff {
body = ensureImageGenerationTool(body, baseModel, auth, opts.Headers)
}
} else {
- body = applyCodexSIWCRequestBodyRules(body, hasExplicitInstructions)
+ body = applyCodexSIWCRequestBodyRules(body, hasExplicitInstructions, cliproxyexecutor.Options{KeepTopLevelEffort: thinking.HasThinkingSuffix(req.Model)})
}
- body = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompat(ctx, "codex executor", body, isCompat)
body = normalizeCodexParallelToolCalls(body, opts.Headers)
body = helps.NormalizeCodexToolSchemas(body)
body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuth(ctx, opts.Headers, body, e.cfg, auth, isCompat)
@@ -84,6 +87,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
if errReplay != nil {
return resp, errReplay
}
+ body = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(ctx, "codex executor", body, isCompat, targetModel, baseURL)
url := strings.TrimSuffix(baseURL, "/") + "/responses"
httpReq, upstreamBody, err := e.cacheHelper(ctx, from, url, req, body, opts.Headers)
@@ -137,11 +141,12 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
data, errRead := io.ReadAll(httpResp.Body)
helps.AppendAPIResponseChunk(ctx, e.cfg, data)
- lines := bytes.Split(data, []byte("\n"))
+ events := helps.NewCodexSSEScanner(ctx, bytes.NewReader(data), len(data)+1)
outputItemsByIndex := make(map[int64][]byte)
var outputItemsFallback [][]byte
sawOutputDelta := false
- for _, line := range lines {
+ for events.Scan() {
+ line := events.Bytes()
if !bytes.HasPrefix(line, dataTag) {
continue
}
@@ -241,10 +246,12 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A
}
originalPayload := originalPayloadSource
isCompat := e.resolveCodexModelIsCompat(auth, req, baseModel)
- originalTranslated, body, updatesChanged, err := translateCodexRequestPairWithUpdateIntent(from, to, baseModel, originalPayload, req.Payload, false, isCompat)
+ targetModel := resolveResponsesSignatureTargetModel(baseModel, req, opts)
+ originalTranslated, body, updatesChanged, err := translateCodexRequestPairWithUpdateIntent(from, to, targetModel, originalPayload, req.Payload, false, isCompat)
if err != nil {
return resp, err
}
+ originalTranslated = helps.SetStringIfDifferent(originalTranslated, "model", baseModel)
body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged)
if err != nil {
@@ -258,9 +265,10 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A
body = normalizeCodexInstructions(body, helps.IsNativeCodexRequest(req.Payload, opts))
} else {
hasExplicitInstructions := gjson.GetBytes(req.Payload, "instructions").Exists() && strings.TrimSpace(gjson.GetBytes(req.Payload, "instructions").String()) != ""
- body = applyCodexSIWCCompactBodyRules(body, hasExplicitInstructions)
+ body = applyCodexSIWCCompactBodyRules(body, hasExplicitInstructions, cliproxyexecutor.Options{KeepTopLevelEffort: thinking.HasThinkingSuffix(req.Model)})
}
- body = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompat(ctx, "codex executor", body, isCompat)
+ body = ensureWebSearchToolForHistory(body, opts.Headers)
+ body = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(ctx, "codex executor", body, isCompat, resolveResponsesSignatureTargetModel(baseModel, req, opts), baseURL)
body = normalizeCodexParallelToolCalls(body, opts.Headers)
body = helps.NormalizeCodexToolSchemas(body)
body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuth(ctx, opts.Headers, body, e.cfg, auth, isCompat)
diff --git a/internal/runtime/executor/codex_executor_reasoning.go b/internal/runtime/executor/codex_executor_reasoning.go
index 7bf5f67e1a1..825b36e9b14 100644
--- a/internal/runtime/executor/codex_executor_reasoning.go
+++ b/internal/runtime/executor/codex_executor_reasoning.go
@@ -199,7 +199,8 @@ func codexInputHasValidReasoningEncryptedContent(body []byte) bool {
if encryptedContent.Type != gjson.String {
continue
}
- if _, err := signature.InspectGPTReasoningSignature(encryptedContent.String()); err == nil {
+ rawSig := signature.SignaturePayloadWithoutProviderPrefix(encryptedContent.String())
+ if signature.IsValidGPTReasoningSignature(rawSig) || signature.IsValidMetaReasoningSignature(rawSig) || signature.IsValidGrokEncryptedContent(rawSig) {
return true
}
}
@@ -361,6 +362,20 @@ func codexReasoningReplayTurnAnchorIndex(inputItems []gjson.Result, turn codexRe
return 0, false
}
+func canonicalCodexReasoningReplaySignatureKey(rawSignature string) string {
+ sig := signature.SignaturePayloadWithoutProviderPrefix(strings.TrimSpace(rawSignature))
+ if gptInfo, err := signature.InspectGPTReasoningSignature(sig); err == nil && gptInfo.RouterSuffix != "" {
+ return stripRouterSuffix(sig, gptInfo.RouterSuffix)
+ }
+ if metaInfo, err := signature.InspectMetaReasoningSignature(sig); err == nil && metaInfo.RouterSuffix != "" {
+ return stripRouterSuffix(sig, metaInfo.RouterSuffix)
+ }
+ if grokInfo, err := signature.InspectGrokEncryptedContent(sig); err == nil && grokInfo.RouterSuffix != "" {
+ return stripRouterSuffix(sig, grokInfo.RouterSuffix)
+ }
+ return sig
+}
+
func filterCodexReasoningReplayTurnItems(inputItems []gjson.Result, items [][]byte) [][]byte {
existingReasoning := make(map[string]bool)
existingCalls := make(map[string]bool)
@@ -369,7 +384,7 @@ func filterCodexReasoningReplayTurnItems(inputItems []gjson.Result, items [][]by
itemType := strings.TrimSpace(inputItem.Get("type").String())
switch itemType {
case "reasoning":
- if encryptedContent := strings.TrimSpace(inputItem.Get("encrypted_content").String()); encryptedContent != "" {
+ if encryptedContent := canonicalCodexReasoningReplaySignatureKey(inputItem.Get("encrypted_content").String()); encryptedContent != "" {
existingReasoning[encryptedContent] = true
}
case "function_call_output", "custom_tool_call_output":
@@ -387,7 +402,7 @@ func filterCodexReasoningReplayTurnItems(inputItems []gjson.Result, items [][]by
itemResult := gjson.ParseBytes(item)
switch strings.TrimSpace(itemResult.Get("type").String()) {
case "reasoning":
- if existingReasoning[strings.TrimSpace(itemResult.Get("encrypted_content").String())] {
+ if existingReasoning[canonicalCodexReasoningReplaySignatureKey(itemResult.Get("encrypted_content").String())] {
continue
}
case "function_call", "custom_tool_call":
diff --git a/internal/runtime/executor/codex_executor_request.go b/internal/runtime/executor/codex_executor_request.go
index 43dd89ba6c2..0b5576af49f 100644
--- a/internal/runtime/executor/codex_executor_request.go
+++ b/internal/runtime/executor/codex_executor_request.go
@@ -200,26 +200,32 @@ func codexSIWCUserAgent(cfg *config.Config) string {
return fmt.Sprintf("%s (%s; %s)", agentName, runtime.GOOS, runtime.GOARCH)
}
-func applyCodexSIWCRequestBodyRules(body []byte, hasExplicitInstructions bool) []byte {
+func applyCodexSIWCRequestBodyRules(body []byte, hasExplicitInstructions bool, opts ...cliproxyexecutor.Options) []byte {
body = helps.SetBoolIfDifferent(body, "store", false)
body = helps.SetBoolIfDifferent(body, "stream", true)
- return stripCodexSIWCUnsupportedFields(body, hasExplicitInstructions)
+ return stripCodexSIWCUnsupportedFields(body, hasExplicitInstructions, opts...)
}
-func applyCodexSIWCWebsocketBodyRules(body []byte, hasExplicitInstructions bool) []byte {
+func applyCodexSIWCWebsocketBodyRules(body []byte, hasExplicitInstructions bool, opts ...cliproxyexecutor.Options) []byte {
body = helps.SetBoolIfDifferent(body, "store", false)
body, _ = sjson.DeleteBytes(body, "stream")
body, _ = sjson.DeleteBytes(body, "background")
- return stripCodexSIWCUnsupportedFields(body, hasExplicitInstructions)
+ return stripCodexSIWCUnsupportedFields(body, hasExplicitInstructions, opts...)
}
-func applyCodexSIWCCompactBodyRules(body []byte, hasExplicitInstructions bool) []byte {
+func applyCodexSIWCCompactBodyRules(body []byte, hasExplicitInstructions bool, opts ...cliproxyexecutor.Options) []byte {
body, _ = sjson.DeleteBytes(body, "stream")
body, _ = sjson.DeleteBytes(body, "store")
- return stripCodexSIWCUnsupportedFields(body, hasExplicitInstructions)
+ return stripCodexSIWCUnsupportedFields(body, hasExplicitInstructions, opts...)
}
-func stripCodexSIWCUnsupportedFields(body []byte, hasExplicitInstructions bool) []byte {
+func stripCodexSIWCUnsupportedFields(body []byte, hasExplicitInstructions bool, opts ...cliproxyexecutor.Options) []byte {
+ // SIWC subscriptions reject configuration_update input items, so degrade
+ // them to the classic top-level reasoning.effort and accept the cache loss.
+ // An explicit model thinking suffix is an operator override: the thinking
+ // layer has already resolved it into the top-level effort, so only the
+ // unsupported items are removed in that case.
+ body = thinking.CollapseConfigurationUpdates(body, len(opts) > 0 && opts[0].KeepTopLevelEffort)
body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
body, _ = sjson.DeleteBytes(body, "prompt_cache_options")
body, _ = sjson.DeleteBytes(body, "max_output_tokens")
@@ -398,6 +404,81 @@ func normalizeCodexInstructions(body []byte, nativeRequest ...bool) []byte {
var imageGenToolJSON = []byte(`{"type":"image_generation","output_format":"png"}`)
var imageGenToolArrayJSON = []byte(`[{"type":"image_generation","output_format":"png"}]`)
+var webSearchToolJSON = []byte(`{"type":"web_search"}`)
+var webSearchToolArrayJSON = []byte(`[{"type":"web_search"}]`)
+
+func isCodexWebSearchToolType(toolType string) bool {
+ switch strings.TrimSpace(toolType) {
+ case "web_search", "web_search_preview", "web_search_preview_2025_03_11":
+ return true
+ default:
+ return false
+ }
+}
+
+// ensureWebSearchToolForHistory reconciles replayed web_search_call input items
+// with the request's tool declarations so upstream Codex does not fail with
+// "response protection is unavailable". Standard Responses requests receive a
+// {"type":"web_search"} tool declaration when missing, while Responses Lite
+// requests (which omit top-level hosted tools) strip the replayed search items.
+func ensureWebSearchToolForHistory(body []byte, headers http.Header) []byte {
+ if !bytes.Contains(body, []byte("web_search_call")) {
+ return body
+ }
+ input := util.GetGJSONBytesNoCopy(body, "input")
+ if !input.IsArray() {
+ return body
+ }
+ hasWebSearchCall := false
+ input.ForEach(func(_, item gjson.Result) bool {
+ if strings.TrimSpace(item.Get("type").String()) == "web_search_call" {
+ hasWebSearchCall = true
+ return false
+ }
+ return true
+ })
+ if !hasWebSearchCall {
+ return body
+ }
+
+ tools := util.GetGJSONBytesNoCopy(body, "tools")
+ if tools.IsArray() {
+ for _, t := range tools.Array() {
+ if isCodexWebSearchToolType(t.Get("type").String()) {
+ return body
+ }
+ }
+ }
+
+ if util.IsCodexResponsesLiteRequest(body, headers) {
+ items := input.Array()
+ rebuilt := make([]string, 0, len(items))
+ for _, item := range items {
+ if strings.TrimSpace(item.Get("type").String()) == "web_search_call" {
+ continue
+ }
+ rebuilt = append(rebuilt, item.Raw)
+ }
+ updated, errSet := sjson.SetRawBytes(body, "input", []byte("["+strings.Join(rebuilt, ",")+"]"))
+ if errSet != nil {
+ return body
+ }
+ return updated
+ }
+
+ if !tools.Exists() || !tools.IsArray() {
+ updated, errSet := sjson.SetRawBytes(body, "tools", webSearchToolArrayJSON)
+ if errSet != nil {
+ return body
+ }
+ return updated
+ }
+ updated, errSet := sjson.SetRawBytes(body, "tools.-1", webSearchToolJSON)
+ if errSet != nil {
+ return body
+ }
+ return updated
+}
func isCodexFreePlanAuth(auth *cliproxyauth.Auth) bool {
if auth == nil || auth.Attributes == nil {
diff --git a/internal/runtime/executor/codex_executor_siwc_configuration_update_test.go b/internal/runtime/executor/codex_executor_siwc_configuration_update_test.go
new file mode 100644
index 00000000000..86610c98888
--- /dev/null
+++ b/internal/runtime/executor/codex_executor_siwc_configuration_update_test.go
@@ -0,0 +1,381 @@
+package executor
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/gorilla/websocket"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
+ "github.com/tidwall/gjson"
+)
+
+// SIWC (Sign in with ChatGPT) subscriptions reject configuration_update input
+// items with subscription_sharing_unsupported_capability, so the SIWC body
+// rules must drop every such item and promote the last nonempty effort into
+// the top-level reasoning.effort even though that invalidates the prompt cache.
+func TestStripCodexSIWCUnsupportedFieldsConfigurationUpdates(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ body string
+ wantInput string
+ wantEffort string
+ }{
+ {
+ name: "latest update overrides baseline effort",
+ body: `{"reasoning":{"effort":"medium","summary":"auto"},"input":[{"type":"configuration_update","reasoning":{"effort":"low"}},{"role":"user","content":"hi"},{"type":"configuration_update","reasoning":{"effort":"high"}}]}`,
+ wantInput: `[{"role":"user","content":"hi"}]`,
+ wantEffort: "high",
+ },
+ {
+ name: "effort is created when reasoning is missing",
+ body: `{"input":[{"type":"configuration_update","reasoning":{"effort":"xhigh"}},{"role":"user","content":"hi"}]}`,
+ wantInput: `[{"role":"user","content":"hi"}]`,
+ wantEffort: "xhigh",
+ },
+ {
+ name: "update without reasoning effort keeps earlier effort",
+ body: `{"reasoning":{"effort":"medium"},"input":[{"type":"configuration_update","reasoning":{"effort":"low"}},{"type":"configuration_update","tools":[]},{"role":"user","content":"hi"}]}`,
+ wantInput: `[{"role":"user","content":"hi"}]`,
+ wantEffort: "low",
+ },
+ {
+ name: "no update effort leaves top level unchanged",
+ body: `{"reasoning":{"effort":"medium"},"input":[{"type":"configuration_update","tools":[]},{"role":"user","content":"hi"}]}`,
+ wantInput: `[{"role":"user","content":"hi"}]`,
+ wantEffort: "medium",
+ },
+ {
+ name: "invalid update efforts are ignored",
+ body: `{"reasoning":{"effort":"medium"},"input":[{"type":"configuration_update","reasoning":{"effort":"low"}},{"type":"configuration_update","reasoning":{"effort":42}},{"type":"configuration_update","reasoning":{"effort":null}},{"type":"configuration_update","reasoning":{"effort":" "}},{"role":"user","content":"hi"}]}`,
+ wantInput: `[{"role":"user","content":"hi"}]`,
+ wantEffort: "low",
+ },
+ {
+ name: "effort is trimmed and lowercased",
+ body: `{"reasoning":{"effort":"medium"},"input":[{"type":"configuration_update","reasoning":{"effort":" High "}},{"role":"user","content":"hi"}]}`,
+ wantInput: `[{"role":"user","content":"hi"}]`,
+ wantEffort: "high",
+ },
+ {
+ name: "none passes through unchanged",
+ body: `{"reasoning":{"effort":"medium"},"input":[{"type":"configuration_update","reasoning":{"effort":"none"}},{"role":"user","content":"hi"}]}`,
+ wantInput: `[{"role":"user","content":"hi"}]`,
+ wantEffort: "none",
+ },
+ {
+ name: "auto passes through unchanged",
+ body: `{"reasoning":{"effort":"medium"},"input":[{"type":"configuration_update","reasoning":{"effort":"auto"}},{"role":"user","content":"hi"}]}`,
+ wantInput: `[{"role":"user","content":"hi"}]`,
+ wantEffort: "auto",
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ got := stripCodexSIWCUnsupportedFields([]byte(tc.body), false)
+ if input := gjson.GetBytes(got, "input").Raw; input != tc.wantInput {
+ t.Fatalf("input = %s, want %s; body=%s", input, tc.wantInput, got)
+ }
+ if effort := gjson.GetBytes(got, "reasoning.effort").String(); effort != tc.wantEffort {
+ t.Fatalf("reasoning.effort = %q, want %q; body=%s", effort, tc.wantEffort, got)
+ }
+ })
+ }
+
+ t.Run("summary and other reasoning fields are preserved", func(t *testing.T) {
+ body := `{"reasoning":{"effort":"medium","summary":"auto","other":7},"input":[{"type":"configuration_update","reasoning":{"effort":"high"}},{"role":"user","content":"hi"}]}`
+ got := stripCodexSIWCUnsupportedFields([]byte(body), false)
+ if summary := gjson.GetBytes(got, "reasoning.summary").String(); summary != "auto" {
+ t.Fatalf("reasoning.summary = %q, want auto; body=%s", summary, got)
+ }
+ if other := gjson.GetBytes(got, "reasoning.other").Int(); other != 7 {
+ t.Fatalf("reasoning.other = %d, want 7; body=%s", other, got)
+ }
+ })
+
+ t.Run("nonarray input remains untouched", func(t *testing.T) {
+ body := `{"reasoning":{"effort":"medium"},"input":{"type":"configuration_update","reasoning":{"effort":"high"}}}`
+ got := stripCodexSIWCUnsupportedFields([]byte(body), false)
+ if string(got) != body {
+ t.Fatalf("body changed: got %s, want %s", got, body)
+ }
+ })
+
+ t.Run("payload without updates is byte identical", func(t *testing.T) {
+ body := `{"reasoning":{"effort":"medium"},"input":[{"role":"user","content":"hi"}]}`
+ got := stripCodexSIWCUnsupportedFields([]byte(body), false)
+ if string(got) != body {
+ t.Fatalf("body changed: got %s, want %s", got, body)
+ }
+ })
+}
+
+func codexSIWCAuthForUpdateTest(baseURL string) *cliproxyauth.Auth {
+ return &cliproxyauth.Auth{
+ ID: "codex-siwc-update",
+ Provider: "codex",
+ Attributes: map[string]string{"base_url": baseURL},
+ Metadata: map[string]any{
+ "type": "codex",
+ "access_token": "outer-tok",
+ "account_id": "acct-1",
+ "use_siwc": true,
+ "siwc": map[string]any{
+ "access_token": "siwc-tok",
+ "client_id": "oaiapp_test",
+ },
+ },
+ }
+}
+
+const codexSIWCUpdatePromptCacheKey = "siwc-update-session"
+
+const codexSIWCUpdatePayload = `{"model":"gpt-6-astra","prompt_cache_key":"` + codexSIWCUpdatePromptCacheKey + `","reasoning":{"effort":"medium","summary":"auto"},"input":[{"type":"configuration_update","reasoning":{"effort":"low"}},{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]},{"type":"configuration_update","reasoning":{"effort":"high"}}]}`
+
+func assertCodexSIWCUpdateStripped(t *testing.T, gotBody []byte, wantEffort string) {
+ t.Helper()
+ if len(gotBody) == 0 {
+ t.Fatal("upstream body was not captured")
+ }
+ for _, item := range gjson.GetBytes(gotBody, "input").Array() {
+ if item.Get("type").String() == "configuration_update" {
+ t.Fatalf("SIWC upstream body still contains configuration_update: %s", gotBody)
+ }
+ }
+ if effort := gjson.GetBytes(gotBody, "reasoning.effort").String(); effort != wantEffort {
+ t.Fatalf("SIWC upstream reasoning.effort = %q, want %q; body=%s", effort, wantEffort, gotBody)
+ }
+}
+
+func TestCodexExecutorUseSIWCStripsConfigurationUpdatesBeforePayloadFinalizer(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ cfg *config.Config
+ wantEffort string
+ }{
+ {name: "latest update becomes top level effort", cfg: &config.Config{}, wantEffort: "high"},
+ {
+ name: "payload finalizer stays the last barrier",
+ cfg: &config.Config{
+ Payload: config.PayloadConfig{
+ Override: []config.PayloadRule{
+ {
+ Models: []config.PayloadModelRule{{Name: "gpt-6-astra", Protocol: "codex"}},
+ Params: map[string]any{"reasoning.effort": "xhigh"},
+ },
+ },
+ },
+ },
+ wantEffort: "xhigh",
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ var gotBody []byte
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ gotBody = body
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte(`data: {"type":"response.completed","response":{"id":"resp_siwc_update","object":"response","status":"completed","model":"gpt-6-astra","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}}` + "\n\n"))
+ }))
+ defer server.Close()
+
+ executor := NewCodexExecutor(tc.cfg)
+ _, err := executor.Execute(context.Background(), codexSIWCAuthForUpdateTest(server.URL), cliproxyexecutor.Request{
+ Model: "gpt-6-astra",
+ Payload: []byte(codexSIWCUpdatePayload),
+ }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("openai-response")})
+ if err != nil {
+ t.Fatalf("Execute error: %v", err)
+ }
+ assertCodexSIWCUpdateStripped(t, gotBody, tc.wantEffort)
+ })
+ }
+}
+
+func TestCodexExecutorCompactUseSIWCStripsConfigurationUpdates(t *testing.T) {
+ var gotBody []byte
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ gotBody = body
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"id":"resp_siwc_compact_update","object":"response.compaction","usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}`))
+ }))
+ defer server.Close()
+
+ executor := NewCodexExecutor(&config.Config{})
+ _, err := executor.Execute(context.Background(), codexSIWCAuthForUpdateTest(server.URL), cliproxyexecutor.Request{
+ Model: "gpt-6-astra",
+ Payload: []byte(codexSIWCUpdatePayload),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("openai-response"),
+ Alt: "responses/compact",
+ })
+ if err != nil {
+ t.Fatalf("Execute compact error: %v", err)
+ }
+ assertCodexSIWCUpdateStripped(t, gotBody, "high")
+}
+
+func TestCodexWebsocketsExecuteUseSIWCStripsConfigurationUpdates(t *testing.T) {
+ upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
+ captured := make(chan []byte, 1)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ conn, errUpgrade := upgrader.Upgrade(w, r, nil)
+ if errUpgrade != nil {
+ t.Errorf("upgrade websocket: %v", errUpgrade)
+ return
+ }
+ defer func() { _ = conn.Close() }()
+ _, payload, errRead := conn.ReadMessage()
+ if errRead != nil {
+ t.Errorf("read upstream websocket message: %v", errRead)
+ return
+ }
+ captured <- append([]byte(nil), payload...)
+ completed := []byte(`{"type":"response.completed","response":{"id":"resp-ws-siwc-update","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`)
+ if errWrite := conn.WriteMessage(websocket.TextMessage, completed); errWrite != nil {
+ t.Errorf("write completed websocket message: %v", errWrite)
+ }
+ }))
+ defer server.Close()
+
+ executor := NewCodexWebsocketsExecutor(&config.Config{SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll}})
+ _, err := executor.Execute(context.Background(), codexSIWCAuthForUpdateTest(server.URL), cliproxyexecutor.Request{
+ Model: "gpt-6-astra",
+ Payload: []byte(codexSIWCUpdatePayload),
+ }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("openai-response")})
+ if err != nil {
+ t.Fatalf("websocket Execute error: %v", err)
+ }
+
+ select {
+ case gotBody := <-captured:
+ assertCodexSIWCUpdateStripped(t, gotBody, "high")
+ if cacheKey := gjson.GetBytes(gotBody, "prompt_cache_key").String(); cacheKey != codexSIWCUpdatePromptCacheKey {
+ t.Fatalf("prompt_cache_key = %q, want %q; body=%s", cacheKey, codexSIWCUpdatePromptCacheKey, gotBody)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("timed out waiting for upstream websocket payload")
+ }
+}
+
+func TestCodexExecutorUseSIWCKeepsSuffixEffortOverConfigurationUpdate(t *testing.T) {
+ var gotBody []byte
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ gotBody = body
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte(`data: {"type":"response.completed","response":{"id":"resp_siwc_suffix","object":"response","status":"completed","model":"gpt-6-astra","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}}` + "\n\n"))
+ }))
+ defer server.Close()
+
+ // With an explicit model suffix the thinking layer resolves the operator's
+ // override into the top-level effort. The SIWC strip must keep that
+ // suffix intent instead of resurrecting the last configuration_update.
+ executor := NewCodexExecutor(&config.Config{})
+ _, err := executor.Execute(context.Background(), codexSIWCAuthForUpdateTest(server.URL), cliproxyexecutor.Request{
+ Model: "gpt-6-astra(xhigh)",
+ Payload: []byte(codexSIWCUpdatePayload),
+ }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("openai-response")})
+ if err != nil {
+ t.Fatalf("Execute error: %v", err)
+ }
+ assertCodexSIWCUpdateStripped(t, gotBody, "xhigh")
+}
+
+func TestCodexExecutorExecuteStreamUseSIWCStripsConfigurationUpdates(t *testing.T) {
+ captured := make(chan []byte, 1)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ captured <- body
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte(`data: {"type":"response.completed","response":{"id":"resp_siwc_stream","object":"response","status":"completed","model":"gpt-6-astra","output":[],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}}` + "\n\n"))
+ }))
+ defer server.Close()
+
+ executor := NewCodexExecutor(&config.Config{})
+ result, err := executor.ExecuteStream(context.Background(), codexSIWCAuthForUpdateTest(server.URL), cliproxyexecutor.Request{
+ Model: "gpt-6-astra",
+ Payload: []byte(codexSIWCUpdatePayload),
+ }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("openai-response")})
+ if err != nil {
+ t.Fatalf("ExecuteStream error: %v", err)
+ }
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ t.Errorf("stream chunk error: %v", chunk.Err)
+ }
+ }
+ assertCodexSIWCUpdateStripped(t, <-captured, "high")
+}
+
+func TestCodexWebsocketsPrepareStreamUseSIWCKeepsSuffixEffort(t *testing.T) {
+ executor := NewCodexWebsocketsExecutor(&config.Config{SDKConfig: config.SDKConfig{DisableImageGeneration: config.DisableImageGenerationAll}})
+ prepared, err := executor.prepareCodexWebsocketStream(context.Background(), codexSIWCAuthForUpdateTest("http://127.0.0.1:1"), cliproxyexecutor.Request{
+ Model: "gpt-6-astra(xhigh)",
+ Payload: []byte(codexSIWCUpdatePayload),
+ }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("openai-response")})
+ if err != nil {
+ t.Fatalf("prepareCodexWebsocketStream error: %v", err)
+ }
+ assertCodexSIWCUpdateStripped(t, prepared.clientBody, "xhigh")
+}
+
+func TestCodexExecutorUseSIWCInvalidSuffixPromotesConfigurationUpdate(t *testing.T) {
+ var gotBody []byte
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ gotBody = body
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte(`data: {"type":"response.completed","response":{"id":"resp_siwc_invalid_suffix","object":"response","status":"completed","model":"gpt-6-astra","output":[],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}}` + "\n\n"))
+ }))
+ defer server.Close()
+
+ executor := NewCodexExecutor(&config.Config{})
+ _, err := executor.Execute(context.Background(), codexSIWCAuthForUpdateTest(server.URL), cliproxyexecutor.Request{
+ Model: "gpt-6-astra(invalid)",
+ Payload: []byte(codexSIWCUpdatePayload),
+ }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("openai-response")})
+ if err != nil {
+ t.Fatalf("Execute error: %v", err)
+ }
+ assertCodexSIWCUpdateStripped(t, gotBody, "high")
+}
+
+func TestCodexExecutorWithoutSIWCKeepsConfigurationUpdates(t *testing.T) {
+ var gotBody []byte
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ gotBody = body
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte(`data: {"type":"response.completed","response":{"id":"resp_oauth_update","object":"response","status":"completed","model":"gpt-6-astra","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}}` + "\n\n"))
+ }))
+ defer server.Close()
+
+ executor := NewCodexExecutor(&config.Config{})
+ _, err := executor.Execute(context.Background(), codexOAuthTestAuth(server.URL), cliproxyexecutor.Request{
+ Model: "gpt-6-astra",
+ Payload: []byte(codexSIWCUpdatePayload),
+ }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("openai-response")})
+ if err != nil {
+ t.Fatalf("Execute error: %v", err)
+ }
+
+ updates := 0
+ for _, item := range gjson.GetBytes(gotBody, "input").Array() {
+ if item.Get("type").String() == "configuration_update" {
+ updates++
+ }
+ }
+ if updates != 2 {
+ t.Fatalf("non-SIWC upstream configuration_update count = %d, want 2; body=%s", updates, gotBody)
+ }
+ if effort := gjson.GetBytes(gotBody, "reasoning.effort").String(); effort != "medium" {
+ t.Fatalf("non-SIWC upstream reasoning.effort = %q, want pinned %q; body=%s", effort, "medium", gotBody)
+ }
+}
diff --git a/internal/runtime/executor/codex_executor_sse_events_test.go b/internal/runtime/executor/codex_executor_sse_events_test.go
new file mode 100644
index 00000000000..558bfdb56a0
--- /dev/null
+++ b/internal/runtime/executor/codex_executor_sse_events_test.go
@@ -0,0 +1,456 @@
+package executor
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
+ log "github.com/sirupsen/logrus"
+ logtest "github.com/sirupsen/logrus/hooks/test"
+)
+
+func codexStreamChunks(t *testing.T, stream string, buffered bool) []string {
+ t.Helper()
+ cfg := &config.Config{}
+ cfg.Codex.StreamBootstrapBuffering = buffered
+ executor := NewCodexExecutor(cfg)
+ ctx := context.WithValue(t.Context(), "cliproxy.roundtripper", codexAllocationTransport{stream: stream})
+ auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "test"}}
+ result, err := executor.ExecuteStream(ctx, auth, cliproxyexecutor.Request{Model: "gpt-5.4", Payload: []byte(`{"model":"gpt-5.4","input":"hello"}`)},
+ cliproxyexecutor.Options{Stream: true, SourceFormat: sdktranslator.FormatOpenAIResponse})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var chunks []string
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("stream error: %v", chunk.Err)
+ }
+ if len(chunk.Payload) > 0 {
+ chunks = append(chunks, string(chunk.Payload))
+ }
+ }
+ return chunks
+}
+
+func TestCodexExecuteStreamTreatsAnEventSplitAcrossDataLinesAsOneEvent(t *testing.T) {
+ completed := `{"type":"response.completed","response":{"id":"resp_split","model":"gpt-5.4","status":"completed","output":[{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"output_text","text":"hi"}]}],"usage":{"input_tokens":5,"output_tokens":1,"total_tokens":6}}}`
+ cut := strings.Index(completed, `"output":`)
+ // With bootstrap buffering, the first output releases the stream before the completed event.
+ created := "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_split\",\"model\":\"gpt-5.4\"}}\n\n" +
+ "event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"item_id\":\"msg_1\",\"output_index\":0,\"content_index\":0,\"delta\":\"hi\"}\n\n"
+ oneLine := created + "event: response.completed\ndata: " + completed + "\n\n"
+ split := created + "event: response.completed\ndata: " + completed[:cut] + "\ndata: " + completed[cut:] + "\n\n"
+ for _, buffered := range []bool{false, true} {
+ t.Run(fmt.Sprintf("buffered=%t", buffered), func(t *testing.T) {
+ want := codexStreamChunks(t, oneLine, buffered)
+ got := codexStreamChunks(t, split, buffered)
+ if fmt.Sprintf("%q", got) != fmt.Sprintf("%q", want) {
+ t.Fatalf("split event chunks = %q\nwant the one-line chunks %q", got, want)
+ }
+ if terminal := want[len(want)-1]; !strings.Contains(terminal, `"response.completed"`) || !json.Valid([]byte(strings.TrimPrefix(terminal, "data: "))) {
+ t.Fatalf("last chunk = %q, want a valid response.completed", terminal)
+ }
+ })
+ }
+}
+
+func cloneLogHooks(src log.LevelHooks) log.LevelHooks {
+ cloned := make(log.LevelHooks, len(src))
+ for lvl, hooks := range src {
+ cloned[lvl] = append([]log.Hook(nil), hooks...)
+ }
+ return cloned
+}
+
+func TestCodexExecuteStreamPassesADoneSentinelAfterTheCompletedEvent(t *testing.T) {
+ origHooks := cloneLogHooks(log.StandardLogger().Hooks)
+ hook := new(logtest.Hook)
+ log.StandardLogger().AddHook(hook)
+ t.Cleanup(func() { log.StandardLogger().ReplaceHooks(origHooks) })
+
+ stream := codexAllocationStream(2, 16) + "data: [DONE]\n\n"
+ chunks := codexStreamChunks(t, stream, false)
+ sawCompleted := false
+ for _, chunk := range chunks {
+ if strings.Contains(chunk, `"response.completed"`) {
+ sawCompleted = true
+ }
+ }
+ if !sawCompleted {
+ t.Fatalf("chunks = %q, want the completed event", chunks)
+ }
+ for _, entry := range hook.AllEntries() {
+ if strings.Contains(entry.Message, "SSE event data is not valid JSON") {
+ t.Fatalf("unexpected framing warning: %s", entry.Message)
+ }
+ }
+}
+
+func TestCodexExecuteNonStreamReadsAnEventSplitAcrossDataLines(t *testing.T) {
+ completed := `{"type":"response.completed","response":{"id":"resp_split","object":"response","model":"gpt-5.4","status":"completed","output":[{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"output_text","text":"hi"}]}],"usage":{"input_tokens":5,"output_tokens":1,"total_tokens":6}}}`
+ cut := strings.Index(completed, `"output":`)
+ stream := "event: response.completed\r\ndata: " + completed[:cut] + "\r\ndata: " + completed[cut:] + "\r\n\r\n"
+ executor := NewCodexExecutor(&config.Config{})
+ ctx := context.WithValue(t.Context(), "cliproxy.roundtripper", codexAllocationTransport{stream: stream})
+ auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "test"}}
+ resp, err := executor.Execute(ctx, auth, cliproxyexecutor.Request{Model: "gpt-5.4", Payload: []byte(`{"model":"gpt-5.4","input":"hello"}`)},
+ cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse})
+ if err != nil {
+ t.Fatalf("Execute error: %v", err)
+ }
+ var got struct {
+ ID string `json:"id"`
+ Output []json.RawMessage `json:"output"`
+ }
+ if errDecode := json.Unmarshal(resp.Payload, &got); errDecode != nil || got.ID != "resp_split" || len(got.Output) != 1 {
+ t.Fatalf("payload = %s (decode error %v), want the completed response", resp.Payload, errDecode)
+ }
+}
+
+func TestCodexExecuteStreamKeepsAnInvalidSplitEventInvalid(t *testing.T) {
+ // Split inside a string, so the SSE-joined data has a raw newline in it and is not JSON.
+ stream := "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_bad\",\"model\":\"gpt-5.4\"}}\n\n" +
+ "event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_bad\",\"status\":\"comp\ndata: leted\",\"output\":[]}}\n\n"
+ chunks := codexStreamChunks(t, stream, false)
+ last := chunks[len(chunks)-1]
+ var values []string
+ for _, line := range strings.Split(last, "\n") {
+ values = append(values, strings.TrimPrefix(strings.TrimPrefix(line, "data:"), " "))
+ }
+ if !strings.HasPrefix(last, "data: ") || strings.Count(last, "\ndata: ") != 1 || json.Valid([]byte(strings.Join(values, "\n"))) {
+ t.Fatalf("last chunk = %q, want the invalid event's two data lines unchanged", last)
+ }
+}
+
+func TestCodexExecuteStreamAssemblesSplitEventsWithAndWithoutBootstrapBuffering(t *testing.T) {
+ // A handshake event split across three data lines, and a terminal event split across two data lines.
+ stream := "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_split\",\"model\":\"gpt-5.4\"}}\n\n" +
+ "data: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_split\",\"output\":[\ndata: {\"x\":1},\ndata: {\"x\":1}]}}\n\n" +
+ "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_split\",\"status\":\"completed\",\ndata: \"output\":[]}}\n\n"
+ fragments := func(chunks []string) int {
+ count := 0
+ for _, chunk := range chunks {
+ if chunk == `data: {"x":1},` {
+ count++
+ }
+ }
+ return count
+ }
+ for _, buffered := range []bool{false, true} {
+ t.Run(fmt.Sprintf("buffered=%t", buffered), func(t *testing.T) {
+ chunks := codexStreamChunks(t, stream, buffered)
+ if count := fragments(chunks); count != 0 {
+ t.Fatalf("buffered=%t: %d fragments emitted, want the event assembled", buffered, count)
+ }
+ last := chunks[len(chunks)-1]
+ if !strings.Contains(last, `"response.completed"`) || !json.Valid([]byte(strings.TrimPrefix(last, "data: "))) {
+ t.Fatalf("last chunk = %q, want a valid response.completed", last)
+ }
+ })
+ }
+}
+
+func TestCodexExecuteStreamMinimalReproFromIssue6575(t *testing.T) {
+ // Minimal reproduction from issue 6575: response.completed immediately follows response.created and is split.
+ stream := "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"r\",\"status\":\"in_progress\",\"output\":[]}}\n\n" +
+ "event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"r\",\"status\":\"completed\",\ndata: \"output\":[]}}\n\n"
+ for _, buffered := range []bool{false, true} {
+ t.Run(fmt.Sprintf("buffered=%t", buffered), func(t *testing.T) {
+ chunks := codexStreamChunks(t, stream, buffered)
+ if len(chunks) == 0 {
+ t.Fatal("expected chunks, got none")
+ }
+ last := chunks[len(chunks)-1]
+ if !strings.Contains(last, `"response.completed"`) || !json.Valid([]byte(strings.TrimPrefix(last, "data: "))) {
+ t.Fatalf("last chunk = %q, want valid response.completed", last)
+ }
+ })
+ }
+}
+
+func TestCodexExecuteStreamSplitHandshakeExceedingLineBudgetReleasesBootstrapWithoutFailover(t *testing.T) {
+ // A split in_progress event that spans 50 data lines (> codexBootstrapMaxBufferedFrames of 48), followed by overload.
+ var stream strings.Builder
+ stream.WriteString("event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_split\",\"model\":\"gpt-5.4\"}}\n\n")
+ stream.WriteString("data: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_split\",\"items\":[\n")
+ for i := 0; i < 50; i++ {
+ stream.WriteString("data: \n")
+ }
+ stream.WriteString("data: ]}}\n\n")
+ // Followed by overload error
+ stream.WriteString("event: error\ndata: {\"type\":\"error\",\"error\":{\"type\":\"server_error\",\"code\":\"server_overloaded\",\"message\":\"The server had an error while processing your request.\",\"param\":\"input\"}}\n\n")
+
+ // When bootstrap buffering is enabled, the 50 lines exceeded the line budget (48 frames),
+ // so the stream is released and the overload error is delivered in-stream rather than failing over.
+ cfg := &config.Config{}
+ cfg.Codex.StreamBootstrapBuffering = true
+ executor := NewCodexExecutor(cfg)
+ ctx := context.WithValue(t.Context(), "cliproxy.roundtripper", codexAllocationTransport{stream: stream.String()})
+ auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "test"}}
+ result, err := executor.ExecuteStream(ctx, auth, cliproxyexecutor.Request{Model: "gpt-5.4", Payload: []byte(`{"model":"gpt-5.4","input":"hello"}`)},
+ cliproxyexecutor.Options{Stream: true, SourceFormat: sdktranslator.FormatOpenAIResponse})
+ if err != nil {
+ t.Fatalf("expected stream to be released without failover error: %v", err)
+ }
+ if result == nil {
+ t.Fatal("expected non-nil stream result")
+ }
+ sawError := false
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ sawError = true
+ }
+ }
+ if !sawError {
+ t.Fatal("expected in-stream delivery of overload error after budget exhausted")
+ }
+}
+
+func TestCodexExecuteStreamEventExceedsBudgetWhileAssemblingReleasesStream(t *testing.T) {
+ // Line 1: event: response.created
+ // Line 2: data: {"type":"response.created",...}
+ // Line 3: blank line
+ // Line 4: data: {"type":"response.in_progress",...
+ // Lines 5-47: 43 empty data lines
+ // Line 48: {"key_at_boundary": 123} - exact 48th physical line triggering budget limit
+ // Lines 49-50: {"key_after_boundary": 456} and ]}} closing event
+ var stream strings.Builder
+ stream.WriteString("event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_split\",\"model\":\"gpt-5.4\"}}\n\n")
+ stream.WriteString("data: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_split\",\"items\":[\n")
+ for i := 0; i < 43; i++ {
+ stream.WriteString("data: \n")
+ }
+ // Line 48: triggers the 48-line frame budget limit during assembly.
+ stream.WriteString("data: {\"key_at_boundary\":123},\n")
+ // Lines 49-50: streamed in goroutine after bootstrap release.
+ stream.WriteString("data: {\"key_after_boundary\":456}\n")
+ stream.WriteString("data: ]}}\n") // without blank separator, next line is error
+ stream.WriteString("event: error\ndata: {\"type\":\"error\",\"error\":{\"type\":\"server_error\",\"code\":\"server_overloaded\",\"message\":\"overloaded\"}}\n\n")
+
+ cfg := &config.Config{}
+ cfg.Codex.StreamBootstrapBuffering = true
+ executor := NewCodexExecutor(cfg)
+ ctx := context.WithValue(t.Context(), "cliproxy.roundtripper", codexAllocationTransport{stream: stream.String()})
+ auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "test"}}
+ result, err := executor.ExecuteStream(ctx, auth, cliproxyexecutor.Request{Model: "gpt-5.4", Payload: []byte(`{"model":"gpt-5.4","input":"hello"}`)},
+ cliproxyexecutor.Options{Stream: true, SourceFormat: sdktranslator.FormatOpenAIResponse})
+ if err != nil {
+ t.Fatalf("expected stream to be released without failover error: %v", err)
+ }
+ if result == nil {
+ t.Fatal("expected non-nil stream result")
+ }
+ sawError := false
+ var inProgressChunk string
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ sawError = true
+ } else if strings.Contains(string(chunk.Payload), "response.in_progress") {
+ inProgressChunk = string(chunk.Payload)
+ }
+ }
+ if !sawError {
+ t.Fatal("expected in-stream delivery of overload error after budget exhausted")
+ }
+ if !strings.Contains(inProgressChunk, `"key_at_boundary":123`) || !strings.Contains(inProgressChunk, `"key_after_boundary":456`) {
+ t.Fatalf("inProgressChunk = %q, want both key_at_boundary and key_after_boundary preserved", inProgressChunk)
+ }
+ if !json.Valid([]byte(strings.TrimPrefix(inProgressChunk, "data: "))) {
+ t.Fatalf("inProgressChunk = %q, want valid JSON without dropped lines", inProgressChunk)
+ }
+}
+
+func TestCodexExecuteStreamEventExceedsTimeBudgetWhileAssemblingReleasesStream(t *testing.T) {
+ // Time budget check while assembling a multi-line event.
+ t0 := time.Date(2026, 9, 14, 12, 0, 0, 0, time.UTC)
+ clock := withMockClock(t, t0)
+
+ bootstrapStarted := make(chan struct{})
+ var once sync.Once
+ cleanup := setCodexBootstrapNowForTest(func() time.Time {
+ now := clock.now()
+ once.Do(func() {
+ close(bootstrapStarted)
+ })
+ return now
+ })
+ t.Cleanup(cleanup)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte("event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_t\"}}\n\n"))
+ _, _ = w.Write([]byte("data: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_t\",\"items\":[\n"))
+ if f, ok := w.(http.Flusher); ok {
+ f.Flush()
+ }
+
+ <-bootstrapStarted
+ clock.advance(11 * time.Second)
+
+ _, _ = w.Write([]byte("data: {\"t_item\":1}\ndata: ]}}\n\n"))
+ _, _ = w.Write([]byte("event: error\ndata: " + codexOverloadEvent + "\n\n"))
+ if f, ok := w.(http.Flusher); ok {
+ f.Flush()
+ }
+ }))
+ defer server.Close()
+
+ req, opts := codexTestRequest()
+ result, err := NewCodexExecutor(codexBufferingConfigWithTimeout(true, "10s")).ExecuteStream(context.Background(), codexTestAuth(server.URL), req, opts)
+ if err != nil {
+ t.Fatalf("the time budget must release the stream: %v", err)
+ }
+ if result == nil {
+ t.Fatal("expected a stream result once the time budget released the stream")
+ }
+ body, streamErr := drainChunks(result)
+ if streamErr == nil {
+ t.Fatal("expected the overload to arrive in-stream after the time budget released")
+ }
+ if !strings.Contains(body, "response.in_progress") || !strings.Contains(body, `"t_item":1`) {
+ t.Fatalf("body = %q, want valid assembled in_progress with t_item:1", body)
+ }
+}
+
+func TestCodexExecuteStreamSplitCompletedAfterOutputAndClockAdvance(t *testing.T) {
+ t0 := time.Date(2026, 9, 14, 12, 0, 0, 0, time.UTC)
+ clock := withMockClock(t, t0)
+
+ bootstrapStarted := make(chan struct{})
+ var once sync.Once
+ cleanup := setCodexBootstrapNowForTest(func() time.Time {
+ now := clock.now()
+ once.Do(func() {
+ close(bootstrapStarted)
+ })
+ return now
+ })
+ t.Cleanup(cleanup)
+
+ completed := `{"type":"response.completed","response":{"id":"resp_clock","model":"gpt-5.4","status":"completed","output":[{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`
+ cut := strings.Index(completed, `"output":`)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte("event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_clock\",\"model\":\"gpt-5.4\"}}\n\n"))
+ _, _ = w.Write([]byte("event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"item_id\":\"msg_1\",\"output_index\":0,\"content_index\":0,\"delta\":\"ok\"}\n\n"))
+ if f, ok := w.(http.Flusher); ok {
+ f.Flush()
+ }
+
+ <-bootstrapStarted
+ // Advance clock past the 10s bootstrap timeout.
+ clock.advance(20 * time.Second)
+
+ // Send split response.completed.
+ _, _ = w.Write([]byte("event: response.completed\ndata: " + completed[:cut] + "\ndata: " + completed[cut:] + "\n\n"))
+ if f, ok := w.(http.Flusher); ok {
+ f.Flush()
+ }
+ }))
+ defer server.Close()
+
+ req, opts := codexTestRequest()
+ result, err := NewCodexExecutor(codexBufferingConfigWithTimeout(true, "10s")).ExecuteStream(context.Background(), codexTestAuth(server.URL), req, opts)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if result == nil {
+ t.Fatal("expected non-nil stream result")
+ }
+ body, streamErr := drainChunks(result)
+ if streamErr != nil {
+ t.Fatalf("unexpected stream error: %v", streamErr)
+ }
+ if !strings.Contains(body, "response.completed") || !strings.Contains(body, `"id":"resp_clock"`) {
+ t.Fatalf("body = %q, want valid assembled response.completed after clock advance", body)
+ }
+}
+
+func TestCodexExecuteStreamMixedFramingCommentInsideSplitEventCountsBudget(t *testing.T) {
+ // 46 data lines held in an in_progress event, then 2 comment lines (total 48 lines), then overload.
+ var stream strings.Builder
+ stream.WriteString("data: {\"type\":\"response.in_progress\",\"items\":[\n")
+ for i := 0; i < 46; i++ {
+ stream.WriteString("data: \n")
+ }
+ // 47th line: comment inside the split event
+ stream.WriteString(": keepalive-1\n")
+ // 48th line: comment inside the split event (hits budget limit)
+ stream.WriteString(": keepalive-2\n")
+ // 49th line: overload without failover
+ stream.WriteString("event: error\ndata: " + codexOverloadEvent + "\n\n")
+ // finishes event
+ stream.WriteString("data: ]}}\n\n")
+
+ cfg := &config.Config{}
+ cfg.Codex.StreamBootstrapBuffering = true
+ executor := NewCodexExecutor(cfg)
+ ctx := context.WithValue(t.Context(), "cliproxy.roundtripper", codexAllocationTransport{stream: stream.String()})
+ auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "test"}}
+ result, err := executor.ExecuteStream(ctx, auth, cliproxyexecutor.Request{Model: "gpt-5.4", Payload: []byte(`{"model":"gpt-5.4","input":"hello"}`)},
+ cliproxyexecutor.Options{Stream: true, SourceFormat: sdktranslator.FormatOpenAIResponse})
+ if err != nil {
+ t.Fatalf("expected stream to be released without failover error: %v", err)
+ }
+ if result == nil {
+ t.Fatal("expected non-nil stream result")
+ }
+ sawError := false
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ sawError = true
+ }
+ }
+ if !sawError {
+ t.Fatal("expected in-stream delivery of overload error after mixed framing budget exhausted")
+ }
+}
+
+func TestCodexExecuteStreamEventExceedsByteBudgetWhileAssemblingReleasesStream(t *testing.T) {
+ // A split event whose data lines exceed codexBootstrapMaxBufferedBytes (1 MiB) while assembling,
+ // followed by overload.
+ var stream strings.Builder
+ stream.WriteString("data: {\"type\":\"response.in_progress\",\"items\":[\n")
+ // Write 5 data lines of 250KB each = 1.25MB > 1MB limit
+ largeChunk := strings.Repeat("x", 250*1024)
+ for i := 0; i < 5; i++ {
+ fmt.Fprintf(&stream, "data: %q,\n", largeChunk)
+ }
+ stream.WriteString("data: \"last\"]}\n\n")
+ stream.WriteString("event: error\ndata: " + codexOverloadEvent + "\n\n")
+
+ cfg := &config.Config{}
+ cfg.Codex.StreamBootstrapBuffering = true
+ executor := NewCodexExecutor(cfg)
+ ctx := context.WithValue(t.Context(), "cliproxy.roundtripper", codexAllocationTransport{stream: stream.String()})
+ auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "test"}}
+ result, err := executor.ExecuteStream(ctx, auth, cliproxyexecutor.Request{Model: "gpt-5.4", Payload: []byte(`{"model":"gpt-5.4","input":"hello"}`)},
+ cliproxyexecutor.Options{Stream: true, SourceFormat: sdktranslator.FormatOpenAIResponse})
+ if err != nil {
+ t.Fatalf("expected stream to be released without failover error: %v", err)
+ }
+ if result == nil {
+ t.Fatal("expected non-nil stream result")
+ }
+ sawError := false
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ sawError = true
+ }
+ }
+ if !sawError {
+ t.Fatal("expected in-stream delivery of overload error after byte budget exhausted")
+ }
+}
diff --git a/internal/runtime/executor/codex_executor_stream.go b/internal/runtime/executor/codex_executor_stream.go
index f59c58d1cb5..ec844d9d91c 100644
--- a/internal/runtime/executor/codex_executor_stream.go
+++ b/internal/runtime/executor/codex_executor_stream.go
@@ -1,7 +1,6 @@
package executor
import (
- "bufio"
"bytes"
"context"
"io"
@@ -53,10 +52,13 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
}
originalPayload := originalPayloadSource
isCompat := e.resolveCodexModelIsCompat(auth, req, baseModel)
- originalTranslated, body, updatesChanged, err := translateCodexRequestPairWithUpdateIntent(from, to, baseModel, originalPayload, req.Payload, true, isCompat)
+ targetModel := resolveResponsesSignatureTargetModel(baseModel, req, opts)
+ originalTranslated, body, updatesChanged, err := translateCodexRequestPairWithUpdateIntent(from, to, targetModel, originalPayload, req.Payload, true, isCompat)
if err != nil {
return nil, err
}
+ originalTranslated = helps.SetStringIfDifferent(originalTranslated, "model", baseModel)
+ body = helps.SetStringIfDifferent(body, "model", baseModel)
body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged)
if err != nil {
@@ -76,15 +78,15 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
body = helps.SetStringIfDifferent(body, "model", baseModel)
useSIWC := codexUseSIWC(auth)
hasExplicitInstructions := gjson.GetBytes(req.Payload, "instructions").Exists() && strings.TrimSpace(gjson.GetBytes(req.Payload, "instructions").String()) != ""
+ body = ensureWebSearchToolForHistory(body, opts.Headers)
if !useSIWC {
body = normalizeCodexInstructions(body, preserveNativeOutput)
if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff {
body = ensureImageGenerationTool(body, baseModel, auth, opts.Headers)
}
} else {
- body = applyCodexSIWCRequestBodyRules(body, hasExplicitInstructions)
+ body = applyCodexSIWCRequestBodyRules(body, hasExplicitInstructions, cliproxyexecutor.Options{KeepTopLevelEffort: thinking.HasThinkingSuffix(req.Model)})
}
- body = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompat(ctx, "codex executor", body, isCompat)
body = normalizeCodexParallelToolCalls(body, opts.Headers)
body = helps.NormalizeCodexToolSchemas(body)
body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuth(ctx, opts.Headers, body, e.cfg, auth, isCompat)
@@ -92,6 +94,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
if errReplay != nil {
return nil, errReplay
}
+ body = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(ctx, "codex executor", body, isCompat, targetModel, baseURL)
url := strings.TrimSuffix(baseURL, "/") + "/responses"
httpReq, upstreamBody, err := e.cacheHelper(ctx, from, url, req, body, opts.Headers)
@@ -154,27 +157,42 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
bootstrapStart = nowCodexBootstrap()
}
- scanner := bufio.NewScanner(httpResp.Body)
- scanner.Buffer(nil, 52_428_800) // 50MB
+ bufferedFrames := 0
+ bufferedBytes := 0
+ budgetExhausted := false
+ exhaustedReason := ""
+
+ scanner := helps.NewCodexSSEScanner(ctx, httpResp.Body, 52_428_800) // 50MB
+ scanner.SetOnLine(func(rawLine []byte) {
+ helps.AppendAPIResponseChunk(ctx, e.cfg, rawLine)
+ })
+ if buffering {
+ scanner.SetBudgetChecker(func(line []byte, heldLines, heldBytes int) bool {
+ timeSinceStart := nowCodexBootstrap().Sub(bootstrapStart)
+ if bootstrapTimeout > 0 && timeSinceStart >= bootstrapTimeout {
+ budgetExhausted = true
+ exhaustedReason = "time budget"
+ return true
+ }
+ if bufferedFrames+heldLines >= codexBootstrapMaxBufferedFrames {
+ budgetExhausted = true
+ exhaustedReason = "frame budget"
+ return true
+ }
+ if bufferedBytes+heldBytes >= codexBootstrapMaxBufferedBytes {
+ budgetExhausted = true
+ exhaustedReason = "byte budget"
+ return true
+ }
+ return false
+ })
+ }
claudeInputTokens := helps.NewClaudeInputTokenState(from, to, responseFormat, originalPayload)
var param any
outputItemsByIndex := make(map[int64][]byte)
var outputItemsFallback [][]byte
var bufferedChunks [][]byte
- // bufferedFrames counts the scanned lines this loop holds and bufferedBytes sums each line
- // together with the chunks it translates into. Every iteration either holds the line or leaves
- // the loop, so this is one unit per line read. Counting only the chunks would bound nothing for
- // a downstream format that renders a frame as zero chunks, and counting only some line kinds
- // would let the upstream's choice of framing decide whether the bound advances at all. The cost
- // is that a verbose framing spends the budget faster: the three-line event:/data:/blank shape
- // protects roughly a third as many events as a stream of bare ": keepalive" comments does.
- //
- // In addition to the frame and byte budgets, bootstrapTimeout bounds how long trickled
- // frames may hold the downstream headers. A peer that never terminates a line is bounded
- // by the caller's request context.
- bufferedFrames := 0
- bufferedBytes := 0
var initialChunks [][]byte
streamStarted := false
immediateTerminal := false
@@ -193,7 +211,12 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
if buffering {
for scanner.Scan() {
line := scanner.Bytes()
- helps.AppendAPIResponseChunk(ctx, e.cfg, line)
+ if budgetExhausted {
+ helps.LogWithRequestID(ctx).Debugf("codex executor: bootstrap %s exhausted after %d lines / %d bytes / %v, releasing stream without overload probing", exhaustedReason, bufferedFrames, bufferedBytes, nowCodexBootstrap().Sub(bootstrapStart))
+ initialChunks = nil
+ streamStarted = true
+ break
+ }
var translatedLine []byte
isHandshake := false
terminalSuccess := false
@@ -282,14 +305,17 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, originalPayload, body, translatedLine, ¶m, claudeInputTokens)
if isHandshake && !terminalSuccess {
- frameBytes := len(line)
+ rawLineCount := scanner.RawLines()
+ frameBytes := scanner.RawBytes()
for i := range chunks {
frameBytes += len(chunks[i])
}
timeSinceStart := nowCodexBootstrap().Sub(bootstrapStart)
timeoutReached := bootstrapTimeout > 0 && timeSinceStart >= bootstrapTimeout
- if !timeoutReached && bufferedFrames < codexBootstrapMaxBufferedFrames && bufferedBytes+frameBytes <= codexBootstrapMaxBufferedBytes {
- bufferedFrames++
+ totalFrames := bufferedFrames + scanner.HeldLines() + rawLineCount
+ totalBytes := bufferedBytes + scanner.HeldBytes() + frameBytes
+ if !timeoutReached && totalFrames <= codexBootstrapMaxBufferedFrames && totalBytes <= codexBootstrapMaxBufferedBytes {
+ bufferedFrames += rawLineCount
bufferedBytes += frameBytes
bufferedChunks = append(bufferedChunks, chunks...)
continue
@@ -297,7 +323,9 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
exhausted := "frame budget"
if timeoutReached {
exhausted = "time budget"
- } else if bufferedFrames < codexBootstrapMaxBufferedFrames {
+ } else if totalFrames > codexBootstrapMaxBufferedFrames {
+ exhausted = "frame budget"
+ } else {
exhausted = "byte budget"
}
helps.LogWithRequestID(ctx).Debugf("codex executor: bootstrap %s exhausted after %d lines / %d bytes / %v, releasing stream without overload probing", exhausted, bufferedFrames, bufferedBytes, timeSinceStart)
@@ -310,6 +338,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
}
break
}
+ scanner.SetBudgetChecker(nil)
if !streamStarted && bootstrapTerminalErr == nil {
closeBootstrapBody()
@@ -385,7 +414,6 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
}()
for scanner.Scan() {
line := scanner.Bytes()
- helps.AppendAPIResponseChunk(ctx, e.cfg, line)
var translatedLine []byte
terminalSuccess := false
diff --git a/internal/runtime/executor/codex_stream_bootstrap_buffering_test.go b/internal/runtime/executor/codex_stream_bootstrap_buffering_test.go
index f2ff79d9e0a..6a5333f9e6d 100644
--- a/internal/runtime/executor/codex_stream_bootstrap_buffering_test.go
+++ b/internal/runtime/executor/codex_stream_bootstrap_buffering_test.go
@@ -46,12 +46,12 @@ func codexTestAuth(baseURL string) *cliproxyauth.Auth {
func codexTestRequest() (cliproxyexecutor.Request, cliproxyexecutor.Options) {
return cliproxyexecutor.Request{
- Model: "gpt-5.6-terra",
- Payload: []byte(`{"model":"gpt-5.6-terra","input":"hello"}`),
- }, cliproxyexecutor.Options{
- SourceFormat: sdktranslator.FromString("openai-response"),
- Stream: true,
- }
+ Model: "gpt-5.6-terra",
+ Payload: []byte(`{"model":"gpt-5.6-terra","input":"hello"}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("openai-response"),
+ Stream: true,
+ }
}
// codexSSEServer streams the supplied event payloads as an HTTP 200 SSE response.
@@ -92,11 +92,11 @@ func codexWebsocketServer(t *testing.T, frames ...string) *httptest.Server {
func codexWebsocketRequest() (cliproxyexecutor.Request, cliproxyexecutor.Options) {
return cliproxyexecutor.Request{
- Model: "gpt-5.6-terra",
- Payload: []byte(`{"model":"gpt-5.6-terra","input":[{"type":"message","role":"user","content":"hello"}]}`),
- }, cliproxyexecutor.Options{
- SourceFormat: sdktranslator.FromString("openai-response"),
- }
+ Model: "gpt-5.6-terra",
+ Payload: []byte(`{"model":"gpt-5.6-terra","input":[{"type":"message","role":"user","content":"hello"}]}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("openai-response"),
+ }
}
// drainChunks collects every payload and the first error from a stream result.
diff --git a/internal/runtime/executor/codex_websockets_duplex.go b/internal/runtime/executor/codex_websockets_duplex.go
index 32bcd071758..6e79dfe1b4c 100644
--- a/internal/runtime/executor/codex_websockets_duplex.go
+++ b/internal/runtime/executor/codex_websockets_duplex.go
@@ -281,6 +281,9 @@ func (e *CodexWebsocketsExecutor) streamCodexDuplex(
// Steering carries business input but must not inherit response.create defaults.
steerReq := req
steerReq.Payload = payload
+ if codexUseSIWC(auth) {
+ payload = thinking.StripConfigurationUpdates(payload)
+ }
payload = helps.NewPayloadFinalizer(e.cfg, "codex-websockets", thinking.ParseSuffix(req.Model).ModelName, "codex", "", payload, steerReq, opts)(payload)
payload, _ = sjson.SetBytes(payload, "type", "response.steer")
parent := gjson.GetBytes(payload, "previous_response_id").String()
@@ -302,7 +305,8 @@ func (e *CodexWebsocketsExecutor) streamCodexDuplex(
}
metadataMu.Unlock()
// Control frames bypass response.create translations and built-in defaults.
- // Unknown fields and unsupported input are left to upstream validation.
+ // Except for SIWC configuration_update stripping, unknown fields and unsupported
+ // input are left to upstream validation.
if !cliproxyexecutor.WebsocketAuthEnabled(streamCtx, auth.ID) {
fail(fmt.Errorf("websocket credential is no longer enabled"))
return
diff --git a/internal/runtime/executor/codex_websockets_duplex_test.go b/internal/runtime/executor/codex_websockets_duplex_test.go
index f7dddb83128..3ae4c3eb9bc 100644
--- a/internal/runtime/executor/codex_websockets_duplex_test.go
+++ b/internal/runtime/executor/codex_websockets_duplex_test.go
@@ -175,6 +175,115 @@ func TestCodexDuplexSteeringLifecycle(t *testing.T) {
}
}
+func TestCodexDuplexSIWCSteerStripsConfigurationUpdates(t *testing.T) {
+ captured := make(chan []byte, 1)
+ serverDone := make(chan struct{})
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ defer close(serverDone)
+ conn, errUpgrade := (&websocket.Upgrader{}).Upgrade(w, r, nil)
+ if errUpgrade != nil {
+ t.Errorf("upgrade websocket: %v", errUpgrade)
+ return
+ }
+ defer func() { _ = conn.Close() }()
+ // This deadline bounds test failures only.
+ _ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
+ _, first, errRead := conn.ReadMessage()
+ if errRead != nil {
+ t.Errorf("read initial create: %v", errRead)
+ return
+ }
+ if gjson.GetBytes(first, "type").String() != "response.create" {
+ t.Errorf("initial create: %s", first)
+ }
+ if errWrite := conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.created","response":{"id":"r1","output":[]}}`)); errWrite != nil {
+ t.Errorf("write response.created: %v", errWrite)
+ return
+ }
+ _, steer, errSteer := conn.ReadMessage()
+ if errSteer != nil {
+ t.Errorf("read steer: %v", errSteer)
+ return
+ }
+ captured <- steer
+ for _, event := range []string{
+ `{"type":"response.steer.accepted","steer":{"id":"s1","previous_response_id":"r1"}}`,
+ `{"type":"response.completed","response":{"id":"r1","output":[]}}`,
+ } {
+ if errWrite := conn.WriteMessage(websocket.TextMessage, []byte(event)); errWrite != nil {
+ t.Errorf("write event: %v", errWrite)
+ return
+ }
+ }
+ _, _, _ = conn.ReadMessage()
+ }))
+ defer server.Close()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
+ defer cancel()
+ input := make(chan core.WebsocketInput, 1)
+ ctx = core.WithWebsocketInput(core.WithDownstreamWebsocket(ctx), input)
+ exec := NewCodexWebsocketsExecutor(&config.Config{Codex: config.CodexConfig{ResponseSteering: true}})
+ exec.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)}
+ req := core.Request{
+ Model: "gpt-6-astra",
+ Payload: []byte(`{"model":"gpt-6-astra","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}],"instructions":"Initial settings"}`),
+ }
+ opts := core.Options{SourceFormat: sdktranslator.FromString("codex"), Metadata: map[string]any{core.ExecutionSessionMetadataKey: "duplex-siwc-steer"}}
+ result, err := exec.ExecuteStream(ctx, codexSIWCAuthForUpdateTest(server.URL), req, opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ completed := false
+ for !completed {
+ select {
+ case chunk, ok := <-result.Chunks:
+ if !ok {
+ t.Fatal("stream ended before response.completed")
+ }
+ if chunk.Err != nil {
+ t.Fatal(chunk.Err)
+ }
+ event := gjson.GetBytes(chunk.Payload, "type").String()
+ id := gjson.GetBytes(chunk.Payload, "response.id").String()
+ if event == "response.created" && id == "r1" {
+ input <- core.WebsocketInput{Payload: []byte(`{"type":"response.steer","previous_response_id":"r1","input":[{"type":"configuration_update","reasoning":{"effort":"high"}},{"role":"user","content":[{"type":"input_text","text":"STEER_OK"}]}]}`)}
+ }
+ completed = event == "response.completed" && id == "r1"
+ case <-ctx.Done():
+ t.Fatal(ctx.Err())
+ }
+ }
+ cancel()
+ for range result.Chunks {
+ }
+ select {
+ case <-serverDone:
+ case <-time.After(3 * time.Second):
+ t.Fatal("upstream did not close")
+ }
+
+ select {
+ case got := <-captured:
+ if gjson.GetBytes(got, "type").String() != "response.steer" || gjson.GetBytes(got, "previous_response_id").String() != "r1" {
+ t.Fatalf("steer routing changed: %s", got)
+ }
+ items := gjson.GetBytes(got, "input").Array()
+ if len(items) != 1 || items[0].Get("role").String() != "user" || items[0].Get("content.0.text").String() != "STEER_OK" {
+ t.Fatalf("steer input must contain only the user message: %s", got)
+ }
+ if gjson.GetBytes(got, "reasoning").Exists() {
+ t.Fatalf("steer must not promote reasoning: %s", got)
+ }
+ if gjson.GetBytes(got, "store").Exists() || gjson.GetBytes(got, "stream").Exists() {
+ t.Fatalf("steer must not inherit response.create defaults: %s", got)
+ }
+ default:
+ t.Fatal("upstream steer was not captured")
+ }
+}
+
func TestCodexDuplexAppendInheritsContextAndInstructions(t *testing.T) {
serverDone := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/runtime/executor/codex_websockets_execute.go b/internal/runtime/executor/codex_websockets_execute.go
index eed3dc0ef02..c8175954dc2 100644
--- a/internal/runtime/executor/codex_websockets_execute.go
+++ b/internal/runtime/executor/codex_websockets_execute.go
@@ -53,10 +53,13 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
}
originalPayload := originalPayloadSource
isCompat := e.resolveCodexModelIsCompat(auth, req, baseModel)
- originalTranslated, body, updatesChanged, err := translateCodexRequestPairWithUpdateIntent(from, to, baseModel, originalPayload, req.Payload, false, isCompat)
+ targetModel := resolveResponsesSignatureTargetModel(baseModel, req, opts)
+ originalTranslated, body, updatesChanged, err := translateCodexRequestPairWithUpdateIntent(from, to, targetModel, originalPayload, req.Payload, false, isCompat)
if err != nil {
return resp, err
}
+ originalTranslated = helps.SetStringIfDifferent(originalTranslated, "model", baseModel)
+ body = helps.SetStringIfDifferent(body, "model", baseModel)
body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged)
if err != nil {
@@ -69,13 +72,13 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
body = helps.SetBoolIfDifferent(body, "stream", true)
body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
body, _ = sjson.DeleteBytes(body, "safety_identifier")
+ body = ensureWebSearchToolForHistory(body, opts.Headers)
if !useSIWC {
body = normalizeCodexInstructions(body, nativeRequest)
if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff {
body = ensureImageGenerationTool(body, baseModel, auth, opts.Headers)
}
}
- body = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompat(ctx, "codex websockets executor", body, isCompat)
body = normalizeCodexWebsocketParallelToolCalls(body, opts.Headers)
body = helps.NormalizeCodexToolSchemas(body)
multiAgentV2Conflict := helps.HasCodexMultiAgentV2NamespaceConflict(body)
@@ -84,6 +87,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
if errReplay != nil {
return resp, errReplay
}
+ body = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(ctx, "codex websockets executor", body, isCompat, targetModel, baseURL)
httpURL := strings.TrimSuffix(baseURL, "/") + "/responses"
wsURL, err := buildCodexResponsesWebsocketURL(httpURL)
@@ -97,7 +101,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
}
body = helps.SanitizeCodexInputItemIDs(body)
if useSIWC {
- body = applyCodexSIWCWebsocketBodyRules(body, hasExplicitInstructions)
+ body = applyCodexSIWCWebsocketBodyRules(body, hasExplicitInstructions, cliproxyexecutor.Options{KeepTopLevelEffort: thinking.HasThinkingSuffix(req.Model)})
}
body = finalizePayload(body)
reporter.SetTranslatedReasoningEffort(body, to.String())
diff --git a/internal/runtime/executor/codex_websockets_stream.go b/internal/runtime/executor/codex_websockets_stream.go
index 99514f39512..13366bc0cdb 100644
--- a/internal/runtime/executor/codex_websockets_stream.go
+++ b/internal/runtime/executor/codex_websockets_stream.go
@@ -784,10 +784,13 @@ func (e *CodexWebsocketsExecutor) prepareCodexWebsocketStream(ctx context.Contex
}
originalPayload := originalPayloadSource
isCompat := e.resolveCodexModelIsCompat(auth, req, baseModel)
- originalTranslated, body, updatesChanged, err := translateCodexRequestPairWithUpdateIntent(from, to, baseModel, originalPayload, req.Payload, true, isCompat)
+ targetModel := resolveResponsesSignatureTargetModel(baseModel, req, opts)
+ originalTranslated, body, updatesChanged, err := translateCodexRequestPairWithUpdateIntent(from, to, targetModel, originalPayload, req.Payload, true, isCompat)
if err != nil {
return nil, err
}
+ originalTranslated = helps.SetStringIfDifferent(originalTranslated, "model", baseModel)
+ body = helps.SetStringIfDifferent(body, "model", baseModel)
body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged)
if err != nil {
@@ -797,13 +800,13 @@ func (e *CodexWebsocketsExecutor) prepareCodexWebsocketStream(ctx context.Contex
hasExplicitInstructions := gjson.GetBytes(req.Payload, "instructions").Exists() && strings.TrimSpace(gjson.GetBytes(req.Payload, "instructions").String()) != ""
finalizePayload := helps.NewPayloadFinalizer(e.cfg, "codex-websockets", baseModel, to.String(), "", originalTranslated, req, opts)
body = helps.SetStringIfDifferent(body, "model", baseModel)
+ body = ensureWebSearchToolForHistory(body, opts.Headers)
if !useSIWC {
body = normalizeCodexInstructions(body, preserveNativeOutput)
if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff {
body = ensureImageGenerationTool(body, baseModel, auth, opts.Headers)
}
}
- body = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompat(ctx, "codex websockets executor", body, isCompat)
body = normalizeCodexWebsocketParallelToolCalls(body, opts.Headers)
body = helps.NormalizeCodexToolSchemas(body)
multiAgentV2Conflict := helps.HasCodexMultiAgentV2NamespaceConflict(body)
@@ -812,6 +815,7 @@ func (e *CodexWebsocketsExecutor) prepareCodexWebsocketStream(ctx context.Contex
if errReplay != nil {
return nil, errReplay
}
+ body = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(ctx, "codex websockets executor", body, isCompat, targetModel, baseURL)
httpURL := strings.TrimSuffix(baseURL, "/") + "/responses"
wsURL, err := buildCodexResponsesWebsocketURL(httpURL)
@@ -825,7 +829,7 @@ func (e *CodexWebsocketsExecutor) prepareCodexWebsocketStream(ctx context.Contex
}
body = helps.SanitizeCodexInputItemIDs(body)
if useSIWC {
- body = applyCodexSIWCWebsocketBodyRules(body, hasExplicitInstructions)
+ body = applyCodexSIWCWebsocketBodyRules(body, hasExplicitInstructions, cliproxyexecutor.Options{KeepTopLevelEffort: thinking.HasThinkingSuffix(req.Model)})
}
body = finalizePayload(body)
wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg, preserveNativeOutput, opts.Headers)
diff --git a/internal/runtime/executor/devin_executor.go b/internal/runtime/executor/devin_executor.go
index 888498c9cd6..1b8ec84d9c1 100644
--- a/internal/runtime/executor/devin_executor.go
+++ b/internal/runtime/executor/devin_executor.go
@@ -277,10 +277,10 @@ func (e *DevinExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
errData, _ := io.ReadAll(io.LimitReader(httpResp.Body, 1<<20))
helps.AppendAPIResponseChunk(ctx, e.cfg, errData)
- return resp, newDevinStatusError(httpResp.StatusCode, httpResp.Header, errData)
+ return resp, newDevinStatusErrorForAuth(httpResp.StatusCode, httpResp.Header, errData, auth)
}
- interactionsJSON, respLog, errConsume := consumeDevinFramesToInteractions(httpResp.Body, req.Model, chatModelUID, helps.ApplyPatchOriginalRequest(req, opts))
+ interactionsJSON, respLog, errConsume := consumeDevinFramesToInteractionsForAuth(httpResp.Body, req.Model, chatModelUID, auth, helps.ApplyPatchOriginalRequest(req, opts))
if respLog != nil || len(interactionsJSON) > 0 {
logRespBody := helps.BuildDevinUpstreamResponseLogBody(respLog, interactionsJSON)
helps.AppendAPIResponseChunk(ctx, e.cfg, logRespBody)
@@ -356,7 +356,7 @@ func (e *DevinExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
errData, _ := io.ReadAll(io.LimitReader(httpResp.Body, 1<<20))
_ = httpResp.Body.Close()
helps.AppendAPIResponseChunk(ctx, e.cfg, errData)
- return nil, newDevinStatusError(httpResp.StatusCode, httpResp.Header, errData)
+ return nil, newDevinStatusErrorForAuth(httpResp.StatusCode, httpResp.Header, errData, auth)
}
out := make(chan cliproxyexecutor.StreamChunk)
@@ -378,7 +378,7 @@ func (e *DevinExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
}
}()
- e.streamDevinFrames(streamCtx, httpResp.Body, req, opts, chatModelUID, responseFormat, reporter, out)
+ e.streamDevinFramesForAuth(streamCtx, httpResp.Body, req, opts, chatModelUID, responseFormat, reporter, out, auth)
}()
return &cliproxyexecutor.StreamResult{
@@ -495,6 +495,22 @@ func (e *DevinExecutor) streamDevinFrames(
responseFormat sdktranslator.Format,
reporter *helps.UsageReporter,
out chan<- cliproxyexecutor.StreamChunk,
+) {
+ e.streamDevinFramesForAuth(ctx, body, req, opts, chatModelUID, responseFormat, reporter, out, nil)
+}
+
+// streamDevinFramesForAuth streams Devin frames for auth; auth may be nil in tests,
+// in which case quota errors carry credential scope without a reset-based delay.
+func (e *DevinExecutor) streamDevinFramesForAuth(
+ ctx context.Context,
+ body io.Reader,
+ req cliproxyexecutor.Request,
+ opts cliproxyexecutor.Options,
+ chatModelUID string,
+ responseFormat sdktranslator.Format,
+ reporter *helps.UsageReporter,
+ out chan<- cliproxyexecutor.StreamChunk,
+ auth *cliproxyauth.Auth,
) {
if reporter != nil {
if chatModelUID != "" {
@@ -852,7 +868,7 @@ func (e *DevinExecutor) streamDevinFrames(
failedEvent, _ := sjson.SetBytes([]byte(`{"event_type":"response.failed","error":{"message":"","code":""}}`), "error.message", errTrailer.Error())
failedEvent, _ = sjson.SetBytes(failedEvent, "error.code", fmt.Sprintf("%d", code))
_ = emitInteractionsEvent(failedEvent)
- emitStreamError(statusErr{code: code, msg: errTrailer.Error()})
+ emitStreamError(devinTrailerStatusError(code, errTrailer.Error(), auth))
return
}
sawEOS = true
@@ -1149,6 +1165,10 @@ func (e *DevinExecutor) streamDevinFrames(
}
func consumeDevinFramesToInteractions(body io.Reader, model, chatModelUID string, original ...[]byte) ([]byte, *helps.DevinUpstreamResponseLog, error) {
+ return consumeDevinFramesToInteractionsForAuth(body, model, chatModelUID, nil, original...)
+}
+
+func consumeDevinFramesToInteractionsForAuth(body io.Reader, model, chatModelUID string, auth *cliproxyauth.Auth, original ...[]byte) ([]byte, *helps.DevinUpstreamResponseLog, error) {
interactionID := fmt.Sprintf("interaction_%s", uuid.New().String()[:12])
var preToolTextParts []string
var postToolTextParts []string
@@ -1242,7 +1262,7 @@ func consumeDevinFramesToInteractions(body io.Reader, model, chatModelUID string
Usage: finalUsage,
UnknownFields: unknownFields,
}
- return nil, respLog, statusErr{code: code, msg: errTrailer.Error()}
+ return nil, respLog, devinTrailerStatusError(code, errTrailer.Error(), auth)
}
sawEOS = true
break
@@ -2360,12 +2380,32 @@ func parseSignatureBytes(sigStr string) ([]byte, string) {
return []byte(s), "openai"
case internalsignature.SignatureProviderGemini:
return []byte(s), "gemini"
+ case internalsignature.SignatureProviderMeta:
+ // Positively foreign to the Devin backend: never supplement it as a
+ // sealed signature.
+ return nil, ""
}
if strings.HasPrefix(s, "AY") {
return []byte(s), "gemini"
}
if decoded, err := base64.StdEncoding.DecodeString(s); err == nil && len(decoded) > 0 {
decStr := string(decoded)
+ if strings.HasPrefix(decStr, "cpa-codex-gemini-carrier-v1:") {
+ inner := strings.TrimSpace(strings.TrimPrefix(decStr, "cpa-codex-gemini-carrier-v1:"))
+ if strings.HasPrefix(inner, "sealed.v1.") {
+ return []byte(inner), "sealed"
+ }
+ switch internalsignature.DetectSignatureProvider(inner) {
+ case internalsignature.SignatureProviderClaude:
+ return []byte(inner), "anthropic"
+ case internalsignature.SignatureProviderGPT:
+ return []byte(inner), "openai"
+ case internalsignature.SignatureProviderGemini:
+ return []byte(inner), "gemini"
+ default:
+ return nil, ""
+ }
+ }
if strings.HasPrefix(decStr, "sealed.v1.") {
return decoded, "sealed"
}
@@ -2376,6 +2416,8 @@ func parseSignatureBytes(sigStr string) ([]byte, string) {
return decoded, "openai"
case internalsignature.SignatureProviderGemini:
return []byte(s), "gemini"
+ case internalsignature.SignatureProviderMeta:
+ return nil, ""
}
if strings.HasPrefix(decStr, "CAQS") || strings.HasPrefix(decStr, "CAIS") {
return decoded, "anthropic"
@@ -2411,6 +2453,8 @@ func detectSignatureType(sig string) string {
return "openai"
case internalsignature.SignatureProviderGemini:
return "gemini"
+ case internalsignature.SignatureProviderMeta:
+ return ""
}
if strings.HasPrefix(s, "CAQS") || strings.HasPrefix(s, "CAIS") {
return "anthropic"
@@ -2513,7 +2557,46 @@ func (e *DevinExecutor) getSensitiveWordMatcher() *helps.SensitiveWordMatcher {
return e.cachedMatcher
}
+// devinTrailerStatusError builds the scheduling view of a Connect trailer
+// error. Explicit account-wide daily/weekly usage quota exhaustion (issue
+// #6690) is credential-scoped and, when auth carries a valid observed reset
+// timestamp for that window, retries only after the reset. Transient
+// capacity and generic rate-limit 429s stay model-scoped.
+func devinTrailerStatusError(code int, msg string, auth *cliproxyauth.Auth) statusErr {
+ return devinQuotaScopedStatusError(statusErr{code: code, msg: msg}, auth, time.Now())
+}
+
+// devinQuotaScopedStatusError upgrades a 429 to credential scope when its
+// message reports daily/weekly usage quota exhaustion, deriving RetryAfter
+// from the matching observed reset signal. An explicit Retry-After already
+// carried by err is preserved and never overwritten by signal data.
+func devinQuotaScopedStatusError(err statusErr, auth *cliproxyauth.Auth, now time.Time) statusErr {
+ if err.code != http.StatusTooManyRequests {
+ return err
+ }
+ period := helps.DevinUsageQuotaExhaustionPeriod(err.msg)
+ if period == helps.DevinQuotaPeriodNone {
+ return err
+ }
+ err.credentialScoped = true
+ if err.retryAfter == nil && auth != nil {
+ key := "daily_quota_reset_at"
+ if period == helps.DevinQuotaPeriodWeekly {
+ key = "weekly_quota_reset_at"
+ }
+ err.retryAfter = helps.DevinQuotaRetryAfter(auth.Quota.Signals[key], now)
+ }
+ return err
+}
+
func newDevinStatusError(code int, headers http.Header, body []byte) statusErr {
+ return newDevinStatusErrorForAuth(code, headers, body, nil)
+}
+
+// newDevinStatusErrorForAuth builds the scheduling view of a direct HTTP
+// (non-trailer) upstream failure. Daily/weekly quota exhaustion becomes
+// credential-scoped and, when auth is known, retries at the observed reset.
+func newDevinStatusErrorForAuth(code int, headers http.Header, body []byte, auth *cliproxyauth.Auth) statusErr {
err := statusErr{code: code, msg: string(body)}
if code == http.StatusTooManyRequests && headers != nil {
if raw := strings.TrimSpace(headers.Get("Retry-After")); raw != "" {
@@ -2528,7 +2611,9 @@ func newDevinStatusError(code int, headers http.Header, body []byte) statusErr {
}
}
}
- return err
+ // An explicit Retry-After header parsed above keeps precedence inside
+ // devinQuotaScopedStatusError; signal data only fills the gap.
+ return devinQuotaScopedStatusError(err, auth, time.Now())
}
func devinAuthCredentials(auth *cliproxyauth.Auth) (apiKey string, baseURL string, deviceSeed string) {
diff --git a/internal/runtime/executor/devin_quota_cooldown_regression_test.go b/internal/runtime/executor/devin_quota_cooldown_regression_test.go
new file mode 100644
index 00000000000..44cba50931f
--- /dev/null
+++ b/internal/runtime/executor/devin_quota_cooldown_regression_test.go
@@ -0,0 +1,268 @@
+package executor
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
+)
+
+// Tests in this file cover issue #6690: Devin daily/weekly usage quota
+// exhaustion must cool down the whole credential until the quota reset
+// (when a valid reset timestamp is known), instead of the short
+// per-model exponential backoff.
+
+func TestDevinQuotaExhaustedError_StreamWeeklyIsCredentialScopedWithResetRetryAfter(t *testing.T) {
+ now := time.Now().UTC().Truncate(time.Second)
+ auth := devinTestAuthWithQuotaResets(now.Add(24*time.Hour), now.Add(6*24*time.Hour))
+
+ err := devinStreamTrailerError(t, auth, "failed_precondition", "Your weekly usage quota has been exhausted.")
+
+ se, ok := err.(statusErr)
+ if !ok {
+ t.Fatalf("stream error type = %T, want statusErr (err=%v)", err, err)
+ }
+ if se.code != http.StatusTooManyRequests {
+ t.Fatalf("code = %d, want 429", se.code)
+ }
+ if !se.IsCredentialScoped() {
+ t.Fatalf("weekly quota exhaustion must be credential-scoped (issue #6690), got model-scoped: %+v", se)
+ }
+ if se.RetryAfter() == nil {
+ t.Fatalf("weekly quota exhaustion must carry reset-based RetryAfter, got nil")
+ }
+ want := 6 * 24 * time.Hour
+ if got := *se.RetryAfter(); got < want-time.Minute || got > want+time.Minute {
+ t.Fatalf("RetryAfter = %v, want ~%v (weekly reset)", got, want)
+ }
+}
+
+func TestDevinQuotaExhaustedError_StreamDailyUsesDailyReset(t *testing.T) {
+ now := time.Now().UTC().Truncate(time.Second)
+ auth := devinTestAuthWithQuotaResets(now.Add(3*time.Hour), now.Add(6*24*time.Hour))
+
+ err := devinStreamTrailerError(t, auth, "failed_precondition", "Your daily usage quota has been exhausted.")
+
+ se, ok := err.(statusErr)
+ if !ok {
+ t.Fatalf("stream error type = %T, want statusErr (err=%v)", err, err)
+ }
+ if !se.IsCredentialScoped() {
+ t.Fatalf("daily quota exhaustion must be credential-scoped (issue #6690), got model-scoped")
+ }
+ if se.RetryAfter() == nil {
+ t.Fatalf("daily quota exhaustion must carry daily-reset-based RetryAfter, got nil")
+ }
+ if got := *se.RetryAfter(); got < 2*time.Hour || got > 4*time.Hour {
+ t.Fatalf("RetryAfter = %v, want ~3h (daily reset, not weekly)", got)
+ }
+}
+
+func TestDevinQuotaExhaustedError_StreamWithoutSignalsStillCredentialScoped(t *testing.T) {
+ auth := &cliproxyauth.Auth{ID: "devin-quota-no-signals", Provider: "devin"}
+
+ err := devinStreamTrailerError(t, auth, "failed_precondition", "Your weekly usage quota has been exhausted.")
+
+ se, ok := err.(statusErr)
+ if !ok {
+ t.Fatalf("stream error type = %T, want statusErr (err=%v)", err, err)
+ }
+ if !se.IsCredentialScoped() {
+ t.Fatalf("weekly quota exhaustion without known reset must still be credential-scoped (issue #6690)")
+ }
+ if se.RetryAfter() != nil {
+ t.Fatalf("RetryAfter = %v, want nil (no valid reset signal; conductor credential backoff applies)", *se.RetryAfter())
+ }
+}
+
+func TestDevinQuotaExhaustedError_StreamPastResetYieldsNoRetryAfter(t *testing.T) {
+ now := time.Now().UTC().Truncate(time.Second)
+ auth := devinTestAuthWithQuotaResets(now.Add(-time.Hour), now.Add(-time.Hour))
+
+ err := devinStreamTrailerError(t, auth, "failed_precondition", "Your weekly usage quota has been exhausted.")
+
+ se, ok := err.(statusErr)
+ if !ok {
+ t.Fatalf("stream error type = %T, want statusErr (err=%v)", err, err)
+ }
+ if !se.IsCredentialScoped() {
+ t.Fatalf("weekly quota exhaustion must be credential-scoped even with a stale reset (issue #6690)")
+ }
+ if se.RetryAfter() != nil {
+ t.Fatalf("RetryAfter = %v, want nil for past reset timestamp", *se.RetryAfter())
+ }
+}
+
+func TestDevinQuotaExhaustedError_StreamFarFutureResetIsRejected(t *testing.T) {
+ now := time.Now().UTC().Truncate(time.Second)
+ // A reset further out than any weekly window (plus slack) is bogus data
+ // and must not pin the credential for months.
+ auth := devinTestAuthWithQuotaResets(now.Add(90*24*time.Hour), now.Add(90*24*time.Hour))
+
+ err := devinStreamTrailerError(t, auth, "failed_precondition", "Your weekly usage quota has been exhausted.")
+
+ se, ok := err.(statusErr)
+ if !ok {
+ t.Fatalf("stream error type = %T, want statusErr (err=%v)", err, err)
+ }
+ if !se.IsCredentialScoped() {
+ t.Fatalf("weekly quota exhaustion must be credential-scoped (issue #6690)")
+ }
+ if se.RetryAfter() != nil {
+ t.Fatalf("RetryAfter = %v, want nil for implausibly far reset", *se.RetryAfter())
+ }
+}
+
+func TestDevinQuotaExhaustedError_NonStreamConsumeWeeklyIsCredentialScoped(t *testing.T) {
+ now := time.Now().UTC().Truncate(time.Second)
+ auth := devinTestAuthWithQuotaResets(now.Add(24*time.Hour), now.Add(72*time.Hour))
+
+ var buf bytes.Buffer
+ buf.Write(helps.WrapConnectEnvelopeWithFlag(helps.ConnectFlagEndStream, []byte(`{"error":{"code":"failed_precondition","message":"Your weekly usage quota has been exhausted."}}`)))
+
+ _, _, errConsume := consumeDevinFramesToInteractionsForAuth(&buf, "devin/claude-opus-5-5", "claude-opus-5-5", auth)
+ if errConsume == nil {
+ t.Fatal("expected trailer error, got nil")
+ }
+ se, ok := errConsume.(statusErr)
+ if !ok {
+ t.Fatalf("consume error type = %T, want statusErr (err=%v)", errConsume, errConsume)
+ }
+ if se.code != http.StatusTooManyRequests {
+ t.Fatalf("code = %d, want 429", se.code)
+ }
+ if !se.IsCredentialScoped() {
+ t.Fatalf("non-stream weekly quota exhaustion must be credential-scoped (issue #6690), err=%+v", se)
+ }
+ if se.RetryAfter() == nil {
+ t.Fatalf("non-stream weekly quota exhaustion must carry reset-based RetryAfter, got nil")
+ }
+ want := 72 * time.Hour
+ if got := *se.RetryAfter(); got < want-time.Minute || got > want+time.Minute {
+ t.Fatalf("RetryAfter = %v, want ~%v (weekly reset)", got, want)
+ }
+}
+
+func TestDevinTransientErrors_StayModelScoped(t *testing.T) {
+ cases := []struct {
+ name string
+ code string
+ message string
+ }{
+ {name: "high demand", code: "permission_denied", message: "We are experiencing high demand. Please try again later."},
+ {name: "resource exhausted", code: "resource_exhausted", message: "Resource exhausted, please retry shortly."},
+ {name: "monthly ACU out of scope", code: "failed_precondition", message: "User monthly ACU quota exhausted (trace ID: 12345)"},
+ {name: "generic rate limit", code: "failed_precondition", message: "Rate limit exceeded. Please slow down."},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ auth := &cliproxyauth.Auth{ID: "devin-transient", Provider: "devin"}
+ err := devinStreamTrailerError(t, auth, tc.code, tc.message)
+ se, ok := err.(statusErr)
+ if !ok {
+ t.Fatalf("stream error type = %T, want statusErr (err=%v)", err, err)
+ }
+ if se.code != http.StatusTooManyRequests {
+ t.Fatalf("code = %d, want 429", se.code)
+ }
+ if se.IsCredentialScoped() {
+ t.Fatalf("%s must stay model-scoped, got credential-scoped", tc.name)
+ }
+ if se.RetryAfter() != nil {
+ t.Fatalf("%s RetryAfter = %v, want nil (short backoff path)", tc.name, *se.RetryAfter())
+ }
+ })
+ }
+}
+
+func TestDevinStatusError_HTTP429WeeklyQuotaUsesResetWhenAuthKnown(t *testing.T) {
+ now := time.Now().UTC().Truncate(time.Second)
+ auth := devinTestAuthWithQuotaResets(now.Add(24*time.Hour), now.Add(48*time.Hour))
+
+ se := newDevinStatusErrorForAuth(http.StatusTooManyRequests, http.Header{}, []byte("Your weekly usage quota has been exhausted."), auth)
+ if !se.IsCredentialScoped() {
+ t.Fatalf("HTTP 429 weekly quota exhaustion must be credential-scoped (issue #6690), got %+v", se)
+ }
+ if se.RetryAfter() == nil {
+ t.Fatal("HTTP 429 weekly quota exhaustion must use the known weekly reset when auth is available")
+ }
+ want := 48 * time.Hour
+ if got := *se.RetryAfter(); got < want-time.Minute || got > want+time.Minute {
+ t.Fatalf("RetryAfter = %v, want ~%v", got, want)
+ }
+
+ // The auth-less helper still guarantees credential scope (nil delay).
+ plain := newDevinStatusError(http.StatusTooManyRequests, http.Header{}, []byte("Your weekly usage quota has been exhausted."))
+ if !plain.IsCredentialScoped() || plain.RetryAfter() != nil {
+ t.Fatalf("auth-less HTTP 429 = %+v, want credential-scoped with nil RetryAfter", plain)
+ }
+
+ // An explicit Retry-After header keeps precedence over signal data.
+ hdr := http.Header{}
+ hdr.Set("Retry-After", "30")
+ withHeader := newDevinStatusErrorForAuth(http.StatusTooManyRequests, hdr, []byte("Your weekly usage quota has been exhausted."), auth)
+ if withHeader.RetryAfter() == nil || *withHeader.RetryAfter() != 30*time.Second {
+ t.Fatalf("explicit Retry-After must win, got %v", withHeader.RetryAfter())
+ }
+}
+
+func devinTestAuthWithQuotaResets(daily, weekly time.Time) *cliproxyauth.Auth {
+ auth := &cliproxyauth.Auth{ID: "devin-quota-auth", Provider: "devin"}
+ auth.Quota.Signals = map[string]string{
+ "daily_quota_remaining_percent": "0%",
+ "weekly_quota_remaining_percent": "0%",
+ }
+ if !daily.IsZero() {
+ auth.Quota.Signals["daily_quota_reset_at"] = daily.Format(time.RFC3339)
+ }
+ if !weekly.IsZero() {
+ auth.Quota.Signals["weekly_quota_reset_at"] = weekly.Format(time.RFC3339)
+ }
+ return auth
+}
+
+func devinStreamTrailerError(t *testing.T, auth *cliproxyauth.Auth, code, message string) error {
+ t.Helper()
+ trailer := fmt.Sprintf(`{"error":{"code":%q,"message":%q}}`, code, message)
+ var buf bytes.Buffer
+ buf.Write(helps.WrapConnectEnvelopeWithFlag(helps.ConnectFlagEndStream, []byte(trailer)))
+
+ e := &DevinExecutor{}
+ out := make(chan cliproxyexecutor.StreamChunk, 16)
+ opts := cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAI,
+ OriginalRequest: []byte(`{"model":"devin/claude-opus-5-5","stream":true,"messages":[{"role":"user","content":"hi"}]}`),
+ }
+ go func() {
+ defer close(out)
+ e.streamDevinFramesForAuth(
+ context.Background(),
+ &buf,
+ cliproxyexecutor.Request{Model: "devin/claude-opus-5-5", Payload: opts.OriginalRequest},
+ opts,
+ "claude-opus-5-5",
+ sdktranslator.FormatOpenAI,
+ nil,
+ out,
+ auth,
+ )
+ }()
+
+ var streamErr error
+ for chunk := range out {
+ if chunk.Err != nil {
+ streamErr = chunk.Err
+ }
+ }
+ if streamErr == nil {
+ t.Fatal("expected stream trailer error chunk, got none")
+ }
+ return streamErr
+}
diff --git a/internal/runtime/executor/gemini_executor.go b/internal/runtime/executor/gemini_executor.go
index 00127ca30bd..a12267b8131 100644
--- a/internal/runtime/executor/gemini_executor.go
+++ b/internal/runtime/executor/gemini_executor.go
@@ -244,6 +244,11 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
if helps.ApplyPatchTranslationError(param) != nil || len(out) == 0 {
return cliproxyexecutor.Response{}, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}
}
+ if errPayload, okErr := helps.TranslatedClaudeErrorPayload(responseFormat, out); okErr {
+ err = statusErr{code: http.StatusBadGateway, msg: errPayload}
+ reporter.PublishFailureWithDetail(ctx, helps.ParseGeminiUsage(data), err)
+ return cliproxyexecutor.Response{}, err
+ }
reporter.Publish(ctx, helps.ParseGeminiUsage(data))
if responseFormat == sdktranslator.FormatOpenAIResponse {
out = helps.EnsureResponsesUsageDetails(out)
@@ -352,7 +357,7 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
err = statusErr{code: httpResp.StatusCode, msg: string(b)}
return nil, err
}
- out := make(chan cliproxyexecutor.StreamChunk)
+ out := make(chan cliproxyexecutor.StreamChunk, 1)
go func() {
defer close(out)
defer reporter.EnsurePublished(ctx)
@@ -366,22 +371,34 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
claudeInputTokens := helps.NewClaudeInputTokenState(from, to, responseFormat, originalPayload)
var streamUsage helps.StreamUsageBuffer
defer streamUsage.Publish(ctx, reporter)
+ var streamErr error
+ defer func() {
+ _ = helps.StopTranslatedClaudeStream(ctx, &streamErr, reporter, &streamUsage, out)
+ }()
var param any
+ var sawFinishReason bool
helps.InitializeApplyPatchStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, ¶m)
for scanner.Scan() {
line := scanner.Bytes()
helps.AppendAPIResponseChunk(ctx, e.cfg, line)
reporter.ObserveResponseModel(line)
+ rawPayload := helps.JSONPayload(line)
+ if !sawFinishReason && geminiStreamHasFinishSignal(rawPayload) {
+ sawFinishReason = true
+ }
+ if detail, ok := helps.ParseGeminiStreamUsage(rawPayload); ok {
+ streamUsage.Observe(detail, true)
+ }
filtered := helps.FilterSSEUsageMetadata(line)
payload := helps.JSONPayload(filtered)
if len(payload) == 0 {
continue
}
- if detail, ok := helps.ParseGeminiStreamUsage(payload); ok {
- streamUsage.Observe(detail, true)
- }
lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, bytes.Clone(payload), ¶m, claudeInputTokens)
helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage})
+ helps.CaptureTranslatedClaudeStreamError(responseFormat, lines, &streamErr, func(msg string) error {
+ return statusErr{code: http.StatusBadGateway, msg: msg}
+ })
for i := range lines {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}:
@@ -396,6 +413,9 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
if helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) {
return
}
+ if helps.StopTranslatedClaudeStream(ctx, &streamErr, reporter, &streamUsage, out) {
+ return
+ }
if errScan := scanner.Err(); errScan != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errScan)
reporter.PublishFailure(ctx, errScan)
@@ -408,8 +428,23 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
if ctx.Err() != nil {
return
}
+ if responseFormat == sdktranslator.FormatClaude && !sawFinishReason {
+ truncErr := statusErr{code: http.StatusBadGateway, msg: "upstream stream closed before finishReason"}
+ helps.RecordAPIResponseError(ctx, e.cfg, truncErr)
+ if !streamUsage.PublishFailure(ctx, reporter, truncErr) {
+ reporter.PublishFailure(ctx, truncErr)
+ }
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Err: truncErr}:
+ case <-ctx.Done():
+ }
+ return
+ }
lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, []byte("[DONE]"), ¶m, claudeInputTokens)
helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage})
+ helps.CaptureTranslatedClaudeStreamError(responseFormat, lines, &streamErr, func(msg string) error {
+ return statusErr{code: http.StatusBadGateway, msg: msg}
+ })
for i := range lines {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}:
@@ -420,10 +455,37 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) {
return
}
+ if helps.StopTranslatedClaudeStream(ctx, &streamErr, reporter, &streamUsage, out) {
+ return
+ }
}()
return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil
}
+func geminiStreamHasFinishSignal(payload []byte) bool {
+ if len(payload) == 0 {
+ return false
+ }
+ for _, path := range []string{
+ "candidates.#.finishReason",
+ "response.candidates.#.finishReason",
+ "promptFeedback.blockReason",
+ "response.promptFeedback.blockReason",
+ } {
+ res := gjson.GetBytes(payload, path)
+ if res.IsArray() {
+ for _, item := range res.Array() {
+ if strings.TrimSpace(item.String()) != "" {
+ return true
+ }
+ }
+ } else if res.Exists() && strings.TrimSpace(res.String()) != "" {
+ return true
+ }
+ }
+ return false
+}
+
func (e *GeminiExecutor) executeInteractions(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
targetName := thinking.ParseSuffix(req.Model).ModelName
apiKey := geminiAPIKey(auth)
diff --git a/internal/runtime/executor/gemini_executor_test.go b/internal/runtime/executor/gemini_executor_test.go
index 868b2ff7a50..db15c4250f9 100644
--- a/internal/runtime/executor/gemini_executor_test.go
+++ b/internal/runtime/executor/gemini_executor_test.go
@@ -6,12 +6,16 @@ import (
"io"
"net/http"
"net/http/httptest"
+ "strings"
"testing"
+ "time"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/translator"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
"github.com/tidwall/gjson"
)
@@ -1402,3 +1406,252 @@ func TestGeminiExecutor_PrepareRequest_EmptyAPIKey_OmitsAuthHeaders(t *testing.T
t.Fatalf("Custom-Token = %q, want gemini-secret", got)
}
}
+
+type malformedFunctionCallUsageCapture struct {
+ authID string
+ records chan usage.Record
+}
+
+func (c *malformedFunctionCallUsageCapture) HandleUsage(_ context.Context, record usage.Record) {
+ if c == nil || record.AuthID != c.authID {
+ return
+ }
+ select {
+ case c.records <- record:
+ default:
+ }
+}
+
+type malformedFunctionCallUsageNoop struct{}
+
+func (malformedFunctionCallUsageNoop) HandleUsage(context.Context, usage.Record) {}
+
+func assertMalformedFunctionCallFailedUsage(t *testing.T, records <-chan usage.Record) {
+ t.Helper()
+ var record usage.Record
+ select {
+ case record = <-records:
+ case <-time.After(3 * time.Second):
+ t.Fatal("timed out waiting for usage record")
+ }
+ if !record.Failed {
+ t.Fatalf("usage record Failed = false, want true; record=%+v", record)
+ }
+ if record.Detail.InputTokens != 120 || record.Detail.OutputTokens != 10 || record.Detail.ReasoningTokens != 313 || record.Detail.TotalTokens != 443 {
+ t.Fatalf("usage detail = %+v, want Input=120 Output=10 Reasoning=313 Total=443", record.Detail)
+ }
+}
+
+func TestGeminiExecutorClaudeMalformedFunctionCallReturnsRetryableError(t *testing.T) {
+ malformedBody := `{"candidates":[{"content":{"role":"model","parts":[]},"finishReason":"MALFORMED_FUNCTION_CALL"}],"usageMetadata":{"promptTokenCount":120,"candidatesTokenCount":10,"thoughtsTokenCount":313,"totalTokenCount":443},"modelVersion":"gemini-4-argon"}`
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.Contains(r.URL.Path, ":streamGenerateContent") {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte("data: " + malformedBody + "\n\n"))
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(malformedBody))
+ }))
+ defer server.Close()
+
+ exec := NewGeminiExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{
+ ID: t.Name(),
+ Provider: "gemini",
+ Attributes: map[string]string{
+ "api_key": "test-key",
+ "base_url": server.URL,
+ },
+ }
+ capture := &malformedFunctionCallUsageCapture{authID: auth.ID, records: make(chan usage.Record, 2)}
+ usage.RegisterNamedPlugin(t.Name(), capture)
+ t.Cleanup(func() {
+ usage.RegisterNamedPlugin(t.Name(), malformedFunctionCallUsageNoop{})
+ })
+
+ req := cliproxyexecutor.Request{
+ Model: "gemini-4-argon",
+ Payload: []byte(`{"model":"gemini-4-argon","max_tokens":1024,"messages":[{"role":"user","content":"hi"}]}`),
+ }
+ opts := cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatClaude,
+ ResponseFormat: sdktranslator.FormatClaude,
+ }
+
+ _, errExec := exec.Execute(context.Background(), auth, req, opts)
+ if errExec == nil {
+ t.Fatal("Execute() error = nil, want retryable 502 error for MALFORMED_FUNCTION_CALL")
+ }
+ status, okStatus := errExec.(interface{ StatusCode() int })
+ if !okStatus || status.StatusCode() != http.StatusBadGateway {
+ t.Fatalf("Execute() error = %T %v, want status 502", errExec, errExec)
+ }
+ if got := gjson.Get(errExec.Error(), "error.type").String(); got != "api_error" {
+ t.Fatalf("Execute() error.type = %q, want api_error. Error: %s", got, errExec.Error())
+ }
+ assertMalformedFunctionCallFailedUsage(t, capture.records)
+
+ streamRes, errStream := exec.ExecuteStream(context.Background(), auth, req, opts)
+ if errStream != nil {
+ t.Fatalf("ExecuteStream() error = %v", errStream)
+ }
+ var combined bytes.Buffer
+ var streamErr error
+ for chunk := range streamRes.Chunks {
+ if chunk.Err != nil {
+ streamErr = chunk.Err
+ continue
+ }
+ combined.Write(chunk.Payload)
+ }
+ if streamErr == nil {
+ t.Fatal("ExecuteStream() chunk.Err = nil, want 502 error propagated for conductor/handler accounting")
+ }
+ streamStatus, okStreamStatus := streamErr.(interface{ StatusCode() int })
+ if !okStreamStatus || streamStatus.StatusCode() != http.StatusBadGateway {
+ t.Fatalf("ExecuteStream() chunk.Err = %T %v, want status 502", streamErr, streamErr)
+ }
+ streamText := combined.String()
+ if strings.Contains(streamText, `"stop_reason":"refusal"`) {
+ t.Fatalf("stream unexpectedly emitted stop_reason refusal:\n%s", streamText)
+ }
+ if !strings.Contains(streamText, "event: error\n") || !strings.Contains(streamText, `"type":"api_error"`) {
+ t.Fatalf("stream missing retryable api_error event:\n%s", streamText)
+ }
+ assertMalformedFunctionCallFailedUsage(t, capture.records)
+}
+
+func TestGeminiExecutorClaudeMalformedFunctionCallSplitUsageAndConductorFailure(t *testing.T) {
+ finishChunk := `{"candidates":[{"content":{"role":"model","parts":[]},"finishReason":"MALFORMED_FUNCTION_CALL"}],"modelVersion":"gemini-4-argon"}`
+ usageChunk := `{"usageMetadata":{"promptTokenCount":120,"candidatesTokenCount":10,"thoughtsTokenCount":313,"totalTokenCount":443},"modelVersion":"gemini-4-argon"}`
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte("data: " + finishChunk + "\n\ndata: " + usageChunk + "\n\n"))
+ }))
+ defer server.Close()
+
+ exec := NewGeminiExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{
+ ID: t.Name(),
+ Provider: "gemini",
+ Status: cliproxyauth.StatusActive,
+ Attributes: map[string]string{
+ "api_key": "test-key",
+ "base_url": server.URL,
+ },
+ }
+ capture := &malformedFunctionCallUsageCapture{authID: auth.ID, records: make(chan usage.Record, 1)}
+ usage.RegisterNamedPlugin(t.Name(), capture)
+ t.Cleanup(func() {
+ usage.RegisterNamedPlugin(t.Name(), malformedFunctionCallUsageNoop{})
+ })
+
+ manager := cliproxyauth.NewManager(nil, nil, nil)
+ manager.RegisterExecutor(exec)
+ if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
+ t.Fatalf("Register() error = %v", errRegister)
+ }
+ registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: "gemini-4-argon"}})
+ t.Cleanup(func() {
+ registry.GetGlobalRegistry().UnregisterClient(auth.ID)
+ })
+
+ req := cliproxyexecutor.Request{
+ Model: "gemini-4-argon",
+ Payload: []byte(`{"model":"gemini-4-argon","max_tokens":1024,"messages":[{"role":"user","content":"hi"}]}`),
+ }
+ opts := cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatClaude,
+ ResponseFormat: sdktranslator.FormatClaude,
+ Stream: true,
+ }
+
+ streamRes, errStream := manager.ExecuteStream(context.Background(), []string{"gemini"}, req, opts)
+ if errStream != nil {
+ t.Fatalf("manager.ExecuteStream() error = %v", errStream)
+ }
+ var sawChunkErr bool
+ for chunk := range streamRes.Chunks {
+ if chunk.Err != nil {
+ sawChunkErr = true
+ }
+ }
+ if !sawChunkErr {
+ t.Fatal("expected manager.ExecuteStream to receive terminal chunk.Err")
+ }
+ assertMalformedFunctionCallFailedUsage(t, capture.records)
+ updated, okAuth := manager.GetByID(auth.ID)
+ if !okAuth || updated.ModelStates["gemini-4-argon"] == nil || updated.ModelStates["gemini-4-argon"].LastError == nil {
+ t.Fatalf("expected conductor to record failure on model state, got %+v", updated)
+ }
+}
+
+func TestGeminiExecutorClaudeMalformedFunctionCallCancelAfterErrorPreservesUsageAndConductorFailure(t *testing.T) {
+ malformedBody := `{"candidates":[{"content":{"role":"model","parts":[]},"finishReason":"MALFORMED_FUNCTION_CALL"}],"usageMetadata":{"promptTokenCount":120,"candidatesTokenCount":10,"thoughtsTokenCount":313,"totalTokenCount":443},"modelVersion":"gemini-4-argon"}`
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = io.WriteString(w, "data: "+malformedBody+"\n\n")
+ if flusher, okFlush := w.(http.Flusher); okFlush {
+ flusher.Flush()
+ }
+ <-r.Context().Done()
+ }))
+ defer server.Close()
+
+ exec := NewGeminiExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{
+ ID: t.Name(),
+ Provider: "gemini",
+ Status: cliproxyauth.StatusActive,
+ Attributes: map[string]string{
+ "api_key": "test-key",
+ "base_url": server.URL,
+ },
+ }
+ capture := &malformedFunctionCallUsageCapture{authID: auth.ID, records: make(chan usage.Record, 1)}
+ usage.RegisterNamedPlugin(t.Name(), capture)
+ t.Cleanup(func() {
+ usage.RegisterNamedPlugin(t.Name(), malformedFunctionCallUsageNoop{})
+ })
+
+ manager := cliproxyauth.NewManager(nil, nil, nil)
+ manager.RegisterExecutor(exec)
+ if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
+ t.Fatalf("Register() error = %v", errRegister)
+ }
+ registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: "gemini-4-argon"}})
+ t.Cleanup(func() {
+ registry.GetGlobalRegistry().UnregisterClient(auth.ID)
+ })
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ req := cliproxyexecutor.Request{
+ Model: "gemini-4-argon",
+ Payload: []byte(`{"model":"gemini-4-argon","max_tokens":1024,"messages":[{"role":"user","content":"hi"}]}`),
+ }
+ opts := cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatClaude,
+ ResponseFormat: sdktranslator.FormatClaude,
+ Stream: true,
+ }
+
+ streamRes, errStream := manager.ExecuteStream(ctx, []string{"gemini"}, req, opts)
+ if errStream != nil {
+ t.Fatalf("manager.ExecuteStream() error = %v", errStream)
+ }
+ for chunk := range streamRes.Chunks {
+ if strings.Contains(string(chunk.Payload), "event: error\n") {
+ cancel()
+ }
+ }
+ assertMalformedFunctionCallFailedUsage(t, capture.records)
+ updated, okAuth := manager.GetByID(auth.ID)
+ if !okAuth || updated.ModelStates["gemini-4-argon"] == nil || updated.ModelStates["gemini-4-argon"].LastError == nil {
+ t.Fatalf("expected conductor to record failure on model state after client cancel on error event, got %+v", updated)
+ }
+ if updated.ModelStates["gemini-4-argon"].LastError.HTTPStatus != http.StatusBadGateway {
+ t.Fatalf("LastError.HTTPStatus = %d, want 502; err=%+v", updated.ModelStates["gemini-4-argon"].LastError.HTTPStatus, updated.ModelStates["gemini-4-argon"].LastError)
+ }
+}
diff --git a/internal/runtime/executor/gemini_vertex_executor.go b/internal/runtime/executor/gemini_vertex_executor.go
index a11f4b79fd9..79309c01c97 100644
--- a/internal/runtime/executor/gemini_vertex_executor.go
+++ b/internal/runtime/executor/gemini_vertex_executor.go
@@ -269,6 +269,9 @@ func (e *GeminiVertexExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
if opts.Alt == "responses/compact" {
return resp, statusErr{code: http.StatusNotImplemented, msg: "/responses/compact not supported"}
}
+ if helps.ShouldExecuteVertexOpenAPI(auth, req.Model) {
+ return e.executeOpenAPI(ctx, auth, req, opts)
+ }
if shouldExecuteVertexInteractions(auth, opts) {
return e.executeInteractions(ctx, auth, req, opts)
}
@@ -294,6 +297,9 @@ func (e *GeminiVertexExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
if opts.Alt == "responses/compact" {
return nil, statusErr{code: http.StatusNotImplemented, msg: "/responses/compact not supported"}
}
+ if helps.ShouldExecuteVertexOpenAPI(auth, req.Model) {
+ return e.executeOpenAPIStream(ctx, auth, req, opts)
+ }
if shouldExecuteVertexInteractions(auth, opts) {
return e.executeInteractionsStream(ctx, auth, req, opts)
}
@@ -315,6 +321,10 @@ func (e *GeminiVertexExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
// CountTokens counts tokens for the given request using the Vertex AI API.
func (e *GeminiVertexExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
+ if helps.ShouldExecuteVertexOpenAPI(auth, req.Model) {
+ normalizedReq, normalizedOpts := normalizeVertexOpenAPIRequest(req, opts)
+ return NewOpenAICompatExecutor(e.Identifier(), e.cfg).CountTokens(ctx, auth, normalizedReq, normalizedOpts)
+ }
// Try API key authentication first
apiKey, baseURL := vertexAPICreds(auth)
@@ -331,6 +341,77 @@ func (e *GeminiVertexExecutor) CountTokens(ctx context.Context, auth *cliproxyau
return e.countTokensWithAPIKey(ctx, auth, req, opts, apiKey, baseURL)
}
+func normalizeVertexOpenAPIRequest(req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Request, cliproxyexecutor.Options) {
+ normalizedModel := helps.NormalizeVertexOpenAPIModel(req.Model)
+ if normalizedModel == "" || normalizedModel == req.Model {
+ return req, opts
+ }
+ if len(opts.OriginalRequest) == 0 && len(req.Payload) > 0 {
+ opts.OriginalRequest = req.Payload
+ }
+ req.Model = normalizedModel
+ return req, opts
+}
+
+func hasVertexServiceAccount(auth *cliproxyauth.Auth) bool {
+ if auth == nil || auth.Metadata == nil {
+ return false
+ }
+ sa, ok := auth.Metadata["service_account"].(map[string]any)
+ return ok && len(sa) > 0
+}
+
+func (e *GeminiVertexExecutor) resolveOpenAPICredentials(ctx context.Context, auth *cliproxyauth.Auth) (string, string, error) {
+ apiKey, baseURL := vertexAPICreds(auth)
+ if apiKey == "" || hasVertexServiceAccount(auth) {
+ projectID, location, saJSON, errCreds := vertexCreds(auth)
+ if errCreds != nil {
+ return "", "", errCreds
+ }
+ token, errTok := vertexAccessToken(ctx, e.cfg, auth, saJSON)
+ if errTok != nil {
+ log.Errorf("vertex executor: access token error: %v", errTok)
+ return "", "", statusErr{code: 500, msg: "internal server error"}
+ }
+ if strings.TrimSpace(token) == "" {
+ return "", "", statusErr{code: http.StatusUnauthorized, msg: "missing access token"}
+ }
+ openAPILocation := helps.VertexOpenAPILocation(auth, location)
+ openAPIBaseURL := helps.VertexServiceAccountOpenAPIBaseURL(helps.VertexAuthBaseURL(auth), vertexAPIVersion, projectID, openAPILocation)
+ return openAPIBaseURL, token, nil
+ }
+ if helps.IsGoogleVertexHost(baseURL) {
+ return "", "", statusErr{code: http.StatusBadRequest, msg: "vertex endpoints/openapi on aiplatform.googleapis.com requires service account credentials"}
+ }
+ openAPILocation := helps.VertexOpenAPILocation(auth, "")
+ openAPIBaseURL := helps.VertexAPIKeyOpenAPIBaseURL(baseURL, vertexAPIVersion, helps.VertexAuthProjectID(auth), openAPILocation)
+ return openAPIBaseURL, apiKey, nil
+}
+
+func (e *GeminiVertexExecutor) executeOpenAPI(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
+ req, opts = normalizeVertexOpenAPIRequest(req, opts)
+ openAPIBaseURL, token, errCreds := e.resolveOpenAPICredentials(ctx, auth)
+ if errCreds != nil {
+ baseModel := thinking.ParseSuffix(req.Model).ModelName
+ reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
+ reporter.TrackFailure(ctx, &errCreds)
+ return resp, errCreds
+ }
+ return NewOpenAICompatExecutor(e.Identifier(), e.cfg).executeWithCredentials(ctx, auth, req, opts, openAPIBaseURL, token, nil)
+}
+
+func (e *GeminiVertexExecutor) executeOpenAPIStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {
+ req, opts = normalizeVertexOpenAPIRequest(req, opts)
+ openAPIBaseURL, token, errCreds := e.resolveOpenAPICredentials(ctx, auth)
+ if errCreds != nil {
+ baseModel := thinking.ParseSuffix(req.Model).ModelName
+ reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
+ reporter.TrackFailure(ctx, &errCreds)
+ return nil, errCreds
+ }
+ return NewOpenAICompatExecutor(e.Identifier(), e.cfg).executeStreamWithCredentials(ctx, auth, req, opts, openAPIBaseURL, token, nil)
+}
+
// Refresh refreshes the authentication credentials (no-op for Vertex).
func (e *GeminiVertexExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
if refreshed, handled, err := helps.RefreshAuthViaHome(ctx, e.cfg, auth); handled {
@@ -399,6 +480,7 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au
}
body = helps.EnsureGeminiLeadingUserContent(body, "contents")
if action != "countTokens" {
+ body = helps.DropGeminiTrailingThoughtOnlyContent(body, "contents")
body = helps.EnsureGeminiTrailingUserContent(body, "contents")
}
baseURL := vertexBaseURL(location)
@@ -488,6 +570,11 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au
if helps.ApplyPatchTranslationError(param) != nil || len(out) == 0 {
return cliproxyexecutor.Response{}, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}
}
+ if errPayload, okErr := helps.TranslatedClaudeErrorPayload(responseFormat, out); okErr {
+ err = statusErr{code: http.StatusBadGateway, msg: errPayload}
+ reporter.PublishFailureWithDetail(ctx, helps.ParseGeminiUsage(data), err)
+ return cliproxyexecutor.Response{}, err
+ }
reporter.Publish(ctx, helps.ParseGeminiUsage(data))
if responseFormat == sdktranslator.FormatOpenAIResponse {
out = helps.EnsureResponsesUsageDetails(out)
@@ -538,14 +625,15 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip
}
body = helps.EnsureGeminiLeadingUserContent(body, "contents")
if action != "countTokens" {
+ body = helps.DropGeminiTrailingThoughtOnlyContent(body, "contents")
body = helps.EnsureGeminiTrailingUserContent(body, "contents")
}
- // For API key auth, use simpler URL format without project/location
- if baseURL == "" {
- baseURL = "https://aiplatform.googleapis.com"
- }
- url := fmt.Sprintf("%s/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, baseModel, action)
+ // For API key auth, use simpler URL format without project/location.
+ // The helper honors base URLs that already encode "/publishers/..." so
+ // configurations like ".../v1/projects/P/locations/L/publishers/google/models"
+ // keep working without a duplicated "/v1" segment.
+ url := vertexAPIKeyModelsURL(baseURL, baseModel, action)
if opts.Alt != "" && action != "countTokens" {
url = url + fmt.Sprintf("?$alt=%s", opts.Alt)
}
@@ -618,6 +706,11 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip
if helps.ApplyPatchTranslationError(param) != nil || len(out) == 0 {
return cliproxyexecutor.Response{}, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}
}
+ if errPayload, okErr := helps.TranslatedClaudeErrorPayload(responseFormat, out); okErr {
+ err = statusErr{code: http.StatusBadGateway, msg: errPayload}
+ reporter.PublishFailureWithDetail(ctx, helps.ParseGeminiUsage(data), err)
+ return cliproxyexecutor.Response{}, err
+ }
reporter.Publish(ctx, helps.ParseGeminiUsage(data))
if responseFormat == sdktranslator.FormatOpenAIResponse {
out = helps.EnsureResponsesUsageDetails(out)
@@ -661,6 +754,7 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte
body = internalsignature.SanitizeGeminiRequestThoughtSignatures(body, "contents")
action := getVertexAction(baseModel, true)
+ body = helps.DropGeminiTrailingThoughtOnlyContent(body, "contents")
body = helps.EnsureGeminiBoundaryUserContent(body, "contents")
baseURL := vertexBaseURL(location)
url := fmt.Sprintf("%s/%s/projects/%s/locations/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, projectID, location, baseModel, action)
@@ -730,7 +824,7 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte
return nil, statusErr{code: httpResp.StatusCode, msg: string(b)}
}
- out := make(chan cliproxyexecutor.StreamChunk)
+ out := make(chan cliproxyexecutor.StreamChunk, 1)
go func() {
defer close(out)
defer reporter.EnsurePublished(ctx)
@@ -744,18 +838,29 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte
claudeInputTokens := helps.NewClaudeInputTokenState(from, to, responseFormat, originalPayload)
var streamUsage helps.StreamUsageBuffer
defer streamUsage.Publish(ctx, reporter)
+ var streamErr error
+ defer func() {
+ _ = helps.StopTranslatedClaudeStream(ctx, &streamErr, reporter, &streamUsage, out)
+ }()
var terminalDelivered bool
+ var sawFinishReason bool
var param any
helps.InitializeApplyPatchStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, ¶m)
for scanner.Scan() {
line := scanner.Bytes()
helps.AppendAPIResponseChunk(ctx, e.cfg, line)
reporter.ObserveResponseModel(line)
+ if !sawFinishReason && geminiStreamHasFinishSignal(helps.JSONPayload(line)) {
+ sawFinishReason = true
+ }
if detail, ok := helps.ParseGeminiStreamUsage(line); ok {
streamUsage.Observe(detail, true)
}
lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, bytes.Clone(line), ¶m, claudeInputTokens)
helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage})
+ helps.CaptureTranslatedClaudeStreamError(responseFormat, lines, &streamErr, func(msg string) error {
+ return statusErr{code: http.StatusBadGateway, msg: msg}
+ })
for i := range lines {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}:
@@ -773,6 +878,9 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte
if helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) {
return
}
+ if helps.StopTranslatedClaudeStream(ctx, &streamErr, reporter, &streamUsage, out) {
+ return
+ }
if errScan := scanner.Err(); errScan != nil {
if terminalDelivered && errors.Is(errScan, context.Canceled) {
return
@@ -788,8 +896,23 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte
if ctx.Err() != nil {
return
}
+ if responseFormat == sdktranslator.FormatClaude && !sawFinishReason {
+ truncErr := statusErr{code: http.StatusBadGateway, msg: "upstream stream closed before finishReason"}
+ helps.RecordAPIResponseError(ctx, e.cfg, truncErr)
+ if !streamUsage.PublishFailure(ctx, reporter, truncErr) {
+ reporter.PublishFailure(ctx, truncErr)
+ }
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Err: truncErr}:
+ case <-ctx.Done():
+ }
+ return
+ }
lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, []byte("[DONE]"), ¶m, claudeInputTokens)
helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage})
+ helps.CaptureTranslatedClaudeStreamError(responseFormat, lines, &streamErr, func(msg string) error {
+ return statusErr{code: http.StatusBadGateway, msg: msg}
+ })
for i := range lines {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}:
@@ -800,6 +923,9 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte
if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) {
return
}
+ if helps.StopTranslatedClaudeStream(ctx, &streamErr, reporter, &streamUsage, out) {
+ return
+ }
}()
return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil
}
@@ -839,12 +965,10 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth
body = internalsignature.SanitizeGeminiRequestThoughtSignatures(body, "contents")
action := getVertexAction(baseModel, true)
+ body = helps.DropGeminiTrailingThoughtOnlyContent(body, "contents")
body = helps.EnsureGeminiBoundaryUserContent(body, "contents")
// For API key auth, use simpler URL format without project/location
- if baseURL == "" {
- baseURL = "https://aiplatform.googleapis.com"
- }
- url := fmt.Sprintf("%s/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, baseModel, action)
+ url := vertexAPIKeyModelsURL(baseURL, baseModel, action)
// Imagen models don't support streaming, skip SSE params
if !isImagenModel(baseModel) {
if opts.Alt == "" {
@@ -908,7 +1032,7 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth
return nil, statusErr{code: httpResp.StatusCode, msg: string(b)}
}
- out := make(chan cliproxyexecutor.StreamChunk)
+ out := make(chan cliproxyexecutor.StreamChunk, 1)
go func() {
defer close(out)
defer reporter.EnsurePublished(ctx)
@@ -922,18 +1046,29 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth
claudeInputTokens := helps.NewClaudeInputTokenState(from, to, responseFormat, originalPayload)
var streamUsage helps.StreamUsageBuffer
defer streamUsage.Publish(ctx, reporter)
+ var streamErr error
+ defer func() {
+ _ = helps.StopTranslatedClaudeStream(ctx, &streamErr, reporter, &streamUsage, out)
+ }()
var terminalDelivered bool
+ var sawFinishReason bool
var param any
helps.InitializeApplyPatchStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, ¶m)
for scanner.Scan() {
line := scanner.Bytes()
helps.AppendAPIResponseChunk(ctx, e.cfg, line)
reporter.ObserveResponseModel(line)
+ if !sawFinishReason && geminiStreamHasFinishSignal(helps.JSONPayload(line)) {
+ sawFinishReason = true
+ }
if detail, ok := helps.ParseGeminiStreamUsage(line); ok {
streamUsage.Observe(detail, true)
}
lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, bytes.Clone(line), ¶m, claudeInputTokens)
helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage})
+ helps.CaptureTranslatedClaudeStreamError(responseFormat, lines, &streamErr, func(msg string) error {
+ return statusErr{code: http.StatusBadGateway, msg: msg}
+ })
for i := range lines {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}:
@@ -951,6 +1086,9 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth
if helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) {
return
}
+ if helps.StopTranslatedClaudeStream(ctx, &streamErr, reporter, &streamUsage, out) {
+ return
+ }
if errScan := scanner.Err(); errScan != nil {
if terminalDelivered && errors.Is(errScan, context.Canceled) {
return
@@ -966,8 +1104,23 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth
if ctx.Err() != nil {
return
}
+ if responseFormat == sdktranslator.FormatClaude && !sawFinishReason {
+ truncErr := statusErr{code: http.StatusBadGateway, msg: "upstream stream closed before finishReason"}
+ helps.RecordAPIResponseError(ctx, e.cfg, truncErr)
+ if !streamUsage.PublishFailure(ctx, reporter, truncErr) {
+ reporter.PublishFailure(ctx, truncErr)
+ }
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Err: truncErr}:
+ case <-ctx.Done():
+ }
+ return
+ }
lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, []byte("[DONE]"), ¶m, claudeInputTokens)
helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage})
+ helps.CaptureTranslatedClaudeStreamError(responseFormat, lines, &streamErr, func(msg string) error {
+ return statusErr{code: http.StatusBadGateway, msg: msg}
+ })
for i := range lines {
select {
case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}:
@@ -978,6 +1131,9 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth
if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) {
return
}
+ if helps.StopTranslatedClaudeStream(ctx, &streamErr, reporter, &streamUsage, out) {
+ return
+ }
}()
return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil
}
@@ -1117,10 +1273,7 @@ func (e *GeminiVertexExecutor) countTokensWithAPIKey(ctx context.Context, auth *
translatedReq = helps.EnsureGeminiLeadingUserContent(translatedReq, "contents")
// For API key auth, use simpler URL format without project/location
- if baseURL == "" {
- baseURL = "https://aiplatform.googleapis.com"
- }
- url := fmt.Sprintf("%s/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, baseModel, "countTokens")
+ url := vertexAPIKeyModelsURL(baseURL, baseModel, "countTokens")
translatedReq = helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslatedForPayload, req, opts)(translatedReq)
httpReq, errNewReq := http.NewRequestWithContext(respCtx, http.MethodPost, url, bytes.NewReader(translatedReq))
@@ -1243,6 +1396,54 @@ func vertexAPICreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
return
}
+// vertexAPIKeyModelsURL builds the API-key models endpoint URL.
+//
+// The configured base URL is treated as a full resource prefix when it
+// already contains a "/publishers/" segment (e.g. a real Vertex endpoint
+// such as "https://aiplatform.googleapis.com/v1/projects/{p}/locations/{l}/publishers/google/models"),
+// in which case only "/{model}:{action}" is appended. Otherwise the default
+// "/v1/publishers/google/models" segment is inserted so short third-party
+// hosts keep working; if the base already ends in a version segment such
+// as "/v1" or "/v1beta1", only "/publishers/google/models" is appended to
+// avoid producing a duplicated version path.
+func vertexAPIKeyModelsURL(baseURL, model, action string) string {
+ base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
+ if base == "" {
+ base = "https://aiplatform.googleapis.com"
+ }
+ if strings.Contains(base, "/publishers/") {
+ return fmt.Sprintf("%s/%s:%s", base, model, action)
+ }
+ if !vertexAPIVersionSuffix(base) {
+ base = fmt.Sprintf("%s/%s", base, vertexAPIVersion)
+ }
+ return fmt.Sprintf("%s/publishers/google/models/%s:%s", base, model, action)
+}
+
+// vertexAPIVersionSuffix reports whether the trimmed base URL already ends
+// with a Google API version segment like "/v1" or "/v1beta1".
+func vertexAPIVersionSuffix(base string) bool {
+ if base == "" {
+ return false
+ }
+ seg := base[strings.LastIndexByte(base, '/')+1:]
+ if len(seg) < 2 || seg[0] != 'v' {
+ return false
+ }
+ hasDigit := false
+ for i := 1; i < len(seg); i++ {
+ c := seg[i]
+ switch {
+ case c >= '0' && c <= '9':
+ hasDigit = true
+ case c == '_' || c == '-' || (c >= 'a' && c <= 'z'):
+ default:
+ return false
+ }
+ }
+ return hasDigit
+}
+
func vertexBaseURL(location string) string {
loc := strings.TrimSpace(location)
if loc == "" {
diff --git a/internal/runtime/executor/gemini_vertex_executor_openapi_test.go b/internal/runtime/executor/gemini_vertex_executor_openapi_test.go
new file mode 100644
index 00000000000..fcfa9fd1a0a
--- /dev/null
+++ b/internal/runtime/executor/gemini_vertex_executor_openapi_test.go
@@ -0,0 +1,607 @@
+package executor
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
+ "github.com/tidwall/gjson"
+)
+
+func TestGeminiVertexOpenAPI_ServiceAccount_Execute(t *testing.T) {
+ var mu sync.Mutex
+ var gotPath string
+ var gotMethod string
+ var gotAuth string
+ var upstreamBody []byte
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/token" {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"access_token":"mock-vertex-sa-token","token_type":"Bearer","expires_in":3600}`))
+ return
+ }
+ mu.Lock()
+ gotPath = r.URL.Path
+ gotMethod = r.Method
+ gotAuth = r.Header.Get("Authorization")
+ body, _ := io.ReadAll(r.Body)
+ upstreamBody = body
+ mu.Unlock()
+
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{
+ "id":"chatcmpl-vertex-maas-1",
+ "object":"chat.completion",
+ "created":1700000000,
+ "model":"deepseek-ai/deepseek-v4.1-flash-maas",
+ "choices":[{"index":0,"message":{"role":"assistant","content":"hello from vertex maas"},"finish_reason":"stop"}],
+ "usage":{"prompt_tokens":8,"completion_tokens":5,"total_tokens":13}
+ }`))
+ }))
+ defer server.Close()
+
+ targetURL, errParse := url.Parse(server.URL)
+ if errParse != nil {
+ t.Fatal(errParse)
+ }
+ transport := http.DefaultTransport.(*http.Transport).Clone()
+ transport.Proxy = nil
+ t.Cleanup(transport.CloseIdleConnections)
+
+ gin.SetMode(gin.TestMode)
+ ginCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
+ ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", issue6258RedirectTransport{
+ target: targetURL,
+ base: transport,
+ })
+ ctx = context.WithValue(ctx, "gin", ginCtx)
+
+ var sa map[string]any
+ if errUnmarshal := json.Unmarshal(testVertexServiceAccountJSON(t, server.URL+"/token"), &sa); errUnmarshal != nil {
+ t.Fatal(errUnmarshal)
+ }
+
+ auth := &cliproxyauth.Auth{
+ ID: "vertex-sa-openapi",
+ Provider: "vertex",
+ Metadata: map[string]any{
+ "project_id": "proxy-test",
+ "location": "global",
+ "email": "vertex-sa@example.iam.gserviceaccount.com",
+ "service_account": sa,
+ },
+ }
+
+ exec := NewGeminiVertexExecutor(&config.Config{
+ SDKConfig: config.SDKConfig{RequestLog: true},
+ })
+ req := cliproxyexecutor.Request{
+ Model: "deepseek-ai/deepseek-v4.1-flash-maas",
+ Payload: []byte(`{"model":"deepseek-ai/deepseek-v4.1-flash-maas","messages":[{"role":"user","content":"hello maas"}]}`),
+ }
+ opts := cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAI,
+ ResponseFormat: sdktranslator.FormatOpenAI,
+ }
+
+ resp, errExecute := exec.Execute(ctx, auth, req, opts)
+ if errExecute != nil {
+ t.Fatalf("Execute() error = %v", errExecute)
+ }
+
+ mu.Lock()
+ defer mu.Unlock()
+
+ expectedPath := "/v1/projects/proxy-test/locations/global/endpoints/openapi/chat/completions"
+ if gotPath != expectedPath {
+ t.Fatalf("got path = %q, want %q", gotPath, expectedPath)
+ }
+ if gotMethod != http.MethodPost {
+ t.Fatalf("got method = %q, want %q", gotMethod, http.MethodPost)
+ }
+ if gotAuth != "Bearer mock-vertex-sa-token" {
+ t.Fatalf("got auth = %q, want Bearer mock-vertex-sa-token", gotAuth)
+ }
+ if gotModel := gjson.GetBytes(upstreamBody, "model").String(); gotModel != "deepseek-ai/deepseek-v4.1-flash-maas" {
+ t.Fatalf("got upstream model = %q, want deepseek-ai/deepseek-v4.1-flash-maas; body=%s", gotModel, string(upstreamBody))
+ }
+ if gotMsg := gjson.GetBytes(upstreamBody, "messages.0.content").String(); gotMsg != "hello maas" {
+ t.Fatalf("got upstream messages.0.content = %q, want hello maas; body=%s", gotMsg, string(upstreamBody))
+ }
+ if gotText := gjson.GetBytes(resp.Payload, "choices.0.message.content").String(); gotText != "hello from vertex maas" {
+ t.Fatalf("got response content = %q, want hello from vertex maas; payload=%s", gotText, string(resp.Payload))
+ }
+
+ if gotType, gotVal := auth.AccountInfo(); gotType != "oauth" || gotVal != "vertex-sa@example.iam.gserviceaccount.com" {
+ t.Fatalf("auth.AccountInfo() = (%q, %q), want (oauth, vertex-sa@example.iam.gserviceaccount.com)", gotType, gotVal)
+ }
+ recordedReq, okReq := ginCtx.Get("API_REQUEST")
+ if !okReq {
+ t.Fatal("expected API_REQUEST in gin context")
+ }
+ recordedStr := string(recordedReq.([]byte))
+ if !strings.Contains(recordedStr, "type=oauth") {
+ t.Fatalf("expected type=oauth in API_REQUEST log, got: %s", recordedStr)
+ }
+ if strings.Contains(recordedStr, "type=api_key") || strings.Contains(recordedStr, "mock-vertex-sa-token") {
+ t.Fatalf("unexpected api_key auth type or raw token in API_REQUEST log: %s", recordedStr)
+ }
+}
+
+func TestGeminiVertexOpenAPI_ModelNormalization_PublisherResourceAndExplicitOptIn(t *testing.T) {
+ var mu sync.Mutex
+ var upstreamModels []string
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/token" {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"access_token":"mock-vertex-sa-token","token_type":"Bearer","expires_in":3600}`))
+ return
+ }
+ body, _ := io.ReadAll(r.Body)
+ mu.Lock()
+ upstreamModels = append(upstreamModels, gjson.GetBytes(body, "model").String())
+ mu.Unlock()
+
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"id":"chatcmpl-norm","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`))
+ }))
+ defer server.Close()
+
+ targetURL, errParse := url.Parse(server.URL)
+ if errParse != nil {
+ t.Fatal(errParse)
+ }
+ transport := http.DefaultTransport.(*http.Transport).Clone()
+ transport.Proxy = nil
+ t.Cleanup(transport.CloseIdleConnections)
+
+ ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", issue6258RedirectTransport{
+ target: targetURL,
+ base: transport,
+ })
+
+ var sa map[string]any
+ if errUnmarshal := json.Unmarshal(testVertexServiceAccountJSON(t, server.URL+"/token"), &sa); errUnmarshal != nil {
+ t.Fatal(errUnmarshal)
+ }
+
+ exec := NewGeminiVertexExecutor(&config.Config{})
+
+ // 1. publishers//models/ normalizes to /
+ saAuth := &cliproxyauth.Auth{
+ ID: "vertex-sa-norm-1",
+ Provider: "vertex",
+ Metadata: map[string]any{
+ "project_id": "proxy-test",
+ "location": "global",
+ "service_account": sa,
+ },
+ }
+ _, errExec1 := exec.Execute(ctx, saAuth, cliproxyexecutor.Request{
+ Model: "publishers/deepseek-ai/models/deepseek-v4.1-flash-maas",
+ Payload: []byte(`{"model":"publishers/deepseek-ai/models/deepseek-v4.1-flash-maas","messages":[{"role":"user","content":"hi"}]}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAI,
+ ResponseFormat: sdktranslator.FormatOpenAI,
+ })
+ if errExec1 != nil {
+ t.Fatalf("Execute(publishers/...) error = %v", errExec1)
+ }
+
+ // 2. Explicit openapi=true with bare model normalizes to google/
+ explicitAuth := &cliproxyauth.Auth{
+ ID: "vertex-sa-norm-2",
+ Provider: "vertex",
+ Metadata: map[string]any{
+ "project_id": "proxy-test",
+ "location": "global",
+ "openapi": true,
+ "service_account": sa,
+ },
+ }
+ _, errExec2 := exec.Execute(ctx, explicitAuth, cliproxyexecutor.Request{
+ Model: "gemini-2.5-pro",
+ Payload: []byte(`{"model":"gemini-2.5-pro","messages":[{"role":"user","content":"hi"}]}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAI,
+ ResponseFormat: sdktranslator.FormatOpenAI,
+ })
+ if errExec2 != nil {
+ t.Fatalf("Execute(explicit openapi=true) error = %v", errExec2)
+ }
+
+ mu.Lock()
+ defer mu.Unlock()
+ if len(upstreamModels) != 2 {
+ t.Fatalf("expected 2 upstream calls, got %d", len(upstreamModels))
+ }
+ if upstreamModels[0] != "deepseek-ai/deepseek-v4.1-flash-maas" {
+ t.Fatalf("upstreamModels[0] = %q, want deepseek-ai/deepseek-v4.1-flash-maas", upstreamModels[0])
+ }
+ if upstreamModels[1] != "google/gemini-2.5-pro" {
+ t.Fatalf("upstreamModels[1] = %q, want google/gemini-2.5-pro", upstreamModels[1])
+ }
+}
+
+func TestGeminiVertexOpenAPI_ServiceAccount_ExecuteStream_AndOpenAPILocationOverride(t *testing.T) {
+ var mu sync.Mutex
+ var gotPath string
+ var gotAuth string
+ var upstreamBody []byte
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/token" {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"access_token":"mock-vertex-sa-token","token_type":"Bearer","expires_in":3600}`))
+ return
+ }
+ mu.Lock()
+ gotPath = r.URL.Path
+ gotAuth = r.Header.Get("Authorization")
+ body, _ := io.ReadAll(r.Body)
+ upstreamBody = body
+ mu.Unlock()
+
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-stream-1\",\"object\":\"chat.completion.chunk\",\"model\":\"qwen/qwen3-coder-480b-a35b-instruct-maas\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"stream ok\"},\"finish_reason\":null}]}\n\n"))
+ _, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-stream-1\",\"object\":\"chat.completion.chunk\",\"model\":\"qwen/qwen3-coder-480b-a35b-instruct-maas\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":4,\"completion_tokens\":2,\"total_tokens\":6}}\n\n"))
+ _, _ = w.Write([]byte("data: [DONE]\n\n"))
+ }))
+ defer server.Close()
+
+ targetURL, errParse := url.Parse(server.URL)
+ if errParse != nil {
+ t.Fatal(errParse)
+ }
+ transport := http.DefaultTransport.(*http.Transport).Clone()
+ transport.Proxy = nil
+ t.Cleanup(transport.CloseIdleConnections)
+
+ ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", issue6258RedirectTransport{
+ target: targetURL,
+ base: transport,
+ })
+
+ var sa map[string]any
+ if errUnmarshal := json.Unmarshal(testVertexServiceAccountJSON(t, server.URL+"/token"), &sa); errUnmarshal != nil {
+ t.Fatal(errUnmarshal)
+ }
+
+ auth := &cliproxyauth.Auth{
+ ID: "vertex-sa-openapi-stream",
+ Provider: "vertex",
+ Metadata: map[string]any{
+ "project_id": "proxy-test",
+ "location": "us-central1",
+ "openapi_location": "global",
+ "service_account": sa,
+ },
+ }
+
+ exec := NewGeminiVertexExecutor(&config.Config{})
+ req := cliproxyexecutor.Request{
+ Model: "qwen/qwen3-coder-480b-a35b-instruct-maas",
+ Payload: []byte(`{"model":"qwen/qwen3-coder-480b-a35b-instruct-maas","messages":[{"role":"user","content":"stream test"}],"stream":true}`),
+ }
+ opts := cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAI,
+ ResponseFormat: sdktranslator.FormatOpenAI,
+ Stream: true,
+ }
+
+ stream, errStream := exec.ExecuteStream(ctx, auth, req, opts)
+ if errStream != nil {
+ t.Fatalf("ExecuteStream() error = %v", errStream)
+ }
+
+ var output strings.Builder
+ for chunk := range stream.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("stream chunk error: %v", chunk.Err)
+ }
+ output.Write(chunk.Payload)
+ }
+
+ mu.Lock()
+ defer mu.Unlock()
+
+ expectedPath := "/v1/projects/proxy-test/locations/global/endpoints/openapi/chat/completions"
+ if gotPath != expectedPath {
+ t.Fatalf("got path = %q, want %q", gotPath, expectedPath)
+ }
+ if gotAuth != "Bearer mock-vertex-sa-token" {
+ t.Fatalf("got auth = %q, want Bearer mock-vertex-sa-token", gotAuth)
+ }
+ if !gjson.GetBytes(upstreamBody, "stream_options.include_usage").Bool() {
+ t.Fatalf("expected stream_options.include_usage=true in upstream body: %s", string(upstreamBody))
+ }
+ if !strings.Contains(output.String(), "stream ok") {
+ t.Fatalf("expected stream output to contain 'stream ok', got: %s", output.String())
+ }
+}
+
+func TestGeminiVertexOpenAPI_PayloadRulesOrdering(t *testing.T) {
+ var mu sync.Mutex
+ var nonStreamBody []byte
+ var streamBody []byte
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/token" {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"access_token":"mock-vertex-sa-token","token_type":"Bearer","expires_in":3600}`))
+ return
+ }
+ body, _ := io.ReadAll(r.Body)
+ if r.Header.Get("Accept") == "text/event-stream" {
+ mu.Lock()
+ streamBody = body
+ mu.Unlock()
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n"))
+ return
+ }
+ mu.Lock()
+ nonStreamBody = body
+ mu.Unlock()
+
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"id":"chatcmpl-1","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`))
+ }))
+ defer server.Close()
+
+ targetURL, errParse := url.Parse(server.URL)
+ if errParse != nil {
+ t.Fatal(errParse)
+ }
+ transport := http.DefaultTransport.(*http.Transport).Clone()
+ transport.Proxy = nil
+ t.Cleanup(transport.CloseIdleConnections)
+
+ ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", issue6258RedirectTransport{
+ target: targetURL,
+ base: transport,
+ })
+
+ var sa map[string]any
+ if errUnmarshal := json.Unmarshal(testVertexServiceAccountJSON(t, server.URL+"/token"), &sa); errUnmarshal != nil {
+ t.Fatal(errUnmarshal)
+ }
+
+ exec := NewGeminiVertexExecutor(&config.Config{
+ Payload: config.PayloadConfig{
+ Override: []config.PayloadRule{
+ {
+ Models: []config.PayloadModelRule{
+ {Name: "deepseek-ai/deepseek-v4.1-flash-maas", Protocol: "openai"},
+ },
+ Params: map[string]any{
+ "temperature": 0.25,
+ "custom_openapi_param": "final_barrier_applied",
+ "stream_options.include_usage": false,
+ },
+ },
+ },
+ Filter: []config.PayloadFilterRule{
+ {
+ Models: []config.PayloadModelRule{
+ {Name: "deepseek-ai/deepseek-v4.1-flash-maas", Protocol: "openai"},
+ },
+ Params: []string{"top_p"},
+ },
+ },
+ },
+ })
+ auth := &cliproxyauth.Auth{
+ ID: "vertex-sa-openapi-rules",
+ Provider: "vertex",
+ Metadata: map[string]any{
+ "project_id": "proxy-test",
+ "location": "global",
+ "service_account": sa,
+ },
+ }
+ req := cliproxyexecutor.Request{
+ Model: "deepseek-ai/deepseek-v4.1-flash-maas",
+ Payload: []byte(`{"model":"deepseek-ai/deepseek-v4.1-flash-maas","temperature":0.9,"top_p":0.8,"messages":[{"role":"user","content":"hi"}]}`),
+ }
+ _, errExecute := exec.Execute(ctx, auth, req, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAI,
+ ResponseFormat: sdktranslator.FormatOpenAI,
+ })
+ if errExecute != nil {
+ t.Fatalf("Execute() error = %v", errExecute)
+ }
+
+ streamRes, errStream := exec.ExecuteStream(ctx, auth, req, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAI,
+ ResponseFormat: sdktranslator.FormatOpenAI,
+ Stream: true,
+ })
+ if errStream != nil {
+ t.Fatalf("ExecuteStream() error = %v", errStream)
+ }
+ for chunk := range streamRes.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("stream chunk error: %v", chunk.Err)
+ }
+ }
+
+ mu.Lock()
+ defer mu.Unlock()
+ for name, body := range map[string][]byte{"Execute": nonStreamBody, "ExecuteStream": streamBody} {
+ if got := gjson.GetBytes(body, "custom_openapi_param").String(); got != "final_barrier_applied" {
+ t.Fatalf("%s: custom_openapi_param = %q, want final_barrier_applied; body=%s", name, got, string(body))
+ }
+ if got := gjson.GetBytes(body, "temperature").Float(); got != 0.25 {
+ t.Fatalf("%s: temperature = %v, want 0.25; body=%s", name, got, string(body))
+ }
+ if gjson.GetBytes(body, "top_p").Exists() {
+ t.Fatalf("%s: expected top_p to be filtered out, got body=%s", name, string(body))
+ }
+ }
+ if gjson.GetBytes(streamBody, "stream_options.include_usage").Bool() {
+ t.Fatalf("ExecuteStream: expected payload override stream_options.include_usage=false to win over built-in default, got body=%s", string(streamBody))
+ }
+}
+
+func TestGeminiVertexOpenAPI_APIKey_ValidationAndThirdPartyRelay(t *testing.T) {
+ var mu sync.Mutex
+ var gotPath string
+ var gotBearer string
+ var gotGoogKey string
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ gotPath = r.URL.Path
+ gotBearer = r.Header.Get("Authorization")
+ gotGoogKey = r.Header.Get("x-goog-api-key")
+ mu.Unlock()
+
+ w.Header().Set("Content-Type", "application/json")
+ if strings.HasSuffix(r.URL.Path, "/chat/completions") {
+ _, _ = w.Write([]byte(`{"id":"chatcmpl-key","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"key openapi ok"},"finish_reason":"stop"}]}`))
+ return
+ }
+ _, _ = w.Write([]byte(`{"responseId":"resp1","candidates":[{"content":{"parts":[{"text":"google native ok"}]},"finishReason":"STOP"}]}`))
+ }))
+ defer server.Close()
+
+ exec := NewGeminiVertexExecutor(&config.Config{})
+
+ // 1. Static API key targeting official Google Vertex host is rejected for endpoints/openapi
+ for _, googleBase := range []string{"", "https://aiplatform.googleapis.com/v1/projects/proxy-test/locations/global"} {
+ googleKeyAuth := &cliproxyauth.Auth{
+ ID: "vertex-apikey-google",
+ Provider: "vertex",
+ Attributes: map[string]string{
+ "api_key": "static-api-key",
+ "base_url": googleBase,
+ },
+ }
+ _, errGoogleKey := exec.Execute(context.Background(), googleKeyAuth, cliproxyexecutor.Request{
+ Model: "deepseek-ai/deepseek-v4.1-flash-maas",
+ Payload: []byte(`{"model":"deepseek-ai/deepseek-v4.1-flash-maas","messages":[{"role":"user","content":"hi"}]}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAI,
+ ResponseFormat: sdktranslator.FormatOpenAI,
+ })
+ if errGoogleKey == nil || !strings.Contains(errGoogleKey.Error(), "requires service account credentials") {
+ t.Fatalf("Execute(googleBase=%q) error = %v, want requires service account credentials", googleBase, errGoogleKey)
+ }
+ }
+
+ // 2. Third-party compatible gateway with custom base_url routes non-google publisher model to endpoints/openapi/chat/completions
+ customGatewayAuth := &cliproxyauth.Auth{
+ ID: "vertex-apikey-custom-gateway",
+ Provider: "vertex",
+ Attributes: map[string]string{
+ "api_key": "test-vertex-key",
+ "base_url": server.URL + "/v1/projects/proxy-test/locations/global",
+ },
+ }
+ resp, errExecute := exec.Execute(context.Background(), customGatewayAuth, cliproxyexecutor.Request{
+ Model: "deepseek-ai/deepseek-v4.1-flash-maas",
+ Payload: []byte(`{"model":"deepseek-ai/deepseek-v4.1-flash-maas","messages":[{"role":"user","content":"hi"}]}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAI,
+ ResponseFormat: sdktranslator.FormatOpenAI,
+ })
+ if errExecute != nil {
+ t.Fatalf("Execute(maas) error = %v", errExecute)
+ }
+ mu.Lock()
+ if gotPath != "/v1/projects/proxy-test/locations/global/endpoints/openapi/chat/completions" {
+ mu.Unlock()
+ t.Fatalf("got path = %q, want /v1/projects/proxy-test/locations/global/endpoints/openapi/chat/completions", gotPath)
+ }
+ if gotBearer != "Bearer test-vertex-key" {
+ mu.Unlock()
+ t.Fatalf("got Authorization = %q, want Bearer test-vertex-key", gotBearer)
+ }
+ mu.Unlock()
+ if gotText := gjson.GetBytes(resp.Payload, "choices.0.message.content").String(); gotText != "key openapi ok" {
+ t.Fatalf("got response = %q, want key openapi ok", gotText)
+ }
+
+ // 3. google/ prefix on a relay base URL keeps native generateContent path
+ relayAuth := &cliproxyauth.Auth{
+ ID: "vertex-apikey-relay",
+ Provider: "vertex",
+ Attributes: map[string]string{
+ "api_key": "test-relay-key",
+ "base_url": server.URL,
+ },
+ }
+ _, errRelay := exec.Execute(context.Background(), relayAuth, cliproxyexecutor.Request{
+ Model: "google/gemini-2.5-pro",
+ Payload: []byte(`{"model":"google/gemini-2.5-pro","messages":[{"role":"user","content":"hi relay"}]}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAI,
+ ResponseFormat: sdktranslator.FormatOpenAI,
+ })
+ if errRelay != nil {
+ t.Fatalf("Execute(google/gemini-2.5-pro) error = %v", errRelay)
+ }
+ mu.Lock()
+ defer mu.Unlock()
+ if gotPath != "/v1/publishers/google/models/google/gemini-2.5-pro:generateContent" {
+ t.Fatalf("got relay path = %q, want /v1/publishers/google/models/google/gemini-2.5-pro:generateContent", gotPath)
+ }
+ if gotGoogKey != "test-relay-key" {
+ t.Fatalf("got x-goog-api-key = %q, want test-relay-key", gotGoogKey)
+ }
+}
+
+func TestGeminiVertexOpenAPI_CountTokens(t *testing.T) {
+ var hitUpstream bool
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hitUpstream = true
+ w.WriteHeader(http.StatusInternalServerError)
+ }))
+ defer server.Close()
+
+ var sa map[string]any
+ if errUnmarshal := json.Unmarshal(testVertexServiceAccountJSON(t, server.URL+"/token"), &sa); errUnmarshal != nil {
+ t.Fatal(errUnmarshal)
+ }
+
+ auth := &cliproxyauth.Auth{
+ ID: "vertex-sa-openapi-count",
+ Provider: "vertex",
+ Metadata: map[string]any{
+ "project_id": "proxy-test",
+ "location": "global",
+ "service_account": sa,
+ },
+ }
+ exec := NewGeminiVertexExecutor(&config.Config{})
+ resp, errCount := exec.CountTokens(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "deepseek-ai/deepseek-v4.1-flash-maas",
+ Payload: []byte(`{"contents":[{"role":"user","parts":[{"text":"count these tokens"}]}]}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatGemini,
+ ResponseFormat: sdktranslator.FormatGemini,
+ })
+ if errCount != nil {
+ t.Fatalf("CountTokens() error = %v", errCount)
+ }
+ if hitUpstream {
+ t.Fatal("expected CountTokens for OpenAPI model to use local tokenizer without hitting upstream")
+ }
+ if gjson.GetBytes(resp.Payload, "totalTokens").Int() <= 0 {
+ t.Fatalf("expected positive totalTokens, got: %s", string(resp.Payload))
+ }
+}
diff --git a/internal/runtime/executor/gemini_vertex_executor_test.go b/internal/runtime/executor/gemini_vertex_executor_test.go
index a4c0abbb347..a49ab9a963b 100644
--- a/internal/runtime/executor/gemini_vertex_executor_test.go
+++ b/internal/runtime/executor/gemini_vertex_executor_test.go
@@ -1,7 +1,9 @@
package executor
import (
+ "bytes"
"context"
+ "encoding/json"
"io"
"net/http"
"net/http/httptest"
@@ -9,8 +11,10 @@ import (
"testing"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
"github.com/tidwall/gjson"
)
@@ -111,3 +115,296 @@ func TestGeminiVertexApplyPatchExecutorReuse(t *testing.T) {
assertExecutorPatchDeclaration(t, <-requests)
assertExecutorPatchStream(t, stream.Chunks)
}
+
+func TestGeminiVertexExecutorClaudeMalformedFunctionCallReturnsRetryableError(t *testing.T) {
+ malformedBody := `{"candidates":[{"content":{"role":"model","parts":[]},"finishReason":"MALFORMED_FUNCTION_CALL"}],"usageMetadata":{"promptTokenCount":120,"candidatesTokenCount":10,"thoughtsTokenCount":313,"totalTokenCount":443},"modelVersion":"gemini-4-argon"}`
+ for _, authMode := range []string{"api_key", "service_account"} {
+ t.Run(authMode, func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/token" {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = io.WriteString(w, `{"access_token":"local-token","token_type":"Bearer","expires_in":3600}`)
+ return
+ }
+ if strings.Contains(r.URL.Path, "streamGenerateContent") {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = io.WriteString(w, "data: "+malformedBody+"\n\n")
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = io.WriteString(w, malformedBody)
+ }))
+ defer server.Close()
+
+ ctx := context.Background()
+ auth := &cliproxyauth.Auth{ID: t.Name(), Provider: "vertex"}
+ if authMode == "api_key" {
+ auth.Attributes = map[string]string{"api_key": "test-key", "base_url": server.URL}
+ } else {
+ var serviceAccount map[string]any
+ if errUnmarshal := json.Unmarshal(testVertexServiceAccountJSON(t, server.URL+"/token"), &serviceAccount); errUnmarshal != nil {
+ t.Fatal(errUnmarshal)
+ }
+ auth.Metadata = map[string]any{
+ "project_id": "proxy-test",
+ "location": "global",
+ "service_account": serviceAccount,
+ }
+ target, errParse := parseTestURL(t, server.URL)
+ if errParse != nil {
+ t.Fatal(errParse)
+ }
+ transport := http.DefaultTransport.(*http.Transport).Clone()
+ transport.Proxy = nil
+ t.Cleanup(transport.CloseIdleConnections)
+ ctx = context.WithValue(ctx, "cliproxy.roundtripper", issue6258RedirectTransport{target: target, base: transport})
+ }
+
+ capture := &malformedFunctionCallUsageCapture{authID: auth.ID, records: make(chan usage.Record, 2)}
+ usage.RegisterNamedPlugin(t.Name(), capture)
+ t.Cleanup(func() {
+ usage.RegisterNamedPlugin(t.Name(), malformedFunctionCallUsageNoop{})
+ })
+
+ exec := NewGeminiVertexExecutor(&config.Config{})
+ req := cliproxyexecutor.Request{
+ Model: "gemini-4-argon",
+ Payload: []byte(`{"model":"gemini-4-argon","max_tokens":1024,"messages":[{"role":"user","content":"hi"}]}`),
+ }
+ opts := cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatClaude,
+ ResponseFormat: sdktranslator.FormatClaude,
+ }
+
+ _, errExec := exec.Execute(ctx, auth, req, opts)
+ if errExec == nil {
+ t.Fatal("Execute() error = nil, want retryable 502 error for MALFORMED_FUNCTION_CALL")
+ }
+ status, okStatus := errExec.(interface{ StatusCode() int })
+ if !okStatus || status.StatusCode() != http.StatusBadGateway {
+ t.Fatalf("Execute() error = %T %v, want status 502", errExec, errExec)
+ }
+ if got := gjson.Get(errExec.Error(), "error.type").String(); got != "api_error" {
+ t.Fatalf("Execute() error.type = %q, want api_error. Error: %s", got, errExec.Error())
+ }
+ assertMalformedFunctionCallFailedUsage(t, capture.records)
+
+ streamRes, errStream := exec.ExecuteStream(ctx, auth, req, opts)
+ if errStream != nil {
+ t.Fatalf("ExecuteStream() error = %v", errStream)
+ }
+ var combined bytes.Buffer
+ var streamErr error
+ for chunk := range streamRes.Chunks {
+ if chunk.Err != nil {
+ streamErr = chunk.Err
+ continue
+ }
+ combined.Write(chunk.Payload)
+ }
+ if streamErr == nil {
+ t.Fatal("ExecuteStream() chunk.Err = nil, want 502 error propagated")
+ }
+ streamText := combined.String()
+ if strings.Contains(streamText, `"stop_reason":"refusal"`) {
+ t.Fatalf("stream unexpectedly emitted stop_reason refusal:\n%s", streamText)
+ }
+ if !strings.Contains(streamText, "event: error\n") || !strings.Contains(streamText, `"type":"api_error"`) {
+ t.Fatalf("stream missing retryable api_error event:\n%s", streamText)
+ }
+ assertMalformedFunctionCallFailedUsage(t, capture.records)
+ })
+ }
+}
+
+func TestGeminiVertexExecutorClaudeMalformedFunctionCallCancelAfterErrorRecordsFailure(t *testing.T) {
+ malformedBody := `{"candidates":[{"content":{"role":"model","parts":[]},"finishReason":"MALFORMED_FUNCTION_CALL"}],"usageMetadata":{"promptTokenCount":120,"candidatesTokenCount":10,"thoughtsTokenCount":313,"totalTokenCount":443},"modelVersion":"gemini-4-argon"}`
+ for _, authMode := range []string{"api_key", "service_account"} {
+ t.Run(authMode, func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/token" {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = io.WriteString(w, `{"access_token":"local-token","token_type":"Bearer","expires_in":3600}`)
+ return
+ }
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = io.WriteString(w, "data: "+malformedBody+"\n\n")
+ if flusher, okFlush := w.(http.Flusher); okFlush {
+ flusher.Flush()
+ }
+ <-r.Context().Done()
+ }))
+ defer server.Close()
+
+ ctx := context.Background()
+ auth := &cliproxyauth.Auth{ID: t.Name(), Provider: "vertex", Status: cliproxyauth.StatusActive}
+ if authMode == "api_key" {
+ auth.Attributes = map[string]string{"api_key": "test-key", "base_url": server.URL}
+ } else {
+ var serviceAccount map[string]any
+ if errUnmarshal := json.Unmarshal(testVertexServiceAccountJSON(t, server.URL+"/token"), &serviceAccount); errUnmarshal != nil {
+ t.Fatal(errUnmarshal)
+ }
+ auth.Metadata = map[string]any{
+ "project_id": "proxy-test",
+ "location": "global",
+ "service_account": serviceAccount,
+ }
+ target, errParse := parseTestURL(t, server.URL)
+ if errParse != nil {
+ t.Fatal(errParse)
+ }
+ transport := http.DefaultTransport.(*http.Transport).Clone()
+ transport.Proxy = nil
+ t.Cleanup(transport.CloseIdleConnections)
+ ctx = context.WithValue(ctx, "cliproxy.roundtripper", issue6258RedirectTransport{target: target, base: transport})
+ }
+
+ capture := &malformedFunctionCallUsageCapture{authID: auth.ID, records: make(chan usage.Record, 1)}
+ usage.RegisterNamedPlugin(t.Name(), capture)
+ t.Cleanup(func() {
+ usage.RegisterNamedPlugin(t.Name(), malformedFunctionCallUsageNoop{})
+ })
+
+ exec := NewGeminiVertexExecutor(&config.Config{})
+ manager := cliproxyauth.NewManager(nil, nil, nil)
+ manager.RegisterExecutor(exec)
+ if _, errRegister := manager.Register(ctx, auth); errRegister != nil {
+ t.Fatalf("Register() error = %v", errRegister)
+ }
+ registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: "gemini-4-argon"}})
+ t.Cleanup(func() {
+ registry.GetGlobalRegistry().UnregisterClient(auth.ID)
+ })
+
+ streamCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+ req := cliproxyexecutor.Request{
+ Model: "gemini-4-argon",
+ Payload: []byte(`{"model":"gemini-4-argon","max_tokens":1024,"messages":[{"role":"user","content":"hi"}]}`),
+ }
+ opts := cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatClaude,
+ ResponseFormat: sdktranslator.FormatClaude,
+ Stream: true,
+ }
+
+ streamRes, errStream := manager.ExecuteStream(streamCtx, []string{"vertex"}, req, opts)
+ if errStream != nil {
+ t.Fatalf("manager.ExecuteStream() error = %v", errStream)
+ }
+ for chunk := range streamRes.Chunks {
+ if strings.Contains(string(chunk.Payload), "event: error\n") {
+ cancel()
+ }
+ }
+ assertMalformedFunctionCallFailedUsage(t, capture.records)
+ updated, okAuth := manager.GetByID(auth.ID)
+ if !okAuth || updated.ModelStates["gemini-4-argon"] == nil || updated.ModelStates["gemini-4-argon"].LastError == nil {
+ t.Fatalf("expected conductor to record failure on model state after client cancel on error event, got %+v", updated)
+ }
+ if updated.ModelStates["gemini-4-argon"].LastError.HTTPStatus != http.StatusBadGateway {
+ t.Fatalf("LastError.HTTPStatus = %d, want 502; err=%+v", updated.ModelStates["gemini-4-argon"].LastError.HTTPStatus, updated.ModelStates["gemini-4-argon"].LastError)
+ }
+ })
+ }
+}
+
+// TestVertexAPIKeyModelsURL pinpoints the exact URL construction rule for
+// API-key mode: the configured base URL is honored as a full resource prefix
+// when it already contains "/publishers/", otherwise "/v1/publishers/google/models"
+// is inserted for it. Keeps third-party short hosts (zenmux-style) working
+// while allowing real Vertex paths that embed projects/locations.
+func TestVertexAPIKeyModelsURL(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ baseURL string
+ model string
+ action string
+ want string
+ }{
+ {
+ name: "empty falls back to default Vertex host with /v1",
+ baseURL: "",
+ model: "gemini-4-argon",
+ action: "streamGenerateContent",
+ want: "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-4-argon:streamGenerateContent",
+ },
+ {
+ name: "bare host gets /v1/publishers/google/models appended",
+ baseURL: "https://zenmux.ai",
+ model: "gemini-2.5-flash",
+ action: "generateContent",
+ want: "https://zenmux.ai/v1/publishers/google/models/gemini-2.5-flash:generateContent",
+ },
+ {
+ name: "host with trailing slash still gets /v1/...",
+ baseURL: "https://zenmux.ai/",
+ model: "gemini-2.5-flash",
+ action: "streamGenerateContent",
+ want: "https://zenmux.ai/v1/publishers/google/models/gemini-2.5-flash:streamGenerateContent",
+ },
+ {
+ name: "third-party host including /v1 keeps its version and appends suffix",
+ baseURL: "https://zenmux.ai/v1",
+ model: "gemini-2.5-flash",
+ action: "streamGenerateContent",
+ want: "https://zenmux.ai/v1/publishers/google/models/gemini-2.5-flash:streamGenerateContent",
+ },
+ {
+ name: "third-party already encoding /publishers/ is used as-is",
+ baseURL: "https://api.zenmux.ai/v1/publishers/google/models",
+ model: "gemini-2.5-flash",
+ action: "streamGenerateContent",
+ want: "https://api.zenmux.ai/v1/publishers/google/models/gemini-2.5-flash:streamGenerateContent",
+ },
+ {
+ name: "real Vertex with projects and locations is used as-is (no double /v1)",
+ baseURL: "https://aiplatform.googleapis.com/v1/projects/279118250380/locations/us-central1/publishers/google/models",
+ model: "gemini-4-argon",
+ action: "streamGenerateContent",
+ want: "https://aiplatform.googleapis.com/v1/projects/279118250380/locations/us-central1/publishers/google/models/gemini-4-argon:streamGenerateContent",
+ },
+ {
+ name: "real Vertex with projects and locations uses countTokens action",
+ baseURL: "https://aiplatform.googleapis.com/v1/projects/279118250380/locations/us-central1/publishers/google/models",
+ model: "gemini-4-argon",
+ action: "countTokens",
+ want: "https://aiplatform.googleapis.com/v1/projects/279118250380/locations/us-central1/publishers/google/models/gemini-4-argon:countTokens",
+ },
+ {
+ name: "custom location with regional endpoint preserved verbatim",
+ baseURL: "https://us-central1-aiplatform.googleapis.com/v1/projects/279118250380/locations/us-central1/publishers/google/models",
+ model: "gemini-4-argon",
+ action: "streamGenerateContent",
+ want: "https://us-central1-aiplatform.googleapis.com/v1/projects/279118250380/locations/us-central1/publishers/google/models/gemini-4-argon:streamGenerateContent",
+ },
+ {
+ name: "v1beta1 full prefix is preserved (no forced /v1)",
+ baseURL: "https://aiplatform.googleapis.com/v1beta1/projects/279118250380/locations/us-central1/publishers/google/models",
+ model: "gemini-4-argon",
+ action: "streamGenerateContent",
+ want: "https://aiplatform.googleapis.com/v1beta1/projects/279118250380/locations/us-central1/publishers/google/models/gemini-4-argon:streamGenerateContent",
+ },
+ {
+ name: "whitespace around base URL is trimmed",
+ baseURL: " https://zenmux.ai ",
+ model: "gemini-2.5-flash",
+ action: "generateContent",
+ want: "https://zenmux.ai/v1/publishers/google/models/gemini-2.5-flash:generateContent",
+ },
+ }
+
+ for _, tc := range tests {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ got := vertexAPIKeyModelsURL(tc.baseURL, tc.model, tc.action)
+ if got != tc.want {
+ t.Errorf("vertexAPIKeyModelsURL(%q, %q, %q)\n got: %q\n want: %q", tc.baseURL, tc.model, tc.action, got, tc.want)
+ }
+ })
+ }
+}
diff --git a/internal/runtime/executor/gemini_vertex_executor_trailing_reasoning_test.go b/internal/runtime/executor/gemini_vertex_executor_trailing_reasoning_test.go
new file mode 100644
index 00000000000..38031ecaf73
--- /dev/null
+++ b/internal/runtime/executor/gemini_vertex_executor_trailing_reasoning_test.go
@@ -0,0 +1,174 @@
+package executor
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
+ "github.com/tidwall/gjson"
+)
+
+const (
+ issue6630UserItem = `{"type":"message","role":"user","content":[{"type":"input_text","text":"say hi"}]}`
+ issue6630ReasoningItem = `{"type":"reasoning","id":"rs_test_1","summary":[{"type":"summary_text","text":"I should say hi briefly."}]}`
+)
+
+// issue6630VertexContents sends a Responses request through the selected Vertex
+// executor path and returns the raw contents array received upstream.
+func issue6630VertexContents(t *testing.T, cfg *config.Config, authMode string, stream bool, input string) string {
+ t.Helper()
+ const okBody = `{"candidates":[{"content":{"role":"model","parts":[{"text":"hi"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2}}`
+ captured := make(chan []byte, 1)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/token" {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = io.WriteString(w, `{"access_token":"local-token","token_type":"Bearer","expires_in":3600}`)
+ return
+ }
+ body, errRead := io.ReadAll(r.Body)
+ if errRead != nil {
+ t.Errorf("read request body: %v", errRead)
+ }
+ captured <- body
+ if strings.Contains(r.URL.Path, "streamGenerateContent") {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = io.WriteString(w, "data: "+okBody+"\n\n")
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = io.WriteString(w, okBody)
+ }))
+ defer server.Close()
+
+ ctx := context.Background()
+ auth := &cliproxyauth.Auth{ID: t.Name(), Provider: "vertex"}
+ if authMode == "api_key" {
+ auth.Attributes = map[string]string{"api_key": "test-key", "base_url": server.URL}
+ } else {
+ var serviceAccount map[string]any
+ if errUnmarshal := json.Unmarshal(testVertexServiceAccountJSON(t, server.URL+"/token"), &serviceAccount); errUnmarshal != nil {
+ t.Fatal(errUnmarshal)
+ }
+ auth.Metadata = map[string]any{"project_id": "proxy-test", "location": "global", "service_account": serviceAccount}
+ target, errParse := parseTestURL(t, server.URL)
+ if errParse != nil {
+ t.Fatal(errParse)
+ }
+ transport := http.DefaultTransport.(*http.Transport).Clone()
+ transport.Proxy = nil
+ t.Cleanup(transport.CloseIdleConnections)
+ ctx = context.WithValue(ctx, "cliproxy.roundtripper", issue6258RedirectTransport{target: target, base: transport})
+ }
+
+ payload := []byte(fmt.Sprintf(`{"model":"gemini-4-argon","input":[%s],"stream":%t,"store":false}`, input, stream))
+ req := cliproxyexecutor.Request{Model: "gemini-4-argon", Payload: payload}
+ opts := cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ OriginalRequest: payload,
+ Stream: stream,
+ }
+ exec := NewGeminiVertexExecutor(cfg)
+ if stream {
+ result, errStream := exec.ExecuteStream(ctx, auth, req, opts)
+ if errStream != nil {
+ t.Fatalf("ExecuteStream() error = %v", errStream)
+ }
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("stream chunk error = %v", chunk.Err)
+ }
+ }
+ } else if _, errExecute := exec.Execute(ctx, auth, req, opts); errExecute != nil {
+ t.Fatalf("Execute() error = %v", errExecute)
+ }
+ return gjson.GetBytes(<-captured, "contents").Raw
+}
+
+// issue6630AssertContents compares upstream contents with a hand-written JSON
+// literal, ignoring object key order.
+func issue6630AssertContents(t *testing.T, got, want string) {
+ t.Helper()
+ var gotValue, wantValue any
+ if errGot := json.Unmarshal([]byte(got), &gotValue); errGot != nil {
+ t.Fatalf("decode upstream contents %q: %v", got, errGot)
+ }
+ if errWant := json.Unmarshal([]byte(want), &wantValue); errWant != nil {
+ t.Fatalf("decode expected contents %q: %v", want, errWant)
+ }
+ if !reflect.DeepEqual(gotValue, wantValue) {
+ t.Fatalf("upstream contents = %s, want %s", got, want)
+ }
+}
+
+// TestGeminiVertexDropsTrailingReasoningTurn covers #6630: Vertex rejects a
+// trailing thought-only model turn even when an empty user turn follows it.
+func TestGeminiVertexDropsTrailingReasoningTurn(t *testing.T) {
+ const signature = "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA"
+ tests := []struct {
+ name string
+ input string
+ want string
+ }{
+ {
+ name: "trailing reasoning",
+ input: issue6630UserItem + `,` + issue6630ReasoningItem,
+ want: `[{"role":"user","parts":[{"text":"say hi"}]}]`,
+ },
+ {
+ name: "trailing signed reasoning",
+ input: issue6630UserItem + `,{"type":"reasoning","id":"rs_test_1","encrypted_content":"` + signature + `","summary":[{"type":"summary_text","text":"I should say hi briefly."}]}`,
+ want: `[{"role":"user","parts":[{"text":"say hi"}]}]`,
+ },
+ {
+ name: "trailing reasoning after tool output",
+ input: issue6630UserItem + `,{"type":"function_call","call_id":"call_1","name":"run","arguments":"{}"},{"type":"function_call_output","call_id":"call_1","output":"done"},` + issue6630ReasoningItem,
+ want: `[{"role":"user","parts":[{"text":"say hi"}]},` +
+ `{"role":"model","parts":[{"functionCall":{"name":"run","args":{}},"thoughtSignature":"skip_thought_signature_validator"}]},` +
+ `{"role":"user","parts":[{"functionResponse":{"name":"run","response":{"result":"done"}}}]}]`,
+ },
+ {
+ name: "reasoning before user turn is kept",
+ input: issue6630UserItem + `,` + issue6630ReasoningItem + `,{"type":"message","role":"user","content":[{"type":"input_text","text":"again"}]}`,
+ want: `[{"role":"user","parts":[{"text":"say hi"}]},` +
+ `{"role":"model","parts":[{"text":"I should say hi briefly.","thought":true}]},` +
+ `{"role":"user","parts":[{"text":"again"}]}]`,
+ },
+ }
+ for _, tt := range tests {
+ for _, authMode := range []string{"api_key", "service_account"} {
+ for _, stream := range []bool{false, true} {
+ t.Run(fmt.Sprintf("%s/%s/stream=%t", tt.name, authMode, stream), func(t *testing.T) {
+ issue6630AssertContents(t, issue6630VertexContents(t, &config.Config{}, authMode, stream, tt.input), tt.want)
+ })
+ }
+ }
+ }
+}
+
+// TestGeminiVertexTrailingReasoningDropPrecedesPayloadRules keeps payload rules
+// as the final barrier: configured contents are sent exactly as configured.
+func TestGeminiVertexTrailingReasoningDropPrecedesPayloadRules(t *testing.T) {
+ const configured = `[{"role":"user","parts":[{"text":"configured"}]},{"role":"model","parts":[{"text":"configured thought","thought":true}]}]`
+ cfg := &config.Config{Payload: config.PayloadConfig{OverrideRaw: []config.PayloadRule{{
+ Models: []config.PayloadModelRule{{Name: "gemini-4-argon"}},
+ Params: map[string]any{"contents": configured},
+ }}}}
+ for _, authMode := range []string{"api_key", "service_account"} {
+ for _, stream := range []bool{false, true} {
+ t.Run(fmt.Sprintf("%s/stream=%t", authMode, stream), func(t *testing.T) {
+ issue6630AssertContents(t, issue6630VertexContents(t, cfg, authMode, stream, issue6630UserItem+`,`+issue6630ReasoningItem), configured)
+ })
+ }
+ }
+}
diff --git a/internal/runtime/executor/helps/antigravity_compaction.go b/internal/runtime/executor/helps/antigravity_compaction.go
index a5189b8edb2..a550c934c23 100644
--- a/internal/runtime/executor/helps/antigravity_compaction.go
+++ b/internal/runtime/executor/helps/antigravity_compaction.go
@@ -13,6 +13,8 @@ import (
"strings"
"time"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@@ -61,6 +63,76 @@ func HasResponsesCompactionItem(payload []byte) bool {
return false
}
+// HasResponsesCompactionTriggerRequest reports whether req.Payload or opts.OriginalRequest contains a compaction_trigger item.
+func HasResponsesCompactionTriggerRequest(req cliproxyexecutor.Request, opts cliproxyexecutor.Options) bool {
+ return HasResponsesCompactionTrigger(req.Payload) || HasResponsesCompactionTrigger(opts.OriginalRequest)
+}
+
+// ResponsesCompactionSourcePayload selects the source payload containing the compaction trigger or request history.
+func ResponsesCompactionSourcePayload(req cliproxyexecutor.Request, opts cliproxyexecutor.Options) []byte {
+ payload := req.Payload
+ if !HasResponsesCompactionTrigger(payload) && HasResponsesCompactionTrigger(opts.OriginalRequest) {
+ payload = opts.OriginalRequest
+ }
+ if len(payload) == 0 && len(opts.OriginalRequest) > 0 {
+ payload = opts.OriginalRequest
+ }
+ return payload
+}
+
+// DropForeignResponsesCompactionItems removes compaction items whose encrypted_content does not use the CPA capsule prefix.
+func DropForeignResponsesCompactionItems(payload []byte, provider string) []byte {
+ input := gjson.GetBytes(payload, "input")
+ if !input.IsArray() {
+ return payload
+ }
+ dropped := 0
+ items := make([]string, 0, len(input.Array()))
+ for _, item := range input.Array() {
+ if item.Get("type").String() == "compaction" && !RecognizedAntigravityCompactionCapsule(item.Get("encrypted_content").String()) {
+ dropped++
+ continue
+ }
+ items = append(items, item.Raw)
+ }
+ if dropped == 0 {
+ return payload
+ }
+ log.Warnf("%s compaction: dropped %d non-CPA compaction item(s)", provider, dropped)
+ out, _ := sjson.SetRawBytes(payload, "input", []byte("["+strings.Join(items, ",")+"]"))
+ return out
+}
+
+// ExpandResponsesCompactionCapsules drops foreign compaction items and expands recognized CPA compaction capsules in req and opts.
+func ExpandResponsesCompactionCapsules(req *cliproxyexecutor.Request, opts *cliproxyexecutor.Options, provider string) error {
+ sameOriginal := req != nil && opts != nil && len(opts.OriginalRequest) > 0 && bytes.Equal(req.Payload, opts.OriginalRequest)
+ if req != nil {
+ req.Payload = DropForeignResponsesCompactionItems(req.Payload, provider)
+ if HasResponsesCompactionItem(req.Payload) {
+ expanded, errExpand := ExpandAntigravityCompactionCapsules(req.Payload)
+ if errExpand != nil {
+ return errExpand
+ }
+ req.Payload = expanded
+ }
+ }
+ if opts != nil && len(opts.OriginalRequest) > 0 {
+ if sameOriginal {
+ opts.OriginalRequest = req.Payload
+ return nil
+ }
+ opts.OriginalRequest = DropForeignResponsesCompactionItems(opts.OriginalRequest, provider)
+ if HasResponsesCompactionItem(opts.OriginalRequest) {
+ expanded, errExpand := ExpandAntigravityCompactionCapsules(opts.OriginalRequest)
+ if errExpand != nil {
+ return errExpand
+ }
+ opts.OriginalRequest = expanded
+ }
+ }
+ return nil
+}
+
// PrepareAntigravityCompactionSummaryPayload prepares a payload for non-stream Antigravity summary generation.
func PrepareAntigravityCompactionSummaryPayload(payload []byte, modelName string) []byte {
out := payload
@@ -230,7 +302,8 @@ func ExtractAntigravitySummaryText(respPayload []byte) (string, error) {
content := item.Get("content")
if content.IsArray() {
for _, part := range content.Array() {
- if part.Get("type").String() == "output_text" {
+ partType := part.Get("type").String()
+ if partType == "output_text" || partType == "text" {
if t := part.Get("text").String(); t != "" {
textParts = append(textParts, t)
}
@@ -377,6 +450,115 @@ func BuildAntigravityCompactionResponse(modelName, capsule string, inputTokens,
return respJSON
}
+// ExtractResponsesCompactionUsage extracts usage counters from an OpenAI Responses summary payload.
+func ExtractResponsesCompactionUsage(payload []byte) (inputTokens, outputTokens, totalTokens, cachedTokens, reasoningTokens int) {
+ if detail, ok := ParseCodexUsage(payload); ok && (detail.TotalTokens > 0 || detail.InputTokens > 0 || detail.OutputTokens > 0) {
+ return int(detail.InputTokens), int(detail.OutputTokens), int(detail.TotalTokens), int(detail.CachedTokens), int(detail.ReasoningTokens)
+ }
+ parsed := ParseOpenAIUsage(payload)
+ return int(parsed.InputTokens), int(parsed.OutputTokens), int(parsed.TotalTokens), int(parsed.CachedTokens), int(parsed.ReasoningTokens)
+}
+
+// PatchResponsesCompactionStreamUsage updates usage counters on a compaction SSE chunk.
+func PatchResponsesCompactionStreamUsage(chunk []byte, inputTokens, outputTokens, totalTokens, cachedTokens, reasoningTokens int) []byte {
+ const dataPrefix = "data: "
+ idx := bytes.Index(chunk, []byte(dataPrefix))
+ if idx < 0 {
+ return chunk
+ }
+ data := bytes.TrimSpace(chunk[idx+len(dataPrefix):])
+ path := ""
+ switch {
+ case gjson.GetBytes(data, "response.usage").Exists():
+ path = "response.usage"
+ case gjson.GetBytes(data, "usage").Exists():
+ path = "usage"
+ default:
+ return chunk
+ }
+ data, _ = sjson.SetBytes(data, path+".input_tokens", inputTokens)
+ data, _ = sjson.SetBytes(data, path+".output_tokens", outputTokens)
+ data, _ = sjson.SetBytes(data, path+".total_tokens", totalTokens)
+ data, _ = sjson.SetBytes(data, path+".input_tokens_details.cached_tokens", cachedTokens)
+ data, _ = sjson.SetBytes(data, path+".output_tokens_details.reasoning_tokens", reasoningTokens)
+ var buf bytes.Buffer
+ buf.Write(chunk[:idx])
+ buf.WriteString(dataPrefix)
+ buf.Write(data)
+ buf.WriteString("\n\n")
+ return buf.Bytes()
+}
+
+// ValidateResponsesCompactionSummaryStatus ensures the upstream summary response finished with status "completed".
+func ValidateResponsesCompactionSummaryStatus(summaryPayload []byte) error {
+ status := strings.ToLower(strings.TrimSpace(gjson.GetBytes(summaryPayload, "status").String()))
+ if status == "" {
+ status = strings.ToLower(strings.TrimSpace(gjson.GetBytes(summaryPayload, "response.status").String()))
+ }
+ if status != "completed" {
+ if status == "" {
+ status = "missing"
+ }
+ return fmt.Errorf("compaction summary response status is %q, want \"completed\"", status)
+ }
+ if eventType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(summaryPayload, "type").String())); eventType == "response.incomplete" || eventType == "response.failed" {
+ return fmt.Errorf("compaction summary response terminal event is %q", eventType)
+ }
+ for _, path := range []string{"incomplete_details", "response.incomplete_details", "error", "response.error"} {
+ if node := gjson.GetBytes(summaryPayload, path); node.Exists() && node.Type != gjson.Null {
+ return fmt.Errorf("compaction summary response contains %s", path)
+ }
+ }
+ if output := gjson.GetBytes(summaryPayload, "output"); output.IsArray() {
+ for _, item := range output.Array() {
+ itemStatus := strings.ToLower(strings.TrimSpace(item.Get("status").String()))
+ if itemStatus != "" && itemStatus != "completed" {
+ return fmt.Errorf("compaction summary output item status is %q, want \"completed\"", itemStatus)
+ }
+ }
+ }
+ return nil
+}
+
+// BuildResponsesCompactionResponseFromSummary seals summary text from an OpenAI Responses payload into a response.compaction payload.
+func BuildResponsesCompactionResponseFromSummary(modelName string, summaryPayload []byte) ([]byte, error) {
+ if errStatus := ValidateResponsesCompactionSummaryStatus(summaryPayload); errStatus != nil {
+ return nil, errStatus
+ }
+ summaryText, errExtract := ExtractAntigravitySummaryText(summaryPayload)
+ if errExtract != nil {
+ return nil, fmt.Errorf("extract summary: %w", errExtract)
+ }
+ capsule, errSeal := SealAntigravityCompaction(summaryText, modelName)
+ if errSeal != nil {
+ return nil, fmt.Errorf("seal compaction capsule: %w", errSeal)
+ }
+ inputTokens, outputTokens, totalTokens, cachedTokens, reasoningTokens := ExtractResponsesCompactionUsage(summaryPayload)
+ respPayload := BuildAntigravityCompactionResponse(modelName, capsule, inputTokens, outputTokens, totalTokens)
+ respPayload, _ = sjson.SetBytes(respPayload, "usage.input_tokens_details.cached_tokens", cachedTokens)
+ respPayload, _ = sjson.SetBytes(respPayload, "usage.output_tokens_details.reasoning_tokens", reasoningTokens)
+ return respPayload, nil
+}
+
+// BuildResponsesCompactionStreamChunksFromResponse converts a sealed response.compaction payload into SSE stream chunks.
+func BuildResponsesCompactionStreamChunksFromResponse(modelName string, compactionPayload []byte) ([][]byte, error) {
+ item := gjson.GetBytes(compactionPayload, "output.0")
+ capsule := item.Get("encrypted_content").String()
+ if item.Get("type").String() != "compaction" || capsule == "" {
+ return nil, fmt.Errorf("extract summary: compaction item missing")
+ }
+ inputTokens := int(gjson.GetBytes(compactionPayload, "usage.input_tokens").Int())
+ outputTokens := int(gjson.GetBytes(compactionPayload, "usage.output_tokens").Int())
+ totalTokens := int(gjson.GetBytes(compactionPayload, "usage.total_tokens").Int())
+ cachedTokens := int(gjson.GetBytes(compactionPayload, "usage.input_tokens_details.cached_tokens").Int())
+ reasoningTokens := int(gjson.GetBytes(compactionPayload, "usage.output_tokens_details.reasoning_tokens").Int())
+ chunks := BuildAntigravityCompactionStreamChunks(modelName, capsule, inputTokens, outputTokens, totalTokens)
+ for i := range chunks {
+ chunks[i] = PatchResponsesCompactionStreamUsage(chunks[i], inputTokens, outputTokens, totalTokens, cachedTokens, reasoningTokens)
+ }
+ return chunks, nil
+}
+
func buildSSEFrame(event string, data []byte) []byte {
var buf bytes.Buffer
buf.WriteString("event: ")
diff --git a/internal/runtime/executor/helps/antigravity_compaction_test.go b/internal/runtime/executor/helps/antigravity_compaction_test.go
index 5209dc8348e..203a5cf9106 100644
--- a/internal/runtime/executor/helps/antigravity_compaction_test.go
+++ b/internal/runtime/executor/helps/antigravity_compaction_test.go
@@ -5,6 +5,7 @@ import (
"strings"
"testing"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
"github.com/tidwall/gjson"
)
@@ -248,3 +249,76 @@ func TestBuildAntigravityCompactionResponseAndChunks_ValidJSON(t *testing.T) {
}
}
}
+
+func TestExpandResponsesCompactionCapsulesAndBuildFromSummary(t *testing.T) {
+ summaryPayload := []byte(`{
+ "id": "resp_sum",
+ "object": "response",
+ "status": "completed",
+ "output": [
+ {
+ "type": "message",
+ "role": "assistant",
+ "content": [{"type": "text", "text": "Summary with text part."}]
+ }
+ ],
+ "usage": {
+ "input_tokens": 20,
+ "output_tokens": 8,
+ "total_tokens": 28,
+ "input_tokens_details": {"cached_tokens": 5},
+ "output_tokens_details": {"reasoning_tokens": 2}
+ }
+ }`)
+
+ compactionResp, errBuild := BuildResponsesCompactionResponseFromSummary("kimi-k3", summaryPayload)
+ if errBuild != nil {
+ t.Fatalf("BuildResponsesCompactionResponseFromSummary failed: %v", errBuild)
+ }
+ if got := gjson.GetBytes(compactionResp, "usage.input_tokens_details.cached_tokens").Int(); got != 5 {
+ t.Fatalf("cached_tokens = %d, want 5", got)
+ }
+ if got := gjson.GetBytes(compactionResp, "usage.output_tokens_details.reasoning_tokens").Int(); got != 2 {
+ t.Fatalf("reasoning_tokens = %d, want 2", got)
+ }
+
+ chunks, errChunks := BuildResponsesCompactionStreamChunksFromResponse("kimi-k3", compactionResp)
+ if errChunks != nil {
+ t.Fatalf("BuildResponsesCompactionStreamChunksFromResponse failed: %v", errChunks)
+ }
+ if len(chunks) != 5 {
+ t.Fatalf("len(chunks) = %d, want 5", len(chunks))
+ }
+
+ capsule := gjson.GetBytes(compactionResp, "output.0.encrypted_content").String()
+ rawReq := []byte(`{"input":[{"type":"compaction","encrypted_content":"foreign"},{"type":"compaction","encrypted_content":"` + capsule + `"},{"type":"compaction_trigger"}]}`)
+ req := cliproxyexecutor.Request{Payload: rawReq}
+ opts := cliproxyexecutor.Options{OriginalRequest: rawReq}
+ if !HasResponsesCompactionTriggerRequest(req, opts) {
+ t.Fatal("expected HasResponsesCompactionTriggerRequest to be true")
+ }
+ if errExpand := ExpandResponsesCompactionCapsules(&req, &opts, "kimi"); errExpand != nil {
+ t.Fatalf("ExpandResponsesCompactionCapsules failed: %v", errExpand)
+ }
+ items := gjson.GetBytes(req.Payload, "input").Array()
+ if len(items) != 2 || items[0].Get("role").String() != "developer" || !strings.Contains(items[0].Get("content.0.text").String(), "Summary with text part.") {
+ t.Fatalf("unexpected expanded input: %s", req.Payload)
+ }
+
+ incompletePayload := []byte(`{
+ "id": "resp_inc",
+ "object": "response",
+ "status": "incomplete",
+ "incomplete_details": {"reason": "max_output_tokens"},
+ "output": [
+ {
+ "type": "message",
+ "role": "assistant",
+ "content": [{"type": "output_text", "text": "Truncated summary"}]
+ }
+ ]
+ }`)
+ if _, errInc := BuildResponsesCompactionResponseFromSummary("kimi-k3", incompletePayload); errInc == nil {
+ t.Fatal("expected error for incomplete summary response, got nil")
+ }
+}
diff --git a/internal/runtime/executor/helps/apply_patch.go b/internal/runtime/executor/helps/apply_patch.go
index a0223f153d0..621807851bc 100644
--- a/internal/runtime/executor/helps/apply_patch.go
+++ b/internal/runtime/executor/helps/apply_patch.go
@@ -1,16 +1,101 @@
package helps
import (
+ "bytes"
"context"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
+ "github.com/tidwall/gjson"
)
// ApplyPatchUpstreamErrorMessage deliberately excludes upstream JSON and patch text.
const ApplyPatchUpstreamErrorMessage = "Invalid apply_patch tool arguments received from upstream."
+// TranslatedClaudeErrorPayload returns the raw JSON payload when a non-streaming
+// translation to Claude format produced a terminal error object instead of a message.
+func TranslatedClaudeErrorPayload(responseFormat sdktranslator.Format, out []byte) (string, bool) {
+ if responseFormat != sdktranslator.FormatClaude || len(out) == 0 {
+ return "", false
+ }
+ if gjson.GetBytes(out, "type").String() != "error" {
+ return "", false
+ }
+ return string(out), true
+}
+
+// TranslatedClaudeStreamErrorPayload returns the raw JSON data payload when a translated
+// Claude SSE chunk contains an error event.
+func TranslatedClaudeStreamErrorPayload(responseFormat sdktranslator.Format, lines [][]byte) (string, bool) {
+ if responseFormat != sdktranslator.FormatClaude {
+ return "", false
+ }
+ for _, chunk := range lines {
+ for _, line := range bytes.Split(chunk, []byte("\n")) {
+ payload := JSONPayload(line)
+ if len(payload) == 0 {
+ continue
+ }
+ if gjson.GetBytes(payload, "type").String() == "error" {
+ return string(payload), true
+ }
+ }
+ }
+ return "", false
+}
+
+// CaptureTranslatedClaudeStreamError retains the first SSE error event payload
+// so failure usage and terminal StreamChunk.Err can be emitted after any trailing usage chunk.
+func CaptureTranslatedClaudeStreamError(responseFormat sdktranslator.Format, lines [][]byte, retainedErr *error, buildErr func(string) error) {
+ if retainedErr == nil || *retainedErr != nil {
+ return
+ }
+ errPayload, okErr := TranslatedClaudeStreamErrorPayload(responseFormat, lines)
+ if !okErr {
+ return
+ }
+ if buildErr != nil {
+ *retainedErr = buildErr(errPayload)
+ }
+}
+
+// PublishTranslatedClaudeStreamFailure publishes failure usage with buffered stream usage
+// when a translated Claude stream retained an SSE error event.
+func PublishTranslatedClaudeStreamFailure(ctx context.Context, retainedErr error, reporter *UsageReporter, streamUsage *StreamUsageBuffer) bool {
+ if retainedErr == nil {
+ return false
+ }
+ if streamUsage != nil && streamUsage.PublishFailure(ctx, reporter, retainedErr) {
+ return true
+ }
+ if reporter != nil {
+ reporter.PublishFailure(ctx, retainedErr)
+ }
+ return true
+}
+
+// StopTranslatedClaudeStream publishes failure usage and propagates a terminal StreamChunk.Err
+// once after a translated Claude stream emitted an SSE error event.
+func StopTranslatedClaudeStream(ctx context.Context, retainedErr *error, reporter *UsageReporter, streamUsage *StreamUsageBuffer, out chan<- cliproxyexecutor.StreamChunk) bool {
+ if retainedErr == nil || *retainedErr == nil {
+ return false
+ }
+ err := *retainedErr
+ *retainedErr = nil
+ PublishTranslatedClaudeStreamFailure(ctx, err, reporter, streamUsage)
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Err: err}:
+ return true
+ default:
+ }
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Err: err}:
+ case <-ctx.Done():
+ }
+ return true
+}
+
// ApplyPatchTranslationError consumes only the optional translator error state.
func ApplyPatchTranslationError(param any) error {
state, okState := param.(interface{ ToolInputError() error })
diff --git a/internal/runtime/executor/helps/claude_client_detection.go b/internal/runtime/executor/helps/claude_client_detection.go
index 6d5a4c0dfdb..e103f8b9ae0 100644
--- a/internal/runtime/executor/helps/claude_client_detection.go
+++ b/internal/runtime/executor/helps/claude_client_detection.go
@@ -496,6 +496,11 @@ func claudeJSONObjectHasKeys(raw []byte, want []string) bool {
return errClosing == nil && closing == json.Delim('}') && keyIndex == len(want)
}
+// IsClaudeCodeUserAgent reports whether userAgent identifies a Claude Code CLI client.
+func IsClaudeCodeUserAgent(userAgent string) bool {
+ return claudeCodeUserAgentPattern.MatchString(strings.TrimSpace(userAgent))
+}
+
func plausibleClaudeCodeUserAgent(userAgent string, cfg *config.Config) bool {
userAgent = strings.TrimSpace(userAgent)
if !claudeCodeUserAgentPattern.MatchString(userAgent) || !claudeCodeNativeUserAgentPattern.MatchString(userAgent) {
diff --git a/internal/runtime/executor/helps/claude_diagnostics.go b/internal/runtime/executor/helps/claude_diagnostics.go
index c43fe6cfed8..8e93f1fbc7f 100644
--- a/internal/runtime/executor/helps/claude_diagnostics.go
+++ b/internal/runtime/executor/helps/claude_diagnostics.go
@@ -119,13 +119,52 @@ type claudeDiagnosticsEntry struct {
expiresAt time.Time
}
+// claudeSessionDatePin retains a session date independently of continuity
+// state when the operator configures a retention window.
+type claudeSessionDatePin struct {
+ date string
+ expiresAt time.Time
+}
+
var claudeDiagnosticsState = struct {
sync.Mutex
entries map[string]claudeDiagnosticsEntry
+ datePins map[string]claudeSessionDatePin
lastCleanup time.Time
nextSequence uint64
nextAccess uint64
-}{entries: make(map[string]claudeDiagnosticsEntry)}
+}{entries: make(map[string]claudeDiagnosticsEntry), datePins: make(map[string]claudeSessionDatePin)}
+
+// ClaudeSessionDatePinKey returns the credential-independent store key
+// for a session date pin, or "" for an empty session ID.
+func ClaudeSessionDatePinKey(sessionID string) string {
+ sessionID = strings.TrimSpace(sessionID)
+ if sessionID == "" {
+ return ""
+ }
+ digest := sha256.Sum256([]byte(sessionID))
+ return hex.EncodeToString(digest[:])
+}
+
+// LookupClaudeSessionDatePin returns the retained pinned date for a session
+// without creating or refreshing it. Non-positive retention returns "".
+func LookupClaudeSessionDatePin(sessionID string, retention time.Duration) string {
+ if retention <= 0 {
+ return ""
+ }
+ pinKey := ClaudeSessionDatePinKey(sessionID)
+ if pinKey == "" {
+ return ""
+ }
+ now := time.Now()
+ claudeDiagnosticsState.Lock()
+ defer claudeDiagnosticsState.Unlock()
+ pin, okPin := claudeDiagnosticsState.datePins[pinKey]
+ if !okPin || now.After(pin.expiresAt) {
+ return ""
+ }
+ return pin.date
+}
// IsValidClaudePromptID verifies whether an explicit prompt ID adheres to strict RFC 4122 UUIDv4 semantics.
func IsValidClaudePromptID(id string) bool {
@@ -197,32 +236,47 @@ func BeginClaudeContinuity(credentialIdentity, sessionID string, isNewPromptTurn
return key, sequence, entry.previousMessageID, entry.previousRequestID, activePromptID
}
-// PinClaudeSessionDate returns the calendar date pinned for this continuity
-// session, recording date on the first call of a session and returning the
-// pinned value on later calls. This keeps the cloaked currentDate reminder
-// byte-stable within a session so a local-midnight flip cannot invalidate the
-// prompt-cache prefix. TTL expiry resets the entry, which re-anchors the next
-// request of the same session to the then-current date. An unknown key (for
-// example after a process restart) returns date unchanged, matching the
-// per-request behaviour.
-func PinClaudeSessionDate(key, date string) string {
+// PinClaudeSessionDate returns the calendar date pinned for a session,
+// recording date on the first call and returning the pinned value on later
+// calls. A non-positive retention keeps the legacy continuity-scoped pin,
+// whose lifetime follows claudeDiagnosticsTTL. A positive retention stores
+// the pin separately under a session-only key, so continuity expiry or a
+// credential switch cannot re-anchor it before the retention expires. An
+// unknown continuity key returns date unchanged, matching per-request dates.
+func PinClaudeSessionDate(key, sessionID, date string, retention time.Duration) string {
key = strings.TrimSpace(key)
+ sessionID = strings.TrimSpace(sessionID)
date = strings.TrimSpace(date)
if key == "" || date == "" {
return date
}
+ now := time.Now()
claudeDiagnosticsState.Lock()
defer claudeDiagnosticsState.Unlock()
- entry, ok := claudeDiagnosticsState.entries[key]
- if !ok {
+ entry, okEntry := claudeDiagnosticsState.entries[key]
+ if !okEntry {
return date
}
- if entry.pinnedDate == "" {
- entry.pinnedDate = date
- claudeDiagnosticsState.entries[key] = entry
- return date
+ if retention <= 0 || sessionID == "" {
+ if entry.pinnedDate == "" {
+ entry.pinnedDate = date
+ claudeDiagnosticsState.entries[key] = entry
+ return date
+ }
+ return entry.pinnedDate
}
- return entry.pinnedDate
+ cleanupClaudeDiagnosticsLocked(now)
+ pinKey := ClaudeSessionDatePinKey(sessionID)
+ pin, okPin := claudeDiagnosticsState.datePins[pinKey]
+ if !okPin {
+ evictClaudeSessionDatePinsLocked()
+ }
+ if !okPin || now.After(pin.expiresAt) {
+ pin.date = date
+ }
+ pin.expiresAt = now.Add(retention)
+ claudeDiagnosticsState.datePins[pinKey] = pin
+ return pin.date
}
// BeginClaudeDiagnostics starts one request generation for a stable credential
@@ -287,9 +341,37 @@ func cleanupClaudeDiagnosticsLocked(now time.Time) {
delete(claudeDiagnosticsState.entries, key)
}
}
+ for key, pin := range claudeDiagnosticsState.datePins {
+ if now.After(pin.expiresAt) {
+ delete(claudeDiagnosticsState.datePins, key)
+ }
+ }
claudeDiagnosticsState.lastCleanup = now
}
+// evictClaudeSessionDatePinsLocked drops the pins closest to expiry once
+// the store is full. Sliding expiry makes those the least recently used.
+func evictClaudeSessionDatePinsLocked() {
+ if len(claudeDiagnosticsState.datePins) < claudeDiagnosticsMaxEntries {
+ return
+ }
+ type candidate struct {
+ key string
+ expiresAt time.Time
+ }
+ candidates := make([]candidate, 0, len(claudeDiagnosticsState.datePins))
+ for key, pin := range claudeDiagnosticsState.datePins {
+ candidates = append(candidates, candidate{key: key, expiresAt: pin.expiresAt})
+ }
+ sort.Slice(candidates, func(i, j int) bool {
+ return candidates[i].expiresAt.Before(candidates[j].expiresAt)
+ })
+ count := min(claudeDiagnosticsEvictBatchSize, len(candidates))
+ for _, candidate := range candidates[:count] {
+ delete(claudeDiagnosticsState.datePins, candidate.key)
+ }
+}
+
func evictClaudeDiagnosticsLocked() {
if len(claudeDiagnosticsState.entries) < claudeDiagnosticsMaxEntries {
return
@@ -316,10 +398,49 @@ func ResetClaudeDiagnosticsForTest() {
resetClaudeDiagnosticsForTest()
}
+// ClaudeSessionDatePinExpiryForTest returns the expiry of the retained date
+// pin for a session, or the zero time when no pin exists.
+func ClaudeSessionDatePinExpiryForTest(sessionID string) time.Time {
+ pinKey := ClaudeSessionDatePinKey(sessionID)
+ if pinKey == "" {
+ return time.Time{}
+ }
+ claudeDiagnosticsState.Lock()
+ defer claudeDiagnosticsState.Unlock()
+ pin, okPin := claudeDiagnosticsState.datePins[pinKey]
+ if !okPin {
+ return time.Time{}
+ }
+ return pin.expiresAt
+}
+
+// ExpireClaudeDiagnosticsForTest expires continuity entries and retained date
+// pins without waiting for wall-clock TTLs. Pin expiry is relative to now.
+func ExpireClaudeDiagnosticsForTest(expireContinuity bool, pinExpiryOffset *time.Duration) {
+ claudeDiagnosticsState.Lock()
+ defer claudeDiagnosticsState.Unlock()
+ if expireContinuity {
+ past := time.Now().Add(-time.Second)
+ for key, entry := range claudeDiagnosticsState.entries {
+ entry.expiresAt = past
+ claudeDiagnosticsState.entries[key] = entry
+ }
+ }
+ if pinExpiryOffset != nil {
+ expiresAt := time.Now().Add(*pinExpiryOffset)
+ for key, pin := range claudeDiagnosticsState.datePins {
+ pin.expiresAt = expiresAt
+ claudeDiagnosticsState.datePins[key] = pin
+ }
+ }
+ claudeDiagnosticsState.lastCleanup = time.Time{}
+}
+
func resetClaudeDiagnosticsForTest() {
claudeDiagnosticsState.Lock()
defer claudeDiagnosticsState.Unlock()
claudeDiagnosticsState.entries = make(map[string]claudeDiagnosticsEntry)
+ claudeDiagnosticsState.datePins = make(map[string]claudeSessionDatePin)
claudeDiagnosticsState.lastCleanup = time.Time{}
claudeDiagnosticsState.nextSequence = 0
claudeDiagnosticsState.nextAccess = 0
diff --git a/internal/runtime/executor/helps/claude_diagnostics_test.go b/internal/runtime/executor/helps/claude_diagnostics_test.go
index 5b425f1e326..221a75bfda3 100644
--- a/internal/runtime/executor/helps/claude_diagnostics_test.go
+++ b/internal/runtime/executor/helps/claude_diagnostics_test.go
@@ -447,29 +447,71 @@ func TestPinClaudeSessionDateAnchorsFirstRequestAndReanchorsAfterTTL(t *testing.
defer resetClaudeDiagnosticsForTest()
key, _, _ := BeginClaudeDiagnostics("credential", "session")
- if got := PinClaudeSessionDate(key, "2026-08-01"); got != "2026-08-01" {
+ if got := PinClaudeSessionDate(key, "session", "2026-08-01", 0); got != "2026-08-01" {
t.Fatalf("first pin = %q, want 2026-08-01", got)
}
// Later requests of the same session keep the anchor even when the
// candidate date has flipped past local midnight.
- if got := PinClaudeSessionDate(key, "2026-08-02"); got != "2026-08-01" {
+ if got := PinClaudeSessionDate(key, "session", "2026-08-02", 0); got != "2026-08-01" {
t.Fatalf("second pin = %q, want anchored 2026-08-01", got)
}
- // TTL expiry resets the entry, so the session re-anchors to the current date.
+ // With no retention, continuity TTL expiry preserves the legacy anchor.
claudeDiagnosticsState.Lock()
entry := claudeDiagnosticsState.entries[key]
entry.expiresAt = time.Now().Add(-time.Second)
claudeDiagnosticsState.entries[key] = entry
claudeDiagnosticsState.Unlock()
BeginClaudeDiagnostics("credential", "session")
- if got := PinClaudeSessionDate(key, "2026-08-02"); got != "2026-08-02" {
+ if got := PinClaudeSessionDate(key, "session", "2026-08-02", 0); got != "2026-08-02" {
t.Fatalf("post-TTL pin = %q, want re-anchored 2026-08-02", got)
}
// Unknown keys (no continuity entry) fall back to the candidate date,
// preserving the pre-pinning per-request behaviour.
- if got := PinClaudeSessionDate("unknown-key", "2026-08-03"); got != "2026-08-03" {
+ if got := PinClaudeSessionDate("unknown-key", "", "2026-08-03", 0); got != "2026-08-03" {
t.Fatalf("unknown key pin = %q, want candidate 2026-08-03", got)
}
}
+
+func TestPinClaudeSessionDateRetentionSurvivesContinuityTTLAndCredentialSwitch(t *testing.T) {
+ resetClaudeDiagnosticsForTest()
+ defer resetClaudeDiagnosticsForTest()
+
+ retention := 7 * 24 * time.Hour
+ keyA, _, _ := BeginClaudeDiagnostics("credential-a", "session")
+ if got := PinClaudeSessionDate(keyA, "session", "2026-08-01", retention); got != "2026-08-01" {
+ t.Fatalf("first pin = %q, want 2026-08-01", got)
+ }
+
+ claudeDiagnosticsState.Lock()
+ entry := claudeDiagnosticsState.entries[keyA]
+ entry.expiresAt = time.Now().Add(-time.Second)
+ claudeDiagnosticsState.entries[keyA] = entry
+ claudeDiagnosticsState.lastCleanup = time.Time{}
+ claudeDiagnosticsState.Unlock()
+ BeginClaudeDiagnostics("credential-a", "session")
+ if got := PinClaudeSessionDate(keyA, "session", "2026-08-02", retention); got != "2026-08-01" {
+ t.Fatalf("post-continuity-TTL pin = %q, want anchored 2026-08-01", got)
+ }
+
+ keyB, _, _ := BeginClaudeDiagnostics("credential-b", "session")
+ if got := PinClaudeSessionDate(keyB, "session", "2026-08-02", retention); got != "2026-08-01" {
+ t.Fatalf("post-credential-switch pin = %q, want anchored 2026-08-01", got)
+ }
+
+ claudeDiagnosticsState.Lock()
+ delete(claudeDiagnosticsState.entries, keyA)
+ delete(claudeDiagnosticsState.entries, keyB)
+ for pinKey := range claudeDiagnosticsState.datePins {
+ pin := claudeDiagnosticsState.datePins[pinKey]
+ pin.expiresAt = time.Now().Add(-time.Second)
+ claudeDiagnosticsState.datePins[pinKey] = pin
+ }
+ claudeDiagnosticsState.lastCleanup = time.Time{}
+ claudeDiagnosticsState.Unlock()
+ keyC, _, _ := BeginClaudeDiagnostics("credential-c", "session")
+ if got := PinClaudeSessionDate(keyC, "session", "2026-08-02", retention); got != "2026-08-02" {
+ t.Fatalf("post-pin-TTL pin = %q, want re-anchored 2026-08-02", got)
+ }
+}
diff --git a/internal/runtime/executor/helps/claude_ratelimit.go b/internal/runtime/executor/helps/claude_ratelimit.go
index e4aa4592c5b..ff1c7f27eee 100644
--- a/internal/runtime/executor/helps/claude_ratelimit.go
+++ b/internal/runtime/executor/helps/claude_ratelimit.go
@@ -2,6 +2,7 @@ package helps
import (
cryptorand "crypto/rand"
+ "encoding/json"
"math"
"math/big"
"net/http"
@@ -118,6 +119,106 @@ func isClaudeUtilizationHealthy(raw string) bool {
return u >= 0 && u < 1.0
}
+// claudeQuotaStatusHeaders are the Anthropic headers whose non-empty value, under any
+// casing or in any repeated entry, declares a quota window. An overage status declares
+// its own window, which keeps the conservative parser in charge.
+var claudeQuotaStatusHeaders = []string{
+ "Anthropic-Ratelimit-Unified-Status",
+ "Anthropic-Ratelimit-Unified-5h-Status",
+ "Anthropic-Ratelimit-Unified-7d-Status",
+ "Anthropic-Ratelimit-Unified-7d_oi-Status",
+ "Anthropic-Ratelimit-Unified-Overage-Status",
+}
+
+// ParseClaudeRateLimitResetForError is ParseClaudeRateLimitReset with one narrow,
+// body-aware exception. An explicit per-minute rate-limit error carries no quota
+// window, so an unclassified unified reset must not stretch its short Retry-After
+// into an hours-long cooldown. The exception applies only when the body is exactly
+// one complete per-minute message, no status header declares a quota window, and
+// Retry-After is a single unambiguous value usable by the downstream deadline
+// filter. Every other input keeps the conservative header-only result.
+func ParseClaudeRateLimitResetForError(headers http.Header, body []byte, now time.Time) *time.Duration {
+ if headers == nil || !claudeBodyIsExplicitPerMinuteRateLimit(body) || claudeHeadersHaveQuotaStatus(headers) {
+ return ParseClaudeRateLimitReset(headers, now)
+ }
+ rawRetryAfter, ok := singleHeaderValueCaseInsensitive(headers, "Retry-After")
+ if !ok {
+ return ParseClaudeRateLimitReset(headers, now)
+ }
+ retryAt, okRetry := parseRetryAfterHeader(rawRetryAfter, now)
+ if !okRetry || !retryAt.After(now) || retryAt.Sub(now) > maxClaudeRateLimitWindow {
+ return ParseClaudeRateLimitReset(headers, now)
+ }
+ // Only the unclassified unified reset can stretch the cooldown here: every
+ // window-specific reset is consulted solely when its window is rejected.
+ rateHeaders := headers.Clone()
+ for name := range rateHeaders {
+ if strings.EqualFold(name, "Anthropic-Ratelimit-Unified-Reset") {
+ delete(rateHeaders, name)
+ }
+ }
+ return ParseClaudeRateLimitReset(rateHeaders, now)
+}
+
+// claudeBodyIsExplicitPerMinuteRateLimit reports whether body is exactly one of the
+// complete per-minute rate-limit messages, ignoring case and surrounding whitespace.
+// Substring matching is unsafe here: a per-minute mention can also be negated or
+// accompany genuine quota exhaustion.
+func claudeBodyIsExplicitPerMinuteRateLimit(body []byte) bool {
+ var envelope struct {
+ Error struct {
+ Type string `json:"type"`
+ Message string `json:"message"`
+ } `json:"error"`
+ }
+ if errUnmarshal := json.Unmarshal(body, &envelope); errUnmarshal != nil || envelope.Error.Type != "rate_limit_error" {
+ return false
+ }
+ switch strings.ToLower(strings.TrimSpace(envelope.Error.Message)) {
+ case "this request would exceed the requests per minute rate limit. please try again later.",
+ "this request would exceed the input tokens per minute rate limit. please try again later.",
+ "this request would exceed the output tokens per minute rate limit. please try again later.":
+ return true
+ default:
+ return false
+ }
+}
+
+func claudeHeadersHaveQuotaStatus(headers http.Header) bool {
+ for _, name := range claudeQuotaStatusHeaders {
+ for key, values := range headers {
+ if !strings.EqualFold(key, name) {
+ continue
+ }
+ for _, value := range values {
+ if strings.TrimSpace(value) != "" {
+ return true
+ }
+ }
+ }
+ }
+ return false
+}
+
+// singleHeaderValueCaseInsensitive returns the sole value of target. A repeated value
+// or a differently cased alias key is ambiguous even when the values agree, because a
+// canonical lookup could then consume a different value than the one validated.
+func singleHeaderValueCaseInsensitive(headers http.Header, target string) (string, bool) {
+ value := ""
+ found := false
+ for key, values := range headers {
+ if !strings.EqualFold(key, target) {
+ continue
+ }
+ if found || len(values) != 1 {
+ return "", false
+ }
+ found = true
+ value = values[0]
+ }
+ return value, found
+}
+
// ParseClaudeRateLimitReset inspects Anthropic response headers for shared and Fable-specific
// unified rate-limit and standard Retry-After reset information, returning the conservative cooldown
// duration including a bounded non-negative random grace period.
diff --git a/internal/runtime/executor/helps/claude_request_rate_test.go b/internal/runtime/executor/helps/claude_request_rate_test.go
new file mode 100644
index 00000000000..ba8c8ae6b44
--- /dev/null
+++ b/internal/runtime/executor/helps/claude_request_rate_test.go
@@ -0,0 +1,153 @@
+package helps
+
+import (
+ "bufio"
+ "bytes"
+ "net/http"
+ "reflect"
+ "strconv"
+ "testing"
+ "time"
+)
+
+// Issue #6720: ParseClaudeRateLimitResetForError narrows the cooldown only for an
+// exact explicit per-minute error with one usable Retry-After and no quota status
+// evidence. All other inputs keep the conservative header-only result.
+
+var claudePerMinuteBodyRequests = []byte(
+ `{"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed the requests per minute rate limit. Please try again later."}}`,
+)
+
+func TestParseClaudeRateLimitResetForError_Issue6720(t *testing.T) {
+ now := time.Date(2026, 10, 17, 14, 0, 0, 0, time.UTC)
+ unifiedReset := strconv.FormatInt(now.Add(4*time.Hour).Unix(), 10)
+
+ baseHeaders := func() http.Header {
+ h := http.Header{}
+ h.Set("Retry-After", "30")
+ h.Set("Anthropic-Ratelimit-Unified-Reset", unifiedReset)
+ return h
+ }
+
+ tests := []struct {
+ name string
+ body []byte
+ mutate func(h http.Header)
+ roundtrip bool
+ wantMin time.Duration
+ wantMax time.Duration
+ wantNil bool
+ }{
+ {name: "nil headers", body: claudePerMinuteBodyRequests, mutate: func(h http.Header) {}, wantNil: true},
+ {name: "short retry honored", body: claudePerMinuteBodyRequests, wantMin: 31 * time.Second, wantMax: 60 * time.Second},
+ {name: "http date retry honored", body: claudePerMinuteBodyRequests, mutate: func(h http.Header) {
+ h.Set("Retry-After", now.Add(30*time.Second).UTC().Format(http.TimeFormat))
+ }, wantMin: 31 * time.Second, wantMax: 60 * time.Second},
+ {name: "message case and whitespace ignored", body: []byte(
+ `{"type":"error","error":{"type":"rate_limit_error","message":" THIS REQUEST WOULD EXCEED THE REQUESTS PER MINUTE RATE LIMIT. PLEASE TRY AGAIN LATER.\n"}}`,
+ ), wantMin: 31 * time.Second, wantMax: 60 * time.Second},
+ {name: "empty alias status values do not block", body: claudePerMinuteBodyRequests, mutate: func(h http.Header) {
+ h["anthropic-ratelimit-unified-status"] = []string{""}
+ }, wantMin: 31 * time.Second, wantMax: 60 * time.Second},
+ {name: "maximum usable retry honored", body: claudePerMinuteBodyRequests, mutate: func(h http.Header) {
+ h.Set("Retry-After", strconv.FormatInt(int64(maxClaudeRateLimitWindow/time.Second), 10))
+ }, wantMin: maxClaudeRateLimitWindow + time.Second, wantMax: maxClaudeRateLimitWindow + 30*time.Second},
+ {name: "nonempty alias status blocks", body: claudePerMinuteBodyRequests, mutate: func(h http.Header) {
+ h["Anthropic-Ratelimit-Unified-Status"] = []string{""}
+ h["anthropic-ratelimit-unified-status"] = []string{"", "rejected"}
+ }, wantMin: 4*time.Hour + time.Second, wantMax: 4*time.Hour + 30*time.Second},
+ // An allowed shared window already excludes the unified reset in the
+ // conservative parser, so the short retry wins with or without the exception.
+ {name: "allowed shared window already excludes unified reset", body: claudePerMinuteBodyRequests, mutate: func(h http.Header) {
+ h.Set("Anthropic-Ratelimit-Unified-5h-Status", "allowed")
+ }, wantMin: 31 * time.Second, wantMax: 60 * time.Second},
+ {name: "aliased retry-after keys are ambiguous", body: claudePerMinuteBodyRequests, mutate: func(h http.Header) {
+ h.Del("Retry-After")
+ h["retry-after"] = []string{"30"}
+ h["RETRY-AFTER"] = []string{"30"}
+ }, wantMin: 4*time.Hour + time.Second, wantMax: 4*time.Hour + 30*time.Second},
+ {name: "unusable retry beyond recoverable window", body: claudePerMinuteBodyRequests, mutate: func(h http.Header) {
+ h.Set("Retry-After", strconv.FormatInt(int64(maxClaudeRateLimitWindow/time.Second)+1, 10))
+ }, wantMin: 4*time.Hour + time.Second, wantMax: 4*time.Hour + 30*time.Second},
+ {name: "lowercase headers honored", body: claudePerMinuteBodyRequests, mutate: func(h http.Header) {
+ h.Del("Retry-After")
+ h.Del("Anthropic-Ratelimit-Unified-Reset")
+ h["retry-after"] = []string{"30"}
+ h["anthropic-ratelimit-unified-reset"] = []string{unifiedReset}
+ }, wantMin: 31 * time.Second, wantMax: 60 * time.Second},
+ // The latest applicable deadline wins in the conservative parser: if a differently cased
+ // unified reset survived, its 2h deadline would beat the 30s retry.
+ {name: "differently cased unified resets all excluded", body: claudePerMinuteBodyRequests, mutate: func(h http.Header) {
+ h["anthropic-ratelimit-unified-reset"] = []string{strconv.FormatInt(now.Add(2*time.Hour).Unix(), 10)}
+ }, wantMin: 31 * time.Second, wantMax: 60 * time.Second},
+ {name: "http header round trip honored", body: claudePerMinuteBodyRequests, roundtrip: true, wantMin: 31 * time.Second, wantMax: 60 * time.Second},
+ {name: "nil body keeps conservative result", body: nil, wantMin: 4*time.Hour + time.Second, wantMax: 4*time.Hour + 30*time.Second},
+ {name: "invalid retry keeps conservative result", body: claudePerMinuteBodyRequests, mutate: func(h http.Header) {
+ h.Set("Retry-After", "invalid")
+ }, wantMin: 4*time.Hour + time.Second, wantMax: 4*time.Hour + 30*time.Second},
+ {name: "zero retry keeps conservative result", body: claudePerMinuteBodyRequests, mutate: func(h http.Header) {
+ h.Set("Retry-After", "0")
+ }, wantMin: 4*time.Hour + time.Second, wantMax: 4*time.Hour + 30*time.Second},
+ {name: "negative retry keeps conservative result", body: claudePerMinuteBodyRequests, mutate: func(h http.Header) {
+ h.Set("Retry-After", "-30")
+ }, wantMin: 4*time.Hour + time.Second, wantMax: 4*time.Hour + 30*time.Second},
+ {name: "overflow retry keeps conservative result", body: claudePerMinuteBodyRequests, mutate: func(h http.Header) {
+ h.Set("Retry-After", "1e300")
+ }, wantMin: 4*time.Hour + time.Second, wantMax: 4*time.Hour + 30*time.Second},
+ {name: "wrong error type", body: []byte(
+ `{"type":"error","error":{"type":"api_error","message":"This request would exceed the requests per minute rate limit. Please try again later."}}`,
+ ), wantMin: 4*time.Hour + time.Second, wantMax: 4*time.Hour + 30*time.Second},
+ {name: "overage-only rejection keeps no credential cooldown", body: claudePerMinuteBodyRequests, mutate: func(h http.Header) {
+ h.Set("Anthropic-Ratelimit-Unified-Status", "rejected")
+ h.Set("Anthropic-Ratelimit-Unified-5h-Status", "allowed")
+ h.Set("Anthropic-Ratelimit-Unified-7d-Status", "allowed")
+ h.Set("Anthropic-Ratelimit-Unified-Overage-Status", "rejected")
+ }, wantNil: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var headers http.Header
+ if tt.name != "nil headers" {
+ headers = baseHeaders()
+ if tt.mutate != nil {
+ tt.mutate(headers)
+ }
+ }
+ if tt.roundtrip && headers != nil {
+ wire := &http.Response{StatusCode: http.StatusTooManyRequests, ProtoMajor: 1, ProtoMinor: 1, Header: headers, Body: http.NoBody}
+ var encoded bytes.Buffer
+ if errWrite := wire.Write(&encoded); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ decoded, errRead := http.ReadResponse(bufio.NewReader(&encoded), nil)
+ if errRead != nil {
+ t.Fatal(errRead)
+ }
+ if errClose := decoded.Body.Close(); errClose != nil {
+ t.Fatal(errClose)
+ }
+ headers = decoded.Header
+ }
+ before := headers.Clone()
+
+ got := ParseClaudeRateLimitResetForError(headers, tt.body, now)
+
+ if !reflect.DeepEqual(headers, before) {
+ t.Fatalf("headers mutated: %v -> %v", before, headers)
+ }
+ if tt.wantNil {
+ if got != nil {
+ t.Fatalf("expected nil cooldown, got %v", *got)
+ }
+ return
+ }
+ if got == nil {
+ t.Fatal("expected non-nil cooldown, got nil")
+ }
+ if *got < tt.wantMin || *got > tt.wantMax {
+ t.Fatalf("cooldown = %v, want between %v and %v", *got, tt.wantMin, tt.wantMax)
+ }
+ })
+ }
+}
diff --git a/internal/runtime/executor/helps/claude_web_search_results.go b/internal/runtime/executor/helps/claude_web_search_results.go
new file mode 100644
index 00000000000..97757dcdda2
--- /dev/null
+++ b/internal/runtime/executor/helps/claude_web_search_results.go
@@ -0,0 +1,114 @@
+package helps
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
+ log "github.com/sirupsen/logrus"
+ "github.com/tidwall/gjson"
+ "github.com/tidwall/sjson"
+)
+
+var claudeWebSearchToolResultMarker = []byte(`"web_search_tool_result"`)
+
+// RecordClaudeWebSearchResults stores the web_search_tool_result blocks of a
+// Claude response for sessionKey. data is either a complete Messages response
+// or SSE text (one or more lines); the result block arrives whole in its
+// content_block_start event.
+func RecordClaudeWebSearchResults(sessionKey string, data []byte, ttl time.Duration, limit int) {
+ RecordClaudeWebSearchResultsContext(context.Background(), sessionKey, data, ttl, limit)
+}
+
+// RecordClaudeWebSearchResultsContext stores the web_search_tool_result blocks
+// of a Claude response for sessionKey using ctx for Home KV operations.
+func RecordClaudeWebSearchResultsContext(ctx context.Context, sessionKey string, data []byte, ttl time.Duration, limit int) {
+ if strings.TrimSpace(sessionKey) == "" || !bytes.Contains(data, claudeWebSearchToolResultMarker) {
+ return
+ }
+ if content := gjson.GetBytes(data, "content"); content.IsArray() {
+ content.ForEach(func(_, block gjson.Result) bool {
+ recordClaudeWebSearchResultBlock(ctx, sessionKey, block, ttl, limit)
+ return true
+ })
+ return
+ }
+ for _, line := range bytes.Split(data, []byte("\n")) {
+ line = bytes.TrimSpace(line)
+ if !bytes.HasPrefix(line, []byte("data:")) {
+ continue
+ }
+ event := gjson.ParseBytes(bytes.TrimSpace(line[len("data:"):]))
+ if event.Get("type").String() == "content_block_start" {
+ recordClaudeWebSearchResultBlock(ctx, sessionKey, event.Get("content_block"), ttl, limit)
+ }
+ }
+}
+
+func recordClaudeWebSearchResultBlock(ctx context.Context, sessionKey string, block gjson.Result, ttl time.Duration, limit int) {
+ if block.Get("type").String() != "web_search_tool_result" {
+ return
+ }
+ content := block.Get("content")
+ if !content.Exists() || claudeWebSearchResultContentEmpty(content) {
+ return
+ }
+ cache.CacheClaudeWebSearchResultBestEffort(ctx, sessionKey, block.Get("tool_use_id").String(), []byte(content.Raw), ttl, limit)
+}
+
+// RestoreClaudeWebSearchResults fills assistant web_search_tool_result blocks
+// whose content is missing or empty with the content stored for sessionKey.
+// Blocks that already carry content, and blocks without a stored entry, are
+// left unchanged.
+func RestoreClaudeWebSearchResults(body []byte, sessionKey string, ttl ...time.Duration) []byte {
+ return RestoreClaudeWebSearchResultsContext(context.Background(), body, sessionKey, ttl...)
+}
+
+// RestoreClaudeWebSearchResultsContext fills assistant web_search_tool_result
+// blocks whose content is missing or empty with the content stored for
+// sessionKey using ctx for Home KV operations.
+func RestoreClaudeWebSearchResultsContext(ctx context.Context, body []byte, sessionKey string, ttl ...time.Duration) []byte {
+ if strings.TrimSpace(sessionKey) == "" || !bytes.Contains(body, claudeWebSearchToolResultMarker) {
+ return body
+ }
+ messages := gjson.GetBytes(body, "messages")
+ if !messages.IsArray() {
+ return body
+ }
+ restored := 0
+ for i, message := range messages.Array() {
+ if message.Get("role").String() != "assistant" {
+ continue
+ }
+ for j, block := range message.Get("content").Array() {
+ if block.Get("type").String() != "web_search_tool_result" || !claudeWebSearchResultContentEmpty(block.Get("content")) {
+ continue
+ }
+ cached, ok, errGet := cache.GetClaudeWebSearchResultRequired(ctx, sessionKey, block.Get("tool_use_id").String(), ttl...)
+ if errGet != nil || !ok {
+ continue
+ }
+ updated, errSet := sjson.SetRawBytes(body, fmt.Sprintf("messages.%d.content.%d.content", i, j), cached)
+ if errSet != nil {
+ log.Debugf("claude web search results: restore messages.%d.content.%d failed: %v", i, j, errSet)
+ continue
+ }
+ body = updated
+ restored++
+ }
+ }
+ if restored > 0 {
+ log.Debugf("claude web search results: restored %d result block(s) from cache", restored)
+ }
+ return body
+}
+
+func claudeWebSearchResultContentEmpty(content gjson.Result) bool {
+ if !content.Exists() || content.Type == gjson.Null {
+ return true
+ }
+ return content.IsArray() && len(content.Array()) == 0
+}
diff --git a/internal/runtime/executor/helps/claude_web_search_results_test.go b/internal/runtime/executor/helps/claude_web_search_results_test.go
new file mode 100644
index 00000000000..f100e5679c5
--- /dev/null
+++ b/internal/runtime/executor/helps/claude_web_search_results_test.go
@@ -0,0 +1,85 @@
+package helps
+
+import (
+ "testing"
+ "time"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
+ "github.com/tidwall/gjson"
+)
+
+const claudeWebSearchTestResults = `[{"type":"web_search_result","url":"https://example.com/a","title":"A","encrypted_content":"enc-a","page_age":null},{"type":"web_search_result","url":"https://example.com/b","title":"B","encrypted_content":"enc-b","page_age":null}]`
+
+func resetClaudeWebSearchResults(t *testing.T) {
+ t.Helper()
+ cache.ClearClaudeWebSearchResultCache()
+ t.Cleanup(cache.ClearClaudeWebSearchResultCache)
+}
+
+func claudeWebSearchRequestBody(resultContent string) []byte {
+ return []byte(`{"model":"claude-opus-5-5","messages":[` +
+ `{"role":"user","content":[{"type":"text","text":"search"}]},` +
+ `{"role":"assistant","content":[` +
+ `{"type":"server_tool_use","id":"srvtoolu_1","name":"web_search","input":{"query":"q"}},` +
+ `{"type":"web_search_tool_result","tool_use_id":"srvtoolu_1","content":` + resultContent + `},` +
+ `{"type":"text","text":"answer"}]},` +
+ `{"role":"user","content":[{"type":"text","text":"how many results?"}]}]}`)
+}
+
+func TestRecordClaudeWebSearchResultsFromStreamEventAndRestore(t *testing.T) {
+ resetClaudeWebSearchResults(t)
+ event := []byte(`data: {"type":"content_block_start","index":1,"content_block":{"type":"web_search_tool_result","tool_use_id":"srvtoolu_1","content":` + claudeWebSearchTestResults + `}}`)
+ RecordClaudeWebSearchResults("session-1", event, 30*time.Minute, 16)
+
+ got := RestoreClaudeWebSearchResults(claudeWebSearchRequestBody(`[]`), "session-1", 30*time.Minute)
+ if raw := gjson.GetBytes(got, "messages.1.content.1.content").Raw; raw != claudeWebSearchTestResults {
+ t.Fatalf("restored content = %s, want original results", raw)
+ }
+ if gjson.GetBytes(got, "messages.1.content.2.text").String() != "answer" {
+ t.Fatalf("neighbouring blocks changed: %s", got)
+ }
+}
+
+func TestRecordClaudeWebSearchResultsFromMessageResponse(t *testing.T) {
+ resetClaudeWebSearchResults(t)
+ response := []byte(`{"id":"msg_1","type":"message","role":"assistant","content":[` +
+ `{"type":"server_tool_use","id":"srvtoolu_1","name":"web_search","input":{"query":"q"}},` +
+ `{"type":"web_search_tool_result","tool_use_id":"srvtoolu_1","content":` + claudeWebSearchTestResults + `}]}`)
+ RecordClaudeWebSearchResults("session-1", response, 30*time.Minute, 16)
+
+ got := RestoreClaudeWebSearchResults(claudeWebSearchRequestBody(`[]`), "session-1", 30*time.Minute)
+ if raw := gjson.GetBytes(got, "messages.1.content.1.content").Raw; raw != claudeWebSearchTestResults {
+ t.Fatalf("restored content = %s, want original results", raw)
+ }
+}
+
+func TestRestoreClaudeWebSearchResultsLeavesBodyUnchangedWhenNotApplicable(t *testing.T) {
+ resetClaudeWebSearchResults(t)
+ RecordClaudeWebSearchResults("session-1", []byte(`data: {"type":"content_block_start","index":1,"content_block":{"type":"web_search_tool_result","tool_use_id":"srvtoolu_1","content":`+claudeWebSearchTestResults+`}}`), 30*time.Minute, 16)
+
+ cases := map[string]struct {
+ body []byte
+ sessionKey string
+ }{
+ "other session": {claudeWebSearchRequestBody(`[]`), "session-2"},
+ "no session": {claudeWebSearchRequestBody(`[]`), ""},
+ "content already present": {claudeWebSearchRequestBody(`[{"type":"web_search_result","url":"https://example.com/z","encrypted_content":"z"}]`), "session-1"},
+ "unknown tool use": {[]byte(`{"messages":[{"role":"assistant","content":[{"type":"web_search_tool_result","tool_use_id":"srvtoolu_9","content":[]}]}]}`), "session-1"},
+ "user role is ignored": {[]byte(`{"messages":[{"role":"user","content":[{"type":"web_search_tool_result","tool_use_id":"srvtoolu_1","content":[]}]}]}`), "session-1"},
+ }
+ for name, tc := range cases {
+ t.Run(name, func(t *testing.T) {
+ if got := RestoreClaudeWebSearchResults(tc.body, tc.sessionKey, 30*time.Minute); string(got) != string(tc.body) {
+ t.Fatalf("body changed:\n got %s\nwant %s", got, tc.body)
+ }
+ })
+ }
+}
+
+func TestRecordClaudeWebSearchResultsIgnoresEmptyResults(t *testing.T) {
+ resetClaudeWebSearchResults(t)
+ RecordClaudeWebSearchResults("session-1", []byte(`data: {"type":"content_block_start","index":1,"content_block":{"type":"web_search_tool_result","tool_use_id":"srvtoolu_1","content":[]}}`), 30*time.Minute, 16)
+ if _, ok := cache.GetClaudeWebSearchResult("session-1", "srvtoolu_1"); ok {
+ t.Fatal("empty result content was cached")
+ }
+}
diff --git a/internal/runtime/executor/helps/codex_sse_scanner.go b/internal/runtime/executor/helps/codex_sse_scanner.go
new file mode 100644
index 00000000000..35f553290d7
--- /dev/null
+++ b/internal/runtime/executor/helps/codex_sse_scanner.go
@@ -0,0 +1,330 @@
+package helps
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "strconv"
+ "strings"
+
+ log "github.com/sirupsen/logrus"
+)
+
+// codexSSEDiagnosticLines caps how many data lines one invalid-event diagnostic describes.
+const codexSSEDiagnosticLines = 8
+
+var codexSSEDataPrefix = []byte("data:")
+
+type codexSSEQueuedItem struct {
+ payload []byte
+ rawLines int
+ rawBytes int
+}
+
+// CodexSSEScanner reads an upstream Responses SSE stream line by line, as bufio.Scanner does, but
+// delivers an event whose data the upstream split across several "data:" lines as one "data:" line.
+// In SSE an event's data may span several data lines, joined with "\n". Taking each data line as a
+// whole event instead cuts such an event: on a split response.completed the stream ends at the
+// first fragment and the rest is never read.
+//
+// Every event on this path is one JSON value. A data line that is complete JSON on its own, or
+// cannot start JSON at all (an empty value, [DONE]), is delivered byte-for-byte as read with no
+// lookahead, which keeps upstreams that omit the blank line between events working as before. A
+// data line that opens a JSON object or array without completing it starts an event that collects
+// the following data lines until one of these:
+// - their joined value closes its outermost object or array;
+// - a blank line ends the event;
+// - the stream ends;
+// - the collected bytes would exceed the line limit.
+//
+// A completed event is delivered compacted onto one line. One that never becomes valid JSON is
+// delivered as the same multi-line data field, so it still fails downstream. The first such event
+// in a stream gets one warning that describes its framing without any of its bytes.
+//
+// The scanner merges data lines and nothing else. It holds only the data lines of an event being
+// collected and delivers every other line the moment it reads it, in the order read. A comment,
+// id: or retry: line inside such an event therefore comes before the event's data instead of
+// between its pieces, which SSE treats alike: an event's fields take effect when it is dispatched.
+// The blank line or over-limit line that ends a collection follows the event's data.
+//
+// SetAssembly(false) makes the scanner read exactly as bufio.Scanner does, for a caller that
+// budgets every upstream line while it holds a stream.
+type CodexSSEScanner struct {
+ ctx context.Context
+ lines *bufio.Scanner
+ maxBytes int
+ // stock is set while assembly is off.
+ stock bool
+ // held is the event being collected: its data lines as read, joined with "\n" in one buffer, so
+ // its memory follows its bytes, not its line count. count is how many lines it holds and size
+ // their bytes. joined is their joined value, and nesting follows it.
+ held []byte
+ count int
+ size int
+ joined []byte
+ nesting codexJSONNesting
+ queue []codexSSEQueuedItem
+ current codexSSEQueuedItem
+ // warned is set once the stream has had its invalid-event warning.
+ warned bool
+ onLine func(line []byte)
+ budgetCheck func(line []byte, heldLines int, heldBytes int) bool
+}
+
+// NewCodexSSEScanner returns a CodexSSEScanner over r, with assembly on. maxBytes bounds one line,
+// as bufio.Scanner.Buffer does, and also the data lines collected for one event.
+func NewCodexSSEScanner(ctx context.Context, r io.Reader, maxBytes int) *CodexSSEScanner {
+ lines := bufio.NewScanner(r)
+ lines.Buffer(nil, maxBytes)
+ return &CodexSSEScanner{ctx: ctx, lines: lines, maxBytes: maxBytes}
+}
+
+// SetOnLine sets a callback invoked for every physical line read from upstream.
+func (s *CodexSSEScanner) SetOnLine(fn func(line []byte)) {
+ s.onLine = fn
+}
+
+// HeldLines returns how many data lines are currently held in an incomplete event.
+func (s *CodexSSEScanner) HeldLines() int {
+ return s.count
+}
+
+// HeldBytes returns how many bytes are currently held in an incomplete event.
+func (s *CodexSSEScanner) HeldBytes() int {
+ return s.size
+}
+
+// SetBudgetChecker sets an optional callback invoked on each raw line read while an event is being held.
+// If it returns true, the scanner yields from Scan() with an empty payload to signal that the
+// caller should release buffering, while preserving the held assembly state for subsequent Scan() calls.
+func (s *CodexSSEScanner) SetBudgetChecker(fn func(line []byte, heldLines int, heldBytes int) bool) {
+ s.budgetCheck = fn
+}
+
+// SetAssembly turns event assembly on or off. While it is off, no data line starts a collection and
+// every line is delivered as read, exactly as bufio.Scanner delivers it. An event already being
+// collected is still completed.
+func (s *CodexSSEScanner) SetAssembly(on bool) { s.stock = !on }
+
+// Scan advances to the next line, or to the next assembled event data line.
+func (s *CodexSSEScanner) Scan() bool {
+ if len(s.queue) > 0 {
+ s.current, s.queue = s.queue[0], s.queue[1:]
+ return true
+ }
+ for s.lines.Scan() {
+ line := s.lines.Bytes()
+ if s.onLine != nil {
+ s.onLine(line)
+ }
+ if !bytes.HasPrefix(line, codexSSEDataPrefix) {
+ if len(line) == 0 && s.count > 0 {
+ s.release([]byte{})
+ return s.Scan()
+ }
+ s.current = codexSSEQueuedItem{payload: line, rawLines: 1, rawBytes: len(line)}
+ return true
+ }
+ if s.count == 0 {
+ if s.stock || !codexSSEIncompleteJSON(bytes.TrimSpace(line[len(codexSSEDataPrefix):])) {
+ s.current = codexSSEQueuedItem{payload: line, rawLines: 1, rawBytes: len(line)}
+ return true
+ }
+ } else if s.size+len(line) > s.maxBytes {
+ // Bounded like a single line: stop collecting and let this line be read on its own.
+ s.release(bytes.Clone(line))
+ return s.Scan()
+ }
+
+ closed := s.hold(line)
+ if !closed && s.budgetCheck != nil && s.budgetCheck(line, s.count, s.size) {
+ s.budgetCheck = nil
+ s.current = codexSSEQueuedItem{payload: nil, rawLines: 0, rawBytes: 0}
+ return true
+ }
+ if closed {
+ s.release()
+ return s.Scan()
+ }
+ }
+ if s.count > 0 {
+ s.release()
+ return s.Scan()
+ }
+ return false
+}
+
+// hold adds a data line to the event being collected and reports whether its data has closed.
+func (s *CodexSSEScanner) hold(line []byte) bool {
+ value := codexSSEValue(line)
+ if s.count > 0 {
+ s.held = append(s.held, '\n')
+ s.joined = append(s.joined, '\n')
+ s.nesting.feed([]byte{'\n'})
+ }
+ s.held = append(s.held, line...)
+ s.joined = append(s.joined, value...)
+ s.count++
+ s.size += len(line)
+ return s.nesting.feed(value)
+}
+
+// release ends the collection and queues the event as one line, followed by the line that ended
+// it, if any.
+func (s *CodexSSEScanner) release(end ...[]byte) {
+ assembled := s.assemble(s.held, s.count, s.joined)
+ s.queue = append(s.queue, codexSSEQueuedItem{
+ payload: assembled,
+ rawLines: s.count,
+ rawBytes: s.size,
+ })
+ for _, e := range end {
+ s.queue = append(s.queue, codexSSEQueuedItem{
+ payload: e,
+ rawLines: 1,
+ rawBytes: len(e),
+ })
+ }
+ s.held, s.count, s.size, s.joined, s.nesting = nil, 0, 0, nil, codexJSONNesting{}
+}
+
+// codexSSEValue is one data line's value: the text after "data:" without the one leading space.
+func codexSSEValue(line []byte) []byte {
+ return bytes.TrimPrefix(line[len(codexSSEDataPrefix):], []byte(" "))
+}
+
+// codexSSEClosed reports whether joined event data is complete JSON. Only a value that now ends
+// with a closing bracket can have become complete, so other values are not re-validated.
+func codexSSEClosed(joined []byte) bool {
+ trimmed := bytes.TrimRight(joined, " \t\r\n")
+ if len(trimmed) == 0 || (trimmed[len(trimmed)-1] != '}' && trimmed[len(trimmed)-1] != ']') {
+ return false
+ }
+ return json.Valid(joined)
+}
+
+// codexSSEIncompleteJSON reports whether one data line's value starts a JSON object or array that
+// it does not complete. Values that cannot start JSON, such as an empty value or [DONE], are not.
+func codexSSEIncompleteJSON(value []byte) bool {
+ if len(value) == 0 || (value[0] != '{' && value[0] != '[') || bytes.Equal(value, []byte("[DONE]")) {
+ return false
+ }
+ return !codexSSEClosed(value)
+}
+
+// codexJSONNesting follows the nesting of a JSON text fed in pieces, so an event split across many
+// data lines is validated once, when its outermost object or array closes, not after every piece.
+type codexJSONNesting struct {
+ depth int
+ inString bool
+ escaped bool
+}
+
+// feed consumes p and reports whether the outermost object or array closed within it.
+func (n *codexJSONNesting) feed(p []byte) bool {
+ for _, c := range p {
+ switch {
+ case n.inString:
+ switch {
+ case n.escaped:
+ n.escaped = false
+ case c == '\\':
+ n.escaped = true
+ case c == '"':
+ n.inString = false
+ }
+ case c == '"':
+ n.inString = true
+ case c == '{' || c == '[':
+ n.depth++
+ case c == '}' || c == ']':
+ n.depth--
+ if n.depth <= 0 {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// Bytes returns the current line. It stays valid until the next call to Scan.
+func (s *CodexSSEScanner) Bytes() []byte { return s.current.payload }
+
+// RawLines returns the count of physical upstream lines consumed to produce the current event.
+func (s *CodexSSEScanner) RawLines() int {
+ if s.current.rawLines == 0 {
+ return 1
+ }
+ return s.current.rawLines
+}
+
+// RawBytes returns the total bytes of physical upstream lines consumed to produce the current event.
+func (s *CodexSSEScanner) RawBytes() int {
+ if s.current.rawBytes == 0 {
+ return len(s.current.payload)
+ }
+ return s.current.rawBytes
+}
+
+// Err returns the error that stopped the scan, as bufio.Scanner does.
+func (s *CodexSSEScanner) Err() error { return s.lines.Err() }
+
+// assemble returns the event held as lines data lines: compacted onto one line when its data is
+// valid JSON, otherwise as held.
+func (s *CodexSSEScanner) assemble(held []byte, lines int, joined []byte) []byte {
+ if lines == 1 {
+ s.diagnose(held, lines, bytes.TrimSpace(joined))
+ return held
+ }
+ if json.Valid(joined) {
+ var compacted bytes.Buffer
+ compacted.Grow(len(codexSSEDataPrefix) + 1 + len(joined))
+ compacted.Write(codexSSEDataPrefix)
+ compacted.WriteByte(' ')
+ if errCompact := json.Compact(&compacted, joined); errCompact == nil {
+ return compacted.Bytes()
+ }
+ }
+ s.diagnose(held, lines, joined)
+ return held
+}
+
+// diagnose logs one bounded warning for the first event in the stream whose data is not valid
+// JSON, so a broken upstream cannot turn every event into a log line. It describes how many data
+// lines carried it, each line's length and whether it is valid on its own, and where the joined
+// data fails to parse. It never logs data bytes.
+func (s *CodexSSEScanner) diagnose(held []byte, lines int, data []byte) {
+ if s.warned || len(data) == 0 || bytes.Equal(data, []byte("[DONE]")) || json.Valid(data) {
+ return
+ }
+ s.warned = true
+ offset := int64(-1)
+ var syntaxErr *json.SyntaxError
+ if errors.As(json.Unmarshal(data, new(json.RawMessage)), &syntaxErr) {
+ offset = syntaxErr.Offset
+ }
+ shown := min(lines, codexSSEDiagnosticLines)
+ lengths := make([]string, shown)
+ valid := make([]string, shown)
+ for i := range shown {
+ var line []byte
+ line, held, _ = bytes.Cut(held, []byte("\n"))
+ value := bytes.TrimSpace(line[len(codexSSEDataPrefix):])
+ lengths[i] = strconv.Itoa(len(value))
+ valid[i] = strconv.FormatBool(json.Valid(value))
+ }
+ more := ""
+ if lines > shown {
+ more = ",+" + strconv.Itoa(lines-shown)
+ }
+ LogWithRequestID(s.ctx).WithFields(log.Fields{
+ "data_lines": lines,
+ "line_bytes": strings.Join(lengths, ",") + more,
+ "lines_valid": strings.Join(valid, ",") + more,
+ "data_bytes": len(data),
+ "json_error_offset": offset,
+ }).Warn("codex executor: upstream SSE event data is not valid JSON")
+}
diff --git a/internal/runtime/executor/helps/codex_sse_scanner_test.go b/internal/runtime/executor/helps/codex_sse_scanner_test.go
new file mode 100644
index 00000000000..289dd2e9336
--- /dev/null
+++ b/internal/runtime/executor/helps/codex_sse_scanner_test.go
@@ -0,0 +1,341 @@
+package helps
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "io"
+ "strings"
+ "testing"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ logtest "github.com/sirupsen/logrus/hooks/test"
+)
+
+// wellFormedCodexSSEStream is a Responses stream with one event per data line, comments, event
+// lines, events without a blank separator, and the [DONE] sentinel.
+func wellFormedCodexSSEStream() string {
+ var stream strings.Builder
+ stream.WriteString("event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\",\"model\":\"gpt-5.4\"}}\n\n")
+ for i := range 6 {
+ fmt.Fprintf(&stream, ": comment-%04d\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"content_index\":0,\"delta\":%q}\n\n", i, strings.Repeat("x", 64))
+ }
+ stream.WriteString("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"status\":\"completed\",\"output\":[]}}\n\n")
+ return stream.String()
+}
+
+func cloneLogHooks(src log.LevelHooks) log.LevelHooks {
+ cloned := make(log.LevelHooks, len(src))
+ for lvl, hooks := range src {
+ cloned[lvl] = append([]log.Hook(nil), hooks...)
+ }
+ return cloned
+}
+
+func captureCodexSSEWarnings(t *testing.T) *logtest.Hook {
+ t.Helper()
+ origHooks := cloneLogHooks(log.StandardLogger().Hooks)
+ hook := new(logtest.Hook)
+ log.StandardLogger().AddHook(hook)
+ t.Cleanup(func() { log.StandardLogger().ReplaceHooks(origHooks) })
+ return hook
+}
+
+func scanCodexSSE(t *testing.T, stream string) []string {
+ t.Helper()
+ scanner := NewCodexSSEScanner(context.Background(), strings.NewReader(stream), 1<<20)
+ var lines []string
+ for scanner.Scan() {
+ lines = append(lines, string(scanner.Bytes()))
+ }
+ if errScan := scanner.Err(); errScan != nil {
+ t.Fatalf("scan error: %v", errScan)
+ }
+ return lines
+}
+
+func TestCodexSSEScannerAssemblesEventData(t *testing.T) {
+ const marker = "body-bytes-must-not-be-logged"
+ cases := []struct {
+ name string
+ stream string
+ want []string
+ warnings int
+ }{
+ {
+ name: "one-line events pass through as read",
+ stream: "event: response.created\ndata: {\"type\":\"response.created\"}\n\n: keep-alive\n\ndata: {\"type\":\"x\",\"v\":\"a b\"}\n\n",
+ want: []string{"event: response.created", `data: {"type":"response.created"}`, "", ": keep-alive", "", `data: {"type":"x","v":"a b"}`, ""},
+ },
+ {
+ name: "CRLF line endings",
+ stream: "event: a\r\ndata: {\"type\":\"a\"}\r\n\r\n",
+ want: []string{"event: a", `data: {"type":"a"}`, ""},
+ },
+ {
+ name: "data lines of one event are joined and compacted",
+ stream: "event: response.completed\ndata: {\"type\":\"response.completed\",\ndata: \"response\":{\"id\":\"r\",\"output\":[]}}\n\n",
+ want: []string{"event: response.completed", `data: {"type":"response.completed","response":{"id":"r","output":[]}}`, ""},
+ },
+ {
+ name: "joined CRLF event",
+ stream: "data: {\"a\":\r\ndata: [1,\r\ndata: 2]}\r\n\r\n",
+ want: []string{`data: {"a":[1,2]}`, ""},
+ },
+ {
+ name: "comment and field lines inside an event come before its data",
+ stream: "event: e\ndata: {\"a\":\n: keep-alive\nid: 7\nretry: 3000\ndata: 1}\n\n",
+ want: []string{"event: e", ": keep-alive", "id: 7", "retry: 3000", `data: {"a":1}`, ""},
+ },
+ {
+ name: "unterminated one-line event at end of stream",
+ stream: "data: {\"type\":\"response.completed\"}",
+ want: []string{`data: {"type":"response.completed"}`},
+ },
+ {
+ name: "unterminated multi-line event at end of stream",
+ stream: "data: {\"type\":\ndata: \"response.completed\"}\n",
+ want: []string{`data: {"type":"response.completed"}`},
+ },
+ {
+ name: "complete events without blank separators stay separate",
+ stream: "data: {\"a\":1}\ndata: {\"b\":2}\n",
+ want: []string{`data: {"a":1}`, `data: {"b":2}`},
+ },
+ {
+ name: "split event followed by an event without a blank separator",
+ stream: "data: {\"a\":\ndata: 1}\ndata: {\"b\":2}\n",
+ want: []string{`data: {"a":1}`, `data: {"b":2}`},
+ },
+ {
+ name: "an invalid fragment absorbs the following data lines up to the blank line",
+ stream: "data: {\"a\":\"" + marker + "\ndata: {\"b\":2}\n\n",
+ want: []string{"data: {\"a\":\"" + marker + "\ndata: {\"b\":2}", ""},
+ warnings: 1,
+ },
+ {
+ name: "only the first invalid event in a stream is described",
+ stream: "data: {\"a\":\n\ndata: {\"b\":\n\ndata: {\"c\":3}\n\n",
+ want: []string{"data: {\"a\":", "", "data: {\"b\":", "", `data: {"c":3}`, ""},
+ warnings: 1,
+ },
+ {
+ name: "a complete object on its own line inside an open array stays in the event",
+ stream: "data: {\"type\":\"response.completed\",\"response\":{\"output\":[\ndata: {\"id\":\"m\",\"type\":\"message\"}\ndata: ]}}\n\n",
+ want: []string{`data: {"type":"response.completed","response":{"output":[{"id":"m","type":"message"}]}}`, ""},
+ },
+ {
+ name: "unterminated incomplete event at end of stream is still delivered",
+ stream: "data: {\"type\":\"response.completed\",\"t\":\"" + marker + "\",",
+ want: []string{`data: {"type":"response.completed","t":"` + marker + `",`},
+ warnings: 1,
+ },
+ {
+ name: "empty and non-JSON data lines take the stock path",
+ stream: "data:\n\ndata: \n\ndata: ping\n\n",
+ want: []string{"data:", "", "data: ", "", "data: ping", ""},
+ },
+ {
+ name: "done sentinel",
+ stream: "data: [DONE]\n\n",
+ want: []string{"data: [DONE]", ""},
+ },
+ {
+ name: "invalid one-line event passes through with one warning",
+ stream: "data: {\"type\":\"response.completed\",\"t\":\"" + marker + "\",\n\n",
+ want: []string{`data: {"type":"response.completed","t":"` + marker + `",`, ""},
+ warnings: 1,
+ },
+ {
+ name: "event split inside a string stays invalid and keeps its data lines",
+ stream: "data: {\"t\":\"" + marker + "\ndata: " + marker + "\"}\n\n",
+ want: []string{`data: {"t":"` + marker + "\ndata: " + marker + `"}`, ""},
+ warnings: 1,
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ hook := captureCodexSSEWarnings(t)
+ got := scanCodexSSE(t, tc.stream)
+ if fmt.Sprintf("%q", got) != fmt.Sprintf("%q", tc.want) {
+ t.Fatalf("lines = %q\nwant %q", got, tc.want)
+ }
+ if len(hook.AllEntries()) != tc.warnings {
+ t.Fatalf("warnings = %d, want %d", len(hook.AllEntries()), tc.warnings)
+ }
+ for _, entry := range hook.AllEntries() {
+ rendered, errRender := entry.String()
+ if errRender != nil {
+ t.Fatal(errRender)
+ }
+ _, hasLines := entry.Data["data_lines"]
+ _, hasOffset := entry.Data["json_error_offset"]
+ if entry.Level != log.WarnLevel || strings.Contains(rendered, marker) || !hasLines || !hasOffset {
+ t.Fatalf("diagnostic = %q, want a bounded warning without data bytes", rendered)
+ }
+ }
+ })
+ }
+}
+
+func TestCodexSSEScannerBoundsAnEventToTheLineLimit(t *testing.T) {
+ hook := captureCodexSSEWarnings(t)
+ // Each fragment fits the 64-byte limit; together they do not.
+ stream := "data: {\"a\":\"" + strings.Repeat("x", 40) + "\ndata: " + strings.Repeat("y", 40) + "\ndata: " + strings.Repeat("z", 40) + "\"}\n\ndata: {\"b\":2}\n\n"
+ scanner := NewCodexSSEScanner(context.Background(), strings.NewReader(stream), 64)
+ var got []string
+ for scanner.Scan() {
+ got = append(got, string(scanner.Bytes()))
+ }
+ if errScan := scanner.Err(); errScan != nil {
+ t.Fatalf("scan error: %v", errScan)
+ }
+ // The collected fragment is flushed as an invalid event; the lines after it are read as stock reads them.
+ want := []string{
+ "data: {\"a\":\"" + strings.Repeat("x", 40),
+ "data: " + strings.Repeat("y", 40),
+ "data: " + strings.Repeat("z", 40) + "\"}",
+ "",
+ `data: {"b":2}`,
+ "",
+ }
+ if fmt.Sprintf("%q", got) != fmt.Sprintf("%q", want) {
+ t.Fatalf("lines = %q\nwant %q", got, want)
+ }
+ if len(hook.AllEntries()) != 1 {
+ t.Fatalf("warnings = %d, want 1 for the flushed invalid event", len(hook.AllEntries()))
+ }
+}
+
+func TestCodexSSEScannerAssemblesAnEventOfThousandsOfLinesQuickly(t *testing.T) {
+ // A 2 MB response.completed whose upstream wrapped it into 2,000 data lines between array
+ // elements. Every line ends with "}", so only nesting, not the last byte, tells the event apart
+ // from a finished one.
+ item := func(i int) string {
+ return fmt.Sprintf(`{"type":"message","content":[{"type":"output_text","text":"%s"}]}`, strings.Repeat(string(rune('a'+i%26)), 960))
+ }
+ head := `{"type":"response.completed","response":{"id":"resp_big","output":[`
+ var payload, stream strings.Builder
+ payload.WriteString(head + item(0))
+ stream.WriteString("data: " + head + item(0) + "\n")
+ for i := 1; i < 2000; i++ {
+ payload.WriteString("," + item(i))
+ stream.WriteString("data: ," + item(i) + "\n")
+ }
+ payload.WriteString("]}}")
+ stream.WriteString("data: ]}}\n\n")
+ started := time.Now()
+ scanner := NewCodexSSEScanner(context.Background(), strings.NewReader(stream.String()), 52_428_800)
+ var got []string
+ for scanner.Scan() {
+ got = append(got, string(scanner.Bytes()))
+ }
+ elapsed := time.Since(started)
+ if len(got) != 2 || got[0] != "data: "+payload.String() {
+ t.Fatalf("assembled %d lines, want the event on one line and its blank line", len(got))
+ }
+ if elapsed > 2*time.Second {
+ t.Fatalf("assembling 2,001 data lines took %v; validation must not grow with every line", elapsed)
+ }
+}
+
+func TestCodexSSEScannerHoldsAnEventInMemoryThatFollowsItsBytes(t *testing.T) {
+ // One valid event of 100,000 empty data lines: about 600 KB, well within the line limit.
+ stream := "data: [\n" + strings.Repeat("data:\n", 100_000) + "data: ]\n\n"
+ var got []string
+ allocs := testing.AllocsPerRun(1, func() {
+ got = got[:0]
+ scanner := NewCodexSSEScanner(context.Background(), strings.NewReader(stream), 1<<20)
+ for scanner.Scan() {
+ got = append(got, string(scanner.Bytes()))
+ }
+ })
+ if fmt.Sprintf("%q", got) != fmt.Sprintf("%q", []string{"data: []", ""}) {
+ t.Fatalf("lines = %q", got)
+ }
+ // Holding each line separately costs at least one allocation per line.
+ if allocs > 1000 {
+ t.Fatalf("assembling 100,002 data lines took %.0f allocations; holding an event must not allocate per line", allocs)
+ }
+}
+
+func TestCodexSSEScannerReadsLineByLineWhileAssemblyIsOff(t *testing.T) {
+ stream := "data: {\"a\":\ndata: 1}\n\ndata: {\"b\":\ndata: 2}\n\n"
+ scanner := NewCodexSSEScanner(context.Background(), strings.NewReader(stream), 1<<20)
+ scanner.SetAssembly(false)
+ var got []string
+ for scanner.Scan() {
+ got = append(got, string(scanner.Bytes()))
+ if len(got) == 3 {
+ // The first event has been read as bufio.Scanner reads it; assemble from here on.
+ scanner.SetAssembly(true)
+ }
+ }
+ want := []string{"data: {\"a\":", "data: 1}", "", `data: {"b":2}`, ""}
+ if fmt.Sprintf("%q", got) != fmt.Sprintf("%q", want) {
+ t.Fatalf("lines = %q\nwant %q", got, want)
+ }
+}
+
+// codexSSEChunkReader returns one chunk per Read and counts the reads.
+type codexSSEChunkReader struct {
+ chunks []string
+ reads int
+}
+
+func (r *codexSSEChunkReader) Read(p []byte) (int, error) {
+ if r.reads == len(r.chunks) {
+ return 0, io.EOF
+ }
+ r.reads++
+ return copy(p, r.chunks[r.reads-1]), nil
+}
+
+func TestCodexSSEScannerDeliversNonDataLinesAsTheyAreRead(t *testing.T) {
+ reader := &codexSSEChunkReader{chunks: []string{"data: {\"a\":\n: keep-alive\nid: 7\n", "data: 1}\n\n"}}
+ scanner := NewCodexSSEScanner(context.Background(), reader, 1<<20)
+ var got []string
+ for scanner.Scan() {
+ got = append(got, fmt.Sprintf("%q@%d", scanner.Bytes(), reader.reads))
+ }
+ // Only the data lines wait for the rest of their event; the other lines are not held for it.
+ want := []string{`": keep-alive"@1`, `"id: 7"@1`, `"data: {\"a\":1}"@2`, `""@2`}
+ if fmt.Sprintf("%q", got) != fmt.Sprintf("%q", want) {
+ t.Fatalf("lines = %q\nwant %q", got, want)
+ }
+}
+
+func TestCodexSSEScannerDiagnosticDescribesFraming(t *testing.T) {
+ hook := captureCodexSSEWarnings(t)
+ // A split inside a string: each fragment is invalid on its own, and so is their SSE join.
+ scanCodexSSE(t, "data: {\"type\":\"response.completed\",\"t\":\"abc\ndata: def\"}\n\n")
+ entries := hook.AllEntries()
+ if len(entries) != 1 {
+ t.Fatalf("warnings = %d, want 1", len(entries))
+ }
+ want := log.Fields{"data_lines": 2, "line_bytes": "37,5", "lines_valid": "false,false", "data_bytes": 43, "json_error_offset": int64(38)}
+ for key, value := range want {
+ if entries[0].Data[key] != value {
+ t.Fatalf("diagnostic field %s = %v, want %v (fields %v)", key, entries[0].Data[key], value, entries[0].Data)
+ }
+ }
+}
+
+func TestCodexSSEScannerMatchesBufioScannerOnWellFormedStreams(t *testing.T) {
+ stream := wellFormedCodexSSEStream() + "data: {\"type\":\"x\"}\ndata: {\"type\":\"y\"}\n: trailing comment\n\nevent: done\ndata: [DONE]\n\n"
+ for _, newline := range []string{"\n", "\r\n"} {
+ input := strings.ReplaceAll(stream, "\n", newline)
+ t.Run(fmt.Sprintf("newline=%q", newline), func(t *testing.T) {
+ stock := bufio.NewScanner(strings.NewReader(input))
+ var want []string
+ for stock.Scan() {
+ want = append(want, stock.Text())
+ }
+ got := scanCodexSSE(t, input)
+ if fmt.Sprintf("%q", got) != fmt.Sprintf("%q", want) {
+ t.Fatalf("lines = %q\nwant bufio.Scanner's %q", got, want)
+ }
+ })
+ }
+}
diff --git a/internal/runtime/executor/helps/codex_tool_schema.go b/internal/runtime/executor/helps/codex_tool_schema.go
index 7742302f05a..d97e5e51609 100644
--- a/internal/runtime/executor/helps/codex_tool_schema.go
+++ b/internal/runtime/executor/helps/codex_tool_schema.go
@@ -3,9 +3,11 @@ package helps
import (
"bytes"
"encoding/json"
+ "errors"
"io"
"math/big"
"net/http"
+ "strconv"
"strings"
log "github.com/sirupsen/logrus"
@@ -22,6 +24,15 @@ const (
codexComplexUnionBranchThreshold = 8
)
+// codexNumericBoundKeywords lists the JSON Schema numeric bound keywords that
+// the Codex backend rejects with HTTP 500 when set to an integer outside int64.
+var codexNumericBoundKeywords = [...]string{
+ "minimum",
+ "maximum",
+ "exclusiveMinimum",
+ "exclusiveMaximum",
+}
+
// IsCodexUserAgent reports whether headers contain a User-Agent indicating a Codex client.
func IsCodexUserAgent(headers http.Header) bool {
return toolschema.IsCodexUserAgent(headers)
@@ -151,16 +162,18 @@ func normalizeCodexParameters(params gjson.Result) ([]byte, bool) {
// stripIncompatiblePatternsFromJSON recursively removes pattern attributes that strict
// upstream validators reject: unsupported Unicode property escapes (\p{...} / \P{...})
-// and the octal NUL escape (\0). See util.HasUnsupportedUnicodePropertyEscape for the
-// predicate and the upstream errors behind each one.
+// and the octal NUL escape (\0), as well as integer numeric bounds (minimum, maximum,
+// exclusiveMinimum, exclusiveMaximum) outside the signed 64-bit integer range (#6579).
+// See util.HasUnsupportedUnicodePropertyEscape for the regex predicate and upstream errors.
// It is schema-aware: only subschemas under known JSON Schema keyword locations are visited,
-// preventing accidental deletion of 'pattern' keys inside user data (e.g. description, default, enum).
+// preventing accidental deletion of keys inside user data (e.g. description, default, enum).
func stripIncompatiblePatternsFromJSON(raw []byte) ([]byte, bool) {
rawStr := string(raw)
- // The fast path avoids parsing when no candidate escape is present. Patterns
- // arrive JSON-escaped, so a literal backslash before '0' is `\\0` in the raw bytes.
+ // The fast path avoids parsing when no candidate escape or numeric bound is present.
+ // Patterns arrive JSON-escaped, so a literal backslash before '0' is `\\0` in the raw bytes.
+ // "inimum" and "aximum" match minimum, maximum, exclusiveMinimum, and exclusiveMaximum.
if !strings.Contains(rawStr, `\p{`) && !strings.Contains(rawStr, `\P{`) && !strings.Contains(rawStr, `\u`) &&
- !strings.Contains(rawStr, `\\0`) {
+ !strings.Contains(rawStr, `\\0`) && !strings.Contains(rawStr, "inimum") && !strings.Contains(rawStr, "aximum") {
return raw, false
}
var root any
@@ -186,6 +199,92 @@ func stripIncompatiblePatternsFromJSON(raw []byte) ([]byte, bool) {
return bytes.TrimSpace(buf.Bytes()), true
}
+func isNumericBoundOutOfInt64(number json.Number) bool {
+ raw := strings.TrimSpace(number.String())
+ if raw == "" {
+ return false
+ }
+ if !strings.ContainsAny(raw, ".eE") {
+ _, errParse := strconv.ParseInt(raw, 10, 64)
+ return errors.Is(errParse, strconv.ErrRange)
+ }
+
+ s := raw
+ negative := false
+ if strings.HasPrefix(s, "-") {
+ negative = true
+ s = s[1:]
+ } else if strings.HasPrefix(s, "+") {
+ s = s[1:]
+ }
+
+ mantissa := s
+ var exp int64
+ if idx := strings.IndexAny(s, "eE"); idx >= 0 {
+ mantissa = s[:idx]
+ expStr := s[idx+1:]
+ parsedExp, errExp := strconv.ParseInt(expStr, 10, 64)
+ if errExp != nil {
+ if errors.Is(errExp, strconv.ErrRange) {
+ return !strings.HasPrefix(expStr, "-") && strings.Trim(mantissa, "0.") != ""
+ }
+ return false
+ }
+ exp = parsedExp
+ }
+
+ intPart := mantissa
+ fracPart := ""
+ if dotIdx := strings.IndexByte(mantissa, '.'); dotIdx >= 0 {
+ intPart = mantissa[:dotIdx]
+ fracPart = mantissa[dotIdx+1:]
+ if strings.IndexByte(fracPart, '.') >= 0 {
+ return false
+ }
+ }
+ if intPart == "" && fracPart == "" {
+ return false
+ }
+ for i := 0; i < len(intPart); i++ {
+ if intPart[i] < '0' || intPart[i] > '9' {
+ return false
+ }
+ }
+ for i := 0; i < len(fracPart); i++ {
+ if fracPart[i] < '0' || fracPart[i] > '9' {
+ return false
+ }
+ }
+
+ allDigits := intPart + fracPart
+ sig := strings.TrimLeft(allDigits, "0")
+ if sig == "" {
+ return false
+ }
+ if exp < -int64(len(raw)) {
+ return false
+ }
+ if exp > int64(len(raw))+20 {
+ return true
+ }
+ trimmed := strings.TrimRight(sig, "0")
+ trailingZeros := int64(len(sig) - len(trimmed))
+ effectiveExp := exp - int64(len(fracPart)) + trailingZeros
+ if effectiveExp < 0 {
+ return false
+ }
+ if effectiveExp > 19 || int64(len(trimmed))+effectiveExp > 19 {
+ return true
+ }
+
+ intStr := trimmed + strings.Repeat("0", int(effectiveExp))
+ if negative {
+ intStr = "-" + intStr
+ }
+ _, errParse := strconv.ParseInt(intStr, 10, 64)
+ return errors.Is(errParse, strconv.ErrRange)
+}
+
func stripIncompatiblePatterns(v any) bool {
changed := false
switch schema := v.(type) {
@@ -194,6 +293,12 @@ func stripIncompatiblePatterns(v any) bool {
delete(schema, "pattern")
changed = true
}
+ for _, boundKey := range codexNumericBoundKeywords {
+ if boundVal, ok := schema[boundKey].(json.Number); ok && isNumericBoundOutOfInt64(boundVal) {
+ delete(schema, boundKey)
+ changed = true
+ }
+ }
// Inspect regex keys under patternProperties
if patternProps, ok := schema["patternProperties"].(map[string]any); ok {
diff --git a/internal/runtime/executor/helps/codex_tool_schema_test.go b/internal/runtime/executor/helps/codex_tool_schema_test.go
index 5c4f4f6258c..cc6da6af257 100644
--- a/internal/runtime/executor/helps/codex_tool_schema_test.go
+++ b/internal/runtime/executor/helps/codex_tool_schema_test.go
@@ -1250,3 +1250,175 @@ func TestNormalizeCodexToolSchemas_DoesNotNormalizeIntegerTypes(t *testing.T) {
t.Fatalf("expected number, got: %s", got)
}
}
+
+func TestNormalizeCodexToolSchemas_StripsOutOfRangeInt64Bounds(t *testing.T) {
+ input := []byte(`{
+ "model": "gpt-6-luna",
+ "tools": [{
+ "type": "function",
+ "name": "emulate_process",
+ "description": "Emulate a process.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "options": {
+ "type": "object",
+ "properties": {
+ "seed": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 18446744073709552000
+ },
+ "over_max_int64": {
+ "type": "integer",
+ "minimum": -9223372036854775809,
+ "maximum": 9223372036854775808,
+ "exclusiveMinimum": -9223372036854775809,
+ "exclusiveMaximum": 9223372036854775808
+ },
+ "exact_int64_limits": {
+ "type": "integer",
+ "minimum": -9223372036854775808,
+ "maximum": 9223372036854775807,
+ "exclusiveMinimum": -9223372036854775808,
+ "exclusiveMaximum": 9223372036854775807
+ },
+ "float_bounds": {
+ "type": "number",
+ "minimum": -0.5,
+ "maximum": 3.14,
+ "exclusiveMinimum": 0.25,
+ "exclusiveMaximum": 99.95
+ },
+ "scientific_bounds": {
+ "type": "number",
+ "minimum": 1e5,
+ "maximum": 1e100,
+ "exclusiveMinimum": -1.8446744073709552e+19,
+ "exclusiveMaximum": 18446744073709552000.0
+ },
+ "high_precision_fraction": {
+ "type": "number",
+ "maximum": 18446744073709552000.00000000000000000000000000000000000000000000000000000000000000000000000000000001
+ },
+ "extreme_exponents": {
+ "type": "number",
+ "minimum": 1.1e-9223372036854775808,
+ "maximum": 10e9223372036854775807
+ },
+ "user_data": {
+ "type": "object",
+ "default": {
+ "maximum": 18446744073709552000
+ },
+ "enum": [
+ {"maximum": 18446744073709552000}
+ ]
+ },
+ "maximum": {
+ "type": "integer",
+ "minimum": 0
+ }
+ }
+ }
+ },
+ "$defs": {
+ "uint64_id": {
+ "type": "integer",
+ "maximum": 18446744073709551615
+ }
+ }
+ }
+ }]
+ }`)
+
+ out := NormalizeCodexToolSchemas(input)
+ params := gjson.GetBytes(out, "tools.0.parameters")
+ optsProps := params.Get("properties.options.properties")
+
+ // Issue #6579 reproduction: u64::MAX bound dropped, minimum: 0 and type preserved.
+ if optsProps.Get("seed.maximum").Exists() {
+ t.Errorf("expected options.seed.maximum to be removed, got %s", optsProps.Get("seed.maximum").Raw)
+ }
+ if got := optsProps.Get("seed.minimum").Raw; got != "0" {
+ t.Errorf("expected options.seed.minimum == 0, got %s", got)
+ }
+ if got := optsProps.Get("seed.type").String(); got != "integer" {
+ t.Errorf("expected options.seed.type == 'integer', got %q", got)
+ }
+
+ // All four bound keywords outside int64 must be dropped.
+ for _, kw := range []string{"minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum"} {
+ if optsProps.Get("over_max_int64." + kw).Exists() {
+ t.Errorf("expected over_max_int64.%s to be removed, got %s", kw, optsProps.Get("over_max_int64."+kw).Raw)
+ }
+ }
+
+ // Exact int64 min/max must be preserved without precision loss.
+ if got := optsProps.Get("exact_int64_limits.minimum").Raw; got != "-9223372036854775808" {
+ t.Errorf("expected exact_int64_limits.minimum == -9223372036854775808, got %s", got)
+ }
+ if got := optsProps.Get("exact_int64_limits.maximum").Raw; got != "9223372036854775807" {
+ t.Errorf("expected exact_int64_limits.maximum == 9223372036854775807, got %s", got)
+ }
+ if got := optsProps.Get("exact_int64_limits.exclusiveMinimum").Raw; got != "-9223372036854775808" {
+ t.Errorf("expected exact_int64_limits.exclusiveMinimum == -9223372036854775808, got %s", got)
+ }
+ if got := optsProps.Get("exact_int64_limits.exclusiveMaximum").Raw; got != "9223372036854775807" {
+ t.Errorf("expected exact_int64_limits.exclusiveMaximum == 9223372036854775807, got %s", got)
+ }
+
+ // Fractional float bounds must be preserved.
+ if got := optsProps.Get("float_bounds.minimum").Raw; got != "-0.5" {
+ t.Errorf("expected float_bounds.minimum == -0.5, got %s", got)
+ }
+ if got := optsProps.Get("float_bounds.maximum").Raw; got != "3.14" {
+ t.Errorf("expected float_bounds.maximum == 3.14, got %s", got)
+ }
+
+ // Scientific/decimal integer bounds: in-range preserved, out-of-range dropped.
+ if got := optsProps.Get("scientific_bounds.minimum").Raw; got != "1e5" {
+ t.Errorf("expected scientific_bounds.minimum == 1e5, got %s", got)
+ }
+ for _, kw := range []string{"maximum", "exclusiveMinimum", "exclusiveMaximum"} {
+ if optsProps.Get("scientific_bounds." + kw).Exists() {
+ t.Errorf("expected scientific_bounds.%s to be removed, got %s", kw, optsProps.Get("scientific_bounds."+kw).Raw)
+ }
+ }
+
+ // High-precision non-integer bound must not be rounded to an integer and dropped.
+ wantHighPrec := "18446744073709552000.00000000000000000000000000000000000000000000000000000000000000000000000000000001"
+ if got := optsProps.Get("high_precision_fraction.maximum").Raw; got != wantHighPrec {
+ t.Errorf("expected high_precision_fraction.maximum preserved as %s, got %s", wantHighPrec, got)
+ }
+
+ // Extreme int64 exponents must not overflow effectiveExp calculation.
+ if got := optsProps.Get("extreme_exponents.minimum").Raw; got != "1.1e-9223372036854775808" {
+ t.Errorf("expected extreme_exponents.minimum preserved as 1.1e-9223372036854775808, got %s", got)
+ }
+ if optsProps.Get("extreme_exponents.maximum").Exists() {
+ t.Errorf("expected extreme_exponents.maximum (10e9223372036854775807) to be removed, got %s", optsProps.Get("extreme_exponents.maximum").Raw)
+ }
+
+ // Non-schema user data under default/enum and property named "maximum" must be preserved.
+ if got := optsProps.Get("user_data.default.maximum").Raw; got != "18446744073709552000" {
+ t.Errorf("expected user_data.default.maximum preserved, got %s", got)
+ }
+ if got := optsProps.Get("user_data.enum.0.maximum").Raw; got != "18446744073709552000" {
+ t.Errorf("expected user_data.enum.0.maximum preserved, got %s", got)
+ }
+ if got := optsProps.Get("maximum.type").String(); got != "integer" {
+ t.Errorf("expected property named 'maximum' preserved, got %q", got)
+ }
+
+ // Subschemas under $defs must also have out-of-range integer bounds removed.
+ if params.Get("$defs.uint64_id.maximum").Exists() {
+ t.Errorf("expected $defs.uint64_id.maximum to be removed, got %s", params.Get("$defs.uint64_id.maximum").Raw)
+ }
+
+ // Idempotence check.
+ outAgain := NormalizeCodexToolSchemas(out)
+ if string(outAgain) != string(out) {
+ t.Errorf("expected NormalizeCodexToolSchemas to be idempotent")
+ }
+}
diff --git a/internal/runtime/executor/helps/devin_quota.go b/internal/runtime/executor/helps/devin_quota.go
new file mode 100644
index 00000000000..d6153e86b6a
--- /dev/null
+++ b/internal/runtime/executor/helps/devin_quota.go
@@ -0,0 +1,63 @@
+package helps
+
+import (
+ "strings"
+ "time"
+)
+
+// DevinQuotaPeriod identifies an account-wide Devin usage quota window.
+type DevinQuotaPeriod int
+
+const (
+ // DevinQuotaPeriodNone means the error is not an account-wide
+ // daily/weekly usage quota exhaustion.
+ DevinQuotaPeriodNone DevinQuotaPeriod = iota
+ // DevinQuotaPeriodDaily marks daily usage quota exhaustion.
+ DevinQuotaPeriodDaily
+ // DevinQuotaPeriodWeekly marks weekly usage quota exhaustion.
+ DevinQuotaPeriodWeekly
+)
+
+// devinQuotaResetMaxDelay bounds how far in the future an observed quota
+// reset timestamp may lie before it is treated as unusable. A weekly window
+// plus slack covers a just-rolled-over observation; anything further out is
+// implausible for a daily/weekly quota and must not pin the credential.
+const devinQuotaResetMaxDelay = 8 * 24 * time.Hour
+
+// DevinUsageQuotaExhaustionPeriod reports whether the upstream error
+// message explicitly says the account-wide daily or weekly usage quota has
+// been exhausted (issue #6690). Matching is deliberately narrow: the period
+// word, "quota", and "exhausted" must all be present, so monthly ACU
+// messages, generic rate limits, and transient capacity errors never match
+// and stay on the short per-model backoff path. If the message somehow
+// names both periods, weekly wins because it is the longer window.
+func DevinUsageQuotaExhaustionPeriod(message string) DevinQuotaPeriod {
+ lower := strings.ToLower(message)
+ if !strings.Contains(lower, "quota") || !strings.Contains(lower, "exhausted") {
+ return DevinQuotaPeriodNone
+ }
+ if strings.Contains(lower, "weekly") {
+ return DevinQuotaPeriodWeekly
+ }
+ if strings.Contains(lower, "daily") {
+ return DevinQuotaPeriodDaily
+ }
+ return DevinQuotaPeriodNone
+}
+
+// DevinQuotaRetryAfter converts an observed quota reset timestamp (stored
+// in auth Quota.Signals by the executor's Refresh path) into a retry delay.
+// It returns nil unless the signal parses and is strictly in the future
+// within a plausible daily/weekly horizon, in which case the caller should
+// leave the cooldown duration to the conductor's credential backoff.
+func DevinQuotaRetryAfter(resetAtSignal string, now time.Time) *time.Duration {
+ resetAt, errParse := time.Parse(time.RFC3339, strings.TrimSpace(resetAtSignal))
+ if errParse != nil {
+ return nil
+ }
+ delay := resetAt.Sub(now)
+ if delay <= 0 || delay > devinQuotaResetMaxDelay {
+ return nil
+ }
+ return &delay
+}
diff --git a/internal/runtime/executor/helps/devin_quota_test.go b/internal/runtime/executor/helps/devin_quota_test.go
new file mode 100644
index 00000000000..7dd0fe5bcf8
--- /dev/null
+++ b/internal/runtime/executor/helps/devin_quota_test.go
@@ -0,0 +1,65 @@
+package helps
+
+import (
+ "testing"
+ "time"
+)
+
+func TestDevinUsageQuotaExhaustionPeriod(t *testing.T) {
+ cases := []struct {
+ name string
+ message string
+ want DevinQuotaPeriod
+ }{
+ {name: "weekly exact", message: "Your weekly usage quota has been exhausted.", want: DevinQuotaPeriodWeekly},
+ {name: "daily exact", message: "Your daily usage quota has been exhausted.", want: DevinQuotaPeriodDaily},
+ {name: "case-insensitive", message: "YOUR WEEKLY USAGE QUOTA HAS BEEN EXHAUSTED.", want: DevinQuotaPeriodWeekly},
+ {name: "wrapped trailer text", message: "devin upstream error (failed_precondition): Your weekly usage quota has been exhausted.", want: DevinQuotaPeriodWeekly},
+ {name: "weekly wins when both named", message: "Your daily and weekly usage quota has been exhausted.", want: DevinQuotaPeriodWeekly},
+ {name: "monthly ACU out of scope", message: "User monthly ACU quota exhausted (trace ID: 12345)", want: DevinQuotaPeriodNone},
+ {name: "rate limit without quota", message: "Rate limit exceeded. Please slow down.", want: DevinQuotaPeriodNone},
+ {name: "weekly without exhausted", message: "Your weekly usage quota is nearly used up.", want: DevinQuotaPeriodNone},
+ {name: "quota without period", message: "Usage quota exhausted.", want: DevinQuotaPeriodNone},
+ {name: "weekly without quota", message: "Your weekly allowance has been exhausted.", want: DevinQuotaPeriodNone},
+ {name: "empty", message: "", want: DevinQuotaPeriodNone},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := DevinUsageQuotaExhaustionPeriod(tc.message); got != tc.want {
+ t.Fatalf("DevinUsageQuotaExhaustionPeriod(%q) = %v, want %v", tc.message, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestDevinQuotaRetryAfter(t *testing.T) {
+ now := time.Date(2026, 10, 16, 15, 37, 9, 0, time.UTC)
+
+ cases := []struct {
+ name string
+ signal string
+ want *time.Duration
+ }{
+ {name: "valid future weekly reset", signal: now.Add(72 * time.Hour).Format(time.RFC3339), want: durationPtr(72 * time.Hour)},
+ {name: "valid future daily reset", signal: now.Add(3 * time.Hour).Format(time.RFC3339), want: durationPtr(3 * time.Hour)},
+ {name: "past reset", signal: now.Add(-time.Minute).Format(time.RFC3339), want: nil},
+ {name: "reset exactly now", signal: now.Format(time.RFC3339), want: nil},
+ {name: "far future reset rejected", signal: now.Add(30 * 24 * time.Hour).Format(time.RFC3339), want: nil},
+ {name: "unparseable", signal: "not-a-time", want: nil},
+ {name: "empty", signal: "", want: nil},
+ {name: "whitespace trimmed", signal: " " + now.Add(time.Hour).Format(time.RFC3339) + " ", want: durationPtr(time.Hour)},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := DevinQuotaRetryAfter(tc.signal, now)
+ if tc.want == nil && got != nil {
+ t.Fatalf("DevinQuotaRetryAfter(%q) = %v, want nil", tc.signal, *got)
+ }
+ if tc.want != nil && (got == nil || *got != *tc.want) {
+ t.Fatalf("DevinQuotaRetryAfter(%q) = %v, want %v", tc.signal, got, *tc.want)
+ }
+ })
+ }
+}
+
+func durationPtr(d time.Duration) *time.Duration { return &d }
diff --git a/internal/runtime/executor/helps/gemini_content_turns.go b/internal/runtime/executor/helps/gemini_content_turns.go
index b9063331da9..1aa2bedc332 100644
--- a/internal/runtime/executor/helps/gemini_content_turns.go
+++ b/internal/runtime/executor/helps/gemini_content_turns.go
@@ -109,8 +109,8 @@ func EnsureGeminiTrailingUserContent(payload []byte, path string) []byte {
// DropGeminiTrailingThoughtOnlyContent removes trailing model turns made only of
// thought parts, keeping at least one turn. Such a tail comes from a replayed
// trailing reasoning item; dropping it avoids relying only on the synthetic
-// empty user turn, which did not prevent Antigravity from rejecting the request
-// as ending with a model turn in #6530.
+// empty user turn, which did not prevent Antigravity (#6530) or Vertex (#6630)
+// from rejecting the request as ending with a model turn.
func DropGeminiTrailingThoughtOnlyContent(payload []byte, path string) []byte {
contents := util.GetGJSONBytesNoCopy(payload, path)
if !contents.IsArray() {
diff --git a/internal/runtime/executor/helps/gemini_interactions.go b/internal/runtime/executor/helps/gemini_interactions.go
index 86e7d48fbe4..9218ebee605 100644
--- a/internal/runtime/executor/helps/gemini_interactions.go
+++ b/internal/runtime/executor/helps/gemini_interactions.go
@@ -8,6 +8,7 @@ import (
"strings"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
"github.com/tidwall/gjson"
@@ -31,31 +32,86 @@ func SanitizeGeminiInteractionsUnsupportedInputIDs(body []byte) []byte {
if !input.IsArray() {
return body
}
- for i, item := range input.Array() {
+ items := input.Array()
+ kept := make([]string, 0, len(items))
+ changed := false
+ for _, item := range items {
+ rawItem := item.Raw
stepType := item.Get("type").String()
+ if stepType == "thought" {
+ sig := strings.TrimSpace(item.Get("signature").String())
+ if sig == "" {
+ sig = strings.TrimSpace(item.Get("thought_signature").String())
+ }
+ if sig == "" {
+ sig = strings.TrimSpace(item.Get("thoughtSignature").String())
+ }
+ normalizedSig, ok := signature.CompatibleSignatureForProviderBlock(
+ signature.SignatureProviderGemini,
+ sig,
+ signature.SignatureBlockKindGeminiModelPart,
+ )
+ if !ok || normalizedSig == "" || signature.IsGeminiThoughtSignatureBypass(signature.SignaturePayloadWithoutProviderPrefix(normalizedSig)) {
+ // Official Gemini Interactions API rejects thought steps without a
+ // valid Gemini signature (or with foreign/corrupt base64 signatures
+ // or "content" arrays).
+ changed = true
+ continue
+ }
+ if normalizedSig != item.Get("signature").String() {
+ rawItem, _ = sjson.Set(rawItem, "signature", normalizedSig)
+ changed = true
+ }
+ for _, unsupportedField := range []string{"id", "content", "text", "thought_signature", "thoughtSignature"} {
+ if gjson.Get(rawItem, unsupportedField).Exists() {
+ rawItem, _ = sjson.Delete(rawItem, unsupportedField)
+ changed = true
+ }
+ }
+ kept = append(kept, rawItem)
+ continue
+ }
if stepType == "function_call" {
if !item.Get("id").Exists() && item.Get("call_id").Exists() {
- body, _ = sjson.SetBytes(body, fmt.Sprintf("input.%d.id", i), item.Get("call_id").String())
+ rawItem, _ = sjson.Set(rawItem, "id", item.Get("call_id").String())
+ changed = true
}
if item.Get("call_id").Exists() {
- body, _ = sjson.DeleteBytes(body, fmt.Sprintf("input.%d.call_id", i))
- }
- } else {
- if item.Get("id").Exists() {
- body, _ = sjson.DeleteBytes(body, fmt.Sprintf("input.%d.id", i))
+ rawItem, _ = sjson.Delete(rawItem, "call_id")
+ changed = true
}
+ } else if item.Get("id").Exists() {
+ rawItem, _ = sjson.Delete(rawItem, "id")
+ changed = true
}
- content := item.Get("content")
- if !content.IsArray() {
- continue
- }
- for j, part := range content.Array() {
- if part.Get("id").Exists() {
- body, _ = sjson.DeleteBytes(body, fmt.Sprintf("input.%d.content.%d.id", i, j))
+ content := gjson.Get(rawItem, "content")
+ if content.IsArray() {
+ for j, part := range content.Array() {
+ if part.Get("id").Exists() {
+ rawItem, _ = sjson.Delete(rawItem, fmt.Sprintf("content.%d.id", j))
+ changed = true
+ }
}
}
+ kept = append(kept, rawItem)
+ }
+ if !changed {
+ return body
+ }
+ var b strings.Builder
+ b.WriteByte('[')
+ for i, raw := range kept {
+ if i > 0 {
+ b.WriteByte(',')
+ }
+ b.WriteString(raw)
+ }
+ b.WriteByte(']')
+ updated, err := sjson.SetRawBytes(body, "input", []byte(b.String()))
+ if err != nil {
+ return body
}
- return body
+ return updated
}
// TranslateGeminiInteractionsRequestBody translates a request payload to FormatInteractions.
diff --git a/internal/runtime/executor/helps/proxy_helpers.go b/internal/runtime/executor/helps/proxy_helpers.go
index 0c5f6c9592a..d98f7d4907b 100644
--- a/internal/runtime/executor/helps/proxy_helpers.go
+++ b/internal/runtime/executor/helps/proxy_helpers.go
@@ -10,6 +10,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/httptransport"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/proxyutil"
log "github.com/sirupsen/logrus"
)
@@ -28,7 +29,8 @@ import (
//
// Returns:
// - *http.Client: An HTTP client with configured proxy or transport
-func NewProxyAwareHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) *http.Client {
+func NewProxyAwareHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) (clientOut *http.Client) {
+ defer func() { clientOut = httptransport.Decorate(ctx, clientOut) }()
httpClient := &http.Client{}
if timeout > 0 {
httpClient.Timeout = timeout
@@ -60,7 +62,8 @@ var devinTransportCache = NewTransportCache[string](DefaultTransportCacheCapacit
// NewDevinHTTPClient creates an HTTP client customized for Devin Connect-RPC upstream.
// Suppresses automatic Accept-Encoding: gzip while preserving connection reuse across requests.
-func NewDevinHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) *http.Client {
+func NewDevinHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) (clientOut *http.Client) {
+ defer func() { clientOut = httptransport.Decorate(ctx, clientOut) }()
// A request proxy replaces both the injected round tripper and credential/global proxy.
// Respect explicitly injected context RoundTripper only when no request override is set.
if cliproxyexecutor.RequestProxyURL(ctx) == "" && ctx != nil {
diff --git a/internal/runtime/executor/helps/response_model.go b/internal/runtime/executor/helps/response_model.go
index b9c0403a0a5..ec2753d0ceb 100644
--- a/internal/runtime/executor/helps/response_model.go
+++ b/internal/runtime/executor/helps/response_model.go
@@ -103,6 +103,11 @@ func extractGeminiResponseModelEvent(data []byte) (model string, terminal bool)
cand = gjson.GetBytes(data, "response.candidates.0.finishReason")
}
terminal = cand.Exists() && cand.String() != ""
+ if !terminal {
+ objectType := gjson.GetBytes(data, "object").String()
+ finishReason := gjson.GetBytes(data, "choices.0.finish_reason").String()
+ terminal = objectType == "chat.completion" || finishReason != ""
+ }
if !terminal {
eventType := gjson.GetBytes(data, "event_type").String()
if eventType == "" {
diff --git a/internal/runtime/executor/helps/transport_decorator_test.go b/internal/runtime/executor/helps/transport_decorator_test.go
new file mode 100644
index 00000000000..c767f787604
--- /dev/null
+++ b/internal/runtime/executor/helps/transport_decorator_test.go
@@ -0,0 +1,213 @@
+package helps
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "sync"
+ "sync/atomic"
+ "testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ coreexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ sdkconfig "github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/httptransport"
+)
+
+type observingTransport struct {
+ http.RoundTripper
+}
+
+func TestClientDecoratorsPreserveProtectedAndProxyTransports(t *testing.T) {
+ for _, factory := range []string{"plain", "devin", "utls"} {
+ for _, proxy := range []string{"", "http://127.0.0.1:12345"} {
+ t.Run(factory+"_"+proxy, func(t *testing.T) {
+ calls := 0
+ var selected http.RoundTripper
+ ctx := httptransport.WithDecorator(context.Background(), func(next http.RoundTripper) http.RoundTripper {
+ calls++
+ selected = next
+ return &observingTransport{RoundTripper: next}
+ })
+ auth := &coreauth.Auth{ProxyURL: proxy}
+ cfg := &config.Config{}
+
+ var c *http.Client
+ var plain *http.Client
+ switch factory {
+ case "utls":
+ c = NewUtlsHTTPClient(ctx, cfg, auth, 0)
+ plain = NewUtlsHTTPClient(context.Background(), cfg, auth, 0)
+ rt, ok := selected.(*fallbackRoundTripper)
+ if !ok || rt.anthropic == nil || rt.chrome == nil || rt.fallback == nil {
+ t.Fatalf("lost TLS profiles: %#v", selected)
+ }
+ plainRT, ok := plain.Transport.(*fallbackRoundTripper)
+ if !ok || plainRT.anthropic != rt.anthropic {
+ t.Fatal("Anthropic cached transport identity changed under decoration")
+ }
+ case "devin":
+ c = NewDevinHTTPClient(ctx, cfg, auth, 0)
+ plain = NewDevinHTTPClient(context.Background(), cfg, auth, 0)
+ if plain.Transport != selected {
+ t.Fatal("Devin pooled transport identity changed under decoration")
+ }
+ default:
+ c = NewProxyAwareHTTPClient(ctx, cfg, auth, 0)
+ if proxy == "" {
+ if selected != http.DefaultTransport {
+ t.Fatalf("expected DefaultTransport for direct client, got %T", selected)
+ }
+ } else {
+ tr, ok := selected.(*http.Transport)
+ if !ok || tr.Proxy == nil {
+ t.Fatalf("expected proxied *http.Transport, got %#v", selected)
+ }
+ probeReq, errProbe := http.NewRequest(http.MethodGet, "https://upstream.example/v1", nil)
+ if errProbe != nil {
+ t.Fatalf("probe request: %v", errProbe)
+ }
+ resolvedProxy, errProxy := tr.Proxy(probeReq)
+ if errProxy != nil || resolvedProxy == nil || resolvedProxy.String() != proxy {
+ t.Fatalf("resolved proxy = %v (err=%v), want %s", resolvedProxy, errProxy, proxy)
+ }
+ }
+ }
+ if calls != 1 {
+ t.Fatalf("decorator calls = %d, want 1", calls)
+ }
+ obs, ok := c.Transport.(*observingTransport)
+ if !ok || obs.RoundTripper != selected {
+ t.Fatal("decoration replaced transport or failed to wrap selected transport")
+ }
+ })
+ }
+ }
+
+ t.Run("request_proxy_override_with_decorator", func(t *testing.T) {
+ var selected http.RoundTripper
+ ctx := coreexecutor.WithRequestProxyURL(context.Background(), "http://127.0.0.1:18081")
+ ctx = httptransport.WithDecorator(ctx, func(next http.RoundTripper) http.RoundTripper {
+ selected = next
+ return &observingTransport{RoundTripper: next}
+ })
+ c := NewProxyAwareHTTPClient(
+ ctx,
+ &config.Config{SDKConfig: sdkconfig.SDKConfig{ProxyURL: "http://127.0.0.1:18080"}},
+ &coreauth.Auth{ProxyURL: "http://127.0.0.1:18082"},
+ 0,
+ )
+ obs, ok := c.Transport.(*observingTransport)
+ if !ok || obs.RoundTripper != selected {
+ t.Fatalf("expected wrapped transport, got %T", c.Transport)
+ }
+ tr, ok := selected.(*http.Transport)
+ if !ok || tr.Proxy == nil {
+ t.Fatalf("selected transport = %#v, want proxied *http.Transport", selected)
+ }
+ probeReq, errProbe := http.NewRequest(http.MethodGet, "https://upstream.example/v1", nil)
+ if errProbe != nil {
+ t.Fatalf("probe request: %v", errProbe)
+ }
+ resolvedProxy, errProxy := tr.Proxy(probeReq)
+ if errProxy != nil || resolvedProxy == nil || resolvedProxy.String() != "http://127.0.0.1:18081" {
+ t.Fatalf("resolved proxy = %v (err=%v), want http://127.0.0.1:18081", resolvedProxy, errProxy)
+ }
+ })
+}
+
+type lifecycleReadCloser struct {
+ io.ReadCloser
+ bytesRead *int64
+ closed *atomic.Bool
+}
+
+func (l *lifecycleReadCloser) Read(p []byte) (int, error) {
+ n, errRead := l.ReadCloser.Read(p)
+ atomic.AddInt64(l.bytesRead, int64(n))
+ return n, errRead
+}
+
+func (l *lifecycleReadCloser) Close() error {
+ l.closed.Store(true)
+ return l.ReadCloser.Close()
+}
+
+func TestClientDecoratorContextIsolationAndStreamLifecycle(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("data: " + string(body) + "\n\n"))
+ }))
+ defer srv.Close()
+
+ var wg sync.WaitGroup
+ for i := 0; i < 8; i++ {
+ wg.Add(2)
+ go func() {
+ defer wg.Done()
+ var capturedBody []byte
+ var bytesRead int64
+ var closed atomic.Bool
+
+ ctx := httptransport.WithDecorator(context.Background(), func(next http.RoundTripper) http.RoundTripper {
+ return roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ if req.Body != nil {
+ data, errReadAll := io.ReadAll(req.Body)
+ if errReadAll != nil {
+ return nil, errReadAll
+ }
+ capturedBody = append([]byte(nil), data...)
+ req.Body = io.NopCloser(bytes.NewReader(data))
+ }
+ resp, errRT := next.RoundTrip(req)
+ if errRT != nil || resp == nil || resp.Body == nil {
+ return resp, errRT
+ }
+ resp.Body = &lifecycleReadCloser{
+ ReadCloser: resp.Body,
+ bytesRead: &bytesRead,
+ closed: &closed,
+ }
+ return resp, nil
+ })
+ })
+
+ client := NewProxyAwareHTTPClient(ctx, &config.Config{}, nil, 0)
+ req, errReq := http.NewRequestWithContext(ctx, http.MethodPost, srv.URL, bytes.NewBufferString(`{"model":"gpt-5","thinking":"high"}`))
+ if errReq != nil {
+ t.Errorf("NewRequestWithContext: %v", errReq)
+ return
+ }
+ resp, errDo := client.Do(req)
+ if errDo != nil {
+ t.Errorf("Do: %v", errDo)
+ return
+ }
+ _, _ = io.ReadAll(resp.Body)
+ if errClose := resp.Body.Close(); errClose != nil {
+ t.Errorf("Close: %v", errClose)
+ return
+ }
+ if string(capturedBody) != `{"model":"gpt-5","thinking":"high"}` {
+ t.Errorf("capturedBody = %q", string(capturedBody))
+ }
+ if atomic.LoadInt64(&bytesRead) == 0 || !closed.Load() {
+ t.Errorf("stream lifecycle not observed: bytesRead=%d closed=%v", atomic.LoadInt64(&bytesRead), closed.Load())
+ }
+ }()
+
+ go func() {
+ defer wg.Done()
+ client := NewProxyAwareHTTPClient(context.Background(), &config.Config{}, nil, 0)
+ if client.Transport != nil {
+ t.Errorf("undecorated default client Transport = %T, want nil", client.Transport)
+ }
+ }()
+ }
+ wg.Wait()
+}
diff --git a/internal/runtime/executor/helps/utls_client.go b/internal/runtime/executor/helps/utls_client.go
index f832748b49d..1b497861a9b 100644
--- a/internal/runtime/executor/helps/utls_client.go
+++ b/internal/runtime/executor/helps/utls_client.go
@@ -2,6 +2,7 @@ package helps
import (
"context"
+ "crypto/x509"
"errors"
"fmt"
"io"
@@ -16,6 +17,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/httpwire"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/httptransport"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/proxyutil"
log "github.com/sirupsen/logrus"
"golang.org/x/net/http2"
@@ -80,7 +82,10 @@ func (t *utlsRoundTripper) createConnection(ctx context.Context, host, addr stri
tlsConfig := &tls.Config{ServerName: host}
tlsConn := tls.UClient(conn, tlsConfig, tls.HelloChrome_Auto)
- if errHandshake := tlsConn.HandshakeContext(ctx); errHandshake != nil {
+ handshakeCtx, cancelHandshake := handshakeContext(ctx)
+ defer cancelHandshake()
+ if errHandshake := tlsConn.HandshakeContext(handshakeCtx); errHandshake != nil {
+ errHandshake = classifyHandshakeError(ctx, handshakeCtx, errHandshake)
if errors.Is(errHandshake, context.Canceled) || errors.Is(errHandshake, context.DeadlineExceeded) {
return nil, fmt.Errorf("utls: TLS handshake: %w", errHandshake)
}
@@ -178,8 +183,13 @@ const claudeCodeSessionCacheCapacity = 32
// native handshake. PreferSkipResumptionOnNilExtension turns uTLS's HelloCustom
// "resume without the matching extension" panic into a skipped resumption.
func newClaudeCodeTLSConfig(host string, sessionCache tls.ClientSessionCache) *tls.Config {
+ return newClaudeCodeTLSConfigWithRoots(host, sessionCache, nil)
+}
+
+func newClaudeCodeTLSConfigWithRoots(host string, sessionCache tls.ClientSessionCache, roots *x509.CertPool) *tls.Config {
return &tls.Config{
ServerName: host,
+ RootCAs: roots,
ClientSessionCache: sessionCache,
OmitEmptyPsk: true,
PreferSkipResumptionOnNilExtension: true,
@@ -318,9 +328,6 @@ func cachedClaudeCodeRoundTripper(proxyURL string) http.RoundTripper {
}
func newClaudeCodeRoundTripper(proxyURL string) http.RoundTripper {
- // The cache is scoped to this round tripper, which is already keyed by proxy,
- // so resumption never crosses proxy boundaries.
- sessionCache := tls.NewLRUClientSessionCache(claudeCodeSessionCacheCapacity)
var dialer proxy.Dialer = proxy.Direct
if proxyURL != "" {
proxyDialer, mode, errBuild := proxyutil.BuildDialer(proxyURL)
@@ -330,6 +337,20 @@ func newClaudeCodeRoundTripper(proxyURL string) http.RoundTripper {
dialer = proxyDialer
}
}
+ return newClaudeCodeRoundTripperForDialer(dialer)
+}
+
+// newClaudeCodeRoundTripperForDialer builds the Claude Code transport on top
+// of an already-resolved dialer. It is split out from
+// newClaudeCodeRoundTripper so tests can inject in-memory connections.
+func newClaudeCodeRoundTripperForDialer(dialer proxy.Dialer) *http.Transport {
+ return newClaudeCodeRoundTripperForDialerAndRoots(dialer, nil)
+}
+
+func newClaudeCodeRoundTripperForDialerAndRoots(dialer proxy.Dialer, roots *x509.CertPool) *http.Transport {
+ // The cache is scoped to this round tripper, which is already keyed by proxy,
+ // so resumption never crosses proxy boundaries.
+ sessionCache := tls.NewLRUClientSessionCache(claudeCodeSessionCacheCapacity)
transport := &http.Transport{
ForceAttemptHTTP2: false,
@@ -354,14 +375,17 @@ func newClaudeCodeRoundTripper(proxyURL string) http.RoundTripper {
}
return nil, fmt.Errorf("claude tls: split upstream address: %w", errSplit)
}
- tlsConn := tls.UClient(conn, newClaudeCodeTLSConfig(host, sessionCache), tls.HelloCustom)
+ tlsConn := tls.UClient(conn, newClaudeCodeTLSConfigWithRoots(host, sessionCache, roots), tls.HelloCustom)
if errPreset := tlsConn.ApplyPreset(claudeCodeTLSClientHelloSpec()); errPreset != nil {
if errClose := tlsConn.Close(); errClose != nil {
log.Debugf("claude tls: close connection after preset failure: %v", errClose)
}
return nil, fmt.Errorf("claude tls: apply Claude Code ClientHello: %w", errPreset)
}
- if errHandshake := tlsConn.HandshakeContext(ctx); errHandshake != nil {
+ handshakeCtx, cancelHandshake := handshakeContext(ctx)
+ defer cancelHandshake()
+ if errHandshake := tlsConn.HandshakeContext(handshakeCtx); errHandshake != nil {
+ errHandshake = classifyHandshakeError(ctx, handshakeCtx, errHandshake)
if errClose := tlsConn.Close(); errClose != nil {
log.Debugf("claude tls: close connection after handshake failure: %v", errClose)
}
@@ -395,7 +419,24 @@ func (f *fallbackRoundTripper) RoundTrip(req *http.Request) (*http.Response, err
// fingerprints for protected hosts. It uses Claude Code's Node/OpenSSL profile
// for Anthropic and a Chrome profile for ChatGPT, with a standard-transport
// fallback for other hosts.
-func NewUtlsHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) *http.Client {
+func NewUtlsHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) (clientOut *http.Client) {
+ // Carry the opt-in TLS establishment bound (requests
+ // tls-handshake-timeout-seconds) on a request-scoped wrapper. This
+ // client is shared, so the bound intentionally reaches both the Claude
+ // Code Anthropic dial path and the Chrome ChatGPT/Codex dial path; the
+ // standard fallback transport ignores the context value. The uTLS dial
+ // paths read it per dial, so cached per-proxy transports need no rebuild
+ // when the value changes. A disabled bound (0) leaves requests untouched.
+ var handshakeTimeout time.Duration
+ if cfg != nil {
+ handshakeTimeout = cfg.TLSHandshakeTimeout()
+ }
+ defer func() {
+ clientOut = httptransport.Decorate(ctx, clientOut)
+ if clientOut != nil && handshakeTimeout > 0 {
+ clientOut.Transport = &tlsHandshakeTimeoutRoundTripper{base: clientOut.Transport, timeout: handshakeTimeout}
+ }
+ }()
proxyURL := effectiveProxyURL(ctx, cfg, auth)
var ctxRoundTripper http.RoundTripper
diff --git a/internal/runtime/executor/helps/utls_client_handshake_timeout_test.go b/internal/runtime/executor/helps/utls_client_handshake_timeout_test.go
new file mode 100644
index 00000000000..9f07ff893cd
--- /dev/null
+++ b/internal/runtime/executor/helps/utls_client_handshake_timeout_test.go
@@ -0,0 +1,458 @@
+package helps
+
+import (
+ "bufio"
+ "context"
+ gotls "crypto/tls"
+ "crypto/x509"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "strings"
+ "testing"
+ "testing/synctest"
+ "time"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+)
+
+// silentTLSListener accepts one TCP connection, drains the TLS ClientHello,
+// and never sends anything back, reproducing the silent-upstream shape from
+// Issue #6615. The returned channel closes once the client side has closed
+// the connection (the drain observes EOF), which also proves the failed
+// socket was released.
+func silentTLSListener(t *testing.T) (addr string, drained <-chan struct{}) {
+ t.Helper()
+ listener, errListen := net.Listen("tcp", "127.0.0.1:0")
+ if errListen != nil {
+ t.Fatal(errListen)
+ }
+ t.Cleanup(func() {
+ if errClose := listener.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) {
+ t.Errorf("close listener: %v", errClose)
+ }
+ })
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ conn, errAccept := listener.Accept()
+ if errAccept != nil {
+ return
+ }
+ defer func() {
+ // The client may already have reset this intentionally stalled
+ // connection, so its close outcome is not part of the assertion.
+ _ = conn.Close()
+ }()
+ _, _ = io.Copy(io.Discard, conn)
+ }()
+ return listener.Addr().String(), done
+}
+
+// TestClaudeCodeTLSHandshakeTimeoutBoundsSilentPeer reproduces Issue #6615
+// through the real Claude Code transport: the peer completes the TCP
+// connection but never answers the TLS ClientHello. With the opt-in
+// handshake timeout configured, connection establishment must fail with a
+// deadline error inside a bounded interval so retry logic can proceed.
+func TestClaudeCodeTLSHandshakeTimeoutBoundsSilentPeer(t *testing.T) {
+ addr, drained := silentTLSListener(t)
+
+ transport, ok := newClaudeCodeRoundTripper("").(*http.Transport)
+ if !ok {
+ t.Fatalf("Claude Code transport type = %T, want *http.Transport", newClaudeCodeRoundTripper(""))
+ }
+ ctx := withTLSHandshakeTimeout(context.Background(), 100*time.Millisecond)
+
+ dialDone := make(chan error, 1)
+ go func() {
+ conn, errDial := transport.DialTLSContext(ctx, "tcp", addr)
+ if conn != nil {
+ errDial = errors.Join(errDial, conn.Close())
+ }
+ dialDone <- errDial
+ }()
+
+ select {
+ case errDial := <-dialDone:
+ if !errors.Is(errDial, context.DeadlineExceeded) {
+ t.Fatalf("DialTLSContext error = %v, want context deadline exceeded", errDial)
+ }
+ if !cliproxyexecutor.IsConfiguredTLSHandshakeTimeoutError(errDial) {
+ t.Fatalf("DialTLSContext error = %q, want typed configured TLS handshake timeout", errDial)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("TLS handshake with silent peer was not bounded by the configured timeout")
+ }
+
+ select {
+ case <-drained:
+ case <-time.After(5 * time.Second):
+ t.Fatal("server did not observe the failed handshake connection being closed")
+ }
+}
+
+// TestClaudeCodeTLSHandshakeTimeoutZeroKeepsUnboundedWait verifies the
+// default (timeout 0) preserves the pre-existing behavior: with the bound
+// disabled, a silent peer keeps the handshake pending and only caller
+// context cancellation ends the wait.
+func TestClaudeCodeTLSHandshakeTimeoutZeroKeepsUnboundedWait(t *testing.T) {
+ clientConn, serverConn := net.Pipe()
+ t.Cleanup(func() {
+ if errClose := serverConn.Close(); errClose != nil && !errors.Is(errClose, io.ErrClosedPipe) && !errors.Is(errClose, net.ErrClosed) {
+ t.Errorf("close server connection: %v", errClose)
+ }
+ })
+
+ dialer := contextDialerFunc(func(context.Context, string, string) (net.Conn, error) {
+ return clientConn, nil
+ })
+ transport := newClaudeCodeRoundTripperForDialer(dialer)
+
+ ctx, cancel := context.WithCancel(withTLSHandshakeTimeout(context.Background(), 0))
+ dialDone := make(chan error, 1)
+ go func() {
+ conn, errDial := transport.DialTLSContext(ctx, "tcp", "api.anthropic.com:443")
+ if conn != nil {
+ errDial = errors.Join(errDial, conn.Close())
+ }
+ dialDone <- errDial
+ }()
+
+ // Consume the full ClientHello record so the handshake is known to be
+ // in flight and blocked reading the peer's answer.
+ header := make([]byte, 5)
+ if _, errRead := io.ReadFull(serverConn, header); errRead != nil {
+ t.Fatalf("read ClientHello record header: %v", errRead)
+ }
+ recordLength := int(header[3])<<8 | int(header[4])
+ if _, errRead := io.ReadFull(serverConn, make([]byte, recordLength)); errRead != nil {
+ t.Fatalf("read ClientHello record body: %v", errRead)
+ }
+
+ select {
+ case errDial := <-dialDone:
+ t.Fatalf("DialTLSContext returned before caller cancellation: %v", errDial)
+ default:
+ }
+
+ cancel()
+ select {
+ case errDial := <-dialDone:
+ if !errors.Is(errDial, context.Canceled) {
+ t.Fatalf("DialTLSContext error = %v, want context canceled", errDial)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("TLS handshake did not stop after caller context cancellation")
+ }
+}
+
+// TestUtlsRoundTripperHandshakeTimeoutBoundsSilentPeer covers the Chrome
+// fingerprint path (ChatGPT) with the same silent-peer shape: when the
+// caller configured the bound, createConnection must also fail in a
+// bounded interval instead of waiting forever.
+func TestUtlsRoundTripperHandshakeTimeoutBoundsSilentPeer(t *testing.T) {
+ clientConn, serverConn := net.Pipe()
+ t.Cleanup(func() {
+ if errClose := serverConn.Close(); errClose != nil && !errors.Is(errClose, io.ErrClosedPipe) && !errors.Is(errClose, net.ErrClosed) {
+ t.Errorf("close server connection: %v", errClose)
+ }
+ })
+ serverDone := make(chan struct{})
+ go func() {
+ defer close(serverDone)
+ _, _ = io.Copy(io.Discard, serverConn)
+ }()
+
+ trackedConn := &trackedNetConn{Conn: clientConn}
+ roundTripper := &utlsRoundTripper{dialer: contextDialerFunc(func(context.Context, string, string) (net.Conn, error) {
+ return trackedConn, nil
+ })}
+ ctx := withTLSHandshakeTimeout(context.Background(), 100*time.Millisecond)
+
+ connectionDone := make(chan error, 1)
+ go func() {
+ tlsConn, errConnect := roundTripper.createConnection(ctx, "chatgpt.com", "chatgpt.com:443")
+ if tlsConn != nil {
+ errConnect = errors.Join(errConnect, tlsConn.Close())
+ }
+ connectionDone <- errConnect
+ }()
+
+ select {
+ case errConnect := <-connectionDone:
+ if !errors.Is(errConnect, context.DeadlineExceeded) {
+ t.Fatalf("createConnection error = %v, want context deadline exceeded", errConnect)
+ }
+ if !cliproxyexecutor.IsConfiguredTLSHandshakeTimeoutError(errConnect) {
+ t.Fatalf("createConnection error = %q, want typed configured TLS handshake timeout", errConnect)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("Chrome-path TLS handshake with silent peer was not bounded by the configured timeout")
+ }
+ select {
+ case <-serverDone:
+ case <-time.After(5 * time.Second):
+ t.Fatal("server did not observe the failed handshake connection being closed")
+ }
+}
+
+// TestUtlsRoundTripperWithoutHandshakeBoundWaitsForCaller mirrors the
+// zero/disabled behavior on the Chrome path: without the ctx carrier the
+// handshake keeps waiting and only caller cancellation ends it, exactly
+// like TestUtlsRoundTripperHandshakeUsesRequestContext.
+func TestUtlsRoundTripperWithoutHandshakeBoundWaitsForCaller(t *testing.T) {
+ clientConn, serverConn := net.Pipe()
+ t.Cleanup(func() {
+ if errClose := serverConn.Close(); errClose != nil && !errors.Is(errClose, io.ErrClosedPipe) && !errors.Is(errClose, net.ErrClosed) {
+ t.Errorf("close server connection: %v", errClose)
+ }
+ })
+ serverDone := make(chan struct{})
+ go func() {
+ defer close(serverDone)
+ _, _ = io.Copy(io.Discard, serverConn)
+ }()
+
+ roundTripper := &utlsRoundTripper{dialer: contextDialerFunc(func(context.Context, string, string) (net.Conn, error) {
+ return clientConn, nil
+ })}
+ ctx, cancel := context.WithCancel(context.Background())
+
+ connectionDone := make(chan error, 1)
+ go func() {
+ tlsConn, errConnect := roundTripper.createConnection(ctx, "chatgpt.com", "chatgpt.com:443")
+ if tlsConn != nil {
+ errConnect = errors.Join(errConnect, tlsConn.Close())
+ }
+ connectionDone <- errConnect
+ }()
+
+ // The handshake must still be in flight here; only the explicit cancel
+ // below may end it. There is no deterministic "handshake started"
+ // signal on this path, so this check is intentionally one-sided: it
+ // fails only if the handshake already terminated on its own.
+ select {
+ case errConnect := <-connectionDone:
+ t.Fatalf("createConnection returned before caller cancellation: %v", errConnect)
+ default:
+ }
+
+ cancel()
+ select {
+ case errConnect := <-connectionDone:
+ if !errors.Is(errConnect, context.Canceled) {
+ t.Fatalf("createConnection error = %v, want context canceled", errConnect)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("Chrome-path TLS handshake did not stop after caller context cancellation")
+ }
+}
+
+// deadlineRecordingConn records connection deadlines so the successful
+// handshake test can prove no establishment deadline remains installed.
+type deadlineRecordingConn struct {
+ net.Conn
+ deadlines chan time.Time
+}
+
+func (c *deadlineRecordingConn) SetDeadline(deadline time.Time) error {
+ c.deadlines <- deadline
+ return c.Conn.SetDeadline(deadline)
+}
+
+func (c *deadlineRecordingConn) SetReadDeadline(deadline time.Time) error {
+ c.deadlines <- deadline
+ return c.Conn.SetReadDeadline(deadline)
+}
+
+func (c *deadlineRecordingConn) SetWriteDeadline(deadline time.Time) error {
+ c.deadlines <- deadline
+ return c.Conn.SetWriteDeadline(deadline)
+}
+
+// TestClaudeCodeTLSHandshakeTimeoutPreservesSuccessfulConnection exercises
+// a real successful Claude uTLS handshake over net.Pipe inside synctest.
+// synctest advances its fake clock only when every goroutine is durably
+// blocked, so the one-second response delay below proves timers beyond the
+// 100ms handshake bound fire before the established connection is used; no
+// test wall-clock time is consumed by that delay.
+func TestClaudeCodeTLSHandshakeTimeoutPreservesSuccessfulConnection(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ // synctest starts at its own fake epoch, so mint the certificate
+ // against that clock rather than the host wall clock.
+ certificate := newResumptionTestCertificateAt(t, time.Now())
+ roots := x509.NewCertPool()
+ roots.AddCert(certificate.Leaf)
+ clientConn, serverConn := net.Pipe()
+ t.Cleanup(func() {
+ if errClose := serverConn.Close(); errClose != nil && !errors.Is(errClose, io.ErrClosedPipe) && !errors.Is(errClose, net.ErrClosed) {
+ t.Errorf("close server connection: %v", errClose)
+ }
+ })
+ recorded := &deadlineRecordingConn{Conn: clientConn, deadlines: make(chan time.Time, 16)}
+ dialer := contextDialerFunc(func(context.Context, string, string) (net.Conn, error) {
+ return recorded, nil
+ })
+ transport := newClaudeCodeRoundTripperForDialerAndRoots(dialer, roots)
+ const handshakeTimeout = 100 * time.Millisecond
+ ctx := withTLSHandshakeTimeout(context.Background(), handshakeTimeout)
+
+ dialDone := make(chan struct {
+ conn net.Conn
+ err error
+ }, 1)
+ go func() {
+ conn, errDial := transport.DialTLSContext(ctx, "tcp", "api.anthropic.com:443")
+ dialDone <- struct {
+ conn net.Conn
+ err error
+ }{conn: conn, err: errDial}
+ }()
+
+ server := gotls.Server(serverConn, &gotls.Config{
+ Certificates: []gotls.Certificate{certificate},
+ MinVersion: gotls.VersionTLS13,
+ NextProtos: []string{"http/1.1"},
+ })
+ serverDone := make(chan error, 1)
+ go func() {
+ if errHandshake := server.Handshake(); errHandshake != nil {
+ serverDone <- fmt.Errorf("server handshake: %w", errHandshake)
+ return
+ }
+ reader := bufio.NewReader(server)
+ if _, errRead := http.ReadRequest(reader); errRead != nil {
+ serverDone <- fmt.Errorf("server read request: %w", errRead)
+ return
+ }
+ // Fake time advances to this timer while all goroutines block,
+ // placing response delivery well beyond the handshake bound.
+ time.Sleep(time.Second)
+ response := "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}"
+ if _, errWrite := io.WriteString(server, response); errWrite != nil {
+ serverDone <- fmt.Errorf("server write response: %w", errWrite)
+ return
+ }
+ // net.Pipe has no half-close: a TLS close_notify write blocks until
+ // the peer reads again, but this test stops after Content-Length bytes.
+ // Response delivery is the behavior under test, so report completion
+ // once those bytes are written and let cleanup close the raw pipe.
+ serverDone <- nil
+ }()
+
+ var established net.Conn
+ select {
+ case result := <-dialDone:
+ if result.err != nil {
+ t.Fatalf("DialTLSContext error = %v, want successful handshake", result.err)
+ }
+ established = result.conn
+ case <-time.After(5 * time.Second):
+ t.Fatal("successful TLS handshake did not complete")
+ }
+ defer func() {
+ // Close the raw pipe from the other end first. Otherwise uTLS can
+ // spend its own five-second alert timeout trying to write close_notify
+ // to a net.Pipe peer that has intentionally stopped reading after the
+ // complete HTTP response.
+ if errClose := serverConn.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) && !errors.Is(errClose, io.ErrClosedPipe) {
+ t.Errorf("close server connection before client: %v", errClose)
+ }
+ if errClose := established.Close(); errClose != nil && !errors.Is(errClose, net.ErrClosed) && !errors.Is(errClose, io.ErrClosedPipe) {
+ t.Errorf("close established connection: %v", errClose)
+ }
+ }()
+
+ select {
+ case deadline := <-recorded.deadlines:
+ t.Fatalf("successful handshake installed a residual connection deadline: %v", deadline)
+ default:
+ }
+ if _, errWrite := io.WriteString(established, "GET /v1/messages HTTP/1.1\r\nHost: api.anthropic.com\r\nConnection: close\r\n\r\n"); errWrite != nil {
+ t.Fatalf("write after successful handshake: %v", errWrite)
+ }
+ request, errRequest := http.NewRequest(http.MethodGet, "https://api.anthropic.com/v1/messages", nil)
+ if errRequest != nil {
+ t.Fatal(errRequest)
+ }
+ resp, errReadResponse := http.ReadResponse(bufio.NewReader(established), request)
+ if errReadResponse != nil {
+ t.Fatalf("read response beyond handshake bound: %v", errReadResponse)
+ }
+ body, errReadBody := io.ReadAll(resp.Body)
+ if errReadBody != nil {
+ t.Fatalf("read response body beyond handshake bound: %v", errReadBody)
+ }
+ if errCloseBody := resp.Body.Close(); errCloseBody != nil {
+ t.Fatalf("close response body: %v", errCloseBody)
+ }
+ if resp.StatusCode != http.StatusOK || string(body) != "{}" {
+ t.Fatalf("response = %d %q, want 200 {}", resp.StatusCode, body)
+ }
+ select {
+ case errServer := <-serverDone:
+ if errServer != nil {
+ t.Fatalf("server completion: %v", errServer)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("TLS server did not finish")
+ }
+ })
+}
+
+// TestNewUtlsHTTPClientStampsConfiguredHandshakeBound verifies the config
+// wiring end to end at the client seam: when
+// requests.tls-handshake-timeout-seconds is positive, every request
+// issued by the client carries the bound so the uTLS dial paths can read
+// it; when it is 0 (or no config is given), requests stay unstamped and
+// behavior is unchanged.
+func TestNewUtlsHTTPClientStampsConfiguredHandshakeBound(t *testing.T) {
+ capture := func(seen *time.Duration) http.RoundTripper {
+ return utlsClientRoundTripFunc(func(req *http.Request) (*http.Response, error) {
+ *seen = tlsHandshakeTimeoutFromContext(req.Context())
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader("{}")),
+ Request: req,
+ }, nil
+ })
+ }
+ tests := []struct {
+ name string
+ seconds int
+ hasCfg bool
+ want time.Duration
+ }{
+ {name: "positive seconds stamp the bound", seconds: 3, hasCfg: true, want: 3 * time.Second},
+ {name: "zero leaves requests unstamped", seconds: 0, hasCfg: true, want: 0},
+ {name: "negative leaves requests unstamped", seconds: -2, hasCfg: true, want: 0},
+ {name: "nil config leaves requests unstamped", hasCfg: false, want: 0},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ seen := time.Duration(-1)
+ ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", capture(&seen))
+ var cfg *config.Config
+ if tt.hasCfg {
+ cfg = &config.Config{}
+ cfg.TLSHandshakeTimeoutSeconds = tt.seconds
+ }
+ client := NewUtlsHTTPClient(ctx, cfg, &cliproxyauth.Auth{}, 0)
+ resp, errGet := client.Get("https://api.anthropic.com/v1/messages")
+ if errGet != nil {
+ t.Fatalf("client.Get error = %v", errGet)
+ }
+ if errClose := resp.Body.Close(); errClose != nil {
+ t.Fatalf("close response body: %v", errClose)
+ }
+ if seen != tt.want {
+ t.Fatalf("request handshake bound = %v, want %v", seen, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/runtime/executor/helps/utls_client_resumption_test.go b/internal/runtime/executor/helps/utls_client_resumption_test.go
index a7a8cb21234..383c57bc598 100644
--- a/internal/runtime/executor/helps/utls_client_resumption_test.go
+++ b/internal/runtime/executor/helps/utls_client_resumption_test.go
@@ -20,6 +20,13 @@ import (
// newResumptionTestCertificate mints a short-lived self-signed leaf for the
// loopback TLS server used by the resumption test.
func newResumptionTestCertificate(t *testing.T) gotls.Certificate {
+ return newResumptionTestCertificateAt(t, time.Now())
+}
+
+// newResumptionTestCertificateAt mints a test leaf valid around now. Tests
+// running inside synctest pass its fake epoch so certificate verification
+// sees the same clock as the handshake timers.
+func newResumptionTestCertificateAt(t *testing.T, now time.Time) gotls.Certificate {
t.Helper()
key, errKey := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if errKey != nil {
@@ -28,8 +35,8 @@ func newResumptionTestCertificate(t *testing.T) gotls.Certificate {
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "api.anthropic.com"},
- NotBefore: time.Now().Add(-time.Hour),
- NotAfter: time.Now().Add(time.Hour),
+ NotBefore: now.Add(-time.Hour),
+ NotAfter: now.Add(time.Hour),
DNSNames: []string{"api.anthropic.com"},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
diff --git a/internal/runtime/executor/helps/utls_handshake_timeout.go b/internal/runtime/executor/helps/utls_handshake_timeout.go
new file mode 100644
index 00000000000..94f1f7a9cb0
--- /dev/null
+++ b/internal/runtime/executor/helps/utls_handshake_timeout.go
@@ -0,0 +1,95 @@
+package helps
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "time"
+
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+)
+
+// tlsHandshakeTimeoutContextKey carries the opt-in TLS establishment bound
+// through request contexts. The Claude Code round tripper is cached per
+// proxy URL, so the bound cannot live on the transport itself: it is read
+// per dial instead, which also lets config hot reloads take effect
+// without rebuilding cached transports.
+type tlsHandshakeTimeoutContextKey struct{}
+
+// withTLSHandshakeTimeout returns a context carrying the TLS establishment
+// bound. A non-positive timeout leaves the context unchanged (bound
+// disabled), matching the config contract where 0 means off.
+func withTLSHandshakeTimeout(ctx context.Context, timeout time.Duration) context.Context {
+ if ctx == nil || timeout <= 0 {
+ return ctx
+ }
+ return context.WithValue(ctx, tlsHandshakeTimeoutContextKey{}, timeout)
+}
+
+// tlsHandshakeTimeoutFromContext returns the configured establishment
+// bound, or 0 when the bound is disabled.
+func tlsHandshakeTimeoutFromContext(ctx context.Context) time.Duration {
+ if ctx == nil {
+ return 0
+ }
+ timeout, _ := ctx.Value(tlsHandshakeTimeoutContextKey{}).(time.Duration)
+ if timeout <= 0 {
+ return 0
+ }
+ return timeout
+}
+
+// tlsHandshakeTimeoutRoundTripper stamps the configured establishment
+// bound onto each outgoing request context so the uTLS dial paths can
+// read it per dial. It only ever annotates the context; request routing,
+// headers, and bodies pass through unchanged.
+type tlsHandshakeTimeoutRoundTripper struct {
+ base http.RoundTripper
+ timeout time.Duration
+}
+
+func (rt *tlsHandshakeTimeoutRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
+ if rt.timeout > 0 {
+ req = req.WithContext(withTLSHandshakeTimeout(req.Context(), rt.timeout))
+ }
+ return rt.base.RoundTrip(req)
+}
+
+// handshakeContext bounds the TLS handshake when a positive timeout is
+// configured on ctx. It starts when the caller invokes it, after dialing
+// and proxy CONNECT have returned. It returns the context to hand to
+// HandshakeContext plus its cancel function. The bound exists only for
+// the duration of the handshake call: crypto/tls (and uTLS) close the raw
+// connection when the handshake context expires, and once the handshake
+// succeeds the context plays no further role, so inference and
+// long-running streams are never time-limited and no connection deadline
+// is left behind. When the bound is disabled the original context is
+// returned unchanged with a no-op cancel, preserving the previous
+// behavior exactly.
+func handshakeContext(ctx context.Context) (context.Context, context.CancelFunc) {
+ timeout := tlsHandshakeTimeoutFromContext(ctx)
+ if timeout <= 0 {
+ return ctx, func() {}
+ }
+ return context.WithTimeout(ctx, timeout)
+}
+
+// classifyHandshakeError annotates only a deadline produced by the
+// configured internal handshake bound. The parent context is checked so a
+// caller cancellation or caller-owned deadline racing the internal timer
+// retains its original error and non-retryable classification.
+func classifyHandshakeError(parentCtx, handshakeCtx context.Context, err error) error {
+ if err == nil || tlsHandshakeTimeoutFromContext(parentCtx) <= 0 {
+ return err
+ }
+ if parentCtx != nil && parentCtx.Err() != nil {
+ return err
+ }
+ if handshakeCtx == nil || !errors.Is(handshakeCtx.Err(), context.DeadlineExceeded) {
+ return err
+ }
+ if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
+ return err
+ }
+ return cliproxyexecutor.NewConfiguredTLSHandshakeTimeoutError(err)
+}
diff --git a/internal/runtime/executor/helps/vertex_openapi.go b/internal/runtime/executor/helps/vertex_openapi.go
new file mode 100644
index 00000000000..29afafbba10
--- /dev/null
+++ b/internal/runtime/executor/helps/vertex_openapi.go
@@ -0,0 +1,322 @@
+package helps
+
+import (
+ "fmt"
+ "net/url"
+ "strings"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+)
+
+// IsVertexOpenAPIModel reports whether the given model identifier targets a
+// non-Google Vertex partner or open model (MaaS) served by endpoints/openapi.
+func IsVertexOpenAPIModel(model string) bool {
+ baseModel := strings.TrimSpace(thinking.ParseSuffix(model).ModelName)
+ if baseModel == "" {
+ return false
+ }
+ if publisher, modelID, ok := cutVertexPublisherResource(baseModel); ok {
+ return !strings.EqualFold(publisher, "google") && !strings.Contains(modelID, "/")
+ }
+ publisher, modelID, ok := strings.Cut(baseModel, "/")
+ publisher = strings.TrimSpace(publisher)
+ modelID = strings.TrimSpace(modelID)
+ if !ok || publisher == "" || modelID == "" || strings.Contains(modelID, "/") {
+ return false
+ }
+ if strings.EqualFold(publisher, "google") || strings.EqualFold(publisher, "models") || strings.EqualFold(publisher, "publishers") {
+ return false
+ }
+ return true
+}
+
+// NormalizeVertexOpenAPIModel normalizes a Vertex model identifier into the
+// "/" form expected by Vertex's endpoints/openapi route while
+// preserving any thinking suffix.
+func NormalizeVertexOpenAPIModel(model string) string {
+ trimmed := strings.TrimSpace(model)
+ if trimmed == "" {
+ return ""
+ }
+ parsed := thinking.ParseSuffix(trimmed)
+ baseModel := strings.TrimSpace(parsed.ModelName)
+ if baseModel == "" {
+ baseModel = trimmed
+ }
+ normalizedBase := normalizeVertexOpenAPIBaseModel(baseModel)
+ if parsed.HasSuffix {
+ return fmt.Sprintf("%s(%s)", normalizedBase, parsed.RawSuffix)
+ }
+ return normalizedBase
+}
+
+func normalizeVertexOpenAPIBaseModel(baseModel string) string {
+ if publisher, modelID, ok := cutVertexPublisherResource(baseModel); ok {
+ return strings.ToLower(publisher) + "/" + modelID
+ }
+ lower := strings.ToLower(baseModel)
+ if strings.HasPrefix(lower, "models/") {
+ modelID := strings.TrimSpace(baseModel[len("models/"):])
+ if modelID != "" {
+ return "google/" + modelID
+ }
+ }
+ if publisher, modelID, ok := strings.Cut(baseModel, "/"); ok {
+ publisher = strings.TrimSpace(publisher)
+ modelID = strings.TrimSpace(modelID)
+ if publisher != "" && modelID != "" {
+ return publisher + "/" + modelID
+ }
+ }
+ return "google/" + baseModel
+}
+
+func cutVertexPublisherResource(baseModel string) (publisher, modelID string, ok bool) {
+ lower := strings.ToLower(baseModel)
+ if !strings.HasPrefix(lower, "publishers/") {
+ return "", "", false
+ }
+ rest := strings.TrimSpace(baseModel[len("publishers/"):])
+ pub, tail, found := strings.Cut(rest, "/")
+ pub = strings.TrimSpace(pub)
+ tail = strings.TrimSpace(tail)
+ if !found || pub == "" || tail == "" {
+ return "", "", false
+ }
+ lowerTail := strings.ToLower(tail)
+ if !strings.HasPrefix(lowerTail, "models/") {
+ return "", "", false
+ }
+ id := strings.TrimSpace(tail[len("models/"):])
+ if id == "" {
+ return "", "", false
+ }
+ return pub, id, true
+}
+
+// ShouldExecuteVertexOpenAPI reports whether the request should be routed
+// through Vertex AI's OpenAI-compatible endpoints/openapi route.
+func ShouldExecuteVertexOpenAPI(auth *cliproxyauth.Auth, model string) bool {
+ if setting, hasExplicit := vertexOpenAPIExplicitSetting(auth); hasExplicit {
+ if !setting {
+ return false
+ }
+ lowerModel := strings.ToLower(strings.TrimSpace(thinking.ParseSuffix(model).ModelName))
+ if strings.Contains(lowerModel, "imagen") {
+ return false
+ }
+ return true
+ }
+ return IsVertexOpenAPIModel(model)
+}
+
+func vertexOpenAPIExplicitSetting(auth *cliproxyauth.Auth) (bool, bool) {
+ if auth == nil {
+ return false, false
+ }
+ if auth.Attributes != nil {
+ if v, ok := parseVertexOpenAPIBool(auth.Attributes["openapi"]); ok {
+ return v, true
+ }
+ }
+ if auth.Metadata != nil {
+ if v, ok := parseVertexOpenAPIBool(auth.Metadata["openapi"]); ok {
+ return v, true
+ }
+ }
+ return false, false
+}
+
+func parseVertexOpenAPIBool(raw any) (bool, bool) {
+ switch v := raw.(type) {
+ case bool:
+ return v, true
+ case string:
+ trimmed := strings.ToLower(strings.TrimSpace(v))
+ switch trimmed {
+ case "true", "1":
+ return true, true
+ case "false", "0":
+ return false, true
+ }
+ }
+ return false, false
+}
+
+// IsGoogleVertexHost reports whether baseURL is empty or points to Google's
+// official aiplatform.googleapis.com domain.
+func IsGoogleVertexHost(baseURL string) bool {
+ trimmed := strings.TrimSpace(baseURL)
+ if trimmed == "" {
+ return true
+ }
+ parsed, errParse := url.Parse(trimmed)
+ if errParse != nil || parsed.Host == "" {
+ lower := strings.ToLower(trimmed)
+ return strings.Contains(lower, "aiplatform.googleapis.com")
+ }
+ host := strings.ToLower(parsed.Hostname())
+ return host == "aiplatform.googleapis.com" || strings.HasSuffix(host, "-aiplatform.googleapis.com") || strings.HasSuffix(host, ".aiplatform.googleapis.com")
+}
+
+// VertexOpenAPILocation returns the target Vertex location for OpenAPI requests,
+// preferring an explicit openapi_location override on the credential.
+func VertexOpenAPILocation(auth *cliproxyauth.Auth, defaultLocation string) string {
+ if auth != nil {
+ if auth.Attributes != nil {
+ for _, key := range []string{"openapi_location", "openapi-location", "location"} {
+ if v := strings.TrimSpace(auth.Attributes[key]); v != "" {
+ if key != "location" || strings.TrimSpace(defaultLocation) == "" {
+ return v
+ }
+ }
+ }
+ }
+ if auth.Metadata != nil {
+ for _, key := range []string{"openapi_location", "openapi-location"} {
+ if v, ok := auth.Metadata[key].(string); ok && strings.TrimSpace(v) != "" {
+ return strings.TrimSpace(v)
+ }
+ }
+ if strings.TrimSpace(defaultLocation) == "" {
+ if v, ok := auth.Metadata["location"].(string); ok && strings.TrimSpace(v) != "" {
+ return strings.TrimSpace(v)
+ }
+ }
+ }
+ }
+ loc := strings.TrimSpace(defaultLocation)
+ if loc == "" {
+ return "us-central1"
+ }
+ return loc
+}
+
+// VertexAuthBaseURL extracts an optional custom base_url from a Vertex credential.
+func VertexAuthBaseURL(auth *cliproxyauth.Auth) string {
+ if auth == nil {
+ return ""
+ }
+ if auth.Attributes != nil {
+ if v := strings.TrimSpace(auth.Attributes["base_url"]); v != "" {
+ return v
+ }
+ }
+ if auth.Metadata != nil {
+ for _, key := range []string{"base_url", "base-url"} {
+ if v, ok := auth.Metadata[key].(string); ok && strings.TrimSpace(v) != "" {
+ return strings.TrimSpace(v)
+ }
+ }
+ }
+ return ""
+}
+
+// VertexAuthProjectID extracts an optional project_id from a Vertex credential.
+func VertexAuthProjectID(auth *cliproxyauth.Auth) string {
+ if auth == nil || auth.Metadata == nil {
+ return ""
+ }
+ for _, key := range []string{"project_id", "project"} {
+ if v, ok := auth.Metadata[key].(string); ok && strings.TrimSpace(v) != "" {
+ return strings.TrimSpace(v)
+ }
+ }
+ return ""
+}
+
+// VertexOpenAPIHostBaseURL returns the Vertex regional or global host URL.
+func VertexOpenAPIHostBaseURL(location string) string {
+ loc := strings.TrimSpace(location)
+ if loc == "" {
+ loc = "us-central1"
+ } else if strings.EqualFold(loc, "global") {
+ return "https://aiplatform.googleapis.com"
+ }
+ return fmt.Sprintf("https://%s-aiplatform.googleapis.com", loc)
+}
+
+// VertexServiceAccountOpenAPIBaseURL builds the OpenAI-compatible base URL for a
+// Vertex service-account credential (.../v1/projects/{project}/locations/{location}/endpoints/openapi).
+func VertexServiceAccountOpenAPIBaseURL(baseURL, version, projectID, location string) string {
+ loc := strings.TrimSpace(location)
+ if loc == "" {
+ loc = "us-central1"
+ }
+ ver := strings.TrimSpace(version)
+ if ver == "" {
+ ver = "v1"
+ }
+ base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
+ if base == "" {
+ base = VertexOpenAPIHostBaseURL(loc)
+ }
+ if strings.HasSuffix(base, "/endpoints/openapi") {
+ return base
+ }
+ if idx := strings.Index(base, "/publishers/"); idx >= 0 {
+ return base[:idx] + "/endpoints/openapi"
+ }
+ if strings.Contains(base, "/projects/") && strings.Contains(base, "/locations/") {
+ return base + "/endpoints/openapi"
+ }
+ if !vertexOpenAPIHasVersionSegment(base) {
+ base = fmt.Sprintf("%s/%s", base, ver)
+ }
+ return fmt.Sprintf("%s/projects/%s/locations/%s/endpoints/openapi", base, strings.TrimSpace(projectID), loc)
+}
+
+// VertexAPIKeyOpenAPIBaseURL builds the OpenAI-compatible base URL for a third-party
+// Vertex-compatible API-key credential.
+func VertexAPIKeyOpenAPIBaseURL(baseURL, version, projectID, location string) string {
+ ver := strings.TrimSpace(version)
+ if ver == "" {
+ ver = "v1"
+ }
+ base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
+ if base == "" {
+ return ""
+ }
+ if strings.HasSuffix(base, "/endpoints/openapi") {
+ return base
+ }
+ if idx := strings.Index(base, "/publishers/"); idx >= 0 {
+ return base[:idx] + "/endpoints/openapi"
+ }
+ if strings.Contains(base, "/projects/") && strings.Contains(base, "/locations/") {
+ return base + "/endpoints/openapi"
+ }
+ if strings.TrimSpace(projectID) != "" {
+ return VertexServiceAccountOpenAPIBaseURL(base, ver, projectID, location)
+ }
+ if !vertexOpenAPIHasVersionSegment(base) {
+ base = fmt.Sprintf("%s/%s", base, ver)
+ }
+ return base + "/endpoints/openapi"
+}
+
+func vertexOpenAPIHasVersionSegment(base string) bool {
+ if base == "" {
+ return false
+ }
+ if strings.Contains(base, "/v1/") || strings.Contains(base, "/v1beta1/") {
+ return true
+ }
+ seg := base[strings.LastIndexByte(base, '/')+1:]
+ if len(seg) < 2 || seg[0] != 'v' {
+ return false
+ }
+ hasDigit := false
+ for i := 1; i < len(seg); i++ {
+ c := seg[i]
+ switch {
+ case c >= '0' && c <= '9':
+ hasDigit = true
+ case c == '_' || c == '-' || (c >= 'a' && c <= 'z'):
+ default:
+ return false
+ }
+ }
+ return hasDigit
+}
diff --git a/internal/runtime/executor/helps/vertex_openapi_test.go b/internal/runtime/executor/helps/vertex_openapi_test.go
new file mode 100644
index 00000000000..20ed01808ff
--- /dev/null
+++ b/internal/runtime/executor/helps/vertex_openapi_test.go
@@ -0,0 +1,92 @@
+package helps
+
+import (
+ "testing"
+
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+)
+
+func TestIsVertexOpenAPIModel(t *testing.T) {
+ tests := []struct {
+ model string
+ want bool
+ }{
+ {"deepseek-ai/deepseek-v4.1-flash-maas", true},
+ {"deepseek-ai/deepseek-v4.1-flash-maas(high)", true},
+ {"qwen/qwen3-coder-480b-a35b-instruct-maas", true},
+ {"xai/grok-4-maas", true},
+ {"publishers/deepseek-ai/models/deepseek-v4.1-flash-maas", true},
+ {"publishers/google/models/gemini-2.5-pro", false},
+ {"publishers/deepseek-ai/deepseek-v4.1-flash-maas", false},
+ {"deepseek-ai/extra/segment", false},
+ {"google/gemini-2.5-pro", false},
+ {"models/gemini-2.5-pro", false},
+ {"gemini-2.5-pro", false},
+ {"imagen-3.0-generate-002", false},
+ {"", false},
+ }
+ for _, tc := range tests {
+ if got := IsVertexOpenAPIModel(tc.model); got != tc.want {
+ t.Errorf("IsVertexOpenAPIModel(%q) = %v, want %v", tc.model, got, tc.want)
+ }
+ }
+}
+
+func TestNormalizeVertexOpenAPIModel(t *testing.T) {
+ tests := []struct {
+ in string
+ want string
+ }{
+ {"deepseek-ai/deepseek-v4.1-flash-maas", "deepseek-ai/deepseek-v4.1-flash-maas"},
+ {"publishers/deepseek-ai/models/deepseek-v4.1-flash-maas", "deepseek-ai/deepseek-v4.1-flash-maas"},
+ {"publishers/deepseek-ai/models/deepseek-v4.1-flash-maas(high)", "deepseek-ai/deepseek-v4.1-flash-maas(high)"},
+ {"gemini-2.5-pro", "google/gemini-2.5-pro"},
+ {"gemini-2.5-pro(8192)", "google/gemini-2.5-pro(8192)"},
+ {"models/gemini-2.5-pro", "google/gemini-2.5-pro"},
+ {"publishers/google/models/gemini-2.5-pro", "google/gemini-2.5-pro"},
+ }
+ for _, tc := range tests {
+ if got := NormalizeVertexOpenAPIModel(tc.in); got != tc.want {
+ t.Errorf("NormalizeVertexOpenAPIModel(%q) = %q, want %q", tc.in, got, tc.want)
+ }
+ }
+}
+
+func TestShouldExecuteVertexOpenAPI(t *testing.T) {
+ if !ShouldExecuteVertexOpenAPI(nil, "deepseek-ai/deepseek-v4.1-flash-maas") {
+ t.Fatal("expected automatic OpenAPI selection for deepseek-ai/deepseek-v4.1-flash-maas")
+ }
+ disabledAuth := &cliproxyauth.Auth{
+ Metadata: map[string]any{"openapi": false},
+ }
+ if ShouldExecuteVertexOpenAPI(disabledAuth, "deepseek-ai/deepseek-v4.1-flash-maas") {
+ t.Fatal("expected explicit openapi=false to disable OpenAPI routing")
+ }
+ enabledAuth := &cliproxyauth.Auth{
+ Attributes: map[string]string{"openapi": "true"},
+ }
+ if !ShouldExecuteVertexOpenAPI(enabledAuth, "gemini-2.5-pro") {
+ t.Fatal("expected explicit openapi=true to enable OpenAPI routing")
+ }
+}
+
+func TestVertexOpenAPIBaseURLBuilders(t *testing.T) {
+ if got := VertexServiceAccountOpenAPIBaseURL("", "v1", "my-proj", "global"); got != "https://aiplatform.googleapis.com/v1/projects/my-proj/locations/global/endpoints/openapi" {
+ t.Fatalf("VertexServiceAccountOpenAPIBaseURL(global) = %q", got)
+ }
+ if got := VertexServiceAccountOpenAPIBaseURL("", "v1", "my-proj", "us-central1"); got != "https://us-central1-aiplatform.googleapis.com/v1/projects/my-proj/locations/us-central1/endpoints/openapi" {
+ t.Fatalf("VertexServiceAccountOpenAPIBaseURL(us-central1) = %q", got)
+ }
+ if got := VertexAPIKeyOpenAPIBaseURL("https://example.com/v1/projects/my-proj/locations/global", "v1", "", ""); got != "https://example.com/v1/projects/my-proj/locations/global/endpoints/openapi" {
+ t.Fatalf("VertexAPIKeyOpenAPIBaseURL(project/location URL) = %q", got)
+ }
+ if got := VertexAPIKeyOpenAPIBaseURL("https://example.com/api", "v1", "", ""); got != "https://example.com/api/v1/endpoints/openapi" {
+ t.Fatalf("VertexAPIKeyOpenAPIBaseURL(relay URL) = %q", got)
+ }
+ if !IsGoogleVertexHost("") || !IsGoogleVertexHost("https://aiplatform.googleapis.com/v1") || !IsGoogleVertexHost("https://us-central1-aiplatform.googleapis.com") {
+ t.Fatal("expected Google Vertex hosts to be detected")
+ }
+ if IsGoogleVertexHost("https://zenmux.ai/api") {
+ t.Fatal("expected third-party host not to be detected as Google Vertex host")
+ }
+}
diff --git a/internal/runtime/executor/kimi_executor.go b/internal/runtime/executor/kimi_executor.go
index 5bc064da58e..d3da9f129b4 100644
--- a/internal/runtime/executor/kimi_executor.go
+++ b/internal/runtime/executor/kimi_executor.go
@@ -394,6 +394,20 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
if helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) {
return
}
+ if errScan := scanner.Err(); errScan != nil {
+ helps.RecordAPIResponseError(ctx, e.cfg, errScan)
+ if !streamUsage.PublishFailure(ctx, reporter, errScan) {
+ reporter.PublishFailure(ctx, errScan)
+ }
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Err: errScan}:
+ case <-ctx.Done():
+ }
+ return
+ }
+ if ctx.Err() != nil {
+ return
+ }
doneChunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, []byte("[DONE]"), ¶m, claudeInputTokens)
helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage})
for i := range doneChunks {
@@ -403,14 +417,6 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
return
}
}
- if errScan := scanner.Err(); errScan != nil {
- helps.RecordAPIResponseError(ctx, e.cfg, errScan)
- reporter.PublishFailure(ctx, errScan)
- select {
- case out <- cliproxyexecutor.StreamChunk{Err: errScan}:
- case <-ctx.Done():
- }
- }
}()
return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil
}
@@ -419,6 +425,12 @@ func (e *KimiExecutor) executeResponses(ctx context.Context, auth *cliproxyauth.
if opts.Alt == "responses/compact" {
return resp, statusErr{code: http.StatusNotImplemented, msg: "/responses/compact not supported"}
}
+ if errExpand := helps.ExpandResponsesCompactionCapsules(&req, &opts, "kimi"); errExpand != nil {
+ return resp, statusErr{code: http.StatusBadRequest, msg: errExpand.Error()}
+ }
+ if helps.HasResponsesCompactionTriggerRequest(req, opts) {
+ return e.executeKimiResponsesCompaction(ctx, auth, req, opts)
+ }
responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts)
baseModel := thinking.ParseSuffix(req.Model).ModelName
@@ -551,6 +563,12 @@ func (e *KimiExecutor) executeResponsesStream(ctx context.Context, auth *cliprox
if opts.Alt == "responses/compact" {
return nil, statusErr{code: http.StatusBadRequest, msg: "streaming not supported for /responses/compact"}
}
+ if errExpand := helps.ExpandResponsesCompactionCapsules(&req, &opts, "kimi"); errExpand != nil {
+ return nil, statusErr{code: http.StatusBadRequest, msg: errExpand.Error()}
+ }
+ if helps.HasResponsesCompactionTriggerRequest(req, opts) {
+ return e.executeKimiResponsesCompactionStream(ctx, auth, req, opts)
+ }
responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts)
baseModel := thinking.ParseSuffix(req.Model).ModelName
@@ -757,9 +775,66 @@ func (e *KimiExecutor) executeResponsesStream(ctx context.Context, auth *cliprox
return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil
}
+func (e *KimiExecutor) executeKimiResponsesCompaction(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
+ var resp cliproxyexecutor.Response
+ baseModel := thinking.ParseSuffix(req.Model).ModelName
+ summaryPayload := helps.PrepareAntigravityCompactionSummaryPayload(helps.ResponsesCompactionSourcePayload(req, opts), baseModel)
+ summaryReq := cliproxyexecutor.Request{
+ Model: req.Model,
+ Payload: summaryPayload,
+ Metadata: req.Metadata,
+ }
+ summaryOpts := opts
+ summaryOpts.Alt = ""
+ summaryOpts.Stream = false
+ summaryOpts.OriginalRequest = nil
+ summaryOpts.SourceFormat = sdktranslator.FormatOpenAIResponse
+ summaryOpts.ResponseFormat = sdktranslator.FormatOpenAIResponse
+
+ summaryResp, errSummary := e.executeResponses(ctx, auth, summaryReq, summaryOpts)
+ if errSummary != nil {
+ return resp, errSummary
+ }
+ respPayload, errBuild := helps.BuildResponsesCompactionResponseFromSummary(baseModel, summaryResp.Payload)
+ if errBuild != nil {
+ return resp, statusErr{code: http.StatusBadGateway, msg: errBuild.Error()}
+ }
+ return cliproxyexecutor.Response{
+ Payload: respPayload,
+ Headers: summaryResp.Headers,
+ }, nil
+}
+
+func (e *KimiExecutor) executeKimiResponsesCompactionStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {
+ baseModel := thinking.ParseSuffix(req.Model).ModelName
+ summaryResp, errSummary := e.executeKimiResponsesCompaction(ctx, auth, req, opts)
+ if errSummary != nil {
+ return nil, errSummary
+ }
+ chunks, errChunks := helps.BuildResponsesCompactionStreamChunksFromResponse(baseModel, summaryResp.Payload)
+ if errChunks != nil {
+ return nil, statusErr{code: http.StatusBadGateway, msg: errChunks.Error()}
+ }
+ out := make(chan cliproxyexecutor.StreamChunk, len(chunks))
+ for _, chunk := range chunks {
+ out <- cliproxyexecutor.StreamChunk{Payload: chunk}
+ }
+ close(out)
+
+ headers := summaryResp.Headers.Clone()
+ if headers == nil {
+ headers = make(http.Header)
+ }
+ headers.Set("Content-Type", "text/event-stream")
+ return &cliproxyexecutor.StreamResult{Headers: headers, Chunks: out}, nil
+}
+
// CountTokens estimates token count for Kimi requests.
func (e *KimiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
if opts.SourceFormat == sdktranslator.FormatOpenAIResponse {
+ if errExpand := helps.ExpandResponsesCompactionCapsules(&req, &opts, "kimi"); errExpand != nil {
+ return cliproxyexecutor.Response{}, statusErr{code: http.StatusBadRequest, msg: errExpand.Error()}
+ }
var errNormalize error
req.Payload, errNormalize = helps.NormalizeApplyPatchResponsesRequest(req.Payload)
if errNormalize != nil {
@@ -1297,23 +1372,57 @@ func normalizeKimiTools(body []byte) []byte {
}
body = normalizeKimiToolList(body, "tools", true)
body = normalizeKimiToolList(body, "functions", false)
+ if input := gjson.GetBytes(body, "input"); input.Exists() && input.IsArray() {
+ for idx, item := range input.Array() {
+ switch strings.TrimSpace(item.Get("type").String()) {
+ case "additional_tools", "tool_search_output":
+ body = normalizeKimiToolList(body, fmt.Sprintf("input.%d.tools", idx), true)
+ }
+ }
+ }
return body
}
func normalizeKimiToolList(body []byte, arrayKey string, isTools bool) []byte {
items := gjson.GetBytes(body, arrayKey)
- if !items.Exists() || !items.IsArray() {
+ normalized, changed := normalizeKimiToolArray(items, isTools)
+ if !changed {
+ return body
+ }
+
+ out, errSetRaw := sjson.SetRawBytes(body, arrayKey, normalized)
+ if errSetRaw != nil {
return body
}
+ return out
+}
+
+func normalizeKimiToolArray(items gjson.Result, isTools bool) ([]byte, bool) {
+ if !items.Exists() || !items.IsArray() {
+ return nil, false
+ }
arr := items.Array()
if len(arr) == 0 {
- return body
+ return nil, false
}
changed := false
- var updatedItems []string
+ updatedItems := make([]string, 0, len(arr))
for _, item := range arr {
itemRaw := item.Raw
+ if isTools && strings.TrimSpace(item.Get("type").String()) == "namespace" {
+ for _, nestedKey := range []string{"tools", "children"} {
+ if nested := gjson.Get(itemRaw, nestedKey); nested.Exists() && nested.IsArray() {
+ if normalizedNested, nestedChanged := normalizeKimiToolArray(nested, true); nestedChanged {
+ if updated, errSet := sjson.SetRawBytes([]byte(itemRaw), nestedKey, normalizedNested); errSet == nil {
+ itemRaw = string(updated)
+ changed = true
+ }
+ }
+ }
+ }
+ }
+
var paramPath string
if isTools && item.Get("function.parameters").Exists() {
paramPath = "function.parameters"
@@ -1337,14 +1446,9 @@ func normalizeKimiToolList(body []byte, arrayKey string, isTools bool) []byte {
}
if !changed {
- return body
+ return nil, false
}
-
- out, errSetRaw := sjson.SetRawBytes(body, arrayKey, helps.JoinRawJSONStrings(updatedItems))
- if errSetRaw != nil {
- return body
- }
- return out
+ return helps.JoinRawJSONStrings(updatedItems), true
}
func normalizeKimiParametersSchema(paramsRaw string) string {
diff --git a/internal/runtime/executor/kimi_executor_test.go b/internal/runtime/executor/kimi_executor_test.go
index 520fced2d61..bdcbbd4a28e 100644
--- a/internal/runtime/executor/kimi_executor_test.go
+++ b/internal/runtime/executor/kimi_executor_test.go
@@ -1242,6 +1242,189 @@ func TestNormalizeKimiToolsDirect(t *testing.T) {
}
}
+func TestKimiExecutorStreamNormalizesNamespacedToolSchemasFromResponses(t *testing.T) {
+ var upstreamBody []byte
+ ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", kimiRoundTripperFunc(func(req *http.Request) (*http.Response, error) {
+ var errRead error
+ upstreamBody, errRead = io.ReadAll(req.Body)
+ if errRead != nil {
+ return nil, errRead
+ }
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
+ Body: io.NopCloser(strings.NewReader(
+ "event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"status\":\"completed\",\"usage\":{\"total_tokens\":2}}}\n\n",
+ )),
+ }, nil
+ }))
+
+ executor := NewKimiExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{
+ Attributes: map[string]string{},
+ Metadata: map[string]any{"access_token": "test-token"},
+ }
+
+ payload := []byte(`{
+ "model": "kimi-k3",
+ "stream": true,
+ "input": [
+ {"type": "message", "role": "user", "content": "Reply with OK."},
+ {
+ "type": "additional_tools",
+ "tools": [
+ {
+ "type": "namespace",
+ "name": "mcp__extra",
+ "tools": [
+ {
+ "type": "function",
+ "name": "extra_fn",
+ "parameters": {
+ "$defs": {"val": {"type": "string"}},
+ "properties": {"arg": {"$ref": "#/$defs/val"}}
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "tools": [
+ {
+ "type": "namespace",
+ "name": "mcp__rea",
+ "tools": [
+ {
+ "type": "function",
+ "name": "capture_native_ui_scenario",
+ "description": "Capture a native UI scenario.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "steps": {"$ref": "#/$defs/__schema6"}
+ },
+ "required": ["steps"],
+ "additionalProperties": false,
+ "$defs": {
+ "__schema6": {
+ "type": "array",
+ "items": {"$ref": "#/$defs/__schema7"},
+ "minItems": 1
+ },
+ "__schema7": {
+ "oneOf": [
+ {
+ "type": "object",
+ "properties": {
+ "kind": {"type": "string", "enum": ["click"]},
+ "path": {
+ "type": "array",
+ "items": {"$ref": "#/$defs/__schema8"}
+ }
+ },
+ "required": ["kind", "path"],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "direction": {
+ "type": "string",
+ "enum": ["increment", "decrement"]
+ },
+ "kind": {"type": "string", "enum": ["scroll"]},
+ "path": {
+ "type": "array",
+ "items": {"$ref": "#/$defs/__schema9"}
+ }
+ },
+ "required": ["kind", "path", "direction"],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "kind": {"type": "string", "enum": ["key-entry"]},
+ "path": {
+ "type": "array",
+ "items": {"$ref": "#/$defs/__schema10"}
+ },
+ "text": {"type": "string"}
+ },
+ "required": ["kind", "path", "text"],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "kind": {"type": "string", "enum": ["wait"]},
+ "milliseconds": {"type": "integer"}
+ },
+ "required": ["kind", "milliseconds"],
+ "additionalProperties": false
+ }
+ ]
+ },
+ "__schema8": {"type": "integer"},
+ "__schema9": {"type": "integer"},
+ "__schema10": {"type": "integer"}
+ }
+ }
+ }
+ ]
+ }
+ ]
+ }`)
+
+ streamResult, err := executor.ExecuteStream(ctx, auth, cliproxyexecutor.Request{
+ Model: "kimi-k3",
+ Payload: payload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ OriginalRequest: payload,
+ Stream: true,
+ })
+ if err != nil {
+ t.Fatalf("ExecuteStream() error = %v", err)
+ }
+ if streamResult != nil && streamResult.Chunks != nil {
+ for range streamResult.Chunks {
+ }
+ }
+
+ nestedTool := gjson.GetBytes(upstreamBody, "tools.0.tools.0")
+ if !nestedTool.Exists() {
+ t.Fatalf("upstream namespaced tool not found: %s", upstreamBody)
+ }
+ if defs := nestedTool.Get("parameters.$defs"); defs.Exists() {
+ t.Fatalf("upstream namespaced tool parameter still contains $defs: %s", nestedTool.Get("parameters").Raw)
+ }
+ if ref := nestedTool.Get("parameters.properties.steps.$ref"); ref.Exists() {
+ t.Fatalf("upstream namespaced tool parameter still contains $ref at properties.steps: %s", nestedTool.Get("parameters").Raw)
+ }
+ if got := nestedTool.Get("parameters.properties.steps.type").String(); got != "array" {
+ t.Fatalf("steps.type = %q, want array", got)
+ }
+ if got := nestedTool.Get("parameters.properties.steps.items.oneOf.0.properties.path.items.type").String(); got != "integer" {
+ t.Fatalf("steps.items.oneOf.0.properties.path.items.type = %q, want integer", got)
+ }
+
+ extraTool := gjson.GetBytes(upstreamBody, "input.1.tools.0.tools.0")
+ if !extraTool.Exists() {
+ t.Fatalf("upstream additional_tools namespaced tool not found: %s", upstreamBody)
+ }
+ if defs := extraTool.Get("parameters.$defs"); defs.Exists() {
+ t.Fatalf("upstream additional_tools namespaced tool parameter still contains $defs: %s", extraTool.Get("parameters").Raw)
+ }
+ if got := extraTool.Get("parameters.properties.arg.type").String(); got != "string" {
+ t.Fatalf("extra_fn arg.type = %q, want string", got)
+ }
+ if got := extraTool.Get("parameters.type").String(); got != "object" {
+ t.Fatalf("extra_fn parameters.type = %q, want object", got)
+ }
+}
+
func TestNormalizeKimiTemperature(t *testing.T) {
tests := []struct {
name string
@@ -1747,3 +1930,274 @@ func TestKimiApplyPatchSSEFramingAndIncompleteEOF(t *testing.T) {
})
}
}
+
+func TestKimiExecutor_ResponsesCompactionTriggerAndReplay_Issue6781(t *testing.T) {
+ var seenBodies [][]byte
+ var returnIncomplete bool
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, errRead := io.ReadAll(r.Body)
+ if errRead != nil {
+ t.Errorf("read upstream body: %v", errRead)
+ http.Error(w, "read error", http.StatusBadRequest)
+ return
+ }
+ seenBodies = append(seenBodies, body)
+ for idx, item := range gjson.GetBytes(body, "input").Array() {
+ switch item.Get("type").String() {
+ case "compaction_trigger", "compaction":
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = fmt.Fprintf(w, `{"error":{"code":"invalid_request_error","message":"invalid_request_error: invalid_request_error: input.%d: item type \"%s\" is not supported","type":"invalid_request_error"}}`, idx, item.Get("type").String())
+ return
+ }
+ }
+
+ if returnIncomplete {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = io.WriteString(w, `{"id":"resp_inc","object":"response","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"model":"k3","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Partial truncated summary."}]}],"usage":{"input_tokens":12,"output_tokens":6,"total_tokens":18}}`)
+ return
+ }
+
+ if gjson.GetBytes(body, "stream").Bool() {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = io.WriteString(w, "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_cont\",\"status\":\"in_progress\",\"model\":\"k3\"}}\n\n"+
+ "event: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"CPA_COMPACTION_MARKER_42\"}]}}\n\n"+
+ "event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_cont\",\"status\":\"completed\",\"model\":\"k3\",\"output\":[{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"CPA_COMPACTION_MARKER_42\"}]}],\"usage\":{\"input_tokens\":10,\"output_tokens\":4,\"total_tokens\":14}}}\n\n")
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = io.WriteString(w, `{"id":"resp_sum","object":"response","status":"completed","model":"k3","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Summary retaining CPA_COMPACTION_MARKER_42."}]}],"usage":{"input_tokens":12,"output_tokens":6,"total_tokens":18,"input_tokens_details":{"cached_tokens":4}}}`)
+ }))
+ defer server.Close()
+
+ executor := NewKimiExecutor(&config.Config{
+ Payload: config.PayloadConfig{
+ Override: []config.PayloadRule{{
+ Models: []config.PayloadModelRule{{Name: "kimi-k3"}},
+ Params: map[string]any{
+ "max_output_tokens": 2048,
+ },
+ }},
+ },
+ })
+ auth := &cliproxyauth.Auth{
+ Provider: "kimi",
+ Attributes: map[string]string{
+ "api_key": "test-kimi-key",
+ "base_url": server.URL,
+ },
+ }
+
+ triggerPayload := []byte(`{
+ "model": "kimi-k3",
+ "stream": true,
+ "store": false,
+ "reasoning": {"effort": "low"},
+ "input": [
+ {
+ "type": "message",
+ "role": "user",
+ "content": [{"type": "input_text", "text": "Remember the exact test marker CPA_COMPACTION_MARKER_42."}]
+ },
+ {"type": "compaction_trigger"}
+ ]
+ }`)
+
+ streamResult, errStream := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "kimi-k3",
+ Payload: triggerPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ Stream: true,
+ OriginalRequest: triggerPayload,
+ })
+ if errStream != nil {
+ t.Fatalf("ExecuteStream(compaction_trigger) error = %v", errStream)
+ }
+
+ var streamBuf bytes.Buffer
+ for chunk := range streamResult.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("stream chunk error = %v", chunk.Err)
+ }
+ streamBuf.Write(chunk.Payload)
+ }
+
+ completed := claudeCompactionCompletedResponse(t, streamBuf.String())
+ outputItems := completed.Get("response.output").Array()
+ if len(outputItems) != 1 || outputItems[0].Get("type").String() != "compaction" {
+ t.Fatalf("expected single compaction item in stream response, got: %s", completed.Raw)
+ }
+ capsule := outputItems[0].Get("encrypted_content").String()
+ if !strings.HasPrefix(capsule, "cpa-ag-compact-v1:") {
+ t.Fatalf("expected CPA compaction capsule prefix, got %q", capsule)
+ }
+ if gotCached := completed.Get("response.usage.input_tokens_details.cached_tokens").Int(); gotCached != 4 {
+ t.Fatalf("cached_tokens = %d, want 4", gotCached)
+ }
+
+ nonStreamResp, errExec := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "kimi-k3",
+ Payload: triggerPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ OriginalRequest: triggerPayload,
+ })
+ if errExec != nil {
+ t.Fatalf("Execute(compaction_trigger) error = %v", errExec)
+ }
+ nonStreamItems := gjson.GetBytes(nonStreamResp.Payload, "output").Array()
+ if len(nonStreamItems) != 1 || nonStreamItems[0].Get("type").String() != "compaction" {
+ t.Fatalf("expected single compaction item in non-stream response, got: %s", nonStreamResp.Payload)
+ }
+
+ replayPayload := []byte(fmt.Sprintf(`{
+ "model": "kimi-k3",
+ "stream": true,
+ "input": [
+ {"type": "compaction", "encrypted_content": "foreign-opaque-token"},
+ {"type": "compaction", "id": %q, "encrypted_content": %q},
+ {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "What is the exact test marker? Reply with only the marker."}]}
+ ]
+ }`, outputItems[0].Get("id").String(), capsule))
+
+ contStream, errCont := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "kimi-k3",
+ Payload: replayPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ Stream: true,
+ OriginalRequest: replayPayload,
+ })
+ if errCont != nil {
+ t.Fatalf("ExecuteStream(replay) error = %v", errCont)
+ }
+ for chunk := range contStream.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("continuation stream chunk error = %v", chunk.Err)
+ }
+ }
+
+ lastSeen := seenBodies[len(seenBodies)-1]
+ if !bytes.Contains(lastSeen, []byte("Summary retaining CPA_COMPACTION_MARKER_42.")) {
+ t.Fatalf("expected expanded summary in continuation upstream request, got: %s", lastSeen)
+ }
+ if bytes.Contains(lastSeen, []byte("foreign-opaque-token")) {
+ t.Fatalf("expected foreign compaction item to be dropped, got: %s", lastSeen)
+ }
+
+ if _, errNonStreamReplay := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "kimi-k3",
+ Payload: replayPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ OriginalRequest: replayPayload,
+ }); errNonStreamReplay != nil {
+ t.Fatalf("Execute(replay) error = %v", errNonStreamReplay)
+ }
+ nonStreamReplaySeen := seenBodies[len(seenBodies)-1]
+ if !bytes.Contains(nonStreamReplaySeen, []byte("Summary retaining CPA_COMPACTION_MARKER_42.")) || bytes.Contains(nonStreamReplaySeen, []byte("foreign-opaque-token")) {
+ t.Fatalf("unexpected non-stream replay upstream body: %s", nonStreamReplaySeen)
+ }
+
+ strippedTriggerPayload := []byte(`{"model":"kimi-k3","stream":false,"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"Remember the exact test marker CPA_COMPACTION_MARKER_42."}]}]}`)
+ origOnlyResp, errOrigOnly := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "kimi-k3",
+ Payload: strippedTriggerPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ OriginalRequest: triggerPayload,
+ })
+ if errOrigOnly != nil {
+ t.Fatalf("Execute(OriginalRequest-only trigger) error = %v", errOrigOnly)
+ }
+ if got := gjson.GetBytes(origOnlyResp.Payload, "output.0.type").String(); got != "compaction" {
+ t.Fatalf("OriginalRequest-only trigger output[0].type = %q, want compaction", got)
+ }
+
+ corruptedPayload := []byte(`{
+ "model": "kimi-k3",
+ "stream": false,
+ "input": [
+ {"type": "compaction", "encrypted_content": "cpa-ag-compact-v1:not-valid"},
+ {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "continue"}]}
+ ]
+ }`)
+ _, errCorrupt := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "kimi-k3",
+ Payload: corruptedPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ OriginalRequest: corruptedPayload,
+ })
+ if errCorrupt == nil {
+ t.Fatal("expected error for corrupted compaction capsule, got nil")
+ }
+ assertStatusErr(t, errCorrupt, http.StatusBadRequest)
+
+ secondTriggerPayload := []byte(fmt.Sprintf(`{
+ "model": "kimi-k3",
+ "stream": false,
+ "input": [
+ {"type": "compaction", "id": %q, "encrypted_content": %q},
+ {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Second phase work."}]},
+ {"type": "compaction_trigger"}
+ ]
+ }`, outputItems[0].Get("id").String(), capsule))
+ secondResp, errSecond := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "kimi-k3",
+ Payload: secondTriggerPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ OriginalRequest: secondTriggerPayload,
+ })
+ if errSecond != nil {
+ t.Fatalf("Execute(second compaction_trigger) error = %v", errSecond)
+ }
+ if got := len(gjson.GetBytes(secondResp.Payload, "output").Array()); got != 1 {
+ t.Fatalf("second compaction output len = %d, want 1", got)
+ }
+ secondSeen := seenBodies[len(seenBodies)-1]
+ if !bytes.Contains(secondSeen, []byte("Summary retaining CPA_COMPACTION_MARKER_42.")) {
+ t.Fatalf("expected first capsule summary in second compaction request, got: %s", secondSeen)
+ }
+ if got := gjson.GetBytes(secondSeen, "max_output_tokens").Int(); got != 2048 {
+ t.Fatalf("max_output_tokens = %d, want payload rule 2048; body=%s", got, secondSeen)
+ }
+
+ returnIncomplete = true
+ _, errIncExec := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "kimi-k3",
+ Payload: triggerPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ OriginalRequest: triggerPayload,
+ })
+ if errIncExec == nil {
+ t.Fatal("expected Execute(compaction_trigger) to fail on incomplete summary, got nil")
+ }
+ assertStatusErr(t, errIncExec, http.StatusBadGateway)
+
+ _, errIncStream := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "kimi-k3",
+ Payload: triggerPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ Stream: true,
+ OriginalRequest: triggerPayload,
+ })
+ if errIncStream == nil {
+ t.Fatal("expected ExecuteStream(compaction_trigger) to fail on incomplete summary, got nil")
+ }
+ assertStatusErr(t, errIncStream, http.StatusBadGateway)
+}
diff --git a/internal/runtime/executor/meta_executor_execute.go b/internal/runtime/executor/meta_executor_execute.go
index b322a509dae..d8af8539673 100644
--- a/internal/runtime/executor/meta_executor_execute.go
+++ b/internal/runtime/executor/meta_executor_execute.go
@@ -31,7 +31,7 @@ type metaPreparedRequest struct {
body []byte
}
-func (e *MetaExecutor) prepareResponsesRequest(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, stream bool) (*metaPreparedRequest, error) {
+func (e *MetaExecutor) prepareResponsesRequest(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, stream bool) (*metaPreparedRequest, error) {
baseModel := thinking.ParseSuffix(req.Model).ModelName
from := opts.SourceFormat
responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts)
@@ -43,8 +43,10 @@ func (e *MetaExecutor) prepareResponsesRequest(ctx context.Context, req cliproxy
}
originalPayload := bytes.Clone(originalPayloadSource)
isCompat := helps.APIKeyModelIsCompat(req)
- originalTranslated := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, stream, isCompat)
- body, err := helps.TranslateRequestReturningError(ctx, opts.Headers, e.cfg, from, to, baseModel, bytes.Clone(req.Payload), stream, isCompat)
+ targetModel := resolveMetaSignatureTargetModel(baseModel, req, opts)
+ originalTranslated := helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, targetModel, originalPayload, stream, isCompat)
+ originalTranslated = helps.SetStringIfDifferent(originalTranslated, "model", baseModel)
+ body, err := helps.TranslateRequestReturningError(ctx, opts.Headers, e.cfg, from, to, targetModel, bytes.Clone(req.Payload), stream, isCompat)
if err != nil {
return nil, err
}
@@ -71,7 +73,8 @@ func (e *MetaExecutor) prepareResponsesRequest(ctx context.Context, req cliproxy
return nil, errNormalizePatch
}
body = normalizeCodexInstructions(body)
- body = sanitizeOpenAIResponsesReasoningEncryptedContentKeepForeign(ctx, "meta executor", body)
+ baseURL, _ := metaCreds(auth)
+ body = sanitizeOpenAIResponsesReasoningEncryptedContentForMetaBaseURL(ctx, "meta executor", body, baseURL)
body = helps.SanitizeMetaWebSearchTools(body)
body = helps.NormalizeCodexToolIntegerTypes(body, opts.Headers)
@@ -92,13 +95,19 @@ func (e *MetaExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req
if opts.Alt == "responses/compact" {
return resp, statusErr{code: http.StatusNotImplemented, msg: "/responses/compact not supported"}
}
+ if errExpand := helps.ExpandResponsesCompactionCapsules(&req, &opts, "meta"); errExpand != nil {
+ return resp, statusErr{code: http.StatusBadRequest, msg: errExpand.Error()}
+ }
+ if helps.HasResponsesCompactionTriggerRequest(req, opts) {
+ return e.executeMetaCompaction(ctx, auth, req, opts)
+ }
enriched, errAuth := e.ensureAuth(ctx, auth)
if errAuth != nil {
return resp, errAuth
}
- prepared, errPrepare := e.prepareResponsesRequest(ctx, req, opts, true)
+ prepared, errPrepare := e.prepareResponsesRequest(ctx, enriched, req, opts, true)
if errPrepare != nil {
return resp, errPrepare
}
@@ -175,11 +184,36 @@ type metaCompletedTranslation struct {
func (e *MetaExecutor) translateMetaCompleted(ctx context.Context, req cliproxyexecutor.Request, prepared *metaPreparedRequest, data []byte, upstreamUsage *helps.StreamUsageBuffer) (metaCompletedTranslation, error) {
outputItemsByIndex := make(map[int64][]byte)
var outputItemsFallback [][]byte
+ var filteringSubscriptionUsage bool
for _, line := range bytes.Split(data, []byte("\n")) {
- if !bytes.HasPrefix(line, dataTag) {
+ trimmedLine := bytes.TrimSpace(line)
+ if len(trimmedLine) == 0 {
+ filteringSubscriptionUsage = false
continue
}
- eventData := bytes.TrimSpace(line[len(dataTag):])
+ if bytes.HasPrefix(trimmedLine, metaEventTag) {
+ eventName := bytes.TrimSpace(trimmedLine[len(metaEventTag):])
+ filteringSubscriptionUsage = bytes.Equal(eventName, []byte(metaSubscriptionUsageEventType))
+ continue
+ }
+ if !bytes.HasPrefix(trimmedLine, dataTag) {
+ continue
+ }
+ eventData := bytes.TrimSpace(trimmedLine[len(dataTag):])
+ eventType := gjson.GetBytes(eventData, "type").String()
+ if isMetaSubscriptionUsageEvent(eventType, eventData) {
+ filteringSubscriptionUsage = false
+ continue
+ }
+ if filteringSubscriptionUsage {
+ if eventType == "" {
+ if gjson.ValidBytes(eventData) {
+ filteringSubscriptionUsage = false
+ }
+ continue
+ }
+ filteringSubscriptionUsage = false
+ }
if detail, ok := helps.ParseCodexUsage(eventData); ok {
upstreamUsage.Observe(detail, true)
}
@@ -233,11 +267,69 @@ func (e *MetaExecutor) translateMetaCompleted(ctx context.Context, req cliproxye
return metaCompletedTranslation{}, statusErr{code: http.StatusRequestTimeout, msg: "meta stream error: stream disconnected before response.completed or response.incomplete"}
}
+func (e *MetaExecutor) executeMetaCompaction(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
+ var resp cliproxyexecutor.Response
+ baseModel := thinking.ParseSuffix(req.Model).ModelName
+ summaryPayload := helps.PrepareAntigravityCompactionSummaryPayload(helps.ResponsesCompactionSourcePayload(req, opts), baseModel)
+ summaryReq := cliproxyexecutor.Request{
+ Model: req.Model,
+ Payload: summaryPayload,
+ Metadata: req.Metadata,
+ }
+ summaryOpts := opts
+ summaryOpts.Alt = ""
+ summaryOpts.Stream = false
+ summaryOpts.OriginalRequest = nil
+ summaryOpts.SourceFormat = sdktranslator.FormatOpenAIResponse
+ summaryOpts.ResponseFormat = sdktranslator.FormatOpenAIResponse
+
+ summaryResp, errSummary := e.Execute(ctx, auth, summaryReq, summaryOpts)
+ if errSummary != nil {
+ return resp, errSummary
+ }
+ respPayload, errBuild := helps.BuildResponsesCompactionResponseFromSummary(baseModel, summaryResp.Payload)
+ if errBuild != nil {
+ return resp, statusErr{code: http.StatusBadGateway, msg: errBuild.Error()}
+ }
+ return cliproxyexecutor.Response{
+ Payload: respPayload,
+ Headers: summaryResp.Headers,
+ }, nil
+}
+
+func (e *MetaExecutor) executeMetaCompactionStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {
+ baseModel := thinking.ParseSuffix(req.Model).ModelName
+ summaryResp, errSummary := e.executeMetaCompaction(ctx, auth, req, opts)
+ if errSummary != nil {
+ return nil, errSummary
+ }
+ chunks, errChunks := helps.BuildResponsesCompactionStreamChunksFromResponse(baseModel, summaryResp.Payload)
+ if errChunks != nil {
+ return nil, statusErr{code: http.StatusBadGateway, msg: errChunks.Error()}
+ }
+ out := make(chan cliproxyexecutor.StreamChunk, len(chunks))
+ for _, chunk := range chunks {
+ out <- cliproxyexecutor.StreamChunk{Payload: chunk}
+ }
+ close(out)
+
+ headers := summaryResp.Headers.Clone()
+ if headers == nil {
+ headers = make(http.Header)
+ }
+ headers.Set("Content-Type", "text/event-stream")
+ return &cliproxyexecutor.StreamResult{Headers: headers, Chunks: out}, nil
+}
+
func (e *MetaExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
- if _, errAuth := e.ensureAuth(ctx, auth); errAuth != nil {
+ if errExpand := helps.ExpandResponsesCompactionCapsules(&req, &opts, "meta"); errExpand != nil {
+ return cliproxyexecutor.Response{}, statusErr{code: http.StatusBadRequest, msg: errExpand.Error()}
+ }
+ enriched, errAuth := e.ensureAuth(ctx, auth)
+ if errAuth != nil {
return cliproxyexecutor.Response{}, errAuth
}
- prepared, errPrepare := e.prepareResponsesRequest(ctx, req, opts, false)
+ prepared, errPrepare := e.prepareResponsesRequest(ctx, enriched, req, opts, false)
if errPrepare != nil {
return cliproxyexecutor.Response{}, errPrepare
}
@@ -296,7 +388,22 @@ func applyMetaAPIHeaders(req *http.Request, auth *cliproxyauth.Auth, token strin
util.ApplyCustomHeadersFromAttrs(req, attrs, clientHeaders)
}
-const metaNotFoundCooldown = 5 * time.Minute
+const (
+ metaNotFoundCooldown = 5 * time.Minute
+ metaSubscriptionUsageEventType = "response.subscription_usage"
+)
+
+var metaEventTag = []byte("event:")
+
+func isMetaSubscriptionUsageEvent(eventType string, eventData []byte) bool {
+ if eventType == metaSubscriptionUsageEventType {
+ return true
+ }
+ if eventType == "" && len(eventData) > 0 && gjson.GetBytes(eventData, "subscription").IsObject() {
+ return true
+ }
+ return false
+}
func wrapMetaUpstreamError(statusCode int, body []byte) error {
se := statusErr{code: statusCode, msg: string(body)}
diff --git a/internal/runtime/executor/meta_executor_stream.go b/internal/runtime/executor/meta_executor_stream.go
index d6733090808..941b4b19a1b 100644
--- a/internal/runtime/executor/meta_executor_stream.go
+++ b/internal/runtime/executor/meta_executor_stream.go
@@ -4,6 +4,7 @@ import (
"bufio"
"bytes"
"context"
+ "errors"
"io"
"net/http"
"strings"
@@ -20,13 +21,19 @@ func (e *MetaExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
if opts.Alt == "responses/compact" {
return nil, statusErr{code: http.StatusNotImplemented, msg: "/responses/compact not supported"}
}
+ if errExpand := helps.ExpandResponsesCompactionCapsules(&req, &opts, "meta"); errExpand != nil {
+ return nil, statusErr{code: http.StatusBadRequest, msg: errExpand.Error()}
+ }
+ if helps.HasResponsesCompactionTriggerRequest(req, opts) {
+ return e.executeMetaCompactionStream(ctx, auth, req, opts)
+ }
enriched, errAuth := e.ensureAuth(ctx, auth)
if errAuth != nil {
return nil, errAuth
}
- prepared, errPrepare := e.prepareResponsesRequest(ctx, req, opts, true)
+ prepared, errPrepare := e.prepareResponsesRequest(ctx, enriched, req, opts, true)
if errPrepare != nil {
return nil, errPrepare
}
@@ -86,6 +93,7 @@ func (e *MetaExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
var param any
outputItemsByIndex := make(map[int64][]byte)
var outputItemsFallback [][]byte
+ var terminalDelivered bool
emitTranslatedLine := func(translatedLine []byte) bool {
lines, errBridge := prepared.applyPatch.Stream(translatedLine)
if errBridge != nil {
@@ -117,16 +125,79 @@ func (e *MetaExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
}
return true
}
+ var pendingEventLine []byte
+ var filteringSubscriptionUsage bool
+ var skipFilteredBlankLine bool
for scanner.Scan() {
line := scanner.Bytes()
helps.AppendAPIResponseChunk(ctx, e.cfg, line)
- if !bytes.HasPrefix(line, dataTag) {
+ trimmedLine := bytes.TrimSpace(line)
+ if bytes.HasPrefix(trimmedLine, metaEventTag) {
+ eventName := bytes.TrimSpace(trimmedLine[len(metaEventTag):])
+ if bytes.Equal(eventName, []byte(metaSubscriptionUsageEventType)) {
+ if pendingEventLine != nil {
+ if !emitTranslatedLine(pendingEventLine) {
+ return
+ }
+ pendingEventLine = nil
+ }
+ filteringSubscriptionUsage = true
+ skipFilteredBlankLine = true
+ continue
+ }
+ filteringSubscriptionUsage = false
+ skipFilteredBlankLine = false
+ if pendingEventLine != nil {
+ if !emitTranslatedLine(pendingEventLine) {
+ return
+ }
+ }
+ pendingEventLine = bytes.Clone(line)
+ continue
+ }
+ if !bytes.HasPrefix(trimmedLine, dataTag) {
+ if len(trimmedLine) == 0 {
+ filteringSubscriptionUsage = false
+ if skipFilteredBlankLine {
+ skipFilteredBlankLine = false
+ continue
+ }
+ }
+ if filteringSubscriptionUsage {
+ continue
+ }
+ skipFilteredBlankLine = false
+ if pendingEventLine != nil {
+ if !emitTranslatedLine(pendingEventLine) {
+ return
+ }
+ pendingEventLine = nil
+ }
if !emitTranslatedLine(bytes.Clone(line)) {
return
}
continue
}
- eventData := bytes.TrimSpace(line[len(dataTag):])
+ eventData := bytes.TrimSpace(trimmedLine[len(dataTag):])
+ eventType := gjson.GetBytes(eventData, "type").String()
+ if isMetaSubscriptionUsageEvent(eventType, eventData) {
+ pendingEventLine = nil
+ filteringSubscriptionUsage = false
+ skipFilteredBlankLine = true
+ continue
+ }
+ if filteringSubscriptionUsage {
+ if eventType == "" {
+ pendingEventLine = nil
+ if gjson.ValidBytes(eventData) {
+ filteringSubscriptionUsage = false
+ }
+ skipFilteredBlankLine = true
+ continue
+ }
+ filteringSubscriptionUsage = false
+ }
+ skipFilteredBlankLine = false
reporter.ObserveResponseModel(eventData)
if errEvent := metaStreamEventError(eventData); errEvent != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errEvent)
@@ -137,7 +208,6 @@ func (e *MetaExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
}
return
}
- eventType := gjson.GetBytes(eventData, "type").String()
switch eventType {
case "response.output_item.done":
xaiCollectOutputItemDone(eventData, outputItemsByIndex, &outputItemsFallback)
@@ -147,9 +217,24 @@ func (e *MetaExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
}
eventData = patchCodexCompletedOutput(eventData, outputItemsByIndex, outputItemsFallback)
}
+ if pendingEventLine != nil {
+ if !emitTranslatedLine(pendingEventLine) {
+ return
+ }
+ pendingEventLine = nil
+ }
if !emitTranslatedLine(append([]byte("data: "), eventData...)) {
return
}
+ if eventType == "response.completed" || eventType == "response.incomplete" {
+ terminalDelivered = true
+ }
+ }
+ if pendingEventLine != nil {
+ if !emitTranslatedLine(pendingEventLine) {
+ return
+ }
+ pendingEventLine = nil
}
finishEvents, errFinish := prepared.applyPatch.FinishStream()
if errFinish != nil {
@@ -174,6 +259,9 @@ func (e *MetaExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
return
}
if errScan := scanner.Err(); errScan != nil {
+ if terminalDelivered && errors.Is(errScan, context.Canceled) {
+ return
+ }
helps.RecordAPIResponseError(ctx, e.cfg, errScan)
reporter.PublishFailure(ctx, errScan)
select {
diff --git a/internal/runtime/executor/meta_executor_terminal_disconnect_test.go b/internal/runtime/executor/meta_executor_terminal_disconnect_test.go
new file mode 100644
index 00000000000..85607e5610a
--- /dev/null
+++ b/internal/runtime/executor/meta_executor_terminal_disconnect_test.go
@@ -0,0 +1,128 @@
+package executor
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
+)
+
+type metaTerminalDisconnectUsageCapture struct {
+ authID string
+ records chan usage.Record
+}
+
+func (c *metaTerminalDisconnectUsageCapture) HandleUsage(_ context.Context, record usage.Record) {
+ if c == nil || record.Provider != "meta" || record.AuthID != c.authID {
+ return
+ }
+ select {
+ case c.records <- record:
+ default:
+ }
+}
+
+type metaTerminalDisconnectUsageNoop struct{}
+
+func (metaTerminalDisconnectUsageNoop) HandleUsage(context.Context, usage.Record) {}
+
+const (
+ metaTerminalDisconnectDelta = "data: {\"type\":\"response.output_text.delta\",\"item_id\":\"msg_1\",\"output_index\":0,\"content_index\":0,\"delta\":\"hello\"}\n\n"
+ metaTerminalDisconnectCompleted = "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"status\":\"completed\",\"model\":\"muse-wonder-1.0\",\"output\":[{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"hello\"}]}],\"usage\":{\"input_tokens\":10,\"output_tokens\":5,\"total_tokens\":15}}}\n\n"
+)
+
+// The upstream keeps the body open after the written events, as Meta does
+// while it still has to send response.subscription_usage. A Claude client
+// cancels once it sees cancelAfter.
+func runMetaClaudeDisconnect(t *testing.T, upstreamSSE, cancelAfter string) usage.Record {
+ t.Helper()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = io.WriteString(w, upstreamSSE)
+ if flusher, ok := w.(http.Flusher); ok {
+ flusher.Flush()
+ }
+ <-r.Context().Done()
+ }))
+ defer server.Close()
+
+ auth := &cliproxyauth.Auth{
+ ID: t.Name(),
+ Provider: "meta",
+ Attributes: map[string]string{
+ "api_key": "meta-token",
+ "base_url": server.URL,
+ },
+ }
+ capture := &metaTerminalDisconnectUsageCapture{authID: auth.ID, records: make(chan usage.Record, 1)}
+ usage.RegisterNamedPlugin(t.Name(), capture)
+ t.Cleanup(func() {
+ usage.RegisterNamedPlugin(t.Name(), metaTerminalDisconnectUsageNoop{})
+ })
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ result, errExecute := NewMetaExecutor(&config.Config{}).ExecuteStream(ctx, auth, cliproxyexecutor.Request{
+ Model: "muse-wonder-1.0",
+ Payload: []byte(`{"model":"muse-wonder-1.0","max_tokens":64,"stream":true,"messages":[{"role":"user","content":"hello"}]}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatClaude,
+ Stream: true,
+ })
+ if errExecute != nil {
+ t.Fatalf("ExecuteStream() error = %v", errExecute)
+ }
+
+ canceled := false
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("unexpected stream error before cancellation: %v", chunk.Err)
+ }
+ if strings.Contains(string(chunk.Payload), cancelAfter) {
+ canceled = true
+ cancel()
+ break
+ }
+ }
+ if !canceled {
+ t.Fatalf("stream ended before %q", cancelAfter)
+ }
+
+ select {
+ case record := <-capture.records:
+ return record
+ case <-time.After(5 * time.Second):
+ t.Fatal("timed out waiting for usage record")
+ return usage.Record{}
+ }
+}
+
+func TestMetaStream_ClientDisconnectAfterTerminalEventIsNotFailed(t *testing.T) {
+ record := runMetaClaudeDisconnect(t, metaTerminalDisconnectDelta+metaTerminalDisconnectCompleted, "message_stop")
+ if record.Failed {
+ t.Fatalf("usage record marked failed: status=%d", record.Fail.StatusCode)
+ }
+ if record.Detail.InputTokens != 10 || record.Detail.OutputTokens != 5 {
+ t.Fatalf("reported usage = %+v, want input 10 output 5", record.Detail)
+ }
+}
+
+func TestMetaStream_ClientDisconnectBeforeTerminalEventIsFailed(t *testing.T) {
+ record := runMetaClaudeDisconnect(t, metaTerminalDisconnectDelta, "content_block_delta")
+ if !record.Failed {
+ t.Fatal("usage record not marked failed")
+ }
+ if record.Fail.StatusCode != clienterror.StatusClientClosedRequest {
+ t.Fatalf("Fail.StatusCode = %d, want %d", record.Fail.StatusCode, clienterror.StatusClientClosedRequest)
+ }
+}
diff --git a/internal/runtime/executor/meta_executor_test.go b/internal/runtime/executor/meta_executor_test.go
index 7fa19128d9d..1e33cc6dd4c 100644
--- a/internal/runtime/executor/meta_executor_test.go
+++ b/internal/runtime/executor/meta_executor_test.go
@@ -1,9 +1,11 @@
package executor
import (
+ "bytes"
"context"
"encoding/json"
"errors"
+ "fmt"
"io"
"net/http"
"net/http/httptest"
@@ -1609,7 +1611,13 @@ func TestMetaExecutor_Execute_PreservesMuseReasoningEncryptedContent_Issue6450(t
},
}
- const museEncryptedContent = "Q-PaDg-mock-muse-spark-encrypted-reasoning-payload"
+ // Shape-valid synthetic Meta envelope (observed header + 1 KiB bucket),
+ // so this exercises the positively recognized Meta branch end to end
+ // rather than the unknown-format fallback. No captured value is embedded.
+ museEncryptedContent := "Q-PaDgE"
+ for len(museEncryptedContent) < 1426 {
+ museEncryptedContent += "A"
+ }
reqPayload := []byte(`{"model":"muse-spark-1.3","store":false,"input":[` +
`{"id":"rs_1","type":"reasoning","encrypted_content":"` + museEncryptedContent + `","content":[{"type":"reasoning_text","text":"thinking step"}],"summary":[]},` +
`{"id":"rs_claude","type":"reasoning","encrypted_content":"` + testClaudeCAISSample + `","summary":[]},` +
@@ -1673,3 +1681,370 @@ func TestMetaExecutor_Execute_PreservesMuseReasoningEncryptedContent_Issue6450(t
t.Fatalf("stream Claude reasoning id should be dropped: %s", gotBody)
}
}
+
+func TestMetaExecutor_FiltersSubscriptionUsageGlobally(t *testing.T) {
+ const subscriptionUsageSSE = "event: response.subscription_usage\n" +
+ "data: {\"subscription\":{\"tier\":\"1234567890123456\",\"weekly\":{\"resets_at\":1792368000,\"used_percent\":80},\"window\":{\"resets_at\":1792272519,\"used_percent\":10,\"window_duration_mins\":300}},\"type\":\"response.subscription_usage\"}\n\n"
+ const createdSSE = "event: response.created\n" +
+ "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"status\":\"in_progress\",\"model\":\"muse-spark-1.3\"}}\n\n"
+ const deltaSSE = "event: response.output_text.delta\n" +
+ "data: {\"type\":\"response.output_text.delta\",\"item_id\":\"msg_1\",\"output_index\":0,\"content_index\":0,\"delta\":\"hello\"}\n\n"
+ const completedSSE = "event: response.completed\n" +
+ "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"status\":\"completed\",\"model\":\"muse-spark-1.3\",\"output\":[{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"hello\"}]}],\"usage\":{\"input_tokens\":10,\"output_tokens\":5,\"total_tokens\":15}}}\n\n"
+
+ upstreamStream := subscriptionUsageSSE + createdSSE + deltaSSE + subscriptionUsageSSE + completedSSE + subscriptionUsageSSE
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = io.WriteString(w, upstreamStream)
+ }))
+ defer server.Close()
+
+ exec := NewMetaExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{
+ Provider: "meta",
+ Attributes: map[string]string{
+ "api_key": "meta-token",
+ "base_url": server.URL,
+ },
+ }
+
+ formats := []struct {
+ format string
+ payload string
+ }{
+ {
+ format: "openai-response",
+ payload: `{"model":"muse-spark-1.3","input":"hello"}`,
+ },
+ {
+ format: "codex",
+ payload: `{"model":"muse-spark-1.3","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}]}`,
+ },
+ {
+ format: "openai",
+ payload: `{"model":"muse-spark-1.3","messages":[{"role":"user","content":"hello"}]}`,
+ },
+ {
+ format: "claude",
+ payload: `{"model":"muse-spark-1.3","max_tokens":64,"messages":[{"role":"user","content":"hello"}]}`,
+ },
+ {
+ format: "gemini",
+ payload: `{"contents":[{"role":"user","parts":[{"text":"hello"}]}]}`,
+ },
+ }
+
+ for _, tc := range formats {
+ t.Run("stream/"+tc.format, func(t *testing.T) {
+ streamResult, errStream := exec.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "muse-spark-1.3",
+ Payload: []byte(tc.payload),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString(tc.format),
+ Stream: true,
+ })
+ if errStream != nil {
+ t.Fatalf("ExecuteStream(%s) error = %v", tc.format, errStream)
+ }
+
+ var combined strings.Builder
+ for chunk := range streamResult.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("ExecuteStream(%s) chunk error = %v", tc.format, chunk.Err)
+ }
+ combined.Write(chunk.Payload)
+ combined.WriteByte('\n')
+ }
+
+ got := combined.String()
+ if strings.Contains(got, "subscription_usage") || strings.Contains(got, "1234567890123456") {
+ t.Fatalf("ExecuteStream(%s) leaked subscription_usage event:\n%s", tc.format, got)
+ }
+ if !strings.Contains(got, "hello") {
+ t.Fatalf("ExecuteStream(%s) missing normal response content:\n%s", tc.format, got)
+ }
+ })
+
+ t.Run("nonstream/"+tc.format, func(t *testing.T) {
+ resp, errExec := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "muse-spark-1.3",
+ Payload: []byte(tc.payload),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString(tc.format),
+ })
+ if errExec != nil {
+ t.Fatalf("Execute(%s) error = %v", tc.format, errExec)
+ }
+
+ got := string(resp.Payload)
+ if strings.Contains(got, "subscription_usage") || strings.Contains(got, "1234567890123456") {
+ t.Fatalf("Execute(%s) leaked subscription_usage event:\n%s", tc.format, got)
+ }
+ if !strings.Contains(got, "hello") {
+ t.Fatalf("Execute(%s) missing normal response content:\n%s", tc.format, got)
+ }
+ })
+ }
+}
+
+func TestMetaExecutor_ResponsesCompactionTriggerAndReplay_Issue6781(t *testing.T) {
+ var seenBodies [][]byte
+ var returnIncomplete bool
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, errRead := io.ReadAll(r.Body)
+ if errRead != nil {
+ t.Errorf("read upstream body: %v", errRead)
+ http.Error(w, "read error", http.StatusBadRequest)
+ return
+ }
+ seenBodies = append(seenBodies, body)
+ for idx, item := range gjson.GetBytes(body, "input").Array() {
+ switch item.Get("type").String() {
+ case "compaction_trigger", "compaction":
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte(`{"error":{"code":null,"message":"` + "`" + fmt.Sprintf("input[%d]", idx) + "`" + ` did not match any supported type","param":"` + fmt.Sprintf("input[%d]", idx) + `","type":"invalid_request_error"}}`))
+ return
+ }
+ }
+
+ w.Header().Set("Content-Type", "text/event-stream")
+ if returnIncomplete {
+ _, _ = io.WriteString(w, "event: response.incomplete\ndata: {\"type\":\"response.incomplete\",\"response\":{\"id\":\"resp_meta_inc\",\"object\":\"response\",\"status\":\"incomplete\",\"incomplete_details\":{\"reason\":\"max_output_tokens\"},\"model\":\"muse-wonder-1.0\",\"output\":[{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Partial truncated summary.\"}]}],\"usage\":{\"input_tokens\":15,\"output_tokens\":5,\"total_tokens\":20}}}\n\n")
+ return
+ }
+ _, _ = io.WriteString(w, "event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_meta_sum\",\"object\":\"response\",\"status\":\"completed\",\"model\":\"muse-wonder-1.0\",\"output\":[{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Summary retaining CPA_COMPACTION_MARKER_42.\"}]}],\"usage\":{\"input_tokens\":15,\"output_tokens\":5,\"total_tokens\":20,\"input_tokens_details\":{\"cached_tokens\":3}}}}\n\n")
+ }))
+ defer server.Close()
+
+ exec := NewMetaExecutor(&config.Config{
+ Payload: config.PayloadConfig{
+ Override: []config.PayloadRule{{
+ Models: []config.PayloadModelRule{{Name: "muse-wonder-1.0"}},
+ Params: map[string]any{
+ "max_output_tokens": 2048,
+ },
+ }},
+ },
+ })
+ auth := &cliproxyauth.Auth{
+ Provider: "meta",
+ Attributes: map[string]string{
+ "api_key": "meta-token",
+ "base_url": server.URL,
+ },
+ }
+
+ triggerPayload := []byte(`{
+ "model": "muse-wonder-1.0",
+ "stream": true,
+ "store": false,
+ "reasoning": {"effort": "low"},
+ "input": [
+ {
+ "type": "message",
+ "role": "user",
+ "content": [{"type": "input_text", "text": "Remember the exact test marker CPA_COMPACTION_MARKER_42."}]
+ },
+ {"type": "compaction_trigger"}
+ ]
+ }`)
+
+ streamResult, errStream := exec.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "muse-wonder-1.0",
+ Payload: triggerPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ Stream: true,
+ OriginalRequest: triggerPayload,
+ })
+ if errStream != nil {
+ t.Fatalf("ExecuteStream(compaction_trigger) error = %v", errStream)
+ }
+
+ var streamBuf bytes.Buffer
+ for chunk := range streamResult.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("stream chunk error = %v", chunk.Err)
+ }
+ streamBuf.Write(chunk.Payload)
+ }
+
+ completed := claudeCompactionCompletedResponse(t, streamBuf.String())
+ outputItems := completed.Get("response.output").Array()
+ if len(outputItems) != 1 || outputItems[0].Get("type").String() != "compaction" {
+ t.Fatalf("expected single compaction item in stream response, got: %s", completed.Raw)
+ }
+ capsule := outputItems[0].Get("encrypted_content").String()
+ if !strings.HasPrefix(capsule, "cpa-ag-compact-v1:") {
+ t.Fatalf("expected CPA compaction capsule prefix, got %q", capsule)
+ }
+ if gotCached := completed.Get("response.usage.input_tokens_details.cached_tokens").Int(); gotCached != 3 {
+ t.Fatalf("cached_tokens = %d, want 3", gotCached)
+ }
+
+ nonStreamResp, errExec := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "muse-wonder-1.0",
+ Payload: triggerPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ OriginalRequest: triggerPayload,
+ })
+ if errExec != nil {
+ t.Fatalf("Execute(compaction_trigger) error = %v", errExec)
+ }
+ nonStreamItems := gjson.GetBytes(nonStreamResp.Payload, "output").Array()
+ if len(nonStreamItems) != 1 || nonStreamItems[0].Get("type").String() != "compaction" {
+ t.Fatalf("expected single compaction item in non-stream response, got: %s", nonStreamResp.Payload)
+ }
+
+ replayPayload := []byte(fmt.Sprintf(`{
+ "model": "muse-wonder-1.0",
+ "stream": true,
+ "input": [
+ {"type": "compaction", "encrypted_content": "foreign-opaque-token"},
+ {"type": "compaction", "id": %q, "encrypted_content": %q},
+ {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "What is the exact test marker? Reply with only the marker."}]}
+ ]
+ }`, outputItems[0].Get("id").String(), capsule))
+
+ contStream, errCont := exec.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "muse-wonder-1.0",
+ Payload: replayPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ Stream: true,
+ OriginalRequest: replayPayload,
+ })
+ if errCont != nil {
+ t.Fatalf("ExecuteStream(replay) error = %v", errCont)
+ }
+ for chunk := range contStream.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("continuation stream chunk error = %v", chunk.Err)
+ }
+ }
+
+ lastSeen := seenBodies[len(seenBodies)-1]
+ if !bytes.Contains(lastSeen, []byte("Summary retaining CPA_COMPACTION_MARKER_42.")) {
+ t.Fatalf("expected expanded summary in continuation upstream request, got: %s", lastSeen)
+ }
+ if bytes.Contains(lastSeen, []byte("foreign-opaque-token")) {
+ t.Fatalf("expected foreign compaction item to be dropped, got: %s", lastSeen)
+ }
+
+ if _, errNonStreamReplay := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "muse-wonder-1.0",
+ Payload: replayPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ OriginalRequest: replayPayload,
+ }); errNonStreamReplay != nil {
+ t.Fatalf("Execute(replay) error = %v", errNonStreamReplay)
+ }
+ nonStreamReplaySeen := seenBodies[len(seenBodies)-1]
+ if !bytes.Contains(nonStreamReplaySeen, []byte("Summary retaining CPA_COMPACTION_MARKER_42.")) || bytes.Contains(nonStreamReplaySeen, []byte("foreign-opaque-token")) {
+ t.Fatalf("unexpected non-stream replay upstream body: %s", nonStreamReplaySeen)
+ }
+
+ strippedTriggerPayload := []byte(`{"model":"muse-wonder-1.0","stream":false,"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"Remember the exact test marker CPA_COMPACTION_MARKER_42."}]}]}`)
+ origOnlyResp, errOrigOnly := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "muse-wonder-1.0",
+ Payload: strippedTriggerPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ OriginalRequest: triggerPayload,
+ })
+ if errOrigOnly != nil {
+ t.Fatalf("Execute(OriginalRequest-only trigger) error = %v", errOrigOnly)
+ }
+ if got := gjson.GetBytes(origOnlyResp.Payload, "output.0.type").String(); got != "compaction" {
+ t.Fatalf("OriginalRequest-only trigger output[0].type = %q, want compaction", got)
+ }
+
+ corruptedPayload := []byte(`{
+ "model": "muse-wonder-1.0",
+ "stream": false,
+ "input": [
+ {"type": "compaction", "encrypted_content": "cpa-ag-compact-v1:not-valid"},
+ {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "continue"}]}
+ ]
+ }`)
+ _, errCorrupt := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "muse-wonder-1.0",
+ Payload: corruptedPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ OriginalRequest: corruptedPayload,
+ })
+ if errCorrupt == nil {
+ t.Fatal("expected error for corrupted compaction capsule, got nil")
+ }
+ assertStatusErr(t, errCorrupt, http.StatusBadRequest)
+
+ secondTriggerPayload := []byte(fmt.Sprintf(`{
+ "model": "muse-wonder-1.0",
+ "stream": false,
+ "input": [
+ {"type": "compaction", "id": %q, "encrypted_content": %q},
+ {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Second phase work."}]},
+ {"type": "compaction_trigger"}
+ ]
+ }`, outputItems[0].Get("id").String(), capsule))
+ secondResp, errSecond := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "muse-wonder-1.0",
+ Payload: secondTriggerPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ OriginalRequest: secondTriggerPayload,
+ })
+ if errSecond != nil {
+ t.Fatalf("Execute(second compaction_trigger) error = %v", errSecond)
+ }
+ if got := len(gjson.GetBytes(secondResp.Payload, "output").Array()); got != 1 {
+ t.Fatalf("second compaction output len = %d, want 1", got)
+ }
+ secondSeen := seenBodies[len(seenBodies)-1]
+ if !bytes.Contains(secondSeen, []byte("Summary retaining CPA_COMPACTION_MARKER_42.")) {
+ t.Fatalf("expected first capsule summary in second compaction request, got: %s", secondSeen)
+ }
+ if got := gjson.GetBytes(secondSeen, "max_output_tokens").Int(); got != 2048 {
+ t.Fatalf("max_output_tokens = %d, want payload rule 2048; body=%s", got, secondSeen)
+ }
+
+ returnIncomplete = true
+ _, errIncExec := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "muse-wonder-1.0",
+ Payload: triggerPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ OriginalRequest: triggerPayload,
+ })
+ if errIncExec == nil {
+ t.Fatal("expected Execute(compaction_trigger) to fail on incomplete summary, got nil")
+ }
+ assertStatusErr(t, errIncExec, http.StatusBadGateway)
+
+ _, errIncStream := exec.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "muse-wonder-1.0",
+ Payload: triggerPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ Stream: true,
+ OriginalRequest: triggerPayload,
+ })
+ if errIncStream == nil {
+ t.Fatal("expected ExecuteStream(compaction_trigger) to fail on incomplete summary, got nil")
+ }
+ assertStatusErr(t, errIncStream, http.StatusBadGateway)
+}
diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go
index b63f1258d75..fc8169ea1aa 100644
--- a/internal/runtime/executor/openai_compat_executor.go
+++ b/internal/runtime/executor/openai_compat_executor.go
@@ -90,13 +90,19 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
if endpointPath := openAICompatImageEndpointPath(opts); endpointPath != "" {
return e.executeImages(ctx, auth, req, opts, endpointPath)
}
+ baseURL, apiKey := e.resolveCredentials(auth)
+ compat := e.resolveCompatConfig(auth, req)
+ return e.executeWithCredentials(ctx, auth, req, opts, baseURL, apiKey, compat)
+}
+
+func (e *OpenAICompatExecutor) executeWithCredentials(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, baseURL, apiKey string, compat *config.OpenAICompatibility) (resp cliproxyexecutor.Response, err error) {
+ ctx = helps.EnsureSessionContext(ctx, opts, req.Payload)
baseModel := thinking.ParseSuffix(req.Model).ModelName
reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
- baseURL, apiKey := e.resolveCredentials(auth)
if baseURL == "" {
err = statusErr{code: http.StatusUnauthorized, msg: "missing provider baseURL"}
return
@@ -116,10 +122,13 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
}
originalPayload := originalPayloadSource
isCompat := helps.APIKeyModelIsCompat(req)
- originalTranslated, translated, updatesChanged, err := helps.TranslateRequestPairWithAPIKeyModelCompatibilityAndUpdateIntent(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, req.Payload, opts.Stream, isCompat)
+ targetModel := resolveResponsesSignatureTargetModel(baseModel, req, opts)
+ originalTranslated, translated, updatesChanged, err := helps.TranslateRequestPairWithAPIKeyModelCompatibilityAndUpdateIntent(ctx, opts.Headers, e.cfg, from, to, targetModel, originalPayload, req.Payload, opts.Stream, isCompat)
if err != nil {
return resp, err
}
+ originalTranslated = helps.SetStringIfDifferent(originalTranslated, "model", baseModel)
+ translated = helps.SetStringIfDifferent(translated, "model", baseModel)
translated, err = helps.ApplyRequestThinking(translated, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged)
if err != nil {
@@ -128,13 +137,13 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
- if helps.ShouldNormalizeOpenAIToolResultsForModel(e.resolveCompatConfig(auth, req), baseModel, requestedModel) {
+ if helps.ShouldNormalizeOpenAIToolResultsForModel(compat, baseModel, requestedModel) {
translated = helps.NormalizeOpenAIToolResultsTextOnly(translated)
}
if opts.Alt != "responses/compact" {
- useMCT := helps.ShouldUseMaxCompletionTokensForModel(e.resolveCompatConfig(auth, req), baseModel, requestedModel)
+ useMCT := helps.ShouldUseMaxCompletionTokensForModel(compat, baseModel, requestedModel)
translated = helps.NormalizeOpenAIMaxTokens(translated, useMCT)
- translated, err = e.applyPromptCacheKey(ctx, auth, from, baseModel, req, opts, translated)
+ translated, err = e.applyPromptCacheKeyWithCompat(ctx, auth, compat, from, baseModel, req, opts, translated)
if err != nil {
return resp, err
}
@@ -143,7 +152,7 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
if updated, errDelete := sjson.DeleteBytes(translated, "stream"); errDelete == nil {
translated = updated
}
- translated = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "openai compat executor", translated)
+ translated = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(ctx, "openai compat executor", translated, true, resolveResponsesSignatureTargetModel(baseModel, req, opts), baseURL)
}
reporter.SetTranslatedReasoningEffort(translated, to.String())
@@ -322,13 +331,19 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
if endpointPath := openAICompatImageEndpointPath(opts); endpointPath != "" {
return e.executeImagesStream(ctx, auth, req, opts, endpointPath)
}
+ baseURL, apiKey := e.resolveCredentials(auth)
+ compat := e.resolveCompatConfig(auth, req)
+ return e.executeStreamWithCredentials(ctx, auth, req, opts, baseURL, apiKey, compat)
+}
+
+func (e *OpenAICompatExecutor) executeStreamWithCredentials(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, baseURL, apiKey string, compat *config.OpenAICompatibility) (_ *cliproxyexecutor.StreamResult, err error) {
+ ctx = helps.EnsureSessionContext(ctx, opts, req.Payload)
baseModel := thinking.ParseSuffix(req.Model).ModelName
reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
- baseURL, apiKey := e.resolveCredentials(auth)
if baseURL == "" {
err = statusErr{code: http.StatusUnauthorized, msg: "missing provider baseURL"}
return nil, err
@@ -343,10 +358,13 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
}
originalPayload := originalPayloadSource
isCompat := helps.APIKeyModelIsCompat(req)
- originalTranslated, translated, updatesChanged, err := helps.TranslateRequestPairWithAPIKeyModelCompatibilityAndUpdateIntent(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, req.Payload, true, isCompat)
+ targetModel := resolveResponsesSignatureTargetModel(baseModel, req, opts)
+ originalTranslated, translated, updatesChanged, err := helps.TranslateRequestPairWithAPIKeyModelCompatibilityAndUpdateIntent(ctx, opts.Headers, e.cfg, from, to, targetModel, originalPayload, req.Payload, true, isCompat)
if err != nil {
return nil, err
}
+ originalTranslated = helps.SetStringIfDifferent(originalTranslated, "model", baseModel)
+ translated = helps.SetStringIfDifferent(translated, "model", baseModel)
translated, err = helps.ApplyRequestThinking(translated, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged)
if err != nil {
@@ -355,13 +373,13 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
- if helps.ShouldNormalizeOpenAIToolResultsForModel(e.resolveCompatConfig(auth, req), baseModel, requestedModel) {
+ if helps.ShouldNormalizeOpenAIToolResultsForModel(compat, baseModel, requestedModel) {
translated = helps.NormalizeOpenAIToolResultsTextOnly(translated)
}
if opts.Alt != "responses/compact" {
- useMCT := helps.ShouldUseMaxCompletionTokensForModel(e.resolveCompatConfig(auth, req), baseModel, requestedModel)
+ useMCT := helps.ShouldUseMaxCompletionTokensForModel(compat, baseModel, requestedModel)
translated = helps.NormalizeOpenAIMaxTokens(translated, useMCT)
- translated, err = e.applyPromptCacheKey(ctx, auth, from, baseModel, req, opts, translated)
+ translated, err = e.applyPromptCacheKeyWithCompat(ctx, auth, compat, from, baseModel, req, opts, translated)
if err != nil {
return nil, err
}
@@ -442,6 +460,7 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
helps.InitializeApplyPatchStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translated, ¶m)
var streamUsage helps.StreamUsageBuffer
var seenDone bool
+ var sawFinishReason bool
var streamFailed bool
var streamAborted bool
var upstreamEvent string
@@ -498,6 +517,9 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
publishStreamError(streamErr, true)
return true
}
+ if !sawFinishReason && openAICompatChunkHasFinishReason(dataPayload) {
+ sawFinishReason = true
+ }
}
// Observe complete upstream frames before translation or downstream backpressure.
@@ -586,12 +608,16 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
case <-ctx.Done():
}
} else if !seenDone {
- // Without a translator-confirmed terminal state, a clean Responses EOF
+ // Without a translator-confirmed terminal state, a clean Responses or Claude EOF
// without [DONE] remains a failed stream instead of completing it.
- if responseFormat == sdktranslator.FormatOpenAIResponse {
+ if responseFormat == sdktranslator.FormatOpenAIResponse || (responseFormat == sdktranslator.FormatClaude && !sawFinishReason) {
streamErr := statusErr{code: http.StatusBadGateway, msg: "upstream stream closed before [DONE]"}
helps.RecordAPIResponseError(ctx, e.cfg, streamErr)
- reporter.PublishFailure(ctx, streamErr)
+ if !helps.RecordApplyPatchStreamFailureWithUsage(ctx, param, reporter, &streamUsage, streamErr) {
+ if !streamUsage.PublishFailure(ctx, reporter, streamErr) {
+ reporter.PublishFailure(ctx, streamErr)
+ }
+ }
select {
case out <- cliproxyexecutor.StreamChunk{Err: streamErr}:
case <-ctx.Done():
@@ -933,7 +959,10 @@ func rewriteOpenAICompatImagesMultipartPayload(payload []byte, model string, bou
}
func (e *OpenAICompatExecutor) applyPromptCacheKey(ctx context.Context, auth *cliproxyauth.Auth, from sdktranslator.Format, baseModel string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, translated []byte) ([]byte, error) {
- compat := e.resolveCompatConfig(auth, req)
+ return e.applyPromptCacheKeyWithCompat(ctx, auth, e.resolveCompatConfig(auth, req), from, baseModel, req, opts, translated)
+}
+
+func (e *OpenAICompatExecutor) applyPromptCacheKeyWithCompat(ctx context.Context, auth *cliproxyauth.Auth, compat *config.OpenAICompatibility, from sdktranslator.Format, baseModel string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, translated []byte) ([]byte, error) {
if compat == nil || !compat.SupportPromptCacheKey {
return translated, nil
}
@@ -1071,6 +1100,15 @@ func openAICompatErrorEvent(eventName string) bool {
return strings.EqualFold(eventName, "error") || strings.EqualFold(eventName, "response.error") || strings.EqualFold(eventName, "response.failed")
}
+func openAICompatChunkHasFinishReason(payload []byte) bool {
+ for _, choice := range gjson.GetBytes(payload, "choices").Array() {
+ if reason := choice.Get("finish_reason"); reason.Exists() && reason.Type != gjson.Null && strings.TrimSpace(reason.String()) != "" {
+ return true
+ }
+ }
+ return false
+}
+
func openAICompatStreamDataError(payload []byte, eventName string) (statusErr, bool) {
if len(payload) == 0 || !json.Valid(payload) {
return statusErr{}, false
diff --git a/internal/runtime/executor/openai_responses_signature.go b/internal/runtime/executor/openai_responses_signature.go
index e6e03d99d39..59469dd32ce 100644
--- a/internal/runtime/executor/openai_responses_signature.go
+++ b/internal/runtime/executor/openai_responses_signature.go
@@ -3,11 +3,14 @@ package executor
import (
"context"
"fmt"
+ "net/url"
"strings"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@@ -48,19 +51,138 @@ func promoteOpenAIResponsesReasoningTextToSummary(itemRaw string, content gjson.
return sjson.SetRaw(itemRaw, "summary", b.String())
}
+type metaSignaturePolicy int
+
+const (
+ // metaSignatureForeign: target is not Meta; a recognized Meta envelope is foreign.
+ metaSignatureForeign metaSignaturePolicy = iota
+ // metaSignatureNativeOnly: native Meta upstream (api.meta.ai); only unsuffixed envelopes replay.
+ metaSignatureNativeOnly
+ // metaSignatureAllowRouted: Meta-family model served by a router (e.g. OpenRouter meta/muse-*); native or router-suffixed envelopes replay verbatim.
+ metaSignatureAllowRouted
+)
+
func sanitizeOpenAIResponsesReasoningEncryptedContent(ctx context.Context, provider string, body []byte) []byte {
- return sanitizeOpenAIResponsesReasoningEncryptedContentWithOptions(ctx, provider, body, false, false)
+ return sanitizeOpenAIResponsesReasoningEncryptedContentWithOptions(ctx, provider, body, false, false, metaSignatureForeign, signature.SignatureProviderUnknown, false)
}
func sanitizeOpenAIResponsesReasoningEncryptedContentWithCompat(ctx context.Context, provider string, body []byte, isCompat bool) []byte {
- return sanitizeOpenAIResponsesReasoningEncryptedContentWithOptions(ctx, provider, body, isCompat, isCompat)
+ return sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModel(ctx, provider, body, isCompat, "")
+}
+
+// sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModel is the
+// compat variant that knows its target model: a positively recognized Meta
+// Muse envelope is preserved whenever the upstream actually serves a
+// Meta-family model, whether or not the route is flagged compat (native
+// OpenAI never serves muse-* models, but a router key such as an OpenRouter
+// codex endpoint can serve meta/muse-* through the Codex executor without
+// isCompat). Router-suffixed envelopes replay verbatim there; the native
+// Meta executor keeps its own stricter native-only gate. For any other
+// target the recognized Meta envelope is known-foreign and stripped like
+// other providers'.
+// resolveResponsesSignatureTargetModel returns the canonical model name to use
+// for reasoning signature target checks, falling back to the client-requested
+// model when an upstream alias mapping hides the Meta Muse family name.
+func resolveResponsesSignatureTargetModel(baseModel string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) string {
+ if signature.SignatureProviderFromModelName(baseModel) != signature.SignatureProviderUnknown {
+ return baseModel
+ }
+ requested := thinking.ParseSuffix(helps.PayloadRequestedModel(opts, req.Model)).ModelName
+ switch signature.SignatureProviderFromModelName(requested) {
+ case signature.SignatureProviderMeta, signature.SignatureProviderGrok, signature.SignatureProviderGPT:
+ return requested
+ }
+ return baseModel
+}
+
+func resolveMetaSignatureTargetModel(baseModel string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) string {
+ targetModel := resolveResponsesSignatureTargetModel(baseModel, req, opts)
+ if signature.SignatureProviderFromModelName(targetModel) == signature.SignatureProviderMeta {
+ return targetModel
+ }
+ return "muse-" + baseModel
+}
+
+func sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModel(ctx context.Context, provider string, body []byte, isCompat bool, targetModel string) []byte {
+ return sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(ctx, provider, body, isCompat, targetModel, "")
+}
+
+func sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(ctx context.Context, provider string, body []byte, isCompat bool, targetModel string, baseURL string) []byte {
+ targetProvider := signature.SignatureProviderFromModelName(targetModel)
+ metaPolicy := metaSignatureForeign
+ if targetProvider == signature.SignatureProviderMeta {
+ if strings.TrimSpace(baseURL) != "" && isNativeMetaUpstreamURL(baseURL) {
+ metaPolicy = metaSignatureNativeOnly
+ } else {
+ metaPolicy = metaSignatureAllowRouted
+ }
+ }
+ return sanitizeOpenAIResponsesReasoningEncryptedContentWithOptions(ctx, provider, body, isCompat, isCompat, metaPolicy, targetProvider, isOpenRouterUpstreamURL(baseURL))
}
func sanitizeOpenAIResponsesReasoningEncryptedContentKeepForeign(ctx context.Context, provider string, body []byte) []byte {
- return sanitizeOpenAIResponsesReasoningEncryptedContentWithOptions(ctx, provider, body, false, true)
+ return sanitizeOpenAIResponsesReasoningEncryptedContentWithOptions(ctx, provider, body, false, true, metaSignatureForeign, signature.SignatureProviderUnknown, false)
+}
+
+// sanitizeOpenAIResponsesReasoningEncryptedContentForMeta is the Meta-target
+// variant and is deliberately native-only: only positively recognized Meta
+// Muse envelopes without a router suffix may replay to api.meta.ai, whose
+// suffix acceptance is unverified. GPT, other known-provider,
+// unknown-format, and router-suffixed values are all stripped, so a wrong
+// signature can never reach the Meta upstream through this path. A future
+// Meta envelope revision will be stripped until the inspector learns it
+// (fail-closed).
+func sanitizeOpenAIResponsesReasoningEncryptedContentForMeta(ctx context.Context, provider string, body []byte) []byte {
+ return sanitizeOpenAIResponsesReasoningEncryptedContentWithOptions(ctx, provider, body, false, true, metaSignatureNativeOnly, signature.SignatureProviderMeta, false)
}
-func sanitizeOpenAIResponsesReasoningEncryptedContentWithOptions(ctx context.Context, provider string, body []byte, isCompat bool, keepForeign bool) []byte {
+// sanitizeOpenAIResponsesReasoningEncryptedContentForMetaBaseURL gates Meta
+// reasoning signatures by whether baseURL is the native Meta upstream
+// (api.meta.ai, which accepts only unsuffixed envelopes) or a third-party
+// Responses provider configured under meta-api-key (which may emit and consume
+// router-suffixed Meta Muse envelopes verbatim when targeting OpenRouter, or
+// strip the OpenRouter suffix for other providers).
+func sanitizeOpenAIResponsesReasoningEncryptedContentForMetaBaseURL(ctx context.Context, provider string, body []byte, baseURL string) []byte {
+ if isNativeMetaUpstreamURL(baseURL) {
+ return sanitizeOpenAIResponsesReasoningEncryptedContentForMeta(ctx, provider, body)
+ }
+ return sanitizeOpenAIResponsesReasoningEncryptedContentWithOptions(ctx, provider, body, false, true, metaSignatureAllowRouted, signature.SignatureProviderMeta, isOpenRouterUpstreamURL(baseURL))
+}
+
+func isNativeMetaUpstreamURL(rawURL string) bool {
+ rawURL = strings.TrimSpace(rawURL)
+ if rawURL == "" {
+ return true
+ }
+ parsed, err := url.Parse(rawURL)
+ if err != nil {
+ return true
+ }
+ host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
+ return host == "" || host == "api.meta.ai" || strings.HasSuffix(host, ".meta.ai")
+}
+
+func isOpenRouterUpstreamURL(rawURL string) bool {
+ rawURL = strings.TrimSpace(rawURL)
+ if rawURL == "" {
+ return false
+ }
+ parsed, err := url.Parse(rawURL)
+ if err != nil {
+ return false
+ }
+ host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
+ return host == "openrouter.ai" || strings.HasSuffix(host, ".openrouter.ai")
+}
+
+func stripRouterSuffix(rawSignature, suffix string) string {
+ if suffix == "" || len(rawSignature) <= len(suffix)+1 {
+ return rawSignature
+ }
+ return rawSignature[:len(rawSignature)-len(suffix)-1]
+}
+
+func sanitizeOpenAIResponsesReasoningEncryptedContentWithOptions(ctx context.Context, provider string, body []byte, isCompat bool, keepForeign bool, metaPolicy metaSignaturePolicy, targetProvider signature.SignatureProvider, allowRouterSuffix bool) []byte {
inputResult := util.GetGJSONBytesNoCopy(body, "input")
if !inputResult.Exists() || !inputResult.IsArray() {
return body
@@ -170,18 +292,73 @@ func sanitizeOpenAIResponsesReasoningEncryptedContentWithOptions(ctx context.Con
}
reason := ""
+ rewrittenSignature := ""
switch encryptedContent.Type {
case gjson.String:
rawSignature := encryptedContent.String()
if rawSignature != strings.TrimSpace(rawSignature) {
reason = "encrypted_content has leading or trailing whitespace"
- } else if _, err := signature.InspectGPTReasoningSignature(rawSignature); err != nil {
- // When keepForeign is true (compat callers enable it via WithCompat),
- // third-party Responses models (such as Muse) expect their own
- // unknown-format encrypted_content to be replayed.
- if !keepForeign || rawSignature == "" || signature.DetectSignatureProvider(rawSignature) != signature.SignatureProviderUnknown {
+ } else if metaPolicy != metaSignatureForeign {
+ // Meta targets never reach the GPT-first gate: a GPT,
+ // foreign-provider, or unknown-format blob must not replay to
+ // Meta. Only a positively recognized Meta envelope survives,
+ // and the native Meta upstream additionally refuses the
+ // router suffix a router such as OpenRouter appends.
+ if info, err := signature.InspectMetaReasoningSignature(rawSignature); err != nil {
+ reason = err.Error()
+ } else if info.RouterSuffix != "" {
+ if metaPolicy == metaSignatureNativeOnly {
+ reason = "router-suffixed Meta reasoning signature is not accepted by the native Meta upstream"
+ } else if !allowRouterSuffix {
+ rewrittenSignature = stripRouterSuffix(rawSignature, info.RouterSuffix)
+ }
+ }
+ } else if targetProvider == signature.SignatureProviderUnknown || targetProvider == signature.SignatureProviderGPT {
+ candidate := rawSignature
+ hasGPTPrefix := false
+ if prefixedProvider, unprefixed, ok := signature.SplitSignatureProviderPrefix(rawSignature); ok && prefixedProvider == signature.SignatureProviderGPT {
+ candidate = unprefixed
+ hasGPTPrefix = true
+ }
+ if gptInfo, err := signature.InspectGPTReasoningSignature(candidate); err == nil {
+ if gptInfo.RouterSuffix != "" && !allowRouterSuffix {
+ rewrittenSignature = stripRouterSuffix(candidate, gptInfo.RouterSuffix)
+ } else if hasGPTPrefix {
+ rewrittenSignature = candidate
+ }
+ } else if !(keepForeign && rawSignature != "" && signature.DetectSignatureProvider(rawSignature) == signature.SignatureProviderUnknown) {
reason = err.Error()
}
+ } else {
+ // Target model belongs to a known non-GPT, non-Meta family (such as
+ // Grok or Gemini served through a Responses router like OpenRouter).
+ // Only preserve signatures that match that target family (or
+ // unknown-format blobs when keepForeign is enabled); foreign GPT or
+ // other known-provider envelopes are stripped.
+ keep := false
+ if rawSignature != "" {
+ switch targetProvider {
+ case signature.SignatureProviderGrok:
+ if grokInfo, errGrok := signature.InspectGrokEncryptedContent(rawSignature); errGrok == nil {
+ keep = true
+ if grokInfo.RouterSuffix != "" && !allowRouterSuffix {
+ rewrittenSignature = stripRouterSuffix(rawSignature, grokInfo.RouterSuffix)
+ }
+ }
+ case signature.SignatureProviderGemini, signature.SignatureProviderClaude, signature.SignatureProviderKimi, signature.SignatureProviderSWE:
+ var normalized string
+ normalized, keep = signature.CompatibleSignatureForProvider(targetProvider, rawSignature)
+ if keep && normalized != "" && normalized != rawSignature {
+ rewrittenSignature = normalized
+ }
+ }
+ if !keep && keepForeign {
+ keep = signature.DetectSignatureProvider(rawSignature) == signature.SignatureProviderUnknown
+ }
+ }
+ if !keep {
+ reason = fmt.Sprintf("reasoning encrypted_content is not compatible with target provider %s", targetProvider)
+ }
}
case gjson.Null:
reason = "encrypted_content is null"
@@ -189,6 +366,13 @@ func sanitizeOpenAIResponsesReasoningEncryptedContentWithOptions(ctx context.Con
reason = fmt.Sprintf("encrypted_content must be a string, got %s", encryptedContent.Type.String())
}
if reason == "" {
+ if rewrittenSignature != "" {
+ if updatedItem, errSet := sjson.Set(nextItem, "encrypted_content", rewrittenSignature); errSet == nil {
+ nextItem = updatedItem
+ changed = true
+ helps.LogWithRequestID(ctx).Debugf("%s: stripped router suffix from reasoning encrypted_content at input[%d] item_id=%q", provider, index, itemID)
+ }
+ }
if !changed {
keep(item.Raw)
continue
diff --git a/internal/runtime/executor/openai_responses_signature_test.go b/internal/runtime/executor/openai_responses_signature_test.go
index d27a3056d3b..15fb4f60793 100644
--- a/internal/runtime/executor/openai_responses_signature_test.go
+++ b/internal/runtime/executor/openai_responses_signature_test.go
@@ -6,6 +6,8 @@ import (
"strings"
"testing"
+ internalcache "github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
"github.com/tidwall/gjson"
)
@@ -28,6 +30,20 @@ func validOpenAIResponsesReasoningEncryptedContentForTest() string {
return base64.RawURLEncoding.EncodeToString(payload)
}
+// validMetaReasoningEncryptedContentForTest builds a shape-valid synthetic
+// Meta envelope: "Q-PaDgE" pins the observed five-byte header, and 1426
+// unpadded base64url chars decode to 45 + 1024 bytes. No captured signature
+// is embedded in tests.
+func validMetaReasoningEncryptedContentForTest() string {
+ return "Q-PaDgE" + strings.Repeat("A", 1419)
+}
+
+// routedMetaReasoningEncryptedContentForTest appends a synthetic
+// OpenRouter-style suffix: one '.' plus 67 alphanumeric characters.
+func routedMetaReasoningEncryptedContentForTest() string {
+ return validMetaReasoningEncryptedContentForTest() + "." + strings.Repeat("Ab3", 23)[:67]
+}
+
func TestSanitizeOpenAIResponsesReasoningEncryptedContent_StripsOrphanIDsWhenStoreDisabled(t *testing.T) {
valid := validOpenAIResponsesReasoningEncryptedContentForTest()
body := []byte(`{"store":false,"input":[` +
@@ -260,6 +276,272 @@ func TestSanitizeOpenAIResponsesReasoningEncryptedContentKeepForeign_Issue6450(t
}
}
+func TestSanitizeOpenAIResponsesReasoningEncryptedContentForMeta_PreservesRecognizedMuse(t *testing.T) {
+ // Synthetic shape-valid Meta envelope: "Q-PaDgE" pins the observed
+ // five-byte header, and 1426 unpadded base64url chars decode to
+ // 45 + 1024 bytes. No captured signature is embedded in tests.
+ metaSig := "Q-PaDgE" + strings.Repeat("A", 1419)
+ const unknownSig = "Q-PaDg-mock-muse-spark-encrypted-reasoning-payload"
+ body := []byte(`{"store":false,"input":[` +
+ `{"id":"rs_meta","type":"reasoning","summary":[],"content":null,"encrypted_content":"` + metaSig + `"},` +
+ `{"id":"rs_unknown","type":"reasoning","summary":[],"content":null,"encrypted_content":"` + unknownSig + `"},` +
+ `{"id":"rs_claude","type":"reasoning","summary":[],"content":null,"encrypted_content":"` + testClaudeCAISSample + `"} ` +
+ `]}`)
+
+ got := sanitizeOpenAIResponsesReasoningEncryptedContentForMeta(context.Background(), "test", body)
+
+ // The Meta target is native-only: the recognized Meta envelope survives,
+ // while unknown-format and known-foreign values are both stripped so no
+ // wrong signature can reach the Meta upstream.
+ if gotSig := gjson.GetBytes(got, "input.0.encrypted_content").String(); gotSig != metaSig {
+ t.Fatalf("recognized Meta encrypted_content not preserved; body=%s", got)
+ }
+ if gjson.GetBytes(got, "input.1.encrypted_content").Exists() {
+ t.Fatalf("unknown-format encrypted_content should be stripped on Meta target: %s", got)
+ }
+ if gjson.GetBytes(got, "input.2.encrypted_content").Exists() {
+ t.Fatalf("Claude signature should be stripped on Meta target: %s", got)
+ }
+
+ // The generic keepForeign path has no Meta allowance: once Muse is a
+ // positively recognized family, its envelope is known-foreign there and
+ // must be stripped instead of riding the unknown-format exemption.
+ generic := sanitizeOpenAIResponsesReasoningEncryptedContentKeepForeign(context.Background(), "test", body)
+ if gjson.GetBytes(generic, "input.0.encrypted_content").Exists() {
+ t.Fatalf("recognized Meta signature should be stripped by generic keepForeign: %s", generic)
+ }
+ if gotSig := gjson.GetBytes(generic, "input.1.encrypted_content").String(); gotSig != unknownSig {
+ t.Fatalf("unknown-format encrypted_content should remain preserved by generic keepForeign; body=%s", generic)
+ }
+
+ // The compat path preserves a recognized Meta envelope only when its
+ // target model is itself a Meta-family model (cascaded/third-party
+ // compat endpoints serving muse-*); other compat targets strip it.
+ compatMeta := sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModel(context.Background(), "test", body, true, "muse-spark-1.3")
+ if gotSig := gjson.GetBytes(compatMeta, "input.0.encrypted_content").String(); gotSig != metaSig {
+ t.Fatalf("compat Meta-model target should preserve recognized Meta signature; body=%s", compatMeta)
+ }
+ if gjson.GetBytes(compatMeta, "input.1.encrypted_content").Exists() {
+ t.Fatalf("compat Meta-model target should strip unknown-format signature: %s", compatMeta)
+ }
+ compatOther := sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModel(context.Background(), "test", body, true, "deepseek-v4")
+ if gjson.GetBytes(compatOther, "input.0.encrypted_content").Exists() {
+ t.Fatalf("compat non-Meta target should strip recognized Meta signature: %s", compatOther)
+ }
+}
+
+func TestSanitizeOpenAIResponsesReasoningEncryptedContentForMeta_StripsRouterSuffixed(t *testing.T) {
+ native := validMetaReasoningEncryptedContentForTest()
+ routed := routedMetaReasoningEncryptedContentForTest()
+ body := []byte(`{"store":false,"input":[` +
+ `{"type":"reasoning","summary":[],"encrypted_content":"` + native + `"},` +
+ `{"type":"reasoning","summary":[],"encrypted_content":"` + routed + `"}` +
+ `]}`)
+
+ got := sanitizeOpenAIResponsesReasoningEncryptedContentForMeta(context.Background(), "test", body)
+
+ // The native Meta upstream is fail-closed on the router suffix: the
+ // unsuffixed envelope survives while the routed form is stripped.
+ if gotSig := gjson.GetBytes(got, "input.0.encrypted_content").String(); gotSig != native {
+ t.Fatalf("native Meta encrypted_content not preserved; body=%s", got)
+ }
+ if gjson.GetBytes(got, "input.1.encrypted_content").Exists() {
+ t.Fatalf("router-suffixed Meta encrypted_content should be stripped on native Meta target: %s", got)
+ }
+}
+
+func TestSanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModel_RoutedMetaTarget(t *testing.T) {
+ native := validMetaReasoningEncryptedContentForTest()
+ routed := routedMetaReasoningEncryptedContentForTest()
+ gptSig := validOpenAIResponsesReasoningEncryptedContentForTest()
+ const unknownSig = "opaque-encrypted-reasoning-token-xyz"
+ buildBody := func() []byte {
+ return []byte(`{"store":false,"input":[` +
+ `{"type":"reasoning","summary":[],"encrypted_content":"` + native + `"},` +
+ `{"type":"reasoning","summary":[],"encrypted_content":"` + routed + `"},` +
+ `{"type":"reasoning","summary":[],"encrypted_content":"` + gptSig + `"},` +
+ `{"type":"reasoning","summary":[],"encrypted_content":"` + unknownSig + `"},` +
+ `{"type":"reasoning","summary":[],"encrypted_content":"` + testClaudeCAISSample + `"}` +
+ `]}`)
+ }
+ assertRoutedTarget := func(t *testing.T, got []byte) {
+ t.Helper()
+ if gotSig := gjson.GetBytes(got, "input.0.encrypted_content").String(); gotSig != native {
+ t.Fatalf("native Meta encrypted_content not preserved; body=%s", got)
+ }
+ if gotSig := gjson.GetBytes(got, "input.1.encrypted_content").String(); gotSig != routed {
+ t.Fatalf("routed Meta encrypted_content = %q, want verbatim; body=%s", gotSig, got)
+ }
+ for index, item := range gjson.GetBytes(got, "input").Array() {
+ if index < 2 {
+ continue
+ }
+ if item.Get("encrypted_content").Exists() {
+ t.Fatalf("input[%d] foreign encrypted_content should be stripped on Meta target; body=%s", index, got)
+ }
+ }
+ }
+
+ // A Meta-family model name on an OpenRouter baseURL preserves routed
+ // envelopes verbatim regardless of the compat flag, while non-OpenRouter
+ // third-party baseURLs strip OpenRouter's private suffix down to the
+ // native Meta envelope.
+ for _, isCompat := range []bool{false, true} {
+ got := sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(context.Background(), "test", buildBody(), isCompat, "meta/muse-wonder", "https://openrouter.ai/api/v1")
+ assertRoutedTarget(t, got)
+ }
+
+ // Namespaced router model IDs resolve to their Meta tail segment.
+ got := sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(context.Background(), "test", buildBody(), false, "openrouter/meta/muse-wonder", "https://openrouter.ai/api/v1")
+ assertRoutedTarget(t, got)
+
+ otherProviderMeta := sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(context.Background(), "test", buildBody(), false, "meta/muse-wonder", "https://example-proxy.internal/v1")
+ if gotSig := gjson.GetBytes(otherProviderMeta, "input.1.encrypted_content").String(); gotSig != native {
+ t.Fatalf("non-OpenRouter provider should strip OpenRouter suffix to native Meta envelope, got %q want %q", gotSig, native)
+ }
+
+ // OpenRouter Meta executor baseURL preserves routed Meta envelopes while
+ // native api.meta.ai drops router-suffixed envelopes completely.
+ routedMetaURL := sanitizeOpenAIResponsesReasoningEncryptedContentForMetaBaseURL(context.Background(), "test", buildBody(), "https://openrouter.ai/api/v1")
+ assertRoutedTarget(t, routedMetaURL)
+
+ nativeMetaURL := sanitizeOpenAIResponsesReasoningEncryptedContentForMetaBaseURL(context.Background(), "test", buildBody(), "https://api.meta.ai/api/v1")
+ if gotSig := gjson.GetBytes(nativeMetaURL, "input.0.encrypted_content").String(); gotSig != native {
+ t.Fatalf("native Meta URL should preserve native envelope; body=%s", nativeMetaURL)
+ }
+ if gjson.GetBytes(nativeMetaURL, "input.1.encrypted_content").Exists() {
+ t.Fatalf("native Meta URL should strip router-suffixed envelope; body=%s", nativeMetaURL)
+ }
+}
+
+func TestSanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModel_StripsMetaForNonMetaTargets(t *testing.T) {
+ native := validMetaReasoningEncryptedContentForTest()
+ routed := routedMetaReasoningEncryptedContentForTest()
+ body := []byte(`{"store":false,"input":[` +
+ `{"type":"reasoning","summary":[],"encrypted_content":"` + native + `"},` +
+ `{"type":"reasoning","summary":[],"encrypted_content":"` + routed + `"}` +
+ `]}`)
+
+ for _, tc := range []struct {
+ model string
+ isCompat bool
+ }{
+ {"gpt-5.4", false},
+ {"deepseek-v4", true},
+ } {
+ got := sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModel(context.Background(), "test", body, tc.isCompat, tc.model)
+ for index, item := range gjson.GetBytes(got, "input").Array() {
+ if item.Get("encrypted_content").Exists() {
+ t.Fatalf("model=%s isCompat=%v: input[%d] Meta encrypted_content should be stripped; body=%s", tc.model, tc.isCompat, index, got)
+ }
+ }
+ }
+}
+
+func TestSanitizeOpenAIResponsesReasoningEncryptedContent_StripsRouterSuffixOnNonOpenRouterUpstreams(t *testing.T) {
+ gptNative := validOpenAIResponsesReasoningEncryptedContentForTest()
+ suffix := strings.Repeat("Ab3", 27)[:79]
+ gptRouted := gptNative + "." + suffix
+
+ body := []byte(`{"store":false,"input":[{"type":"reasoning","summary":[],"encrypted_content":"` + gptRouted + `"}]}`)
+
+ // Official Codex / non-OpenRouter upstreams must strip the private OpenRouter suffix.
+ gotOfficial := sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(
+ context.Background(),
+ "test",
+ body,
+ false,
+ "gpt-5.4",
+ "https://chatgpt.com/backend-api/codex",
+ )
+ if gotSig := gjson.GetBytes(gotOfficial, "input.0.encrypted_content").String(); gotSig != gptNative {
+ t.Fatalf("official Codex encrypted_content = %q, want stripped native %q", gotSig, gptNative)
+ }
+
+ // OpenRouter upstream preserves its router suffix verbatim.
+ gotOpenRouter := sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(
+ context.Background(),
+ "test",
+ body,
+ false,
+ "openrouter/openai/o4-mini",
+ "https://openrouter.ai/api/v1",
+ )
+ if gotSig := gjson.GetBytes(gotOpenRouter, "input.0.encrypted_content").String(); gotSig != gptRouted {
+ t.Fatalf("OpenRouter encrypted_content = %q, want verbatim %q", gotSig, gptRouted)
+ }
+
+ // Foreign GPT encrypted_content must be stripped when targeting a non-GPT family (e.g. Grok or Gemini on OpenRouter).
+ for _, model := range []string{"openrouter/x-ai/grok-4.7", "openrouter/google/gemini-3.8-flash"} {
+ gotForeign := sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(
+ context.Background(),
+ "test",
+ body,
+ true,
+ model,
+ "https://openrouter.ai/api/v1",
+ )
+ if gjson.GetBytes(gotForeign, "input.0.encrypted_content").Exists() {
+ t.Fatalf("model=%s: foreign GPT encrypted_content should be stripped; body=%s", model, gotForeign)
+ }
+ }
+
+ // Cache-prefixed compatible signatures (e.g. "swe#sealed.v1.sig" or "gpt#...")
+ // must be rewritten to their normalized unprefixed form in encrypted_content.
+ prefixedSWEBody := []byte(`{"store":false,"input":[{"type":"reasoning","summary":[],"encrypted_content":"swe#sealed.v1.token"}]}`)
+ gotSWE := sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(
+ context.Background(),
+ "test",
+ prefixedSWEBody,
+ true,
+ "devin/swe-2",
+ "https://openrouter.ai/api/v1",
+ )
+ if gotSig := gjson.GetBytes(gotSWE, "input.0.encrypted_content").String(); gotSig != "sealed.v1.token" {
+ t.Fatalf("prefixed SWE encrypted_content = %q, want normalized %q", gotSig, "sealed.v1.token")
+ }
+
+ // Gemini carrier wrapping a Meta signature must never be misclassified as a
+ // Devin sealed signature.
+ metaCarrier := base64.StdEncoding.EncodeToString([]byte("cpa-codex-gemini-carrier-v1:Q-PaDgE" + strings.Repeat("A", 1419)))
+ if sigBytes, sigType := parseSignatureBytes(metaCarrier); len(sigBytes) != 0 || sigType != "" {
+ t.Fatalf("parseSignatureBytes(metaCarrier) = (%q, %q), want empty", sigBytes, sigType)
+ }
+
+ // Replay cache backfill + channel switch: a routed signature cached from
+ // OpenRouter is preserved on OpenRouter and stripped to native on official Codex.
+ internalcache.ClearCodexReasoningReplayCache()
+ t.Cleanup(internalcache.ClearCodexReasoningReplayCache)
+ const sessionKey = "execution:test-channel-switch"
+ internalcache.CacheCodexReasoningReplayItem("o4-mini", sessionKey, []byte(`{"type":"reasoning","summary":[],"encrypted_content":"`+gptRouted+`"}`))
+ replayReq := cliproxyexecutor.Request{
+ Model: "o4-mini",
+ Payload: []byte(`{"model":"o4-mini","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"next"}]}]}`),
+ }
+ replayOpts := cliproxyexecutor.Options{
+ SourceFormat: "claude",
+ Metadata: map[string]any{
+ cliproxyexecutor.ExecutionSessionMetadataKey: "test-channel-switch",
+ },
+ }
+ injected, _, errReplay := applyCodexReasoningReplayCacheRequired(context.Background(), "claude", replayReq, replayOpts, replayReq.Payload)
+ if errReplay != nil {
+ t.Fatalf("applyCodexReasoningReplayCacheRequired error = %v", errReplay)
+ }
+ sanitizedOpenRouter := sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(
+ context.Background(), "test", injected, false, "o4-mini", "https://openrouter.ai/api/v1",
+ )
+ if gotSig := gjson.GetBytes(sanitizedOpenRouter, "input.0.encrypted_content").String(); gotSig != gptRouted {
+ t.Fatalf("replay cache -> OpenRouter encrypted_content = %q, want routed %q", gotSig, gptRouted)
+ }
+ sanitizedOfficial := sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(
+ context.Background(), "test", injected, false, "o4-mini", "https://chatgpt.com/backend-api/codex",
+ )
+ if gotSig := gjson.GetBytes(sanitizedOfficial, "input.0.encrypted_content").String(); gotSig != gptNative {
+ t.Fatalf("replay cache -> official Codex encrypted_content = %q, want stripped native %q", gotSig, gptNative)
+ }
+}
+
func BenchmarkSanitizeOpenAIResponsesReasoningEncryptedContentLargeNoopPayload(b *testing.B) {
body := []byte(`{"store":false,"input":[{"type":"message","role":"user","content":"` + strings.Repeat("x", 8<<20) + `"}]}`)
b.ReportAllocs()
diff --git a/internal/runtime/executor/xai_executor_execute.go b/internal/runtime/executor/xai_executor_execute.go
index be546f86473..5d088aa0451 100644
--- a/internal/runtime/executor/xai_executor_execute.go
+++ b/internal/runtime/executor/xai_executor_execute.go
@@ -47,6 +47,7 @@ func (e *XAIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req
reporter.SetTranslatedReasoningEffort(prepared.body, e.Identifier())
url := strings.TrimSuffix(baseURL, "/") + "/responses"
+ prepared.body = sanitizeXAIInputEncryptedContentForBaseURL(prepared.body, baseURL)
prepared.body = prepared.finalizePayload(prepared.body)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(prepared.body))
if err != nil {
@@ -212,6 +213,7 @@ func (e *XAIExecutor) executeCompactRequest(ctx context.Context, auth *cliproxya
reporter.SetTranslatedReasoningEffort(prepared.body, e.Identifier())
requestURL := strings.TrimSuffix(baseURL, "/") + "/responses/compact"
+ prepared.body = sanitizeXAIInputEncryptedContentForBaseURL(prepared.body, baseURL)
prepared.body = prepared.finalizePayload(prepared.body)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewReader(prepared.body))
if err != nil {
diff --git a/internal/runtime/executor/xai_executor_request.go b/internal/runtime/executor/xai_executor_request.go
index 853d7b9dfc2..edbecb2724a 100644
--- a/internal/runtime/executor/xai_executor_request.go
+++ b/internal/runtime/executor/xai_executor_request.go
@@ -12,6 +12,7 @@ import (
"github.com/google/uuid"
xaiauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/xai"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
@@ -58,6 +59,13 @@ type xaiClientToolKey struct {
toolType string
}
+func resolveXAISignatureTargetModel(baseModel string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) string {
+ if resolved := resolveResponsesSignatureTargetModel(baseModel, req, opts); signature.SignatureProviderFromModelName(resolved) == signature.SignatureProviderGrok {
+ return resolved
+ }
+ return "grok"
+}
+
func (e *XAIExecutor) prepareResponsesRequest(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, stream bool) (*xaiPreparedRequest, error) {
return e.prepareResponsesRequestTo(ctx, req, opts, stream, sdktranslator.FormatCodex)
}
@@ -71,12 +79,15 @@ func (e *XAIExecutor) prepareResponsesRequestTo(ctx context.Context, req cliprox
originalPayloadSource = opts.OriginalRequest
}
originalPayload := bytes.Clone(originalPayloadSource)
- originalTranslated := helps.TranslateRequestWithAPIKeyModelCompatibilityForExecutor(ctx, opts.Headers, e.cfg, e.Identifier(), from, to, baseModel, originalPayload, stream, helps.APIKeyModelIsCompat(req))
+ targetModel := resolveXAISignatureTargetModel(baseModel, req, opts)
+ originalTranslated := helps.TranslateRequestWithAPIKeyModelCompatibilityForExecutor(ctx, opts.Headers, e.cfg, e.Identifier(), from, to, targetModel, originalPayload, stream, helps.APIKeyModelIsCompat(req))
+ originalTranslated = helps.SetStringIfDifferent(originalTranslated, "model", baseModel)
originalTranslated = preserveXAIResponsesOutputControls(originalTranslated, originalPayload, from)
- body, updatesChanged, err := helps.TranslateRequestWithAPIKeyModelCompatibilityAndUpdateIntentForExecutor(ctx, opts.Headers, e.cfg, e.Identifier(), from, to, baseModel, bytes.Clone(req.Payload), stream, helps.APIKeyModelIsCompat(req))
+ body, updatesChanged, err := helps.TranslateRequestWithAPIKeyModelCompatibilityAndUpdateIntentForExecutor(ctx, opts.Headers, e.cfg, e.Identifier(), from, to, targetModel, bytes.Clone(req.Payload), stream, helps.APIKeyModelIsCompat(req))
if err != nil {
return nil, err
}
+ body = helps.SetStringIfDifferent(body, "model", baseModel)
body = preserveXAIResponsesOutputControls(body, req.Payload, from)
body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), e.Identifier(), e.Identifier(), updatesChanged)
@@ -143,7 +154,7 @@ func (e *XAIExecutor) prepareResponsesRequestTo(ctx context.Context, req cliprox
body = aliasXAIClientWebSearchInput(body, webSearchAlias, namespaceTools)
}
body = normalizeXAIInputReasoningItems(body)
- body = sanitizeXAIInputEncryptedContent(body)
+ body = sanitizeXAIInputEncryptedContentWithOptions(body, true)
body = normalizeCodexInstructions(body)
// stop is supported by Chat Completions but not by xAI's Responses API.
// Thinking was handled before payload overrides and must not be revalidated here.
@@ -1571,6 +1582,29 @@ func normalizeXAINamespaceToolChoiceWithFold(body []byte, shouldFold bool) []byt
return body
}
+// xaiUnsupportedWebSearchFields are OpenAI Responses web_search options that the
+// xAI Responses API rejects with "Argument not supported". Options xAI accepts,
+// such as filters, user_location and search_content_types, are forwarded as is.
+var xaiUnsupportedWebSearchFields = [...]string{"external_web_access", "search_context_size"}
+
+// stripUnsupportedXAIWebSearchFields removes web_search options that xAI rejects
+// and reports which ones were removed.
+func stripUnsupportedXAIWebSearchFields(raw []byte) ([]byte, []string, bool) {
+ var removed []string
+ for _, field := range xaiUnsupportedWebSearchFields {
+ if !gjson.GetBytes(raw, field).Exists() {
+ continue
+ }
+ updated, errDel := sjson.DeleteBytes(raw, field)
+ if errDel != nil {
+ return nil, nil, false
+ }
+ raw = updated
+ removed = append(removed, field)
+ }
+ return raw, removed, true
+}
+
func normalizeXAITool(tool gjson.Result, namespaceName string, keepImageGeneration bool) ([]byte, bool, bool) {
toolType := tool.Get("type").String()
changed := false
@@ -1620,13 +1654,16 @@ func normalizeXAITool(tool gjson.Result, namespaceName string, keepImageGenerati
toolType = xaiFunctionToolType
changed = true
}
- if toolType == xaiWebSearchToolType && tool.Get("external_web_access").Exists() {
- updatedTool, errDel := sjson.DeleteBytes(raw, "external_web_access")
- if errDel != nil {
+ if toolType == xaiWebSearchToolType {
+ updatedTool, removed, ok := stripUnsupportedXAIWebSearchFields(raw)
+ if !ok {
return nil, false, false
}
- raw = updatedTool
- changed = true
+ if len(removed) > 0 {
+ raw = updatedTool
+ changed = true
+ log.Debugf("xai: removed unsupported web_search fields %v", removed)
+ }
}
if toolType == xaiFunctionToolType && !schemaTool.Get("parameters").Exists() {
updatedTool, errSet := sjson.SetRawBytes(raw, "parameters", []byte(`{"type":"object","properties":{}}`))
diff --git a/internal/runtime/executor/xai_executor_response.go b/internal/runtime/executor/xai_executor_response.go
index 11fe695b19d..64c0b984bc3 100644
--- a/internal/runtime/executor/xai_executor_response.go
+++ b/internal/runtime/executor/xai_executor_response.go
@@ -678,6 +678,14 @@ func xaiFunctionParametersNeedSimplification(tool gjson.Result, namespaceName st
}
func sanitizeXAIInputEncryptedContent(body []byte) []byte {
+ return sanitizeXAIInputEncryptedContentWithOptions(body, false)
+}
+
+func sanitizeXAIInputEncryptedContentForBaseURL(body []byte, baseURL string) []byte {
+ return sanitizeXAIInputEncryptedContentWithOptions(body, isOpenRouterUpstreamURL(baseURL))
+}
+
+func sanitizeXAIInputEncryptedContentWithOptions(body []byte, allowRouterSuffix bool) []byte {
input := gjson.GetBytes(body, "input")
if !input.Exists() || !input.IsArray() {
return body
@@ -699,10 +707,14 @@ func sanitizeXAIInputEncryptedContent(body []byte) []byte {
continue
}
reason := ""
+ rewrittenSig := ""
switch encryptedContent.Type {
case gjson.String:
- if _, err := signature.InspectGrokEncryptedContent(encryptedContent.String()); err != nil {
+ rawSig := encryptedContent.String()
+ if info, err := signature.InspectGrokEncryptedContent(rawSig); err != nil {
reason = err.Error()
+ } else if info.RouterSuffix != "" && !allowRouterSuffix {
+ rewrittenSig = stripRouterSuffix(rawSig, info.RouterSuffix)
}
case gjson.Null:
reason = "encrypted_content is null"
@@ -710,6 +722,13 @@ func sanitizeXAIInputEncryptedContent(body []byte) []byte {
reason = fmt.Sprintf("encrypted_content must be a string, got %s", encryptedContent.Type.String())
}
if reason == "" {
+ if rewrittenSig != "" {
+ if next, errSet := sjson.SetBytes([]byte(item.Raw), "encrypted_content", rewrittenSig); errSet == nil {
+ items = append(items, json.RawMessage(next))
+ changed = true
+ continue
+ }
+ }
items = append(items, json.RawMessage(item.Raw))
continue
}
diff --git a/internal/runtime/executor/xai_executor_stream.go b/internal/runtime/executor/xai_executor_stream.go
index f6aad351b49..69814311b09 100644
--- a/internal/runtime/executor/xai_executor_stream.go
+++ b/internal/runtime/executor/xai_executor_stream.go
@@ -41,6 +41,7 @@ func (e *XAIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth
reporter.SetTranslatedReasoningEffort(prepared.body, e.Identifier())
url := strings.TrimSuffix(baseURL, "/") + "/responses"
+ prepared.body = sanitizeXAIInputEncryptedContentForBaseURL(prepared.body, baseURL)
prepared.body = prepared.finalizePayload(prepared.body)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(prepared.body))
if err != nil {
diff --git a/internal/runtime/executor/xai_executor_test.go b/internal/runtime/executor/xai_executor_test.go
index daaa358d501..1ed1cbb6b6b 100644
--- a/internal/runtime/executor/xai_executor_test.go
+++ b/internal/runtime/executor/xai_executor_test.go
@@ -1157,6 +1157,56 @@ func TestXAIExecutorPrepareNormalizesClaudeWebSearchToolChoice_Grok46(t *testing
}
}
+func TestXAIExecutorPrepareStripsUnsupportedWebSearchOptions(t *testing.T) {
+ t.Parallel()
+
+ exec := NewXAIExecutor(&config.Config{})
+ prepared, errPrepare := exec.prepareResponsesRequest(context.Background(), cliproxyexecutor.Request{
+ Model: "grok-4.3",
+ Payload: []byte(`{
+ "model":"grok-4.3",
+ "input":[{"role":"user","content":"hello"}],
+ "tools":[
+ {"type":"web_search","external_web_access":true,"search_context_size":"high","search_content_types":["text","image"],"user_location":{"type":"approximate","country":"NL"},"filters":{"allowed_domains":["github.com"]}},
+ {"type":"namespace","name":"research","description":"Research tools.","tools":[{"type":"web_search","search_context_size":"low"}]}
+ ]
+ }`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ }, false)
+ if errPrepare != nil {
+ t.Fatalf("prepareResponsesRequest() error = %v", errPrepare)
+ }
+
+ webSearchTools := 0
+ for i, tool := range gjson.GetBytes(prepared.body, "tools").Array() {
+ if tool.Get("type").String() != "web_search" {
+ continue
+ }
+ webSearchTools++
+ for _, field := range []string{"external_web_access", "search_context_size"} {
+ if tool.Get(field).Exists() {
+ t.Fatalf("tools.%d.%s exists, want removed; body=%s", i, field, prepared.body)
+ }
+ }
+ if webSearchTools > 1 {
+ continue
+ }
+ if got := tool.Get("search_content_types.1").String(); got != "image" {
+ t.Fatalf("tools.%d.search_content_types.1 = %q, want image; body=%s", i, got, prepared.body)
+ }
+ if got := tool.Get("user_location.country").String(); got != "NL" {
+ t.Fatalf("tools.%d.user_location.country = %q, want NL; body=%s", i, got, prepared.body)
+ }
+ if got := tool.Get("filters.allowed_domains.0").String(); got != "github.com" {
+ t.Fatalf("tools.%d.filters.allowed_domains.0 = %q, want github.com; body=%s", i, got, prepared.body)
+ }
+ }
+ if webSearchTools == 0 {
+ t.Fatalf("web_search tool missing from prepared request; body=%s", prepared.body)
+ }
+}
+
func TestPruneXAIOrphanedToolChoice(t *testing.T) {
t.Parallel()
@@ -5219,6 +5269,18 @@ func TestSanitizeXAIInputEncryptedContent_PreservesValidBlob(t *testing.T) {
if gotEnc := gjson.GetBytes(got, "input.0.encrypted_content").String(); gotEnc != sample {
t.Fatalf("valid encrypted_content should be preserved, got %q", gotEnc)
}
+
+ routedSample := sample + "." + strings.Repeat("Ab3", 21)[:62]
+ routedBody, _ := sjson.SetBytes(body, "input.0.encrypted_content", routedSample)
+ gotRouted := sanitizeXAIInputEncryptedContent(routedBody)
+ if gotEnc := gjson.GetBytes(gotRouted, "input.0.encrypted_content").String(); gotEnc != sample {
+ t.Fatalf("router suffix should be stripped for native xAI upstream, got %q want %q", gotEnc, sample)
+ }
+
+ gotOpenRouter := sanitizeXAIInputEncryptedContentForBaseURL(routedBody, "https://openrouter.ai/api/v1")
+ if gotEnc := gjson.GetBytes(gotOpenRouter, "input.0.encrypted_content").String(); gotEnc != routedSample {
+ t.Fatalf("router suffix should be preserved for OpenRouter xAI upstream, got %q want %q", gotEnc, routedSample)
+ }
}
func TestXAIExecutorReMergesReasoningAfterDroppingInvalidEncryptedContent(t *testing.T) {
diff --git a/internal/runtime/executor/xai_reasoning_replay.go b/internal/runtime/executor/xai_reasoning_replay.go
index e8e5b2cbdb9..3cb31650c51 100644
--- a/internal/runtime/executor/xai_reasoning_replay.go
+++ b/internal/runtime/executor/xai_reasoning_replay.go
@@ -8,6 +8,7 @@ import (
internalcache "github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
@@ -93,8 +94,17 @@ func xaiReasoningReplayEnabledForSource(from sdktranslator.Format) bool {
sourceFormatEqual(from, sdktranslator.FormatOpenAIResponse)
}
+func canonicalXAIReasoningReplaySignatureKey(rawSignature string) string {
+ sig := signature.SignaturePayloadWithoutProviderPrefix(strings.TrimSpace(rawSignature))
+ if info, err := signature.InspectGrokEncryptedContent(sig); err == nil && info.RouterSuffix != "" {
+ return stripRouterSuffix(sig, info.RouterSuffix)
+ }
+ return sig
+}
+
func xaiInputHasReasoningEncryptedContent(inputItems []gjson.Result, encryptedContent string) bool {
- if encryptedContent == "" {
+ canonicalTarget := canonicalXAIReasoningReplaySignatureKey(encryptedContent)
+ if canonicalTarget == "" {
return false
}
for _, item := range inputItems {
@@ -105,7 +115,7 @@ func xaiInputHasReasoningEncryptedContent(inputItems []gjson.Result, encryptedCo
if inputEncryptedContent.Type != gjson.String {
continue
}
- if inputEncryptedContent.String() == encryptedContent {
+ if canonicalXAIReasoningReplaySignatureKey(inputEncryptedContent.String()) == canonicalTarget {
return true
}
}
diff --git a/internal/runtime/executor/xai_websockets_executor.go b/internal/runtime/executor/xai_websockets_executor.go
index 6fa16db39c9..ba7a54e7c21 100644
--- a/internal/runtime/executor/xai_websockets_executor.go
+++ b/internal/runtime/executor/xai_websockets_executor.go
@@ -519,6 +519,7 @@ func (e *XAIWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *cliprox
}
reporter.SetTranslatedReasoningEffort(prepared.body, e.Identifier())
+ prepared.body = sanitizeXAIInputEncryptedContentForBaseURL(prepared.body, baseURL)
wsHeaders := applyXAIWebsocketHeaders(ctx, http.Header{}, auth, token, prepared.sessionID, opts.Headers)
wsReqBody := buildXAIWebsocketRequestBody(prepared.body, prepared.finalizePayload)
requestType := strings.TrimSpace(gjson.GetBytes(req.Payload, "type").String())
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/internal/signature/gpt_validation.go b/internal/signature/gpt_validation.go
index a0964275a22..e69da4b8462 100644
--- a/internal/signature/gpt_validation.go
+++ b/internal/signature/gpt_validation.go
@@ -12,6 +12,9 @@ const MaxGPTReasoningSignatureLen = 32 * 1024 * 1024
type GPTReasoningSignatureInfo struct {
DecodedLen int
CiphertextLen int
+ // RouterSuffix is the dot-delimited suffix a router such as OpenRouter
+ // appends after the native Fernet envelope; empty for native values.
+ RouterSuffix string
}
func IsValidGPTReasoningSignature(rawSignature string) bool {
@@ -38,11 +41,20 @@ func InspectGPTReasoningSignature(rawSignature string) (*GPTReasoningSignatureIn
if !strings.HasPrefix(sig, "gAAAA") {
return nil, fmt.Errorf("invalid GPT reasoning signature: expected gAAAA prefix")
}
- if index, r, ok := firstInvalidGPTReasoningSignatureChar(sig); ok {
+ native := sig
+ routerSuffix := ""
+ if head, tail, ok := strings.Cut(sig, "."); ok {
+ if !isValidMetaRouterSuffix(tail) {
+ return nil, fmt.Errorf("invalid GPT reasoning signature: router suffix must be 1-%d alphanumeric characters", maxMetaRouterSuffixLen)
+ }
+ native = head
+ routerSuffix = tail
+ }
+ if index, r, ok := firstInvalidGPTReasoningSignatureChar(native); ok {
return nil, fmt.Errorf("invalid GPT reasoning signature: contains non-base64url character U+%04X at byte %d", r, index)
}
- decoded, err := decodeGPTReasoningSignature(sig)
+ decoded, err := decodeGPTReasoningSignature(native)
if err != nil {
return nil, err
}
@@ -61,6 +73,7 @@ func InspectGPTReasoningSignature(rawSignature string) (*GPTReasoningSignatureIn
return &GPTReasoningSignatureInfo{
DecodedLen: len(decoded),
CiphertextLen: ciphertextLen,
+ RouterSuffix: routerSuffix,
}, nil
}
diff --git a/internal/signature/gpt_validation_test.go b/internal/signature/gpt_validation_test.go
index 21befa8285f..e1e19ca81a3 100644
--- a/internal/signature/gpt_validation_test.go
+++ b/internal/signature/gpt_validation_test.go
@@ -33,3 +33,30 @@ func TestInspectGPTReasoningSignatureRejectsUnicodeEllipsis(t *testing.T) {
t.Fatalf("error = %q, want U+2026 base64url detail", err.Error())
}
}
+
+func TestInspectGPTReasoningSignature_RouterSuffixed(t *testing.T) {
+ native := testGPTReasoningSignature()
+ suffix := strings.Repeat("Ab3", 27)[:79]
+ routed := native + "." + suffix
+
+ info, err := InspectGPTReasoningSignature(routed)
+ if err != nil {
+ t.Fatalf("InspectGPTReasoningSignature(routed) error = %v", err)
+ }
+ if info.RouterSuffix != suffix {
+ t.Fatalf("RouterSuffix = %q, want %q", info.RouterSuffix, suffix)
+ }
+ if got := DetectSignatureProvider(routed); got != SignatureProviderGPT {
+ t.Fatalf("DetectSignatureProvider(routed) = %q, want %q", got, SignatureProviderGPT)
+ }
+ if got, ok := CompatibleSignatureForProvider(SignatureProviderGPT, routed); !ok || got != routed {
+ t.Fatalf("CompatibleSignatureForProvider(GPT, routed) = (%q, %v), want verbatim routed value", got, ok)
+ }
+
+ // Malformed router suffixes must be rejected.
+ for _, bad := range []string{native + ".", native + ".bad_char", native + ".a.b"} {
+ if _, err := InspectGPTReasoningSignature(bad); err == nil {
+ t.Fatalf("InspectGPTReasoningSignature(%q) unexpectedly succeeded", bad)
+ }
+ }
+}
diff --git a/internal/signature/grok_validation.go b/internal/signature/grok_validation.go
index 8b424ac3c71..60fdb67de31 100644
--- a/internal/signature/grok_validation.go
+++ b/internal/signature/grok_validation.go
@@ -29,6 +29,9 @@ const (
type GrokEncryptedContentInfo struct {
RawLen int
DecodedLen int
+ // RouterSuffix is the dot-delimited suffix a router such as OpenRouter
+ // appends after the native Grok ciphertext; empty for native values.
+ RouterSuffix string
}
// InspectGrokEncryptedContent validates the transport shape of xAI/Grok
@@ -56,12 +59,21 @@ func InspectGrokEncryptedContent(raw string) (*GrokEncryptedContentInfo, error)
if strings.Contains(sig, "=") {
return nil, fmt.Errorf("invalid Grok encrypted_content: expected unpadded standard base64")
}
- if index, r, ok := firstInvalidGrokEncryptedContentChar(sig); ok {
- return nil, fmt.Errorf("invalid Grok encrypted_content: contains non-base64 character U+%04X at byte %d", r, index)
- }
if _, _, ok := SplitSignatureProviderPrefix(sig); ok {
return nil, fmt.Errorf("invalid Grok encrypted_content: carries another provider's cache prefix")
}
+ native := sig
+ routerSuffix := ""
+ if head, tail, ok := strings.Cut(sig, "."); ok {
+ if !isValidMetaRouterSuffix(tail) {
+ return nil, fmt.Errorf("invalid Grok encrypted_content: router suffix must be 1-%d alphanumeric characters", maxMetaRouterSuffixLen)
+ }
+ native = head
+ routerSuffix = tail
+ }
+ if index, r, ok := firstInvalidGrokEncryptedContentChar(native); ok {
+ return nil, fmt.Errorf("invalid Grok encrypted_content: contains non-base64 character U+%04X at byte %d", r, index)
+ }
// Foreign-envelope rejection only has to run for the narrow set of base64
// first characters a self-describing envelope can produce. Native xAI
// ciphertext is uniformly distributed, so this skips the whole chain for
@@ -70,17 +82,20 @@ func InspectGrokEncryptedContent(raw string) (*GrokEncryptedContentInfo, error)
// particular is high-entropy standard base64 that drops its padding whenever
// the decoded length is a multiple of 3, so the padding gate above does not
// exclude it on its own.
- if maybeSelfDescribingSignatureEnvelope(sig) {
- if strings.HasPrefix(sig, "gAAAA") {
+ if maybeSelfDescribingSignatureEnvelope(native) {
+ if strings.HasPrefix(native, "gAAAA") {
return nil, fmt.Errorf("Grok encrypted_content looks like GPT/Codex reasoning signature")
}
- if IsValidClaudeThinkingSignature(sig, ClaudeSignatureValidationOptions{Strict: true}) {
+ if IsValidMetaReasoningSignature(native) {
+ return nil, fmt.Errorf("Grok encrypted_content looks like Meta reasoning signature")
+ }
+ if IsValidClaudeThinkingSignature(native, ClaudeSignatureValidationOptions{Strict: true}) {
return nil, fmt.Errorf("Grok encrypted_content looks like Claude thinking signature")
}
- if IsValidClaudeCAISSignature(sig) {
+ if IsValidClaudeCAISSignature(native) {
return nil, fmt.Errorf("Grok encrypted_content looks like Claude CAIS thinking signature")
}
- if _, err := InspectGeminiThoughtSignature(sig, GeminiThoughtSignatureValidationOptions{RequireKnownEnvelope: true}); err == nil {
+ if _, err := InspectGeminiThoughtSignature(native, GeminiThoughtSignatureValidationOptions{RequireKnownEnvelope: true}); err == nil {
return nil, fmt.Errorf("Grok encrypted_content looks like Gemini thoughtSignature")
}
}
@@ -90,14 +105,17 @@ func InspectGrokEncryptedContent(raw string) (*GrokEncryptedContentInfo, error)
// length tracks reasoning volume continuously at 1-byte granularity. Neither
// observed Kimi length appears anywhere in 1027 catalogued signatures or 215
// native Grok samples, so rejecting them here costs no real Grok traffic.
- if IsValidKimiThinkingSignature(sig) {
+ if IsValidKimiThinkingSignature(native) {
return nil, fmt.Errorf("Grok encrypted_content has a Kimi thinking signature length")
}
- decoded, err := decodeGrokEncryptedContent(sig)
+ decoded, err := decodeGrokEncryptedContent(native)
if err != nil {
return nil, err
}
+ if strings.HasPrefix(string(decoded), "cpa-codex-gemini-carrier-v1:") {
+ return nil, fmt.Errorf("invalid Grok encrypted_content: carries Gemini Codex thoughtSignature carrier")
+ }
if len(decoded) < MinGrokEncryptedContentDecodedLen {
return nil, fmt.Errorf("invalid Grok encrypted_content: decoded payload too short (%d bytes)", len(decoded))
}
@@ -105,8 +123,9 @@ func InspectGrokEncryptedContent(raw string) (*GrokEncryptedContentInfo, error)
return nil, fmt.Errorf("invalid Grok encrypted_content: decoded payload entropy ratio %.3f below %.3f", entropyRatio, MinGrokEncryptedContentEntropyRatio)
}
return &GrokEncryptedContentInfo{
- RawLen: len(sig),
- DecodedLen: len(decoded),
+ RawLen: len(sig),
+ DecodedLen: len(decoded),
+ RouterSuffix: routerSuffix,
}, nil
}
diff --git a/internal/signature/grok_validation_test.go b/internal/signature/grok_validation_test.go
index 1c9a2d72786..6474e1052e0 100644
--- a/internal/signature/grok_validation_test.go
+++ b/internal/signature/grok_validation_test.go
@@ -248,6 +248,32 @@ func TestByteEntropyRatio_SingleByteReturnsZero(t *testing.T) {
}
}
+func TestInspectGrokEncryptedContent_RouterSuffixed(t *testing.T) {
+ rawBytes := make([]byte, 135)
+ for i := range rawBytes {
+ rawBytes[i] = byte((i*73 + 19) & 0xff)
+ }
+ native := base64.RawStdEncoding.EncodeToString(rawBytes)
+ suffix := strings.Repeat("Ab3", 21)[:62]
+ routed := native + "." + suffix
+
+ info, err := InspectGrokEncryptedContent(routed)
+ if err != nil {
+ t.Fatalf("InspectGrokEncryptedContent(routed) error = %v", err)
+ }
+ if info.RouterSuffix != suffix || info.DecodedLen != len(rawBytes) {
+ t.Fatalf("info = %+v, want DecodedLen=%d RouterSuffix=%q", info, len(rawBytes), suffix)
+ }
+ if !IsRecognizedReasoningSignature(routed) {
+ t.Fatal("IsRecognizedReasoningSignature(routed Grok) = false, want true")
+ }
+ for _, bad := range []string{native + ".", native + ".bad-dash", native + ".a.b"} {
+ if _, err := InspectGrokEncryptedContent(bad); err == nil {
+ t.Fatalf("InspectGrokEncryptedContent(%q) unexpectedly succeeded", bad)
+ }
+ }
+}
+
func testGeminiThoughtSignatureEnvelope() string {
payload := []byte{0x01, 0x0c}
for i := 0; i < 97; i++ {
diff --git a/internal/signature/meta_validation.go b/internal/signature/meta_validation.go
new file mode 100644
index 00000000000..fe75192e069
--- /dev/null
+++ b/internal/signature/meta_validation.go
@@ -0,0 +1,174 @@
+package signature
+
+import (
+ "bytes"
+ "encoding/base64"
+ "fmt"
+ "strings"
+ "unicode/utf8"
+)
+
+// MaxMetaReasoningSignatureLen bounds the raw base64url text accepted by
+// InspectMetaReasoningSignature. It mirrors the GPT bound; the bucket-length
+// rule below is far more restrictive in practice.
+const MaxMetaReasoningSignatureLen = 32 * 1024 * 1024
+
+const (
+ metaReasoningSignatureOverheadLen = 45
+ metaReasoningSignatureMinPayload = 1024
+ // maxMetaRouterSuffixLen bounds the dot-delimited suffix a router such as
+ // OpenRouter appends after the native envelope. Observed values are well
+ // under this bound.
+ maxMetaRouterSuffixLen = 128
+)
+
+// metaReasoningSignatureHeader is the fixed five-byte prefix observed on every
+// captured Meta Muse reasoning encrypted_content value (api.meta.ai,
+// 2026-10-15 corpus: 82 values across nine muse-* models). Its semantics are
+// unresolved - it may be a format magic, a key/context fingerprint, or a
+// versioned identifier ending in 0x01 - and every capture came from one
+// account on one day, so a future rotation would fall back to Unknown rather
+// than being claimed here on weaker evidence. Cache-prefixed forms
+// ("meta#...") are intentionally not recognized: no producer emits them, so
+// there is no alias in SignatureProviderFromCachePrefix.
+var metaReasoningSignatureHeader = []byte{0x43, 0xe3, 0xda, 0x0e, 0x01}
+
+// MetaReasoningSignatureInfo describes the transport shape of a Meta Muse
+// reasoning encrypted_content envelope.
+type MetaReasoningSignatureInfo struct {
+ DecodedLen int
+ // PayloadLen is DecodedLen minus the fixed 45-byte envelope overhead. It
+ // is the power-of-two padded region, not a proven plaintext length.
+ PayloadLen int
+ BucketExponent int
+ // RouterSuffix is the dot-delimited suffix a router such as OpenRouter
+ // appends after the native envelope; empty for native values. Replay must
+ // forward the full value verbatim; never strip the suffix.
+ RouterSuffix string
+}
+
+func IsValidMetaReasoningSignature(rawSignature string) bool {
+ _, err := InspectMetaReasoningSignature(rawSignature)
+ return err == nil
+}
+
+// InspectMetaReasoningSignature validates the outer envelope shape observed
+// for Meta Muse reasoning encrypted_content: canonical unpadded base64url,
+// the fixed five-byte header above, and a decoded length of exactly
+// 45 + 1024*2^k bytes. A router such as OpenRouter may serve the same model
+// family and append one "." plus a short alphanumeric routing suffix to the
+// native value; that suffix is validated here and reported via
+// MetaReasoningSignatureInfo.RouterSuffix, and the full value must be
+// replayed verbatim. This is only a transport-shape check; it does not
+// prove decryptability or upstream replay acceptance.
+func InspectMetaReasoningSignature(rawSignature string) (*MetaReasoningSignatureInfo, error) {
+ sig := strings.TrimSpace(rawSignature)
+ if sig == "" {
+ return nil, fmt.Errorf("empty Meta reasoning signature")
+ }
+ if len(sig) > MaxMetaReasoningSignatureLen {
+ return nil, fmt.Errorf("Meta reasoning signature exceeds maximum length (%d bytes)", MaxMetaReasoningSignatureLen)
+ }
+ // Reject other providers' signatures before scanning for a router suffix.
+ if !strings.HasPrefix(sig, "Q-PaDg") {
+ return nil, fmt.Errorf("invalid Meta reasoning signature: expected Q-PaDg prefix")
+ }
+ native := sig
+ routerSuffix := ""
+ if head, tail, ok := strings.Cut(sig, "."); ok {
+ // The native alphabet never contains '.', so the first dot is an
+ // unambiguous boundary. The suffix is restricted to the observed
+ // router form: a single run of alphanumeric characters.
+ if !isValidMetaRouterSuffix(tail) {
+ return nil, fmt.Errorf("invalid Meta reasoning signature: router suffix must be 1-%d alphanumeric characters", maxMetaRouterSuffixLen)
+ }
+ native = head
+ routerSuffix = tail
+ }
+ info, err := inspectNativeMetaReasoningSignature(native)
+ if err != nil {
+ return nil, err
+ }
+ info.RouterSuffix = routerSuffix
+ return info, nil
+}
+
+// inspectNativeMetaReasoningSignature validates the native (unsuffixed)
+// envelope shape described on InspectMetaReasoningSignature.
+func inspectNativeMetaReasoningSignature(sig string) (*MetaReasoningSignatureInfo, error) {
+ // The literal prefix is the cheapest discriminator: every observed value
+ // starts with "Q-PaDg", and probing this validator is on the hot path for
+ // signatures of every provider.
+ if !strings.HasPrefix(sig, "Q-PaDg") {
+ return nil, fmt.Errorf("invalid Meta reasoning signature: expected Q-PaDg prefix")
+ }
+ // Reject by encoded length before scanning or allocating a decoded copy:
+ // only bucket-sized candidates ever reach the decoder.
+ if len(sig)%4 == 1 {
+ return nil, fmt.Errorf("invalid Meta reasoning signature: base64url length %d cannot decode", len(sig))
+ }
+ decodedLen := len(sig) / 4 * 3
+ switch len(sig) % 4 {
+ case 2:
+ decodedLen++
+ case 3:
+ decodedLen += 2
+ }
+ payloadLen := decodedLen - metaReasoningSignatureOverheadLen
+ if payloadLen < metaReasoningSignatureMinPayload || payloadLen&(payloadLen-1) != 0 {
+ return nil, fmt.Errorf("invalid Meta reasoning signature: payload length %d is not a power-of-two bucket", payloadLen)
+ }
+ if index, r, ok := firstInvalidMetaReasoningSignatureChar(sig); ok {
+ return nil, fmt.Errorf("invalid Meta reasoning signature: contains non-base64url character U+%04X at byte %d", r, index)
+ }
+ // Strict decoding rejects non-canonical trailing bits, so no re-encode
+ // comparison is needed to prove canonical form.
+ decoded, err := base64.RawURLEncoding.Strict().DecodeString(sig)
+ if err != nil {
+ return nil, fmt.Errorf("invalid Meta reasoning signature: base64url decode failed")
+ }
+ if !bytes.Equal(decoded[:len(metaReasoningSignatureHeader)], metaReasoningSignatureHeader) {
+ return nil, fmt.Errorf("invalid Meta reasoning signature: unexpected envelope header %x", decoded[:len(metaReasoningSignatureHeader)])
+ }
+ exponent := 0
+ for p := payloadLen; p > metaReasoningSignatureMinPayload; p >>= 1 {
+ exponent++
+ }
+ return &MetaReasoningSignatureInfo{
+ DecodedLen: len(decoded),
+ PayloadLen: payloadLen,
+ BucketExponent: exponent,
+ }, nil
+}
+
+// metaReasoningSignatureCharSet is the unpadded base64url alphabet. Observed
+// Meta values never carry '=' padding, so padding is rejected rather than
+// normalized away.
+var metaReasoningSignatureCharSet = base64AlphabetSet("-_")
+
+func firstInvalidMetaReasoningSignatureChar(sig string) (int, rune, bool) {
+ for index := 0; index < len(sig); index++ {
+ if !metaReasoningSignatureCharSet[sig[index]] {
+ r, _ := utf8.DecodeRuneInString(sig[index:])
+ return index, r, true
+ }
+ }
+ return 0, 0, false
+}
+
+// metaRouterSuffixCharSet is the alphanumeric-only alphabet observed on
+// router-appended suffixes; base64url's '-'/'_' and any second '.' are all
+// rejected so the suffix can never be mistaken for more envelope payload.
+var metaRouterSuffixCharSet = base64AlphabetSet("")
+
+func isValidMetaRouterSuffix(suffix string) bool {
+ if len(suffix) == 0 || len(suffix) > maxMetaRouterSuffixLen {
+ return false
+ }
+ for index := 0; index < len(suffix); index++ {
+ if !metaRouterSuffixCharSet[suffix[index]] {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/signature/meta_validation_test.go b/internal/signature/meta_validation_test.go
new file mode 100644
index 00000000000..29856c95bb1
--- /dev/null
+++ b/internal/signature/meta_validation_test.go
@@ -0,0 +1,248 @@
+package signature
+
+import (
+ "encoding/base64"
+ "strings"
+ "testing"
+)
+
+// testMetaReasoningSignature builds a synthetic Meta Muse envelope with the
+// observed fixed header and a power-of-two payload bucket. The body bytes are
+// deterministic filler; recognition is a transport-shape check, so no real
+// captured value is needed (or permitted) here.
+func testMetaReasoningSignature(payloadLen int) string {
+ return base64.RawURLEncoding.EncodeToString(testMetaReasoningSignatureBytes(payloadLen, nil))
+}
+
+func testMetaReasoningSignatureBytes(payloadLen int, mutate func([]byte)) []byte {
+ decoded := make([]byte, metaReasoningSignatureOverheadLen+payloadLen)
+ copy(decoded, metaReasoningSignatureHeader)
+ for i := len(metaReasoningSignatureHeader); i < len(decoded); i++ {
+ decoded[i] = byte(i*31 + 7)
+ }
+ if mutate != nil {
+ mutate(decoded)
+ }
+ return decoded
+}
+
+// testMetaRouterSuffix builds a synthetic router suffix in the observed
+// OpenRouter shape: a single run of 67 alphanumeric characters.
+func testMetaRouterSuffix() string {
+ return strings.Repeat("Ab3", 23)[:67]
+}
+
+func TestInspectMetaReasoningSignature_ValidBuckets(t *testing.T) {
+ for _, tc := range []struct {
+ payload int
+ exponent int
+ }{
+ {1024, 0},
+ {2048, 1},
+ {4096, 2},
+ {32768, 5},
+ } {
+ sig := testMetaReasoningSignature(tc.payload)
+ info, err := InspectMetaReasoningSignature(sig)
+ if err != nil {
+ t.Fatalf("payload %d: InspectMetaReasoningSignature error = %v", tc.payload, err)
+ }
+ if info.DecodedLen != tc.payload+metaReasoningSignatureOverheadLen || info.PayloadLen != tc.payload || info.BucketExponent != tc.exponent {
+ t.Fatalf("payload %d: info = %+v", tc.payload, info)
+ }
+ if !IsValidMetaReasoningSignature(sig) {
+ t.Fatalf("payload %d: IsValidMetaReasoningSignature = false", tc.payload)
+ }
+ if got := DetectSignatureProvider(sig); got != SignatureProviderMeta {
+ t.Fatalf("payload %d: DetectSignatureProvider = %q, want %q", tc.payload, got, SignatureProviderMeta)
+ }
+ }
+}
+
+func TestInspectMetaReasoningSignature_RejectsMalformed(t *testing.T) {
+ valid := testMetaReasoningSignature(1024)
+ encode := func(payloadLen int, mutate func([]byte)) string {
+ return base64.RawURLEncoding.EncodeToString(testMetaReasoningSignatureBytes(payloadLen, mutate))
+ }
+ nonCanonical := func() string {
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
+ last := strings.IndexByte(alphabet, valid[len(valid)-1])
+ for i := 0; i < len(alphabet); i++ {
+ if i != last && i&0x30 == last&0x30 {
+ return valid[:len(valid)-1] + string(alphabet[i])
+ }
+ }
+ return valid
+ }()
+ cases := map[string]string{
+ "empty": "",
+ "mock prefix only": "Q-PaDg-mock-muse-spark-encrypted-reasoning-payload",
+ "wrong literal start": "R" + valid[1:],
+ "header byte flipped": encode(1024, func(b []byte) { b[2] ^= 0xff }),
+ "version byte zero": encode(1024, func(b []byte) { b[4] = 0x00 }),
+ "payload below min": encode(512, nil),
+ "payload not power2": encode(1025, nil),
+ "payload off by one": encode(2047, nil),
+ "padded encoding": valid + "=",
+ "standard base64 +": valid[:100] + "+" + valid[101:],
+ "embedded whitespace": valid[:100] + " " + valid[100:],
+ "length mod4 is 1": valid[:len(valid)-1],
+ "non-canonical bits": nonCanonical,
+ }
+ for name, sig := range cases {
+ if _, err := InspectMetaReasoningSignature(sig); err == nil {
+ t.Errorf("%s: InspectMetaReasoningSignature unexpectedly succeeded", name)
+ }
+ if got := DetectSignatureProvider(sig); got == SignatureProviderMeta {
+ t.Errorf("%s: DetectSignatureProvider = %q, must not claim Meta", name, got)
+ }
+ }
+}
+
+func TestInspectMetaReasoningSignature_RouterSuffixed(t *testing.T) {
+ native := testMetaReasoningSignature(2048)
+ suffix := testMetaRouterSuffix()
+ routed := native + "." + suffix
+
+ info, err := InspectMetaReasoningSignature(routed)
+ if err != nil {
+ t.Fatalf("InspectMetaReasoningSignature(routed) error = %v", err)
+ }
+ if info.RouterSuffix != suffix {
+ t.Fatalf("RouterSuffix = %q, want %q", info.RouterSuffix, suffix)
+ }
+ nativeInfo, err := InspectMetaReasoningSignature(native)
+ if err != nil {
+ t.Fatalf("InspectMetaReasoningSignature(native) error = %v", err)
+ }
+ if nativeInfo.RouterSuffix != "" {
+ t.Fatalf("native RouterSuffix = %q, want empty", nativeInfo.RouterSuffix)
+ }
+ if info.DecodedLen != nativeInfo.DecodedLen || info.PayloadLen != nativeInfo.PayloadLen || info.BucketExponent != nativeInfo.BucketExponent {
+ t.Fatalf("routed info = %+v, want same envelope fields as native %+v", info, nativeInfo)
+ }
+ if got := DetectSignatureProvider(routed); got != SignatureProviderMeta {
+ t.Fatalf("DetectSignatureProvider(routed) = %q, want %q", got, SignatureProviderMeta)
+ }
+ if got, ok := CompatibleSignatureForProvider(SignatureProviderMeta, routed); !ok || got != routed {
+ t.Fatalf("CompatibleSignatureForProvider(Meta, routed) = (%q..., %v), want verbatim routed value", got[:16], ok)
+ }
+
+ // The routed form is still a foreign envelope to non-Meta targets.
+ decision := DecideSignatureCompatibility(SignatureProviderGPT, routed, SignatureBlockKindUnknown)
+ if decision.Compatible || decision.DetectedProvider != SignatureProviderMeta || decision.Action != SignatureActionDropBlock {
+ t.Fatalf("GPT target decision for routed = %+v, want detected Meta + drop_block", decision)
+ }
+}
+
+func TestInspectMetaReasoningSignature_RejectsMalformedRouterSuffix(t *testing.T) {
+ native := testMetaReasoningSignature(1024)
+ suffix := testMetaRouterSuffix()
+ encode := func(payloadLen int, mutate func([]byte)) string {
+ return base64.RawURLEncoding.EncodeToString(testMetaReasoningSignatureBytes(payloadLen, mutate))
+ }
+ cases := map[string]string{
+ "trailing dot": native + ".",
+ "oversized suffix": native + "." + strings.Repeat("a", maxMetaRouterSuffixLen+1),
+ "suffix with dash": native + "." + suffix[:60] + "-" + suffix[61:],
+ "suffix with underscore": native + "." + suffix[:60] + "_" + suffix[61:],
+ "suffix with padding": native + "." + suffix[:60] + "=" + suffix[61:],
+ "suffix with plus": native + "." + suffix[:60] + "+" + suffix[61:],
+ "suffix with slash": native + "." + suffix[:60] + "/" + suffix[61:],
+ "suffix with space": native + "." + suffix[:60] + " " + suffix[61:],
+ "second dot": native + "." + suffix[:60] + "." + suffix[61:],
+ "bad native header": encode(1024, func(b []byte) { b[2] ^= 0xff }) + "." + suffix,
+ "non-bucket native": encode(1025, nil) + "." + suffix,
+ "suffix only": "." + suffix,
+ }
+ for name, sig := range cases {
+ if _, err := InspectMetaReasoningSignature(sig); err == nil {
+ t.Errorf("%s: InspectMetaReasoningSignature unexpectedly succeeded", name)
+ }
+ if got := DetectSignatureProvider(sig); got == SignatureProviderMeta {
+ t.Errorf("%s: DetectSignatureProvider = %q, must not claim Meta", name, got)
+ }
+ }
+}
+
+func TestMetaSignatureCompatibility(t *testing.T) {
+ sig := testMetaReasoningSignature(2048)
+
+ decision := DecideSignatureCompatibility(SignatureProviderMeta, sig, SignatureBlockKindUnknown)
+ if !decision.Compatible || decision.Action != SignatureActionPreserve || decision.NormalizedSignature != sig {
+ t.Fatalf("Meta target decision = %+v, want preserve exact signature", decision)
+ }
+ if got, ok := CompatibleSignatureForProvider(SignatureProviderMeta, sig); !ok || got != sig {
+ t.Fatalf("CompatibleSignatureForProvider(Meta) = (%q..., %v), want exact signature", got[:16], ok)
+ }
+
+ gptDecision := DecideSignatureCompatibility(SignatureProviderGPT, sig, SignatureBlockKindUnknown)
+ if gptDecision.Compatible || gptDecision.Action != SignatureActionDropBlock {
+ t.Fatalf("GPT target decision = %+v, want drop_block", gptDecision)
+ }
+
+ foreign := testGPTReasoningSignature()
+ metaDecision := DecideSignatureCompatibility(SignatureProviderMeta, foreign, SignatureBlockKindUnknown)
+ if metaDecision.Compatible || metaDecision.DetectedProvider != SignatureProviderGPT || metaDecision.Action != SignatureActionDropBlock {
+ t.Fatalf("Meta target with GPT signature decision = %+v, want detected GPT + drop_block", metaDecision)
+ }
+}
+
+func TestSignatureProviderFromModelName_Meta(t *testing.T) {
+ for _, model := range []string{"muse-wonder-full-1.0-contributor", "muse-spark-1.3", "Muse-Wonder-1.0", "meta/muse-spark-1.2", "meta/muse", "muse", "meta-muse-spark", "muse_spark_1_3"} {
+ if got := SignatureProviderFromModelName(model); got != SignatureProviderMeta {
+ t.Errorf("SignatureProviderFromModelName(%q) = %q, want %q", model, got, SignatureProviderMeta)
+ }
+ }
+ // The match is anchored to the "muse-" model-id boundary so unrelated
+ // names containing the substring "muse" stay unmapped.
+ for _, model := range []string{"museum-1", "amuse-model", "meta-llama-4"} {
+ if got := SignatureProviderFromModelName(model); got != SignatureProviderUnknown {
+ t.Errorf("SignatureProviderFromModelName(%q) = %q, want %q", model, got, SignatureProviderUnknown)
+ }
+ }
+}
+
+func TestMetaSignatureCompatibility_OtherTargets(t *testing.T) {
+ sig := testMetaReasoningSignature(1024)
+ for _, tc := range []struct {
+ target SignatureProvider
+ action SignatureCompatibilityAction
+ }{
+ {SignatureProviderClaude, SignatureActionDropBlock},
+ {SignatureProviderGemini, SignatureActionReplaceWithGeminiBypass},
+ {SignatureProviderKimi, SignatureActionDropSignature},
+ {SignatureProviderGrok, SignatureActionDropBlock},
+ } {
+ decision := DecideSignatureCompatibility(tc.target, sig, SignatureBlockKindUnknown)
+ if decision.Compatible || decision.DetectedProvider != SignatureProviderMeta || decision.Action != tc.action {
+ t.Errorf("target %q: decision = %+v, want detected Meta + action %q", tc.target, decision, tc.action)
+ }
+ }
+}
+
+func TestSignatureProviderFromModelName_NamespacedTailWins(t *testing.T) {
+ for model, want := range map[string]SignatureProvider{
+ "openai-compatible/muse-spark-1.3": SignatureProviderMeta,
+ "muse-proxy/gpt-5.4": SignatureProviderGPT,
+ "openrouter/anthropic/claude-sonnet-4-6": SignatureProviderClaude,
+ } {
+ if got := SignatureProviderFromModelName(model); got != want {
+ t.Errorf("SignatureProviderFromModelName(%q) = %q, want %q", model, got, want)
+ }
+ }
+}
+
+func TestMetaCachePrefixUnsupported(t *testing.T) {
+ // No producer emits provider-prefixed Meta signatures, so unlike GPT or
+ // Claude there is deliberately no cache-prefix alias for Meta yet.
+ sig := testMetaReasoningSignature(1024)
+ for _, prefixed := range []string{"meta#" + sig, "muse#" + sig} {
+ if got := DetectSignatureProvider(prefixed); got != SignatureProviderUnknown {
+ t.Errorf("DetectSignatureProvider(%q...) = %q, want %q", prefixed[:12], got, SignatureProviderUnknown)
+ }
+ if _, ok := CompatibleSignatureForProvider(SignatureProviderMeta, prefixed); ok {
+ t.Errorf("CompatibleSignatureForProvider(Meta, %q...) unexpectedly compatible", prefixed[:12])
+ }
+ }
+}
diff --git a/internal/signature/provider_compatibility.go b/internal/signature/provider_compatibility.go
index f28d86ae272..95d719a12f5 100644
--- a/internal/signature/provider_compatibility.go
+++ b/internal/signature/provider_compatibility.go
@@ -22,6 +22,12 @@ const (
SignatureProviderGrok SignatureProvider = "grok"
// SignatureProviderSWE represents Cognition's SWE model family emitting sealed.v1 envelopes.
SignatureProviderSWE SignatureProvider = "swe"
+ // SignatureProviderMeta is identified by the observed Meta Muse envelope:
+ // a fixed five-byte header plus a power-of-two padded payload length,
+ // optionally followed by a router-appended (e.g. OpenRouter) dot suffix
+ // that replays verbatim. See meta_validation.go for the capture basis and
+ // its limits.
+ SignatureProviderMeta SignatureProvider = "meta"
)
type SignatureBlockKind string
@@ -59,6 +65,28 @@ type SignatureCompatibilityDecision struct {
// whose signed history can be safely replayed for that model.
func SignatureProviderFromModelName(modelName string) SignatureProvider {
lower := strings.ToLower(strings.TrimSpace(modelName))
+ if lower == "" {
+ return SignatureProviderUnknown
+ }
+ // A namespaced ID's canonical model is its last path segment, so a
+ // vendor/router prefix such as "openai-compatible/" must not outrank the
+ // actual model family ("openai-compatible/muse-spark-1.3" is Meta, not
+ // GPT). Only when the last segment names no family does the full string
+ // get the legacy substring treatment.
+ candidate := lower
+ if idx := strings.LastIndexAny(lower, "/:"); idx >= 0 && idx < len(lower)-1 {
+ candidate = lower[idx+1:]
+ }
+ if provider := signatureProviderFromModelNameValue(candidate); provider != SignatureProviderUnknown {
+ return provider
+ }
+ if candidate != lower {
+ return signatureProviderFromModelNameValue(lower)
+ }
+ return SignatureProviderUnknown
+}
+
+func signatureProviderFromModelNameValue(lower string) SignatureProvider {
switch {
case strings.Contains(lower, "claude"):
return SignatureProviderClaude
@@ -78,6 +106,8 @@ func SignatureProviderFromModelName(modelName string) SignatureProvider {
return SignatureProviderKimi
case strings.Contains(lower, "grok"):
return SignatureProviderGrok
+ case isMetaMuseModelName(lower):
+ return SignatureProviderMeta
case strings.Contains(lower, "swe-"):
return SignatureProviderSWE
default:
@@ -85,6 +115,29 @@ func SignatureProviderFromModelName(modelName string) SignatureProvider {
}
}
+// isMetaMuseModelName reports whether lower names a Meta Muse model on a
+// native or third-party provider ("muse", "muse-*", "muse_*", "meta-muse-*")
+// without matching unrelated substrings such as "museum" or "amuse".
+func isMetaMuseModelName(lower string) bool {
+ if lower == "muse" ||
+ strings.HasPrefix(lower, "muse-") ||
+ strings.HasPrefix(lower, "muse_") ||
+ strings.HasSuffix(lower, "/muse") ||
+ strings.Contains(lower, "/muse-") ||
+ strings.Contains(lower, "/muse_") {
+ return true
+ }
+ for _, prefix := range []string{"meta-", "meta_"} {
+ if idx := strings.Index(lower, prefix+"muse"); idx >= 0 {
+ rest := lower[idx+len(prefix+"muse"):]
+ if rest == "" || rest[0] == '-' || rest[0] == '_' || rest[0] == '.' || rest[0] == ':' {
+ return true
+ }
+ }
+ }
+ return false
+}
+
// selfDescribingSignatureFirstChars are the base64 first characters that a
// self-describing provider envelope can produce. A base64 first character is
// exactly the first payload byte shifted right by two, so a single character
@@ -92,7 +145,7 @@ func SignatureProviderFromModelName(modelName string) SignatureProvider {
//
// 'C' -> 0x08..0x0b : Claude CAIS (0x08)
// 'E' -> 0x10..0x13 : Claude single-layer (0x12), Gemini protobuf_field_2 (0x12)
-// 'Q' -> 0x40..0x43 : Antigravity double-layer CAQS (0x43, inner 'C')
+// 'Q' -> 0x40..0x43 : Antigravity double-layer CAQS (0x43, inner 'C'), Meta Muse (0x43)
// 'R' -> 0x44..0x47 : Claude double-layer R (0x45, inner 'E')
// 'g' -> 0x80..0x83 : GPT Fernet reasoning (0x80)
//
@@ -207,9 +260,11 @@ func DetectSignatureProviderForBlock(rawSignature string, blockKind SignatureBlo
// Probes run from the strongest marker to the weakest:
// 1. GPT carries the literal "gAAAA" prefix, which pins both the version
// byte and the high timestamp bytes.
- // 2. Claude CAIS/CAQS carries marker 0x08 with nested container/channel structure.
- // 3. Claude single/double-layer carries marker 0x12 plus the same literal.
- // 4. Gemini validates wire shape only and has no literal to anchor on, so
+ // 2. Meta Muse carries a fixed five-byte decoded header plus an exact
+ // power-of-two padded length, which no other family can collide with.
+ // 3. Claude CAIS/CAQS carries marker 0x08 with nested container/channel structure.
+ // 4. Claude single/double-layer carries marker 0x12 plus the same literal.
+ // 5. Gemini validates wire shape only and has no literal to anchor on, so
// it is the weakest judge and goes last.
//
// This ordering is defense in depth rather than a correctness requirement:
@@ -228,6 +283,9 @@ func DetectSignatureProviderForBlock(rawSignature string, blockKind SignatureBlo
if IsValidGPTReasoningSignature(sig) {
return SignatureProviderGPT
}
+ if IsValidMetaReasoningSignature(sig) {
+ return SignatureProviderMeta
+ }
if IsValidClaudeCAISSignature(sig) {
return SignatureProviderClaude
}
@@ -316,6 +374,9 @@ func DecideSignatureCompatibilityForModel(targetProvider SignatureProvider, targ
case SignatureProviderSWE:
decision.Action = SignatureActionDropBlock
decision.Reason = "SWE requires sealed.v1 signature from its own backend"
+ case SignatureProviderMeta:
+ decision.Action = SignatureActionDropBlock
+ decision.Reason = "Meta Muse requires its native encrypted_content envelope and has no cross-provider bypass sentinel"
case SignatureProviderKimi:
// Kimi is the only target that can keep the reasoning text when the
// signature does not match. Its Messages endpoint never reads the field
@@ -460,6 +521,8 @@ func signatureProviderMatchesTarget(target, detected SignatureProvider) bool {
return detected == SignatureProviderGPT
case SignatureProviderSWE:
return detected == SignatureProviderSWE
+ case SignatureProviderMeta:
+ return detected == SignatureProviderMeta
case SignatureProviderKimi:
return detected == SignatureProviderKimi
default:
@@ -497,6 +560,10 @@ func normalizeCompatibleSignatureForProvider(targetProvider SignatureProvider, r
if strings.HasPrefix(payload, "sealed.v1.") {
return payload
}
+ case SignatureProviderMeta:
+ if IsValidMetaReasoningSignature(payload) {
+ return payload
+ }
case SignatureProviderKimi:
if IsValidKimiThinkingSignature(payload) {
return payload
@@ -517,7 +584,7 @@ func isRecognizedGeminiProviderSignature(rawSignature string, blockKind Signatur
// IsRecognizedReasoningSignature reports whether rawSignature is a structurally valid
// reasoning signature or encrypted_content payload from any known provider
-// (GPT, Claude, Gemini, Kimi, Grok, Devin).
+// (GPT, Claude, Gemini, Kimi, Grok, Devin, Meta).
func IsRecognizedReasoningSignature(rawSignature string) bool {
sig := strings.TrimSpace(rawSignature)
if sig == "" {
diff --git a/internal/signature/provider_compatibility_test.go b/internal/signature/provider_compatibility_test.go
index 1e5d4c52e53..d66745b3391 100644
--- a/internal/signature/provider_compatibility_test.go
+++ b/internal/signature/provider_compatibility_test.go
@@ -41,6 +41,7 @@ func TestBase64AlphabetSet_MatchesEncoderAlphabets(t *testing.T) {
}{
{"grok unpadded std", grokEncryptedContentCharSet, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"},
{"gpt base64url", gptReasoningSignatureCharSet, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_="},
+ {"meta base64url unpadded", metaReasoningSignatureCharSet, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"},
}
for _, tc := range cases {
allowed := map[byte]bool{}
@@ -73,6 +74,7 @@ func replaySafeEnvelopeFixtures(t *testing.T) map[string]struct {
"antigravity CAQS": {antigravityCAQSFixture(t), SignatureProviderClaude},
"gemini protobuf field2": {testGeminiThoughtSignatureEnvelope(), SignatureProviderGemini},
"gpt fernet": {testGPTReasoningSignature(), SignatureProviderGPT},
+ "meta muse": {testMetaReasoningSignature(1024), SignatureProviderMeta},
}
}
diff --git a/internal/store/config_seed_test.go b/internal/store/config_seed_test.go
new file mode 100644
index 00000000000..154713a3fd9
--- /dev/null
+++ b/internal/store/config_seed_test.go
@@ -0,0 +1,334 @@
+package store
+
+import (
+ "bytes"
+ "context"
+ "database/sql"
+ "database/sql/driver"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+
+ "github.com/minio/minio-go/v7"
+ "github.com/minio/minio-go/v7/pkg/credentials"
+ "golang.org/x/crypto/bcrypt"
+ "gopkg.in/yaml.v3"
+)
+
+const seedTestPlaintextSecret = "seed-plaintext-secret"
+
+// seedTestConfigsKeepingPlaintext hold a plaintext secret that cannot be removed before
+// seeding: the file cannot be parsed, or a YAML anchor keeps the plaintext after the
+// referencing key is hashed or dropped as a conflicting legacy field.
+var seedTestConfigsKeepingPlaintext = map[string]string{
+ "unparsable": "remote-management:\n secret-key: " + seedTestPlaintextSecret + "\nport: [8317\n",
+ "anchored secret": "defaults: &management\n secret-key: " + seedTestPlaintextSecret + "\nmanagement: *management\n",
+ "anchored legacy secret beside v8 secret": "defaults: &legacy\n secret-key: " + seedTestPlaintextSecret + "\nremote-management: *legacy\nmanagement:\n secret-key: current-plaintext-secret\n",
+}
+
+var seedTestDriverID atomic.Uint64
+
+func seedTestPlaintextConfig(section string) string {
+ return "# operator note\nport: 8317\n" + section + ":\n secret-key: " + seedTestPlaintextSecret + "\n"
+}
+
+// seedTestSettledConfigs hold no plaintext secret, so seeding must upload them unchanged.
+func seedTestSettledConfigs(t *testing.T) map[string]string {
+ t.Helper()
+ hashed, errHash := bcrypt.GenerateFromPassword([]byte(seedTestPlaintextSecret), bcrypt.MinCost)
+ if errHash != nil {
+ t.Fatalf("bcrypt.GenerateFromPassword() error = %v", errHash)
+ }
+ return map[string]string{
+ "hashed secret": "# operator note\nport: 8317\nremote-management:\n secret-key: '" + string(hashed) + "'\n",
+ "null secret": "# operator note\nport: 8317\nmanagement:\n secret-key: null\n",
+ }
+}
+
+func writeSeedTestFile(t *testing.T, path, content string) {
+ t.Helper()
+ if errMkdir := os.MkdirAll(filepath.Dir(path), 0o700); errMkdir != nil {
+ t.Fatalf("create directory: %v", errMkdir)
+ }
+ if errWrite := os.WriteFile(path, []byte(content), 0o600); errWrite != nil {
+ t.Fatalf("write %s: %v", path, errWrite)
+ }
+}
+
+func readSeedTestFile(t *testing.T, path string) []byte {
+ t.Helper()
+ data, errRead := os.ReadFile(path)
+ if errRead != nil {
+ t.Fatalf("read %s: %v", path, errRead)
+ }
+ return data
+}
+
+// assertSeededWithHashedSecret checks that a seeded config carries the bcrypt hash of
+// the plaintext secret under section and matches the local file, because later starts
+// overwrite the local file with the remote copy.
+func assertSeededWithHashedSecret(t *testing.T, seeded []byte, configPath, section string) {
+ t.Helper()
+ if bytes.Contains(seeded, []byte(seedTestPlaintextSecret)) {
+ t.Fatalf("remote store seeded with the plaintext management secret:\n%s", seeded)
+ }
+ var doc map[string]any
+ if errParse := yaml.Unmarshal(seeded, &doc); errParse != nil {
+ t.Fatalf("parse seeded config: %v", errParse)
+ }
+ management, _ := doc[section].(map[string]any)
+ secret, _ := management["secret-key"].(string)
+ if errCompare := bcrypt.CompareHashAndPassword([]byte(secret), []byte(seedTestPlaintextSecret)); errCompare != nil {
+ t.Fatalf("seeded %s.secret-key %q is not the bcrypt hash of the plaintext secret: %v", section, secret, errCompare)
+ }
+ if local := readSeedTestFile(t, configPath); !bytes.Equal(local, seeded) {
+ t.Fatalf("seeded config differs from the local file:\nremote:\n%s\nlocal:\n%s", seeded, local)
+ }
+}
+
+// newSeedTestObjectStore returns an object store whose bucket has no config object and
+// records every config upload.
+func newSeedTestObjectStore(t *testing.T, configPath string) (*ObjectTokenStore, *[][]byte) {
+ t.Helper()
+ // Every request answers 404, so the bucket holds no config.
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ }))
+ t.Cleanup(server.Close)
+ client, errClient := minio.New(strings.TrimPrefix(server.URL, "http://"), &minio.Options{
+ Creds: credentials.NewStaticV4("access", "secret", ""),
+ Region: "us-east-1",
+ })
+ if errClient != nil {
+ t.Fatalf("minio.New() error = %v", errClient)
+ }
+ uploads := &[][]byte{}
+ objectStore := &ObjectTokenStore{
+ client: client,
+ cfg: ObjectStoreConfig{Bucket: "bucket"},
+ configPath: configPath,
+ putObjectFn: func(_ context.Context, key string, data []byte, _ string) error {
+ if key != objectStoreConfigKey {
+ return fmt.Errorf("unexpected object upload %s", key)
+ }
+ *uploads = append(*uploads, append([]byte(nil), data...))
+ return nil
+ },
+ }
+ return objectStore, uploads
+}
+
+func TestObjectTokenStoreSeedHashesPlaintextManagementSecret(t *testing.T) {
+ dir := t.TempDir()
+ examplePath := filepath.Join(dir, "config.example.yaml")
+ writeSeedTestFile(t, examplePath, seedTestPlaintextConfig("remote-management"))
+ configPath := filepath.Join(dir, "objectstore", "config", "config.yaml")
+ objectStore, uploads := newSeedTestObjectStore(t, configPath)
+
+ if errSync := objectStore.syncConfigFromBucket(context.Background(), examplePath); errSync != nil {
+ t.Fatalf("syncConfigFromBucket() error = %v", errSync)
+ }
+ if len(*uploads) != 1 {
+ t.Fatalf("config uploads = %d, want 1", len(*uploads))
+ }
+ assertSeededWithHashedSecret(t, (*uploads)[0], configPath, "remote-management")
+}
+
+func TestObjectTokenStoreSeedUploadsSettledConfigUnchanged(t *testing.T) {
+ for name, original := range seedTestSettledConfigs(t) {
+ t.Run(name, func(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.yaml")
+ writeSeedTestFile(t, configPath, original)
+ objectStore, uploads := newSeedTestObjectStore(t, configPath)
+
+ if errSync := objectStore.syncConfigFromBucket(context.Background(), ""); errSync != nil {
+ t.Fatalf("syncConfigFromBucket() error = %v", errSync)
+ }
+ if len(*uploads) != 1 || string((*uploads)[0]) != original {
+ t.Fatalf("config uploads = %q, want exactly the original config %q", *uploads, original)
+ }
+ if local := readSeedTestFile(t, configPath); string(local) != original {
+ t.Fatalf("local config rewritten to %q, want it untouched", local)
+ }
+ })
+ }
+}
+
+// Bootstrap must fail rather than seed, and keep the local file as it was: a later
+// start finds the same file and has to refuse it again.
+func TestObjectTokenStoreSeedRefusesConfigThatKeepsPlaintextSecret(t *testing.T) {
+ for name, content := range seedTestConfigsKeepingPlaintext {
+ t.Run(name, func(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.yaml")
+ writeSeedTestFile(t, configPath, content)
+ objectStore, uploads := newSeedTestObjectStore(t, configPath)
+
+ for attempt := 1; attempt <= 2; attempt++ {
+ if errSync := objectStore.syncConfigFromBucket(context.Background(), ""); errSync == nil {
+ t.Fatalf("attempt %d: syncConfigFromBucket() succeeded, want bootstrap to fail", attempt)
+ }
+ }
+ if len(*uploads) != 0 {
+ t.Fatalf("uploaded a config that still holds the plaintext secret:\n%s", (*uploads)[0])
+ }
+ if local := readSeedTestFile(t, configPath); string(local) != content {
+ t.Fatalf("local config rewritten to %q, want the original kept", local)
+ }
+ })
+ }
+}
+
+// seedTestDatabase is a database/sql driver whose config table is empty; it records
+// every config upsert.
+type seedTestDatabase struct {
+ mu sync.Mutex
+ upserts []string
+}
+
+type seedTestConn struct {
+ database *seedTestDatabase
+}
+
+type seedTestRows struct{}
+
+func (d *seedTestDatabase) Open(string) (driver.Conn, error) {
+ return &seedTestConn{database: d}, nil
+}
+
+func (d *seedTestDatabase) recordedUpserts() []string {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ return append([]string(nil), d.upserts...)
+}
+
+func (*seedTestConn) Prepare(string) (driver.Stmt, error) {
+ return nil, errors.New("prepare is not supported")
+}
+
+func (*seedTestConn) Close() error {
+ return nil
+}
+
+func (*seedTestConn) Begin() (driver.Tx, error) {
+ return nil, errors.New("transactions are not supported")
+}
+
+func (c *seedTestConn) ExecContext(_ context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
+ if !strings.Contains(query, "INSERT INTO") || len(args) != 2 {
+ return nil, fmt.Errorf("unexpected statement %q", query)
+ }
+ content, ok := args[1].Value.(string)
+ if !ok {
+ return nil, errors.New("config content is not a string")
+ }
+ c.database.mu.Lock()
+ c.database.upserts = append(c.database.upserts, content)
+ c.database.mu.Unlock()
+ return driver.RowsAffected(1), nil
+}
+
+// QueryContext serves an empty config table.
+func (*seedTestConn) QueryContext(context.Context, string, []driver.NamedValue) (driver.Rows, error) {
+ return seedTestRows{}, nil
+}
+
+func (seedTestRows) Columns() []string {
+ return []string{"content"}
+}
+
+func (seedTestRows) Close() error {
+ return nil
+}
+
+func (seedTestRows) Next([]driver.Value) error {
+ return io.EOF
+}
+
+func newSeedTestPostgresStore(t *testing.T, configPath string) (*PostgresStore, *seedTestDatabase) {
+ t.Helper()
+ database := &seedTestDatabase{}
+ driverName := fmt.Sprintf("cliproxy_postgres_config_seed_test_%d", seedTestDriverID.Add(1))
+ sql.Register(driverName, database)
+ db, errOpen := sql.Open(driverName, "")
+ if errOpen != nil {
+ t.Fatalf("sql.Open() error = %v", errOpen)
+ }
+ t.Cleanup(func() {
+ if errClose := db.Close(); errClose != nil {
+ t.Errorf("db.Close() error = %v", errClose)
+ }
+ })
+ postgresStore := &PostgresStore{
+ db: db,
+ cfg: PostgresStoreConfig{ConfigTable: defaultConfigTable},
+ configPath: configPath,
+ }
+ return postgresStore, database
+}
+
+func TestPostgresStoreSeedHashesPlaintextManagementSecret(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "pgstore", "config", "config.yaml")
+ writeSeedTestFile(t, configPath, seedTestPlaintextConfig("management"))
+ postgresStore, database := newSeedTestPostgresStore(t, configPath)
+
+ if errSync := postgresStore.syncConfigFromDatabase(context.Background(), ""); errSync != nil {
+ t.Fatalf("syncConfigFromDatabase() error = %v", errSync)
+ }
+ upserts := database.recordedUpserts()
+ if len(upserts) != 1 {
+ t.Fatalf("config upserts = %d, want 1", len(upserts))
+ }
+ assertSeededWithHashedSecret(t, []byte(upserts[0]), configPath, "management")
+}
+
+func TestPostgresStoreSeedUploadsSettledConfigUnchanged(t *testing.T) {
+ for name, original := range seedTestSettledConfigs(t) {
+ t.Run(name, func(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.yaml")
+ writeSeedTestFile(t, configPath, original)
+ postgresStore, database := newSeedTestPostgresStore(t, configPath)
+
+ if errSync := postgresStore.syncConfigFromDatabase(context.Background(), ""); errSync != nil {
+ t.Fatalf("syncConfigFromDatabase() error = %v", errSync)
+ }
+ if upserts := database.recordedUpserts(); len(upserts) != 1 || upserts[0] != original {
+ t.Fatalf("config upserts = %q, want exactly the original config %q", upserts, original)
+ }
+ if local := readSeedTestFile(t, configPath); string(local) != original {
+ t.Fatalf("local config rewritten to %q, want it untouched", local)
+ }
+ })
+ }
+}
+
+// Bootstrap must fail rather than seed, and keep the local file as it was: a later
+// start finds the same file and has to refuse it again.
+func TestPostgresStoreSeedRefusesConfigThatKeepsPlaintextSecret(t *testing.T) {
+ for name, content := range seedTestConfigsKeepingPlaintext {
+ t.Run(name, func(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.yaml")
+ writeSeedTestFile(t, configPath, content)
+ postgresStore, database := newSeedTestPostgresStore(t, configPath)
+
+ for attempt := 1; attempt <= 2; attempt++ {
+ if errSync := postgresStore.syncConfigFromDatabase(context.Background(), ""); errSync == nil {
+ t.Fatalf("attempt %d: syncConfigFromDatabase() succeeded, want bootstrap to fail", attempt)
+ }
+ }
+ if upserts := database.recordedUpserts(); len(upserts) != 0 {
+ t.Fatalf("seeded a config that still holds the plaintext secret:\n%s", upserts[0])
+ }
+ if local := readSeedTestFile(t, configPath); string(local) != content {
+ t.Fatalf("local config rewritten to %q, want the original kept", local)
+ }
+ })
+ }
+}
diff --git a/internal/store/objectstore.go b/internal/store/objectstore.go
index 11517ac9cdc..39b8b089825 100644
--- a/internal/store/objectstore.go
+++ b/internal/store/objectstore.go
@@ -17,6 +17,7 @@ import (
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/misc"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
@@ -394,6 +395,12 @@ func (s *ObjectTokenStore) syncConfigFromBucket(ctx context.Context, example str
}
}
}
+ // Seed the bucket with the file the startup load leaves behind, so a plaintext
+ // management secret never reaches it. Fail instead of skipping: the startup load
+ // could turn a refused file into one that a later start would upload.
+ if errPrepare := config.PrepareConfigFileForUpload(s.configPath); errPrepare != nil {
+ return fmt.Errorf("object store: prepare local config for seeding: %w", errPrepare)
+ }
data, errRead := os.ReadFile(s.configPath)
if errRead != nil {
return fmt.Errorf("object store: read local config: %w", errRead)
diff --git a/internal/store/postgresstore.go b/internal/store/postgresstore.go
index 69986200bf1..69bc318bdaa 100644
--- a/internal/store/postgresstore.go
+++ b/internal/store/postgresstore.go
@@ -14,6 +14,7 @@ import (
"time"
_ "github.com/jackc/pgx/v5/stdlib"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/misc"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
@@ -470,6 +471,12 @@ func (s *PostgresStore) syncConfigFromDatabase(ctx context.Context, exampleConfi
}
}
}
+ // Seed the database with the file the startup load leaves behind, so a plaintext
+ // management secret never reaches it. Fail instead of skipping: the startup load
+ // could turn a refused file into one that a later start would upload.
+ if errPrepare := config.PrepareConfigFileForUpload(s.configPath); errPrepare != nil {
+ return fmt.Errorf("postgres store: prepare local config for seeding: %w", errPrepare)
+ }
data, errRead := os.ReadFile(s.configPath)
if errRead != nil {
return fmt.Errorf("postgres store: read local config: %w", errRead)
diff --git a/internal/thinking/apply.go b/internal/thinking/apply.go
index 7b65c77e86c..395b14cd134 100644
--- a/internal/thinking/apply.go
+++ b/internal/thinking/apply.go
@@ -8,6 +8,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
+ "github.com/tidwall/sjson"
)
type pluginProviderApplier struct {
@@ -405,6 +406,14 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF
if thinkingIsFullyDisabled(*validated) || nativeResponses {
return applied, nil
}
+ if providerFormat == "claude" && thinkingRequestedOff(config) {
+ // The model cannot turn thinking off, so ValidateConfig fell back to its
+ // lowest level. Drop any stale block_binding from the off request and keep
+ // the caller's intent of seeing no thinking: without an explicit omitted
+ // display, cloaking would add progress updates.
+ applied, _ = sjson.DeleteBytes(applied, "thinking.block_binding")
+ summaryConfig = SummaryConfig{Mode: SummaryDisabled}
+ }
return applySummaryConfigForProvider(applied, providerFormat, baseModel, providerKey, modelInfo, summaryConfig), nil
}
@@ -412,6 +421,10 @@ func thinkingIsFullyDisabled(config ThinkingConfig) bool {
return config.Mode == ModeNone && config.Budget == 0 && config.Level == ""
}
+func thinkingRequestedOff(config ThinkingConfig) bool {
+ return config.Mode == ModeNone || (config.Mode == ModeBudget && config.Budget == 0)
+}
+
func shouldMapConfiguredHighIntent(fromFormat, toFormat string, modelInfo *registry.ModelInfo) bool {
fromFormat = strings.ToLower(strings.TrimSpace(fromFormat))
toFormat = strings.ToLower(strings.TrimSpace(toFormat))
@@ -668,6 +681,12 @@ func extractThinkingConfigForUsage(body []byte, provider string) ThinkingConfig
switch strings.ToLower(strings.TrimSpace(provider)) {
case "codex", "xai", "openai-response":
return extractCodexUsageConfig(body)
+ case "claude":
+ // between_tools is how claude-sonnet-5-5 turns thinking off.
+ if gjson.GetBytes(body, "thinking.type").String() == "between_tools" {
+ return ThinkingConfig{Mode: ModeNone, Budget: 0}
+ }
+ return extractThinkingConfig(body, provider)
default:
return extractThinkingConfig(body, provider)
}
diff --git a/internal/thinking/claude_issue_6698_test.go b/internal/thinking/claude_issue_6698_test.go
new file mode 100644
index 00000000000..dd76aded6ad
--- /dev/null
+++ b/internal/thinking/claude_issue_6698_test.go
@@ -0,0 +1,304 @@
+package thinking_test
+
+import (
+ "testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
+ _ "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking/provider/claude"
+ "github.com/tidwall/gjson"
+)
+
+// claude-sonnet-5-5 rejects thinking.type="disabled" and requires "between_tools"
+// instead. That type accepts no extra thinking fields (display, budget_tokens,
+// block_binding) and only effort high or below, so turning thinking off must
+// emit {"type":"between_tools"} and remove output_config.effort.
+func TestApplyThinking_ClaudeSonnet55DisabledUsesBetweenTools(t *testing.T) {
+ const msgs = `"messages":[{"role":"user","content":"hi"}]`
+ tests := []struct {
+ name string
+ body string
+ model string
+ }{
+ {
+ name: "disabled",
+ body: `{"model":"claude-sonnet-5-5",` + msgs + `,"thinking":{"type":"disabled"}}`,
+ model: "claude-sonnet-5-5",
+ },
+ {
+ name: "disabled with stale budget display block_binding and effort",
+ body: `{"model":"claude-sonnet-5-5",` + msgs + `,"thinking":{"type":"disabled","budget_tokens":8000,"display":"omitted","block_binding":{"prefix_mismatch_behavior":"drop_block"}},"output_config":{"effort":"xhigh"}}`,
+ model: "claude-sonnet-5-5",
+ },
+ {
+ name: "suffix none overrides active thinking with display and block_binding",
+ body: `{"model":"claude-sonnet-5-5",` + msgs + `,"thinking":{"type":"adaptive","display":"summarized","block_binding":{"prefix_mismatch_behavior":"drop_block"}},"output_config":{"effort":"max"}}`,
+ model: "claude-sonnet-5-5(none)",
+ },
+ {
+ name: "suffix zero",
+ body: `{"model":"claude-sonnet-5-5",` + msgs + `,"thinking":{"type":"adaptive"},"output_config":{"effort":"high"}}`,
+ model: "claude-sonnet-5-5(0)",
+ },
+ {
+ name: "zero budget with display and block_binding",
+ body: `{"model":"claude-sonnet-5-5",` + msgs + `,"thinking":{"type":"enabled","budget_tokens":0,"display":"summarized","block_binding":{"prefix_mismatch_behavior":"drop_block"}}}`,
+ model: "claude-sonnet-5-5",
+ },
+ {
+ name: "context variant",
+ body: `{"model":"claude-sonnet-5-5[1m]",` + msgs + `,"thinking":{"type":"disabled"}}`,
+ model: "claude-sonnet-5-5[1m]",
+ },
+ {
+ name: "dated variant",
+ body: `{"model":"claude-sonnet-5-5-20261001",` + msgs + `,"thinking":{"type":"disabled"}}`,
+ model: "claude-sonnet-5-5-20261001",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ out, err := thinking.ApplyThinking([]byte(tt.body), tt.model, "claude", "claude", "claude")
+ if err != nil {
+ t.Fatalf("ApplyThinking: %v", err)
+ }
+ if got := gjson.GetBytes(out, "thinking").Raw; got != `{"type":"between_tools"}` {
+ t.Fatalf("thinking = %s, want {\"type\":\"between_tools\"}; body=%s", got, out)
+ }
+ if gjson.GetBytes(out, "output_config.effort").Exists() {
+ t.Fatalf("between_tools retained output_config.effort: %s", out)
+ }
+ if got := thinking.ExtractTranslatedReasoningEffort(out, "claude"); got != "none" {
+ t.Fatalf("ExtractTranslatedReasoningEffort = %q, want none", got)
+ }
+ })
+ }
+}
+
+// Anthropic rejects thinking.type="disabled" on claude-opus-5-5, claude-fable-5,
+// and claude-fable-5-1. Turning thinking off must fall back to adaptive thinking
+// at the lowest supported effort ("low") with display="omitted", and keep an
+// explicit {"type":"disabled"} block (without display, budget_tokens, or
+// block_binding) on models that accept disabled.
+func TestApplyThinking_ClaudeDisabledFallsBackOnAdaptiveOnlyModels(t *testing.T) {
+ tests := []struct {
+ model string
+ wantThinking string
+ wantEffort string
+ }{
+ {model: "claude-opus-5-5", wantThinking: "adaptive", wantEffort: "low"},
+ {model: "claude-opus-5-5[1m]", wantThinking: "adaptive", wantEffort: "low"},
+ {model: "claude-opus-5-5-20261001", wantThinking: "adaptive", wantEffort: "low"},
+ {model: "claude-fable-5", wantThinking: "adaptive", wantEffort: "low"},
+ {model: "claude-fable-5[1m]", wantThinking: "adaptive", wantEffort: "low"},
+ {model: "claude-fable-5-1", wantThinking: "adaptive", wantEffort: "low"},
+ {model: "claude-fable-5-1[1m]", wantThinking: "adaptive", wantEffort: "low"},
+ {model: "claude-opus-5", wantThinking: "disabled"},
+ {model: "claude-sonnet-5", wantThinking: "disabled"},
+ {model: "claude-haiku-5-5", wantThinking: "disabled"},
+ {model: "claude-opus-4-8", wantThinking: "disabled"},
+ {model: "claude-opus-4-7", wantThinking: "disabled"},
+ {model: "claude-haiku-4-5-20251001", wantThinking: "disabled"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.model, func(t *testing.T) {
+ body := `{"model":"` + tt.model + `","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"disabled","budget_tokens":8000,"display":"summarized","block_binding":{"prefix_mismatch_behavior":"drop_block"}}}`
+ out, err := thinking.ApplyThinking([]byte(body), tt.model, "claude", "claude", "claude")
+ if err != nil {
+ t.Fatalf("ApplyThinking: %v", err)
+ }
+ if got := gjson.GetBytes(out, "thinking.type").String(); got != tt.wantThinking {
+ t.Fatalf("thinking.type = %q, want %q; body=%s", got, tt.wantThinking, out)
+ }
+ if got := gjson.GetBytes(out, "output_config.effort").String(); got != tt.wantEffort {
+ t.Fatalf("output_config.effort = %q, want %q; body=%s", got, tt.wantEffort, out)
+ }
+ if gjson.GetBytes(out, "thinking.budget_tokens").Exists() {
+ t.Fatalf("thinking retained budget_tokens: %s", out)
+ }
+ if gjson.GetBytes(out, "thinking.block_binding").Exists() {
+ t.Fatalf("thinking retained block_binding on off rewrite: %s", out)
+ }
+ if tt.wantThinking == "adaptive" {
+ if got := gjson.GetBytes(out, "thinking.display").String(); got != "omitted" {
+ t.Fatalf("thinking.display = %q, want omitted; body=%s", got, out)
+ }
+ } else if gjson.GetBytes(out, "thinking.display").Exists() {
+ t.Fatalf("disabled thinking retained display: %s", out)
+ }
+ })
+ }
+}
+
+func TestApplyThinking_ClaudeDisabledFallbackInputs(t *testing.T) {
+ const activeWithExtras = `{"model":"claude-opus-5-5","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":8000,"display":"summarized","block_binding":{"prefix_mismatch_behavior":"drop_block"}}}`
+ tests := []struct {
+ name string
+ body string
+ model string
+ }{
+ {name: "suffix none", body: activeWithExtras, model: "claude-opus-5-5(none)"},
+ {name: "suffix zero", body: activeWithExtras, model: "claude-opus-5-5(0)"},
+ {name: "zero budget", body: `{"model":"claude-opus-5-5","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":0,"display":"summarized","block_binding":{"prefix_mismatch_behavior":"drop_block"}}}`, model: "claude-opus-5-5"},
+ {name: "zero budget onopus5", body: `{"model":"claude-opus-5","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":0,"display":"summarized","block_binding":{"prefix_mismatch_behavior":"drop_block"}}}`, model: "claude-opus-5"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ out, err := thinking.ApplyThinking([]byte(tt.body), tt.model, "claude", "claude", "claude")
+ if err != nil {
+ t.Fatalf("ApplyThinking: %v", err)
+ }
+ if gjson.GetBytes(out, "thinking.budget_tokens").Exists() {
+ t.Fatalf("retained budget_tokens: %s", out)
+ }
+ if gjson.GetBytes(out, "thinking.block_binding").Exists() {
+ t.Fatalf("retained block_binding: %s", out)
+ }
+ if tt.model == "claude-opus-5" {
+ if got := gjson.GetBytes(out, "thinking").Raw; got != `{"type":"disabled"}` {
+ t.Fatalf("thinking = %s, want {\"type\":\"disabled\"}; body=%s", got, out)
+ }
+ return
+ }
+ if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" {
+ t.Fatalf("thinking.type = %q, want adaptive; body=%s", got, out)
+ }
+ if got := gjson.GetBytes(out, "output_config.effort").String(); got != "low" {
+ t.Fatalf("output_config.effort = %q, want low; body=%s", got, out)
+ }
+ if got := gjson.GetBytes(out, "thinking.display").String(); got != "omitted" {
+ t.Fatalf("thinking.display = %q, want omitted; body=%s", got, out)
+ }
+ })
+ }
+}
+
+// Opus 4.7, Opus 4.8, Fable 5, and Fable 5.1 reject thinking.budget_tokens and
+// accept only adaptive thinking with output_config.effort. Numeric budgets must
+// map to the nearest effort level, and auto must stay plain adaptive without
+// a synthesized mid-range budget. Opus 4.6 and Sonnet 4.6 still accept budgets.
+func TestApplyThinking_ClaudeAdaptiveOnlyModelsConvertBudgetAndAuto(t *testing.T) {
+ adaptiveOnlyModels := []string{
+ "claude-opus-4-7",
+ "claude-opus-4-8",
+ "claude-fable-5",
+ "claude-fable-5-1",
+ }
+ for _, model := range adaptiveOnlyModels {
+ t.Run(model+"/native_budget_tokens", func(t *testing.T) {
+ body := `{"model":"` + model + `","max_tokens":32000,"messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":16384}}`
+ out, err := thinking.ApplyThinking([]byte(body), model, "claude", "claude", "claude")
+ if err != nil {
+ t.Fatalf("ApplyThinking: %v", err)
+ }
+ if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" {
+ t.Fatalf("thinking.type = %q, want adaptive; body=%s", got, out)
+ }
+ if gjson.GetBytes(out, "thinking.budget_tokens").Exists() {
+ t.Fatalf("thinking.budget_tokens still present: %s", out)
+ }
+ if got := gjson.GetBytes(out, "output_config.effort").String(); got != "high" {
+ t.Fatalf("output_config.effort = %q, want high; body=%s", got, out)
+ }
+ })
+
+ t.Run(model+"/numeric_suffix", func(t *testing.T) {
+ body := `{"model":"` + model + `","max_tokens":32000,"messages":[{"role":"user","content":"hi"}]}`
+ out, err := thinking.ApplyThinking([]byte(body), model+"(64000)", "claude", "claude", "claude")
+ if err != nil {
+ t.Fatalf("ApplyThinking: %v", err)
+ }
+ if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" {
+ t.Fatalf("thinking.type = %q, want adaptive; body=%s", got, out)
+ }
+ if gjson.GetBytes(out, "thinking.budget_tokens").Exists() {
+ t.Fatalf("thinking.budget_tokens still present: %s", out)
+ }
+ if got := gjson.GetBytes(out, "output_config.effort").String(); got != "xhigh" {
+ t.Fatalf("output_config.effort = %q, want xhigh; body=%s", got, out)
+ }
+ })
+
+ t.Run(model+"/translated_budget", func(t *testing.T) {
+ info := registry.LookupModelInfo(model, "claude")
+ body := `{"model":"` + model + `","max_tokens":32000,"messages":[{"role":"user","content":"hi"}]}`
+ source := `{"generationConfig":{"thinkingConfig":{"thinkingBudget":4096}}}`
+ out, err := thinking.ApplyThinkingWithModelInfo([]byte(body), []byte(source), model, "gemini", "claude", "claude", info)
+ if err != nil {
+ t.Fatalf("ApplyThinkingWithModelInfo: %v", err)
+ }
+ if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" {
+ t.Fatalf("thinking.type = %q, want adaptive; body=%s", got, out)
+ }
+ if gjson.GetBytes(out, "thinking.budget_tokens").Exists() {
+ t.Fatalf("thinking.budget_tokens still present: %s", out)
+ }
+ if got := gjson.GetBytes(out, "output_config.effort").String(); got != "medium" {
+ t.Fatalf("output_config.effort = %q, want medium; body=%s", got, out)
+ }
+ })
+
+ t.Run(model+"/auto_suffix", func(t *testing.T) {
+ body := `{"model":"` + model + `","max_tokens":32000,"messages":[{"role":"user","content":"hi"}]}`
+ out, err := thinking.ApplyThinking([]byte(body), model+"(auto)", "claude", "claude", "claude")
+ if err != nil {
+ t.Fatalf("ApplyThinking: %v", err)
+ }
+ if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" {
+ t.Fatalf("thinking.type = %q, want adaptive; body=%s", got, out)
+ }
+ if gjson.GetBytes(out, "thinking.budget_tokens").Exists() {
+ t.Fatalf("auto synthesized budget_tokens: %s", out)
+ }
+ if gjson.GetBytes(out, "output_config.effort").Exists() {
+ t.Fatalf("auto set explicit effort: %s", out)
+ }
+ })
+ }
+
+ // Configured routes with unordered levels or special "auto" token must still
+ // select the lowest canonical effort level ("low") when falling back from off.
+ t.Run("configured_unordered_or_auto_levels_select_lowest_canonical", func(t *testing.T) {
+ for _, levels := range [][]string{
+ {"high", "medium", "low"},
+ {"auto", "high", "low"},
+ } {
+ info := ®istry.ModelInfo{
+ ID: "claude-opus-5-5",
+ Type: "claude",
+ Thinking: ®istry.ThinkingSupport{
+ ZeroAllowed: false,
+ DynamicAllowed: true,
+ Levels: levels,
+ },
+ }
+ body := []byte(`{"model":"claude-opus-5-5","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"disabled"}}`)
+ out, err := thinking.ApplyThinkingWithModelInfo(body, nil, "claude-opus-5-5", "claude", "claude", "claude", info)
+ if err != nil {
+ t.Fatalf("ApplyThinkingWithModelInfo(%v): %v", levels, err)
+ }
+ if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" {
+ t.Fatalf("levels=%v thinking.type = %q, want adaptive; body=%s", levels, got, out)
+ }
+ if got := gjson.GetBytes(out, "output_config.effort").String(); got != "low" {
+ t.Fatalf("levels=%v output_config.effort = %q, want low; body=%s", levels, got, out)
+ }
+ }
+ })
+
+ for _, hybridModel := range []string{"claude-opus-4-6", "claude-sonnet-4-6"} {
+ t.Run(hybridModel+"/retains_budget_tokens", func(t *testing.T) {
+ body := `{"model":"` + hybridModel + `","max_tokens":32000,"messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled","budget_tokens":16384}}`
+ out, err := thinking.ApplyThinking([]byte(body), hybridModel, "claude", "claude", "claude")
+ if err != nil {
+ t.Fatalf("ApplyThinking: %v", err)
+ }
+ if got := gjson.GetBytes(out, "thinking.type").String(); got != "enabled" {
+ t.Fatalf("thinking.type = %q, want enabled; body=%s", got, out)
+ }
+ if got := gjson.GetBytes(out, "thinking.budget_tokens").Int(); got != 16384 {
+ t.Fatalf("thinking.budget_tokens = %d, want 16384; body=%s", got, out)
+ }
+ })
+ }
+}
diff --git a/internal/thinking/configuration_update.go b/internal/thinking/configuration_update.go
index b826641a9aa..a2e5f50e97c 100644
--- a/internal/thinking/configuration_update.go
+++ b/internal/thinking/configuration_update.go
@@ -45,6 +45,37 @@ func extractConfigurationUpdateConfig(body []byte) ThinkingConfig {
}
}
+// CollapseConfigurationUpdates removes every configuration_update input item.
+// Unless keepTopLevelEffort is true, the last nonempty update effort is also
+// promoted into the top-level reasoning.effort. Targets that reject
+// configuration_update items entirely (for example Sign in with ChatGPT
+// subscriptions) use it to degrade to the classic request-level effort,
+// accepting the resulting prompt-cache loss. Keep the top-level effort when
+// an explicit model thinking suffix already resolved the operator's override
+// into it. A body without array input or without any configuration_update
+// item is returned unchanged, and the top-level effort is only touched when
+// an update carries a usable effort.
+func CollapseConfigurationUpdates(body []byte, keepTopLevelEffort ...bool) []byte {
+ updated := stripConfigurationUpdates(body)
+ if len(keepTopLevelEffort) > 0 && keepTopLevelEffort[0] {
+ return updated
+ }
+ effort := reasoningEffortFromConfig(extractConfigurationUpdateConfig(body))
+ if effort == "" {
+ return updated
+ }
+ collapsed, errSet := sjson.SetBytes(updated, "reasoning.effort", effort)
+ if errSet != nil {
+ return updated
+ }
+ return collapsed
+}
+
+// StripConfigurationUpdates removes configuration_update input items without changing reasoning.
+func StripConfigurationUpdates(body []byte) []byte {
+ return stripConfigurationUpdates(body)
+}
+
// stripConfigurationUpdates removes unsupported Responses input items without
// modifying other input items or introducing an input field.
func stripConfigurationUpdates(body []byte) []byte {
diff --git a/internal/thinking/configuration_update_test.go b/internal/thinking/configuration_update_test.go
new file mode 100644
index 00000000000..b3e0255f338
--- /dev/null
+++ b/internal/thinking/configuration_update_test.go
@@ -0,0 +1,86 @@
+package thinking
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/tidwall/gjson"
+)
+
+func TestCollapseConfigurationUpdatesBoundaries(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ body string
+ }{
+ {name: "empty input", body: `{"input":[]}`},
+ {name: "invalid JSON", body: `{"input":[`},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ body := []byte(tc.body)
+ if got := CollapseConfigurationUpdates(body); !bytes.Equal(got, body) {
+ t.Fatalf("body changed: got %s, want %s", got, body)
+ }
+ })
+ }
+
+ t.Run("only configuration updates", func(t *testing.T) {
+ body := []byte(`{"input":[{"type":"configuration_update","reasoning":{"effort":"low"}},{"type":"configuration_update","reasoning":{"effort":"high"}}]}`)
+ got := CollapseConfigurationUpdates(body)
+ if input := gjson.GetBytes(got, "input"); !input.IsArray() || len(input.Array()) != 0 {
+ t.Fatalf("input = %s, want []; body=%s", input.Raw, got)
+ }
+ if effort := gjson.GetBytes(got, "reasoning.effort").String(); effort != "high" {
+ t.Fatalf("reasoning.effort = %q, want high; body=%s", effort, got)
+ }
+ })
+}
+
+func TestStripConfigurationUpdatesExported(t *testing.T) {
+ t.Run("items removed without changing top level effort", func(t *testing.T) {
+ body := []byte(`{"reasoning":{"effort":"medium"},"input":[{"type":"configuration_update","reasoning":{"effort":"high"}},{"role":"user","content":"hi"}]}`)
+ got := StripConfigurationUpdates(body)
+ if input := gjson.GetBytes(got, "input").Raw; input != `[{"role":"user","content":"hi"}]` {
+ t.Fatalf("input = %s, want user message; body=%s", input, got)
+ }
+ if effort := gjson.GetBytes(got, "reasoning.effort").String(); effort != "medium" {
+ t.Fatalf("reasoning.effort = %q, want medium; body=%s", effort, got)
+ }
+ })
+
+ for _, tc := range []struct {
+ name string
+ body string
+ }{
+ {name: "empty input", body: `{"input":[]}`},
+ {name: "invalid JSON", body: `{"input":[`},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ body := []byte(tc.body)
+ if got := StripConfigurationUpdates(body); !bytes.Equal(got, body) {
+ t.Fatalf("body changed: got %s, want %s", got, body)
+ }
+ })
+ }
+}
+
+func TestHasThinkingSuffix(t *testing.T) {
+ for _, tc := range []struct {
+ model string
+ want bool
+ }{
+ {model: "model", want: false},
+ {model: "model()", want: false},
+ {model: "model(invalid)", want: false},
+ {model: "model(high)", want: true},
+ {model: "model(xhigh)", want: true},
+ {model: "model(none)", want: true},
+ {model: "model(auto)", want: true},
+ {model: "model(16384)", want: true},
+ } {
+ t.Run(tc.model, func(t *testing.T) {
+ if got := HasThinkingSuffix(tc.model); got != tc.want {
+ t.Fatalf("HasThinkingSuffix(%q) = %t, want %t", tc.model, got, tc.want)
+ }
+ })
+ }
+}
diff --git a/internal/thinking/provider/claude/apply.go b/internal/thinking/provider/claude/apply.go
index ea2cefd79c4..57ea7d47a8f 100644
--- a/internal/thinking/provider/claude/apply.go
+++ b/internal/thinking/provider/claude/apply.go
@@ -40,7 +40,7 @@ func init() {
// - ModeBudget: manual thinking budget_tokens
// - ModeLevel: adaptive thinking effort (Claude 4.6)
// - ModeAuto: provider default adaptive/manual behavior
-// - ModeNone: disabled
+// - ModeNone: disabled, or the model's ThinkingSupport.DisableType (between_tools on claude-sonnet-5-5)
//
// Expected output format when enabled:
//
@@ -85,15 +85,7 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *
switch config.Mode {
case thinking.ModeNone:
- result, _ := sjson.SetBytes(body, "thinking.type", "disabled")
- result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
- // Summary display only applies to an active thinking block.
- result, _ = sjson.DeleteBytes(result, "thinking.display")
- result, _ = sjson.DeleteBytes(result, "output_config.effort")
- if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
- result, _ = sjson.DeleteBytes(result, "output_config")
- }
- return result, nil
+ return disableThinking(body, modelInfo), nil
case thinking.ModeLevel:
// Adaptive thinking effort is only valid when the model advertises discrete levels.
@@ -119,13 +111,7 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *
// Budget is expected to be pre-validated by ValidateConfig (clamped, ZeroAllowed enforced).
// Decide enabled/disabled based on budget value.
if config.Budget == 0 {
- result, _ := sjson.SetBytes(body, "thinking.type", "disabled")
- result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
- result, _ = sjson.DeleteBytes(result, "output_config.effort")
- if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
- result, _ = sjson.DeleteBytes(result, "output_config")
- }
- return result, nil
+ return disableThinking(body, modelInfo), nil
}
result, _ := sjson.SetBytes(body, "thinking.type", "enabled")
@@ -166,6 +152,25 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *
}
}
+// disableThinking turns thinking off with the type the model accepts. Most Claude
+// models take "disabled"; claude-sonnet-5-5 rejects it and needs "between_tools".
+// Neither type accepts extra thinking fields (budget_tokens, display, block_binding),
+// and between_tools only accepts effort high or below, so effort is left to the
+// model default in both cases.
+func disableThinking(body []byte, modelInfo *registry.ModelInfo) []byte {
+ disableType := "disabled"
+ if modelInfo != nil && modelInfo.Thinking != nil && modelInfo.Thinking.DisableType != "" {
+ disableType = modelInfo.Thinking.DisableType
+ }
+ result, _ := sjson.SetRawBytes(body, "thinking", []byte(`{}`))
+ result, _ = sjson.SetBytes(result, "thinking.type", disableType)
+ result, _ = sjson.DeleteBytes(result, "output_config.effort")
+ if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
+ result, _ = sjson.DeleteBytes(result, "output_config")
+ }
+ return result
+}
+
// normalizeClaudeBudget applies Claude-specific constraints to ensure max_tokens > budget_tokens.
// Anthropic API requires this constraint; violating it returns a 400 error.
func (a *Applier) normalizeClaudeBudget(body []byte, budgetTokens int, modelInfo *registry.ModelInfo) []byte {
@@ -231,15 +236,7 @@ func applyCompatibleClaude(body []byte, config thinking.ThinkingConfig) ([]byte,
switch config.Mode {
case thinking.ModeNone:
- result, _ := sjson.SetBytes(body, "thinking.type", "disabled")
- result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
- // Summary display only applies to an active thinking block.
- result, _ = sjson.DeleteBytes(result, "thinking.display")
- result, _ = sjson.DeleteBytes(result, "output_config.effort")
- if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
- result, _ = sjson.DeleteBytes(result, "output_config")
- }
- return result, nil
+ return disableThinking(body, nil), nil
case thinking.ModeAuto:
result, _ := sjson.SetBytes(body, "thinking.type", "enabled")
result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
@@ -259,6 +256,9 @@ func applyCompatibleClaude(body []byte, config thinking.ThinkingConfig) ([]byte,
result, _ = sjson.SetBytes(result, "output_config.effort", string(config.Level))
return result, nil
default:
+ if config.Budget == 0 {
+ return disableThinking(body, nil), nil
+ }
result, _ := sjson.SetBytes(body, "thinking.type", "enabled")
result, _ = sjson.SetBytes(result, "thinking.budget_tokens", config.Budget)
result, _ = sjson.DeleteBytes(result, "output_config.effort")
diff --git a/internal/thinking/suffix.go b/internal/thinking/suffix.go
index 7f2959da5e7..5df8dec99e7 100644
--- a/internal/thinking/suffix.go
+++ b/internal/thinking/suffix.go
@@ -43,6 +43,12 @@ func ParseSuffix(model string) SuffixResult {
}
}
+// HasThinkingSuffix reports whether the model suffix defines a thinking configuration.
+func HasThinkingSuffix(model string) bool {
+ result := ParseSuffix(model)
+ return result.HasSuffix && hasThinkingConfig(parseSuffixToConfig(result.RawSuffix, "", result.ModelName))
+}
+
// ParseNumericSuffix attempts to parse a raw suffix as a numeric budget value.
//
// This function parses the raw suffix content (from ParseSuffix.RawSuffix) as an integer.
diff --git a/internal/thinking/validate.go b/internal/thinking/validate.go
index 1be8db48554..fe99eecafa4 100644
--- a/internal/thinking/validate.go
+++ b/internal/thinking/validate.go
@@ -167,10 +167,24 @@ func ValidateConfig(config ThinkingConfig, modelInfo *registry.ModelInfo, fromFo
}
if config.Mode == ModeNone && toFormat == "claude" {
- // Claude supports explicit disable via thinking.type="disabled".
- // Keep Budget=0 so applier can omit budget_tokens.
- config.Budget = 0
- config.Level = ""
+ if !support.ZeroAllowed {
+ if lowest, ok := lowestSupportedLevel(support.Levels); ok {
+ // Adaptive-only Claude models (for example claude-opus-5-5, claude-fable-5,
+ // and claude-fable-5-1) reject thinking.type="disabled". Like other models
+ // that cannot be disabled, fall back to the lowest supported level.
+ config.Mode = ModeLevel
+ config.Budget = 0
+ config.Level = lowest
+ } else {
+ config.Budget = 0
+ config.Level = ""
+ }
+ } else {
+ // Claude supports explicit disable via thinking.type="disabled" (or the
+ // model's DisableType). Keep Budget=0 so applier can omit budget_tokens.
+ config.Budget = 0
+ config.Level = ""
+ }
} else {
switch config.Mode {
case ModeBudget, ModeAuto, ModeNone:
@@ -183,7 +197,9 @@ func ValidateConfig(config ThinkingConfig, modelInfo *registry.ModelInfo, fromFo
// their Min/Max range is zero.
cannotDisableLevelModel := !support.ZeroAllowed && !isLevelSupported(string(LevelNone), support.Levels)
if config.Mode == ModeNone && len(support.Levels) > 0 && (config.Budget > 0 || cannotDisableLevelModel) {
- config.Level = ThinkingLevel(support.Levels[0])
+ if lowest, ok := lowestSupportedLevel(support.Levels); ok {
+ config.Level = lowest
+ }
}
}
@@ -239,6 +255,23 @@ func convertAutoToMidRange(config ThinkingConfig, support *registry.ThinkingSupp
// standardLevelOrder defines the canonical ordering of thinking levels from lowest to highest.
var standardLevelOrder = []ThinkingLevel{LevelMinimal, LevelLow, LevelMedium, LevelHigh, LevelXHigh, LevelMax}
+// lowestSupportedLevel returns the lowest canonical effort level in supported,
+// ignoring mode markers such as "none" and "auto".
+func lowestSupportedLevel(supported []string) (ThinkingLevel, bool) {
+ bestIdx := -1
+ for _, s := range supported {
+ if idx := levelIndex(strings.TrimSpace(s)); idx != -1 {
+ if bestIdx == -1 || idx < bestIdx {
+ bestIdx = idx
+ }
+ }
+ }
+ if bestIdx >= 0 {
+ return standardLevelOrder[bestIdx], true
+ }
+ return "", false
+}
+
// clampLevel clamps the given level to the nearest supported level.
// On tie, prefers the lower level.
func clampLevel(level ThinkingLevel, modelInfo *registry.ModelInfo, provider string) ThinkingLevel {
diff --git a/internal/translator/antigravity/claude/antigravity_claude_response.go b/internal/translator/antigravity/claude/antigravity_claude_response.go
index c1ecddecc7d..5360092c9ff 100644
--- a/internal/translator/antigravity/claude/antigravity_claude_response.go
+++ b/internal/translator/antigravity/claude/antigravity_claude_response.go
@@ -89,6 +89,10 @@ type Params struct {
// Reverse map: sanitized Gemini function name → original Claude tool name.
// Populated lazily on the first response chunk from the original request JSON.
ToolNameMap map[string]string
+
+ CurrentToolName string // Client-facing name of the currently open tool_use block
+ CurrentToolIsParameterless bool // Whether the open tool_use block was declared parameterless
+ CurrentToolInputEmitted bool // Whether cleaned input JSON was already emitted for the open tool_use block
}
// toolUseIDCounter provides a process-wide unique counter for tool use identifiers.
@@ -353,6 +357,27 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR
params.HasToolUse = true
fcName := util.RestoreSanitizedToolName(params.ToolNameMap, functionCallResult.Get("name").String())
+ if params.ResponseType == 3 && fcName == "" && params.CurrentToolName != "" {
+ if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
+ partialJSON := util.CleanClaudeToolArgumentsWithTranslated(fcArgsResult.Raw, params.CurrentToolName, originalRequestRawJSON, requestRawJSON)
+ if params.CurrentToolIsParameterless {
+ if params.CurrentToolInputEmitted {
+ partialJSON = ""
+ } else {
+ params.CurrentToolInputEmitted = true
+ }
+ } else if partialJSON != "" {
+ params.CurrentToolInputEmitted = true
+ }
+ data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, params.ResponseIndex)), "delta.partial_json", partialJSON)
+ appendEvent("content_block_delta", string(data))
+ }
+ params.HasContent = true
+ params.HasSemanticContent = true
+ params.LastSemanticKind = geminiClaudeCarrierFunction
+ continue
+ }
+
// Handle state transitions when switching to function calls
// Close any existing function call block first
if params.ResponseType == 3 {
@@ -374,6 +399,9 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR
params.ResponseIndex++
}
+ params.CurrentToolName = fcName
+ params.CurrentToolIsParameterless = util.ClaudeToolIsParameterless(fcName, originalRequestRawJSON)
+ params.CurrentToolInputEmitted = false
// Start a new tool use content block
// This creates the structure for a function call in Claude Code format
// Create the tool use block with unique ID and function details
@@ -387,7 +415,17 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR
appendEvent("content_block_start", string(data))
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
- data, _ = sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, params.ResponseIndex)), "delta.partial_json", fcArgsResult.Raw)
+ cleanArgs := util.CleanClaudeToolArgumentsWithTranslated(fcArgsResult.Raw, fcName, originalRequestRawJSON, requestRawJSON)
+ if params.CurrentToolIsParameterless {
+ if params.CurrentToolInputEmitted {
+ cleanArgs = ""
+ } else {
+ params.CurrentToolInputEmitted = true
+ }
+ } else if cleanArgs != "" {
+ params.CurrentToolInputEmitted = true
+ }
+ data, _ = sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, params.ResponseIndex)), "delta.partial_json", cleanArgs)
appendEvent("content_block_delta", string(data))
}
params.ResponseType = 3
@@ -405,13 +443,19 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR
if usageResult := gjson.GetBytes(rawJSON, "response.usageMetadata"); usageResult.Exists() {
params.HasUsageMetadata = true
+ rawPromptTokenCount := usageResult.Get("promptTokenCount").Int()
params.CachedTokenCount = usageResult.Get("cachedContentTokenCount").Int()
- params.PromptTokenCount = usageResult.Get("promptTokenCount").Int() - params.CachedTokenCount
+ params.PromptTokenCount = rawPromptTokenCount - params.CachedTokenCount
+ if params.PromptTokenCount < 0 {
+ params.PromptTokenCount = 0
+ }
params.CandidatesTokenCount = usageResult.Get("candidatesTokenCount").Int()
params.ThoughtsTokenCount = usageResult.Get("thoughtsTokenCount").Int()
params.TotalTokenCount = usageResult.Get("totalTokenCount").Int()
if params.CandidatesTokenCount == 0 && params.TotalTokenCount > 0 {
- params.CandidatesTokenCount = params.TotalTokenCount - params.PromptTokenCount - params.ThoughtsTokenCount
+ // totalTokenCount includes the cached part of promptTokenCount, so the
+ // fallback must use the raw prompt count, not the cache-reduced one.
+ params.CandidatesTokenCount = params.TotalTokenCount - rawPromptTokenCount - params.ThoughtsTokenCount
if params.CandidatesTokenCount < 0 {
params.CandidatesTokenCount = 0
}
@@ -475,7 +519,9 @@ func appendFinalEvents(params *Params, output *[]byte, force bool) {
stopReason := resolveStopReason(params)
usageOutputTokens := params.CandidatesTokenCount + params.ThoughtsTokenCount
if usageOutputTokens == 0 && params.TotalTokenCount > 0 {
- usageOutputTokens = params.TotalTokenCount - params.PromptTokenCount
+ // PromptTokenCount excludes cached tokens for input_tokens, while
+ // TotalTokenCount includes them; add the cache back for the fallback.
+ usageOutputTokens = params.TotalTokenCount - (params.PromptTokenCount + params.CachedTokenCount)
if usageOutputTokens < 0 {
usageOutputTokens = 0
}
@@ -651,7 +697,8 @@ func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, or
}
if args := functionCall.Get("args"); args.Exists() && args.Raw != "" && gjson.Valid(args.Raw) && args.IsObject() {
- toolBlock, _ = sjson.SetRawBytes(toolBlock, "input", []byte(args.Raw))
+ cleanArgs := util.CleanClaudeToolArgumentsWithTranslated(args.Raw, name, originalRequestRawJSON, requestRawJSON)
+ toolBlock, _ = sjson.SetRawBytes(toolBlock, "input", []byte(cleanArgs))
}
blocks = append(blocks, toolBlock)
diff --git a/internal/translator/antigravity/claude/antigravity_claude_response_test.go b/internal/translator/antigravity/claude/antigravity_claude_response_test.go
index d37ad542ff5..80b6e70f645 100644
--- a/internal/translator/antigravity/claude/antigravity_claude_response_test.go
+++ b/internal/translator/antigravity/claude/antigravity_claude_response_test.go
@@ -238,6 +238,58 @@ func TestConvertAntigravityResponseToClaudeStream_EmptyCandidateClosesMessage(t
}
}
+func TestConvertAntigravityResponseToClaudeStream_ZeroCandidatesDoesNotCountCachedAsOutput(t *testing.T) {
+ requestJSON := []byte(`{"model":"gemini-3.8-flash-high"}`)
+ responseJSON := []byte(`{
+ "response": {
+ "candidates": [{
+ "content": {"role": "model", "parts": [{"text": ""}]},
+ "finishReason": "STOP"
+ }],
+ "usageMetadata": {"promptTokenCount": 142871, "cachedContentTokenCount": 140830, "candidatesTokenCount": 0, "thoughtsTokenCount": 0, "totalTokenCount": 142871},
+ "modelVersion": "gemini-3.8-flash-n",
+ "responseId": "resp_cached_zero_candidates"
+ }
+ }`)
+
+ var param any
+ output := bytes.Join(ConvertAntigravityResponseToClaude(context.Background(), "gemini-3.8-flash-high", requestJSON, requestJSON, responseJSON, ¶m), nil)
+ output = append(output, bytes.Join(ConvertAntigravityResponseToClaude(context.Background(), "gemini-3.8-flash-high", requestJSON, requestJSON, []byte("[DONE]"), ¶m), nil)...)
+
+ messageDelta := sseDataForEvent(t, string(output), "message_delta")
+ if got := gjson.Get(messageDelta, "usage.input_tokens").Int(); got != 2041 {
+ t.Fatalf("input_tokens = %d, want 2041: %s", got, messageDelta)
+ }
+ if outputTokens := gjson.Get(messageDelta, "usage.output_tokens"); !outputTokens.Exists() || outputTokens.Int() != 0 {
+ t.Fatalf("output_tokens = %s, want 0: %s", outputTokens.Raw, messageDelta)
+ }
+ if got := gjson.Get(messageDelta, "usage.cache_read_input_tokens").Int(); got != 140830 {
+ t.Fatalf("cache_read_input_tokens = %d, want 140830: %s", got, messageDelta)
+ }
+}
+
+func TestConvertAntigravityResponseToClaudeStream_AppendFinalEventsFallbackUsesRawPrompt(t *testing.T) {
+ params := &Params{
+ HasUsageMetadata: true,
+ HasContent: true,
+ PromptTokenCount: 2041,
+ CachedTokenCount: 140830,
+ CandidatesTokenCount: 0,
+ ThoughtsTokenCount: 0,
+ TotalTokenCount: 142871,
+ }
+ output := make([]byte, 0, 256)
+ appendFinalEvents(params, &output, true)
+
+ messageDelta := sseDataForEvent(t, string(output), "message_delta")
+ if outputTokens := gjson.Get(messageDelta, "usage.output_tokens"); !outputTokens.Exists() || outputTokens.Int() != 0 {
+ t.Fatalf("output_tokens = %s, want 0: %s", outputTokens.Raw, messageDelta)
+ }
+ if got := gjson.Get(messageDelta, "usage.cache_read_input_tokens").Int(); got != 140830 {
+ t.Fatalf("cache_read_input_tokens = %d, want 140830: %s", got, messageDelta)
+ }
+}
+
func testEmptyAntigravityResponse() []byte {
return []byte(`{
"response": {
@@ -1565,3 +1617,78 @@ func TestConvertAntigravityResponseToClaudeStream_EmptyTextPartFollowedByThinkin
t.Fatalf("expected exactly 2 stopped blocks (0=thinking, 1=text), got: %v", stopped)
}
}
+
+func TestConvertAntigravityResponseToClaude_SanitizesParameterlessToolArguments(t *testing.T) {
+ origRequest := []byte(`{"model":"claude-sonnet-4-6","tools":[{"name":"EnterPlanMode","input_schema":{"type":"object","properties":{}}}]}`)
+ translatedRequest := []byte(`{"model":"gemini-3.8-flash-high"}`)
+ responseJSON := []byte(`{"response":{"candidates":[{"content":{"parts":[{"functionCall":{"name":"EnterPlanMode","args":{"reason":"Switching to plan mode to inspect files"}}}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.8-flash-high","responseId":"resp-1"}}`)
+
+ nonStream := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "claude-sonnet-4-6", origRequest, translatedRequest, responseJSON, nil)
+ if got := gjson.GetBytes(nonStream, "content.0.input").Raw; got != "{}" {
+ t.Fatalf("expected non-streaming input {}, got %s", got)
+ }
+
+ var param any
+ streamChunks := ConvertAntigravityResponseToClaude(context.Background(), "claude-sonnet-4-6", origRequest, translatedRequest, responseJSON, ¶m)
+ streamText := string(bytes.Join(streamChunks, nil))
+ var deltaPartial strings.Builder
+ for _, line := range strings.Split(streamText, "\n") {
+ if strings.HasPrefix(line, "data: ") {
+ payload := strings.TrimPrefix(line, "data: ")
+ if gjson.Get(payload, "delta.type").String() == "input_json_delta" {
+ deltaPartial.WriteString(gjson.Get(payload, "delta.partial_json").String())
+ }
+ }
+ }
+ if got := deltaPartial.String(); got != "{}" {
+ t.Fatalf("expected streaming partial_json {}, got %q (full output: %s)", got, streamText)
+ }
+}
+
+func TestConvertAntigravityResponseToClaude_StripsExecutorInjectedUnderscore(t *testing.T) {
+ origRequest := []byte(`{"model":"claude-sonnet-4-6","tools":[{"name":"WrappedPlaceholder","input_schema":{"type":"object","properties":{"flag":{"type":"string"}},"additionalProperties":false}}]}`)
+ translatedRequest, errConvert := ConvertClaudeRequestToAntigravity("claude-sonnet-4-6", origRequest, false)
+ if errConvert != nil {
+ t.Fatalf("ConvertClaudeRequestToAntigravity error: %v", errConvert)
+ }
+ responseJSON := []byte(`{"response":{"candidates":[{"content":{"parts":[{"functionCall":{"name":"WrappedPlaceholder","args":{"flag":"x","_":true}}}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.8-flash-high","responseId":"resp-wrapped"}}`)
+
+ nonStream := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "claude-sonnet-4-6", origRequest, translatedRequest, responseJSON, nil)
+ if got := gjson.GetBytes(nonStream, "content.0.input").Raw; got != `{"flag":"x"}` {
+ t.Fatalf("expected executor-injected underscore stripped, got %s (translated: %s)", got, string(translatedRequest))
+ }
+}
+
+func TestConvertAntigravityResponseToClaude_ParameterlessContinuationDoesNotDuplicateEmptyObject(t *testing.T) {
+ origRequest := []byte(`{"model":"claude-sonnet-4-6","tools":[{"name":"EnterPlanMode","input_schema":{"type":"object","properties":{}}}]}`)
+ translatedRequest := []byte(`{"model":"gemini-3.8-flash-high"}`)
+ firstChunk := []byte(`{"response":{"candidates":[{"content":{"parts":[{"functionCall":{"name":"EnterPlanMode","args":{"reason":"Switching to plan mode"}}}]}}],"modelVersion":"gemini-3.8-flash-high","responseId":"resp-1"}}`)
+ continuationChunk := []byte(`{"response":{"candidates":[{"content":{"parts":[{"functionCall":{"args":{"reason":"more"}}}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.8-flash-high","responseId":"resp-1"}}`)
+
+ var param any
+ streamBytes := bytes.Join(ConvertAntigravityResponseToClaude(context.Background(), "claude-sonnet-4-6", origRequest, translatedRequest, firstChunk, ¶m), nil)
+ streamBytes = append(streamBytes, bytes.Join(ConvertAntigravityResponseToClaude(context.Background(), "claude-sonnet-4-6", origRequest, translatedRequest, continuationChunk, ¶m), nil)...)
+ streamText := string(streamBytes)
+ var deltaPartial strings.Builder
+ toolUseStarts := 0
+ for _, line := range strings.Split(streamText, "\n") {
+ if strings.HasPrefix(line, "data: ") {
+ payload := strings.TrimPrefix(line, "data: ")
+ if gjson.Get(payload, "type").String() == "content_block_start" && gjson.Get(payload, "content_block.type").String() == "tool_use" {
+ toolUseStarts++
+ }
+ if gjson.Get(payload, "delta.type").String() == "input_json_delta" {
+ deltaPartial.WriteString(gjson.Get(payload, "delta.partial_json").String())
+ }
+ }
+ }
+ if toolUseStarts != 1 {
+ t.Fatalf("expected exactly one tool_use block, got %d (full output: %s)", toolUseStarts, streamText)
+ }
+ if got := deltaPartial.String(); got != "{}" {
+ t.Fatalf("expected concatenated partial_json {}, got %q (full output: %s)", got, streamText)
+ }
+ if strings.Contains(streamText, `"name":""`) {
+ t.Fatalf("continuation must not open an unnamed tool_use block: %s", streamText)
+ }
+}
diff --git a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_tool_search_test.go b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_tool_search_test.go
new file mode 100644
index 00000000000..7299796a08e
--- /dev/null
+++ b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_tool_search_test.go
@@ -0,0 +1,78 @@
+package responses
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/tidwall/gjson"
+)
+
+func TestConvertOpenAIResponsesRequestToAntigravity_ExposesClientToolSearch(t *testing.T) {
+ raw := []byte(`{
+ "model": "gemini-3.8-flash-high",
+ "tools": [
+ {"type": "tool_search", "execution": "client", "description": "Search deferred tools.", "parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}}
+ ],
+ "input": [
+ {"type": "message", "role": "user", "content": "find tools"},
+ {"type": "tool_search_call", "execution": "client", "call_id": "search_1", "status": "completed", "arguments": {"query": "fixture"}},
+ {"type": "tool_search_output", "execution": "client", "call_id": "search_1", "status": "completed", "tools": [
+ {"type": "namespace", "name": "fixture_tools", "tools": [{"type": "function", "name": "ping", "defer_loading": true, "parameters": {"type": "object", "properties": {}}}]}
+ ]}
+ ]
+ }`)
+ out, err := ConvertOpenAIResponsesRequestToAntigravity("gemini-3.8-flash-high", raw, true)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ root := gjson.ParseBytes(out)
+
+ names := make([]string, 0)
+ root.Get("request.tools.0.functionDeclarations").ForEach(func(_, declaration gjson.Result) bool {
+ names = append(names, declaration.Get("name").String())
+ return true
+ })
+ if len(names) != 2 || names[0] != "tool_search" || names[1] != "fixture_tools__ping" {
+ t.Fatalf("functionDeclarations = %v, want [tool_search fixture_tools__ping]; raw: %s", names, out)
+ }
+ contents := root.Get("request.contents").Array()
+ if len(contents) != 3 {
+ t.Fatalf("expected 3 contents, got %d: %s", len(contents), root.Get("request.contents").Raw)
+ }
+ if got := contents[1].Get("parts.0.functionCall.name").String(); got != "tool_search" {
+ t.Fatalf("replayed search call name = %q, want tool_search", got)
+ }
+ if got := contents[2].Get("parts.0.functionResponse.name").String(); got != "tool_search" {
+ t.Fatalf("replayed search output name = %q, want tool_search", got)
+ }
+}
+
+func TestConvertAntigravityResponseToOpenAIResponses_ToolSearchCall(t *testing.T) {
+ originalRequest := []byte(`{"model":"gemini-3.8-flash-high","tools":[{"type":"tool_search","execution":"client"}],"input":"find tools"}`)
+ chunk := []byte(`data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"tool_search","args":{"query":"fixture"}}}]},"finishReason":"STOP"}],"responseId":"resp_ag_tool_search"}}`)
+
+ var param any
+ var done gjson.Result
+ for _, output := range ConvertAntigravityResponseToOpenAIResponses(context.Background(), "gemini-3.8-flash-high", originalRequest, nil, chunk, ¶m) {
+ payload := string(output)
+ if idx := strings.Index(payload, "data:"); idx >= 0 {
+ data := gjson.Parse(payload[idx+len("data:"):])
+ if data.Get("type").String() == "response.output_item.done" {
+ done = data
+ }
+ }
+ }
+ if !done.Exists() {
+ t.Fatalf("missing response.output_item.done event")
+ }
+ if got := done.Get("item.type").String(); got != "tool_search_call" {
+ t.Fatalf("item type = %q, want tool_search_call; raw: %s", got, done.Raw)
+ }
+ if got := done.Get("item.arguments.query").String(); got != "fixture" {
+ t.Fatalf("item arguments = %s", done.Get("item.arguments").Raw)
+ }
+ if got := done.Get("item.execution").String(); got != "client" {
+ t.Fatalf("item execution = %q", got)
+ }
+}
diff --git a/internal/translator/claude/gemini/claude_gemini_request.go b/internal/translator/claude/gemini/claude_gemini_request.go
index 08747fbe628..616e8645b79 100644
--- a/internal/translator/claude/gemini/claude_gemini_request.go
+++ b/internal/translator/claude/gemini/claude_gemini_request.go
@@ -114,6 +114,7 @@ func convertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
mi := registry.LookupModelInfo(modelName, "claude")
supportsAdaptive := mi != nil && mi.Thinking != nil && len(mi.Thinking.Levels) > 0
supportsMax := supportsAdaptive && thinking.HasLevel(mi.Thinking.Levels, string(thinking.LevelMax))
+ supportsDynamic := supportsAdaptive && mi.Thinking.DynamicAllowed
// MapToClaudeEffort normalizes levels (e.g. minimal→low, xhigh→high) to avoid
// validation errors since validate treats same-provider unsupported levels as errors.
@@ -124,15 +125,21 @@ func convertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
if thinkingLevel.Exists() {
level := strings.ToLower(strings.TrimSpace(thinkingLevel.String()))
if supportsAdaptive {
- switch level {
- case "":
- case "none":
+ switch {
+ case level == "":
+ case level == "none":
out, _ = sjson.SetBytes(out, "thinking.type", "disabled")
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
out, _ = sjson.DeleteBytes(out, "output_config.effort")
+ case level == "auto" && supportsDynamic:
+ out, _ = sjson.SetBytes(out, "thinking.type", "adaptive")
+ out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
+ out, _ = sjson.DeleteBytes(out, "output_config.effort")
default:
- if mapped, ok := thinking.MapToClaudeEffort(level, supportsMax); ok {
- level = mapped
+ if !thinking.HasLevel(mi.Thinking.Levels, level) {
+ if mapped, ok := thinking.MapToClaudeEffort(level, supportsMax); ok {
+ level = mapped
+ }
}
out, _ = sjson.SetBytes(out, "thinking.type", "adaptive")
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
@@ -162,16 +169,22 @@ func convertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
if thinkingBudget.Exists() {
budget := int(thinkingBudget.Int())
if supportsAdaptive {
- switch budget {
- case 0:
+ switch {
+ case budget == 0:
out, _ = sjson.SetBytes(out, "thinking.type", "disabled")
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
out, _ = sjson.DeleteBytes(out, "output_config.effort")
+ case budget == -1 && supportsDynamic:
+ out, _ = sjson.SetBytes(out, "thinking.type", "adaptive")
+ out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
+ out, _ = sjson.DeleteBytes(out, "output_config.effort")
default:
level, ok := thinking.ConvertBudgetToLevel(budget)
if ok {
- if mapped, okM := thinking.MapToClaudeEffort(level, supportsMax); okM {
- level = mapped
+ if !thinking.HasLevel(mi.Thinking.Levels, level) {
+ if mapped, okM := thinking.MapToClaudeEffort(level, supportsMax); okM {
+ level = mapped
+ }
}
out, _ = sjson.SetBytes(out, "thinking.type", "adaptive")
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request.go b/internal/translator/claude/openai/chat-completions/claude_openai_request.go
index 6c6cd1b682d..839a18cb58e 100644
--- a/internal/translator/claude/openai/chat-completions/claude_openai_request.go
+++ b/internal/translator/claude/openai/chat-completions/claude_openai_request.go
@@ -16,6 +16,11 @@ import (
"github.com/tidwall/sjson"
)
+const (
+ defaultClaudeChatMaxTokens = 32000
+ defaultFableChatMaxTokens = 64000
+)
+
// ConvertOpenAIRequestToClaude parses and transforms an OpenAI Chat Completions API request into Claude Code API format.
// It extracts the model name, system instruction, message contents, and tool declarations
// from the raw JSON request and returns them in the format expected by the Claude Code API.
@@ -53,6 +58,7 @@ func convertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream,
// Base Claude Code API template with default max_tokens value
out := []byte(`{"model":"","max_tokens":32000,"messages":[],"metadata":{}}`)
out, _ = sjson.SetBytes(out, "metadata.user_id", userID)
+ out, _ = sjson.SetBytes(out, "max_tokens", defaultClaudeChatMaxTokensForModel(modelName))
root := gjson.ParseBytes(rawJSON)
@@ -78,8 +84,10 @@ func convertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream,
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
out, _ = sjson.DeleteBytes(out, "output_config.effort")
default:
- if mapped, ok := thinking.MapToClaudeEffort(effort, supportsMax); ok {
- effort = mapped
+ if !thinking.HasLevel(mi.Thinking.Levels, effort) {
+ if mapped, ok := thinking.MapToClaudeEffort(effort, supportsMax); ok {
+ effort = mapped
+ }
}
out, _ = sjson.SetBytes(out, "thinking.type", "adaptive")
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
@@ -577,10 +585,29 @@ func convertOpenAIToolResultContent(content gjson.Result) (string, bool) {
return content.Raw, false
}
-// firstExisting returns the first result that exists, or an empty result.
+func defaultClaudeChatMaxTokensForModel(modelName string) int {
+ normalized := strings.ToLower(strings.TrimSpace(modelName))
+ maxTokens := defaultClaudeChatMaxTokens
+ if strings.Contains(normalized, "fable") {
+ maxTokens = defaultFableChatMaxTokens
+ }
+ info := registry.LookupModelInfo(modelName, "claude")
+ if info == nil || info.MaxCompletionTokens <= 0 {
+ return maxTokens
+ }
+ // Fable keeps its conservative omitted-field ceiling. Every other registered
+ // model uses its output limit, including when that limit is above the
+ // historical 32000 default.
+ if strings.Contains(normalized, "fable") && info.MaxCompletionTokens >= maxTokens {
+ return maxTokens
+ }
+ return info.MaxCompletionTokens
+}
+
+// firstExisting returns the first non-null result that exists, or an empty result.
func firstExisting(values ...gjson.Result) gjson.Result {
for _, value := range values {
- if value.Exists() {
+ if value.Exists() && value.Type != gjson.Null {
return value
}
}
diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go b/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go
index ebc4a3962a5..022534b2509 100644
--- a/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go
+++ b/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go
@@ -781,7 +781,7 @@ func TestConvertOpenAIRequestToClaude_MaxTokensAndMaxCompletionTokens(t *testing
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
- out, _ := ConvertOpenAIRequestToClaude("claude-3-7-sonnet-20250219", []byte(tc.rawJSON), false)
+ out, _ := ConvertOpenAIRequestToClaude("claude-opus-4-1-20250805", []byte(tc.rawJSON), false)
got := gjson.GetBytes(out, "max_tokens").Int()
if got != tc.wantLimit {
t.Fatalf("max_tokens = %d, want %d. Output: %s", got, tc.wantLimit, string(out))
@@ -790,6 +790,76 @@ func TestConvertOpenAIRequestToClaude_MaxTokensAndMaxCompletionTokens(t *testing
}
}
+func TestConvertOpenAIRequestToClaude_OmittedMaxTokensUsesRegistryLimit(t *testing.T) {
+ tests := []struct {
+ name string
+ model string
+ rawJSON string
+ wantLimit int64
+ }{
+ {
+ name: "opus 5.5 omitted field uses 128k registry limit",
+ model: "claude-opus-5-5",
+ rawJSON: `{"model":"claude-opus-5-5","messages":[{"role":"user","content":"hi"}]}`,
+ wantLimit: 128000,
+ },
+ {
+ name: "opus 5.5 null max_tokens uses 128k registry limit",
+ model: "claude-opus-5-5",
+ rawJSON: `{"model":"claude-opus-5-5","max_tokens":null,"messages":[{"role":"user","content":"hi"}]}`,
+ wantLimit: 128000,
+ },
+ {
+ name: "opus 5.5 null max_completion_tokens uses 128k registry limit",
+ model: "claude-opus-5-5",
+ rawJSON: `{"model":"claude-opus-5-5","max_completion_tokens":null,"messages":[{"role":"user","content":"hi"}]}`,
+ wantLimit: 128000,
+ },
+ {
+ name: "null max_tokens falls back to explicit max_completion_tokens",
+ model: "claude-opus-5-5",
+ rawJSON: `{"model":"claude-opus-5-5","max_tokens":null,"max_completion_tokens":4096,"messages":[{"role":"user","content":"hi"}]}`,
+ wantLimit: 4096,
+ },
+ {
+ name: "sonnet 4.6 omitted field uses 64k registry limit",
+ model: "claude-sonnet-4-6",
+ rawJSON: `{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"hi"}]}`,
+ wantLimit: 64000,
+ },
+ {
+ name: "fable 5.1 omitted field keeps conservative 64k ceiling",
+ model: "claude-fable-5-1",
+ rawJSON: `{"model":"claude-fable-5-1","messages":[{"role":"user","content":"hi"}]}`,
+ wantLimit: 64000,
+ },
+ {
+ name: "haiku 3.5 omitted field uses 8192 registry limit",
+ model: "claude-3-5-haiku-20241022",
+ rawJSON: `{"model":"claude-3-5-haiku-20241022","messages":[{"role":"user","content":"hi"}]}`,
+ wantLimit: 8192,
+ },
+ {
+ name: "unregistered model omitted field falls back to 32000",
+ model: "claude-test",
+ rawJSON: `{"model":"claude-test","messages":[{"role":"user","content":"hi"}]}`,
+ wantLimit: 32000,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ out, errConvert := ConvertOpenAIRequestToClaude(tc.model, []byte(tc.rawJSON), false)
+ if errConvert != nil {
+ t.Fatalf("convert: %v", errConvert)
+ }
+ if got := gjson.GetBytes(out, "max_tokens").Int(); got != tc.wantLimit {
+ t.Fatalf("max_tokens = %d, want %d. Output: %s", got, tc.wantLimit, string(out))
+ }
+ })
+ }
+}
+
func TestConvertOpenAIRequestToClaude_PreservesCallerSuppliedMetadataUserID(t *testing.T) {
testCases := []struct {
name string
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request.go b/internal/translator/claude/openai/responses/claude_openai-responses_request.go
index 0e927946852..ca0fc307e9a 100644
--- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go
@@ -97,8 +97,10 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
out, _ = sjson.DeleteBytes(out, "output_config.effort")
default:
- if mapped, ok := thinking.MapToClaudeEffort(effort, supportsMax); ok {
- effort = mapped
+ if !thinking.HasLevel(mi.Thinking.Levels, effort) {
+ if mapped, ok := thinking.MapToClaudeEffort(effort, supportsMax); ok {
+ effort = mapped
+ }
}
out, _ = sjson.SetBytes(out, "thinking.type", "adaptive")
out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
@@ -529,12 +531,13 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
}
emittedToolResults[rawID] = struct{}{}
}
- output := item.Get("output")
+ outputItem := item
if rawID != "" {
if lastItem, exists := lastToolResult[rawID]; exists {
- output = lastItem.Get("output")
+ outputItem = lastItem
}
}
+ output := outputItem.Get("output")
// Standalone outputs (no call_id, or one that never paired with a
// function_call in this input, e.g. the create_thread delegation seed)
// have no tool_use to attach to. Claude rejects orphan tool_result
@@ -548,6 +551,12 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
callID := util.SanitizeClaudeToolID(rawID)
toolResult := []byte(`{"type":"tool_result","tool_use_id":"","content":""}`)
toolResult, _ = sjson.SetBytes(toolResult, "tool_use_id", callID)
+ // Responses reports an unfinished tool output as status
+ // "incomplete"; Claude's only in-band equivalent is is_error
+ // on tool_result, so carry the signal over instead of dropping it.
+ if strings.EqualFold(strings.TrimSpace(outputItem.Get("status").String()), "incomplete") {
+ toolResult, _ = sjson.SetBytes(toolResult, "is_error", true)
+ }
toolResult = applyResponsesToolResultContent(toolResult, output)
appendParts("user", toolResult)
@@ -635,6 +644,9 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
// one winner for each final name, while keeping the original order for the
// tools that survive conversion.
var toolItems [][]byte
+ // The deferred anchor goes between declared and discovered tools, so each
+ // discovery only appends to tools[].
+ anchorIndex := -1
for _, descriptor := range responsesToolDescriptors(root, toolSearch) {
winner, ok := winners[descriptor.name]
if !ok || winner.order != descriptor.order {
@@ -646,6 +658,9 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
continue
}
if nativeToolSearch && descriptor.sourcePriority == responsesDiscoveredToolPriority {
+ if anchorIndex < 0 {
+ anchorIndex = len(toolItems)
+ }
// Anthropic rejects cache_control on deferred tools.
tJSON, _ = sjson.DeleteBytes(tJSON, "cache_control")
tJSON, _ = sjson.SetBytes(tJSON, "defer_loading", true)
@@ -657,6 +672,14 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
}
toolItems = append(toolItems, tJSON)
}
+ if nativeToolSearch {
+ if anchorIndex < 0 {
+ anchorIndex = len(toolItems)
+ }
+ if anchor, ok := responsesDeferredAnchorTool(toolItems); ok {
+ toolItems = append(toolItems[:anchorIndex], append([][]byte{anchor}, toolItems[anchorIndex:]...)...)
+ }
+ }
toolNameMap = responsesToolNameMap(root, includedToolNames, toolSearch)
if len(toolItems) > 0 {
out, _ = sjson.SetRawBytes(out, "tools", common.JoinRawArray(toolItems))
@@ -746,7 +769,7 @@ func isResponsesSystemLevelRole(role string) bool {
// dropUnsupportedClaudeAssistantPrefill removes a trailing assistant message for
// Claude model families that reject assistant message prefill (e.g., Fable,
-// Opus 5, Sonnet 4.6).
+// Opus 5, Sonnet 4.6, Haiku 5.5).
func dropUnsupportedClaudeAssistantPrefill(modelName string, messages [][]byte) [][]byte {
if !claudeModelRejectsAssistantPrefill(modelName) || len(messages) == 0 {
return messages
@@ -823,7 +846,7 @@ func claudeModelRejectsAssistantPrefill(modelName string) bool {
if tokens[0] == "fable" {
return true
}
- if len(tokens) < 2 || (tokens[0] != "opus" && tokens[0] != "sonnet") {
+ if len(tokens) < 2 || (tokens[0] != "opus" && tokens[0] != "sonnet" && tokens[0] != "haiku") {
return false
}
parseVersion := func(token string) int {
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go
index 42726e48392..ff145f52b31 100644
--- a/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go
@@ -2136,6 +2136,11 @@ func TestConvertOpenAIResponsesRequestToClaude_UnsupportedPrefillModelsStripTrai
"claude-opus-5.1",
"claude-sonnet-4-6-20260217",
" ANTHROPIC/CLAUDE-OPUS-5-THINKING ",
+ "claude-haiku-5-5",
+ "claude-haiku-5",
+ "claude-haiku-5.5",
+ "haiku-5-5",
+ "anthropic/claude-haiku-5-5",
}
for _, model := range unsupportedModels {
t.Run(model, func(t *testing.T) {
@@ -2165,6 +2170,8 @@ func TestConvertOpenAIResponsesRequestToClaude_SupportedPrefillModelsPreserveAss
supportedModels := []string{
"claude-sonnet-4-5",
"claude-haiku-4-5",
+ "claude-haiku-5foo",
+ "claude-haiku-20260217",
"claude-3-opus-20240229",
"claude-opus-20240229",
"claude-sonnet-4-20260217",
@@ -2948,3 +2955,46 @@ func TestApplyPatchClaudeRequestContractAndHistory(t *testing.T) {
t.Fatalf("history mismatch: %s", result.Raw)
}
}
+
+func TestConvertOpenAIResponsesRequestToClaude_IncompleteToolOutputIsError(t *testing.T) {
+ inputJSON := `{
+ "model": "gpt-4o",
+ "tools": [{"type":"function","name":"get_weather","description":"Get weather for a city","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}],
+ "input": [
+ {"type":"message","role":"user","content":"Weather in Atlantis?"},
+ {"type":"function_call","call_id":"call_err1","name":"get_weather","arguments":"{\"city\":\"Atlantis\"}"},
+ {"type":"function_call_output","call_id":"call_err1","status":"incomplete","output":"City not found"},
+ {"type":"message","role":"user","content":"Did it fail?"}
+ ],
+ "max_output_tokens": 128,
+ "stream": false
+ }`
+
+ result, err := ConvertOpenAIResponsesRequestToClaude("claude-opus-5-5", []byte(inputJSON), false)
+ if err != nil {
+ t.Fatalf("ConvertOpenAIResponsesRequestToClaude error: %v", err)
+ }
+ resultJSON := gjson.ParseBytes(result)
+ var toolResult gjson.Result
+ found := false
+ resultJSON.Get("messages").ForEach(func(_, message gjson.Result) bool {
+ message.Get("content").ForEach(func(_, block gjson.Result) bool {
+ if block.Get("type").String() == "tool_result" && block.Get("tool_use_id").String() == "call_err1" {
+ toolResult = block
+ found = true
+ return false
+ }
+ return true
+ })
+ return !found
+ })
+ if !found {
+ t.Fatalf("expected tool_result for call_err1. Output: %s", string(result))
+ }
+ if got := toolResult.Get("content").String(); got != "City not found" {
+ t.Fatalf("tool_result content = %q, want %q. Output: %s", got, "City not found", string(result))
+ }
+ if !toolResult.Get("is_error").Bool() {
+ t.Fatalf("tool_result is_error should be true for status incomplete. Output: %s", string(result))
+ }
+}
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_response.go b/internal/translator/claude/openai/responses/claude_openai-responses_response.go
index aee38924a3e..383c37e514d 100644
--- a/internal/translator/claude/openai/responses/claude_openai-responses_response.go
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_response.go
@@ -19,7 +19,10 @@ type claudeToResponsesState struct {
translatorcommon.ApplyPatchErrorState
ApplyPatchCalls map[string]*translatorcommon.ApplyPatchCallState
// ToolSearch is upstream.claude.tool-search as pinned for this response.
- ToolSearch bool
+ ToolSearch bool
+ // RequestJSON is the validated request body picked once per stream;
+ // the per-chunk path reuses it instead of re-validating the full body.
+ RequestJSON []byte
ToolWinners map[string]responsesToolDescriptor
ToolNames claudeToolNames
FuncItemAdded map[int]bool
@@ -638,6 +641,7 @@ func newClaudeToResponsesState(requestJSON []byte, toolSearch bool) *claudeToRes
winners := responsesToolWinners(root, toolSearch)
return &claudeToResponsesState{
ToolSearch: toolSearch,
+ RequestJSON: requestJSON,
ToolWinners: winners,
ToolNames: buildClaudeToolNamesWithWinners(root, winners, toolSearch),
ApplyPatchCalls: make(map[string]*translatorcommon.ApplyPatchCallState),
@@ -675,7 +679,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
}
rawJSON = bytes.TrimSpace(rawJSON[5:])
root := gjson.ParseBytes(rawJSON)
- requestForToolMetadata := pickRequestJSON(originalRequestRawJSON, requestRawJSON)
+ requestForToolMetadata := st.RequestJSON
ev := root.Get("type").String()
var out [][]byte
@@ -1009,7 +1013,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
}
// Inject original request fields into response as per docs/response.completed.json
- reqBytes := pickRequestJSON(originalRequestRawJSON, requestRawJSON)
+ reqBytes := st.RequestJSON
if len(reqBytes) > 0 {
req := gjson.ParseBytes(reqBytes)
if v := req.Get("instructions"); v.Exists() {
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go
index 96a171c3d5e..8066707fd8d 100644
--- a/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go
@@ -2425,3 +2425,78 @@ func TestApplyPatchClaudeSuccessfulTerminalSealsState(t *testing.T) {
}
}
}
+
+// The request body is picked and validated once when the per-stream state is
+// created; later chunks reuse that pinned copy for tool metadata and the
+// completed event instead of re-picking (and re-validating) the full body on
+// every chunk. Regression test for issue #6665.
+func TestConvertClaudeResponseToOpenAIResponses_PinsRequestPerStream(t *testing.T) {
+ originalRequest := []byte(`{
+ "model":"gpt-test",
+ "instructions":"pinned-instructions",
+ "input":[{"type":"additional_tools","role":"developer","tools":[
+ {"type":"namespace","name":"functions","tools":[{"type":"custom","name":"exec"}]}
+ ]}]
+ }`)
+ chunks := [][]byte{
+ []byte(`data: {"type":"message_start","message":{"id":"msg_pinned","usage":{"input_tokens":1,"output_tokens":0}}}`),
+ []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"call_pinned","name":"functions__exec","input":{}}}`),
+ []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"input\":\"pwd\"}"}}`),
+ []byte(`data: {"type":"content_block_stop","index":0}`),
+ []byte(`data: {"type":"message_stop"}`),
+ }
+
+ var param any
+ var added, completed gjson.Result
+ for i, chunk := range chunks {
+ // Only the first chunk carries the request copy; the pinned state
+ // must keep tool metadata and completed-event fields working even
+ // when later chunks do not carry the request again.
+ var requestForChunk []byte
+ if i == 0 {
+ requestForChunk = originalRequest
+ }
+ for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-test", requestForChunk, nil, chunk, ¶m) {
+ event, data := parseClaudeResponsesSSEEvent(t, output)
+ switch event {
+ case "response.output_item.added":
+ if data.Get("item.type").String() == "custom_tool_call" {
+ added = data
+ }
+ case "response.completed":
+ completed = data
+ }
+ }
+ }
+
+ if !added.Exists() || !completed.Exists() {
+ t.Fatalf("missing lifecycle events: added=%v completed=%v", added.Exists(), completed.Exists())
+ }
+ if got := added.Get("item.name").String(); got != "exec" {
+ t.Fatalf("added name = %q, want exec", got)
+ }
+ if got := added.Get("item.namespace").String(); got != "functions" {
+ t.Fatalf("added namespace = %q, want functions", got)
+ }
+ if got := completed.Get("response.instructions").String(); got != "pinned-instructions" {
+ t.Fatalf("completed instructions = %q, want pinned-instructions", got)
+ }
+}
+
+func BenchmarkConvertClaudeResponseToOpenAIResponses_TextDelta(b *testing.B) {
+ // A 64 KiB request body, as a Codex session with a long history sends.
+ request := []byte(`{"model":"claude-test","instructions":"` + strings.Repeat("x", 64*1024) + `","tools":[{"name":"shell","description":"run","input_schema":{"type":"object"}}]}`)
+ start := []byte(`data: {"type":"message_start","message":{"id":"msg_1"}}`)
+ blockStart := []byte(`data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`)
+ delta := []byte(`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"token "}}`)
+
+ var param any
+ ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-test", request, nil, start, ¶m)
+ ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-test", request, nil, blockStart, ¶m)
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-test", request, nil, delta, ¶m)
+ }
+}
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_tool_search.go b/internal/translator/claude/openai/responses/claude_openai-responses_tool_search.go
index 7b60cabcc65..3c47af15790 100644
--- a/internal/translator/claude/openai/responses/claude_openai-responses_tool_search.go
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_tool_search.go
@@ -19,15 +19,20 @@ import (
// tool, the call and output become a tool_use/tool_result pair, and every tool
// discovered in history is declared on later requests.
//
-// Declaring a newly discovered tool changes the tools[] prefix, so the request
-// right after a discovery misses Anthropic's prompt cache once; later turns
-// keep the same prefix and cache normally.
+// With native tool search, discovered tools are declared with defer_loading
+// and referenced through tool_reference blocks. Anthropic keeps the prompt
+// cache when deferred tools are added, but not when a request goes from no
+// deferred tool to one, so a constant deferred anchor is declared from the
+// first request on and discoveries only append after it.
const (
// responsesToolSearchType is both the declaration type and the tool name,
// as Codex names the tool after its type.
responsesToolSearchType = "tool_search"
// responsesToolSearchIDPrefix mirrors Codex's item id prefix for tool_search_call.
responsesToolSearchIDPrefix = "tsc_"
+ // responsesDeferredAnchorName names the constant deferred tool declared with
+ // native tool search; nothing ever references it.
+ responsesDeferredAnchorName = "tool_search_deferred_anchor"
)
// clientToolSearchEnabled mirrors upstream.claude.tool-search. While it is off
@@ -71,6 +76,21 @@ func ClientToolSearchEnabledFor(ctx context.Context) bool {
const claudeToolSearchDefaultSchema = `{"type":"object","properties":{"query":{"type":"string","description":"Search query for deferred tools."},"limit":{"type":"number","description":"Maximum number of tools to return."}},"required":["query"],"additionalProperties":false}`
+const responsesDeferredAnchorJSON = `{"name":"` + responsesDeferredAnchorName + `","description":"Placeholder that is never loaded; tool search returns the real tools.","input_schema":{"type":"object","properties":{}},"defer_loading":true}`
+
+// responsesDeferredAnchorTool returns the deferred anchor declaration, unless a
+// converted tool already carries its Claude name (names are normalized during
+// conversion, so the check runs on the final tools[]). On a clash the request
+// keeps the client's tool and simply has no anchor.
+func responsesDeferredAnchorTool(toolItems [][]byte) ([]byte, bool) {
+ for _, tool := range toolItems {
+ if gjson.GetBytes(tool, "name").String() == responsesDeferredAnchorName {
+ return nil, false
+ }
+ }
+ return []byte(responsesDeferredAnchorJSON), true
+}
+
// isResponsesClientToolSearch reports whether a tool declaration asks the
// client to execute tool search. Hosted (server) search cannot run on Claude.
func isResponsesClientToolSearch(tool gjson.Result) bool {
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_tool_search_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_tool_search_test.go
index d84bc79fdf9..f3ae872ed34 100644
--- a/internal/translator/claude/openai/responses/claude_openai-responses_tool_search_test.go
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_tool_search_test.go
@@ -104,22 +104,23 @@ func TestConvertOpenAIResponsesRequestToClaude_HostedToolSearchIsNotForwarded(t
func TestConvertOpenAIResponsesRequestToClaude_ToolSearchNameIsCollisionSafe(t *testing.T) {
req := convertToolSearchRequest(t, []byte(`{"model":"claude-sonnet-4-5","input":"hi","tools":[{"type":"function","name":"tool.search","parameters":{"type":"object"}},`+codexToolSearchDeclaration+`]}`))
names := claudeToolNamesOf(req)
- if len(names) != 2 || names[1] != responsesToolSearchType || names[0] == names[1] {
+ // The third tool is the deferred anchor native tool search declares.
+ if len(names) != 3 || names[1] != responsesToolSearchType || names[0] == names[1] || names[2] != responsesDeferredAnchorName {
t.Fatalf("tool names = %v, want unique names with tool_search kept", names)
}
}
func TestConvertOpenAIResponsesRequestToClaude_ToolSearchOmitsDeferredTools(t *testing.T) {
req := convertToolSearchRequest(t, codexToolSearchRequest(codexUserTurn))
- if got := strings.Join(claudeToolNamesOf(req), ","); got != "exec_command,tool_search" {
- t.Fatalf("tools = %s, want deferred tools omitted", got)
+ if got := strings.Join(claudeToolNamesOf(req), ","); got != "exec_command,tool_search,"+responsesDeferredAnchorName {
+ t.Fatalf("tools = %s, want deferred tools omitted and only the anchor declared", got)
}
}
func TestConvertOpenAIResponsesRequestToClaude_ToolSearchHistoryReplaysAndActivatesTools(t *testing.T) {
turn2 := convertToolSearchRequest(t, codexToolSearchRequest(codexUserTurn, codexSearchCall1, codexSearchOutput1))
- wantTurn2 := "exec_command,tool_search,mcp__codex_apps__calendar_create_event,lookup_order"
+ wantTurn2 := "exec_command,tool_search," + responsesDeferredAnchorName + ",mcp__codex_apps__calendar_create_event,lookup_order"
if got := strings.Join(claudeToolNamesOf(turn2), ","); got != wantTurn2 {
t.Fatalf("turn 2 tools = %s, want %s", got, wantTurn2)
}
@@ -376,7 +377,7 @@ func TestConvertOpenAIResponsesRequestToClaude_ToolSearchNameCollisionRoutesCons
wantProp string
}{
{"user function first", userFunction + "," + codexToolSearchDeclaration, "tool_search,lookup_order", "function_call", "x"},
- {"client declaration first", codexToolSearchDeclaration + "," + userFunction, "tool_search", "tool_search_call", "query"},
+ {"client declaration first", codexToolSearchDeclaration + "," + userFunction, "tool_search," + responsesDeferredAnchorName, "tool_search_call", "query"},
} {
t.Run(tc.name, func(t *testing.T) {
request := []byte(`{"model":"claude-sonnet-4-5","input":"hi","tools":[` + tc.tools + `,` + codexDeferredTool + `]}`)
@@ -479,3 +480,83 @@ func TestConvertOpenAIResponsesRequestToClaude_DeferredToolsDropCacheControl(t *
}
}
}
+
+// Anthropic keeps the prompt cache when deferred tools are added, but only once
+// the request already carries a deferred tool: going from no deferred tools to
+// one misses the cache (measured on claude-opus-5-5, 2026-10-12). Codex hides
+// deferred tools until a search returns them, so native tool search always
+// declares one constant deferred anchor and discoveries only append after it.
+func TestConvertOpenAIResponsesRequestToClaude_DeferredAnchorKeepsToolsPrefixStable(t *testing.T) {
+ turn1 := convertToolSearchRequest(t, codexToolSearchRequest(codexUserTurn))
+ turn2 := convertToolSearchRequest(t, codexToolSearchRequest(codexUserTurn, codexSearchCall1, codexSearchOutput1))
+
+ wantTurn1 := "exec_command,tool_search," + responsesDeferredAnchorName
+ if got := strings.Join(claudeToolNamesOf(turn1), ","); got != wantTurn1 {
+ t.Fatalf("turn 1 tools = %s, want %s", got, wantTurn1)
+ }
+ anchor := turn1.Get(`tools.#(name=="` + responsesDeferredAnchorName + `")`)
+ if !anchor.Get("defer_loading").Bool() || anchor.Get("cache_control").Exists() || !anchor.Get("input_schema").IsObject() {
+ t.Fatalf("anchor = %s, want a deferred tool without cache_control", anchor.Raw)
+ }
+ turn1Tools, turn2Tools := turn1.Get("tools").Array(), turn2.Get("tools").Array()
+ if len(turn2Tools) <= len(turn1Tools) {
+ t.Fatalf("turn 2 tools = %s, want turn 1 tools plus discoveries", turn2.Get("tools").Raw)
+ }
+ for i, tool := range turn1Tools {
+ if turn2Tools[i].Raw != tool.Raw {
+ t.Fatalf("tool %d changed after discovery:\n%s\n%s", i, tool.Raw, turn2Tools[i].Raw)
+ }
+ }
+ references := turn2.Get("messages.2.content.0.content").Raw
+ if strings.Contains(references, responsesDeferredAnchorName) {
+ t.Fatalf("anchor must never be referenced: %s", references)
+ }
+}
+
+// A client tool whose final Claude name equals the anchor name, directly, after
+// name normalization, or as a discovered tool, keeps its declaration and the
+// request carries no second tool with that name.
+func TestConvertOpenAIResponsesRequestToClaude_DeferredAnchorYieldsToClientToolName(t *testing.T) {
+ declared := func(name string) string {
+ return `{"type":"function","name":"` + name + `","description":"Client tool.","parameters":{"type":"object","properties":{}}}`
+ }
+ discovered := `{"type":"tool_search_output","call_id":"call_search_1","status":"completed","execution":"client","tools":[{"type":"function","name":"` +
+ responsesDeferredAnchorName + `","description":"Client tool.","defer_loading":true,"parameters":{"type":"object","properties":{}}}]}`
+ for name, request := range map[string][]byte{
+ "exact declared name": []byte(`{"model":"claude-sonnet-4-5","input":"hi","tools":[` + codexToolSearchDeclaration + `,` + declared(responsesDeferredAnchorName) + `]}`),
+ "normalized name": []byte(`{"model":"claude-sonnet-4-5","input":"hi","tools":[` + codexToolSearchDeclaration + `,` + declared("tool.search.deferred.anchor") + `]}`),
+ "discovered tool": codexToolSearchRequest(codexUserTurn, codexSearchCall1, discovered),
+ } {
+ req := convertToolSearchRequest(t, request)
+ matches := req.Get(`tools.#(name=="` + responsesDeferredAnchorName + `")#`).Array()
+ if len(matches) != 1 || matches[0].Get("description").String() != "Client tool." {
+ t.Fatalf("%s: tools = %s, want only the client tool under the anchor name", name, req.Get("tools").Raw)
+ }
+ }
+}
+
+// Without native tool search nothing is deferred, so no anchor is declared.
+func TestConvertOpenAIResponsesRequestToClaude_NoDeferredAnchorWithoutNativeToolSearch(t *testing.T) {
+ userFunction := `{"type":"function","name":"tool_search","parameters":{"type":"object","properties":{"q":{"type":"string"}}}}`
+ for name, request := range map[string][]byte{
+ "no tool_search": []byte(`{"model":"claude-sonnet-4-5","input":"hi","tools":[` + codexExecCommandTool + `,` + codexDeferredTool + `]}`),
+ "user function wins name": []byte(`{"model":"claude-sonnet-4-5","input":"hi","tools":[` + userFunction + `,` + codexToolSearchDeclaration + `,` + codexDeferredTool + `]}`),
+ } {
+ req := convertToolSearchRequest(t, request)
+ if strings.Contains(req.Get("tools").Raw, responsesDeferredAnchorName) {
+ t.Fatalf("%s: anchor declared without native tool search: %s", name, req.Get("tools").Raw)
+ }
+ }
+ out, err := ConvertOpenAIResponsesRequestToClaude("claude-sonnet-4-5", codexToolSearchRequest(codexUserTurn), true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ setClientToolSearchEnabledForTest(t, false)
+ off, err := ConvertOpenAIResponsesRequestToClaude("claude-sonnet-4-5", codexToolSearchRequest(codexUserTurn), true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(out), responsesDeferredAnchorName) || strings.Contains(string(off), responsesDeferredAnchorName) {
+ t.Fatalf("switch on/off: on=%s off=%s", gjson.GetBytes(out, "tools").Raw, gjson.GetBytes(off, "tools").Raw)
+ }
+}
diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go
index 6585a6cd4b6..4e45a14fa2d 100644
--- a/internal/translator/codex/claude/codex_claude_request.go
+++ b/internal/translator/codex/claude/codex_claude_request.go
@@ -175,7 +175,8 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool,
}
rawSignature := part.Get("signature").String()
- signature, ok := sigcompat.CompatibleSignatureForProvider(sigcompat.SignatureProviderGPT, rawSignature)
+ signatureTargetModel := translatorcommon.ResolveCodexReplayTargetModel(modelName, rawJSON)
+ signature, ok := translatorcommon.CodexReplaySignatureForTarget(signatureTargetModel, rawSignature)
if !ok && preserveEmptyThinkingBlocks && part.Get("signature").Type == gjson.String && strings.TrimSpace(rawSignature) != "" &&
sigcompat.DetectSignatureProviderForBlock(rawSignature, sigcompat.SignatureBlockKindClaudeThinking) == sigcompat.SignatureProviderUnknown {
signature = rawSignature
@@ -185,13 +186,7 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool,
if preserveEmptyThinkingBlocks && strings.TrimSpace(rawSignature) == "" {
signature = rawSignature
} else {
- if !codexClaudeTargetAcceptsGrokSignature(modelName) {
- return
- }
- if _, err := sigcompat.InspectGrokEncryptedContent(rawSignature); err != nil {
- return
- }
- signature = rawSignature
+ return
}
}
@@ -464,11 +459,6 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool,
return template, drops.Err()
}
-func codexClaudeTargetAcceptsGrokSignature(modelName string) bool {
- baseModel := strings.ToLower(strings.TrimSpace(thinking.ParseSuffix(modelName).ModelName))
- return strings.Contains(baseModel, "grok")
-}
-
func normalizeCodexServiceTier(result gjson.Result) string {
if !result.Exists() || result.Type != gjson.String {
return ""
diff --git a/internal/translator/codex/claude/codex_claude_request_test.go b/internal/translator/codex/claude/codex_claude_request_test.go
index 694542cf57f..af2c6230f9a 100644
--- a/internal/translator/codex/claude/codex_claude_request_test.go
+++ b/internal/translator/codex/claude/codex_claude_request_test.go
@@ -714,6 +714,83 @@ func TestConvertClaudeRequestToCodex_AssistantGrokSignatureToReasoningItem(t *te
}
}
+func testMetaClaudeSignature() string {
+ // Shape-valid synthetic Meta envelope; no captured value is embedded.
+ signature := "Q-PaDgE"
+ for len(signature) < 1426 {
+ signature += "A"
+ }
+ return signature
+}
+
+func TestConvertClaudeRequestToCodex_AssistantMetaSignatureToReasoningItem(t *testing.T) {
+ signature := testMetaClaudeSignature()
+ payload := []byte(`{"model":"muse-spark-1.3","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"summary","signature":""},{"type":"text","text":"answer"}]},{"role":"user","content":"next"}]}`)
+ payload, _ = sjson.SetBytes(payload, "messages.0.content.0.signature", signature)
+
+ out, _ := ConvertClaudeRequestToCodex("muse-spark-1.3", payload, false)
+ reasoning := gjson.GetBytes(out, "input.0")
+ if reasoning.Get("type").String() != "reasoning" {
+ t.Fatalf("input.0 type = %q, want reasoning; output=%s", reasoning.Get("type").String(), out)
+ }
+ if got := reasoning.Get("encrypted_content").String(); got != signature {
+ t.Fatalf("encrypted_content = %q, want Meta signature", got)
+ }
+}
+
+func TestConvertClaudeRequestToCodex_IgnoresMetaSignatureForNonMetaTargets(t *testing.T) {
+ signature := testMetaClaudeSignature()
+ payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"summary","signature":""},{"type":"text","text":"answer"}]},{"role":"user","content":"next"}]}`)
+ payload, _ = sjson.SetBytes(payload, "messages.0.content.0.signature", signature)
+
+ for _, modelName := range []string{"gpt-5.4", "claude-sonnet-4-6", "grok-4.5"} {
+ t.Run(modelName, func(t *testing.T) {
+ out, _ := ConvertClaudeRequestToCodex(modelName, payload, false)
+ if got := countRequestInputItemsByType(out, "reasoning"); got != 0 {
+ t.Fatalf("got %d reasoning items for non-Meta target, want 0; output=%s", got, out)
+ }
+ })
+ }
+}
+
+func TestConvertClaudeRequestToCodex_AssistantRoutedMetaSignatureToReasoningItem(t *testing.T) {
+ // OpenRouter serves Meta Muse models and appends a dot-delimited
+ // alphanumeric suffix to the native envelope; the full value must replay
+ // verbatim.
+ signature := testMetaClaudeSignature() + "." + strings.Repeat("Ab3", 23)[:67]
+ payload := []byte(`{"model":"meta/muse-wonder","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"summary","signature":""},{"type":"text","text":"answer"}]},{"role":"user","content":"next"}]}`)
+ payload, _ = sjson.SetBytes(payload, "messages.0.content.0.signature", signature)
+
+ out, _ := ConvertClaudeRequestToCodex("meta/muse-wonder", payload, false)
+ reasoning := gjson.GetBytes(out, "input.0")
+ if reasoning.Get("type").String() != "reasoning" {
+ t.Fatalf("input.0 type = %q, want reasoning; output=%s", reasoning.Get("type").String(), out)
+ }
+ if got := reasoning.Get("encrypted_content").String(); got != signature {
+ t.Fatalf("encrypted_content = %q, want routed Meta signature verbatim", got)
+ }
+
+ out, _ = ConvertClaudeRequestToCodex("gpt-5.4", payload, false)
+ if got := countRequestInputItemsByType(out, "reasoning"); got != 0 {
+ t.Fatalf("got %d reasoning items for non-Meta target, want 0; output=%s", got, out)
+ }
+
+ // A foreign GPT signature must not be replayed when the target is a Meta Muse model.
+ gptPayload := []byte(`{"model":"muse-spark-1.3","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"summary","signature":""},{"type":"text","text":"answer"}]},{"role":"user","content":"next"}]}`)
+ gptPayload, _ = sjson.SetBytes(gptPayload, "messages.0.content.0.signature", validCodexReasoningSignature())
+ out, _ = ConvertClaudeRequestToCodex("muse-spark-1.3", gptPayload, false)
+ if got := countRequestInputItemsByType(out, "reasoning"); got != 0 {
+ t.Fatalf("got %d reasoning items for GPT signature on Meta target, want 0; output=%s", got, out)
+ }
+
+ // When modelName is an unmapped upstream deployment alias, the client-requested
+ // Meta Muse model in the payload preserves Meta signatures.
+ aliasOut, _ := ConvertClaudeRequestToCodex("deployment-123", payload, false)
+ if got := countRequestInputItemsByType(aliasOut, "reasoning"); got != 1 {
+ t.Fatalf("got %d reasoning items for aliased Meta target, want 1; output=%s", got, aliasOut)
+ }
+}
+
func TestConvertClaudeRequestToCodex_IgnoresGrokSignatureForNonGrokTargets(t *testing.T) {
signature := "HmlYdr2aCAqCYP/m9mr8PS6KOsdMs72FGDigmydR+Jsmuv8KX97yWPlbOwmXJgWn0CbHaCacdQD3+n5EvpgLfPNmafS3kdICBjRuDf4bzHy7uBiUhNVhqPtp/ee1y9q4imPE4LYgD1VZ4J+bp9mTeqA1+nC9Oue58CiNEMV9SVaGenCD+aBnVuSTzQhD32Y+68i6HLJW0Dx6ifaRfb8hxYtA/sPM+/FTvAMW11nRho5a2BBSkpnzfqqAz/e/vGJ77/bygpXM823QA9wL9i0X"
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"summary","signature":""},{"type":"text","text":"answer"}]},{"role":"user","content":"next"}]}`)
diff --git a/internal/translator/codex/claude/codex_claude_response.go b/internal/translator/codex/claude/codex_claude_response.go
index cc144cb1df0..fd7bc2e1846 100644
--- a/internal/translator/codex/claude/codex_claude_response.go
+++ b/internal/translator/codex/claude/codex_claude_response.go
@@ -120,10 +120,11 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
output = append(output, startCodexThinkingBlock(params)...)
}
params.ThinkingSummarySeen = true
- case "response.reasoning_summary_text.delta":
+ case "response.reasoning_summary_text.delta", "response.reasoning_text.delta":
output = append(output, stopCodexTextBlock(params)...)
output = append(output, startCodexThinkingBlock(params)...)
output = append(output, appendCodexThinkingDelta(params, rootResult.Get("delta").String())...)
+ params.ThinkingSummarySeen = true
case "response.reasoning_summary_part.done":
// Intentionally does not close the thinking block: it stays open until
// output_item.done delivers the reasoning item's final encrypted_content.
@@ -250,6 +251,13 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
if signature := itemResult.Get("encrypted_content").String(); signature != "" {
params.ThinkingSignature = signature
}
+ if !params.ThinkingSummarySeen {
+ if doneText := extractCodexReasoningItemText(itemResult); doneText != "" {
+ output = append(output, startCodexThinkingBlock(params)...)
+ output = append(output, appendCodexThinkingDelta(params, doneText)...)
+ params.ThinkingSummarySeen = true
+ }
+ }
if params.ThinkingSummarySeen {
output = append(output, finalizeCodexThinkingBlock(params)...)
} else {
@@ -385,41 +393,11 @@ func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, original
output.ForEach(func(_, item gjson.Result) bool {
switch item.Get("type").String() {
case "reasoning":
- thinkingBuilder := strings.Builder{}
+ thinkingText := extractCodexReasoningItemText(item)
signature := item.Get("encrypted_content").String()
- if summary := item.Get("summary"); summary.Exists() {
- if summary.IsArray() {
- summary.ForEach(func(_, part gjson.Result) bool {
- if txt := part.Get("text"); txt.Exists() {
- thinkingBuilder.WriteString(txt.String())
- } else {
- thinkingBuilder.WriteString(part.String())
- }
- return true
- })
- } else {
- thinkingBuilder.WriteString(summary.String())
- }
- }
- if thinkingBuilder.Len() == 0 {
- if content := item.Get("content"); content.Exists() {
- if content.IsArray() {
- content.ForEach(func(_, part gjson.Result) bool {
- if txt := part.Get("text"); txt.Exists() {
- thinkingBuilder.WriteString(txt.String())
- } else {
- thinkingBuilder.WriteString(part.String())
- }
- return true
- })
- } else {
- thinkingBuilder.WriteString(content.String())
- }
- }
- }
- if thinkingBuilder.Len() > 0 || signature != "" {
+ if thinkingText != "" || signature != "" {
block := []byte(`{"type":"thinking","thinking":""}`)
- block, _ = sjson.SetBytes(block, "thinking", thinkingBuilder.String())
+ block, _ = sjson.SetBytes(block, "thinking", thinkingText)
if signature != "" {
block, _ = sjson.SetBytes(block, "signature", signature)
}
@@ -917,6 +895,41 @@ func stopCodexTextBlock(params *ConvertCodexResponseToClaudeParams) []byte {
return translatorcommon.AppendSSEEventBytes(nil, "content_block_stop", template, 2)
}
+func extractCodexReasoningItemText(item gjson.Result) string {
+ thinkingBuilder := strings.Builder{}
+ if summary := item.Get("summary"); summary.Exists() {
+ if summary.IsArray() {
+ summary.ForEach(func(_, part gjson.Result) bool {
+ if txt := part.Get("text"); txt.Exists() {
+ thinkingBuilder.WriteString(txt.String())
+ } else {
+ thinkingBuilder.WriteString(part.String())
+ }
+ return true
+ })
+ } else {
+ thinkingBuilder.WriteString(summary.String())
+ }
+ }
+ if thinkingBuilder.Len() == 0 {
+ if content := item.Get("content"); content.Exists() {
+ if content.IsArray() {
+ content.ForEach(func(_, part gjson.Result) bool {
+ if txt := part.Get("text"); txt.Exists() {
+ thinkingBuilder.WriteString(txt.String())
+ } else {
+ thinkingBuilder.WriteString(part.String())
+ }
+ return true
+ })
+ } else {
+ thinkingBuilder.WriteString(content.String())
+ }
+ }
+ }
+ return thinkingBuilder.String()
+}
+
func startCodexThinkingBlock(params *ConvertCodexResponseToClaudeParams) []byte {
if params.ThinkingBlockOpen {
return nil
diff --git a/internal/translator/codex/gemini/codex_gemini_request.go b/internal/translator/codex/gemini/codex_gemini_request.go
index 71bdebaceaa..2fce6b1d3b7 100644
--- a/internal/translator/codex/gemini/codex_gemini_request.go
+++ b/internal/translator/codex/gemini/codex_gemini_request.go
@@ -6,6 +6,7 @@
package gemini
import (
+ "encoding/base64"
"fmt"
"strconv"
"strings"
@@ -69,6 +70,8 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
// consume them in order when functionResponses arrive.
var pendingCallIDs []string
callCounter := 0
+ emittedThoughtSignatures := map[string]bool{}
+ signatureTargetModel := translatorcommon.ResolveCodexReplayTargetModel(modelName, inputRawJSON)
getGeminiCallID := func(value gjson.Result) string {
if callID := strings.TrimSpace(value.Get("id").String()); callID != "" {
@@ -138,8 +141,49 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
continue
}
parr := parts.Array()
+ var pendingThoughtSummaryParts [][]byte
for j := 0; j < len(parr); j++ {
p := parr[j]
+
+ // A thoughtSignature is replayable reasoning state, not visible
+ // content. Restore it as a reasoning item only when its detected
+ // family matches this request's target model family, so a Muse
+ // (or GPT/Grok) signature can never be replayed to a wrong upstream
+ // through the Gemini ingress. Unmatched signatures fall through to
+ // the existing hidden-thought drop below.
+ if role == "assistant" {
+ if rawSignature := geminiPartThoughtSignature(p); rawSignature != "" {
+ if normalizedSignature, ok := normalizedGeminiThoughtSignatureForCodexTarget(signatureTargetModel, rawSignature); ok && !emittedThoughtSignatures[normalizedSignature] {
+ reasoningItem := []byte(`{"type":"reasoning","encrypted_content":"","summary":[]}`)
+ reasoningItem, _ = sjson.SetBytes(reasoningItem, "encrypted_content", normalizedSignature)
+ summaryParts := pendingThoughtSummaryParts
+ pendingThoughtSummaryParts = nil
+ if translatorcommon.IsGeminiThoughtPart(p) {
+ if thoughtText := p.Get("text").String(); thoughtText != "" {
+ summaryPart := []byte(`{"type":"summary_text","text":""}`)
+ summaryPart, _ = sjson.SetBytes(summaryPart, "text", thoughtText)
+ summaryParts = append(summaryParts, summaryPart)
+ }
+ }
+ if len(summaryParts) > 0 {
+ reasoningItem, _ = sjson.SetRawBytes(reasoningItem, "summary", translatorcommon.JoinRawArray(summaryParts))
+ }
+ inputItems = append(inputItems, reasoningItem)
+ emittedThoughtSignatures[normalizedSignature] = true
+ } else {
+ pendingThoughtSummaryParts = nil
+ }
+ } else if translatorcommon.IsGeminiThoughtPart(p) {
+ if thoughtText := p.Get("text").String(); thoughtText != "" {
+ summaryPart := []byte(`{"type":"summary_text","text":""}`)
+ summaryPart, _ = sjson.SetBytes(summaryPart, "text", thoughtText)
+ pendingThoughtSummaryParts = append(pendingThoughtSummaryParts, summaryPart)
+ }
+ } else {
+ pendingThoughtSummaryParts = nil
+ }
+ }
+
if translatorcommon.IsGeminiThoughtPart(p) {
continue
}
@@ -344,6 +388,37 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
return out, nil
}
+// geminiPartThoughtSignature extracts a part's thought signature under either
+// the camelCase or snake_case field spelling.
+func geminiPartThoughtSignature(part gjson.Result) string {
+ if signature := strings.TrimSpace(part.Get("thoughtSignature").String()); signature != "" {
+ return signature
+ }
+ return strings.TrimSpace(part.Get("thought_signature").String())
+}
+
+// decodeGeminiCodexThoughtSignature unwraps the carrier written by
+// encodeGeminiCodexThoughtSignature. Values without the carrier magic are
+// returned unchanged, preserving legacy raw-signature echoes.
+func decodeGeminiCodexThoughtSignature(value string) string {
+ value = strings.TrimSpace(value)
+ decoded, err := base64.StdEncoding.DecodeString(value)
+ if err != nil || !strings.HasPrefix(string(decoded), geminiCodexThoughtSignatureCarrierMagic) {
+ return value
+ }
+ return strings.TrimSpace(strings.TrimPrefix(string(decoded), geminiCodexThoughtSignatureCarrierMagic))
+}
+
+// normalizedGeminiThoughtSignatureForCodexTarget unwraps and normalizes a
+// client-echoed thought signature for the Codex-format upstream serving
+// modelName, returning the exact value that may be written as reasoning
+// encrypted_content. The carrier magic written by
+// encodeGeminiCodexThoughtSignature is stripped first; the family match and
+// normalization rules live in translatorcommon.CodexReplaySignatureForTarget.
+func normalizedGeminiThoughtSignatureForCodexTarget(modelName, rawSignature string) (string, bool) {
+ return translatorcommon.CodexReplaySignatureForTarget(modelName, decodeGeminiCodexThoughtSignature(rawSignature))
+}
+
func setCodexToolChoiceFromGeminiToolConfig(out []byte, functionCallingConfig gjson.Result) []byte {
if !functionCallingConfig.Exists() {
return out
diff --git a/internal/translator/codex/gemini/codex_gemini_request_test.go b/internal/translator/codex/gemini/codex_gemini_request_test.go
index 4ac4976a42b..3b6d9996632 100644
--- a/internal/translator/codex/gemini/codex_gemini_request_test.go
+++ b/internal/translator/codex/gemini/codex_gemini_request_test.go
@@ -1,10 +1,13 @@
package gemini
import (
+ "encoding/base64"
"fmt"
+ "strings"
"testing"
"github.com/tidwall/gjson"
+ "github.com/tidwall/sjson"
)
func TestConvertGeminiRequestToCodex_PreservesCustomCallIDs(t *testing.T) {
@@ -116,6 +119,105 @@ func TestConvertGeminiRequestToCodex_DropsHiddenThoughtParts(t *testing.T) {
})
}
+func testGeminiMetaThoughtSignature() string {
+ // Shape-valid synthetic Meta envelope; no captured value is embedded.
+ signature := "Q-PaDgE"
+ for len(signature) < 1426 {
+ signature += "A"
+ }
+ return signature
+}
+
+func TestConvertGeminiRequestToCodex_PreservesMetaThoughtSignatureForMuseTarget(t *testing.T) {
+ signature := testGeminiMetaThoughtSignature()
+ payload := []byte(`{"contents":[{"role":"model","parts":[{"thought":true,"text":"internal reasoning","thoughtSignature":""},{"text":"visible answer"}]},{"role":"user","parts":[{"text":"continue"}]}]}`)
+ payload, _ = sjson.SetBytes(payload, "contents.0.parts.0.thoughtSignature", signature)
+
+ out, _ := ConvertGeminiRequestToCodex("muse-spark-1.3", payload, false)
+ input := gjson.GetBytes(out, "input").Array()
+ if len(input) != 3 {
+ t.Fatalf("input len = %d, want reasoning+message+user. Output: %s", len(input), string(out))
+ }
+ if got := input[0].Get("type").String(); got != "reasoning" {
+ t.Fatalf("input.0 type = %q, want reasoning. Output: %s", got, string(out))
+ }
+ if got := input[0].Get("encrypted_content").String(); got != signature {
+ t.Fatalf("encrypted_content = %q, want Meta signature", got)
+ }
+ if got := input[0].Get("summary.0.text").String(); got != "internal reasoning" {
+ t.Fatalf("summary text = %q, want thought text. Output: %s", got, string(out))
+ }
+ if got := input[1].Get("content.0.text").String(); got != "visible answer" {
+ t.Fatalf("visible text = %q. Output: %s", got, string(out))
+ }
+}
+
+func TestConvertGeminiRequestToCodex_DropsMetaThoughtSignatureForNonMetaTargets(t *testing.T) {
+ signature := testGeminiMetaThoughtSignature()
+ payload := []byte(`{"contents":[{"role":"model","parts":[{"thought":true,"text":"internal reasoning","thoughtSignature":""},{"text":"visible answer"}]},{"role":"user","parts":[{"text":"continue"}]}]}`)
+ payload, _ = sjson.SetBytes(payload, "contents.0.parts.0.thoughtSignature", signature)
+
+ for _, modelName := range []string{"gpt-5.1-codex", "claude-sonnet-4-6", "grok-4.5"} {
+ t.Run(modelName, func(t *testing.T) {
+ out, _ := ConvertGeminiRequestToCodex(modelName, payload, false)
+ for _, item := range gjson.GetBytes(out, "input").Array() {
+ if item.Get("type").String() == "reasoning" {
+ t.Fatalf("Meta signature leaked to %s target. Output: %s", modelName, string(out))
+ }
+ }
+ })
+ }
+}
+
+func testGeminiGPTThoughtSignature() string {
+ decoded := make([]byte, 89)
+ decoded[0] = 0x80
+ for i := 9; i < len(decoded); i++ {
+ decoded[i] = byte(i)
+ }
+ return base64.RawURLEncoding.EncodeToString(decoded)
+}
+
+func TestConvertGeminiRequestToCodex_NormalizesPrefixedAndCarrierSignatures(t *testing.T) {
+ gptSignature := testGeminiGPTThoughtSignature()
+ metaSignature := testGeminiMetaThoughtSignature()
+ // OpenRouter-appended suffix shape: one '.' plus 67 alphanumeric chars.
+ routedMetaSignature := metaSignature + "." + strings.Repeat("Ab3", 23)[:67]
+ cases := []struct {
+ name string
+ model string
+ echoed string
+ want string
+ wantFound bool
+ }{
+ {"gpt cache prefixed", "gpt-5.1-codex", "gpt#" + gptSignature, gptSignature, true},
+ {"carrier wrapped gpt prefixed", "gpt-5.1-codex", encodeGeminiCodexThoughtSignature("gpt#" + gptSignature), gptSignature, true},
+ {"carrier wrapped meta", "muse-spark-1.3", encodeGeminiCodexThoughtSignature(metaSignature), metaSignature, true},
+ {"carrier wrapped meta to gpt target", "gpt-5.1-codex", encodeGeminiCodexThoughtSignature(metaSignature), "", false},
+ {"carrier wrapped routed meta", "meta/muse-wonder", encodeGeminiCodexThoughtSignature(routedMetaSignature), routedMetaSignature, true},
+ {"carrier wrapped routed meta to gpt target", "gpt-5.1-codex", encodeGeminiCodexThoughtSignature(routedMetaSignature), "", false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ payload := []byte(`{"contents":[{"role":"model","parts":[{"thought":true,"text":"internal reasoning","thoughtSignature":""},{"text":"visible answer"}]},{"role":"user","parts":[{"text":"continue"}]}]}`)
+ payload, _ = sjson.SetBytes(payload, "contents.0.parts.0.thoughtSignature", tc.echoed)
+ out, _ := ConvertGeminiRequestToCodex(tc.model, payload, false)
+ found := ""
+ for _, item := range gjson.GetBytes(out, "input").Array() {
+ if item.Get("type").String() == "reasoning" {
+ found = item.Get("encrypted_content").String()
+ }
+ }
+ if tc.wantFound && found != tc.want {
+ t.Fatalf("encrypted_content = %q, want normalized signature; output=%s", found, string(out))
+ }
+ if !tc.wantFound && found != "" {
+ t.Fatalf("foreign signature leaked as reasoning item; output=%s", string(out))
+ }
+ })
+ }
+}
+
func TestConvertGeminiRequestToCodex_DeterministicCallIDs(t *testing.T) {
raw := []byte(`{
"contents": [
diff --git a/internal/translator/codex/gemini/codex_gemini_response.go b/internal/translator/codex/gemini/codex_gemini_response.go
index ccf3877ac24..a6a9049baa9 100644
--- a/internal/translator/codex/gemini/codex_gemini_response.go
+++ b/internal/translator/codex/gemini/codex_gemini_response.go
@@ -8,6 +8,7 @@ import (
"bytes"
"context"
"crypto/sha256"
+ "encoding/base64"
"strings"
"time"
@@ -22,12 +23,27 @@ var (
// ConvertCodexResponseToGeminiParams holds parameters for response conversion.
type ConvertCodexResponseToGeminiParams struct {
- Model string
- CreatedAt int64
- ResponseID string
- LastStorageOutput []byte
+ Model string
+ CreatedAt int64
+ ResponseID string
+ // StorageOutputs queues completed function-call chunks in stream order
+ // until the next non-storage event flushes them. A queue (not a single
+ // slot) so consecutive function calls can never overwrite each other.
+ StorageOutputs [][]byte
HasOutputTextDelta bool
LastImageHashByID map[string][32]byte
+ // PendingThoughtSignature holds a completed reasoning item's
+ // encrypted_content until the next visible part can carry it as a
+ // Gemini thoughtSignature, mirroring native Gemini part placement.
+ PendingThoughtSignature string
+ // SeenReasoningSignatures deduplicates repeated reasoning done events
+ // by item ID + signature hash within one response stream.
+ SeenReasoningSignatures map[[32]byte]bool
+ // HasReasoningDelta and StreamedReasoningItems record whether reasoning
+ // deltas were already emitted for the current or keyed reasoning item so
+ // done-only reasoning summaries can fall back cleanly.
+ HasReasoningDelta bool
+ StreamedReasoningItems map[string]bool
}
// ConvertCodexResponseToGemini converts Codex streaming response format to Gemini format.
@@ -49,7 +65,6 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
Model: modelName,
CreatedAt: 0,
ResponseID: "",
- LastStorageOutput: nil,
HasOutputTextDelta: false,
LastImageHashByID: make(map[string][32]byte),
}
@@ -66,6 +81,25 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
params := (*param).(*ConvertCodexResponseToGeminiParams)
+ // flushStorageOutputs drains queued function-call chunks ahead of any
+ // extra chunks, preserving stream order on every return path.
+ flushStorageOutputs := func(extra ...[]byte) [][]byte {
+ outputs := make([][]byte, 0, len(params.StorageOutputs)+len(extra))
+ for _, stored := range params.StorageOutputs {
+ outputs = append(outputs, append([]byte(nil), stored...))
+ }
+ params.StorageOutputs = nil
+ return append(outputs, extra...)
+ }
+ attachPendingThoughtSignature := func(part []byte) []byte {
+ if params.PendingThoughtSignature == "" {
+ return part
+ }
+ part = setGeminiThoughtSignature(part, params.PendingThoughtSignature)
+ params.PendingThoughtSignature = ""
+ return part
+ }
+
// Base Gemini response template
template := []byte(`{"candidates":[{"content":{"role":"model","parts":[]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-2.5-pro","createTime":"2025-08-15T02:52:03.884209Z","responseId":"06CeaPH7NaCU48APvNXDyA4"}`)
{
@@ -101,14 +135,62 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
part := []byte(`{"inlineData":{"data":"","mimeType":""}}`)
part, _ = sjson.SetBytes(part, "inlineData.data", b64)
part, _ = sjson.SetBytes(part, "inlineData.mimeType", mimeType)
+ part = attachPendingThoughtSignature(part)
template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", [][]byte{part})
- return [][]byte{template}
+ return flushStorageOutputs(template)
}
// Handle function call completion
if typeStr == "response.output_item.done" {
itemResult := rootResult.Get("item")
itemType := itemResult.Get("type").String()
+ if itemType == "reasoning" {
+ signature := strings.TrimSpace(itemResult.Get("encrypted_content").String())
+ itemID := strings.TrimSpace(itemResult.Get("id").String())
+ hadStreamedReasoning := params.HasReasoningDelta
+ if itemID != "" && len(params.StreamedReasoningItems) > 0 {
+ hadStreamedReasoning = params.StreamedReasoningItems[itemID]
+ }
+ params.HasReasoningDelta = false
+ var thoughtTexts []string
+ if !hadStreamedReasoning {
+ thoughtTexts = extractCodexGeminiReasoningTexts(itemResult)
+ }
+ if signature == "" && len(thoughtTexts) == 0 {
+ return [][]byte{}
+ }
+ if signature != "" {
+ seenKey := sha256.Sum256([]byte(itemID + "\x00" + signature))
+ if params.SeenReasoningSignatures == nil {
+ params.SeenReasoningSignatures = make(map[[32]byte]bool)
+ }
+ if params.SeenReasoningSignatures[seenKey] {
+ return [][]byte{}
+ }
+ params.SeenReasoningSignatures[seenKey] = true
+ }
+ var emittedParts [][]byte
+ if params.PendingThoughtSignature != "" && signature != "" {
+ // A previous reasoning step never got a visible carrier part;
+ // flush it as an empty thought part so no signature is lost.
+ part := setGeminiThoughtSignature([]byte(`{"thought":true,"text":""}`), params.PendingThoughtSignature)
+ params.PendingThoughtSignature = ""
+ emittedParts = append(emittedParts, part)
+ }
+ for _, thoughtText := range thoughtTexts {
+ part := []byte(`{"thought":true,"text":""}`)
+ part, _ = sjson.SetBytes(part, "text", thoughtText)
+ emittedParts = append(emittedParts, part)
+ }
+ if signature != "" {
+ params.PendingThoughtSignature = signature
+ }
+ if len(emittedParts) > 0 {
+ template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", emittedParts)
+ return flushStorageOutputs(template)
+ }
+ return [][]byte{}
+ }
if itemType == "image_generation_call" {
itemID := itemResult.Get("id").String()
b64 := itemResult.Get("result").String()
@@ -132,8 +214,9 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
part := []byte(`{"inlineData":{"data":"","mimeType":""}}`)
part, _ = sjson.SetBytes(part, "inlineData.data", b64)
part, _ = sjson.SetBytes(part, "inlineData.mimeType", mimeType)
+ part = attachPendingThoughtSignature(part)
template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", [][]byte{part})
- return [][]byte{template}
+ return flushStorageOutputs(template)
}
if itemType == "function_call" {
// Create function call part
@@ -157,11 +240,12 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
}
}
functionCall = setGeminiFunctionCallID(functionCall, itemResult)
+ functionCall = attachPendingThoughtSignature(functionCall)
template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", [][]byte{functionCall})
template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP")
- params.LastStorageOutput = append([]byte(nil), template...)
+ params.StorageOutputs = append(params.StorageOutputs, append([]byte(nil), template...))
// Use this return to storage message
return [][]byte{}
@@ -169,17 +253,53 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
}
if typeStr == "response.created" { // Handle response creation - set model and response ID
+ // A new response must not inherit per-response streaming state if the
+ // caller reuses params across responses.
+ params.StorageOutputs = nil
+ params.PendingThoughtSignature = ""
+ params.SeenReasoningSignatures = nil
+ params.HasReasoningDelta = false
+ params.StreamedReasoningItems = nil
+ params.HasOutputTextDelta = false
template, _ = sjson.SetBytes(template, "modelVersion", rootResult.Get("response.model").String())
template, _ = sjson.SetBytes(template, "responseId", rootResult.Get("response.id").String())
params.ResponseID = rootResult.Get("response.id").String()
- } else if typeStr == "response.reasoning_summary_text.delta" { // Handle reasoning/thinking content delta
+ } else if typeStr == "response.output_item.added" && rootResult.Get("item.type").String() == "reasoning" {
+ params.HasReasoningDelta = false
+ if params.PendingThoughtSignature != "" {
+ part := setGeminiThoughtSignature([]byte(`{"thought":true,"text":""}`), params.PendingThoughtSignature)
+ params.PendingThoughtSignature = ""
+ template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", [][]byte{part})
+ return flushStorageOutputs(template)
+ }
+ return [][]byte{}
+ } else if typeStr == "response.reasoning_summary_text.delta" || typeStr == "response.reasoning_text.delta" { // Handle reasoning/thinking content delta
+ params.HasReasoningDelta = true
+ if itemID := strings.TrimSpace(rootResult.Get("item_id").String()); itemID != "" {
+ if params.StreamedReasoningItems == nil {
+ params.StreamedReasoningItems = make(map[string]bool)
+ }
+ params.StreamedReasoningItems[itemID] = true
+ }
+ var emittedParts [][]byte
+ if params.PendingThoughtSignature != "" {
+ carrier := setGeminiThoughtSignature([]byte(`{"thought":true,"text":""}`), params.PendingThoughtSignature)
+ params.PendingThoughtSignature = ""
+ emittedParts = append(emittedParts, carrier)
+ }
part := []byte(`{"thought":true,"text":""}`)
part, _ = sjson.SetBytes(part, "text", rootResult.Get("delta").String())
- template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", [][]byte{part})
+ emittedParts = append(emittedParts, part)
+ template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", emittedParts)
} else if typeStr == "response.output_text.delta" { // Handle regular text content delta
params.HasOutputTextDelta = true
part := []byte(`{"text":""}`)
part, _ = sjson.SetBytes(part, "text", rootResult.Get("delta").String())
+ if rootResult.Get("delta").String() != "" {
+ // An empty text part may be discarded by clients; never spend the
+ // only signature copy on one.
+ part = attachPendingThoughtSignature(part)
+ }
template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", [][]byte{part})
} else if typeStr == "response.output_item.done" { // Fallback: emit final message text when no delta chunks were received
itemResult := rootResult.Get("item")
@@ -201,16 +321,22 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
}
part := []byte(`{"text":""}`)
part, _ = sjson.SetBytes(part, "text", text)
+ part = attachPendingThoughtSignature(part)
template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", part)
wroteText = true
return true
})
if wroteText {
params.HasOutputTextDelta = true
- return [][]byte{template}
+ return flushStorageOutputs(template)
}
return [][]byte{}
} else if typeStr == "response.completed" || typeStr == "response.incomplete" { // Handle response completion with usage metadata
+ if params.PendingThoughtSignature != "" {
+ part := setGeminiThoughtSignature([]byte(`{"thought":true,"text":""}`), params.PendingThoughtSignature)
+ params.PendingThoughtSignature = ""
+ template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", [][]byte{part})
+ }
template, _ = sjson.SetBytes(template, "usageMetadata.promptTokenCount", rootResult.Get("response.usage.input_tokens").Int())
template, _ = sjson.SetBytes(template, "usageMetadata.candidatesTokenCount", rootResult.Get("response.usage.output_tokens").Int())
totalTokens := rootResult.Get("response.usage.input_tokens").Int() + rootResult.Get("response.usage.output_tokens").Int()
@@ -222,12 +348,7 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
return [][]byte{}
}
- if len(params.LastStorageOutput) > 0 {
- stored := append([]byte(nil), params.LastStorageOutput...)
- params.LastStorageOutput = nil
- return [][]byte{stored, template}
- }
- return [][]byte{template}
+ return flushStorageOutputs(template)
}
// ConvertCodexResponseToGeminiNonStream converts a non-streaming Codex response to a non-streaming Gemini response.
@@ -308,10 +429,23 @@ func ConvertCodexResponseToGeminiNonStream(_ context.Context, modelName string,
// Flush any pending function calls before adding non-function content
flushPendingFunctionCalls()
- // Add thinking content
- if content := value.Get("content"); content.Exists() {
+ // Surface the reasoning summary as thought parts and carry
+ // the encrypted reasoning state as thoughtSignature on the
+ // first of them, so a Gemini client can replay it next turn.
+ // With no summary text, emit an empty thought carrier part;
+ // dropping the signature here would silently break replay.
+ signature := strings.TrimSpace(value.Get("encrypted_content").String())
+ thoughtTexts := extractCodexGeminiReasoningTexts(value)
+ for i, thoughtText := range thoughtTexts {
part := []byte(`{"text":"","thought":true}`)
- part, _ = sjson.SetBytes(part, "text", content.String())
+ part, _ = sjson.SetBytes(part, "text", thoughtText)
+ if i == len(thoughtTexts)-1 && signature != "" {
+ part = setGeminiThoughtSignature(part, signature)
+ }
+ parts = append(parts, part)
+ }
+ if len(thoughtTexts) == 0 && signature != "" {
+ part := setGeminiThoughtSignature([]byte(`{"text":"","thought":true}`), signature)
parts = append(parts, part)
}
@@ -413,6 +547,55 @@ func buildReverseMapFromGeminiOriginal(original []byte) map[string]string {
return rev
}
+// geminiCodexThoughtSignatureCarrierMagic marks a CPA carrier payload inside
+// a Gemini thoughtSignature bytes field. The Gemini JSON wire form of a bytes
+// field is standard padded base64, while Responses signatures are opaque
+// strings that may use the base64url alphabet without padding; writing such a
+// string verbatim makes official SDK []byte decoding fail. The carrier therefore
+// stores magic+rawSignature as the byte payload and relies on standard base64
+// JSON encoding, which every Gemini client decodes and echoes losslessly.
+func extractCodexGeminiReasoningTexts(value gjson.Result) []string {
+ var thoughtTexts []string
+ if summary := value.Get("summary"); summary.IsArray() {
+ for _, entry := range summary.Array() {
+ if text := entry.Get("text").String(); text != "" {
+ thoughtTexts = append(thoughtTexts, text)
+ }
+ }
+ }
+ if content := value.Get("content"); content.IsArray() {
+ for _, entry := range content.Array() {
+ if entry.Get("type").String() == "reasoning_text" {
+ if text := entry.Get("text").String(); text != "" {
+ thoughtTexts = append(thoughtTexts, text)
+ }
+ }
+ }
+ } else if content.Type == gjson.String && content.String() != "" {
+ thoughtTexts = append(thoughtTexts, content.String())
+ }
+ return thoughtTexts
+}
+
+const geminiCodexThoughtSignatureCarrierMagic = "cpa-codex-gemini-carrier-v1:"
+
+func encodeGeminiCodexThoughtSignature(rawSignature string) string {
+ rawSignature = strings.TrimSpace(rawSignature)
+ if rawSignature == "" {
+ return ""
+ }
+ return base64.StdEncoding.EncodeToString([]byte(geminiCodexThoughtSignatureCarrierMagic + rawSignature))
+}
+
+func setGeminiThoughtSignature(part []byte, rawSignature string) []byte {
+ encoded := encodeGeminiCodexThoughtSignature(rawSignature)
+ if encoded == "" {
+ return part
+ }
+ part, _ = sjson.SetBytes(part, "thoughtSignature", encoded)
+ return part
+}
+
func setGeminiFunctionCallID(functionCall []byte, item gjson.Result) []byte {
if callID := strings.TrimSpace(item.Get("call_id").String()); callID != "" {
functionCall, _ = sjson.SetBytes(functionCall, "functionCall.id", callID)
diff --git a/internal/translator/codex/gemini/codex_gemini_response_test.go b/internal/translator/codex/gemini/codex_gemini_response_test.go
index 5dda9cd5803..ebded574606 100644
--- a/internal/translator/codex/gemini/codex_gemini_response_test.go
+++ b/internal/translator/codex/gemini/codex_gemini_response_test.go
@@ -2,6 +2,8 @@ package gemini
import (
"context"
+ "encoding/json"
+ "strings"
"testing"
"github.com/tidwall/gjson"
@@ -156,6 +158,180 @@ func TestConvertCodexResponseToGemini_StreamPreservesFunctionCallID(t *testing.T
}
}
+func TestConvertCodexResponseToGemini_ReasoningThoughtSignature(t *testing.T) {
+ ctx := context.Background()
+ signature := testGeminiMetaThoughtSignature()
+
+ t.Run("non-stream summary carries signature", func(t *testing.T) {
+ raw := []byte(`{"type":"response.completed","response":{"id":"resp_1","model":"muse-spark-1.3","created_at":1,"usage":{"input_tokens":1,"output_tokens":2},"output":[{"type":"reasoning","encrypted_content":"` + signature + `","summary":[{"type":"summary_text","text":"thinking summary"}]},{"type":"message","content":[{"type":"output_text","text":"answer"}]}]}}`)
+ out := ConvertCodexResponseToGeminiNonStream(ctx, "muse-spark-1.3", nil, nil, raw, nil)
+ part := gjson.GetBytes(out, "candidates.0.content.parts.0")
+ if !part.Get("thought").Bool() || part.Get("text").String() != "thinking summary" {
+ t.Fatalf("part.0 = %s, want thought summary part; payload=%s", part.Raw, out)
+ }
+ if got := part.Get("thoughtSignature").String(); got != encodeGeminiCodexThoughtSignature(signature) {
+ t.Fatalf("thoughtSignature is not the carrier-encoded Meta signature; payload=%s", out)
+ }
+ // The Gemini wire form must survive an official-SDK-style []byte
+ // JSON decode (standard base64), and unwrap back to the raw signature.
+ var sdkPart struct {
+ ThoughtSignature []byte `json:"thoughtSignature"`
+ }
+ if err := json.Unmarshal([]byte(part.Raw), &sdkPart); err != nil {
+ t.Fatalf("SDK []byte decode of thoughtSignature failed: %v", err)
+ }
+ if got := decodeGeminiCodexThoughtSignature(part.Get("thoughtSignature").String()); got != signature {
+ t.Fatalf("carrier round-trip = %q, want raw Meta signature", got)
+ }
+ if got := gjson.GetBytes(out, "candidates.0.content.parts.1.text").String(); got != "answer" {
+ t.Fatalf("part.1 text = %q, want answer; payload=%s", got, out)
+ }
+ })
+
+ t.Run("non-stream empty summary still carries signature", func(t *testing.T) {
+ raw := []byte(`{"type":"response.completed","response":{"id":"resp_1","model":"muse-spark-1.3","created_at":1,"usage":{"input_tokens":1,"output_tokens":2},"output":[{"type":"reasoning","encrypted_content":"` + signature + `","summary":[]},{"type":"message","content":[{"type":"output_text","text":"answer"}]}]}}`)
+ out := ConvertCodexResponseToGeminiNonStream(ctx, "muse-spark-1.3", nil, nil, raw, nil)
+ part := gjson.GetBytes(out, "candidates.0.content.parts.0")
+ if !part.Get("thought").Bool() || part.Get("thoughtSignature").String() != encodeGeminiCodexThoughtSignature(signature) {
+ t.Fatalf("part.0 = %s, want empty thought carrier with signature; payload=%s", part.Raw, out)
+ }
+ })
+
+ t.Run("stream signature attaches to next text part", func(t *testing.T) {
+ var param any
+ done := []byte(`data: {"type":"response.output_item.done","item":{"type":"reasoning","encrypted_content":"` + signature + `","summary":[]}}`)
+ if out := ConvertCodexResponseToGemini(ctx, "muse-spark-1.3", nil, nil, done, ¶m); len(out) != 0 {
+ t.Fatalf("reasoning done emitted %d chunks before a visible carrier, want 0", len(out))
+ }
+ out := ConvertCodexResponseToGemini(ctx, "muse-spark-1.3", nil, nil, []byte(`data: {"type":"response.output_text.delta","delta":"answer"}`), ¶m)
+ if len(out) != 1 {
+ t.Fatalf("text delta emitted %d chunks, want 1", len(out))
+ }
+ part := gjson.GetBytes(out[0], "candidates.0.content.parts.0")
+ if part.Get("text").String() != "answer" || part.Get("thoughtSignature").String() != encodeGeminiCodexThoughtSignature(signature) {
+ t.Fatalf("text part = %s, want answer with Meta thoughtSignature", part.Raw)
+ }
+ })
+}
+
+func TestConvertCodexResponseToGemini_StreamMultipleFunctionCallsKeepSignatureAndOrder(t *testing.T) {
+ ctx := context.Background()
+ signature := testGeminiMetaThoughtSignature()
+ var param any
+ var outputs [][]byte
+ events := [][]byte{
+ []byte(`data: {"type":"response.output_item.done","item":{"type":"reasoning","encrypted_content":"` + signature + `","summary":[]}}`),
+ []byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_a","name":"alpha","arguments":"{}"}}`),
+ []byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_b","name":"beta","arguments":"{}"}}`),
+ []byte(`data: {"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1}}}`),
+ }
+ for _, event := range events {
+ outputs = append(outputs, ConvertCodexResponseToGemini(ctx, "muse-spark-1.3", nil, nil, event, ¶m)...)
+ }
+ var names []string
+ signatureOnAlpha := false
+ for _, chunk := range outputs {
+ for _, part := range gjson.GetBytes(chunk, "candidates.0.content.parts").Array() {
+ name := part.Get("functionCall.name").String()
+ if name == "" {
+ continue
+ }
+ names = append(names, name)
+ if name == "alpha" && decodeGeminiCodexThoughtSignature(part.Get("thoughtSignature").String()) == signature {
+ signatureOnAlpha = true
+ }
+ }
+ }
+ if len(names) != 2 || names[0] != "alpha" || names[1] != "beta" {
+ t.Fatalf("function calls = %v, want [alpha beta] in order; outputs=%q", names, outputs)
+ }
+ if !signatureOnAlpha {
+ t.Fatalf("first function call lost the pending Meta thoughtSignature; outputs=%q", outputs)
+ }
+}
+
+func TestConvertCodexResponseToGeminiNonStream_MultiSummaryRoundTrip(t *testing.T) {
+ ctx := context.Background()
+ signature := testGeminiMetaThoughtSignature()
+ raw := []byte(`{"type":"response.completed","response":{"id":"resp_multi","model":"muse-spark-1.3","created_at":1,"usage":{"input_tokens":1,"output_tokens":2},"output":[{"type":"reasoning","encrypted_content":"` + signature + `","summary":[{"type":"summary_text","text":"step A"},{"type":"summary_text","text":"step B"}]},{"type":"message","content":[{"type":"output_text","text":"final answer"}]}]}}`)
+ geminiOut := ConvertCodexResponseToGeminiNonStream(ctx, "muse-spark-1.3", nil, nil, raw, nil)
+
+ partsRaw := gjson.GetBytes(geminiOut, "candidates.0.content.parts").Raw
+ reqPayload := []byte(`{"model":"muse-spark-1.3","contents":[{"role":"user","parts":[{"text":"hi"}]},{"role":"model","parts":` + partsRaw + `},{"role":"user","parts":[{"text":"next"}]}]}`)
+ codexIn, _ := ConvertGeminiRequestToCodex("deployment-alias", reqPayload, false)
+
+ reasoning := gjson.GetBytes(codexIn, "input.1")
+ if reasoning.Get("type").String() != "reasoning" || reasoning.Get("encrypted_content").String() != signature {
+ t.Fatalf("round-trip reasoning item = %s, want Meta signature", reasoning.Raw)
+ }
+ summaries := reasoning.Get("summary").Array()
+ if len(summaries) != 2 || summaries[0].Get("text").String() != "step A" || summaries[1].Get("text").String() != "step B" {
+ t.Fatalf("round-trip summaries = %s, want [step A, step B]", reasoning.Get("summary").Raw)
+ }
+
+ // Streaming done-only reasoning item without prior deltas must still emit
+ // its summary thought parts and attach the signature to the next text delta.
+ var streamParam any
+ doneOnly := []byte(`data: {"type":"response.output_item.done","item":{"id":"rs_done_only","type":"reasoning","encrypted_content":"` + signature + `","summary":[{"type":"summary_text","text":"done-only thought"}]}}`)
+ doneChunks := ConvertCodexResponseToGemini(ctx, "muse-spark-1.3", nil, nil, doneOnly, &streamParam)
+ if len(doneChunks) != 1 || !gjson.GetBytes(doneChunks[0], "candidates.0.content.parts.0.thought").Bool() || gjson.GetBytes(doneChunks[0], "candidates.0.content.parts.0.text").String() != "done-only thought" {
+ t.Fatalf("done-only reasoning chunks = %q, want thought summary chunk", doneChunks)
+ }
+ textChunks := ConvertCodexResponseToGemini(ctx, "muse-spark-1.3", nil, nil, []byte(`data: {"type":"response.output_text.delta","delta":"visible"}`), &streamParam)
+ if len(textChunks) != 1 || decodeGeminiCodexThoughtSignature(gjson.GetBytes(textChunks[0], "candidates.0.content.parts.0.thoughtSignature").String()) != signature {
+ t.Fatalf("text chunk after done-only reasoning = %q, want carrier signature", textChunks)
+ }
+
+ // Two consecutive streamed reasoning items must flush the first item's
+ // signature before the second item's thought deltas so each reasoning item
+ // keeps its own summary on replay.
+ sig2 := signature + "." + strings.Repeat("Zy9", 21)[:62]
+ var consecutiveParam any
+ var streamedParts []string
+ for _, ev := range [][]byte{
+ []byte(`data: {"type":"response.reasoning_summary_text.delta","item_id":"rs_1","delta":"first summary"}`),
+ []byte(`data: {"type":"response.output_item.done","item":{"id":"rs_1","type":"reasoning","encrypted_content":"` + signature + `","summary":[{"type":"summary_text","text":"first summary"}]}}`),
+ []byte(`data: {"type":"response.reasoning_summary_text.delta","item_id":"rs_2","delta":"second summary"}`),
+ []byte(`data: {"type":"response.output_item.done","item":{"id":"rs_2","type":"reasoning","encrypted_content":"` + sig2 + `","summary":[{"type":"summary_text","text":"second summary"}]}}`),
+ []byte(`data: {"type":"response.output_text.delta","delta":"answer"}`),
+ } {
+ for _, chunk := range ConvertCodexResponseToGemini(ctx, "muse-spark-1.3", nil, nil, ev, &consecutiveParam) {
+ for _, p := range gjson.GetBytes(chunk, "candidates.0.content.parts").Array() {
+ streamedParts = append(streamedParts, p.Raw)
+ }
+ }
+ }
+ replayReq := []byte(`{"model":"muse-spark-1.3","contents":[{"role":"user","parts":[{"text":"q"}]},{"role":"model","parts":[` + strings.Join(streamedParts, ",") + `]},{"role":"user","parts":[{"text":"next"}]}]}`)
+ replayCodex, _ := ConvertGeminiRequestToCodex("muse-spark-1.3", replayReq, false)
+ if gjson.GetBytes(replayCodex, "input.1.summary.0.text").String() != "first summary" || gjson.GetBytes(replayCodex, "input.2.summary.0.text").String() != "second summary" {
+ t.Fatalf("replayed consecutive reasoning items = %s, want separate summaries", replayCodex)
+ }
+
+ // Whitespace-only and space-padded thought chunks must round-trip without
+ // trimming inter-word spaces.
+ var wsParam any
+ var wsParts []string
+ for _, ev := range [][]byte{
+ []byte(`data: {"type":"response.reasoning_summary_text.delta","item_id":"rs_ws","delta":"Hello"}`),
+ []byte(`data: {"type":"response.reasoning_summary_text.delta","item_id":"rs_ws","delta":" "}`),
+ []byte(`data: {"type":"response.reasoning_summary_text.delta","item_id":"rs_ws","delta":"world"}`),
+ []byte(`data: {"type":"response.output_item.done","item":{"id":"rs_ws","type":"reasoning","encrypted_content":"` + signature + `","summary":[]}}`),
+ []byte(`data: {"type":"response.output_text.delta","delta":"ok"}`),
+ } {
+ for _, chunk := range ConvertCodexResponseToGemini(ctx, "muse-spark-1.3", nil, nil, ev, &wsParam) {
+ for _, p := range gjson.GetBytes(chunk, "candidates.0.content.parts").Array() {
+ wsParts = append(wsParts, p.Raw)
+ }
+ }
+ }
+ wsReplayReq := []byte(`{"model":"muse-spark-1.3","contents":[{"role":"user","parts":[{"text":"q"}]},{"role":"model","parts":[` + strings.Join(wsParts, ",") + `]},{"role":"user","parts":[{"text":"next"}]}]}`)
+ wsReplayCodex, _ := ConvertGeminiRequestToCodex("muse-spark-1.3", wsReplayReq, false)
+ wsSummaries := gjson.GetBytes(wsReplayCodex, "input.1.summary").Array()
+ if len(wsSummaries) != 3 || wsSummaries[0].Get("text").String()+wsSummaries[1].Get("text").String()+wsSummaries[2].Get("text").String() != "Hello world" {
+ t.Fatalf("whitespace thought chunks round-trip = %s, want ['Hello',' ','world']", gjson.GetBytes(wsReplayCodex, "input.1.summary").Raw)
+ }
+}
+
func TestConvertCodexResponseToGeminiNonStreamPreservesFunctionCallID(t *testing.T) {
ctx := context.Background()
originalRequest := []byte(`{"tools":[]}`)
diff --git a/internal/translator/codex/interactions/interactions_codex_request.go b/internal/translator/codex/interactions/interactions_codex_request.go
index c6f5a0b2cc7..47be307a291 100644
--- a/internal/translator/codex/interactions/interactions_codex_request.go
+++ b/internal/translator/codex/interactions/interactions_codex_request.go
@@ -20,18 +20,20 @@ func ConvertInteractionsRequestToCodex(modelName string, inputRawJSON []byte, st
// because its only attachment has no Codex equivalent.
func convertInteractionsRequestToCodex(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) {
root := gjson.ParseBytes(inputRawJSON)
- out := []byte(`{"model":"","instructions":"","input":[]}`)
+ out := []byte(`{"model":"","instructions":"","store":false,"input":[]}`)
out, _ = sjson.SetBytes(out, "model", modelName)
if stream || root.Get("stream").Bool() {
out, _ = sjson.SetBytes(out, "stream", true)
}
out = copyInteractionsSystemToCodex(out, root)
out = copyInteractionsGenerationConfigToCodex(out, root)
+ signatureTargetModel := translatorcommon.ResolveCodexReplayTargetModel(modelName, inputRawJSON)
inputItems := translatorcommon.NewRawArrayItems(root.Get("input.#").Int())
- errInput := appendInteractionsInputToCodex(&inputItems, root.Get("input"))
+ errInput := appendInteractionsInputToCodex(&inputItems, root.Get("input"), signatureTargetModel)
out = translatorcommon.SetRawArrayItems(out, "input", inputItems)
out = copyInteractionsToolsToCodex(out, root)
out = copyInteractionsCodexTopLevel(out, root)
+ out = ensureInteractionsCodexEncryptedContentInclude(out)
return out, errInput
}
@@ -190,7 +192,7 @@ func interactionsCodexReasoningSummary(cfg gjson.Result) string {
// appendInteractionsInputToCodex adds the input steps and reports a user turn that
// was left with nothing to send because of an attachment Codex cannot carry.
-func appendInteractionsInputToCodex(items *[][]byte, input gjson.Result) error {
+func appendInteractionsInputToCodex(items *[][]byte, input gjson.Result, modelName string) error {
if !input.Exists() {
return nil
}
@@ -199,19 +201,20 @@ func appendInteractionsInputToCodex(items *[][]byte, input gjson.Result) error {
return nil
}
var run translatorcommon.UserRun
+ emittedSignatures := make(map[string]bool)
if input.IsArray() {
input.ForEach(func(_, step gjson.Result) bool {
- appendInteractionsStepToCodex(items, step, "user", &run)
+ appendInteractionsStepToCodex(items, step, "user", &run, modelName, emittedSignatures)
return true
})
} else if steps := input.Get("steps"); steps.Exists() && steps.IsArray() {
defaultRole := interactionsCodexStepRole(input, "user")
steps.ForEach(func(_, step gjson.Result) bool {
- appendInteractionsStepToCodex(items, step, defaultRole, &run)
+ appendInteractionsStepToCodex(items, step, defaultRole, &run, modelName, emittedSignatures)
return true
})
} else {
- appendInteractionsStepToCodex(items, input, "user", &run)
+ appendInteractionsStepToCodex(items, input, "user", &run, modelName, emittedSignatures)
}
run.End()
return run.Err()
@@ -227,7 +230,7 @@ func interactionsCodexUserRun(run *translatorcommon.UserRun, role string) *trans
return nil
}
-func appendInteractionsStepToCodex(items *[][]byte, step gjson.Result, defaultRole string, run *translatorcommon.UserRun) {
+func appendInteractionsStepToCodex(items *[][]byte, step gjson.Result, defaultRole string, run *translatorcommon.UserRun, modelName string, emittedSignatures map[string]bool) {
if step.Type == gjson.String {
appendInteractionsRoleTextToCodex(items, defaultRole, step.String(), run)
return
@@ -235,7 +238,7 @@ func appendInteractionsStepToCodex(items *[][]byte, step gjson.Result, defaultRo
if steps := step.Get("steps"); steps.Exists() && steps.IsArray() {
role := interactionsCodexStepRole(step, defaultRole)
steps.ForEach(func(_, nested gjson.Result) bool {
- appendInteractionsStepToCodex(items, nested, role, run)
+ appendInteractionsStepToCodex(items, nested, role, run, modelName, emittedSignatures)
return true
})
return
@@ -254,7 +257,7 @@ func appendInteractionsStepToCodex(items *[][]byte, step gjson.Result, defaultRo
appendInteractionsContentToCodexItem(items, step.Get("content"), "assistant", run)
case "thought", "reasoning":
run.End()
- appendInteractionsThoughtToCodex(items, step)
+ appendInteractionsThoughtToCodex(items, step, modelName, emittedSignatures)
case "user_input", "message", "":
role := interactionsCodexStepRole(step, defaultRole)
if content := step.Get("content"); content.Exists() {
@@ -426,19 +429,67 @@ func setInteractionsCodexRawIfDifferent(out []byte, path string, value gjson.Res
return updated
}
-func appendInteractionsThoughtToCodex(items *[][]byte, step gjson.Result) {
+// ensureInteractionsCodexEncryptedContentInclude makes the outbound request
+// ask the upstream for encrypted reasoning content so a signature can come
+// back for replay.
+func ensureInteractionsCodexEncryptedContentInclude(out []byte) []byte {
+ const reasoningEncryptedContent = "reasoning.encrypted_content"
+ include := gjson.GetBytes(out, "include")
+ if !include.Exists() {
+ out, _ = sjson.SetRawBytes(out, "include", []byte(`["reasoning.encrypted_content"]`))
+ return out
+ }
+ if !include.IsArray() {
+ return out
+ }
+ for _, value := range include.Array() {
+ if value.Type == gjson.String && value.String() == reasoningEncryptedContent {
+ return out
+ }
+ }
+ out, _ = sjson.SetBytes(out, "include.-1", reasoningEncryptedContent)
+ return out
+}
+
+// appendInteractionsThoughtToCodex adds a thought step. A client-echoed
+// signature that CodexReplaySignatureForTarget accepts for this request's
+// target model is restored as Responses reasoning encrypted_content, with
+// the thought text carried as a summary; identical signatures already
+// emitted in this request are replayed only once. The step id is
+// deliberately not copied onto replay items: replay is stateless and the
+// executor strips orphan ids. Every other thought step is dropped entirely:
+// a reasoning item without accepted encrypted_content carries no replayable
+// state, and the legacy text fallback shape is rejected by both the native
+// Meta upstream (missing summary) and routers (content expected array).
+func appendInteractionsThoughtToCodex(items *[][]byte, step gjson.Result, modelName string, emittedSignatures map[string]bool) {
text := interactionsCodexContentText(step.Get("content"))
if text == "" {
text = step.Get("text").String()
}
- item := []byte(`{"type":"reasoning"}`)
- if text != "" {
- item, _ = sjson.SetBytes(item, "content", text)
+ if raw := interactionsThoughtStepSignature(step); raw != "" {
+ if normalized, ok := translatorcommon.CodexReplaySignatureForTarget(modelName, raw); ok && !emittedSignatures[normalized] {
+ emittedSignatures[normalized] = true
+ item := []byte(`{"type":"reasoning","encrypted_content":"","summary":[]}`)
+ item, _ = sjson.SetBytes(item, "encrypted_content", normalized)
+ if text != "" {
+ summaryPart := []byte(`{"type":"summary_text","text":""}`)
+ summaryPart, _ = sjson.SetBytes(summaryPart, "text", text)
+ item, _ = sjson.SetRawBytes(item, "summary", translatorcommon.JoinRawArray([][]byte{summaryPart}))
+ }
+ *items = append(*items, item)
+ }
}
- if id := step.Get("id"); id.Exists() {
- item, _ = sjson.SetBytes(item, "id", id.String())
+}
+
+// interactionsThoughtStepSignature extracts a thought step's signature under
+// any of the field spellings seen on the wire.
+func interactionsThoughtStepSignature(step gjson.Result) string {
+ for _, path := range []string{"signature", "thought_signature", "thoughtSignature"} {
+ if signature := strings.TrimSpace(step.Get(path).String()); signature != "" {
+ return signature
+ }
}
- *items = append(*items, item)
+ return ""
}
func appendInteractionsTextToCodex(items *[][]byte, role, text string) {
diff --git a/internal/translator/codex/interactions/interactions_codex_response.go b/internal/translator/codex/interactions/interactions_codex_response.go
index 1f11276533e..9e1a899389e 100644
--- a/internal/translator/codex/interactions/interactions_codex_response.go
+++ b/internal/translator/codex/interactions/interactions_codex_response.go
@@ -3,6 +3,7 @@ package interactions
import (
"bytes"
"context"
+ "crypto/sha256"
"fmt"
"strings"
"time"
@@ -13,19 +14,24 @@ import (
)
type codexToInteractionsStreamState struct {
- Started bool
- Completed bool
- Done bool
- ActiveStepOpen bool
- ActiveStepType string
- ActiveStepIndex int
- StepIndex int
- ID string
- Model string
- CreatedAt int64
- HasOutputText bool
- FunctionCallName string
- FunctionCallID string
+ Started bool
+ Completed bool
+ Done bool
+ ActiveStepOpen bool
+ ActiveStepType string
+ ActiveStepIndex int
+ StepIndex int
+ ID string
+ Model string
+ CreatedAt int64
+ HasOutputText bool
+ HasThoughtSummary bool
+ StreamedSummaryItems map[string]bool
+ FunctionCallName string
+ FunctionCallID string
+ // SeenReasoningSignatures dedupes repeated reasoning output_item.done
+ // events so one signature is emitted as a thought_signature delta once.
+ SeenReasoningSignatures map[[32]byte]bool
}
func ConvertCodexResponseToInteractions(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
@@ -233,6 +239,13 @@ func codexReasoningDeltaToInteractions(st *codexToInteractionsStreamState, root
delta := []byte(`{"index":0,"delta":{"content":{"text":"","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}`)
delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex)
delta, _ = sjson.SetBytes(delta, "delta.content.text", root.Get("delta").String())
+ if itemID := strings.TrimSpace(root.Get("item_id").String()); itemID != "" {
+ if st.StreamedSummaryItems == nil {
+ st.StreamedSummaryItems = make(map[string]bool)
+ }
+ st.StreamedSummaryItems[itemID] = true
+ }
+ st.HasThoughtSummary = true
return append(out, translatorcommon.SSEEventData("step.delta", delta))
}
@@ -280,6 +293,9 @@ func appendCodexInteractionsStepStart(out [][]byte, st *codexToInteractionsStrea
st.StepIndex++
st.ActiveStepOpen = true
st.ActiveStepType = stepType
+ if stepType == "thought" {
+ st.HasThoughtSummary = false
+ }
stepStart := []byte(`{"index":0,"step":{"type":""},"event_type":"step.start"}`)
stepStart, _ = sjson.SetBytes(stepStart, "index", st.ActiveStepIndex)
stepStart, _ = sjson.SetBytes(stepStart, "step.type", stepType)
@@ -310,6 +326,9 @@ func appendCodexInteractionsStepStop(out [][]byte, st *codexToInteractionsStream
stepStop := []byte(`{"index":0,"event_type":"step.stop"}`)
stepStop, _ = sjson.SetBytes(stepStop, "index", st.ActiveStepIndex)
out = append(out, translatorcommon.SSEEventData("step.stop", stepStop))
+ if st.ActiveStepType == "thought" {
+ st.HasThoughtSummary = false
+ }
st.ActiveStepOpen = false
st.ActiveStepType = ""
return out
@@ -332,11 +351,18 @@ func buildCodexMessageItemToInteractions(item gjson.Result) []byte {
func buildCodexReasoningItemToInteractions(item gjson.Result) []byte {
text := codexReasoningText(item)
- if text == "" {
+ sig := strings.TrimSpace(item.Get("encrypted_content").String())
+ if text == "" && sig == "" {
return nil
}
- step := []byte(`{"type":"thought","content":[{"type":"text","text":""}]}`)
- step, _ = sjson.SetBytes(step, "content.0.text", text)
+ step := []byte(`{"type":"thought"}`)
+ if sig != "" {
+ step, _ = sjson.SetBytes(step, "signature", sig)
+ }
+ if text != "" {
+ step, _ = sjson.SetRawBytes(step, "content", []byte(`[{"type":"text","text":""}]`))
+ step, _ = sjson.SetBytes(step, "content.0.text", text)
+ }
return step
}
@@ -407,14 +433,41 @@ func appendCodexMessageItemToInteractionsStream(out [][]byte, st *codexToInterac
func appendCodexReasoningItemToInteractionsStream(out [][]byte, st *codexToInteractionsStreamState, item gjson.Result) [][]byte {
text := codexReasoningText(item)
- if text == "" {
- return out
- }
- out = ensureCodexInteractionsStep(out, st, "thought", item)
- delta := []byte(`{"index":0,"delta":{"content":{"text":"","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}`)
- delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex)
- delta, _ = sjson.SetBytes(delta, "delta.content.text", text)
- return append(out, translatorcommon.SSEEventData("step.delta", delta))
+ sig := strings.TrimSpace(item.Get("encrypted_content").String())
+ if sig != "" {
+ key := sha256.Sum256([]byte(item.Get("id").String() + "\x00" + sig))
+ if st.SeenReasoningSignatures[key] {
+ return out
+ }
+ if st.SeenReasoningSignatures == nil {
+ st.SeenReasoningSignatures = make(map[[32]byte]bool)
+ }
+ st.SeenReasoningSignatures[key] = true
+ }
+ itemID := strings.TrimSpace(item.Get("id").String())
+ hadStreamedSummary := st.HasThoughtSummary
+ if itemID != "" && len(st.StreamedSummaryItems) > 0 {
+ hadStreamedSummary = st.StreamedSummaryItems[itemID]
+ }
+ if text != "" && !hadStreamedSummary {
+ out = ensureCodexInteractionsStep(out, st, "thought", item)
+ delta := []byte(`{"index":0,"delta":{"content":{"text":"","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}`)
+ delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex)
+ delta, _ = sjson.SetBytes(delta, "delta.content.text", text)
+ out = append(out, translatorcommon.SSEEventData("step.delta", delta))
+ }
+ if sig != "" {
+ out = ensureCodexInteractionsStep(out, st, "thought", item)
+ signatureDelta := []byte(`{"index":0,"delta":{"signature":"","type":"thought_signature"},"event_type":"step.delta"}`)
+ signatureDelta, _ = sjson.SetBytes(signatureDelta, "index", st.ActiveStepIndex)
+ signatureDelta, _ = sjson.SetBytes(signatureDelta, "delta.signature", sig)
+ out = append(out, translatorcommon.SSEEventData("step.delta", signatureDelta))
+ }
+ // Completing a reasoning item resets the current item's summary flag so a
+ // subsequent done-only reasoning item is not suppressed by an earlier
+ // item's streamed deltas.
+ st.HasThoughtSummary = false
+ return out
}
func appendCodexFunctionCallItemToInteractionsStream(out [][]byte, st *codexToInteractionsStreamState, item gjson.Result) [][]byte {
diff --git a/internal/translator/codex/interactions/interactions_codex_test.go b/internal/translator/codex/interactions/interactions_codex_test.go
index 67581a1046c..eb85e412fbe 100644
--- a/internal/translator/codex/interactions/interactions_codex_test.go
+++ b/internal/translator/codex/interactions/interactions_codex_test.go
@@ -3,6 +3,7 @@ package interactions
import (
"bytes"
"context"
+ "encoding/base64"
"strings"
"testing"
@@ -17,17 +18,16 @@ func TestConvertInteractionsRequestToCodexWithToolMessagesDirect(t *testing.T) {
if got := gjson.GetBytes(out, "input.0.content.0.text").String(); got != "hi" {
t.Fatalf("input.0.content.0.text = %q, want hi. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.1.type").String(); got != "reasoning" {
- t.Fatalf("input.1.type = %q, want reasoning. Output: %s", got, string(out))
+ // The signatureless thought step is dropped entirely: a reasoning item
+ // without replayable encrypted_content is rejected by both upstreams.
+ if got := gjson.GetBytes(out, "input.1.type").String(); got != "function_call" {
+ t.Fatalf("input.1.type = %q, want function_call. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.2.type").String(); got != "function_call" {
- t.Fatalf("input.2.type = %q, want function_call. Output: %s", got, string(out))
- }
- if got := gjson.GetBytes(out, "input.2.call_id").String(); got != "call_1" {
+ if got := gjson.GetBytes(out, "input.1.call_id").String(); got != "call_1" {
t.Fatalf("function_call call_id = %q, want call_1. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.3.type").String(); got != "function_call_output" {
- t.Fatalf("input.3.type = %q, want function_call_output. Output: %s", got, string(out))
+ if got := gjson.GetBytes(out, "input.2.type").String(); got != "function_call_output" {
+ t.Fatalf("input.2.type = %q, want function_call_output. Output: %s", got, string(out))
}
if got := gjson.GetBytes(out, "tools.0.name").String(); got != "lookup" {
t.Fatalf("tools.0.name = %q, want lookup. Output: %s", got, string(out))
@@ -163,6 +163,266 @@ func TestConvertCodexResponseToInteractionsStreamCompletesAfterSteps(t *testing.
}
}
+// testInteractionsMetaSignature builds a shape-valid synthetic Meta envelope;
+// no captured signature is embedded.
+func testInteractionsMetaSignature() string {
+ return "Q-PaDgE" + strings.Repeat("A", 1419)
+}
+
+func testInteractionsGPTSignature() string {
+ decoded := make([]byte, 89)
+ decoded[0] = 0x80
+ for i := 9; i < len(decoded); i++ {
+ decoded[i] = byte(i)
+ }
+ return base64.RawURLEncoding.EncodeToString(decoded)
+}
+
+func findCodexInteractionsEventPayloads(events [][]byte, eventType string) []gjson.Result {
+ var out []gjson.Result
+ for _, event := range events {
+ for _, line := range bytes.Split(event, []byte("\n")) {
+ line = bytes.TrimSpace(line)
+ if !bytes.HasPrefix(line, []byte("data:")) {
+ continue
+ }
+ payload := bytes.TrimSpace(line[len("data:"):])
+ if codexInteractionsEventName(codexInteractionsFrameEventName(event), payload) == eventType {
+ out = append(out, gjson.ParseBytes(payload))
+ }
+ }
+ }
+ return out
+}
+
+func TestConvertCodexResponseToInteractionsNonStream_ReasoningSignature(t *testing.T) {
+ metaSig := testInteractionsMetaSignature()
+ routedSig := metaSig + "." + strings.Repeat("Ab3", 23)[:67]
+ raw := []byte(`{"type":"response.completed","response":{"id":"resp_1","output":[` +
+ `{"type":"reasoning","summary":[{"type":"summary_text","text":"thinking hard"}],"encrypted_content":"` + metaSig + `"},` +
+ `{"type":"reasoning","summary":[],"encrypted_content":"` + routedSig + `"},` +
+ `{"type":"reasoning","summary":[]}` +
+ `]}}`)
+
+ out := ConvertCodexResponseToInteractionsNonStream(context.Background(), "codex-test", nil, nil, raw, nil)
+ steps := gjson.GetBytes(out, "steps").Array()
+ if len(steps) != 2 {
+ t.Fatalf("steps len = %d, want 2 (empty reasoning emits no step). Output: %s", len(steps), string(out))
+ }
+ if got := steps[0].Get("type").String(); got != "thought" {
+ t.Fatalf("steps.0.type = %q, want thought. Output: %s", got, string(out))
+ }
+ if got := steps[0].Get("signature").String(); got != metaSig {
+ t.Fatalf("steps.0.signature = %q, want Meta signature verbatim. Output: %s", got, string(out))
+ }
+ if got := steps[0].Get("content.0.text").String(); got != "thinking hard" {
+ t.Fatalf("steps.0.content.0.text = %q, want thinking hard. Output: %s", got, string(out))
+ }
+ if got := steps[1].Get("signature").String(); got != routedSig {
+ t.Fatalf("steps.1.signature = %q, want routed Meta signature verbatim. Output: %s", got, string(out))
+ }
+ if steps[1].Get("content").Exists() {
+ t.Fatalf("steps.1 should carry no content when there is no text. Output: %s", string(out))
+ }
+}
+
+func TestConvertCodexResponseToInteractionsStream_ReasoningSignature(t *testing.T) {
+ metaSig := testInteractionsMetaSignature()
+ var param any
+ var events [][]byte
+ for _, chunk := range [][]byte{
+ []byte(`data: {"type":"response.created","response":{"id":"resp_1","model":"codex-test"}}`),
+ []byte(`data: {"type":"response.reasoning_summary_text.delta","delta":"streamed thinking"}`),
+ []byte(`data: {"type":"response.output_item.done","item":{"id":"rs_1","type":"reasoning","summary":[{"type":"summary_text","text":"streamed thinking"}],"encrypted_content":"` + metaSig + `"}}`),
+ // A duplicated done event for the same item must not re-emit the signature or summary.
+ []byte(`data: {"type":"response.output_item.done","item":{"id":"rs_1","type":"reasoning","summary":[{"type":"summary_text","text":"streamed thinking"}],"encrypted_content":"` + metaSig + `"}}`),
+ []byte(`data: {"type":"response.output_item.done","item":{"type":"message","content":[{"type":"output_text","text":"answer"}]}}`),
+ } {
+ events = append(events, ConvertCodexResponseToInteractions(context.Background(), "codex-test", nil, nil, chunk, ¶m)...)
+ }
+
+ starts := findCodexInteractionsEventPayloads(events, "step.start")
+ if len(starts) != 2 {
+ t.Fatalf("step.start count = %d, want thought + model_output. Events: %q", len(starts), events)
+ }
+ if got := starts[0].Get("step.type").String(); got != "thought" {
+ t.Fatalf("step.start[0] type = %q, want thought", got)
+ }
+ if got := starts[1].Get("step.type").String(); got != "model_output" {
+ t.Fatalf("step.start[1] type = %q, want model_output", got)
+ }
+
+ signatureDeltas := 0
+ for _, delta := range findCodexInteractionsEventPayloads(events, "step.delta") {
+ if delta.Get("delta.type").String() != "thought_signature" {
+ continue
+ }
+ signatureDeltas++
+ if got := delta.Get("delta.signature").String(); got != metaSig {
+ t.Fatalf("thought_signature delta signature = %q, want Meta signature verbatim", got)
+ }
+ }
+ if signatureDeltas != 1 {
+ t.Fatalf("thought_signature delta count = %d, want 1 (duplicate suppressed)", signatureDeltas)
+ }
+ summaryDeltas := 0
+ for _, delta := range findCodexInteractionsEventPayloads(events, "step.delta") {
+ if delta.Get("delta.type").String() == "thought_summary" {
+ summaryDeltas++
+ }
+ }
+ if summaryDeltas != 1 {
+ t.Fatalf("thought_summary delta count = %d, want 1 (output_item.done must not duplicate streamed summary)", summaryDeltas)
+ }
+
+ names := strings.Join(codexInteractionsEventNames(events), ",")
+ want := "interaction.created,interaction.status_update,step.start,step.delta,step.delta,step.stop,step.start,step.delta,step.stop"
+ if names != want {
+ t.Fatalf("events = %s, want %s", names, want)
+ }
+
+ // A second done-only reasoning item after a streamed-delta reasoning item must still emit its summary.
+ var param2 any
+ var events2 [][]byte
+ for _, chunk := range [][]byte{
+ []byte(`data: {"type":"response.created","response":{"id":"resp_2","model":"codex-test"}}`),
+ []byte(`data: {"type":"response.reasoning_summary_text.delta","delta":"first summary"}`),
+ []byte(`data: {"type":"response.output_item.done","item":{"id":"rs_a","type":"reasoning","summary":[{"type":"summary_text","text":"first summary"}],"encrypted_content":"` + metaSig + `"}}`),
+ []byte(`data: {"type":"response.output_item.done","item":{"id":"rs_b","type":"reasoning","summary":[{"type":"summary_text","text":"second summary only in done"}]}}`),
+ } {
+ events2 = append(events2, ConvertCodexResponseToInteractions(context.Background(), "codex-test", nil, nil, chunk, ¶m2)...)
+ }
+ var summaries2 []string
+ for _, delta := range findCodexInteractionsEventPayloads(events2, "step.delta") {
+ if delta.Get("delta.type").String() == "thought_summary" {
+ summaries2 = append(summaries2, delta.Get("delta.content.text").String())
+ }
+ }
+ if len(summaries2) != 2 || summaries2[0] != "first summary" || summaries2[1] != "second summary only in done" {
+ t.Fatalf("consecutive reasoning summaries = %v, want [first summary, second summary only in done]", summaries2)
+ }
+}
+
+func TestConvertInteractionsRequestToCodex_RestoresThoughtSignature(t *testing.T) {
+ metaSig := testInteractionsMetaSignature()
+ routedSig := metaSig + "." + strings.Repeat("Ab3", 23)[:67]
+ for _, tc := range []struct {
+ name string
+ model string
+ sig string
+ }{
+ {"native meta", "muse-spark-1.3", metaSig},
+ {"namespaced router model", "meta/muse-wonder", metaSig},
+ {"routed meta verbatim", "meta/muse-wonder", routedSig},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ payload := []byte(`{"model":"` + tc.model + `","input":[{"type":"thought","signature":"` + tc.sig + `","content":[{"type":"text","text":"thinking"}]}]}`)
+ out, _ := ConvertInteractionsRequestToCodex(tc.model, payload, false)
+ item := gjson.GetBytes(out, "input.0")
+ if got := item.Get("type").String(); got != "reasoning" {
+ t.Fatalf("input.0.type = %q, want reasoning. Output: %s", got, string(out))
+ }
+ if got := item.Get("encrypted_content").String(); got != tc.sig {
+ t.Fatalf("encrypted_content = %q, want signature verbatim. Output: %s", got, string(out))
+ }
+ if got := item.Get("summary.0.text").String(); got != "thinking" {
+ t.Fatalf("summary.0.text = %q, want thinking. Output: %s", got, string(out))
+ }
+ if item.Get("content").Exists() {
+ t.Fatalf("replay item must not carry content. Output: %s", string(out))
+ }
+ })
+ }
+}
+
+func TestConvertInteractionsRequestToCodex_ThoughtSignatureTargetMismatch(t *testing.T) {
+ metaSig := testInteractionsMetaSignature()
+ payload := []byte(`{"model":"gpt-5.4","input":[{"type":"thought","signature":"` + metaSig + `","content":[{"type":"text","text":"thinking"}],"id":"step_1"}]}`)
+
+ // A foreign signature is never replayed and the thought step is dropped
+ // entirely rather than sent as an unsigned reasoning item.
+ out, _ := ConvertInteractionsRequestToCodex("gpt-5.4", payload, false)
+ if got := len(gjson.GetBytes(out, "input").Array()); got != 0 {
+ t.Fatalf("input len = %d, want 0 (mismatched thought dropped). Output: %s", got, string(out))
+ }
+}
+
+func TestConvertInteractionsRequestToCodex_DropsEmptyThoughtSteps(t *testing.T) {
+ metaSig := testInteractionsMetaSignature()
+
+ // A rejected signature with no text emits no item at all: a bare
+ // reasoning item carries nothing replayable and is rejected upstream.
+ out, _ := ConvertInteractionsRequestToCodex("gpt-5.4", []byte(`{"input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]},{"type":"thought","signature":"`+metaSig+`"}]}`), false)
+ if got := len(gjson.GetBytes(out, "input").Array()); got != 1 {
+ t.Fatalf("input len = %d, want 1 (thought step dropped). Output: %s", got, string(out))
+ }
+
+ // A signatureless, textless thought step emits no item.
+ out, _ = ConvertInteractionsRequestToCodex("muse-spark-1.3", []byte(`{"input":[{"type":"thought"}]}`), false)
+ if got := len(gjson.GetBytes(out, "input").Array()); got != 0 {
+ t.Fatalf("input len = %d, want 0. Output: %s", got, string(out))
+ }
+
+ // A duplicate accepted signature with no text emits nothing the second
+ // time either.
+ out, _ = ConvertInteractionsRequestToCodex("muse-spark-1.3", []byte(`{"input":[{"type":"thought","signature":"`+metaSig+`"},{"type":"thought","signature":"`+metaSig+`"}]}`), false)
+ if got := len(gjson.GetBytes(out, "input").Array()); got != 1 {
+ t.Fatalf("input len = %d, want 1 (duplicate without text dropped). Output: %s", got, string(out))
+ }
+
+ // A signatureless thought with text is dropped too, and the message/tool
+ // items around it keep their order.
+ out, _ = ConvertInteractionsRequestToCodex("muse-spark-1.3", []byte(`{"input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]},{"type":"thought","content":[{"type":"text","text":"thinking"}]},{"type":"function_call","name":"lookup","call_id":"call_1","arguments":{"q":"x"}}]}`), false)
+ items := gjson.GetBytes(out, "input").Array()
+ if len(items) != 2 || items[0].Get("type").String() != "message" || items[1].Get("type").String() != "function_call" {
+ t.Fatalf("input = %s, want [message, function_call] with the thought dropped", items)
+ }
+}
+
+func TestConvertInteractionsRequestToCodex_ThoughtSignatureVariants(t *testing.T) {
+ metaSig := testInteractionsMetaSignature()
+ gptSig := testInteractionsGPTSignature()
+
+ // A cache-prefixed signature normalizes to its native payload.
+ out, _ := ConvertInteractionsRequestToCodex("gpt-5.4", []byte(`{"input":[{"type":"thought","signature":"gpt#`+gptSig+`"}]}`), false)
+ if got := gjson.GetBytes(out, "input.0.encrypted_content").String(); got != gptSig {
+ t.Fatalf("prefixed GPT signature should normalize to native payload. Output: %s", string(out))
+ }
+
+ // The snake_case thought_signature spelling is accepted too.
+ out, _ = ConvertInteractionsRequestToCodex("muse-spark-1.3", []byte(`{"input":[{"type":"thought","thought_signature":"`+metaSig+`"}]}`), false)
+ if got := gjson.GetBytes(out, "input.0.encrypted_content").String(); got != metaSig {
+ t.Fatalf("thought_signature field should be accepted. Output: %s", string(out))
+ }
+
+ // An identical signature replays once; the duplicate thought is dropped.
+ out, _ = ConvertInteractionsRequestToCodex("muse-spark-1.3", []byte(`{"input":[{"type":"thought","signature":"`+metaSig+`","content":[{"type":"text","text":"one"}]},{"type":"thought","signature":"`+metaSig+`","content":[{"type":"text","text":"two"}]}]}`), false)
+ if got := len(gjson.GetBytes(out, "input").Array()); got != 1 {
+ t.Fatalf("input len = %d, want 1 (duplicate dropped). Output: %s", got, string(out))
+ }
+ if got := gjson.GetBytes(out, "input.0.encrypted_content").String(); got != metaSig {
+ t.Fatalf("first thought should carry the signature. Output: %s", string(out))
+ }
+}
+
+func TestConvertInteractionsRequestToCodex_IncludeEncryptedContent(t *testing.T) {
+ out, _ := ConvertInteractionsRequestToCodex("codex-test", []byte(`{"input":"hi"}`), false)
+ if got := gjson.GetBytes(out, "include.0").String(); got != "reasoning.encrypted_content" {
+ t.Fatalf("include.0 = %q, want reasoning.encrypted_content. Output: %s", got, string(out))
+ }
+
+ out, _ = ConvertInteractionsRequestToCodex("codex-test", []byte(`{"input":"hi","include":["web_search_call.action.sources"]}`), false)
+ include := gjson.GetBytes(out, "include").Array()
+ if len(include) != 2 || include[0].String() != "web_search_call.action.sources" || include[1].String() != "reasoning.encrypted_content" {
+ t.Fatalf("include = %s, want sources + reasoning.encrypted_content. Output: %s", include, string(out))
+ }
+
+ out, _ = ConvertInteractionsRequestToCodex("codex-test", []byte(`{"input":"hi","include":["reasoning.encrypted_content"]}`), false)
+ if include := gjson.GetBytes(out, "include").Array(); len(include) != 1 {
+ t.Fatalf("include = %s, want unchanged single entry. Output: %s", include, string(out))
+ }
+}
+
func findCodexInteractionsEventPayload(events [][]byte, eventType string) []byte {
prefix := []byte("data:")
for _, event := range events {
diff --git a/internal/translator/common/codex_replay_signature.go b/internal/translator/common/codex_replay_signature.go
new file mode 100644
index 00000000000..ff290ee8919
--- /dev/null
+++ b/internal/translator/common/codex_replay_signature.go
@@ -0,0 +1,63 @@
+package common
+
+import (
+ "strings"
+
+ sigcompat "github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
+ "github.com/tidwall/gjson"
+)
+
+// ResolveCodexReplayTargetModel returns the model name to use for reasoning
+// signature family checks, falling back to the client-supplied "model" field in
+// rawPayload when modelName is an unmapped upstream deployment alias for a
+// Responses-native model family (Meta Muse, Grok, or GPT).
+func ResolveCodexReplayTargetModel(modelName string, rawPayload []byte) string {
+ if sigcompat.SignatureProviderFromModelName(thinking.ParseSuffix(modelName).ModelName) != sigcompat.SignatureProviderUnknown {
+ return modelName
+ }
+ if len(rawPayload) > 0 {
+ if rawModel := strings.TrimSpace(gjson.GetBytes(rawPayload, "model").String()); rawModel != "" {
+ switch sigcompat.SignatureProviderFromModelName(thinking.ParseSuffix(rawModel).ModelName) {
+ case sigcompat.SignatureProviderMeta, sigcompat.SignatureProviderGrok, sigcompat.SignatureProviderGPT:
+ return rawModel
+ }
+ }
+ }
+ return modelName
+}
+
+// CodexReplaySignatureForTarget returns the exact value that may be written as a
+// Responses reasoning encrypted_content for the Codex-format upstream serving
+// modelName, or false when rawSignature must not replay there. Positively
+// detected families must match the target family exactly (an unmapped target
+// defaults to the Codex GPT family, mirroring the Codex translators' purpose)
+// and go through the shared compatibility normalization, so cache-prefixed
+// values such as "gpt#..." are stripped to their native payload instead of
+// being forwarded verbatim. Grok has no detectable envelope, so it falls back
+// to its shape inspector keyed on the target.
+func CodexReplaySignatureForTarget(modelName, rawSignature string) (string, bool) {
+ rawSignature = strings.TrimSpace(rawSignature)
+ if rawSignature == "" {
+ return "", false
+ }
+ target := sigcompat.SignatureProviderFromModelName(thinking.ParseSuffix(modelName).ModelName)
+ detected := sigcompat.DetectSignatureProvider(rawSignature)
+ if detected != sigcompat.SignatureProviderUnknown {
+ effectiveTarget := target
+ if effectiveTarget == sigcompat.SignatureProviderUnknown {
+ effectiveTarget = sigcompat.SignatureProviderGPT
+ }
+ if detected != effectiveTarget {
+ return "", false
+ }
+ normalized, ok := sigcompat.CompatibleSignatureForProvider(effectiveTarget, rawSignature)
+ return normalized, ok && normalized != ""
+ }
+ if target == sigcompat.SignatureProviderGrok {
+ if _, err := sigcompat.InspectGrokEncryptedContent(rawSignature); err == nil {
+ return rawSignature, true
+ }
+ }
+ return "", false
+}
diff --git a/internal/translator/common/codex_replay_signature_test.go b/internal/translator/common/codex_replay_signature_test.go
new file mode 100644
index 00000000000..0a9471adc50
--- /dev/null
+++ b/internal/translator/common/codex_replay_signature_test.go
@@ -0,0 +1,66 @@
+package common
+
+import (
+ "encoding/base64"
+ "strings"
+ "testing"
+)
+
+// testCodexReplayMetaSignature builds a shape-valid synthetic Meta envelope:
+// "Q-PaDgE" pins the observed five-byte header, and 1426 unpadded base64url
+// chars decode to 45 + 1024 bytes. No captured signature is embedded.
+func testCodexReplayMetaSignature() string {
+ return "Q-PaDgE" + strings.Repeat("A", 1419)
+}
+
+func testCodexReplayGPTSignature() string {
+ decoded := make([]byte, 89)
+ decoded[0] = 0x80
+ for i := 9; i < len(decoded); i++ {
+ decoded[i] = byte(i)
+ }
+ return base64.RawURLEncoding.EncodeToString(decoded)
+}
+
+func TestCodexReplaySignatureForTarget(t *testing.T) {
+ metaSig := testCodexReplayMetaSignature()
+ routedSig := metaSig + "." + strings.Repeat("Ab3", 23)[:67]
+ gptSig := testCodexReplayGPTSignature()
+
+ cases := []struct {
+ name string
+ model string
+ sig string
+ want string
+ ok bool
+ }{
+ {"native meta on muse target", "muse-spark-1.3", metaSig, metaSig, true},
+ {"routed meta on muse target", "meta/muse-wonder", routedSig, routedSig, true},
+ {"gpt on gpt target", "gpt-5.4", gptSig, gptSig, true},
+ {"routed gpt on openrouter gpt target", "openrouter/openai/gpt-5.6-luna-pro", gptSig + "." + strings.Repeat("Ab3", 27)[:79], gptSig + "." + strings.Repeat("Ab3", 27)[:79], true},
+ {"gpt cache prefix normalized", "gpt-5.4", "gpt#" + gptSig, gptSig, true},
+ {"meta sig on gpt target rejected", "gpt-5.4", metaSig, "", false},
+ {"routed meta on gpt target rejected", "gpt-5.4", routedSig, "", false},
+ {"gpt sig on meta target rejected", "muse-spark-1.3", gptSig, "", false},
+ // An unmapped target defaults to the Codex GPT family.
+ {"gpt on unmapped target", "deepseek-v4", gptSig, gptSig, true},
+ {"meta on unmapped target rejected", "deepseek-v4", metaSig, "", false},
+ {"empty signature", "gpt-5.4", "", "", false},
+ {"unknown format", "gpt-5.4", "opaque-blob-xyz", "", false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got, ok := CodexReplaySignatureForTarget(tc.model, tc.sig)
+ if ok != tc.ok || got != tc.want {
+ t.Fatalf("CodexReplaySignatureForTarget(%q, sig) = (%v, %v), want (%v, %v)", tc.model, truncateForTest(got), ok, truncateForTest(tc.want), tc.ok)
+ }
+ })
+ }
+}
+
+func truncateForTest(s string) string {
+ if len(s) > 24 {
+ return s[:24] + "..."
+ }
+ return s
+}
diff --git a/internal/translator/gemini/claude/gemini_claude_response.go b/internal/translator/gemini/claude/gemini_claude_response.go
index 8d52f9226f9..216ac872d29 100644
--- a/internal/translator/gemini/claude/gemini_claude_response.go
+++ b/internal/translator/gemini/claude/gemini_claude_response.go
@@ -21,19 +21,28 @@ import (
// Params holds parameters for response conversion.
type Params struct {
- IsGlAPIKey bool
- HasFirstResponse bool
- ResponseType int
- ResponseIndex int
- HasContent bool // Tracks whether any content (text, thinking, or tool use) has been output
- ToolNameMap map[string]string
- SanitizedNameMap map[string]string
- SawToolCall bool
- HasFinalEvents bool
- FinishReason string
- InputTokens int64
- OutputTokens int64
- CachedTokens int64
+ IsGlAPIKey bool
+ HasFirstResponse bool
+ ResponseType int
+ ResponseIndex int
+ HasContent bool // Tracks whether any content (text, thinking, or tool use) has been output
+ ToolNameMap map[string]string
+ SanitizedNameMap map[string]string
+ SawToolCall bool
+ HasFinalEvents bool
+ FinishReason string
+ InputTokens int64
+ OutputTokens int64
+ CachedTokens int64
+ CurrentToolName string // Client-facing name of the currently open tool_use block
+ CurrentToolIsParameterless bool // Whether the open tool_use block was declared parameterless
+ CurrentToolInputEmitted bool // Whether cleaned input JSON was already emitted for the open tool_use block
+}
+
+const geminiMalformedFunctionCallErrorPayload = `{"type":"error","error":{"type":"api_error","message":"Gemini response finished with MALFORMED_FUNCTION_CALL"}}`
+
+func isGeminiMalformedFunctionCall(finishReason string, sawToolCall bool) bool {
+ return finishReason == "MALFORMED_FUNCTION_CALL" && !sawToolCall
}
func resolveGeminiClaudeStopReason(finishReason string, sawToolCall bool) string {
@@ -43,7 +52,7 @@ func resolveGeminiClaudeStopReason(finishReason string, sawToolCall bool) string
switch finishReason {
case "MAX_TOKENS":
return "max_tokens"
- case "SAFETY", "RECITATION", "PROHIBITED_CONTENT", "SPII", "BLOCKLIST", "MALFORMED_FUNCTION_CALL", "IMAGE_SAFETY":
+ case "SAFETY", "RECITATION", "PROHIBITED_CONTENT", "SPII", "BLOCKLIST", "IMAGE_SAFETY":
return "refusal"
case "STOP", "FINISH_REASON_UNSPECIFIED", "UNKNOWN", "":
return "end_turn"
@@ -91,6 +100,20 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR
p := (*param).(*Params)
if bytes.Equal(rawJSON, []byte("[DONE]")) {
+ if isGeminiMalformedFunctionCall(p.FinishReason, p.SawToolCall) {
+ if p.ResponseType != 0 {
+ appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, p.ResponseIndex))
+ p.ResponseType = 0
+ }
+ if !p.HasFinalEvents {
+ appendEvent("error", geminiMalformedFunctionCallErrorPayload)
+ p.HasFinalEvents = true
+ }
+ if len(output) > 0 {
+ return [][]byte{output}
+ }
+ return [][]byte{}
+ }
if p.HasFirstResponse && !p.HasContent {
appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"text","text":""}}`, p.ResponseIndex))
p.ResponseType = 1
@@ -118,6 +141,10 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR
return [][]byte{}
}
+ if isGeminiMalformedFunctionCall(p.FinishReason, p.SawToolCall) && p.HasFinalEvents {
+ return [][]byte{}
+ }
+
appendSignatureDelta := func(signature string) {
if signature == "" || (*param).(*Params).ResponseType != 2 {
return
@@ -234,11 +261,21 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR
(*param).(*Params).SawToolCall = true
upstreamToolName := functionCallResult.Get("name").String()
upstreamToolName = util.RestoreSanitizedToolName((*param).(*Params).SanitizedNameMap, upstreamToolName)
- clientToolName := util.MapToolName((*param).(*Params).ToolNameMap, upstreamToolName)
+ clientToolName := util.MapClaudeToolName((*param).(*Params).ToolNameMap, originalRequestRawJSON, upstreamToolName)
if (*param).(*Params).ResponseType == 3 && upstreamToolName == "" {
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
- data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, (*param).(*Params).ResponseIndex)), "delta.partial_json", fcArgsResult.Raw)
+ partialJSON := util.CleanClaudeToolArgumentsWithTranslated(fcArgsResult.Raw, (*param).(*Params).CurrentToolName, originalRequestRawJSON, requestRawJSON)
+ if (*param).(*Params).CurrentToolIsParameterless {
+ if (*param).(*Params).CurrentToolInputEmitted {
+ partialJSON = ""
+ } else {
+ (*param).(*Params).CurrentToolInputEmitted = true
+ }
+ } else if partialJSON != "" {
+ (*param).(*Params).CurrentToolInputEmitted = true
+ }
+ data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, (*param).(*Params).ResponseIndex)), "delta.partial_json", partialJSON)
appendEvent("content_block_delta", string(data))
}
if hasThoughtSignature {
@@ -261,13 +298,26 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR
(*param).(*Params).ResponseIndex++
}
+ (*param).(*Params).CurrentToolName = clientToolName
+ (*param).(*Params).CurrentToolIsParameterless = util.ClaudeToolIsParameterless(clientToolName, originalRequestRawJSON)
+ (*param).(*Params).CurrentToolInputEmitted = false
data := []byte(fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`, (*param).(*Params).ResponseIndex))
data, _ = sjson.SetBytes(data, "content_block.id", util.SanitizeClaudeToolID(fmt.Sprintf("%s-%d", upstreamToolName, atomic.AddUint64(&toolUseIDCounter, 1))))
data, _ = sjson.SetBytes(data, "content_block.name", clientToolName)
appendEvent("content_block_start", string(data))
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
- data, _ = sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, (*param).(*Params).ResponseIndex)), "delta.partial_json", fcArgsResult.Raw)
+ partialJSON := util.CleanClaudeToolArgumentsWithTranslated(fcArgsResult.Raw, clientToolName, originalRequestRawJSON, requestRawJSON)
+ if (*param).(*Params).CurrentToolIsParameterless {
+ if (*param).(*Params).CurrentToolInputEmitted {
+ partialJSON = ""
+ } else {
+ (*param).(*Params).CurrentToolInputEmitted = true
+ }
+ } else if partialJSON != "" {
+ (*param).(*Params).CurrentToolInputEmitted = true
+ }
+ data, _ = sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, (*param).(*Params).ResponseIndex)), "delta.partial_json", partialJSON)
appendEvent("content_block_delta", string(data))
}
(*param).(*Params).ResponseType = 3
@@ -336,6 +386,16 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR
(*param).(*Params).CachedTokens = cachedTokens
}
+ if isGeminiMalformedFunctionCall((*param).(*Params).FinishReason, (*param).(*Params).SawToolCall) && !(*param).(*Params).HasFinalEvents {
+ if (*param).(*Params).ResponseType != 0 {
+ appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex))
+ (*param).(*Params).ResponseType = 0
+ }
+ appendEvent("error", geminiMalformedFunctionCallErrorPayload)
+ (*param).(*Params).HasFinalEvents = true
+ return [][]byte{output}
+ }
+
if usageResult.Exists() && bytes.Contains(rawJSON, []byte(`"finishReason"`)) && !(*param).(*Params).HasFinalEvents {
if !(*param).(*Params).HasContent && (*param).(*Params).HasFirstResponse {
appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"text","text":""}}`, (*param).(*Params).ResponseIndex))
@@ -402,7 +462,6 @@ func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, origina
textBuilder := strings.Builder{}
thinkingBuilder := strings.Builder{}
var thinkingSignature string
- toolIDCounter := 0
hasToolCall := false
var blocks [][]byte
@@ -493,14 +552,13 @@ func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, origina
upstreamToolName := functionCall.Get("name").String()
upstreamToolName = util.RestoreSanitizedToolName(sanitizedNameMap, upstreamToolName)
- clientToolName := util.MapToolName(toolNameMap, upstreamToolName)
- toolIDCounter++
+ clientToolName := util.MapClaudeToolName(toolNameMap, originalRequestRawJSON, upstreamToolName)
toolBlock := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
- toolBlock, _ = sjson.SetBytes(toolBlock, "id", util.SanitizeClaudeToolID(fmt.Sprintf("%s-%d", upstreamToolName, toolIDCounter)))
+ toolBlock, _ = sjson.SetBytes(toolBlock, "id", util.SanitizeClaudeToolID(fmt.Sprintf("%s-%d", upstreamToolName, atomic.AddUint64(&toolUseIDCounter, 1))))
toolBlock, _ = sjson.SetBytes(toolBlock, "name", clientToolName)
inputRaw := "{}"
if args := functionCall.Get("args"); args.Exists() && gjson.Valid(args.Raw) && args.IsObject() {
- inputRaw = args.Raw
+ inputRaw = util.CleanClaudeToolArgumentsWithTranslated(args.Raw, clientToolName, originalRequestRawJSON, requestRawJSON)
}
toolBlock, _ = sjson.SetRawBytes(toolBlock, "input", []byte(inputRaw))
blocks = append(blocks, toolBlock)
@@ -535,6 +593,9 @@ func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, origina
if finish := root.Get("candidates.0.finishReason"); finish.Exists() {
finishReason = finish.String()
}
+ if isGeminiMalformedFunctionCall(finishReason, hasToolCall) {
+ return []byte(geminiMalformedFunctionCallErrorPayload)
+ }
out, _ = sjson.SetBytes(out, "stop_reason", resolveGeminiClaudeStopReason(finishReason, hasToolCall))
if inputTokens == int64(0) && outputTokens == int64(0) && !root.Get("usageMetadata").Exists() {
diff --git a/internal/translator/gemini/claude/gemini_claude_response_test.go b/internal/translator/gemini/claude/gemini_claude_response_test.go
index b729fe20081..d27127aca0e 100644
--- a/internal/translator/gemini/claude/gemini_claude_response_test.go
+++ b/internal/translator/gemini/claude/gemini_claude_response_test.go
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"strings"
+ "sync/atomic"
"testing"
"github.com/tidwall/gjson"
@@ -368,25 +369,155 @@ func TestConvertGeminiResponseToClaudeStream_PartlessMalformedFunctionCallCloses
output = append(output, bytes.Join(ConvertGeminiResponseToClaude(ctx, "claude-opus-5-5", requestJSON, requestJSON, []byte("[DONE]"), ¶m), nil)...)
outputText := string(output)
- if !strings.Contains(outputText, `"type":"message_delta"`) {
- t.Fatalf("expected message_delta in output:\n%s", outputText)
+ if strings.Contains(outputText, `"stop_reason":"refusal"`) {
+ t.Fatalf("unexpected refusal stop_reason in output:\n%s", outputText)
}
- if !strings.Contains(outputText, `"type":"message_stop"`) {
- t.Fatalf("expected message_stop in output:\n%s", outputText)
+ if strings.Contains(outputText, `"type":"message_delta"`) {
+ t.Fatalf("unexpected message_delta in output:\n%s", outputText)
+ }
+ if strings.Contains(outputText, `"type":"message_stop"`) {
+ t.Fatalf("unexpected message_stop in output:\n%s", outputText)
+ }
+ if !strings.Contains(outputText, "event: error\n") {
+ t.Fatalf("expected SSE error event in output:\n%s", outputText)
}
- var foundDelta bool
+ var foundError bool
for _, line := range strings.Split(outputText, "\n") {
- if strings.HasPrefix(line, "data: ") && strings.Contains(line, `"type":"message_delta"`) {
- foundDelta = true
- deltaJSON := gjson.Parse(strings.TrimPrefix(line, "data: "))
- if got := deltaJSON.Get("delta.stop_reason").String(); got != "refusal" {
- t.Fatalf("stop_reason = %q, want refusal. Payload: %s", got, line)
+ if strings.HasPrefix(line, "data: ") && strings.Contains(line, `"type":"error"`) {
+ foundError = true
+ errJSON := gjson.Parse(strings.TrimPrefix(line, "data: "))
+ if got := errJSON.Get("error.type").String(); got != "api_error" {
+ t.Fatalf("error.type = %q, want api_error. Payload: %s", got, line)
+ }
+ if !strings.Contains(errJSON.Get("error.message").String(), "MALFORMED_FUNCTION_CALL") {
+ t.Fatalf("error.message = %q, want mention of MALFORMED_FUNCTION_CALL. Payload: %s", errJSON.Get("error.message").String(), line)
}
}
}
- if !foundDelta {
- t.Fatalf("failed to find message_delta in output:\n%s", outputText)
+ if !foundError {
+ t.Fatalf("failed to find error event data in output:\n%s", outputText)
+ }
+}
+
+func TestConvertGeminiResponseToClaudeStream_MalformedFunctionCallAfterThinkingEmitsError(t *testing.T) {
+ requestJSON := []byte(`{"model":"claude-opus-5-5","messages":[{"role":"user","content":"hi"}]}`)
+ chunk1 := []byte(`{
+ "candidates": [{
+ "content": {"role": "model", "parts": [{"thought": true, "text": "planning tool call"}]},
+ "index": 0
+ }],
+ "modelVersion": "m"
+ }`)
+ chunk2 := []byte(`{
+ "candidates": [{
+ "content": {"role": "model", "parts": []},
+ "index": 0,
+ "finishReason": "MALFORMED_FUNCTION_CALL"
+ }],
+ "modelVersion": "m",
+ "usageMetadata": {
+ "promptTokenCount": 50,
+ "candidatesTokenCount": 10,
+ "thoughtsTokenCount": 313,
+ "totalTokenCount": 373
+ }
+ }`)
+
+ var param any
+ ctx := context.Background()
+ output := bytes.Join(ConvertGeminiResponseToClaude(ctx, "claude-opus-5-5", requestJSON, requestJSON, chunk1, ¶m), nil)
+ output = append(output, bytes.Join(ConvertGeminiResponseToClaude(ctx, "claude-opus-5-5", requestJSON, requestJSON, chunk2, ¶m), nil)...)
+ output = append(output, bytes.Join(ConvertGeminiResponseToClaude(ctx, "claude-opus-5-5", requestJSON, requestJSON, []byte("[DONE]"), ¶m), nil)...)
+ outputText := string(output)
+
+ stopIdx := strings.Index(outputText, "event: content_block_stop\n")
+ errIdx := strings.Index(outputText, "event: error\n")
+ if stopIdx < 0 || errIdx < 0 || stopIdx >= errIdx {
+ t.Fatalf("expected content_block_stop before error event in output:\n%s", outputText)
+ }
+ if strings.Contains(outputText, `"type":"message_delta"`) || strings.Contains(outputText, `"type":"message_stop"`) {
+ t.Fatalf("unexpected normal completion events after MALFORMED_FUNCTION_CALL:\n%s", outputText)
+ }
+}
+
+func TestConvertGeminiResponseToClaudeStream_MalformedFunctionCallWithoutUsageAndDuplicateTerminalChunk(t *testing.T) {
+ requestJSON := []byte(`{"model":"claude-opus-5-5","messages":[{"role":"user","content":"hi"}]}`)
+ chunkWithoutUsage := []byte(`{
+ "candidates": [{
+ "content": {"role": "model", "parts": []},
+ "index": 0,
+ "finishReason": "MALFORMED_FUNCTION_CALL"
+ }],
+ "modelVersion": "m"
+ }`)
+ duplicateTerminalChunk := []byte(`{
+ "candidates": [{
+ "content": {"role": "model", "parts": [{"text": "late text"}]},
+ "index": 0,
+ "finishReason": "MALFORMED_FUNCTION_CALL"
+ }],
+ "modelVersion": "m",
+ "usageMetadata": {
+ "promptTokenCount": 50,
+ "candidatesTokenCount": 10,
+ "totalTokenCount": 60
+ }
+ }`)
+
+ var param any
+ ctx := context.Background()
+ output := bytes.Join(ConvertGeminiResponseToClaude(ctx, "claude-opus-5-5", requestJSON, requestJSON, chunkWithoutUsage, ¶m), nil)
+ lateOutput := bytes.Join(ConvertGeminiResponseToClaude(ctx, "claude-opus-5-5", requestJSON, requestJSON, duplicateTerminalChunk, ¶m), nil)
+ doneOutput := bytes.Join(ConvertGeminiResponseToClaude(ctx, "claude-opus-5-5", requestJSON, requestJSON, []byte("[DONE]"), ¶m), nil)
+ if len(lateOutput) != 0 {
+ t.Fatalf("expected duplicate terminal chunk after error to emit nothing, got:\n%s", string(lateOutput))
+ }
+ if len(doneOutput) != 0 {
+ t.Fatalf("expected [DONE] after error to emit nothing, got:\n%s", string(doneOutput))
+ }
+ outputText := string(output)
+ if got := strings.Count(outputText, "event: error\n"); got != 1 {
+ t.Fatalf("error event count = %d, want 1. Output:\n%s", got, outputText)
+ }
+}
+
+func TestConvertGeminiResponseToClaude_MalformedFunctionCallWithToolUsePreservesToolUse(t *testing.T) {
+ requestJSON := []byte(`{"model":"claude-opus-5-5","messages":[{"role":"user","content":"hi"}],"tools":[{"name":"Bash","input_schema":{"type":"object"}}]}`)
+ rawJSON := []byte(`{
+ "candidates": [{
+ "content": {
+ "role": "model",
+ "parts": [{"functionCall": {"name": "Bash", "args": {"command": "ls"}}}]
+ },
+ "index": 0,
+ "finishReason": "MALFORMED_FUNCTION_CALL"
+ }],
+ "usageMetadata": {
+ "promptTokenCount": 50,
+ "candidatesTokenCount": 10,
+ "totalTokenCount": 60
+ },
+ "modelVersion": "m",
+ "responseId": "resp-tool"
+ }`)
+
+ ctx := context.Background()
+ nonStream := ConvertGeminiResponseToClaudeNonStream(ctx, "claude-opus-5-5", requestJSON, requestJSON, rawJSON, nil)
+ nonStreamJSON := gjson.ParseBytes(nonStream)
+ if got := nonStreamJSON.Get("stop_reason").String(); got != "tool_use" {
+ t.Fatalf("non-stream stop_reason = %q, want tool_use. Output: %s", got, string(nonStream))
+ }
+
+ var param any
+ streamOut := bytes.Join(ConvertGeminiResponseToClaude(ctx, "claude-opus-5-5", requestJSON, requestJSON, rawJSON, ¶m), nil)
+ streamOut = append(streamOut, bytes.Join(ConvertGeminiResponseToClaude(ctx, "claude-opus-5-5", requestJSON, requestJSON, []byte("[DONE]"), ¶m), nil)...)
+ streamText := string(streamOut)
+ if strings.Contains(streamText, "event: error\n") {
+ t.Fatalf("unexpected error event when valid functionCall is present:\n%s", streamText)
+ }
+ if !strings.Contains(streamText, `"stop_reason":"tool_use"`) {
+ t.Fatalf("expected stop_reason tool_use in stream output:\n%s", streamText)
}
}
@@ -446,11 +577,11 @@ func TestConvertGeminiResponseToClaudeNonStream_SafetyAndMalformedFunctionCallRe
wantReason string
}{
{finishReason: "SAFETY", wantReason: "refusal"},
- {finishReason: "MALFORMED_FUNCTION_CALL", wantReason: "refusal"},
{finishReason: "RECITATION", wantReason: "refusal"},
{finishReason: "PROHIBITED_CONTENT", wantReason: "refusal"},
{finishReason: "SPII", wantReason: "refusal"},
{finishReason: "BLOCKLIST", wantReason: "refusal"},
+ {finishReason: "IMAGE_SAFETY", wantReason: "refusal"},
{finishReason: "MAX_TOKENS", wantReason: "max_tokens"},
{finishReason: "STOP", wantReason: "end_turn"},
}
@@ -480,6 +611,38 @@ func TestConvertGeminiResponseToClaudeNonStream_SafetyAndMalformedFunctionCallRe
}
}
+func TestConvertGeminiResponseToClaudeNonStream_MalformedFunctionCallReturnsAPIError(t *testing.T) {
+ requestJSON := []byte(`{"model":"claude-opus-5-5","messages":[{"role":"user","content":"hi"}]}`)
+ rawJSON := []byte(`{
+ "candidates": [{
+ "content": {"role": "model", "parts": []},
+ "finishReason": "MALFORMED_FUNCTION_CALL"
+ }],
+ "usageMetadata": {
+ "promptTokenCount": 120,
+ "candidatesTokenCount": 37,
+ "totalTokenCount": 157
+ },
+ "modelVersion": "m",
+ "responseId": "resp-test"
+ }`)
+
+ output := ConvertGeminiResponseToClaudeNonStream(context.Background(), "claude-opus-5-5", requestJSON, requestJSON, rawJSON, nil)
+ outputJSON := gjson.ParseBytes(output)
+ if got := outputJSON.Get("stop_reason").String(); got == "refusal" {
+ t.Fatalf("unexpected stop_reason = %q. Output: %s", got, string(output))
+ }
+ if got := outputJSON.Get("type").String(); got != "error" {
+ t.Fatalf("type = %q, want error. Output: %s", got, string(output))
+ }
+ if got := outputJSON.Get("error.type").String(); got != "api_error" {
+ t.Fatalf("error.type = %q, want api_error. Output: %s", got, string(output))
+ }
+ if !strings.Contains(outputJSON.Get("error.message").String(), "MALFORMED_FUNCTION_CALL") {
+ t.Fatalf("error.message = %q, want mention of MALFORMED_FUNCTION_CALL. Output: %s", outputJSON.Get("error.message").String(), string(output))
+ }
+}
+
func TestConvertGeminiResponseToClaudeNonStream_Issue6409_SignedVisibleText(t *testing.T) {
requestJSON := []byte(`{"model":"gemini-3.5-flash-lite","messages":[{"role":"user","content":"hi"}]}`)
geminiResponse := []byte(`{
@@ -861,3 +1024,159 @@ func TestConvertGeminiResponseToClaudeStream_Issue6409_ThinkingFollowedBySignedV
t.Fatalf("expected block 2 text_delta, got: %s", outputText)
}
}
+
+func TestConvertGeminiResponseToClaudeNonStream_UniqueToolUseIDsAcrossTurns(t *testing.T) {
+ atomic.StoreUint64(&toolUseIDCounter, 0)
+
+ requestJSON := []byte(`{"model":"gemini-4-argon","messages":[{"role":"user","content":"run command"}]}`)
+ rawResponse := []byte(`{
+ "candidates": [{
+ "content": {
+ "parts": [
+ {"functionCall": {"name": "Bash", "args": {"command": "ls"}}}
+ ],
+ "role": "model"
+ },
+ "finishReason": "STOP"
+ }],
+ "modelVersion": "gemini-4-argon",
+ "responseId": "resp-tool"
+ }`)
+
+ ctx := context.Background()
+
+ var param any
+ streamBytes := bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-4-argon", requestJSON, requestJSON, rawResponse, ¶m), nil)
+ streamBytes = append(streamBytes, bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-4-argon", requestJSON, requestJSON, []byte("[DONE]"), ¶m), nil)...)
+ var streamToolID string
+ for _, line := range strings.Split(string(streamBytes), "\n") {
+ if strings.HasPrefix(line, "data: ") && strings.Contains(line, `"type":"tool_use"`) {
+ streamToolID = gjson.Get(strings.TrimPrefix(line, "data: "), "content_block.id").String()
+ break
+ }
+ }
+ if streamToolID == "" {
+ t.Fatalf("expected tool_use id in streamed response, got: %s", string(streamBytes))
+ }
+
+ nonStream1 := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-4-argon", requestJSON, requestJSON, rawResponse, nil)
+ nonStreamID1 := gjson.GetBytes(nonStream1, "content.0.id").String()
+ if nonStreamID1 == "" {
+ t.Fatalf("expected tool_use id in first non-stream response, got: %s", string(nonStream1))
+ }
+
+ nonStream2 := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-4-argon", requestJSON, requestJSON, rawResponse, nil)
+ nonStreamID2 := gjson.GetBytes(nonStream2, "content.0.id").String()
+ if nonStreamID2 == "" {
+ t.Fatalf("expected tool_use id in second non-stream response, got: %s", string(nonStream2))
+ }
+
+ if nonStreamID1 == streamToolID {
+ t.Fatalf("non-stream tool_use id %q collided with prior streamed tool_use id %q", nonStreamID1, streamToolID)
+ }
+ if nonStreamID1 == nonStreamID2 {
+ t.Fatalf("non-stream tool_use ids repeated across turns: first=%q second=%q", nonStreamID1, nonStreamID2)
+ }
+}
+
+func geminiClaudeInputJSONDeltas(t *testing.T, streamText string) string {
+ t.Helper()
+ var builder strings.Builder
+ for _, line := range strings.Split(streamText, "\n") {
+ if !strings.HasPrefix(line, "data: ") {
+ continue
+ }
+ payload := strings.TrimPrefix(line, "data: ")
+ if gjson.Get(payload, "delta.type").String() == "input_json_delta" {
+ builder.WriteString(gjson.Get(payload, "delta.partial_json").String())
+ }
+ }
+ return builder.String()
+}
+
+func TestConvertGeminiResponseToClaude_SanitizesParameterlessToolArguments(t *testing.T) {
+ origRequest := []byte(`{"model":"gemini-3-flash","tools":[{"name":"EnterPlanMode","input_schema":{"type":"object","properties":{}}}]}`)
+ rawResponse := []byte(`{"candidates":[{"content":{"parts":[{"functionCall":{"name":"EnterPlanMode","args":{"reason":"Plan mode switch"}}}],"role":"model"},"finishReason":"STOP"}],"modelVersion":"gemini-3-flash","responseId":"resp-zero-arg"}`)
+
+ ctx := context.Background()
+
+ nonStream := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-3-flash", origRequest, origRequest, rawResponse, nil)
+ if got := gjson.GetBytes(nonStream, "content.0.input").Raw; got != "{}" {
+ t.Fatalf("expected non-streaming input {}, got %s", got)
+ }
+
+ var param any
+ streamBytes := bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-3-flash", origRequest, origRequest, rawResponse, ¶m), nil)
+ if got := geminiClaudeInputJSONDeltas(t, string(streamBytes)); got != "{}" {
+ t.Fatalf("expected streaming partial_json {}, got %q (stream: %s)", got, string(streamBytes))
+ }
+}
+
+func TestConvertGeminiResponseToClaude_CanonicalNameCollisionKeepsExactToolArguments(t *testing.T) {
+ origRequest := []byte(`{"model":"gemini-3-flash","tools":[{"name":"Read","input_schema":{"type":"object","properties":{}}},{"name":"read","input_schema":{"type":"object","properties":{"path":{"type":"string"}}}}]}`)
+ readWithArgs := []byte(`{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read","args":{"path":"/tmp/a"}}}],"role":"model"},"finishReason":"STOP"}],"modelVersion":"gemini-3-flash","responseId":"resp-collision"}`)
+ readParameterless := []byte(`{"candidates":[{"content":{"parts":[{"functionCall":{"name":"Read","args":{"reason":"x"}}}],"role":"model"},"finishReason":"STOP"}],"modelVersion":"gemini-3-flash","responseId":"resp-collision-2"}`)
+
+ ctx := context.Background()
+
+ nonStream := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-3-flash", origRequest, origRequest, readWithArgs, nil)
+ if got := gjson.GetBytes(nonStream, "content.0.input").Raw; got != `{"path":"/tmp/a"}` {
+ t.Fatalf("expected exact read tool args preserved, got %s (%s)", got, string(nonStream))
+ }
+ if got := gjson.GetBytes(nonStream, "content.0.name").String(); got != "read" {
+ t.Fatalf("expected exact tool name read, got %q (%s)", got, string(nonStream))
+ }
+
+ var param any
+ streamBytes := bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-3-flash", origRequest, origRequest, readWithArgs, ¶m), nil)
+ if got := geminiClaudeInputJSONDeltas(t, string(streamBytes)); got != `{"path":"/tmp/a"}` {
+ t.Fatalf("expected streaming exact read args preserved, got %q (stream: %s)", got, string(streamBytes))
+ }
+
+ nonStreamParameterless := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-3-flash", origRequest, origRequest, readParameterless, nil)
+ if got := gjson.GetBytes(nonStreamParameterless, "content.0.input").Raw; got != "{}" {
+ t.Fatalf("expected exact Read tool normalized to {}, got %s", got)
+ }
+}
+
+func TestConvertGeminiResponseToClaude_AmbiguousCanonicalNameKeepsUpstreamNameAndArguments(t *testing.T) {
+ origRequest := []byte(`{"model":"gemini-3-flash","tools":[{"name":"Read","input_schema":{"type":"object","properties":{}}},{"name":"read","input_schema":{"type":"object","properties":{"path":{"type":"string"}}}}]}`)
+ ambiguousResponse := []byte(`{"candidates":[{"content":{"parts":[{"functionCall":{"name":"READ","args":{"path":"/tmp/a"}}}],"role":"model"},"finishReason":"STOP"}],"modelVersion":"gemini-3-flash","responseId":"resp-ambiguous"}`)
+
+ nonStream := ConvertGeminiResponseToClaudeNonStream(context.Background(), "gemini-3-flash", origRequest, origRequest, ambiguousResponse, nil)
+ if got := gjson.GetBytes(nonStream, "content.0.name").String(); got != "READ" {
+ t.Fatalf("expected ambiguous upstream name READ preserved, got %q (%s)", got, string(nonStream))
+ }
+ if got := gjson.GetBytes(nonStream, "content.0.input").Raw; got != `{"path":"/tmp/a"}` {
+ t.Fatalf("expected ambiguous tool args preserved, got %s", got)
+ }
+}
+
+func TestConvertGeminiResponseToClaude_KeepsReasonWhenTranslatedSchemaHasNoPlaceholder(t *testing.T) {
+ origRequest := []byte(`{"model":"gemini-3-flash","tools":[{"name":"Bash","input_schema":{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}}]}`)
+ ctx := context.Background()
+ translatedRequest, errConvert := ConvertClaudeRequestToGemini("gemini-3-flash", origRequest, false)
+ if errConvert != nil {
+ t.Fatalf("ConvertClaudeRequestToGemini error: %v", errConvert)
+ }
+ rawResponse := []byte(`{"candidates":[{"content":{"parts":[{"functionCall":{"name":"Bash","args":{"command":"ls","reason":"why"}}}],"role":"model"},"finishReason":"STOP"}],"modelVersion":"gemini-3-flash","responseId":"resp-bash-reason"}`)
+
+ nonStream := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-3-flash", origRequest, translatedRequest, rawResponse, nil)
+ if got := gjson.GetBytes(nonStream, "content.0.input").Raw; got != `{"command":"ls","reason":"why"}` {
+ t.Fatalf("expected non-streaming input preserved, got %s (translated: %s)", got, string(translatedRequest))
+ }
+}
+
+func TestConvertGeminiResponseToClaude_ParameterlessToolContinuationDoesNotDuplicateEmptyObject(t *testing.T) {
+ origRequest := []byte(`{"model":"gemini-3-flash","tools":[{"name":"EnterPlanMode","input_schema":{"type":"object","properties":{}}}]}`)
+ firstChunk := []byte(`{"candidates":[{"content":{"parts":[{"functionCall":{"name":"EnterPlanMode","args":{"reason":"Plan mode switch"}}}],"role":"model"}}],"modelVersion":"gemini-3-flash","responseId":"resp-zero-arg"}`)
+ continuationChunk := []byte(`{"candidates":[{"content":{"parts":[{"functionCall":{"args":{"reason":"more"}}}],"role":"model"},"finishReason":"STOP"}],"modelVersion":"gemini-3-flash","responseId":"resp-zero-arg"}`)
+
+ ctx := context.Background()
+ var param any
+ streamBytes := bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-3-flash", origRequest, origRequest, firstChunk, ¶m), nil)
+ streamBytes = append(streamBytes, bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-3-flash", origRequest, origRequest, continuationChunk, ¶m), nil)...)
+ if got := geminiClaudeInputJSONDeltas(t, string(streamBytes)); got != "{}" {
+ t.Fatalf("expected concatenated partial_json {}, got %q (stream: %s)", got, string(streamBytes))
+ }
+}
diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go
index 2ff55037a22..c8e4b0185cc 100644
--- a/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go
+++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go
@@ -24,10 +24,11 @@ import (
type convertGeminiResponseToOpenAIChatParams struct {
UnixTimestamp int64
// FunctionIndex tracks tool call indices per candidate index to support multiple candidates.
- FunctionIndex map[int]int
- SawToolCall map[int]bool
- UpstreamFinishReason map[int]string
- SanitizedNameMap map[string]string
+ FunctionIndex map[int]int
+ SawToolCall map[int]bool
+ UpstreamFinishReason map[int]string
+ SanitizedNameMap map[string]string
+ SanitizedNameMapResolved bool
}
// functionCallIDCounter provides a process-wide unique counter for function call identifiers.
@@ -51,11 +52,12 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR
// Initialize parameters if nil.
if *param == nil {
*param = &convertGeminiResponseToOpenAIChatParams{
- UnixTimestamp: 0,
- FunctionIndex: make(map[int]int),
- SawToolCall: make(map[int]bool),
- UpstreamFinishReason: make(map[int]string),
- SanitizedNameMap: util.SanitizedToolNameMap(originalRequestRawJSON),
+ UnixTimestamp: 0,
+ FunctionIndex: make(map[int]int),
+ SawToolCall: make(map[int]bool),
+ UpstreamFinishReason: make(map[int]string),
+ SanitizedNameMap: util.SanitizedToolNameMap(originalRequestRawJSON),
+ SanitizedNameMapResolved: true,
}
}
@@ -70,8 +72,11 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR
if p.UpstreamFinishReason == nil {
p.UpstreamFinishReason = make(map[int]string)
}
- if p.SanitizedNameMap == nil {
- p.SanitizedNameMap = util.SanitizedToolNameMap(originalRequestRawJSON)
+ if !p.SanitizedNameMapResolved {
+ if p.SanitizedNameMap == nil {
+ p.SanitizedNameMap = util.SanitizedToolNameMap(originalRequestRawJSON)
+ }
+ p.SanitizedNameMapResolved = true
}
if bytes.HasPrefix(rawJSON, []byte("data:")) {
diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_response_test.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_response_test.go
index 3f2bb39be22..8e603f2da4b 100644
--- a/internal/translator/gemini/openai/chat-completions/gemini_openai_response_test.go
+++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_response_test.go
@@ -2,6 +2,7 @@ package chat_completions
import (
"context"
+ "strings"
"testing"
"github.com/tidwall/gjson"
@@ -126,3 +127,48 @@ func TestConvertGeminiResponseToOpenAINonStream_TextPrecedenceOverAudioTranscrip
t.Fatalf("expected content to prioritize explicit text, got %v. Output: %s", content.Raw, result)
}
}
+
+func TestConvertGeminiResponseToOpenAI_PinsSanitizedNameMapPerStream(t *testing.T) {
+ ctx := context.Background()
+
+ // 1. Only the first chunk carries the request with a sanitized tool name.
+ var param any
+ reqWithTool := []byte(`{"model":"gemini-2.5-flash","tools":[{"name":"mcp/search"}]}`)
+ chunk1 := []byte(`{"candidates":[{"content":{"parts":[{"text":"thinking"}]}}]}`)
+ chunk2 := []byte(`{"candidates":[{"content":{"parts":[{"functionCall":{"name":"mcp_search","args":{"q":"test"}}}]}}]}`)
+
+ ConvertGeminiResponseToOpenAI(ctx, "gemini-2.5-flash", reqWithTool, nil, chunk1, ¶m)
+ out := ConvertGeminiResponseToOpenAI(ctx, "gemini-2.5-flash", nil, nil, chunk2, ¶m)
+ if len(out) != 1 {
+ t.Fatalf("expected 1 chunk output, got %d", len(out))
+ }
+ if got := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.function.name").String(); got != "mcp/search" {
+ t.Fatalf("restored tool name = %q, want mcp/search", got)
+ }
+
+ // 2. When the first chunk's request produces a nil SanitizedNameMap, later chunks must not rebuild it.
+ var nilMapParam any
+ reqNoSanitize := []byte(`{"model":"gemini-2.5-flash","tools":[{"name":"plain_tool"}]}`)
+ ConvertGeminiResponseToOpenAI(ctx, "gemini-2.5-flash", reqNoSanitize, nil, chunk1, &nilMapParam)
+ outNil := ConvertGeminiResponseToOpenAI(ctx, "gemini-2.5-flash", reqWithTool, nil, chunk2, &nilMapParam)
+ if len(outNil) != 1 {
+ t.Fatalf("expected 1 chunk output, got %d", len(outNil))
+ }
+ if got := gjson.GetBytes(outNil[0], "choices.0.delta.tool_calls.0.function.name").String(); got != "mcp_search" {
+ t.Fatalf("tool name = %q, want mcp_search (must not rebuild nil SanitizedNameMap on later chunks)", got)
+ }
+}
+
+func BenchmarkConvertGeminiResponseToOpenAI_TextDelta(b *testing.B) {
+ request := []byte(`{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"` + strings.Repeat("x", 1024*1024) + `"}]}`)
+ delta := []byte(`{"candidates":[{"content":{"parts":[{"text":"token "}]}}],"modelVersion":"gemini-2.5-flash","responseId":"resp_bench"}`)
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ var param any
+ for j := 0; j < 64; j++ {
+ ConvertGeminiResponseToOpenAI(context.Background(), "gemini-2.5-flash", request, nil, delta, ¶m)
+ }
+ }
+}
diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go
index 2d7ac679277..5cf785df57e 100644
--- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go
+++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go
@@ -75,7 +75,7 @@ func convertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
// Convert input messages to Gemini contents format
if input := root.Get("input"); input.Exists() && input.IsArray() {
- inputItems, hasGeminiCarrier := normalizeGeminiResponsesCarriers(restoreGeminiResponsesTextSignatures(modelName, input.Array()))
+ inputItems, hasGeminiCarrier := normalizeGeminiResponsesCarriers(restoreGeminiResponsesTextSignatures(modelName, normalizeOpenAIResponsesToolSearchItems(input.Array(), forwardMap)))
if hasGeminiCarrier {
useGeminiNativeReasoningLayout = true
}
diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go
index c395b5274bc..20f568dd67c 100644
--- a/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go
+++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go
@@ -139,12 +139,16 @@ type geminiToResponsesState struct {
FuncArgsBuf map[int]*strings.Builder
FuncInputBuf map[int]string
FuncCustom map[int]bool
+ FuncToolSearch map[int]bool
FuncNames map[int]string
FuncNamespaces map[int]string
FuncCallIDs map[int]string
FuncDone map[int]bool
SanitizedNameMap map[string]string
ToolIdentityMap map[string]util.ResponsesToolIdentity
+ RequestJSON []byte
+ RequestResolved bool
+ ToolMapsResolved bool
FunctionEvidence map[string]*geminiFunctionCallEvidence
// web search aggregation
@@ -256,7 +260,7 @@ func isUpstreamGeminiRequest(rawJSON []byte) bool {
return false
}
-func determineWebSearchStreamMode(modelName, requestModelName string, originalRequestRawJSON, requestRawJSON []byte) bool {
+func determineWebSearchStreamMode(modelName, requestModelName string, originalRequestRawJSON, requestRawJSON, reqJSON []byte) bool {
if len(originalRequestRawJSON) > 0 {
origRoot := unwrapRequestRoot(gjson.ParseBytes(originalRequestRawJSON))
if !AllowsResponsesWebSearchToolChoice(origRoot) {
@@ -272,7 +276,9 @@ func determineWebSearchStreamMode(modelName, requestModelName string, originalRe
return hasEffectiveGoogleSearchTool(requestRawJSON)
}
}
- reqJSON := pickRequestJSON(originalRequestRawJSON, requestRawJSON)
+ if len(reqJSON) == 0 {
+ reqJSON = pickRequestJSON(originalRequestRawJSON, requestRawJSON)
+ }
if len(reqJSON) > 0 {
reqRoot := unwrapRequestRoot(gjson.ParseBytes(reqJSON))
return HasResponsesWebSearchTool(reqRoot) &&
@@ -284,12 +290,13 @@ func determineWebSearchStreamMode(modelName, requestModelName string, originalRe
// ConvertGeminiResponseToOpenAIResponses converts Gemini SSE chunks into OpenAI Responses SSE events.
func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
- reqJSON := pickRequestJSON(originalRequestRawJSON, requestRawJSON)
if *param == nil {
+ reqJSON := pickRequestJSON(originalRequestRawJSON, requestRawJSON)
*param = &geminiToResponsesState{
FuncArgsBuf: make(map[int]*strings.Builder),
FuncInputBuf: make(map[int]string),
FuncCustom: make(map[int]bool),
+ FuncToolSearch: make(map[int]bool),
FuncNames: make(map[int]string),
FuncNamespaces: make(map[int]string),
FuncCallIDs: make(map[int]string),
@@ -300,10 +307,18 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
SeenReasoningSignatures: make(map[string]bool),
SanitizedNameMap: util.SanitizedToolNameMap(originalRequestRawJSON),
ToolIdentityMap: util.ResponsesToolReverseIdentityMap(reqJSON),
+ RequestJSON: reqJSON,
+ RequestResolved: true,
+ ToolMapsResolved: true,
EmittedAnnotationCount: make(map[int]int),
}
}
st := (*param).(*geminiToResponsesState)
+ if !st.RequestResolved {
+ st.RequestJSON = pickRequestJSON(originalRequestRawJSON, requestRawJSON)
+ st.RequestResolved = true
+ }
+ reqJSON := st.RequestJSON
if st.FuncArgsBuf == nil {
st.FuncArgsBuf = make(map[int]*strings.Builder)
}
@@ -313,6 +328,9 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
if st.FuncCustom == nil {
st.FuncCustom = make(map[int]bool)
}
+ if st.FuncToolSearch == nil {
+ st.FuncToolSearch = make(map[int]bool)
+ }
if st.FuncNames == nil {
st.FuncNames = make(map[int]string)
}
@@ -337,11 +355,14 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
if st.SeenReasoningSignatures == nil {
st.SeenReasoningSignatures = make(map[string]bool)
}
- if st.SanitizedNameMap == nil {
- st.SanitizedNameMap = util.SanitizedToolNameMap(originalRequestRawJSON)
- }
- if st.ToolIdentityMap == nil {
- st.ToolIdentityMap = util.ResponsesToolReverseIdentityMap(reqJSON)
+ if !st.ToolMapsResolved {
+ if st.SanitizedNameMap == nil {
+ st.SanitizedNameMap = util.SanitizedToolNameMap(originalRequestRawJSON)
+ }
+ if st.ToolIdentityMap == nil {
+ st.ToolIdentityMap = util.ResponsesToolReverseIdentityMap(reqJSON)
+ }
+ st.ToolMapsResolved = true
}
if st.EmittedAnnotationCount == nil {
st.EmittedAnnotationCount = make(map[int]int)
@@ -780,6 +801,9 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
created, _ = sjson.SetBytes(created, "response.id", st.ResponseID)
created, _ = sjson.SetBytes(created, "response.created_at", st.CreatedAt)
requestModelName := translatorcommon.RequestModelName(originalRequestRawJSON, requestRawJSON)
+ if requestModelName == "" && len(reqJSON) > 0 {
+ requestModelName = translatorcommon.RequestModelName(reqJSON, nil)
+ }
if requestModelName == "" {
requestModelName = modelName
}
@@ -799,7 +823,7 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
st.Started = true
st.NextIndex = 0
- st.WebSearchStreamMode = determineWebSearchStreamMode(modelName, requestModelName, originalRequestRawJSON, requestRawJSON)
+ st.WebSearchStreamMode = determineWebSearchStreamMode(modelName, requestModelName, originalRequestRawJSON, requestRawJSON, reqJSON)
}
// Handle groundingMetadata for web search
@@ -1141,6 +1165,7 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
name := identity.Name
namespace := identity.Namespace
isCustom := identity.Custom
+ isToolSearch := identity.ToolSearch
if evidence.ApplyPatch && evidence.PatchCall != nil {
if _, _, errFinishArguments := evidence.PatchCall.FinishArguments(fc.Get("args").Raw); errFinishArguments != nil {
st.SetToolInputError(errFinishArguments)
@@ -1157,7 +1182,7 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
if st.FuncArgsBuf[idx] == nil {
st.FuncArgsBuf[idx] = &strings.Builder{}
}
- if identity.ApplyPatch {
+ if identity.ApplyPatch || isToolSearch {
st.FuncCallIDs[idx] = evidence.UpstreamID
}
if st.FuncCallIDs[idx] == "" {
@@ -1166,6 +1191,7 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
st.FuncNames[idx] = name
st.FuncNamespaces[idx] = namespace
st.FuncCustom[idx] = isCustom
+ st.FuncToolSearch[idx] = isToolSearch
argsJSON := "{}"
if args := fc.Get("args"); args.Exists() {
@@ -1175,7 +1201,30 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
st.FuncArgsBuf[idx].WriteString(argsJSON)
}
- if isCustom {
+ if isToolSearch {
+ itemStatus := "completed"
+ if fr := root.Get("candidates.0.finishReason").String(); fr != "" {
+ _, itemStatus, _ = geminiResponsesTerminalState(fr)
+ } else if st.FinishReason != "" {
+ _, itemStatus, _ = geminiResponsesTerminalState(st.FinishReason)
+ }
+ itemID := openAIResponsesToolSearchCallItemID(st.FuncCallIDs[idx])
+ item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{}}`)
+ item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())
+ item, _ = sjson.SetBytes(item, "output_index", idx)
+ item, _ = sjson.SetRawBytes(item, "item", buildOpenAIResponsesToolSearchCallItem(itemID, st.FuncCallIDs[idx], "in_progress", argsJSON))
+ out = append(out, emitEvent("response.output_item.added", item))
+
+ if !st.FuncDone[idx] {
+ itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{}}`)
+ itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
+ itemDone, _ = sjson.SetBytes(itemDone, "output_index", idx)
+ itemDone, _ = sjson.SetRawBytes(itemDone, "item", buildOpenAIResponsesToolSearchCallItem(itemID, st.FuncCallIDs[idx], itemStatus, argsJSON))
+ out = append(out, emitEvent("response.output_item.done", itemDone))
+
+ st.FuncDone[idx] = true
+ }
+ } else if isCustom {
inputStr := util.UnwrapResponsesCustomToolInput(argsJSON)
var patchCall *translatorcommon.ApplyPatchCallState
if identity.ApplyPatch {
@@ -1328,7 +1377,17 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
if st.FuncDone[idx] {
continue
}
- if st.FuncCustom[idx] {
+ if st.FuncToolSearch[idx] {
+ args := "{}"
+ if b := st.FuncArgsBuf[idx]; b != nil && b.Len() > 0 {
+ args = b.String()
+ }
+ itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{}}`)
+ itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
+ itemDone, _ = sjson.SetBytes(itemDone, "output_index", idx)
+ itemDone, _ = sjson.SetRawBytes(itemDone, "item", buildOpenAIResponsesToolSearchCallItem(openAIResponsesToolSearchCallItemID(st.FuncCallIDs[idx]), st.FuncCallIDs[idx], status, args))
+ out = append(out, emitEvent("response.output_item.done", itemDone))
+ } else if st.FuncCustom[idx] {
inputStr := st.FuncInputBuf[idx]
inputDone := []byte(`{"type":"response.custom_tool_call_input.done","sequence_number":0,"item_id":"","output_index":0,"input":""}`)
inputDone, _ = sjson.SetBytes(inputDone, "sequence_number", nextSeq())
@@ -1383,7 +1442,7 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
completed, _ = sjson.SetBytes(completed, "response.id", st.ResponseID)
completed, _ = sjson.SetBytes(completed, "response.created_at", st.CreatedAt)
- if reqJSON := pickRequestJSON(originalRequestRawJSON, requestRawJSON); len(reqJSON) > 0 {
+ if len(reqJSON) > 0 {
req := unwrapRequestRoot(gjson.ParseBytes(reqJSON))
if v := req.Get("instructions"); v.Exists() {
completed, _ = sjson.SetBytes(completed, "response.instructions", v.String())
@@ -1484,7 +1543,13 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
}
if callID, ok := st.FuncCallIDs[idx]; ok && callID != "" {
- if st.FuncCustom[idx] {
+ if st.FuncToolSearch[idx] {
+ args := "{}"
+ if b := st.FuncArgsBuf[idx]; b != nil && b.Len() > 0 {
+ args = b.String()
+ }
+ outputs = append(outputs, buildOpenAIResponsesToolSearchCallItem(openAIResponsesToolSearchCallItemID(callID), callID, status, args))
+ } else if st.FuncCustom[idx] {
inputStr := st.FuncInputBuf[idx]
item := []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`)
item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("ctc_%s", callID))
@@ -1561,7 +1626,7 @@ func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string
resp, _ = sjson.SetBytes(resp, "created_at", createdAt)
// Echo request fields when present; fallback model from response modelVersion
- if reqJSON := pickRequestJSON(originalRequestRawJSON, requestRawJSON); len(reqJSON) > 0 {
+ if len(reqJSON) > 0 {
req := unwrapRequestRoot(gjson.ParseBytes(reqJSON))
if v := req.Get("instructions"); v.Exists() {
resp, _ = sjson.SetBytes(resp, "instructions", v.String())
@@ -1828,11 +1893,13 @@ func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string
argsStr = args.Raw
}
callID := fmt.Sprintf("call_%x_%d", time.Now().UnixNano(), atomic.AddUint64(&funcCallIDCounter, 1))
- if identity.ApplyPatch && evidence.UpstreamID != "" {
+ if (identity.ApplyPatch || identity.ToolSearch) && evidence.UpstreamID != "" {
callID = evidence.UpstreamID
}
var itemJSON []byte
- if isCustom {
+ if identity.ToolSearch {
+ itemJSON = buildOpenAIResponsesToolSearchCallItem(openAIResponsesToolSearchCallItemID(callID), callID, status, argsStr)
+ } else if isCustom {
inputStr := util.UnwrapResponsesCustomToolInput(argsStr)
if identity.ApplyPatch {
var patchCall translatorcommon.ApplyPatchCallState
diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_response_test.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_response_test.go
index 8ccbbf53cef..fcf86ce94cd 100644
--- a/internal/translator/gemini/openai/responses/gemini_openai-responses_response_test.go
+++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_response_test.go
@@ -1568,3 +1568,92 @@ func TestConvertGeminiResponseToOpenAIResponses_MessageOutputItemDoneFields(t *t
t.Fatalf("missing message response.output_item.done event")
}
}
+
+func TestConvertGeminiResponseToOpenAIResponses_PinsRequestPerStream(t *testing.T) {
+ originalRequest := []byte(`{
+ "model":"gemini-2.5-flash",
+ "instructions":"pinned-instructions",
+ "input":[{"type":"additional_tools","role":"developer","tools":[
+ {"type":"namespace","name":"functions","tools":[{"type":"custom","name":"exec"}]}
+ ]}]
+ }`)
+ chunks := [][]byte{
+ []byte(`data: {"candidates":[{"content":{"role":"model","parts":[{"text":"starting"}]}}],"modelVersion":"gemini-2.5-flash","responseId":"resp_pinned"}`),
+ []byte(`data: {"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"functions__exec","args":{"input":"pwd"}}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15},"modelVersion":"gemini-2.5-flash","responseId":"resp_pinned"}`),
+ []byte("data: [DONE]"),
+ }
+
+ var param any
+ var added, completed gjson.Result
+ for i, chunk := range chunks {
+ var requestForChunk []byte
+ if i == 0 {
+ requestForChunk = originalRequest
+ }
+ for _, output := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-2.5-flash", requestForChunk, nil, chunk, ¶m) {
+ event, data := parseSSEEvent(t, output)
+ switch event {
+ case "response.output_item.added":
+ if data.Get("item.type").String() == "custom_tool_call" {
+ added = data
+ }
+ case "response.completed":
+ completed = data
+ }
+ }
+ }
+
+ if !added.Exists() || !completed.Exists() {
+ t.Fatalf("missing lifecycle events: added=%v completed=%v", added.Exists(), completed.Exists())
+ }
+ if got := added.Get("item.name").String(); got != "exec" {
+ t.Fatalf("added name = %q, want exec", got)
+ }
+ if got := added.Get("item.namespace").String(); got != "functions" {
+ t.Fatalf("added namespace = %q, want functions", got)
+ }
+ if got := completed.Get("response.instructions").String(); got != "pinned-instructions" {
+ t.Fatalf("completed instructions = %q, want pinned-instructions", got)
+ }
+
+ // Verify nil tool maps resolved on the first chunk are not rebuilt on subsequent chunks.
+ var nilMapParam any
+ firstReqNoTools := []byte(`{"model":"gemini-2.5-flash","instructions":"pinned-no-tools"}`)
+ laterReqWithTool := []byte(`{"model":"gemini-2.5-flash","instructions":"later-instructions","tools":[{"type":"function","name":"mcp/search","parameters":{"type":"object"}}]}`)
+ ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-2.5-flash", firstReqNoTools, nil, chunks[0], &nilMapParam)
+ var funcAdded, noToolsCompleted gjson.Result
+ laterChunk := []byte(`data: {"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"mcp_search","args":{}}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2},"modelVersion":"gemini-2.5-flash","responseId":"resp_pinned_nil"}`)
+ for _, output := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-2.5-flash", laterReqWithTool, nil, laterChunk, &nilMapParam) {
+ event, data := parseSSEEvent(t, output)
+ switch event {
+ case "response.output_item.added":
+ if data.Get("item.type").String() == "function_call" {
+ funcAdded = data
+ }
+ case "response.completed":
+ noToolsCompleted = data
+ }
+ }
+ if got := funcAdded.Get("item.name").String(); got != "mcp_search" {
+ t.Fatalf("nil-map stream tool name = %q, want mcp_search (must not rebuild tool maps on later chunk)", got)
+ }
+ if got := noToolsCompleted.Get("response.instructions").String(); got != "pinned-no-tools" {
+ t.Fatalf("nil-map stream completed instructions = %q, want pinned-no-tools", got)
+ }
+}
+
+func BenchmarkConvertGeminiResponseToOpenAIResponses_TextDelta(b *testing.B) {
+ request := []byte(`{"model":"gemini-2.5-flash","instructions":"` + strings.Repeat("x", 1024*1024) + `"}`)
+ delta := []byte(`data: {"candidates":[{"content":{"role":"model","parts":[{"text":"token "}]} }],"modelVersion":"gemini-2.5-flash","responseId":"resp_bench"}`)
+ done := []byte(`data: [DONE]`)
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ var param any
+ for j := 0; j < 64; j++ {
+ ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-2.5-flash", request, nil, delta, ¶m)
+ }
+ ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-2.5-flash", request, nil, done, ¶m)
+ }
+}
diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_tool_search.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_tool_search.go
new file mode 100644
index 00000000000..46cbb1573db
--- /dev/null
+++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_tool_search.go
@@ -0,0 +1,151 @@
+package responses
+
+import (
+ "strings"
+
+ translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
+ "github.com/tidwall/gjson"
+ "github.com/tidwall/sjson"
+)
+
+const openAIResponsesToolSearchIDPrefix = "ts_"
+
+// normalizeOpenAIResponsesToolSearchItems rewrites replayed client-executed
+// tool_search history into the function_call / function_call_output shape that
+// the Gemini request conversion already pairs and serializes.
+func normalizeOpenAIResponsesToolSearchItems(items []gjson.Result, forwardMap map[string]string) []gjson.Result {
+ normalized := make([]gjson.Result, 0, len(items))
+ for _, item := range items {
+ switch item.Get("type").String() {
+ case util.ResponsesToolSearchCallType:
+ if util.IsResponsesClientToolSearchItem(item) {
+ normalized = append(normalized, openAIResponsesToolSearchCallAsFunctionCall(item, forwardMap))
+ }
+ case util.ResponsesToolSearchOutputType:
+ if util.IsResponsesClientToolSearchItem(item) {
+ normalized = append(normalized, openAIResponsesToolSearchOutputAsFunctionCallOutput(item, forwardMap))
+ }
+ default:
+ normalized = append(normalized, item)
+ }
+ }
+ return normalized
+}
+
+func openAIResponsesToolSearchCallAsFunctionCall(item gjson.Result, forwardMap map[string]string) gjson.Result {
+ call := []byte(`{"type":"function_call","name":"","call_id":"","arguments":"{}"}`)
+ call, _ = sjson.SetBytes(call, "name", util.MapResponsesClientToolSearchName(forwardMap))
+ call, _ = sjson.SetBytes(call, "call_id", extractOpenAIResponsesCallID(item))
+ if id := item.Get("id"); id.Exists() {
+ call, _ = sjson.SetBytes(call, "id", id.String())
+ }
+ if status := item.Get("status"); status.Exists() {
+ call, _ = sjson.SetBytes(call, "status", status.String())
+ }
+ if arguments := item.Get("arguments"); arguments.Exists() {
+ if arguments.Type == gjson.String {
+ call, _ = translatorcommon.SetStringWithoutHTMLEscape(call, "arguments", arguments.String())
+ } else if arguments.IsObject() || arguments.IsArray() {
+ call, _ = translatorcommon.SetStringWithoutHTMLEscape(call, "arguments", arguments.Raw)
+ }
+ }
+ return gjson.ParseBytes(call)
+}
+
+func openAIResponsesToolSearchOutputAsFunctionCallOutput(item gjson.Result, forwardMap map[string]string) gjson.Result {
+ output := []byte(`{"type":"function_call_output","name":"","call_id":"","output":""}`)
+ output, _ = sjson.SetBytes(output, "name", util.MapResponsesClientToolSearchName(forwardMap))
+ output, _ = sjson.SetBytes(output, "call_id", extractOpenAIResponsesCallID(item))
+ if id := item.Get("id"); id.Exists() {
+ output, _ = sjson.SetBytes(output, "id", id.String())
+ }
+
+ summary := []byte(`{"status":"completed","tools":[]}`)
+ if status := strings.TrimSpace(item.Get("status").String()); status != "" {
+ summary, _ = sjson.SetBytes(summary, "status", status)
+ }
+ if errorResult := item.Get("error"); errorResult.Exists() && errorResult.Type != gjson.Null {
+ summary, _ = sjson.SetRawBytes(summary, "error", []byte(errorResult.Raw))
+ }
+ names := openAIResponsesDiscoveredToolNames(item.Get("tools"), forwardMap)
+ summary, _ = sjson.SetBytes(summary, "tools", names)
+ output, _ = translatorcommon.SetStringWithoutHTMLEscape(output, "output", string(summary))
+ return gjson.ParseBytes(output)
+}
+
+func openAIResponsesDiscoveredToolNames(tools gjson.Result, forwardMap map[string]string) []string {
+ names := make([]string, 0)
+ if !tools.Exists() || !tools.IsArray() {
+ return names
+ }
+ seen := make(map[string]struct{})
+ appendName := func(name string) {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return
+ }
+ mapped := util.MapResponsesToolName(forwardMap, name)
+ if _, exists := seen[mapped]; exists {
+ return
+ }
+ seen[mapped] = struct{}{}
+ names = append(names, mapped)
+ }
+ tools.ForEach(func(_, tool gjson.Result) bool {
+ switch strings.TrimSpace(tool.Get("type").String()) {
+ case "", "function", "custom":
+ name := strings.TrimSpace(tool.Get("name").String())
+ if name == "" {
+ name = strings.TrimSpace(tool.Get("function.name").String())
+ }
+ appendName(name)
+ case "namespace":
+ namespaceName := strings.TrimSpace(tool.Get("name").String())
+ children := tool.Get("tools")
+ if !children.Exists() || !children.IsArray() {
+ children = tool.Get("children")
+ }
+ children.ForEach(func(_, child gjson.Result) bool {
+ childName := strings.TrimSpace(child.Get("name").String())
+ if childName == "" {
+ childName = strings.TrimSpace(child.Get("function.name").String())
+ }
+ if childName != "" {
+ appendName(util.QualifyResponsesNamespaceToolName(namespaceName, childName))
+ }
+ return true
+ })
+ }
+ return true
+ })
+ return names
+}
+
+func openAIResponsesToolSearchCallStatus(status, argsJSON string) string {
+ if status == "completed" {
+ trimmed := strings.TrimSpace(argsJSON)
+ if !gjson.Valid(trimmed) || !gjson.Parse(trimmed).IsObject() {
+ return "incomplete"
+ }
+ }
+ return status
+}
+
+func buildOpenAIResponsesToolSearchCallItem(id, callID, status, argsJSON string) []byte {
+ status = openAIResponsesToolSearchCallStatus(status, argsJSON)
+ item := []byte(`{"id":"","type":"tool_search_call","status":"","execution":"client","call_id":"","arguments":{}}`)
+ item, _ = sjson.SetBytes(item, "id", id)
+ item, _ = sjson.SetBytes(item, "status", status)
+ item, _ = sjson.SetBytes(item, "call_id", callID)
+ if trimmed := strings.TrimSpace(argsJSON); gjson.Valid(trimmed) {
+ if args := gjson.Parse(trimmed); args.IsObject() {
+ item, _ = sjson.SetRawBytes(item, "arguments", []byte(args.Raw))
+ }
+ }
+ return item
+}
+
+func openAIResponsesToolSearchCallItemID(callID string) string {
+ return openAIResponsesToolSearchIDPrefix + callID
+}
diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_tool_search_test.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_tool_search_test.go
new file mode 100644
index 00000000000..96995c075b1
--- /dev/null
+++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_tool_search_test.go
@@ -0,0 +1,418 @@
+package responses
+
+import (
+ "context"
+ "testing"
+
+ "github.com/tidwall/gjson"
+)
+
+const toolSearchFixtureRequest = `{
+ "model": "gemini-3.8-flash-high",
+ "stream": true,
+ "tools": [
+ {"type": "function", "name": "exec_command", "parameters": {"type": "object", "properties": {"cmd": {"type": "string"}}}},
+ {"type": "tool_search", "execution": "client", "description": "Search available deferred tools. Multi-agent tools: Spawn and manage sub-agents.", "parameters": {"type": "object", "properties": {"query": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["query"], "additionalProperties": false}},
+ {"type": "function", "name": "spawn_agent", "defer_loading": true, "parameters": {"type": "object", "properties": {"prompt": {"type": "string"}}}}
+ ],
+ "input": [
+ {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Find the multi-agent tools before answering."}]},
+ {"type": "tool_search_call", "id": "tsc_search_1", "execution": "client", "call_id": "search_1", "status": "completed", "arguments": {"query": "fixture tools", "limit": 1}},
+ {"type": "tool_search_output", "id": "tso_search_1", "execution": "client", "call_id": "search_1", "status": "completed", "tools": [
+ {"type": "namespace", "name": "fixture_tools", "description": "Minimal discovery fixture, not the actual Codex agent schema", "tools": [
+ {"type": "function", "name": "ping", "description": "Return a test reply", "defer_loading": true, "parameters": {"type": "object", "properties": {}, "additionalProperties": false}}
+ ]}
+ ]},
+ {"type": "function_call", "call_id": "call_ping", "name": "ping", "namespace": "fixture_tools", "arguments": "{}"},
+ {"type": "function_call_output", "call_id": "call_ping", "output": "pong"}
+ ]
+}`
+
+func TestConvertOpenAIResponsesRequestToGemini_ToolSearchRoundTrip(t *testing.T) {
+ out, err := ConvertOpenAIResponsesRequestToGemini("gemini-3.8-flash-high", []byte(toolSearchFixtureRequest), true)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ root := gjson.ParseBytes(out)
+
+ declarations := root.Get("tools.0.functionDeclarations")
+ names := make([]string, 0)
+ declarations.ForEach(func(_, declaration gjson.Result) bool {
+ names = append(names, declaration.Get("name").String())
+ return true
+ })
+ if len(names) != 3 || names[0] != "exec_command" || names[1] != "tool_search" || names[2] != "fixture_tools__ping" {
+ t.Fatalf("functionDeclarations = %v, want [exec_command tool_search fixture_tools__ping]; raw: %s", names, out)
+ }
+ if got := declarations.Get("1.description").String(); got != "Search available deferred tools. Multi-agent tools: Spawn and manage sub-agents." {
+ t.Fatalf("tool_search shim description = %q", got)
+ }
+ if got := declarations.Get("1.parametersJsonSchema.required.0").String(); got != "query" {
+ t.Fatalf("tool_search shim parameters lost: %s", declarations.Get("1").Raw)
+ }
+ if got := declarations.Get("2.description").String(); got != "Return a test reply" {
+ t.Fatalf("discovered tool description = %q", got)
+ }
+
+ contents := root.Get("contents").Array()
+ if len(contents) != 5 {
+ t.Fatalf("expected 5 contents (user, search call, search output, ping call, ping output), got %d: %s", len(contents), root.Get("contents").Raw)
+ }
+ searchCall := contents[1]
+ if got := searchCall.Get("role").String(); got != "model" {
+ t.Fatalf("search call role = %q, want model", got)
+ }
+ if got := searchCall.Get("parts.0.functionCall.name").String(); got != "tool_search" {
+ t.Fatalf("search call function name = %q, want tool_search; raw: %s", got, searchCall.Raw)
+ }
+ if got := searchCall.Get("parts.0.functionCall.id").String(); got != "search_1" {
+ t.Fatalf("search call id = %q, want search_1", got)
+ }
+ if got := searchCall.Get("parts.0.functionCall.args.query").String(); got != "fixture tools" {
+ t.Fatalf("search call args = %s, want object-valued query", searchCall.Get("parts.0.functionCall.args").Raw)
+ }
+ if got := searchCall.Get("parts.0.functionCall.args.limit").Int(); got != 1 {
+ t.Fatalf("search call limit = %d, want 1", got)
+ }
+
+ searchOutput := contents[2]
+ if got := searchOutput.Get("role").String(); got != "user" {
+ t.Fatalf("search output role = %q, want user", got)
+ }
+ if got := searchOutput.Get("parts.0.functionResponse.name").String(); got != "tool_search" {
+ t.Fatalf("search output function name = %q, want tool_search; raw: %s", got, searchOutput.Raw)
+ }
+ if got := searchOutput.Get("parts.0.functionResponse.id").String(); got != "search_1" {
+ t.Fatalf("search output id = %q, want search_1", got)
+ }
+ result := gjson.Parse(searchOutput.Get("parts.0.functionResponse.response.result").String())
+ if got := result.Get("status").String(); got != "completed" {
+ t.Fatalf("search output status = %q; raw result: %s", got, result.Raw)
+ }
+ if got := result.Get("tools.0").String(); got != "fixture_tools__ping" || len(result.Get("tools").Array()) != 1 {
+ t.Fatalf("search output must list the Gemini-visible discovered names, got %s", result.Get("tools").Raw)
+ }
+
+ if got := contents[3].Get("parts.0.functionCall.name").String(); got != "fixture_tools__ping" {
+ t.Fatalf("discovered tool call name = %q, want fixture_tools__ping", got)
+ }
+ if got := contents[4].Get("parts.0.functionResponse.name").String(); got != "fixture_tools__ping" {
+ t.Fatalf("discovered tool response name = %q, want fixture_tools__ping", got)
+ }
+}
+
+func TestConvertGeminiResponseToOpenAIResponses_ToolSearchCallStream(t *testing.T) {
+ chunks := [][]byte{
+ []byte(`data: {"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"tool_search","args":{"query":"fixture tools","limit":2}}}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.8-flash-high","responseId":"resp_tool_search"}`),
+ []byte(`data: [DONE]`),
+ }
+
+ var param any
+ var added, done, completed gjson.Result
+ for _, chunk := range chunks {
+ for _, output := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.8-flash-high", []byte(toolSearchFixtureRequest), nil, chunk, ¶m) {
+ event, data := parseSSEEvent(t, output)
+ switch event {
+ case "response.output_item.added":
+ added = data
+ case "response.output_item.done":
+ done = data
+ case "response.function_call_arguments.delta", "response.function_call_arguments.done":
+ t.Fatalf("tool_search_call must not emit function_call_arguments events, got %s", event)
+ case "response.completed":
+ completed = data
+ }
+ }
+ }
+ if !added.Exists() || !done.Exists() || !completed.Exists() {
+ t.Fatalf("missing tool_search_call lifecycle events: added=%v done=%v completed=%v", added.Exists(), done.Exists(), completed.Exists())
+ }
+ for _, test := range []struct {
+ label string
+ item gjson.Result
+ status string
+ }{
+ {label: "added", item: added.Get("item"), status: "in_progress"},
+ {label: "done", item: done.Get("item"), status: "completed"},
+ {label: "completed", item: completed.Get("response.output.0"), status: "completed"},
+ } {
+ if got := test.item.Get("type").String(); got != "tool_search_call" {
+ t.Fatalf("%s type = %q, want tool_search_call; raw: %s", test.label, got, test.item.Raw)
+ }
+ if got := test.item.Get("execution").String(); got != "client" {
+ t.Fatalf("%s execution = %q, want client", test.label, got)
+ }
+ if got := test.item.Get("status").String(); got != test.status {
+ t.Fatalf("%s status = %q, want %s", test.label, got, test.status)
+ }
+ if test.item.Get("name").Exists() {
+ t.Fatalf("%s must not carry a function name: %s", test.label, test.item.Raw)
+ }
+ }
+ for _, test := range []struct {
+ label string
+ item gjson.Result
+ }{
+ {label: "done", item: done.Get("item")},
+ {label: "completed", item: completed.Get("response.output.0")},
+ } {
+ if !test.item.Get("arguments").IsObject() {
+ t.Fatalf("%s arguments must be an object: %s", test.label, test.item.Raw)
+ }
+ if got := test.item.Get("arguments.query").String(); got != "fixture tools" {
+ t.Fatalf("%s arguments.query = %q", test.label, got)
+ }
+ if got := test.item.Get("arguments.limit").Int(); got != 2 {
+ t.Fatalf("%s arguments.limit = %d", test.label, got)
+ }
+ }
+ if added.Get("item.call_id").String() == "" || added.Get("item.call_id").String() != done.Get("item.call_id").String() || done.Get("item.call_id").String() != completed.Get("response.output.0.call_id").String() {
+ t.Fatalf("call_id must be non-empty and stable across events")
+ }
+}
+
+func TestConvertGeminiResponseToOpenAIResponsesNonStream_ToolSearchCall(t *testing.T) {
+ raw := []byte(`{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"tool_search","args":{"query":"fixture tools"}}}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.8-flash-high","responseId":"resp_tool_search_nonstream"}`)
+ out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.8-flash-high", []byte(toolSearchFixtureRequest), nil, raw, nil)
+ root := gjson.ParseBytes(out)
+
+ item := root.Get("output.0")
+ if got := item.Get("type").String(); got != "tool_search_call" {
+ t.Fatalf("non-stream output type = %q, want tool_search_call; raw: %s", got, out)
+ }
+ if got := item.Get("execution").String(); got != "client" {
+ t.Fatalf("non-stream execution = %q", got)
+ }
+ if got := item.Get("status").String(); got != "completed" {
+ t.Fatalf("non-stream status = %q", got)
+ }
+ if got := item.Get("arguments.query").String(); got != "fixture tools" {
+ t.Fatalf("non-stream arguments = %s", item.Get("arguments").Raw)
+ }
+ if item.Get("name").Exists() {
+ t.Fatalf("non-stream tool_search_call must not carry a name: %s", item.Raw)
+ }
+}
+
+func TestConvertOpenAIResponsesRequestToGemini_ToolSearchCallWithStringArgumentsAndEmptyOutput(t *testing.T) {
+ request := `{
+ "model": "gemini-3.8-flash-high",
+ "tools": [{"type": "tool_search", "execution": "client"}],
+ "input": [
+ {"type": "message", "role": "user", "content": "hi"},
+ {"type": "tool_search_call", "execution": "client", "call_id": "search_2", "arguments": "{\"query\":\"nothing\"}"},
+ {"type": "tool_search_output", "execution": "client", "call_id": "search_2", "status": "completed", "tools": []}
+ ]
+ }`
+ out, err := ConvertOpenAIResponsesRequestToGemini("gemini-3.8-flash-high", []byte(request), false)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ root := gjson.ParseBytes(out)
+ contents := root.Get("contents").Array()
+ if len(contents) != 3 {
+ t.Fatalf("expected 3 contents, got %d: %s", len(contents), root.Get("contents").Raw)
+ }
+ if got := contents[1].Get("parts.0.functionCall.args.query").String(); got != "nothing" {
+ t.Fatalf("string arguments must be parsed into args, got %s", contents[1].Raw)
+ }
+ result := gjson.Parse(contents[2].Get("parts.0.functionResponse.response.result").String())
+ if !result.Get("tools").IsArray() || len(result.Get("tools").Array()) != 0 {
+ t.Fatalf("empty discovery must produce an empty tools list, got %s", result.Raw)
+ }
+}
+
+func TestConvertGeminiResponseToOpenAIResponses_DiscoveredToolCallRestoresNamespace(t *testing.T) {
+ chunks := [][]byte{
+ []byte(`data: {"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"fixture_tools__ping","args":{}}}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.8-flash-high","responseId":"resp_discovered"}`),
+ []byte(`data: [DONE]`),
+ }
+ var param any
+ var done gjson.Result
+ for _, chunk := range chunks {
+ for _, output := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.8-flash-high", []byte(toolSearchFixtureRequest), nil, chunk, ¶m) {
+ event, data := parseSSEEvent(t, output)
+ if event == "response.output_item.done" && data.Get("item.type").String() == "function_call" {
+ done = data
+ }
+ }
+ }
+ if !done.Exists() {
+ t.Fatalf("missing function_call done event for discovered tool")
+ }
+ if got := done.Get("item.name").String(); got != "ping" {
+ t.Fatalf("discovered tool name = %q, want ping", got)
+ }
+ if got := done.Get("item.namespace").String(); got != "fixture_tools" {
+ t.Fatalf("discovered tool namespace = %q, want fixture_tools", got)
+ }
+}
+
+func TestConvertGeminiResponseToOpenAIResponses_UserToolSearchFunctionStaysFunctionCall(t *testing.T) {
+ request := []byte(`{"model":"gemini-3.8-flash-high","tools":[{"type":"function","name":"tool_search","parameters":{"type":"object","properties":{"query":{"type":"string"}}}}]}`)
+ chunks := [][]byte{
+ []byte(`data: {"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"tool_search","args":{"query":"x"}}}]},"finishReason":"STOP"}],"responseId":"resp_user_fn"}`),
+ []byte(`data: [DONE]`),
+ }
+ var param any
+ sawFunctionCall := false
+ for _, chunk := range chunks {
+ for _, output := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.8-flash-high", request, nil, chunk, ¶m) {
+ event, data := parseSSEEvent(t, output)
+ if event == "response.output_item.done" {
+ switch data.Get("item.type").String() {
+ case "function_call":
+ sawFunctionCall = true
+ case "tool_search_call":
+ t.Fatalf("a user-declared tool_search function must stay a function_call")
+ }
+ }
+ }
+ }
+ if !sawFunctionCall {
+ t.Fatalf("expected a function_call done event")
+ }
+}
+
+func TestConvertGeminiResponses_SameNamedUserFunctionAndClientToolSearchCoexist(t *testing.T) {
+ userFn := `{"type":"function","name":"tool_search","description":"User tool.","parameters":{"type":"object","properties":{"x":{"type":"string"}},"required":["x"]}}`
+ clientSearch := `{"type":"tool_search","execution":"client","description":"Search deferred tools.","parameters":{"type":"object","properties":{"query":{"type":"string"}},"required":["query"]}}`
+ deferredFn := `{"type":"function","name":"hidden_until_discovered","defer_loading":true,"parameters":{"type":"object","properties":{}}}`
+
+ for _, tc := range []struct {
+ name string
+ tools string
+ }{
+ {name: "user function first", tools: userFn + "," + clientSearch + "," + deferredFn},
+ {name: "client tool_search first", tools: clientSearch + "," + userFn + "," + deferredFn},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ request := []byte(`{
+ "model": "gemini-3.8-flash-high",
+ "tools": [` + tc.tools + `],
+ "input": [
+ {"type": "message", "role": "user", "content": "hi"},
+ {"type": "tool_search_call", "execution": "client", "call_id": "search_1", "status": "completed", "arguments": {"query": "ping"}},
+ {"type": "tool_search_output", "execution": "client", "call_id": "search_1", "status": "completed", "tools": []},
+ {"type": "function_call", "call_id": "user_call_1", "name": "tool_search", "arguments": "{\"x\":\"val\"}"},
+ {"type": "function_call_output", "call_id": "user_call_1", "output": "user_ok"}
+ ]
+ }`)
+ geminiReqBytes, err := ConvertOpenAIResponsesRequestToGemini("gemini-3.8-flash-high", request, true)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ geminiReq := gjson.ParseBytes(geminiReqBytes)
+ decls := geminiReq.Get("tools.0.functionDeclarations").Array()
+ if len(decls) != 2 {
+ t.Fatalf("expected 2 functionDeclarations (user tool_search + client shim, with deferred hidden), got %d: %s", len(decls), geminiReqBytes)
+ }
+
+ var shimGeminiName string
+ for _, d := range decls {
+ n := d.Get("name").String()
+ if n != "tool_search" {
+ shimGeminiName = n
+ }
+ }
+ if shimGeminiName == "" {
+ t.Fatalf("expected a distinct Gemini name for the client tool_search shim: %s", geminiReqBytes)
+ }
+
+ // History replay: search_1 uses shimGeminiName, user_call_1 uses "tool_search".
+ contents := geminiReq.Get("contents").Array()
+ if len(contents) != 5 {
+ t.Fatalf("expected 5 contents, got %d: %s", len(contents), geminiReq.Get("contents").Raw)
+ }
+ if got := contents[1].Get("parts.0.functionCall.name").String(); got != shimGeminiName {
+ t.Fatalf("replayed tool_search_call name = %q, want %q", got, shimGeminiName)
+ }
+ if got := contents[2].Get("parts.0.functionResponse.name").String(); got != shimGeminiName {
+ t.Fatalf("replayed tool_search_output name = %q, want %q", got, shimGeminiName)
+ }
+ if got := contents[3].Get("parts.0.functionCall.name").String(); got != "tool_search" {
+ t.Fatalf("replayed user function_call name = %q, want tool_search", got)
+ }
+ if got := contents[4].Get("parts.0.functionResponse.name").String(); got != "tool_search" {
+ t.Fatalf("replayed user function_call_output name = %q, want tool_search", got)
+ }
+
+ // Response restoration: calling shimGeminiName -> tool_search_call; calling "tool_search" -> function_call.
+ streamChunk := []byte(`data: {"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"id":"c_shim","name":"` + shimGeminiName + `","args":{"query":"agents"}}},{"functionCall":{"id":"c_user","name":"tool_search","args":{"x":"abc"}}}]},"finishReason":"STOP"}],"responseId":"resp_coexist"}`)
+ var param any
+ var doneItems []gjson.Result
+ for _, frame := range [][]byte{streamChunk, []byte("data: [DONE]")} {
+ for _, output := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.8-flash-high", request, nil, frame, ¶m) {
+ event, data := parseSSEEvent(t, output)
+ if event == "response.output_item.done" {
+ doneItems = append(doneItems, data.Get("item"))
+ }
+ }
+ }
+ if len(doneItems) != 2 {
+ t.Fatalf("expected 2 done items, got %d", len(doneItems))
+ }
+ if doneItems[0].Get("type").String() != "tool_search_call" || doneItems[0].Get("arguments.query").String() != "agents" {
+ t.Fatalf("first item (shim call) = %s, want tool_search_call", doneItems[0].Raw)
+ }
+ if doneItems[1].Get("type").String() != "function_call" || doneItems[1].Get("name").String() != "tool_search" {
+ t.Fatalf("second item (user call) = %s, want function_call named tool_search", doneItems[1].Raw)
+ }
+ })
+ }
+}
+
+func TestConvertOpenAIResponsesRequestToGemini_HistoryOnlyWithNamespacedToolSearchChild(t *testing.T) {
+ request := []byte(`{
+ "model": "gemini-3.8-flash-high",
+ "stream": true,
+ "tools": [],
+ "input": [
+ {
+ "type": "tool_search_call",
+ "execution": "client",
+ "call_id": "search_1",
+ "status": "completed",
+ "arguments": {"query": "fixture tools", "limit": 1}
+ },
+ {
+ "type": "tool_search_output",
+ "execution": "client",
+ "call_id": "search_1",
+ "status": "completed",
+ "tools": [
+ {
+ "type": "namespace",
+ "name": "fixture_tools",
+ "tools": [
+ {
+ "type": "function",
+ "name": "tool_search",
+ "description": "Namespaced child named tool_search",
+ "parameters": {"type": "object", "properties": {}, "additionalProperties": false}
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }`)
+ out, err := ConvertOpenAIResponsesRequestToGemini("gemini-3.8-flash-high", request, true)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ root := gjson.ParseBytes(out)
+ if got := root.Get("tools.0.functionDeclarations.0.name").String(); got != "fixture_tools__tool_search" {
+ t.Fatalf("discovered declaration name = %q, want fixture_tools__tool_search; raw: %s", got, out)
+ }
+ contents := root.Get("contents").Array()
+ if len(contents) != 2 {
+ t.Fatalf("expected 2 contents, got %d: %s", len(contents), root.Get("contents").Raw)
+ }
+ if got := contents[0].Get("parts.0.functionCall.name").String(); got != "tool_search" {
+ t.Fatalf("replayed tool_search_call name = %q, want tool_search (must not be hijacked by fixture_tools__tool_search)", got)
+ }
+ if got := contents[1].Get("parts.0.functionResponse.name").String(); got != "tool_search" {
+ t.Fatalf("replayed tool_search_output name = %q, want tool_search (must not be hijacked by fixture_tools__tool_search)", got)
+ }
+}
diff --git a/internal/translator/openai/claude/openai_claude_response.go b/internal/translator/openai/claude/openai_claude_response.go
index d416d41690f..83b2c03e90d 100644
--- a/internal/translator/openai/claude/openai_claude_response.go
+++ b/internal/translator/openai/claude/openai_claude_response.go
@@ -68,6 +68,14 @@ type ConvertOpenAIResponseToAnthropicParams struct {
UsageOutputTokens int64
UsageCachedTokens int64
UsageCacheWriteTokens int64
+ // ToolNameMapResolved tracks whether ToolNameMap has been resolved from
+ // the original request once per stream.
+ ToolNameMapResolved bool
+ // StreamResolved and StreamRequested cache whether the original request
+ // asked for streaming, resolved once per stream so large request bodies
+ // are not re-parsed on every chunk.
+ StreamResolved bool
+ StreamRequested bool
}
// InterleavedContentChunk stores a buffered chunk of text or thinking arriving while a tool call is open
@@ -131,21 +139,28 @@ func ConvertOpenAIResponseToClaude(_ context.Context, _ string, originalRequestR
}
rawJSON = bytes.TrimSpace(rawJSON[5:])
- if (*param).(*ConvertOpenAIResponseToAnthropicParams).ToolNameMap == nil {
- (*param).(*ConvertOpenAIResponseToAnthropicParams).ToolNameMap = util.ToolNameMapFromClaudeRequest(originalRequestRawJSON)
+ state := (*param).(*ConvertOpenAIResponseToAnthropicParams)
+ if !state.ToolNameMapResolved {
+ if state.ToolNameMap == nil {
+ state.ToolNameMap = util.ToolNameMapFromClaudeRequest(originalRequestRawJSON)
+ }
+ state.ToolNameMapResolved = true
}
// Check if this is the [DONE] marker
if bytes.Equal(bytes.TrimSpace(rawJSON), []byte("[DONE]")) {
- return convertOpenAIDoneToAnthropic((*param).(*ConvertOpenAIResponseToAnthropicParams))
+ return convertOpenAIDoneToAnthropic(state)
}
- streamResult := gjson.GetBytes(originalRequestRawJSON, "stream")
- if !streamResult.Exists() || (streamResult.Exists() && streamResult.Type == gjson.False) {
+ if !state.StreamResolved {
+ streamResult := gjson.GetBytes(originalRequestRawJSON, "stream")
+ state.StreamRequested = streamResult.Exists() && streamResult.Type != gjson.False
+ state.StreamResolved = true
+ }
+ if !state.StreamRequested {
return convertOpenAINonStreamingToAnthropic(rawJSON)
- } else {
- return convertOpenAIStreamingChunkToAnthropic(rawJSON, (*param).(*ConvertOpenAIResponseToAnthropicParams))
}
+ return convertOpenAIStreamingChunkToAnthropic(rawJSON, state)
}
func hasValidToolCallArguments(param *ConvertOpenAIResponseToAnthropicParams) bool {
diff --git a/internal/translator/openai/claude/openai_claude_response_test.go b/internal/translator/openai/claude/openai_claude_response_test.go
index 6dc811238a7..bfd5582c367 100644
--- a/internal/translator/openai/claude/openai_claude_response_test.go
+++ b/internal/translator/openai/claude/openai_claude_response_test.go
@@ -1468,3 +1468,79 @@ func TestExtractOpenAIUsage(t *testing.T) {
})
}
}
+
+const streamFlagTestChunk = `data: {"id":"chatcmpl_1","model":"test-model","created":1,"choices":[{"index":0,"delta":{"content":"hi"},"finish_reason":null}]}`
+
+func TestConvertOpenAIResponseToClaude_StreamFlagResolvedOncePerStream(t *testing.T) {
+ var param any
+ first := ConvertOpenAIResponseToClaude(context.Background(), "test-model", []byte(`{"model":"m","stream":true}`), nil, []byte(streamFlagTestChunk), ¶m)
+ if !bytes.Contains(bytes.Join(first, nil), []byte("content_block_delta")) {
+ t.Fatalf("expected streaming events on first chunk, got %q", bytes.Join(first, nil))
+ }
+
+ // An empty original request on subsequent chunks would parse as non-streaming if re-read.
+ second := ConvertOpenAIResponseToClaude(context.Background(), "test-model", nil, nil, []byte(streamFlagTestChunk), ¶m)
+ if !bytes.Contains(bytes.Join(second, nil), []byte("content_block_delta")) {
+ t.Fatalf("expected cached stream flag to be reused on subsequent chunks, got %q", bytes.Join(second, nil))
+ }
+}
+
+func TestConvertOpenAIResponseToClaude_ToolNameMapResolvedOnceWhenNoTools(t *testing.T) {
+ var param any
+ first := ConvertOpenAIResponseToClaude(context.Background(), "test-model", []byte(`{"model":"m","stream":true}`), nil, []byte(streamFlagTestChunk), ¶m)
+ if !bytes.Contains(bytes.Join(first, nil), []byte("content_block_delta")) {
+ t.Fatalf("expected streaming events on first chunk, got %q", bytes.Join(first, nil))
+ }
+
+ // Even when the first chunk resolved ToolNameMap to nil (no tools in request),
+ // later chunks must not re-parse originalRequestRawJSON to rebuild ToolNameMap.
+ toolChunk := `data: {"id":"chatcmpl_1","model":"test-model","created":1,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"bash","arguments":"{}"}}]},"finish_reason":null}]}`
+ laterReqWithTools := []byte(`{"model":"m","stream":true,"tools":[{"name":"Bash"}]}`)
+ second := ConvertOpenAIResponseToClaude(context.Background(), "test-model", laterReqWithTools, nil, []byte(toolChunk), ¶m)
+ joined := bytes.Join(second, nil)
+ if !bytes.Contains(joined, []byte(`"name":"bash"`)) {
+ t.Fatalf("expected cached nil ToolNameMap to be reused without re-parsing originalRequestRawJSON, got %q", joined)
+ }
+}
+
+func TestConvertOpenAIResponseToClaude_NonStreamingRequestStaysNonStreaming(t *testing.T) {
+ for _, original := range []string{`{"model":"m"}`, `{"model":"m","stream":false}`} {
+ var param any
+ out := ConvertOpenAIResponseToClaude(context.Background(), "test-model", []byte(original), nil, []byte(streamFlagTestChunk), ¶m)
+ joined := bytes.Join(out, nil)
+ if len(out) != 1 || gjson.GetBytes(joined, "type").String() != "message" {
+ t.Fatalf("request %s expected a single non-streaming message payload, got %q", original, joined)
+ }
+ if bytes.Contains(joined, []byte("content_block_delta")) {
+ t.Fatalf("request %s must not produce streaming events, got %q", original, joined)
+ }
+
+ // Subsequent call with stream:true must still use the cached non-streaming decision.
+ second := ConvertOpenAIResponseToClaude(context.Background(), "test-model", []byte(`{"model":"m","stream":true}`), nil, []byte(streamFlagTestChunk), ¶m)
+ secondJoined := bytes.Join(second, nil)
+ if len(second) != 1 || gjson.GetBytes(secondJoined, "type").String() != "message" || bytes.Contains(secondJoined, []byte("content_block_delta")) {
+ t.Fatalf("request %s expected cached non-streaming flag on second call, got %q", original, secondJoined)
+ }
+ }
+}
+
+func BenchmarkConvertOpenAIResponseToClaude_LargeOriginalRequest(b *testing.B) {
+ var sb strings.Builder
+ sb.WriteString(`{"model":"m","tools":[`)
+ for i := 0; i < 60; i++ {
+ if i > 0 {
+ sb.WriteByte(',')
+ }
+ fmt.Fprintf(&sb, `{"name":"tool_%d","description":%q,"input_schema":{"type":"object","properties":{"a":{"type":"string"}}}}`, i, strings.Repeat("x", 5000))
+ }
+ sb.WriteString(`],"stream":true}`)
+ original := []byte(sb.String())
+ chunk := []byte(streamFlagTestChunk)
+
+ var param any
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ ConvertOpenAIResponseToClaude(context.Background(), "test-model", original, nil, chunk, ¶m)
+ }
+}
diff --git a/internal/translator/openai/interactions/responses/apply_patch_identity_test.go b/internal/translator/openai/interactions/responses/apply_patch_identity_test.go
index b197f4b1780..0ffc61e7b5a 100644
--- a/internal/translator/openai/interactions/responses/apply_patch_identity_test.go
+++ b/internal/translator/openai/interactions/responses/apply_patch_identity_test.go
@@ -147,6 +147,9 @@ func TestInteractionsApplyPatchTerminalIdentityFallback(t *testing.T) {
if events := patchSend(¶m, patchStep("step.start", step)); len(events) != 0 {
t.Fatalf("early fallback: %v", events)
}
+ if first == "neither" {
+ id = "item_" + param.(*interactionsToResponsesStreamState).ItemIDPrefix + "_2"
+ }
if events := patchSend(¶m, []byte(`{"event_type":"step.delta","index":2,"delta":{"type":"arguments_delta","arguments":"{\"input\":\"pq\"}"}}`)); len(events) != 0 {
t.Fatalf("early delta: %v", events)
}
diff --git a/internal/translator/openai/interactions/responses/interactions_openai_responses_response.go b/internal/translator/openai/interactions/responses/interactions_openai_responses_response.go
index 63a5a7357d1..4ce1e08f2b1 100644
--- a/internal/translator/openai/interactions/responses/interactions_openai_responses_response.go
+++ b/internal/translator/openai/interactions/responses/interactions_openai_responses_response.go
@@ -8,6 +8,7 @@ import (
"strings"
"time"
+ "github.com/google/uuid"
applypatch "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/apply-patch"
"github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
@@ -19,6 +20,7 @@ import (
type interactionsToResponsesStreamState struct {
translatorcommon.ApplyPatchErrorState
ID string
+ ItemIDPrefix string
EnvironmentID string
FunctionCalls map[int]*interactionsFunctionCallState
ItemIDs map[int]string
@@ -86,9 +88,12 @@ func ConvertInteractionsResponseToOpenAIResponses(ctx context.Context, modelName
param = &local
}
if *param == nil {
- *param = &interactionsToResponsesStreamState{}
+ *param = &interactionsToResponsesStreamState{ItemIDPrefix: uuid.NewString()}
}
st := (*param).(*interactionsToResponsesStreamState)
+ if st.ItemIDPrefix == "" {
+ st.ItemIDPrefix = uuid.NewString()
+ }
st.ForAntigravity = isAntigravityModel(modelName)
if st.FunctionCalls == nil {
st.FunctionCalls = make(map[int]*interactionsFunctionCallState)
@@ -400,7 +405,7 @@ func interactionsStepStartToResponses(modelName string, root gjson.Result, st *i
if call := st.FunctionCalls[index]; call != nil && (call.PatchCall != nil || st.ToolIdentityMap[call.RawName].ApplyPatch || (call.RawName == "" && interactionsHasPatchBridge(st))) {
return interactionsUpdateFunctionCall(index, step, st, true)
}
- itemID := firstNonEmpty(step.Get("id").String(), step.Get("call_id").String(), fmt.Sprintf("item_%d", index))
+ itemID := firstNonEmpty(step.Get("id").String(), step.Get("call_id").String(), interactionsFallbackItemID(st, index))
if stepType == "function_call" {
return interactionsUpdateFunctionCall(index, step, st, true)
}
@@ -1386,6 +1391,13 @@ func firstUsageInt(root gjson.Result, paths ...string) (int64, bool) {
return 0, false
}
+func interactionsFallbackItemID(st *interactionsToResponsesStreamState, index int) string {
+ if st != nil && st.ItemIDPrefix != "" {
+ return fmt.Sprintf("item_%s_%d", st.ItemIDPrefix, index)
+ }
+ return fmt.Sprintf("item_%d", index)
+}
+
func nextResponsesSeq(st *interactionsToResponsesStreamState) int {
st.Seq++
return st.Seq
@@ -1776,7 +1788,7 @@ func interactionsUpdateFunctionCall(index int, step gjson.Result, st *interactio
} else {
call.ArgumentFragments = nil
if !call.ItemIDSeen && !call.Added {
- call.ID = firstNonEmpty(call.CallID, fmt.Sprintf("item_%d", index))
+ call.ID = firstNonEmpty(call.CallID, interactionsFallbackItemID(st, index))
}
if !call.CallIDSeen && !call.Added {
call.CallID = call.ID
@@ -1848,10 +1860,10 @@ func interactionsFinishPatchCalls(st *interactionsToResponsesStreamState) [][]by
call := st.FunctionCalls[index]
if call.PatchCall == nil {
// Interactions already maps an absent call_id to id (and an absent id
- // to call_id/item_). Freeze that compatibility mapping only at
- // the response terminal, after all supplied snapshot IDs were reconciled.
+ // to call_id or a per-response fallback item ID). Freeze that compatibility
+ // mapping only at the response terminal, after all supplied snapshot IDs were reconciled.
if !call.ItemIDSeen {
- call.ID = firstNonEmpty(call.CallID, fmt.Sprintf("item_%d", index))
+ call.ID = firstNonEmpty(call.CallID, interactionsFallbackItemID(st, index))
}
if !call.CallIDSeen {
call.CallID = call.ID
diff --git a/internal/translator/openai/interactions/responses/interactions_openai_responses_response_test.go b/internal/translator/openai/interactions/responses/interactions_openai_responses_response_test.go
index c07750ab443..4476bac153d 100644
--- a/internal/translator/openai/interactions/responses/interactions_openai_responses_response_test.go
+++ b/internal/translator/openai/interactions/responses/interactions_openai_responses_response_test.go
@@ -22,6 +22,50 @@ func TestConvertInteractionsResponseToOpenAIResponsesNonStream(t *testing.T) {
}
}
+func TestInteractionsResponsesGeneratedItemIDsDoNotCollideAcrossResponses(t *testing.T) {
+ seen := make(map[string]bool)
+ for _, tc := range []struct {
+ name string
+ req []byte
+ step string
+ delta string
+ deltaEvent string
+ }{
+ {"reasoning", nil, `{"type":"thought"}`, `{"type":"thought_summary","text":"thinking"}`, "response.reasoning_summary_text.delta"},
+ {"message", nil, `{"type":"model_output"}`, `{"type":"text","text":"ready"}`, "response.output_text.delta"},
+ {"function", nil, `{"type":"function_call","name":"exec_command","arguments":{}}`, `{"type":"arguments_delta","arguments":"{\"cmd\":\"pwd\"}"}`, "response.function_call_arguments.delta"},
+ {"apply_patch", []byte(patchRequest), `{"type":"function_call","name":"functions__apply_patch"}`, `{"type":"arguments_delta","arguments":"{\"input\":\"pq\"}"}`, "response.custom_tool_call_input.delta"},
+ } {
+ for response := 0; response < 2; response++ {
+ var param any
+ var events [][]byte
+ for _, raw := range []string{
+ fmt.Sprintf(`{"event_type":"interaction.created","interaction":{"id":"interaction_%s_%d"}}`, tc.name, response),
+ fmt.Sprintf(`{"event_type":"step.start","index":0,"step":%s}`, tc.step),
+ fmt.Sprintf(`{"event_type":"step.delta","index":0,"delta":%s}`, tc.delta),
+ `{"event_type":"step.stop","index":0}`,
+ `{"event_type":"interaction.completed","interaction":{"status":"completed"}}`,
+ } {
+ events = append(events, ConvertInteractionsResponseToOpenAIResponses(context.Background(), "devin/gpt-6-1-sol", tc.req, nil, []byte(raw), ¶m)...)
+ }
+ id := gjson.GetBytes(findResponsesEventPayload(events, "response.output_item.added"), "item.id").String()
+ if id == "" || seen[id] {
+ t.Fatalf("%s response %d generated duplicate or empty item ID %q", tc.name, response, id)
+ }
+ seen[id] = true
+ for _, check := range []struct{ event, path string }{
+ {tc.deltaEvent, "item_id"},
+ {"response.output_item.done", "item.id"},
+ {"response.completed", "response.output.0.id"},
+ } {
+ if got := gjson.GetBytes(findResponsesEventPayload(events, check.event), check.path).String(); got != id {
+ t.Fatalf("%s response %d %s item ID = %q, want %q", tc.name, response, check.event, got, id)
+ }
+ }
+ }
+ }
+}
+
func TestConvertInteractionsResponseToOpenAIResponsesStream(t *testing.T) {
var param any
var out [][]byte
diff --git a/internal/util/gemini_schema.go b/internal/util/gemini_schema.go
index 8c97b72cc35..9e342d928d5 100644
--- a/internal/util/gemini_schema.go
+++ b/internal/util/gemini_schema.go
@@ -27,12 +27,16 @@ const placeholderReasonDescription = "Brief explanation of why you are calling t
// once already; scope every call site to the schema itself.
type jsonSchemaCleanOptions struct {
- addPlaceholder bool
- addMissingArrayItems bool
- antigravitySemantics bool
- removeToolTitle bool
- removeGeminiMetadata bool
- flattenUnions bool
+ addPlaceholder bool
+ addMissingArrayItems bool
+ antigravitySemantics bool
+ removeToolTitle bool
+ removeGeminiMetadata bool
+ flattenUnions bool
+ // flattenMultiBranchUnions forces multi-branch anyOf/oneOf unions down to a single
+ // strongest branch instead of preserving them. It is used on routes whose upstream
+ // rejects nested multi-branch unions even though they are valid JSON Schema.
+ flattenMultiBranchUnions bool
forceEnumStringType bool
dropAllEnums bool
dropBooleanEnums bool
@@ -53,13 +57,28 @@ func CleanJSONSchemaForAntigravity(jsonStr string) string {
// type of generated function arguments, so numeric and boolean types must not be rewritten.
// requirePlaceholder is used only for Claude VALIDATED mode.
func CleanJSONSchemaForAntigravityTool(jsonStr string, requirePlaceholder bool) string {
+ return cleanJSONSchemaForAntigravityTool(jsonStr, requirePlaceholder, false)
+}
+
+// CleanJSONSchemaForAntigravityClaudeTool is the Antigravity tool cleaner for Claude
+// models. It applies the same Antigravity schema cleanup, but flattens multi-branch
+// anyOf/oneOf unions to a single strongest branch: the Antigravity Claude backend
+// converts Gemini function declarations into Claude custom.input_schema and rejects
+// nested multi-branch unions with "JSON schema is invalid", while the Gemini
+// backends accept preserved unions (see Issue 3662 and its regression follow-up).
+func CleanJSONSchemaForAntigravityClaudeTool(jsonStr string, requirePlaceholder bool) string {
+ return cleanJSONSchemaForAntigravityTool(jsonStr, requirePlaceholder, true)
+}
+
+func cleanJSONSchemaForAntigravityTool(jsonStr string, requirePlaceholder, flattenMultiBranchUnions bool) string {
return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{
- addPlaceholder: requirePlaceholder,
- addMissingArrayItems: true,
- antigravitySemantics: true,
- removeToolTitle: !requirePlaceholder,
- flattenUnions: true,
- dropAllEnums: true,
+ addPlaceholder: requirePlaceholder,
+ addMissingArrayItems: true,
+ antigravitySemantics: true,
+ removeToolTitle: !requirePlaceholder,
+ flattenUnions: true,
+ flattenMultiBranchUnions: flattenMultiBranchUnions,
+ dropAllEnums: true,
})
}
@@ -71,7 +90,7 @@ func CleanJSONSchemaForAntigravityTool(jsonStr string, requirePlaceholder bool)
// additionalProperties: false (which Antigravity natively enforces for response schemas).
// - Description hints + deletion: unsupported or accepted-but-ignored constraints.
// - Flattened: allOf merged into properties/required.
-// - Projected: anyOf/oneOf select the strongest branch; null branches become nullable:true.
+// - Unions: multi-branch anyOf/oneOf unions are preserved; single-type nullable unions ([T, null]) project to T with nullable:true.
// - Resolved: local $ref targets are inlined before $defs/definitions are removed.
// - Dropped: unresolved $ref (after a hint), metadata, unsupported object-key constraints,
// conditional keywords (after non-conflicting properties are retained), and x-* extensions.
@@ -115,10 +134,9 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string {
// Phase 0: Normalize malformed schemas (e.g. bare property maps and boolean required from MCP tools)
jsonStr = normalizeMalformedSchemaObjects(jsonStr, options.addMissingArrayItems)
- // Phase 1: Convert and add hints
- if options.antigravitySemantics {
- jsonStr = inlineLocalRefs(jsonStr)
- }
+ // Phase 1: Convert and add hints. Every cleaner removes $defs/definitions later, so local
+ // references must be expanded here for every route; otherwise nested fields are lost.
+ jsonStr = inlineLocalRefsForClean(jsonStr)
jsonStr = convertRefsToHints(jsonStr, options.antigravitySemantics)
jsonStr = convertConstToEnum(jsonStr)
jsonStr = convertEnumValuesToStrings(jsonStr, options.forceEnumStringType)
@@ -136,7 +154,7 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string {
jsonStr = mergeConditionals(jsonStr)
jsonStr = mergeAllOf(jsonStr)
if options.flattenUnions {
- jsonStr = flattenAnyOfOneOf(jsonStr)
+ jsonStr = flattenAnyOfOneOf(jsonStr, options.preserveStandardConstraints, options.flattenMultiBranchUnions)
}
jsonStr = flattenTypeArrays(jsonStr, options.antigravitySemantics)
@@ -152,8 +170,8 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string {
jsonStr = removeKeywords(jsonStr, []string{"title"})
}
jsonStr = cleanupRequiredFields(jsonStr)
- jsonStr = sanitizeArrayItems(jsonStr)
jsonStr = sanitizeObjectProperties(jsonStr)
+ jsonStr = sanitizeArrayItems(jsonStr, options.addMissingArrayItems)
// Phase 4: Add placeholder for empty object schemas (Claude VALIDATED mode requirement)
if options.addPlaceholder {
jsonStr = addEmptySchemaPlaceholder(jsonStr)
@@ -187,10 +205,13 @@ func sanitizeObjectProperties(jsonStr string) string {
return jsonStr
}
-// sanitizeArrayItems ensures that any schema node declaring "items" has "type": "array".
+// sanitizeArrayItems ensures that any schema node declaring "items" has "type": "array", and
+// (when addMissing is set) that array-typed nodes still missing "items" after reference
+// inlining receive the placeholder Phase 0 adds. Phase 0 cannot add it before inlining: on a
+// $ref node the placeholder would clobber the referenced definition's real items.
// Gemini's protobuf validator enforces a strict field predicate on items ($type == Type.ARRAY);
// if type is missing, it is inferred as array; if type is explicitly a non-array, items is removed.
-func sanitizeArrayItems(jsonStr string) string {
+func sanitizeArrayItems(jsonStr string, addMissing bool) string {
paths := findPaths(jsonStr, "items")
sortByDepth(paths)
for _, p := range paths {
@@ -207,6 +228,23 @@ func sanitizeArrayItems(jsonStr string) string {
jsonStr, _ = sjson.Delete(jsonStr, p)
}
}
+
+ if !addMissing {
+ return jsonStr
+ }
+ typePaths := findPaths(jsonStr, "type")
+ sortByDepth(typePaths)
+ for _, p := range typePaths {
+ parentPath := trimSuffix(p, ".type")
+ if isPropertyDefinition(parentPath) || !isArrayDeclaredType(gjson.Get(jsonStr, p).Value()) {
+ continue
+ }
+ if gjson.Get(jsonStr, joinPath(parentPath, "items")).Exists() || gjson.Get(jsonStr, joinPath(parentPath, "$ref")).Exists() {
+ continue
+ }
+ updated, _ := sjson.SetRawBytes([]byte(jsonStr), joinPath(parentPath, "items"), []byte(`{"type":"string"}`))
+ jsonStr = string(updated)
+ }
return jsonStr
}
@@ -514,8 +552,10 @@ func repairSchemaNode(node map[string]any, addMissingArrayItems bool) (map[strin
}
// Gemini and Antigravity reject tool array schemas without an items definition,
- // and reject tool schemas with items whose type is not ARRAY.
- if addMissingArrayItems {
+ // and reject tool schemas with items whose type is not ARRAY. A node still holding an
+ // unresolved $ref gets neither a placeholder items nor a synthesized type: both would become
+ // sibling keywords during inlining and clobber the referenced definition's real values.
+ if addMissingArrayItems && clone["$ref"] == nil {
if isArrayDeclaredType(clone["type"]) {
if _, hasItems := clone["items"]; !hasItems {
clone["items"] = map[string]any{"type": "string"}
@@ -715,17 +755,34 @@ func mergeStringSlices(existing, promoted []string) []string {
return res
}
-// InlineLocalRefs resolves JSON Pointer references against the original schema before definition
-// containers are stripped. Each expansion receives its own copy, sibling keywords override the
+// InlineLocalRefs resolves JSON Pointer references against the original schema. Every reference
+// receives its own copy as long as the expansion budget lasts, sibling keywords override the
// referenced definition, and cycles terminate as a typed hint instead of recursing forever.
func InlineLocalRefs(jsonStr string) string {
return inlineLocalRefs(jsonStr)
}
-// inlineLocalRefs resolves JSON Pointer references against the original schema before definition
-// containers are stripped. Each expansion receives its own copy, sibling keywords override the
+// maxInlinedSchemaBytes bounds the total size of schema material copied into the output by local
+// reference expansion. Shared definitions fan out once per reference, so a small input can expand
+// exponentially; expansions past the budget degrade to a typed hint like cycles do.
+const maxInlinedSchemaBytes = 256 * 1024
+
+// maxFallbackDescriptionBytes bounds the target description copied into a degraded reference, so a
+// large shared description cannot be repeated per reference and defeat the expansion budget.
+const maxFallbackDescriptionBytes = 512
+
+// inlineLocalRefs resolves JSON Pointer references against the original schema. Every reference
+// receives its own copy as long as the expansion budget lasts, sibling keywords override the
// referenced definition, and cycles terminate as a typed hint instead of recursing forever.
func inlineLocalRefs(jsonStr string) string {
+ return inlineLocalRefsRooted(jsonStr, nil, false)
+}
+
+// inlineLocalRefsForClean inlines local references for schema cleaning. Cleaning sometimes
+// receives the single-key {"schema": ...} carrier used to nest a schema one level down (see Phase
+// 0); references inside it must then resolve against the inner schema, not the carrier, or every
+// lookup misses. The carrier shape and root keys are otherwise preserved byte-for-byte.
+func inlineLocalRefsForClean(jsonStr string) string {
if !strings.Contains(jsonStr, `"$ref"`) {
return jsonStr
}
@@ -737,7 +794,43 @@ func inlineLocalRefs(jsonStr string) string {
return jsonStr
}
- resolved := resolveLocalRefs(root, root, make(map[string]bool))
+ resolveRoot := root
+ wrapped := false
+ if rootMap, ok := root.(map[string]any); ok && len(rootMap) == 1 {
+ if inner, okInner := rootMap["schema"].(map[string]any); okInner {
+ resolveRoot = inner
+ wrapped = true
+ }
+ }
+ if !wrapped {
+ return inlineLocalRefsRooted(jsonStr, root, true)
+ }
+
+ state := newRefResolveState()
+ resolved := resolveSchemaRoot(resolveRoot, state, true)
+ out, err := json.Marshal(map[string]any{"schema": resolved})
+ if err != nil {
+ return jsonStr
+ }
+ return string(out)
+}
+
+func inlineLocalRefsRooted(jsonStr string, parsedRoot any, forClean bool) string {
+ if !strings.Contains(jsonStr, `"$ref"`) {
+ return jsonStr
+ }
+
+ root := parsedRoot
+ if root == nil {
+ decoder := json.NewDecoder(strings.NewReader(jsonStr))
+ decoder.UseNumber()
+ if err := decoder.Decode(&root); err != nil {
+ return jsonStr
+ }
+ }
+
+ state := newRefResolveState()
+ resolved := resolveSchemaRoot(root, state, forClean)
out, err := json.Marshal(resolved)
if err != nil {
return jsonStr
@@ -745,43 +838,366 @@ func inlineLocalRefs(jsonStr string) string {
return string(out)
}
-func resolveLocalRefs(root, value any, active map[string]bool) any {
+// refResolveState carries the remaining expansion budget of one inlining pass. Every reference
+// charges the size of the target it copies (nested references charge their own targets), so
+// shared-definition fan-out stops once the budget is spent while ordinary reuse still expands.
+type refResolveState struct {
+ budget int
+}
+
+func newRefResolveState() *refResolveState {
+ return &refResolveState{budget: maxInlinedSchemaBytes}
+}
+
+// sortedKeys returns map keys in a deterministic order, so budget allocation never depends on
+// Go map iteration order.
+func sortedKeys[V any](m map[string]V) []string {
+ keys := make([]string, 0, len(m))
+ for key := range m {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ return keys
+}
+
+// resolveSchemaRoot resolves references in the schema body before resolving definition
+// containers. Definitions only matter through references, so walking them first would spend the
+// expansion budget on copies the cleaners delete next (and whose map order is arbitrary), while
+// the fields that are actually kept degraded to hints. For the public InlineLocalRefs the
+// containers are kept, so they are still resolved afterwards with whatever budget remains; when
+// cleaning they are dropped here altogether (references still resolve against the original
+// root), so their bytes never reach the intermediate JSON at all.
+func resolveSchemaRoot(root any, state *refResolveState, forClean bool) any {
+ rootMap, ok := root.(map[string]any)
+ if !ok {
+ return resolveLocalRefs(root, root, make(map[string]bool), state, false, forClean)
+ }
+
+ out := make(map[string]any, len(rootMap))
+ for _, key := range sortedKeys(rootMap) {
+ item := rootMap[key]
+ switch key {
+ case "$defs", "definitions":
+ if !forClean {
+ out[key] = nil // placeholder, resolved after the schema body below
+ }
+ case "$ref":
+ out[key] = item
+ default:
+ out[key] = resolveSchemaEntry(rootMap, key, item, make(map[string]bool), state, forClean)
+ }
+ }
+
+ // A root-level reference expands into the whole referenced schema; its siblings were resolved
+ // above and must override the target, mirroring resolveLocalRefs.
+ if ref, hasRef := rootMap["$ref"].(string); hasRef && strings.HasPrefix(ref, "#/") {
+ if target, okTarget := resolveJSONPointer(rootMap, ref); okTarget {
+ if size := targetExpansionSize(target, forClean); size <= state.budget {
+ state.budget -= size
+ active := map[string]bool{ref: true}
+ if targetMap, okMap := resolveLocalRefs(rootMap, target, active, state, false, forClean).(map[string]any); okMap {
+ merged := make(map[string]any, len(targetMap)+len(out))
+ for _, key := range sortedKeys(targetMap) {
+ merged[key] = targetMap[key]
+ }
+ for _, key := range sortedKeys(out) {
+ if key != "$ref" {
+ merged[key] = out[key]
+ }
+ }
+ out = merged
+ } else {
+ out = resolveRefFallback(rootMap, out, target, ref, make(map[string]bool), state, forClean)
+ }
+ } else {
+ out = resolveRefFallback(rootMap, out, target, ref, make(map[string]bool), state, forClean)
+ }
+ delete(out, "$ref")
+ }
+ }
+
+ if !forClean {
+ for _, key := range []string{"$defs", "definitions"} {
+ if item, exists := rootMap[key]; exists {
+ out[key] = resolveSchemaEntry(rootMap, key, item, make(map[string]bool), state, false)
+ }
+ }
+ }
+ return out
+}
+
+// targetExpansionSize estimates what copying a reference target into the output costs. When
+// cleaning, definitions nested inside the target are deleted later and never copied, so they are
+// excluded; otherwise a small target carrying a large unused definitions block would be degraded
+// purely because of material that is about to be thrown away. Name-map and literal context is
+// threaded so properties named like a container keyword, and data inside literal values, still
+// count in full.
+func targetExpansionSize(value any, forClean bool) int {
+ if !forClean {
+ return jsonValueSize(value)
+ }
+ return cleanExpansionSize(value, false)
+}
+
+func cleanExpansionSize(value any, inNameMap bool) int {
+ switch node := value.(type) {
+ case nil:
+ return 4
+ case bool:
+ return 5
+ case json.Number:
+ return len(node.String())
+ case string:
+ return jsonStringEncodedSize(node)
+ case []any:
+ size := 2
+ for _, item := range node {
+ size += cleanExpansionSize(item, false) + 1
+ if size > maxInlinedSchemaBytes+1 {
+ return maxInlinedSchemaBytes + 1
+ }
+ }
+ return size
+ case map[string]any:
+ size := 2
+ for key, item := range node {
+ if !inNameMap && (key == "$defs" || key == "definitions") {
+ continue
+ }
+ child := item
+ childInNameMap := false
+ if !inNameMap {
+ if isLiteralValueKeyword(key) {
+ size += jsonStringEncodedSize(key) + 1 + jsonValueSize(child) + 1
+ if size > maxInlinedSchemaBytes+1 {
+ return maxInlinedSchemaBytes + 1
+ }
+ continue
+ }
+ if _, isNameMap := schemaNameMapKeywords[key]; isNameMap {
+ childInNameMap = true
+ }
+ }
+ size += jsonStringEncodedSize(key) + 1 + cleanExpansionSize(child, childInNameMap) + 1
+ if size > maxInlinedSchemaBytes+1 {
+ return maxInlinedSchemaBytes + 1
+ }
+ }
+ return size
+ default:
+ return 16
+ }
+}
+
+// jsonValueSize estimates the encoded size of a decoded JSON value as encoding/json will emit
+// it, including string escaping (notably the six-byte \u00XX forms for <, >, & and control
+// characters). It is used to bound ref expansion, so it must not under-count what serialization
+// will actually produce.
+func jsonValueSize(value any) int {
+ switch node := value.(type) {
+ case nil:
+ return 4
+ case bool:
+ return 5
+ case json.Number:
+ return len(node.String())
+ case string:
+ return jsonStringEncodedSize(node)
+ case []any:
+ size := 2
+ for _, item := range node {
+ size += jsonValueSize(item) + 1
+ }
+ return size
+ case map[string]any:
+ size := 2
+ for key, item := range node {
+ size += jsonStringEncodedSize(key) + 1 + jsonValueSize(item) + 1
+ }
+ return size
+ default:
+ return 16
+ }
+}
+
+// jsonStringEncodedSize returns the encoded length of one JSON string including quotes,
+// mirroring encoding/json's default HTML escaping.
+func jsonStringEncodedSize(s string) int {
+ size := 2
+ for i := 0; i < len(s); i++ {
+ c := s[i]
+ switch {
+ case c < 0x20:
+ if c == '\n' || c == '\r' || c == '\t' {
+ size += 2
+ } else {
+ size += 6
+ }
+ case c == '"' || c == '\\':
+ size += 2
+ case c == '<' || c == '>' || c == '&':
+ size += 6
+ case c < 0x80:
+ size++
+ case c == 0xe2 && i+2 < len(s) && s[i+1] == 0x80 && (s[i+2] == 0xa8 || s[i+2] == 0xa9):
+ size += 6
+ i += 2
+ default:
+ size++
+ }
+ }
+ return size
+}
+
+// literalValueKeywords carry client data rather than subschemas. Cleaning and ref inlining must
+// copy their values verbatim: keys inside them that happen to look like schema keywords are data.
+var literalValueKeywords = map[string]struct{}{
+ "default": {},
+ "example": {},
+ "examples": {},
+ "const": {},
+ "enum": {},
+}
+
+func isLiteralValueKeyword(key string) bool {
+ _, ok := literalValueKeywords[key]
+ return ok
+}
+
+func resolveLocalRefs(root, value any, active map[string]bool, state *refResolveState, inNameMap, forClean bool) any {
switch node := value.(type) {
case []any:
out := make([]any, len(node))
for i, item := range node {
- out[i] = resolveLocalRefs(root, item, active)
+ out[i] = resolveLocalRefs(root, item, active, state, false, forClean)
}
return out
case map[string]any:
- ref, hasRef := node["$ref"].(string)
- if hasRef && strings.HasPrefix(ref, "#/") {
- if target, ok := resolveJSONPointer(root, ref); ok {
- if active[ref] {
- return cyclicRefFallback(node, target, ref)
- }
- active[ref] = true
- resolvedTarget := resolveLocalRefs(root, target, active)
- delete(active, ref)
- if targetMap, okTarget := resolvedTarget.(map[string]any); okTarget {
- out := make(map[string]any, len(targetMap)+len(node))
- for key, item := range targetMap {
- out[key] = item
+ if !inNameMap {
+ ref, hasRef := node["$ref"].(string)
+ if hasRef && strings.HasPrefix(ref, "#/") {
+ if target, ok := resolveJSONPointer(root, ref); ok {
+ if active[ref] {
+ return resolveRefFallback(root, node, target, ref, active, state, forClean)
}
- for key, item := range node {
- if key == "$ref" {
- continue
+ // Charge the stored target size and let nested references charge their own
+ // costs during recursion. The total copied material stays bounded, while an
+ // over-budget chain still expands its affordable outer levels and only the
+ // deepest references degrade to hints.
+ if size := targetExpansionSize(target, forClean); size > state.budget {
+ return resolveRefFallback(root, node, target, ref, active, state, forClean)
+ } else {
+ state.budget -= size
+ }
+ active[ref] = true
+ resolvedTarget := resolveLocalRefs(root, target, active, state, false, forClean)
+ delete(active, ref)
+ if targetMap, okTarget := resolvedTarget.(map[string]any); okTarget {
+ out := make(map[string]any, len(targetMap)+len(node))
+ for _, key := range sortedKeys(targetMap) {
+ if forClean && (key == "$defs" || key == "definitions") {
+ continue // dropped with every other definitions container when cleaning
+ }
+ out[key] = targetMap[key]
+ }
+ for _, key := range sortedKeys(node) {
+ if key == "$ref" {
+ continue
+ }
+ if forClean && (key == "$defs" || key == "definitions") {
+ continue
+ }
+ out[key] = resolveSchemaEntry(root, key, node[key], active, state, forClean)
}
- out[key] = resolveLocalRefs(root, item, active)
+ return out
}
- return out
}
}
}
+ out := make(map[string]any, len(node))
+ for _, key := range sortedKeys(node) {
+ if forClean && !inNameMap && (key == "$defs" || key == "definitions") {
+ continue // dropped with every other definitions container when cleaning
+ }
+ out[key] = resolveSchemaEntry(root, key, node[key], active, state, forClean)
+ }
+ return out
+ default:
+ return value
+ }
+}
+
+// resolveRefFallback degrades a reference that cannot be expanded (cycle or exhausted budget) to
+// a typed hint, but still resolves the sibling subschemas on the same node so their own references
+// are not left dangling behind the degraded one. Literal values stay verbatim through
+// resolveSchemaEntry. The description assembled by cyclicRefFallback is kept afterwards so
+// resolved siblings cannot clobber the hint.
+func resolveRefFallback(root any, node map[string]any, target any, ref string, active map[string]bool, state *refResolveState, forClean bool) map[string]any {
+ out := cyclicRefFallback(node, target, ref)
+ // The target description copied into the fallback is expansion output like any other; charge
+ // it to the budget. Once the budget is spent, fallbacks carry only the short hint, so a large
+ // number of degraded references cannot multiply copied descriptions without bound. Cyclic
+ // fallbacks omit over-long descriptions entirely, and omitted content is not charged.
+ if targetMap, okMap := target.(map[string]any); okMap {
+ if description, okDescription := targetMap["description"].(string); okDescription && description != "" && len(description) <= maxFallbackDescriptionBytes {
+ if _, hasOwn := node["description"]; !hasOwn {
+ cost := jsonStringEncodedSize(description)
+ if cost > state.budget {
+ out["description"] = "See: " + refName(ref)
+ } else {
+ state.budget -= cost
+ }
+ }
+ }
+ }
+ for _, key := range sortedKeys(node) {
+ if key == "$ref" || key == "description" {
+ continue
+ }
+ if forClean && (key == "$defs" || key == "definitions") {
+ delete(out, key)
+ continue
+ }
+ out[key] = resolveSchemaEntry(root, key, node[key], active, state, forClean)
+ }
+ return out
+}
+
+// resolveSchemaEntry resolves one map entry during ref inlining. A literal-value keyword's value
+// is client data and is deep-copied without resolving refs inside it. Under a name map
+// ("properties" and friends), however, a key spelled like a literal keyword is an author-chosen
+// property name whose value is a subschema; name-map entries are therefore resolved directly
+// here so such property names keep their schema semantics. Nested definition containers outside
+// the root still resolve through this path; when cleaning, callers skip them entirely instead.
+func resolveSchemaEntry(root any, key string, item any, active map[string]bool, state *refResolveState, forClean bool) any {
+ if isLiteralValueKeyword(key) {
+ return deepCopyJSONValue(item)
+ }
+ if _, isNameMap := schemaNameMapKeywords[key]; isNameMap {
+ if nameMap, ok := item.(map[string]any); ok {
+ out := make(map[string]any, len(nameMap))
+ for _, name := range sortedKeys(nameMap) {
+ out[name] = resolveLocalRefs(root, nameMap[name], active, state, false, forClean)
+ }
+ return out
+ }
+ }
+ return resolveLocalRefs(root, item, active, state, false, forClean)
+}
+
+func deepCopyJSONValue(value any) any {
+ switch node := value.(type) {
+ case []any:
+ out := make([]any, len(node))
+ for i, item := range node {
+ out[i] = deepCopyJSONValue(item)
+ }
+ return out
+ case map[string]any:
out := make(map[string]any, len(node))
for key, item := range node {
- out[key] = resolveLocalRefs(root, item, active)
+ out[key] = deepCopyJSONValue(item)
}
return out
default:
@@ -818,6 +1234,11 @@ func cyclicRefFallback(node map[string]any, target any, ref string) map[string]a
if targetMap, ok := target.(map[string]any); ok {
for _, key := range []string{"type", "nullable", "description"} {
if value, exists := targetMap[key]; exists {
+ if key == "description" {
+ if description, okDescription := value.(string); okDescription && len(description) > maxFallbackDescriptionBytes {
+ continue
+ }
+ }
out[key] = value
}
}
@@ -851,10 +1272,12 @@ func convertRefsToHints(jsonStr string, preserveSiblings bool) string {
sortByDepth(paths)
for _, p := range paths {
+ parentPath := trimSuffix(p, ".$ref")
+ if isPropertyDefinition(parentPath) {
+ continue // a property literally named "$ref", not a reference keyword
+ }
refVal := gjson.Get(jsonStr, p).String()
defName := refName(refVal)
-
- parentPath := trimSuffix(p, ".$ref")
hint := fmt.Sprintf("See: %s", defName)
if !preserveSiblings {
if existing := gjson.Get(jsonStr, descriptionPath(parentPath)).String(); existing != "" {
@@ -1121,80 +1544,248 @@ func mergeMissingSchemaAtPath(jsonStr, destination string, incoming gjson.Result
return jsonStr
}
-func flattenAnyOfOneOf(jsonStr string) string {
- for _, key := range []string{"anyOf", "oneOf"} {
- paths := findPaths(jsonStr, key)
- sortByDepth(paths)
+func isRequiredOnlyUnionBranch(item gjson.Result) bool {
+ if !item.IsObject() || !item.Get("required").Exists() {
+ return false
+ }
+ for _, key := range []string{"type", "properties", "items", "enum", "anyOf", "oneOf", "allOf"} {
+ if item.Get(key).Exists() {
+ return false
+ }
+ }
+ return true
+}
- for _, p := range paths {
- arr := gjson.Get(jsonStr, p)
- if !arr.IsArray() || len(arr.Array()) == 0 {
- continue
+func materializeRequiredOnlyBranch(item, parentProps gjson.Result) (gjson.Result, bool) {
+ if !parentProps.IsObject() {
+ return gjson.Result{}, false
+ }
+ reqs := getStrings(item.Raw, "required")
+ if len(reqs) == 0 {
+ return gjson.Result{}, false
+ }
+ raw := item.Raw
+ for _, reqName := range reqs {
+ escaped := escapeGJSONPathKey(reqName)
+ propVal := parentProps.Get(escaped)
+ if !propVal.Exists() {
+ return gjson.Result{}, false
+ }
+ updated, errSet := sjson.SetRawBytes([]byte(raw), "properties."+escaped, []byte(propVal.Raw))
+ if errSet == nil {
+ raw = string(updated)
+ }
+ }
+ if updated, errSet := sjson.SetBytes([]byte(raw), "type", "object"); errSet == nil {
+ raw = string(updated)
+ }
+ return gjson.Parse(raw), true
+}
+
+func isNullOnlyBranch(item gjson.Result) bool {
+ if item.Get("properties").Exists() || item.Get("items").Exists() {
+ return false
+ }
+ t := item.Get("type")
+ if t.Type == gjson.String {
+ return t.String() == "null"
+ }
+ if t.IsArray() && len(t.Array()) > 0 {
+ for _, elem := range t.Array() {
+ if elem.String() != "null" {
+ return false
}
+ }
+ return true
+ }
+ return false
+}
- parentPath := trimSuffix(p, "."+key)
- parent := gjson.Get(jsonStr, parentPath)
- if parentPath == "" {
- parent = gjson.Parse(jsonStr)
+func partitionUnionBranches(items []gjson.Result, parentProps gjson.Result) (nonNullItems []gjson.Result, hasNull, hasFilteredBranch bool) {
+ for _, item := range items {
+ if item.Type == gjson.True {
+ nonNullItems = append(nonNullItems, gjson.Parse("{}"))
+ hasFilteredBranch = true
+ continue
+ }
+ if !item.IsObject() {
+ hasFilteredBranch = true
+ continue
+ }
+ if isNullOnlyBranch(item) {
+ hasNull = true
+ continue
+ }
+ if isRequiredOnlyUnionBranch(item) {
+ if materialized, ok := materializeRequiredOnlyBranch(item, parentProps); ok {
+ nonNullItems = append(nonNullItems, materialized)
+ hasFilteredBranch = true
+ continue
}
+ hasFilteredBranch = true
+ continue
+ }
+ nonNullItems = append(nonNullItems, item)
+ }
+ return
+}
- items := arr.Array()
+func flattenAnyOfOneOf(jsonStr string, preserveOneOf, flattenMultiBranch bool) string {
+ pathsByField := findPathsByFields(jsonStr, []string{"oneOf", "anyOf"})
+ var paths []string
+ for _, key := range []string{"oneOf", "anyOf"} {
+ paths = append(paths, pathsByField[key]...)
+ }
+ sortByDepth(paths)
- // If the parent already defines properties (e.g. an object schema with anyOf/oneOf constraints),
- // do not replace the parent with a single branch. Instead, merge any branch properties
- // into the parent and delete the union keyword.
- if parentProps := parent.Get("properties"); parentProps.IsObject() {
- hasNull := false
- for _, item := range items {
- if item.Get("type").String() == "null" {
- hasNull = true
- }
- if branchProps := item.Get("properties"); branchProps.IsObject() {
- branchProps.ForEach(func(propKey, propVal gjson.Result) bool {
- destPath := joinPath(parentPath, "properties."+escapeGJSONPathKey(propKey.String()))
- jsonStr = mergeMissingSchemaAtPath(jsonStr, destPath, propVal)
- return true
- })
- }
+ for _, p := range paths {
+ key := "anyOf"
+ if p == "oneOf" || strings.HasSuffix(p, ".oneOf") {
+ key = "oneOf"
+ }
+
+ arr := gjson.Get(jsonStr, p)
+ if !arr.IsArray() || len(arr.Array()) == 0 {
+ continue
+ }
+
+ parentPath := trimSuffix(p, "."+key)
+ if isPropertyDefinition(parentPath) {
+ continue
+ }
+ parent := gjson.Get(jsonStr, parentPath)
+ if parentPath == "" {
+ parent = gjson.Parse(jsonStr)
+ }
+
+ // When a node declares both anyOf and oneOf on a native Schema carrier (!preserveOneOf),
+ // protobuf Schema has only a single anyOf field and cannot represent a second union
+ // conjunction structurally. Preserve anyOf as the primary union and move the sibling
+ // oneOf constraint to a description hint instead of concatenating it into anyOf.
+ // Routes that flatten multi-branch unions skip this shortcut: the primary union is
+ // about to be flattened away too, and hinting the raw oneOf JSON into the
+ // description would only add a giant, misleading hint.
+ if key == "oneOf" && !flattenMultiBranch && !preserveOneOf && parent.Get("anyOf").IsArray() {
+ jsonStr = appendHint(jsonStr, parentPath, "oneOf: "+arr.Raw)
+ jsonStr, _ = sjson.Delete(jsonStr, p)
+ continue
+ }
+
+ items := arr.Array()
+ parentProps := parent.Get("properties")
+ nonNullItems, hasNull, hasFilteredBranch := partitionUnionBranches(items, parentProps)
+ // An earlier union-preserving pass may already have stripped the null branch and
+ // recorded it as nullable:true on this node (translator -> executor chain). That
+ // marker is part of the schema contract, so the flattening below must inherit it.
+ nullable := hasNull || parent.Get("nullable").Bool()
+
+ // Preserve multi-branch unions (2+ standalone non-null branches) so non-first branches are not lost.
+ // On native Gemini/Antigravity Schema carriers (!preserveOneOf), normalize oneOf to anyOf because
+ // the protobuf Schema message supports anyOf but rejects oneOf.
+ // Routes that reject multi-branch unions upstream (flattenMultiBranch) skip this
+ // preservation and fall through to the single-strongest-branch flattening below.
+ if len(nonNullItems) >= 2 && !flattenMultiBranch {
+ targetKey := key
+ if key == "oneOf" && !preserveOneOf {
+ targetKey = "anyOf"
+ }
+ targetPath := joinPath(parentPath, targetKey)
+
+ if targetKey != key || hasNull || hasFilteredBranch {
+ rawBranches := make([]json.RawMessage, 0, len(nonNullItems))
+ for _, item := range nonNullItems {
+ rawBranches = append(rawBranches, json.RawMessage(item.Raw))
}
- if hasNull {
- updated, _ := sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "nullable"), true)
+ updated, errSet := sjson.SetBytes([]byte(jsonStr), targetPath, rawBranches)
+ if errSet == nil {
jsonStr = string(updated)
}
- if parent.Get("type").String() == "" {
- updated, _ := sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "type"), "object")
+ if targetKey != key {
+ jsonStr, _ = sjson.Delete(jsonStr, p)
+ }
+ }
+ if hasNull {
+ updated, errSet := sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "nullable"), true)
+ if errSet == nil {
jsonStr = string(updated)
}
- jsonStr, _ = sjson.Delete(jsonStr, p)
- continue
+ jsonStr = appendHint(jsonStr, parentPath, "(nullable)")
}
+ continue
+ }
- parentDesc := gjson.Get(jsonStr, descriptionPath(parentPath)).String()
- bestIdx, allTypes := selectBest(items)
- selected := items[bestIdx].Raw
- hasNull := false
- for _, item := range items {
- if item.Get("type").String() == "null" {
- hasNull = true
- break
+ // If the parent already defines properties (e.g. an object schema with unmaterializable
+ // union constraints or a single-branch union), do not replace the parent with a single branch.
+ // Instead, merge any branch properties into the parent and delete the union keyword.
+ if parentProps.IsObject() {
+ for _, item := range nonNullItems {
+ if branchProps := item.Get("properties"); branchProps.IsObject() {
+ branchProps.ForEach(func(propKey, propVal gjson.Result) bool {
+ destPath := joinPath(parentPath, "properties."+escapeGJSONPathKey(propKey.String()))
+ jsonStr = mergeMissingSchemaAtPath(jsonStr, destPath, propVal)
+ return true
+ })
}
}
- if hasNull && items[bestIdx].Get("type").String() != "null" {
- updated, _ := sjson.SetBytes([]byte(selected), "nullable", true)
- selected = string(updated)
+ if nullable {
+ updated, _ := sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "nullable"), true)
+ jsonStr = string(updated)
+ }
+ if parent.Get("type").String() == "" {
+ updated, _ := sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "type"), "object")
+ jsonStr = string(updated)
}
+ jsonStr, _ = sjson.Delete(jsonStr, p)
+ continue
+ }
- if parentDesc != "" {
+ if len(nonNullItems) == 0 {
+ if !hasNull {
+ jsonStr, _ = sjson.Delete(jsonStr, p)
+ continue
+ }
+ selected := `{"type":"null"}`
+ if parentDesc := gjson.Get(jsonStr, descriptionPath(parentPath)).String(); parentDesc != "" {
selected = mergeDescriptionRaw(selected, parentDesc)
}
+ jsonStr = setRawAt(jsonStr, parentPath, selected)
+ continue
+ }
- if len(allTypes) > 1 {
- hint := "Accepts: " + strings.Join(allTypes, " | ")
- selected = appendHintRaw(selected, hint)
+ candidateItems := items
+ if hasFilteredBranch || len(nonNullItems) >= 2 {
+ candidateItems = nonNullItems
+ if hasNull {
+ candidateItems = append(append([]gjson.Result(nil), nonNullItems...), gjson.Parse(`{"type":"null"}`))
}
+ }
- jsonStr = setRawAt(jsonStr, parentPath, selected)
+ parentDesc := gjson.Get(jsonStr, descriptionPath(parentPath)).String()
+ _, allTypes := selectBest(candidateItems)
+ // Select the strongest non-null branch (ties keep the first one), matching the
+ // pre-preservation flattening behavior. selectBest already treats union-bearing
+ // branches as score-0, so a nested union never wins over a null-free scalar here.
+ selected := nonNullItems[0].Raw
+ if bestIdx, _ := selectBest(nonNullItems); bestIdx >= 0 && bestIdx < len(nonNullItems) {
+ selected = nonNullItems[bestIdx].Raw
+ }
+ if nullable {
+ updated, _ := sjson.SetBytes([]byte(selected), "nullable", true)
+ selected = string(updated)
+ }
+
+ if parentDesc != "" {
+ selected = mergeDescriptionRaw(selected, parentDesc)
+ }
+
+ if len(allTypes) > 1 {
+ hint := "Accepts: " + strings.Join(allTypes, " | ")
+ selected = appendHintRaw(selected, hint)
+ } else if nullable {
+ selected = appendHintRaw(selected, "(nullable)")
}
+
+ jsonStr = setRawAt(jsonStr, parentPath, selected)
}
return jsonStr
}
@@ -1324,8 +1915,8 @@ func removeUnsupportedKeywords(jsonStr string, options jsonSchemaCleanOptions) s
"propertyNames", "patternProperties", // Gemini doesn't support these schema keywords
"dependentRequired", "dependentSchemas", "dependencies",
"if", "then", "else",
- "$comment", "enumDescriptions", "enumTitles", "prefill", "deprecated", "encrypted", // Schema metadata fields unsupported by Gemini
- "additionalItems", "unevaluatedProperties", "unevaluatedItems", "contentSchema",
+ "$comment", "enumDescriptions", "enumTitles", "prefill", "deprecated", "encrypted", "discriminator", "readOnly", "writeOnly", // Schema metadata fields unsupported by Gemini
+ "additionalItems", "unevaluatedProperties", "unevaluatedItems", "contentSchema", "contentMediaType", "contentEncoding",
)
if options.antigravitySemantics {
keywords = append(keywords, "not")
@@ -1395,6 +1986,11 @@ func walkForExtensions(value gjson.Result, path string, paths *[]string) {
return true
}
+ // Literal values are client data; x-* keys inside them are not schema extensions.
+ if isLiteralValueKeyword(keyStr) && !isPropertyDefinition(path) {
+ return true
+ }
+
walkForExtensions(val, childPath, paths)
return true
})
@@ -1460,6 +2056,9 @@ func addEmptySchemaPlaceholder(jsonStr string) string {
reqPath := joinPath(parentPath, "required")
reqVal := gjson.Get(jsonStr, reqPath)
hasRequiredProperties := reqVal.IsArray() && len(reqVal.Array()) > 0
+ if gjson.Get(jsonStr, joinPath(parentPath, "anyOf")).IsArray() || gjson.Get(jsonStr, joinPath(parentPath, "oneOf")).IsArray() {
+ continue
+ }
needsPlaceholder := false
if !propsVal.Exists() {
@@ -1508,7 +2107,7 @@ func addEmptySchemaPlaceholder(jsonStr string) string {
func findPaths(jsonStr, field string) []string {
var paths []string
- Walk(gjson.Parse(jsonStr), "", field, &paths)
+ WalkSchema(gjson.Parse(jsonStr), "", field, &paths)
return paths
}
@@ -1540,6 +2139,12 @@ func walkForFields(value gjson.Result, path string, fields map[string]struct{},
paths[keyStr] = append(paths[keyStr], childPath)
}
+ // Literal values are client data; schema-looking keys inside them are not keywords.
+ // Under a name map the key is a property name instead, so keep descending there.
+ if isLiteralValueKeyword(keyStr) && !isPropertyDefinition(path) {
+ return true
+ }
+
walkForFields(val, childPath, fields, paths)
return true
})
@@ -1577,11 +2182,14 @@ func setRawAt(jsonStr, path, value string) string {
}
// schemaNameMapKeywords are the schema keywords whose value maps author-chosen names to
-// subschemas. A key directly under one of them is a name, never a schema keyword.
+// subschemas. A key directly under one of them is a name, never a schema keyword. The Draft-07
+// "dependencies" container qualifies for object (schema) values; array values are property-name
+// lists and are never walked as maps by the checks that use this set.
var schemaNameMapKeywords = map[string]struct{}{
"properties": {},
"patternProperties": {},
"dependentSchemas": {},
+ "dependencies": {},
"$defs": {},
"definitions": {},
}
diff --git a/internal/util/gemini_schema_test.go b/internal/util/gemini_schema_test.go
index 5da8f3f4d8c..1ff4ea3acc3 100644
--- a/internal/util/gemini_schema_test.go
+++ b/internal/util/gemini_schema_test.go
@@ -3,6 +3,7 @@ package util
import (
"encoding/json"
"reflect"
+ "strconv"
"strings"
"testing"
@@ -158,8 +159,10 @@ func TestCleanJSONSchemaForAntigravity_OneOfFlattening(t *testing.T) {
"type": "object",
"properties": {
"config": {
- "type": "string",
- "description": "Accepts: string | integer"
+ "anyOf": [
+ { "type": "string" },
+ { "type": "integer" }
+ ]
}
}
}`
@@ -445,14 +448,22 @@ func TestCleanJSONSchemaForAntigravity_AnyOfAlternativeHints(t *testing.T) {
}
}`
- result := CleanJSONSchemaForAntigravity(input)
+ expected := `{
+ "type": "object",
+ "properties": {
+ "value": {
+ "anyOf": [
+ { "type": "string" },
+ { "type": "integer" }
+ ],
+ "nullable": true,
+ "description": "(nullable)"
+ }
+ }
+ }`
- if !strings.Contains(result, "Accepts:") {
- t.Errorf("Expected alternative types hint, got: %s", result)
- }
- if !strings.Contains(result, "string") || !strings.Contains(result, "integer") {
- t.Errorf("Expected all alternative types in hint, got: %s", result)
- }
+ result := CleanJSONSchemaForAntigravity(input)
+ compareJSON(t, expected, result)
}
func TestCleanJSONSchemaForAntigravity_NullableHint(t *testing.T) {
@@ -549,31 +560,48 @@ func TestCleanJSONSchemaForAntigravity_AdditionalPropertiesHint(t *testing.T) {
}
func TestCleanJSONSchemaForAntigravity_AnyOfFlattening_PreservesDescription(t *testing.T) {
- input := `{
+ nullableInput := `{
"type": "object",
"properties": {
"config": {
"description": "Parent desc",
"anyOf": [
{ "type": "string", "description": "Child desc" },
- { "type": "integer" }
+ { "type": "null" }
]
}
}
}`
- expected := `{
+ expectedNullable := `{
"type": "object",
"properties": {
"config": {
"type": "string",
- "description": "Parent desc (Child desc) (Accepts: string | integer)"
+ "nullable": true,
+ "description": "Parent desc (Child desc) (Accepts: string | null)"
}
}
}`
- result := CleanJSONSchemaForAntigravity(input)
- compareJSON(t, expected, result)
+ resultNullable := CleanJSONSchemaForAntigravity(nullableInput)
+ compareJSON(t, expectedNullable, resultNullable)
+
+ multiBranchInput := `{
+ "type": "object",
+ "properties": {
+ "config": {
+ "description": "Parent desc",
+ "anyOf": [
+ { "type": "string", "description": "Child desc" },
+ { "type": "integer" }
+ ]
+ }
+ }
+ }`
+
+ resultMulti := CleanJSONSchemaForAntigravity(multiBranchInput)
+ compareJSON(t, multiBranchInput, resultMulti)
}
func TestCleanJSONSchemaForAntigravity_SingleEnumBecomesHint(t *testing.T) {
@@ -2280,7 +2308,8 @@ func TestCleanJSONSchema_PreservesAdditionalPropertiesObjectSchema(t *testing.T)
}
// TestCleanJSONSchemaForAntigravityResponse_AnyOfRequiredOnlyBranches tests issue 5219 #2:
-// anyOf with required-only branches should not overwrite the parent object's type and properties.
+// anyOf with required-only branches should not overwrite the parent object's type and properties,
+// and should materialize valid typed object branches from parent properties so the union is preserved.
func TestCleanJSONSchemaForAntigravityResponse_AnyOfRequiredOnlyBranches(t *testing.T) {
input := `{
"type": "object",
@@ -2304,8 +2333,15 @@ func TestCleanJSONSchemaForAntigravityResponse_AnyOfRequiredOnlyBranches(t *test
if !parsed.Get("properties.left").Exists() || !parsed.Get("properties.right").Exists() {
t.Fatalf("properties were wiped out; cleaned: %s", got)
}
- if parsed.Get("anyOf").Exists() {
- t.Fatalf("anyOf was not removed; cleaned: %s", got)
+ branches := parsed.Get("anyOf").Array()
+ if len(branches) != 2 {
+ t.Fatalf("anyOf branch count = %d, want 2; cleaned: %s", len(branches), got)
+ }
+ if branches[0].Get("type").String() != "object" || !branches[0].Get("properties.left").Exists() || branches[0].Get("required.0").String() != "left" {
+ t.Fatalf("anyOf[0] was not materialized into valid object branch: %s", branches[0].Raw)
+ }
+ if branches[1].Get("type").String() != "object" || !branches[1].Get("properties.right").Exists() || branches[1].Get("required.0").String() != "right" {
+ t.Fatalf("anyOf[1] was not materialized into valid object branch: %s", branches[1].Raw)
}
}
@@ -3135,3 +3171,1242 @@ func TestCleanJSONSchema_RemovesDependentKeywords_Issue6539(t *testing.T) {
})
}
}
+
+// TestCleanJSONSchema_PreservesMultiBranchAnyOfOneOf_Issue3662 covers Issue #3662:
+// Multi-branch anyOf/oneOf unions (such as string | image object | audio object) must not be
+// flattened to a single selected branch, which previously caused non-first object branches (e.g. audio)
+// to disappear from the cleaned schema.
+// Multi-branch unions are preserved by every cleaner except the Antigravity Claude
+// tool cleaner, which flattens them for the Claude-side upstream that rejects nested
+// multi-branch unions (Issue 3662 regression follow-up).
+func TestCleanJSONSchema_PreservesMultiBranchAnyOfOneOf_Issue3662(t *testing.T) {
+ input := `{
+ "type": "object",
+ "properties": {
+ "item": {
+ "anyOf": [
+ { "type": "string" },
+ {
+ "type": "object",
+ "properties": {
+ "image": { "type": "string" }
+ },
+ "required": ["image"]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "audio": { "type": "string" }
+ },
+ "required": ["audio"]
+ }
+ ]
+ }
+ },
+ "required": ["item"]
+ }`
+
+ cleaners := map[string]func(string) string{
+ "Antigravity": CleanJSONSchemaForAntigravity,
+ "AntigravityTool": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) },
+ "AntigravityResponse": CleanJSONSchemaForAntigravityResponse,
+ "Gemini": CleanJSONSchemaForGemini,
+ "GeminiJSONSchema": CleanJSONSchemaForGeminiJSONSchema,
+ }
+
+ for name, clean := range cleaners {
+ t.Run(name, func(t *testing.T) {
+ got := clean(input)
+ compareJSON(t, input, got)
+
+ // Verify idempotency when cleaned a second time
+ gotSecond := clean(got)
+ compareJSON(t, got, gotSecond)
+ })
+ }
+
+ t.Run("NullableMultiBranchUnionStripsNullBranchAndSetsNullable", func(t *testing.T) {
+ nullableUnion := `{
+ "type": "object",
+ "properties": {
+ "item": {
+ "anyOf": [
+ { "type": "string" },
+ {
+ "type": "object",
+ "properties": { "image": { "type": "string" } },
+ "required": ["image"]
+ },
+ {
+ "type": "object",
+ "properties": { "audio": { "type": "string" } },
+ "required": ["audio"]
+ },
+ { "type": "null" }
+ ]
+ }
+ },
+ "required": ["item"]
+ }`
+
+ agCleaned := gjson.Parse(CleanJSONSchemaForAntigravity(nullableUnion))
+ branches := agCleaned.Get("properties.item.anyOf").Array()
+ if len(branches) != 3 {
+ t.Fatalf("Antigravity anyOf branch count = %d, want 3: %s", len(branches), agCleaned.Raw)
+ }
+ if !agCleaned.Get("properties.item.nullable").Bool() {
+ t.Fatalf("Antigravity properties.item.nullable = false, want true: %s", agCleaned.Raw)
+ }
+ if !agCleaned.Get("properties.item.anyOf.2.properties.audio").Exists() {
+ t.Fatalf("Antigravity lost audio branch: %s", agCleaned.Raw)
+ }
+
+ geminiCleaned := gjson.Parse(CleanJSONSchemaForGeminiJSONSchema(nullableUnion))
+ if len(geminiCleaned.Get("properties.item.anyOf").Array()) != 3 {
+ t.Fatalf("GeminiJSONSchema anyOf branch count = %d, want 3: %s", len(geminiCleaned.Get("properties.item.anyOf").Array()), geminiCleaned.Raw)
+ }
+ if geminiCleaned.Get("properties.item.nullable").Exists() {
+ t.Fatalf("GeminiJSONSchema should not keep nullable keyword: %s", geminiCleaned.Raw)
+ }
+ if !strings.Contains(geminiCleaned.Get("properties.item.description").String(), "(nullable)") {
+ t.Fatalf("GeminiJSONSchema missing (nullable) hint: %s", geminiCleaned.Raw)
+ }
+
+ // Nested nullable union in both [null, nestedUnion] and [nestedUnion, null] orders
+ // must unwrap to the nested union with nullable metadata and remain idempotent.
+ for _, nestedNullable := range []string{
+ `{"type":"object","properties":{"item":{"anyOf":[{"type":"null"},{"anyOf":[{"type":"string"},{"type":"integer"}]}]}}}`,
+ `{"type":"object","properties":{"item":{"anyOf":[{"anyOf":[{"type":"string"},{"type":"integer"}]},{"type":"null"}]}}}`,
+ } {
+ expectedNested := `{
+ "type": "object",
+ "properties": {
+ "item": {
+ "anyOf": [
+ { "type": "string" },
+ { "type": "integer" }
+ ],
+ "nullable": true,
+ "description": "(nullable)"
+ }
+ }
+ }`
+ cleanedOnce := CleanJSONSchemaForAntigravity(nestedNullable)
+ compareJSON(t, expectedNested, cleanedOnce)
+ cleanedTwice := CleanJSONSchemaForAntigravity(cleanedOnce)
+ compareJSON(t, expectedNested, cleanedTwice)
+ }
+ })
+
+ t.Run("OneOfNormalizedToAnyOfOnNativeSchemaCarriers", func(t *testing.T) {
+ oneOfInput := `{
+ "type": "object",
+ "properties": {
+ "item": {
+ "oneOf": [
+ { "type": "string" },
+ {
+ "type": "object",
+ "properties": { "image": { "type": "string" } },
+ "required": ["image"]
+ },
+ {
+ "type": "object",
+ "properties": { "audio": { "type": "string" } },
+ "required": ["audio"]
+ }
+ ]
+ }
+ },
+ "required": ["item"]
+ }`
+
+ for name, clean := range map[string]func(string) string{
+ "Antigravity": CleanJSONSchemaForAntigravity,
+ "AntigravityTool": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) },
+ "AntigravityResponse": CleanJSONSchemaForAntigravityResponse,
+ "Gemini": CleanJSONSchemaForGemini,
+ } {
+ parsed := gjson.Parse(clean(oneOfInput))
+ if parsed.Get("properties.item.oneOf").Exists() {
+ t.Fatalf("[%s] oneOf survived on native Schema carrier: %s", name, parsed.Raw)
+ }
+ if got := len(parsed.Get("properties.item.anyOf").Array()); got != 3 {
+ t.Fatalf("[%s] anyOf branch count = %d, want 3: %s", name, got, parsed.Raw)
+ }
+ }
+
+ // parametersJsonSchema carrier preserves standard JSON Schema oneOf
+ jsonSchemaParsed := gjson.Parse(CleanJSONSchemaForGeminiJSONSchema(oneOfInput))
+ if got := len(jsonSchemaParsed.Get("properties.item.oneOf").Array()); got != 3 {
+ t.Fatalf("[GeminiJSONSchema] oneOf branch count = %d, want 3: %s", got, jsonSchemaParsed.Raw)
+ }
+ })
+
+ t.Run("PreservesUnionAndBranchRequiredWhenParentHasProperties", func(t *testing.T) {
+ parentPropsUnion := `{
+ "type": "object",
+ "properties": {},
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": { "image": { "type": "string" } },
+ "required": ["image"]
+ },
+ {
+ "type": "object",
+ "properties": { "audio": { "type": "string" } },
+ "required": ["audio"]
+ }
+ ]
+ }`
+
+ for name, clean := range cleaners {
+ t.Run(name, func(t *testing.T) {
+ got := clean(parentPropsUnion)
+ compareJSON(t, parentPropsUnion, got)
+ })
+ }
+
+ // Mixed union with a required-only branch materializes the required-only branch from parent properties
+ // so that the alternative branch (e.g. text) is preserved in anyOf.
+ mixedRequiredOnlyBranch := `{
+ "type": "object",
+ "properties": {
+ "image": { "type": "string" },
+ "audio": { "type": "string" },
+ "text": { "type": "string" }
+ },
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": { "image": { "type": "string" } },
+ "required": ["image"]
+ },
+ {
+ "type": "object",
+ "properties": { "audio": { "type": "string" } },
+ "required": ["audio"]
+ },
+ {
+ "required": ["text"]
+ }
+ ]
+ }`
+ expectedMixedRequiredOnly := `{
+ "type": "object",
+ "properties": {
+ "image": { "type": "string" },
+ "audio": { "type": "string" },
+ "text": { "type": "string" }
+ },
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": { "image": { "type": "string" } },
+ "required": ["image"]
+ },
+ {
+ "type": "object",
+ "properties": { "audio": { "type": "string" } },
+ "required": ["audio"]
+ },
+ {
+ "type": "object",
+ "properties": { "text": { "type": "string" } },
+ "required": ["text"]
+ }
+ ]
+ }`
+ compareJSON(t, expectedMixedRequiredOnly, CleanJSONSchemaForAntigravity(mixedRequiredOnlyBranch))
+
+ // Pure required-only union branches on an object with parent properties are materialized into valid object branches.
+ pureRequiredOnlyUnion := `{
+ "type": "object",
+ "properties": {
+ "image": { "type": "string" },
+ "audio": { "type": "string" }
+ },
+ "anyOf": [
+ { "required": ["image"] },
+ { "required": ["audio"] }
+ ]
+ }`
+ expectedPureRequiredOnly := `{
+ "type": "object",
+ "properties": {
+ "image": { "type": "string" },
+ "audio": { "type": "string" }
+ },
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": { "image": { "type": "string" } },
+ "required": ["image"]
+ },
+ {
+ "type": "object",
+ "properties": { "audio": { "type": "string" } },
+ "required": ["audio"]
+ }
+ ]
+ }`
+ compareJSON(t, expectedPureRequiredOnly, CleanJSONSchemaForAntigravity(pureRequiredOnlyUnion))
+ })
+
+ t.Run("BooleanUnionBranchesAndSiblingAnyOfOneOf", func(t *testing.T) {
+ // Boolean false subschema branches in anyOf/oneOf are unsatisfiable and must be stripped
+ // instead of leaking raw boolean false into protobuf Schema anyOf.
+ falseSingleBranch := `{"type":"object","properties":{"value":{"anyOf":[false,{"type":"string"}]}}}`
+ expectedFalseSingle := `{"type":"object","properties":{"value":{"type":"string"}}}`
+ compareJSON(t, expectedFalseSingle, CleanJSONSchemaForAntigravity(falseSingleBranch))
+
+ falseMultiBranch := `{"type":"object","properties":{"value":{"oneOf":[false,{"type":"string"},{"type":"integer"}]}}}`
+ expectedFalseMulti := `{"type":"object","properties":{"value":{"anyOf":[{"type":"string"},{"type":"integer"}]}}}`
+ compareJSON(t, expectedFalseMulti, CleanJSONSchemaForAntigravity(falseMultiBranch))
+
+ // Boolean true subschema branch in anyOf/oneOf is normalized to an unconstrained {} branch and preserved.
+ trueMultiBranch := `{"type":"object","properties":{"value":{"anyOf":[true,{"type":"string"},{"type":"integer"}]}}}`
+ expectedTrueMulti := `{"type":"object","properties":{"value":{"anyOf":[{},{"type":"string"},{"type":"integer"}]}}}`
+ compareJSON(t, expectedTrueMulti, CleanJSONSchemaForAntigravityTool(trueMultiBranch, false))
+
+ // When a node declares both anyOf and oneOf, native Schema carriers keep anyOf without concatenating
+ // oneOf into it and record oneOf as a description hint, while GeminiJSONSchema preserves both.
+ siblingUnions := `{
+ "type": "object",
+ "properties": {
+ "value": {
+ "anyOf": [
+ { "type": "string" },
+ { "type": "integer" }
+ ],
+ "oneOf": [
+ { "type": "string" },
+ { "type": "boolean" }
+ ]
+ }
+ }
+ }`
+ for name, clean := range map[string]func(string) string{
+ "Antigravity": CleanJSONSchemaForAntigravity,
+ "AntigravityTool": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) },
+ "AntigravityResponse": CleanJSONSchemaForAntigravityResponse,
+ "Gemini": CleanJSONSchemaForGemini,
+ } {
+ parsed := gjson.Parse(clean(siblingUnions))
+ if parsed.Get("properties.value.oneOf").Exists() {
+ t.Fatalf("[%s] sibling oneOf survived on native Schema carrier: %s", name, parsed.Raw)
+ }
+ if got := len(parsed.Get("properties.value.anyOf").Array()); got != 2 {
+ t.Fatalf("[%s] anyOf branch count = %d, want 2 (must not concatenate oneOf): %s", name, got, parsed.Raw)
+ }
+ if !strings.Contains(parsed.Get("properties.value.description").String(), "oneOf:") {
+ t.Fatalf("[%s] missing oneOf description hint: %s", name, parsed.Raw)
+ }
+ }
+
+ jsonSchemaSibling := gjson.Parse(CleanJSONSchemaForGeminiJSONSchema(siblingUnions))
+ if len(jsonSchemaSibling.Get("properties.value.anyOf").Array()) != 2 || len(jsonSchemaSibling.Get("properties.value.oneOf").Array()) != 2 {
+ t.Fatalf("[GeminiJSONSchema] expected both anyOf and oneOf to be preserved: %s", jsonSchemaSibling.Raw)
+ }
+
+ // Issue 3662 regression: only the Antigravity Claude tool cleaner flattens the
+ // multi-branch unions that every other cleaner above preserves, because only the
+ // Antigravity Claude backend is known to reject nested multi-branch unions when
+ // it converts function declarations to Claude custom.input_schema.
+ t.Run("AntigravityClaudeToolFlattensNestedUnions", func(t *testing.T) {
+ flattened := gjson.Parse(CleanJSONSchemaForAntigravityClaudeTool(input, false))
+ item := flattened.Get("properties.item")
+ if item.Get("anyOf").Exists() || item.Get("oneOf").Exists() {
+ t.Fatalf("Antigravity Claude tool union survived cleaning: %s", flattened.Raw)
+ }
+ if got := item.Get("type").String(); got != "object" {
+ t.Fatalf("Antigravity Claude flattened item type = %q, want object (strongest branch): %s", got, flattened.Raw)
+ }
+ if !item.Get("properties.image").Exists() {
+ t.Fatalf("Antigravity Claude flattened item lost the first object branch: %s", flattened.Raw)
+ }
+ if desc := item.Get("description").String(); !strings.Contains(desc, "Accepts:") || !strings.Contains(desc, "string") {
+ t.Fatalf("Antigravity Claude flattened item lost the dropped-branch hint: %s", flattened.Raw)
+ }
+
+ // Idempotency matters on the Antigravity path: translators clean the schema
+ // once, then the executor cleans it again before sending it upstream.
+ flattenedTwice := CleanJSONSchemaForAntigravityClaudeTool(flattened.Raw, false)
+ compareJSON(t, flattened.Raw, flattenedTwice)
+
+ // Double-cleaning across the canonical translator -> executor chain must
+ // keep the flattened Claude shape unchanged on the second pass.
+ recleaned := CleanJSONSchemaForAntigravityClaudeTool(CleanJSONSchemaForAntigravity(input), false)
+ compareJSON(t, flattened.Raw, recleaned)
+ compareJSON(t, flattened.Raw, CleanJSONSchemaForAntigravity(flattened.Raw))
+
+ // Nullable multi-branch unions must keep nullability through the whole
+ // translator -> executor chain: the union-preserving first pass strips the
+ // null branch and marks the parent nullable:true, so the flattening second
+ // pass must inherit that marker instead of silently dropping it.
+ nullableUnionInput := `{"type":"object","properties":{"value":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"null"}]}}}`
+ directFlattened := CleanJSONSchemaForAntigravityClaudeTool(nullableUnionInput, false)
+ directValue := gjson.Parse(directFlattened).Get("properties.value")
+ if !directValue.Get("nullable").Bool() || directValue.Get("type").String() != "string" {
+ t.Fatalf("direct Claude flattening lost nullability: %s", directFlattened)
+ }
+ chainedFlattened := CleanJSONSchemaForAntigravityClaudeTool(CleanJSONSchemaForAntigravity(nullableUnionInput), false)
+ chainedValue := gjson.Parse(chainedFlattened).Get("properties.value")
+ if !chainedValue.Get("nullable").Bool() || chainedValue.Get("type").String() != "string" || chainedValue.Get("anyOf").Exists() {
+ t.Fatalf("translator -> executor chain lost nullability while flattening: %s", chainedFlattened)
+ }
+ compareJSON(t, chainedFlattened, CleanJSONSchemaForAntigravityClaudeTool(chainedFlattened, false))
+
+ // Boolean subschema branches must not leak raw booleans or unions into the
+ // flattened output: true normalizes away, false is unstisfiable and dropped.
+ trueBranch := `{"type":"object","properties":{"value":{"anyOf":[true,{"type":"string"},{"type":"integer"}]}}}`
+ compareJSON(t, `{"type":"object","properties":{"value":{"type":"string","description":"Accepts: string | integer"}}}`, CleanJSONSchemaForAntigravityClaudeTool(trueBranch, false))
+ falseBranch := `{"type":"object","properties":{"value":{"oneOf":[false,{"type":"string"},{"type":"integer"}]}}}`
+ compareJSON(t, `{"type":"object","properties":{"value":{"type":"string","description":"Accepts: string | integer"}}}`, CleanJSONSchemaForAntigravityClaudeTool(falseBranch, false))
+
+ // Sibling anyOf + oneOf on one node: both are flattened and neither raw union
+ // JSON leaks into the description (paths are processed oneOf-first, so the
+ // oneOf branch set wins the single remaining node).
+ siblingClaude := gjson.Parse(CleanJSONSchemaForAntigravityClaudeTool(siblingUnions, false))
+ siblingValue := siblingClaude.Get("properties.value")
+ if siblingValue.Get("anyOf").Exists() || siblingValue.Get("oneOf").Exists() {
+ t.Fatalf("Antigravity Claude sibling union survived cleaning: %s", siblingClaude.Raw)
+ }
+ compareJSON(t, `{"type":"string","description":"Accepts: string | boolean"}`, siblingValue.Raw)
+ compareJSON(t, siblingClaude.Raw, CleanJSONSchemaForAntigravityClaudeTool(siblingClaude.Raw, false))
+ })
+
+ // Null-only unions (anyOf:[null], oneOf:[null], anyOf:[false, null]) must not panic across any cleaner.
+ nullOnlyCleaners := map[string]func(string) string{
+ "Antigravity": CleanJSONSchemaForAntigravity,
+ "AntigravityTool": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) },
+ "AntigravityClaudeTool": func(s string) string { return CleanJSONSchemaForAntigravityClaudeTool(s, false) },
+ "AntigravityResponse": CleanJSONSchemaForAntigravityResponse,
+ "Gemini": CleanJSONSchemaForGemini,
+ "GeminiJSONSchema": CleanJSONSchemaForGeminiJSONSchema,
+ }
+ for _, nullOnlyInput := range []string{
+ `{"type":"object","properties":{"value":{"anyOf":[{"type":"null"}]}}}`,
+ `{"type":"object","properties":{"value":{"oneOf":[{"type":"null"},{"type":"null"}]}}}`,
+ `{"type":"object","properties":{"value":{"anyOf":[false,{"type":"null"}],"description":"Null only"}}}`,
+ } {
+ for name, clean := range nullOnlyCleaners {
+ got := clean(nullOnlyInput)
+ if !gjson.Valid(got) {
+ t.Fatalf("[%s] invalid JSON for null-only union %s: %s", name, nullOnlyInput, got)
+ }
+ if gjson.Get(got, "properties.value.type").String() != "null" {
+ t.Fatalf("[%s] expected properties.value.type = null, got: %s", name, got)
+ }
+ }
+ }
+ })
+}
+
+// TestCleanJSONSchema_RemovesDiscriminatorKeyword_Issue6639 covers Issue #6639:
+// OpenAPI/JSON Schema keyword "discriminator" alongside preserved anyOf/oneOf unions
+// (both directly on a property schema and inside array items) or on object schemas
+// is rejected by the Gemini/Antigravity backend with 400 INVALID_ARGUMENT
+// ("Unknown name \"discriminator\" ... Cannot find field") and must be stripped while
+// preserving legitimate tool properties named "discriminator".
+func TestCleanJSONSchema_RemovesDiscriminatorKeyword_Issue6639(t *testing.T) {
+ input := `{
+ "type": "object",
+ "properties": {
+ "target": {
+ "anyOf": [
+ {"type": "object", "properties": {"kind": {"const": "a"}, "url": {"type": "string"}}},
+ {"type": "object", "properties": {"kind": {"const": "b"}, "path": {"type": "string"}}}
+ ],
+ "discriminator": {"propertyName": "kind"}
+ },
+ "deliverables": {
+ "type": "array",
+ "items": {
+ "anyOf": [
+ {"type": "object", "properties": {"kind": {"const": "file"}, "path": {"type": "string"}}},
+ {"type": "object", "properties": {"kind": {"const": "link"}, "url": {"type": "string"}}}
+ ],
+ "discriminator": {"propertyName": "kind"}
+ }
+ },
+ "polymorphicObject": {
+ "type": "object",
+ "properties": {
+ "kind": {"type": "string"}
+ },
+ "oneOf": [
+ {"properties": {"a": {"type": "string"}}},
+ {"properties": {"b": {"type": "number"}}}
+ ],
+ "discriminator": {
+ "propertyName": "kind",
+ "mapping": {"a": "#/definitions/A", "b": "#/definitions/B"}
+ }
+ },
+ "discriminator": {
+ "type": "string",
+ "description": "Legitimate property named discriminator"
+ },
+ "nestedDiscriminatorProp": {
+ "type": "object",
+ "properties": {
+ "discriminator": {
+ "type": "object",
+ "properties": {
+ "kind": {"type": "string"}
+ },
+ "discriminator": {"propertyName": "kind"}
+ }
+ }
+ }
+ },
+ "discriminator": {"propertyName": "target"}
+ }`
+
+ cleaners := map[string]func(string) string{
+ "Antigravity": CleanJSONSchemaForAntigravity,
+ "AntigravityTool": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) },
+ "AntigravityClaudeTool": func(s string) string { return CleanJSONSchemaForAntigravityClaudeTool(s, false) },
+ "AntigravityResponse": CleanJSONSchemaForAntigravityResponse,
+ "Gemini": CleanJSONSchemaForGemini,
+ "GeminiJSONSchema": CleanJSONSchemaForGeminiJSONSchema,
+ }
+
+ for name, clean := range cleaners {
+ t.Run(name, func(t *testing.T) {
+ got := clean(input)
+ parsed := gjson.Parse(got)
+
+ for _, forbiddenPath := range []string{
+ "discriminator",
+ "properties.target.discriminator",
+ "properties.deliverables.items.discriminator",
+ "properties.polymorphicObject.discriminator",
+ "properties.nestedDiscriminatorProp.properties.discriminator.discriminator",
+ } {
+ if parsed.Get(forbiddenPath).Exists() {
+ t.Errorf("[%s] %s was not removed: %s", name, forbiddenPath, got)
+ }
+ }
+
+ if name != "AntigravityClaudeTool" {
+ if gotBranches := len(parsed.Get("properties.target.anyOf").Array()); gotBranches != 2 {
+ t.Errorf("[%s] properties.target.anyOf branch count = %d, want 2: %s", name, gotBranches, got)
+ }
+ if gotBranches := len(parsed.Get("properties.deliverables.items.anyOf").Array()); gotBranches != 2 {
+ t.Errorf("[%s] properties.deliverables.items.anyOf branch count = %d, want 2: %s", name, gotBranches, got)
+ }
+ }
+
+ if !parsed.Get("properties.discriminator").Exists() {
+ t.Errorf("[%s] legitimate property \"discriminator\" was removed: %s", name, got)
+ }
+ if !parsed.Get("properties.nestedDiscriminatorProp.properties.discriminator.properties.kind").Exists() {
+ t.Errorf("[%s] legitimate nested property \"discriminator\" was removed: %s", name, got)
+ }
+ })
+ }
+}
+
+// TestCleanJSONSchema_RemovesContentMediaTypeKeyword_Issue6699 covers Issue #6699:
+// JSON Schema Content Vocabulary annotations ("contentMediaType" and "contentEncoding")
+// inside nested anyOf/oneOf branches, on property schemas, or at the schema root are
+// rejected by the Gemini/Antigravity backend with 400 INVALID_ARGUMENT
+// ("Unknown name \"contentMediaType\" ... Cannot find field") and must be stripped
+// while preserving the anyOf branches and any legitimate property named
+// "contentMediaType" or "contentEncoding".
+func TestCleanJSONSchema_RemovesContentMediaTypeKeyword_Issue6699(t *testing.T) {
+ input := `{
+ "type": "object",
+ "contentMediaType": "application/json",
+ "contentEncoding": "utf-8",
+ "properties": {
+ "value": {
+ "anyOf": [
+ { "type": "object", "properties": {} },
+ { "type": "string", "contentMediaType": "application/json", "contentEncoding": "base64" }
+ ]
+ },
+ "directPayload": {
+ "type": "string",
+ "contentMediaType": "application/json",
+ "contentEncoding": "base64"
+ },
+ "contentMediaType": {
+ "type": "string",
+ "contentMediaType": "text/plain",
+ "description": "Legitimate property named contentMediaType"
+ },
+ "contentEncoding": {
+ "type": "string",
+ "contentEncoding": "base64",
+ "description": "Legitimate property named contentEncoding"
+ }
+ }
+ }`
+
+ cleaners := map[string]func(string) string{
+ "Antigravity": CleanJSONSchemaForAntigravity,
+ "AntigravityTool": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) },
+ "AntigravityClaudeTool": func(s string) string { return CleanJSONSchemaForAntigravityClaudeTool(s, false) },
+ "AntigravityResponse": CleanJSONSchemaForAntigravityResponse,
+ "Gemini": CleanJSONSchemaForGemini,
+ "GeminiJSONSchema": CleanJSONSchemaForGeminiJSONSchema,
+ }
+
+ for name, clean := range cleaners {
+ t.Run(name, func(t *testing.T) {
+ got := clean(input)
+ parsed := gjson.Parse(got)
+
+ for _, forbiddenPath := range []string{
+ "contentMediaType",
+ "contentEncoding",
+ "properties.value.anyOf.1.contentMediaType",
+ "properties.value.anyOf.1.contentEncoding",
+ "properties.directPayload.contentMediaType",
+ "properties.directPayload.contentEncoding",
+ "properties.contentMediaType.contentMediaType",
+ "properties.contentEncoding.contentEncoding",
+ } {
+ if parsed.Get(forbiddenPath).Exists() {
+ t.Errorf("[%s] %s was not removed: %s", name, forbiddenPath, got)
+ }
+ }
+
+ if name != "AntigravityClaudeTool" {
+ if gotBranches := len(parsed.Get("properties.value.anyOf").Array()); gotBranches != 2 {
+ t.Errorf("[%s] properties.value.anyOf branch count = %d, want 2: %s", name, gotBranches, got)
+ }
+ if gotType := parsed.Get("properties.value.anyOf.1.type").String(); gotType != "string" {
+ t.Errorf("[%s] properties.value.anyOf.1.type = %q, want \"string\": %s", name, gotType, got)
+ }
+ }
+
+ if !parsed.Get("properties.contentMediaType").Exists() {
+ t.Errorf("[%s] legitimate property \"contentMediaType\" was removed: %s", name, got)
+ }
+ if !parsed.Get("properties.contentEncoding").Exists() {
+ t.Errorf("[%s] legitimate property \"contentEncoding\" was removed: %s", name, got)
+ }
+ })
+ }
+}
+
+// TestIssue6732_GeminiCleanersInlineLocalDefs verifies that the Gemini cleaners expand local
+// $defs references before the definition container is stripped, so nested fields survive.
+func TestIssue6732_GeminiCleanersInlineLocalDefs(t *testing.T) {
+ input := `{"type":"object","properties":{"person":{"$ref":"#/$defs/Person"}},"required":["person"],"$defs":{"Person":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}}}`
+
+ for name, clean := range map[string]func(string) string{
+ "gemini": CleanJSONSchemaForGemini,
+ "geminiJSONSchema": CleanJSONSchemaForGeminiJSONSchema,
+ } {
+ got := clean(input)
+ parsed := gjson.Parse(got)
+ if gotType := parsed.Get("properties.person.properties.name.type").String(); gotType != "string" {
+ t.Errorf("%s: inlined properties.person.properties.name.type = %q, want %q: %s", name, gotType, "string", got)
+ }
+ if parsed.Get("$defs").Exists() {
+ t.Errorf("%s: $defs should be removed after inlining: %s", name, got)
+ }
+ if strings.Contains(got, "See: Person") {
+ t.Errorf("%s: resolvable local ref degraded to a hint: %s", name, got)
+ }
+ }
+}
+
+// TestIssue6732_GeminiCleanersInlinePropertyReuse verifies that a Zod-style #/properties/...
+// reference is expanded by the Gemini cleaners instead of becoming a description hint.
+func TestIssue6732_GeminiCleanersInlinePropertyReuse(t *testing.T) {
+ input := `{"type":"object","properties":{"billing":{"type":"object","properties":{"zip":{"type":"string"}}},"shipping":{"$ref":"#/properties/billing"}}}`
+
+ for name, clean := range map[string]func(string) string{
+ "gemini": CleanJSONSchemaForGemini,
+ "geminiJSONSchema": CleanJSONSchemaForGeminiJSONSchema,
+ } {
+ got := clean(input)
+ parsed := gjson.Parse(got)
+ if gotType := parsed.Get("properties.shipping.properties.zip.type").String(); gotType != "string" {
+ t.Errorf("%s: properties.shipping.properties.zip.type = %q, want %q: %s", name, gotType, "string", got)
+ }
+ }
+}
+
+// TestIssue6732_GeminiCleanersReuseSameDefinitionTwice verifies that two properties referencing
+// the same definition both keep the referenced fields; bounding expansion must not degrade
+// ordinary reuse of a shared definition to a hint.
+func TestIssue6732_GeminiCleanersReuseSameDefinitionTwice(t *testing.T) {
+ input := `{"type":"object","properties":{"billing":{"$ref":"#/$defs/Address"},"shipping":{"$ref":"#/$defs/Address"}},"$defs":{"Address":{"type":"object","properties":{"zip":{"type":"string"}}}}}`
+
+ for name, clean := range map[string]func(string) string{
+ "gemini": CleanJSONSchemaForGemini,
+ "geminiJSONSchema": CleanJSONSchemaForGeminiJSONSchema,
+ } {
+ got := clean(input)
+ parsed := gjson.Parse(got)
+ for _, field := range []string{"billing", "shipping"} {
+ if gotType := parsed.Get("properties." + field + ".properties.zip.type").String(); gotType != "string" {
+ t.Errorf("%s: properties.%s.properties.zip.type = %q, want %q: %s", name, field, gotType, "string", got)
+ }
+ }
+ }
+}
+
+// TestIssue6732_WrappedSchemaResolvesAgainstInnerRoot verifies that a schema wrapped in the
+// single-key {"schema": ...} carrier used by the Antigravity executor resolves local refs
+// against the inner schema rather than the carrier.
+func TestIssue6732_WrappedSchemaResolvesAgainstInnerRoot(t *testing.T) {
+ inner := `{"type":"object","properties":{"person":{"$ref":"#/$defs/Person"}},"required":["person"],"$defs":{"Person":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}}}`
+ wrapped := `{"schema":` + inner + `}`
+
+ got := CleanJSONSchemaForAntigravityTool(wrapped, false)
+ parsed := gjson.Parse(got)
+ if gotType := parsed.Get("schema.properties.person.properties.name.type").String(); gotType != "string" {
+ t.Errorf("wrapped schema.properties.person.properties.name.type = %q, want %q: %s", gotType, "string", got)
+ }
+ if parsed.Get("schema.$defs").Exists() {
+ t.Errorf("wrapped $defs should be removed after inlining: %s", got)
+ }
+ if strings.Contains(got, "See: Person") {
+ t.Errorf("wrapped resolvable local ref degraded to a hint: %s", got)
+ }
+}
+
+// TestIssue6732_DefaultLiteralPreservedOnJSONSchemaRoute verifies that the parametersJsonSchema
+// route forwards default values verbatim even when they contain schema-looking keys.
+func TestIssue6732_DefaultLiteralPreservedOnJSONSchemaRoute(t *testing.T) {
+ input := `{"type":"object","properties":{"report":{"type":"object","default":{"title":"Report","format":"pdf","id":7,"x-owner":"ops"}}}}`
+
+ got := CleanJSONSchemaForGeminiJSONSchema(input)
+ def := gjson.Parse(got).Get("properties.report.default")
+ if !def.Exists() {
+ t.Fatalf("default literal missing: %s", got)
+ }
+ if def.Get("title").String() != "Report" || def.Get("format").String() != "pdf" || def.Get("id").Int() != 7 || def.Get("x-owner").String() != "ops" {
+ t.Errorf("default literal was rewritten, got %s: %s", def.Raw, got)
+ }
+}
+
+// TestIssue6732_ExampleLiteralsPreserved verifies that example/examples values keep
+// schema-looking keys on the parametersJsonSchema route.
+func TestIssue6732_ExampleLiteralsPreserved(t *testing.T) {
+ input := `{"type":"object","properties":{"a":{"type":"string","example":{"title":"Hi","x-owner":"ops","id":3},"examples":[{"title":"Hi2","x-owner":"ops","id":4}]}}}`
+
+ got := CleanJSONSchemaForGeminiJSONSchema(input)
+ parsed := gjson.Parse(got)
+ example := parsed.Get("properties.a.example")
+ if example.Get("title").String() != "Hi" || example.Get("x-owner").String() != "ops" || example.Get("id").Int() != 3 {
+ t.Errorf("example literal was rewritten, got %s: %s", example.Raw, got)
+ }
+ exampleItem := parsed.Get("properties.a.examples.0")
+ if exampleItem.Get("title").String() != "Hi2" || exampleItem.Get("x-owner").String() != "ops" || exampleItem.Get("id").Int() != 4 {
+ t.Errorf("examples literal was rewritten, got %s: %s", exampleItem.Raw, got)
+ }
+}
+
+// TestIssue6732_InlineLocalRefsLeavesLiteralRefs verifies that the inliner does not resolve
+// {"$ref": ...} objects that appear as client data inside a default value.
+func TestIssue6732_InlineLocalRefsLeavesLiteralRefs(t *testing.T) {
+ input := `{"type":"object","properties":{"a":{"type":"object","default":{"$ref":"#/$defs/Person"}}},"$defs":{"Person":{"type":"object","properties":{"name":{"type":"string"}}}}}`
+
+ got := InlineLocalRefs(input)
+ parsed := gjson.Parse(got)
+ if ref := parsed.Get("properties.a.default.$ref").String(); ref != "#/$defs/Person" {
+ t.Errorf("literal $ref inside default = %q, want it preserved verbatim: %s", ref, got)
+ }
+ if parsed.Get("properties.a.default.properties").Exists() {
+ t.Errorf("literal $ref inside default was expanded: %s", got)
+ }
+ if !parsed.Get("$defs.Person.properties.name").Exists() {
+ t.Errorf("InlineLocalRefs must not strip definitions: %s", got)
+ }
+}
+
+// TestIssue6732_PropertiesNamedDefaultAndTypeRemainSchemas verifies that properties literally
+// named "default" and "type" keep their schema semantics: their own keywords are still cleaned
+// and their own $ref is still inlined.
+func TestIssue6732_PropertiesNamedDefaultAndTypeRemainSchemas(t *testing.T) {
+ input := `{"type":"object","properties":{"default":{"$ref":"#/$defs/Person"},"type":{"type":"string","title":"Kind"}},"$defs":{"Person":{"type":"object","properties":{"name":{"type":"string"}}}}}`
+
+ got := CleanJSONSchemaForGeminiJSONSchema(input)
+ parsed := gjson.Parse(got)
+ if gotType := parsed.Get("properties.default.properties.name.type").String(); gotType != "string" {
+ t.Errorf("property named \"default\" ref was not inlined, name type = %q: %s", gotType, got)
+ }
+ if parsed.Get("properties.type.title").Exists() {
+ t.Errorf("property named \"type\" schema title should still be cleaned: %s", got)
+ }
+}
+
+// TestIssue6732_ExpansionBudgetBoundsSharedDefs verifies that inlining a schema whose shared
+// definitions fan out exponentially stays bounded instead of expanding without limit, while the
+// affordable outer levels are still expanded.
+func TestIssue6732_ExpansionBudgetBoundsSharedDefs(t *testing.T) {
+ input := geminiSchemaDiamondInput()
+ got := CleanJSONSchemaForGeminiJSONSchema(input)
+ if len(got) >= 512*1024 {
+ t.Errorf("diamond schema output = %d bytes, want < %d (input %d bytes)", len(got), 512*1024, len(input))
+ }
+ parsed := gjson.Parse(got)
+ if !parsed.Get("properties.leaf").Exists() {
+ t.Errorf("diamond schema lost the top-level property: %s", got)
+ }
+ if !parsed.Get("properties.leaf.properties.a.properties.a").Exists() {
+ t.Errorf("diamond schema outer levels should still be expanded: %s", got)
+ }
+ if parsed.Get("$defs").Exists() {
+ t.Errorf("diamond schema should not retain $defs: %s", got)
+ }
+}
+
+// TestIssue6732_UnusedDefinitionsDoNotConsumeExpansionBudget verifies that definitions the
+// cleaners are about to delete anyway neither consume the expansion budget nor make the kept
+// fields depend on map iteration order.
+func TestIssue6732_UnusedDefinitionsDoNotConsumeExpansionBudget(t *testing.T) {
+ var sb strings.Builder
+ sb.WriteString(`{"type":"object","properties":{"person":{"$ref":"#/$defs/Person"}},"required":["person"],"$defs":{"leaf0":{"type":"string","description":"leaf leaf leaf leaf leaf leaf leaf leaf leaf leaf"},"Person":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}`)
+ for i := range 13 {
+ target := "leaf0"
+ if i > 0 {
+ target = "D" + strconv.Itoa(i-1)
+ }
+ sb.WriteString(`,"D` + strconv.Itoa(i) + `":{"type":"object","properties":{"a":{"$ref":"#/$defs/` + target + `"},"b":{"$ref":"#/$defs/` + target + `"}}}`)
+ }
+ sb.WriteString(`}}`)
+ input := sb.String()
+
+ first := CleanJSONSchemaForGeminiJSONSchema(input)
+ for run := range 30 {
+ got := CleanJSONSchemaForGeminiJSONSchema(input)
+ if got != first {
+ t.Fatalf("run %d: output is not deterministic:\nfirst: %s\ngot: %s", run, first, got)
+ }
+ parsed := gjson.Parse(got)
+ if gotType := parsed.Get("properties.person.properties.name.type").String(); gotType != "string" {
+ t.Fatalf("run %d: used definition was not inlined, name type = %q: %s", run, gotType, got)
+ }
+ if parsed.Get("$defs").Exists() {
+ t.Fatalf("run %d: $defs should be removed: %s", run, got)
+ }
+ }
+}
+
+// TestIssue6732_BudgetFallbackDoesNotCopyLargeDescriptions verifies that references degraded by
+// the expansion budget do not each copy a large target description, which would defeat the bound.
+func TestIssue6732_BudgetFallbackDoesNotCopyLargeDescriptions(t *testing.T) {
+ var sb strings.Builder
+ sb.WriteString(`{"type":"object","properties":{`)
+ for i := range 200 {
+ if i > 0 {
+ sb.WriteByte(',')
+ }
+ sb.WriteString(`"p` + strconv.Itoa(i) + `":{"$ref":"#/$defs/Big"}`)
+ }
+ sb.WriteString(`},"$defs":{"Big":{"type":"object","description":"` + strings.Repeat("x", 32*1024) + `","properties":{"name":{"type":"string"}}}}}`)
+
+ got := CleanJSONSchemaForGeminiJSONSchema(sb.String())
+ if len(got) >= 512*1024 {
+ t.Errorf("shared large-description output = %d bytes, want < %d", len(got), 512*1024)
+ }
+ parsed := gjson.Parse(got)
+ if !parsed.Get("properties.p0").Exists() || !parsed.Get("properties.p199").Exists() {
+ t.Errorf("shared large-description schema lost properties: %s", got)
+ }
+ if gotType := parsed.Get("properties.p0.properties.name.type").String(); gotType != "string" {
+ t.Errorf("first affordable expansion should keep its fields, name type = %q: %s", gotType, got)
+ }
+ if parsed.Get("$defs").Exists() {
+ t.Errorf("$defs should be removed: %s", got)
+ }
+}
+
+// TestIssue6732_NestedUnusedDefinitionsGraphDoesNotConsumeBudget verifies that a self-contained
+// reference graph inside a nested definitions container (which cleaning deletes at every level)
+// neither spends the expansion budget nor starves kept fields.
+func TestIssue6732_NestedUnusedDefinitionsGraphDoesNotConsumeBudget(t *testing.T) {
+ var sb strings.Builder
+ sb.WriteString(`{"type":"object","properties":{"person":{"$ref":"#/$defs/Person"},"a":{"type":"object","$defs":{"Leaf":{"type":"string","description":"` + strings.Repeat("x", 1024) + `"}`)
+ for i := range 256 {
+ sb.WriteString(`,"U` + strconv.Itoa(i) + `":{"$ref":"#/properties/a/$defs/Leaf"}`)
+ }
+ sb.WriteString(`}}},"required":["person"],"$defs":{"Person":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}}}`)
+
+ got := CleanJSONSchemaForGeminiJSONSchema(sb.String())
+ parsed := gjson.Parse(got)
+ if gotType := parsed.Get("properties.person.properties.name.type").String(); gotType != "string" {
+ t.Errorf("nested unused definitions starved the kept field, name type = %q: %s", gotType, got)
+ }
+ if parsed.Get("properties.a.$defs").Exists() || parsed.Get("$defs").Exists() {
+ t.Errorf("nested and root $defs should be removed: %s", got)
+ }
+}
+
+// TestIssue6732_RefSiblingNameMapReferencesStillExpand verifies that references inside a $ref
+// node's own name-map siblings expand when the reference target itself expands.
+func TestIssue6732_RefSiblingNameMapReferencesStillExpand(t *testing.T) {
+ input := `{"type":"object","properties":{"a":{"$ref":"#/$defs/Base","properties":{"x":{"$ref":"#/$defs/S"}}}},"$defs":{"Base":{"type":"object"},"S":{"type":"string"}}}`
+
+ got := CleanJSONSchemaForGeminiJSONSchema(input)
+ parsed := gjson.Parse(got)
+ if gotType := parsed.Get("properties.a.properties.x.type").String(); gotType != "string" {
+ t.Errorf("ref sibling name-map reference lost its type, got %q: %s", gotType, got)
+ }
+}
+
+// TestIssue6732_CleanInliningStripsDefinitionsFromIntermediate verifies that the cleaning
+// inliner's intermediate output does not serialize definition containers (root or carried inside
+// expanded targets), so repeated references to a target holding a huge unused definitions block
+// cannot multiply that block in the intermediate JSON.
+func TestIssue6732_CleanInliningStripsDefinitionsFromIntermediate(t *testing.T) {
+ person := `{"type":"object","properties":{"name":{"type":"string"}},"$defs":{"Unused":{"type":"string","description":"` + strings.Repeat("x", 280*1024) + `"}}}`
+ input := `{"type":"object","properties":{"p0":{"$ref":"#/$defs/Person"},"p1":{"$ref":"#/$defs/Person"}},"$defs":{"Person":` + person + `}}`
+
+ got := inlineLocalRefsForClean(input)
+ if len(got) >= 64*1024 {
+ t.Errorf("clean inlining intermediate = %d bytes, want < %d", len(got), 64*1024)
+ }
+ parsed := gjson.Parse(got)
+ if parsed.Get("$defs").Exists() {
+ t.Errorf("intermediate should not carry root $defs: %s", got)
+ }
+ if parsed.Get("properties.p0.$defs").Exists() {
+ t.Errorf("intermediate should not carry target-nested $defs: %s", got)
+ }
+ if gotType := parsed.Get("properties.p1.properties.name.type").String(); gotType != "string" {
+ t.Errorf("second expansion lost its fields, name type = %q: %s", gotType, got)
+ }
+}
+
+// TestIssue6732_ArrayRefWithExplicitTypeKeepsTargetItems verifies that the placeholder items
+// Phase 0 adds to a typed but unresolved $ref node does not clobber the items of the referenced
+// definition during inlining.
+func TestIssue6732_ArrayRefWithExplicitTypeKeepsTargetItems(t *testing.T) {
+ input := `{"type":"object","properties":{"rows":{"type":"array","$ref":"#/$defs/Rows"}},"$defs":{"Rows":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"}}}}}}`
+
+ got := CleanJSONSchemaForGeminiJSONSchema(input)
+ parsed := gjson.Parse(got)
+ if gotType := parsed.Get("properties.rows.items.properties.name.type").String(); gotType != "string" {
+ t.Errorf("array ref target items were clobbered, name type = %q: %s", gotType, got)
+ }
+}
+
+// TestIssue6732_BudgetFallbackStillResolvesSiblings verifies that when a reference target is too
+// large to expand, sibling subschemas on the same node are still resolved instead of being copied
+// verbatim with their own references left dangling.
+func TestIssue6732_BudgetFallbackStillResolvesSiblings(t *testing.T) {
+ input := `{"type":"object","properties":{"a":{"$ref":"#/$defs/Big","properties":{"x":{"$ref":"#/$defs/S"}}}},"$defs":{"Big":{"type":"object","description":"` + strings.Repeat("x", 280*1024) + `"},"S":{"type":"string"}}}`
+
+ got := CleanJSONSchemaForGeminiJSONSchema(input)
+ parsed := gjson.Parse(got)
+ if gotType := parsed.Get("properties.a.properties.x.type").String(); gotType != "string" {
+ t.Errorf("sibling reference after budget fallback lost its type, got %q: %s", gotType, got)
+ }
+ if strings.Contains(got, `"$ref"`) {
+ t.Errorf("cleaned schema left a dangling reference: %s", got)
+ }
+}
+
+// TestIssue6732_InlineLocalRefsDefinitionNamesKeepSchemaSemantics verifies that definitions whose
+// names look like schema keywords are treated as schemas by the public inliner: a definition named
+// "properties" keeps its literal default intact, and a definition named "default" still expands.
+func TestIssue6732_InlineLocalRefsDefinitionNamesKeepSchemaSemantics(t *testing.T) {
+ input := `{"$defs":{"S":{"type":"string"},"properties":{"type":"object","default":{"$ref":"#/$defs/S"}},"default":{"type":"object","properties":{"x":{"$ref":"#/$defs/S"}}}}}`
+
+ got := InlineLocalRefs(input)
+ parsed := gjson.Parse(got)
+ if ref := parsed.Get("$defs.properties.default.$ref").String(); ref != "#/$defs/S" {
+ t.Errorf("literal default inside definition named \"properties\" was expanded, $ref = %q: %s", ref, got)
+ }
+ if gotType := parsed.Get("$defs.default.properties.x.type").String(); gotType != "string" {
+ t.Errorf("definition named \"default\" was not resolved as a schema, x type = %q: %s", gotType, got)
+ }
+}
+
+// TestIssue6732_TargetNestedDefinitionsExemptFromBudget verifies that unused definitions nested
+// inside a referenced target (which cleaning deletes) do not inflate the target's budget charge.
+func TestIssue6732_TargetNestedDefinitionsExemptFromBudget(t *testing.T) {
+ person := `{"type":"object","properties":{"name":{"type":"string"}},"$defs":{"Unused":{"type":"string","description":"` + strings.Repeat("x", 280*1024) + `"}}}`
+ cases := map[string]string{
+ "propertyRef": `{"type":"object","properties":{"person":{"$ref":"#/$defs/Person"}},"$defs":{"Person":` + person + `}}`,
+ "rootRef": `{"$ref":"#/$defs/Person","$defs":{"Person":` + person + `}}`,
+ }
+ for caseName, input := range cases {
+ got := CleanJSONSchemaForGeminiJSONSchema(input)
+ parsed := gjson.Parse(got)
+ namePath := "properties.person.properties.name.type"
+ if caseName == "rootRef" {
+ namePath = "properties.name.type"
+ }
+ if gotType := parsed.Get(namePath).String(); gotType != "string" {
+ t.Errorf("%s: %s = %q, want %q: %s", caseName, namePath, gotType, "string", got)
+ }
+ if parsed.Get("$defs").Exists() {
+ t.Errorf("%s: $defs should be removed: %s", caseName, got)
+ }
+ }
+}
+
+// TestIssue6732_ArrayRefWithoutTargetItemsGetsPlaceholder verifies that a typed array whose
+// referenced definition carries no items still ends up with the placeholder items Phase 0 would
+// have added, and that cleaning twice is stable.
+func TestIssue6732_ArrayRefWithoutTargetItemsGetsPlaceholder(t *testing.T) {
+ input := `{"type":"object","properties":{"rows":{"type":"array","$ref":"#/$defs/Common"}},"$defs":{"Common":{"description":"Rows"}}}`
+
+ first := CleanJSONSchemaForAntigravityTool(input, false)
+ parsed := gjson.Parse(first)
+ if gotType := parsed.Get("properties.rows.items.type").String(); gotType != "string" {
+ t.Errorf("array ref without target items missing placeholder items, got %q: %s", gotType, first)
+ }
+ if second := CleanJSONSchemaForAntigravityTool(first, false); second != first {
+ t.Errorf("cleaning is not idempotent for array refs:\nfirst: %s\nsecond: %s", first, second)
+ }
+}
+
+// TestIssue6732_SmallTargetFanOutStaysBounded verifies that thousands of references to a small
+// target with a near-limit description cannot multiply the description without bound through a
+// mix of expansions and fallback copies.
+func TestIssue6732_SmallTargetFanOutStaysBounded(t *testing.T) {
+ var sb strings.Builder
+ sb.WriteString(`{"type":"object","properties":{`)
+ for i := range 2000 {
+ if i > 0 {
+ sb.WriteByte(',')
+ }
+ sb.WriteString(`"p` + strconv.Itoa(i) + `":{"$ref":"#/$defs/S"}`)
+ }
+ sb.WriteString(`},"$defs":{"S":{"type":"string","description":"` + strings.Repeat("x", 512) + `"}}}`)
+
+ got := CleanJSONSchemaForGeminiJSONSchema(sb.String())
+ if len(got) >= 512*1024 {
+ t.Errorf("small-target fan-out output = %d bytes, want < %d", len(got), 512*1024)
+ }
+}
+
+// TestIssue6732_EscapedDescriptionFanOutStaysBounded verifies that budget accounting covers
+// HTML-escaped string growth during JSON encoding.
+func TestIssue6732_EscapedDescriptionFanOutStaysBounded(t *testing.T) {
+ input := `{"type":"object","properties":{"p0":{"$ref":"#/$defs/Big"},"p1":{"$ref":"#/$defs/Big"},"p2":{"$ref":"#/$defs/Big"},"p3":{"$ref":"#/$defs/Big"},"p4":{"$ref":"#/$defs/Big"},"p5":{"$ref":"#/$defs/Big"},"p6":{"$ref":"#/$defs/Big"},"p7":{"$ref":"#/$defs/Big"}},"$defs":{"Big":{"type":"object","description":"` + strings.Repeat("<", 32*1024) + `","properties":{"name":{"type":"string"}}}}}`
+
+ got := CleanJSONSchemaForGeminiJSONSchema(input)
+ if len(got) >= 512*1024 {
+ t.Errorf("escaped-description fan-out output = %d bytes, want < %d", len(got), 512*1024)
+ }
+}
+
+// TestIssue6732_DependenciesNameMapKeepsSchemaSemantics verifies that the Draft-07 dependencies
+// container behaves as a name map: a dependency keyed "default" holds a schema whose references
+// still expand in the public inliner.
+func TestIssue6732_DependenciesNameMapKeepsSchemaSemantics(t *testing.T) {
+ input := `{"type":"object","dependencies":{"default":{"properties":{"x":{"$ref":"#/definitions/S"}}}},"definitions":{"S":{"type":"string"}}}`
+
+ got := InlineLocalRefs(input)
+ parsed := gjson.Parse(got)
+ if gotType := parsed.Get("dependencies.default.properties.x.type").String(); gotType != "string" {
+ t.Errorf("dependency schema named \"default\" was not resolved, x type = %q: %s", gotType, got)
+ }
+ if parsed.Get("dependencies.default.properties.x.$ref").Exists() {
+ t.Errorf("dependency schema reference was left dangling: %s", got)
+ }
+}
+
+// TestIssue6732_OmittedFallbackDescriptionNotCharged verifies that a target description the
+// fallback omits (because it exceeds the copy limit) is not charged to the expansion budget, so
+// a following small reference still expands.
+func TestIssue6732_OmittedFallbackDescriptionNotCharged(t *testing.T) {
+ input := `{"type":"object","properties":{"a":{"$ref":"#/$defs/A"},"b":{"$ref":"#/$defs/B"}},"$defs":{"A":{"type":"string","description":"` + strings.Repeat("x", 262142) + `"},"B":{"type":"object","properties":{"name":{"type":"string"}}}}}`
+
+ got := CleanJSONSchemaForGeminiJSONSchema(input)
+ parsed := gjson.Parse(got)
+ if gotType := parsed.Get("properties.b.properties.name.type").String(); gotType != "string" {
+ t.Errorf("small reference after omitted fallback description lost its fields, name type = %q: %s", gotType, got)
+ }
+}
+
+// TestIssue6732_SynthesizedTypeDoesNotOverrideRefTarget verifies that the type Phase 0 would
+// infer from a ref node's sibling items does not clobber the referenced definition's object
+// type, and that cleaning the result again is stable.
+func TestIssue6732_SynthesizedTypeDoesNotOverrideRefTarget(t *testing.T) {
+ input := `{"$ref":"#/$defs/O","items":{"type":"integer"},"$defs":{"O":{"type":"object","properties":{"x":{"type":"string"}}}}}`
+
+ first := CleanJSONSchemaForGeminiJSONSchema(input)
+ parsed := gjson.Parse(first)
+ if gotType := parsed.Get("type").String(); gotType != "object" {
+ t.Errorf("root ref target type = %q, want %q: %s", gotType, "object", first)
+ }
+ if parsed.Get("items").Exists() {
+ t.Errorf("object schema should not carry items: %s", first)
+ }
+ if gotType := parsed.Get("properties.x.type").String(); gotType != "string" {
+ t.Errorf("target property lost its type, got %q: %s", gotType, first)
+ }
+ if second := CleanJSONSchemaForGeminiJSONSchema(first); second != first {
+ t.Errorf("cleaning is not idempotent:\nfirst: %s\nsecond: %s", first, second)
+ }
+}
+
+// TestIssue6732_PropertyNamedRefRemainsProperty verifies that a property literally named "$ref"
+// is treated as a property, not as a reference keyword, by the cleaner.
+func TestIssue6732_PropertyNamedRefRemainsProperty(t *testing.T) {
+ input := `{"type":"object","properties":{"$ref":{"type":"string"}}}`
+
+ got := CleanJSONSchemaForGeminiJSONSchema(input)
+ parsed := gjson.Parse(got)
+ if gotType := parsed.Get(`properties.\$ref.type`).String(); gotType != "string" {
+ t.Errorf("property named $ref lost its schema, type = %q: %s", gotType, got)
+ }
+ if strings.Contains(got, "See:") {
+ t.Errorf("property named $ref was converted into a hint: %s", got)
+ }
+}
+
+// TestIssue6732_PropertyNamedConstRemainsProperty verifies that a property literally named
+// "const" holding a schema object is not converted into an enum constraint on its parent.
+func TestIssue6732_PropertyNamedConstRemainsProperty(t *testing.T) {
+ input := `{"type":"object","properties":{"const":{"type":"string"}}}`
+
+ got := CleanJSONSchemaForGeminiJSONSchema(input)
+ parsed := gjson.Parse(got)
+ if gotType := parsed.Get(`properties.const.type`).String(); gotType != "string" {
+ t.Errorf("property named const lost its schema, type = %q: %s", gotType, got)
+ }
+ if parsed.Get("enum").Exists() {
+ t.Errorf("property named const fabricates a root enum: %s", got)
+ }
+}
+
+// geminiSchemaDiamondInput builds a schema referencing D12, where each Di is an object with two
+// properties referencing Di-1 and D0 references a leaf definition twice. Fully expanding it
+// produces 2^13 leaf copies.
+func geminiSchemaDiamondInput() string {
+ var sb strings.Builder
+ sb.WriteString(`{"type":"object","properties":{"leaf":{"$ref":"#/$defs/D12"}},"$defs":{"leaf0":{"type":"string","description":"leaf leaf leaf leaf leaf leaf leaf leaf leaf leaf"}`)
+ for i := range 13 {
+ target := "leaf0"
+ if i > 0 {
+ target = "D" + strconv.Itoa(i-1)
+ }
+ sb.WriteString(`,"D` + strconv.Itoa(i) + `":{"type":"object","properties":{"a":{"$ref":"#/$defs/` + target + `"},"b":{"$ref":"#/$defs/` + target + `"}}}`)
+ }
+ sb.WriteString(`}}`)
+ return sb.String()
+}
+
+// TestCleanJSONSchema_RemovesReadOnlyWriteOnlyKeywords_Issue6778 covers Issue #6778:
+// JSON Schema annotation keywords "readOnly" and "writeOnly" (common in Google Discovery-derived
+// MCP tools such as Firestore) are rejected by the Gemini/Antigravity backend with 400
+// INVALID_ARGUMENT ("Unknown name \"readOnly\" ... Cannot find field") and must be stripped
+// while preserving legitimate tool properties named "readOnly" or "writeOnly".
+func TestCleanJSONSchema_RemovesReadOnlyWriteOnlyKeywords_Issue6778(t *testing.T) {
+ input := `{
+ "type": "object",
+ "$schema": "http://json-schema.org/draft-04/schema#",
+ "readOnly": false,
+ "writeOnly": false,
+ "properties": {
+ "parent": { "type": "string", "description": "Parent resource name" },
+ "database": {
+ "type": "object",
+ "properties": {
+ "name": { "type": "string", "description": "Database resource name", "readOnly": true },
+ "uid": { "type": "string", "readOnly": true },
+ "createTime": { "type": "string", "readOnly": true },
+ "etag": { "type": "string", "writeOnly": true },
+ "locationId": { "type": "string" }
+ }
+ },
+ "itemsList": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "readOnly": true,
+ "writeOnly": false
+ }
+ },
+ "unionField": {
+ "anyOf": [
+ { "type": "string", "readOnly": true },
+ { "type": "integer", "writeOnly": true }
+ ]
+ },
+ "readOnly": {
+ "type": "boolean",
+ "readOnly": true,
+ "description": "Legitimate property named readOnly"
+ },
+ "writeOnly": {
+ "type": "boolean",
+ "writeOnly": true,
+ "description": "Legitimate property named writeOnly"
+ }
+ },
+ "required": ["parent"]
+ }`
+
+ cleaners := map[string]func(string) string{
+ "Antigravity": CleanJSONSchemaForAntigravity,
+ "AntigravityTool": func(s string) string { return CleanJSONSchemaForAntigravityTool(s, false) },
+ "AntigravityClaudeTool": func(s string) string { return CleanJSONSchemaForAntigravityClaudeTool(s, false) },
+ "AntigravityResponse": CleanJSONSchemaForAntigravityResponse,
+ "Gemini": CleanJSONSchemaForGemini,
+ "GeminiJSONSchema": CleanJSONSchemaForGeminiJSONSchema,
+ }
+
+ for name, clean := range cleaners {
+ t.Run(name, func(t *testing.T) {
+ got := clean(input)
+ parsed := gjson.Parse(got)
+
+ for _, forbiddenPath := range []string{
+ "readOnly",
+ "writeOnly",
+ "properties.database.properties.name.readOnly",
+ "properties.database.properties.uid.readOnly",
+ "properties.database.properties.createTime.readOnly",
+ "properties.database.properties.etag.writeOnly",
+ "properties.itemsList.items.readOnly",
+ "properties.itemsList.items.writeOnly",
+ "properties.unionField.readOnly",
+ "properties.unionField.writeOnly",
+ "properties.unionField.anyOf.0.readOnly",
+ "properties.unionField.anyOf.1.writeOnly",
+ "properties.readOnly.readOnly",
+ "properties.writeOnly.writeOnly",
+ } {
+ if parsed.Get(forbiddenPath).Exists() {
+ t.Errorf("[%s] %s was not removed: %s", name, forbiddenPath, got)
+ }
+ }
+
+ for _, keptField := range []string{
+ "name",
+ "uid",
+ "createTime",
+ "etag",
+ "locationId",
+ } {
+ if gotType := parsed.Get("properties.database.properties." + keptField + ".type").String(); gotType != "string" {
+ t.Errorf("[%s] properties.database.properties.%s.type = %q, want \"string\": %s", name, keptField, gotType, got)
+ }
+ }
+ if gotDesc := parsed.Get("properties.database.properties.name.description").String(); gotDesc != "Database resource name" {
+ t.Errorf("[%s] properties.database.properties.name.description = %q, want %q: %s", name, gotDesc, "Database resource name", got)
+ }
+
+ if gotType := parsed.Get("properties.readOnly.type").String(); gotType != "boolean" {
+ t.Errorf("[%s] legitimate property \"readOnly\" lost its type (%q): %s", name, gotType, got)
+ }
+ if gotDesc := parsed.Get("properties.readOnly.description").String(); gotDesc != "Legitimate property named readOnly" {
+ t.Errorf("[%s] legitimate property \"readOnly\" lost its description (%q): %s", name, gotDesc, got)
+ }
+ if gotType := parsed.Get("properties.writeOnly.type").String(); gotType != "boolean" {
+ t.Errorf("[%s] legitimate property \"writeOnly\" lost its type (%q): %s", name, gotType, got)
+ }
+ if gotDesc := parsed.Get("properties.writeOnly.description").String(); gotDesc != "Legitimate property named writeOnly" {
+ t.Errorf("[%s] legitimate property \"writeOnly\" lost its description (%q): %s", name, gotDesc, got)
+ }
+ })
+ }
+}
diff --git a/internal/util/provider.go b/internal/util/provider.go
index e49992971c6..810a9cb2d0b 100644
--- a/internal/util/provider.go
+++ b/internal/util/provider.go
@@ -283,7 +283,9 @@ func shouldMaskQueryParam(key string) bool {
return false
}
key = strings.TrimSuffix(key, "[]")
- if key == "key" || strings.Contains(key, "api-key") || strings.Contains(key, "apikey") || strings.Contains(key, "api_key") {
+ // The management API deletes a client API key by ?value=, so a plain
+ // "value" parameter can carry a full secret into the request log.
+ if key == "key" || key == "value" || strings.Contains(key, "api-key") || strings.Contains(key, "apikey") || strings.Contains(key, "api_key") {
return true
}
if strings.Contains(key, "token") || strings.Contains(key, "secret") {
diff --git a/internal/util/provider_test.go b/internal/util/provider_test.go
new file mode 100644
index 00000000000..daf45c8db6d
--- /dev/null
+++ b/internal/util/provider_test.go
@@ -0,0 +1,39 @@
+package util_test
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
+)
+
+// TestMaskSensitiveQueryMasksValue covers the related half of issue #6666:
+// deleting a client API key by value passes the secret as ?value=, so the
+// request logger must mask it like other secret-bearing parameters.
+func TestMaskSensitiveQueryMasksValue(t *testing.T) {
+ secretKey := "sk-secret-client-key"
+
+ t.Run("value parameter is masked", func(t *testing.T) {
+ masked := util.MaskSensitiveQuery("value=" + secretKey)
+ if strings.Contains(masked, secretKey) {
+ t.Fatalf("value parameter leaked in full: %s", masked)
+ }
+ if masked != "value=sk-s...-key" {
+ t.Fatalf("masked=%q want the HideAPIKey form", masked)
+ }
+ })
+
+ t.Run("index parameter is untouched", func(t *testing.T) {
+ masked := util.MaskSensitiveQuery("index=3")
+ if masked != "index=3" {
+ t.Fatalf("masked=%q want %q", masked, "index=3")
+ }
+ })
+
+ t.Run("known secret parameters stay masked", func(t *testing.T) {
+ masked := util.MaskSensitiveQuery("key=" + secretKey + "&access_token=" + secretKey)
+ if strings.Contains(masked, secretKey) {
+ t.Fatalf("secret parameter leaked in full: %s", masked)
+ }
+ })
+}
diff --git a/internal/util/responses_tools.go b/internal/util/responses_tools.go
index a8c95e88ca4..6c2122feaa2 100644
--- a/internal/util/responses_tools.go
+++ b/internal/util/responses_tools.go
@@ -18,8 +18,31 @@ type ResponsesToolIdentity struct {
Namespace string
Custom bool
ApplyPatch bool // Resolved from the winning original declaration, never from the upstream name.
+ ToolSearch bool // Marks the function shim standing in for a client-executed tool_search declaration.
}
+const (
+ // ResponsesToolSearchType is the Responses tool type for deferred tool discovery.
+ ResponsesToolSearchType = "tool_search"
+ // ResponsesToolSearchExecutionClient marks a tool_search that the client executes itself.
+ ResponsesToolSearchExecutionClient = "client"
+ // ResponsesToolSearchFunctionName is the function name exposed to providers
+ // that have no native tool_search support.
+ ResponsesToolSearchFunctionName = "tool_search"
+ // ResponsesClientToolSearchKey is the internal forwardMap key for the
+ // client-executed tool_search shim. The leading NUL byte cannot appear in a
+ // JSON identifier, preventing collisions with user-declared tool names.
+ ResponsesClientToolSearchKey = "\x00client_tool_search"
+ // ResponsesToolSearchCallType is the Responses item type for a tool_search invocation.
+ ResponsesToolSearchCallType = "tool_search_call"
+ // ResponsesToolSearchOutputType is the Responses input item carrying discovered tools.
+ ResponsesToolSearchOutputType = "tool_search_output"
+ // ResponsesDiscoveredToolPriority ranks tools loaded by tool_search_output after declared tools.
+ ResponsesDiscoveredToolPriority = 2
+
+ responsesToolSearchDefaultParameters = `{"type":"object","properties":{"query":{"type":"string","description":"Search query for deferred tools."},"limit":{"type":"number","description":"Maximum number of tools to return."}},"required":["query"],"additionalProperties":false}`
+)
+
// ResponsesToolDescriptor is an internal representation of a tool declaration in a Responses request.
type ResponsesToolDescriptor struct {
Name string // Qualified name (e.g. "functions__exec" or "exec")
@@ -27,9 +50,10 @@ type ResponsesToolDescriptor struct {
Namespace string // Namespace if any (e.g. "functions")
ToolType string // "function", "custom", etc.
Tool gjson.Result
- SourcePriority int // 0 for top-level tools, 1 for additional_tools
+ SourcePriority int // 0 for top-level tools, 1 for additional_tools, 2 for tool_search_output
Direct bool // true if declared directly, false if declared as namespace child
Order int // original discovery order
+ ClientSearch bool // true for a client-executed tool_search declaration
}
// QualifyResponsesNamespaceToolName qualifies a child tool name with its namespace.
@@ -48,7 +72,7 @@ func QualifyResponsesNamespaceToolName(namespaceName, childName string) string {
return namespaceName + "__" + childName
}
-func responsesToolSources(root gjson.Result) []struct {
+func responsesToolSources(root gjson.Result, includeClientToolSearch bool) []struct {
tools gjson.Result
priority int
} {
@@ -72,10 +96,33 @@ func responsesToolSources(root gjson.Result) []struct {
}
return true
})
+ if includeClientToolSearch {
+ input.ForEach(func(_, item gjson.Result) bool {
+ if item.Get("type").String() == ResponsesToolSearchOutputType && IsResponsesClientToolSearchItem(item) {
+ appendSource(item.Get("tools"), ResponsesDiscoveredToolPriority)
+ }
+ return true
+ })
+ }
}
return sources
}
+// IsResponsesClientToolSearchDeclaration reports whether a tool declaration is
+// a client-executed Responses tool_search.
+func IsResponsesClientToolSearchDeclaration(tool gjson.Result) bool {
+ return strings.TrimSpace(tool.Get("type").String()) == ResponsesToolSearchType &&
+ strings.TrimSpace(tool.Get("execution").String()) == ResponsesToolSearchExecutionClient
+}
+
+// IsResponsesClientToolSearchItem reports whether a tool_search_call or
+// tool_search_output input item belongs to client-executed tool search.
+// Hosted server search explicitly sets execution to "server".
+func IsResponsesClientToolSearchItem(item gjson.Result) bool {
+ execution := strings.TrimSpace(item.Get("execution").String())
+ return execution == "" || execution == ResponsesToolSearchExecutionClient
+}
+
func responsesToolName(tool gjson.Result) string {
if name := strings.TrimSpace(tool.Get("name").String()); name != "" {
return name
@@ -117,8 +164,12 @@ func responsesToolParameters(tool gjson.Result) gjson.Result {
// CollectResponsesToolDescriptors extracts all tool descriptors from a Responses request root.
func CollectResponsesToolDescriptors(root gjson.Result) []ResponsesToolDescriptor {
+ return collectResponsesToolDescriptors(root, false)
+}
+
+func collectResponsesToolDescriptors(root gjson.Result, includeClientToolSearch bool) []ResponsesToolDescriptor {
var descriptors []ResponsesToolDescriptor
- appendDescriptor := func(tool gjson.Result, name, localName, namespace string, toolType string, sourcePriority int, direct bool) {
+ appendDescriptor := func(tool gjson.Result, name, localName, namespace string, toolType string, sourcePriority int, direct bool, clientSearch bool) {
if name == "" {
return
}
@@ -131,6 +182,7 @@ func CollectResponsesToolDescriptors(root gjson.Result) []ResponsesToolDescripto
SourcePriority: sourcePriority,
Direct: direct,
Order: len(descriptors),
+ ClientSearch: clientSearch,
})
}
appendNamespaceChildren := func(namespaceTool gjson.Result, sourcePriority int) {
@@ -150,30 +202,55 @@ func CollectResponsesToolDescriptors(root gjson.Result) []ResponsesToolDescripto
qualifiedName := QualifyResponsesNamespaceToolName(namespaceName, childName)
switch strings.TrimSpace(child.Get("type").String()) {
case "", "function":
- appendDescriptor(child, qualifiedName, childName, namespaceName, "function", sourcePriority, false)
+ appendDescriptor(child, qualifiedName, childName, namespaceName, "function", sourcePriority, false, false)
case "custom":
- appendDescriptor(child, qualifiedName, childName, namespaceName, "custom", sourcePriority, false)
+ appendDescriptor(child, qualifiedName, childName, namespaceName, "custom", sourcePriority, false, false)
}
return true
})
}
- for _, source := range responsesToolSources(root) {
+ for _, source := range responsesToolSources(root, includeClientToolSearch) {
source.tools.ForEach(func(_, tool gjson.Result) bool {
toolType := strings.TrimSpace(tool.Get("type").String())
switch toolType {
case "", "function":
name := responsesToolName(tool)
- appendDescriptor(tool, name, name, "", "function", source.priority, true)
+ appendDescriptor(tool, name, name, "", "function", source.priority, true, false)
case "custom":
name := responsesToolName(tool)
- appendDescriptor(tool, name, name, "", "custom", source.priority, true)
+ appendDescriptor(tool, name, name, "", "custom", source.priority, true, false)
case "namespace":
appendNamespaceChildren(tool, source.priority)
+ case ResponsesToolSearchType:
+ if includeClientToolSearch && source.priority < ResponsesDiscoveredToolPriority && IsResponsesClientToolSearchDeclaration(tool) {
+ appendDescriptor(tool, ResponsesToolSearchFunctionName, ResponsesToolSearchFunctionName, "", ResponsesToolSearchType, source.priority, true, true)
+ }
}
return true
})
}
- return descriptors
+
+ if !includeClientToolSearch || !responsesHasClientToolSearch(descriptors) {
+ return descriptors
+ }
+ kept := make([]ResponsesToolDescriptor, 0, len(descriptors))
+ for _, descriptor := range descriptors {
+ if !descriptor.ClientSearch && descriptor.SourcePriority != ResponsesDiscoveredToolPriority && descriptor.Tool.Get("defer_loading").Bool() {
+ continue
+ }
+ descriptor.Order = len(kept)
+ kept = append(kept, descriptor)
+ }
+ return kept
+}
+
+func responsesHasClientToolSearch(descriptors []ResponsesToolDescriptor) bool {
+ for i := range descriptors {
+ if descriptors[i].ClientSearch {
+ return true
+ }
+ }
+ return false
}
func responsesToolDescriptorPrecedes(left, right ResponsesToolDescriptor) bool {
@@ -188,8 +265,12 @@ func responsesToolDescriptorPrecedes(left, right ResponsesToolDescriptor) bool {
// CollectResponsesToolWinners collects deduplicated winning descriptors for each qualified tool name.
func CollectResponsesToolWinners(root gjson.Result) map[string]ResponsesToolDescriptor {
+ return collectResponsesToolWinnersFromDescriptors(CollectResponsesToolDescriptors(root))
+}
+
+func collectResponsesToolWinnersFromDescriptors(descriptors []ResponsesToolDescriptor) map[string]ResponsesToolDescriptor {
winners := map[string]ResponsesToolDescriptor{}
- for _, descriptor := range CollectResponsesToolDescriptors(root) {
+ for _, descriptor := range descriptors {
current, exists := winners[descriptor.Name]
if !exists || responsesToolDescriptorPrecedes(descriptor, current) {
winners[descriptor.Name] = descriptor
@@ -251,62 +332,148 @@ func disambiguateResponsesSanitizedName(base, original string, used map[string]s
}
}
+type responsesToolWinnerKey struct {
+ name string
+ clientSearch bool
+}
+
// BuildGeminiFunctionDeclarations builds Gemini function declarations, forward name mapping, and reverse identity mapping.
func BuildGeminiFunctionDeclarations(root gjson.Result) ([][]byte, map[string]string, map[string]ResponsesToolIdentity) {
- descriptors := CollectResponsesToolDescriptors(root)
- winners := CollectResponsesToolWinners(root)
+ descriptors := collectResponsesToolDescriptors(root, true)
+ winners := make(map[responsesToolWinnerKey]ResponsesToolDescriptor, len(descriptors))
+ for _, descriptor := range descriptors {
+ key := responsesToolWinnerKey{name: descriptor.Name, clientSearch: descriptor.ClientSearch}
+ current, exists := winners[key]
+ if !exists || responsesToolDescriptorPrecedes(descriptor, current) {
+ winners[key] = descriptor
+ }
+ }
- seenNames := make(map[string]struct{})
+ seenKeys := make(map[responsesToolWinnerKey]struct{}, len(winners))
var winningList []ResponsesToolDescriptor
for _, descriptor := range descriptors {
- winner, ok := winners[descriptor.Name]
+ key := responsesToolWinnerKey{name: descriptor.Name, clientSearch: descriptor.ClientSearch}
+ winner, ok := winners[key]
if !ok || winner.Order != descriptor.Order {
continue
}
- if _, seen := seenNames[descriptor.Name]; seen {
+ if _, seen := seenKeys[key]; seen {
continue
}
- seenNames[descriptor.Name] = struct{}{}
+ seenKeys[key] = struct{}{}
winningList = append(winningList, descriptor)
}
+ hasClientSearchHistory := false
+ if include := root.Get("input"); include.Exists() && include.IsArray() {
+ include.ForEach(func(_, item gjson.Result) bool {
+ typ := item.Get("type").String()
+ if (typ == ResponsesToolSearchCallType || typ == ResponsesToolSearchOutputType) && IsResponsesClientToolSearchItem(item) {
+ hasClientSearchHistory = true
+ return false
+ }
+ return true
+ })
+ }
+
if len(winningList) == 0 {
- return nil, nil, nil
+ if !hasClientSearchHistory {
+ return nil, nil, nil
+ }
+ forwardMap := map[string]string{
+ ResponsesClientToolSearchKey: ResponsesToolSearchFunctionName,
+ ResponsesToolSearchFunctionName: ResponsesToolSearchFunctionName,
+ }
+ reverseMap := map[string]ResponsesToolIdentity{
+ ResponsesToolSearchFunctionName: {
+ Name: ResponsesToolSearchFunctionName,
+ ToolSearch: true,
+ },
+ }
+ return nil, forwardMap, reverseMap
}
+ hasClientSearch := false
+ userOwnsExactToolSearch := false
qualifiedNames := make([]string, 0, len(winningList))
for _, desc := range winningList {
+ if desc.ClientSearch {
+ hasClientSearch = true
+ continue
+ }
+ if desc.Name == ResponsesToolSearchFunctionName {
+ userOwnsExactToolSearch = true
+ }
qualifiedNames = append(qualifiedNames, desc.Name)
}
sanitizedMap := sanitizeResponsesToolNames(qualifiedNames)
+ if sanitizedMap == nil {
+ sanitizedMap = make(map[string]string, len(winningList))
+ }
- forwardMap := make(map[string]string, len(winningList)*2)
- reverseMap := make(map[string]ResponsesToolIdentity, len(winningList)*2)
+ clientSearchGeminiName := ""
+ if hasClientSearch || hasClientSearchHistory {
+ used := make(map[string]string, len(sanitizedMap)+1)
+ for origName, mappedName := range sanitizedMap {
+ used[mappedName] = origName
+ }
+ if !userOwnsExactToolSearch {
+ // Reserve "tool_search" for the client tool_search shim and
+ // disambiguate any non-exact tool (e.g. "tool.search") that sanitized to it.
+ if collidingOrig, taken := used[ResponsesToolSearchFunctionName]; taken && collidingOrig != ResponsesToolSearchFunctionName {
+ used[ResponsesToolSearchFunctionName] = ResponsesClientToolSearchKey
+ newMapped := disambiguateResponsesSanitizedName(ResponsesToolSearchFunctionName, collidingOrig, used)
+ sanitizedMap[collidingOrig] = newMapped
+ used[newMapped] = collidingOrig
+ } else {
+ used[ResponsesToolSearchFunctionName] = ResponsesClientToolSearchKey
+ }
+ clientSearchGeminiName = ResponsesToolSearchFunctionName
+ } else {
+ clientSearchGeminiName = disambiguateResponsesSanitizedName(ResponsesToolSearchFunctionName, ResponsesClientToolSearchKey, used)
+ used[clientSearchGeminiName] = ResponsesClientToolSearchKey
+ }
+ }
+
+ forwardMap := make(map[string]string, len(winningList)*2+1)
+ reverseMap := make(map[string]ResponsesToolIdentity, len(winningList)*2+1)
+ if clientSearchGeminiName != "" {
+ forwardMap[ResponsesClientToolSearchKey] = clientSearchGeminiName
+ forwardMap[clientSearchGeminiName] = clientSearchGeminiName
+ if !userOwnsExactToolSearch {
+ forwardMap[ResponsesToolSearchFunctionName] = clientSearchGeminiName
+ }
+ reverseMap[clientSearchGeminiName] = ResponsesToolIdentity{
+ Name: ResponsesToolSearchFunctionName,
+ ToolSearch: true,
+ }
+ }
var declarations [][]byte
for _, desc := range winningList {
geminiName := desc.Name
- if mapped, ok := sanitizedMap[desc.Name]; ok && mapped != "" {
+ if desc.ClientSearch {
+ geminiName = clientSearchGeminiName
+ } else if mapped, ok := sanitizedMap[desc.Name]; ok && mapped != "" {
geminiName = mapped
} else {
geminiName = SanitizeFunctionName(desc.Name)
}
- forwardMap[desc.Name] = geminiName
- if desc.LocalName != "" && desc.LocalName != desc.Name {
- if _, exists := forwardMap[desc.LocalName]; !exists {
- forwardMap[desc.LocalName] = geminiName
- }
+ if !desc.ClientSearch {
+ forwardMap[desc.Name] = geminiName
}
+ forwardMap[geminiName] = geminiName
identity := ResponsesToolIdentity{
Name: desc.LocalName,
Namespace: desc.Namespace,
Custom: desc.ToolType == "custom",
ApplyPatch: applypatch.IsCustomTool(desc.Tool),
+ ToolSearch: desc.ClientSearch,
}
reverseMap[geminiName] = identity
- if desc.Name != geminiName {
+ if !desc.ClientSearch && desc.Name != geminiName {
reverseMap[desc.Name] = identity
}
@@ -321,6 +488,8 @@ func BuildGeminiFunctionDeclarations(root gjson.Result) ([][]byte, map[string]st
funcDecl, _ = sjson.SetRawBytes(funcDecl, "parametersJsonSchema", applypatch.Parameters())
} else if desc.ToolType == "custom" {
funcDecl, _ = sjson.SetRawBytes(funcDecl, "parametersJsonSchema", []byte(`{"type":"object","properties":{"input":{"type":"string"}},"required":["input"]}`))
+ } else if desc.ClientSearch {
+ funcDecl, _ = sjson.SetRawBytes(funcDecl, "parametersJsonSchema", buildGeminiToolSearchParametersSchema(desc.Tool))
} else {
params := responsesToolParameters(desc.Tool)
if params.Exists() {
@@ -330,9 +499,29 @@ func BuildGeminiFunctionDeclarations(root gjson.Result) ([][]byte, map[string]st
declarations = append(declarations, funcDecl)
}
+ // Populate local-name aliases after all direct/qualified names are registered
+ // so a namespace child's local name never shadows a direct tool or the search shim.
+ for _, desc := range winningList {
+ if !desc.ClientSearch && desc.LocalName != "" && desc.LocalName != desc.Name {
+ if _, exists := forwardMap[desc.LocalName]; !exists {
+ forwardMap[desc.LocalName] = forwardMap[desc.Name]
+ }
+ }
+ }
+
return declarations, forwardMap, reverseMap
}
+func buildGeminiToolSearchParametersSchema(tool gjson.Result) []byte {
+ if params := responsesToolParameters(tool); params.Exists() && params.IsObject() {
+ cleaned := CleanJSONSchemaForGeminiJSONSchema(params.Raw)
+ if gjson.Valid(cleaned) && gjson.Parse(cleaned).IsObject() {
+ return []byte(cleaned)
+ }
+ }
+ return []byte(responsesToolSearchDefaultParameters)
+}
+
// ResponsesToolReverseIdentityMap builds a Gemini function name -> ResponsesToolIdentity map from a Responses request raw JSON.
func ResponsesToolReverseIdentityMap(rawJSON []byte) map[string]ResponsesToolIdentity {
if len(rawJSON) == 0 || !gjson.ValidBytes(rawJSON) {
@@ -354,6 +543,15 @@ func MapResponsesToolName(forwardMap map[string]string, name string) string {
return SanitizeFunctionName(name)
}
+// MapResponsesClientToolSearchName returns the mapped Gemini function name for
+// the client-executed tool_search shim.
+func MapResponsesClientToolSearchName(forwardMap map[string]string) string {
+ if mapped, ok := forwardMap[ResponsesClientToolSearchKey]; ok && mapped != "" {
+ return mapped
+ }
+ return ResponsesToolSearchFunctionName
+}
+
// ConvertResponsesToolChoiceToGemini translates Responses tool_choice into Gemini functionCallingConfig JSON.
func ConvertResponsesToolChoiceToGemini(toolChoice gjson.Result, forwardMap map[string]string) ([]byte, bool) {
if !toolChoice.Exists() {
@@ -379,6 +577,9 @@ func ConvertResponsesToolChoiceToGemini(toolChoice gjson.Result, forwardMap map[
mode = "AUTO"
case "required", "any":
mode = "ANY"
+ case ResponsesToolSearchType:
+ mode = "ANY"
+ allowedNames = append(allowedNames, MapResponsesClientToolSearchName(forwardMap))
case "function", "custom", "tool", "":
mode = "ANY"
name := strings.TrimSpace(toolChoice.Get("name").String())
diff --git a/internal/util/responses_tools_test.go b/internal/util/responses_tools_test.go
index ba78fb4c409..ab779c6a19b 100644
--- a/internal/util/responses_tools_test.go
+++ b/internal/util/responses_tools_test.go
@@ -267,3 +267,192 @@ func TestBuildGeminiApplyPatchIdentityUsesWinningOriginalDeclaration(t *testing.
t.Fatal("ordinary function became custom patch")
}
}
+
+func TestBuildGeminiFunctionDeclarations_ClientToolSearchShimAndDeferredFiltering(t *testing.T) {
+ raw := `{
+ "tools": [
+ {"type": "function", "name": "exec_command", "parameters": {"type": "object", "properties": {"cmd": {"type": "string"}}}},
+ {"type": "tool_search", "execution": "client", "description": "Search deferred tools.", "parameters": {"type": "object", "properties": {"query": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["query"], "additionalProperties": false}},
+ {"type": "function", "name": "deferred_fn", "defer_loading": true, "parameters": {"type": "object", "properties": {}}},
+ {"type": "web_search"}
+ ]
+ }`
+ declarations, forwardMap, reverseMap := BuildGeminiFunctionDeclarations(gjson.Parse(raw))
+
+ if len(declarations) != 2 {
+ t.Fatalf("expected exec_command and tool_search declarations (with deferred_fn hidden), got %d: %s", len(declarations), declarations)
+ }
+ shim := gjson.ParseBytes(declarations[1])
+ if got := shim.Get("name").String(); got != "tool_search" {
+ t.Fatalf("shim name = %q, want tool_search", got)
+ }
+ if got := shim.Get("description").String(); got != "Search deferred tools." {
+ t.Fatalf("shim description = %q, want original description", got)
+ }
+ if got := shim.Get("parametersJsonSchema.required.0").String(); got != "query" {
+ t.Fatalf("shim parameters not preserved: %s", shim.Raw)
+ }
+ if got := forwardMap["tool_search"]; got != "tool_search" {
+ t.Fatalf("forward map for shim = %q, want tool_search", got)
+ }
+ if _, ok := reverseMap["tool_search"]; !ok {
+ t.Fatalf("expected tool_search in reverseMap")
+ }
+ if !reverseMap["tool_search"].ToolSearch || reverseMap["tool_search"].Custom || reverseMap["tool_search"].Namespace != "" {
+ t.Fatalf("unexpected shim identity: %+v", reverseMap["tool_search"])
+ }
+ if reverseMap["exec_command"].ToolSearch {
+ t.Fatalf("regular function must not be marked ToolSearch: %+v", reverseMap["exec_command"])
+ }
+}
+
+func TestBuildGeminiFunctionDeclarations_ToolSearchShimDefaultsAndPrecedence(t *testing.T) {
+ t.Run("default parameters when omitted and custom schema preserved when provided", func(t *testing.T) {
+ defaultDecls, _, _ := BuildGeminiFunctionDeclarations(gjson.Parse(`{"tools": [{"type": "tool_search", "execution": "client"}]}`))
+ if len(defaultDecls) != 1 {
+ t.Fatalf("expected one shim declaration, got %d", len(defaultDecls))
+ }
+ if got := gjson.GetBytes(defaultDecls[0], "parametersJsonSchema.properties.query.type").String(); got != "string" {
+ t.Fatalf("default parameters missing query: %s", defaultDecls[0])
+ }
+ if !gjson.GetBytes(defaultDecls[0], `parametersJsonSchema.required.#(=="query")`).Exists() {
+ t.Fatalf("default parameters missing required query: %s", defaultDecls[0])
+ }
+
+ customGoalRaw := `{"tools": [{"type": "tool_search", "execution": "client", "description": "Custom goal search", "parameters": {"type": "object", "properties": {"goal": {"type": "string"}}, "required": ["goal"], "additionalProperties": false}}]}`
+ customDecls, _, _ := BuildGeminiFunctionDeclarations(gjson.Parse(customGoalRaw))
+ if len(customDecls) != 1 {
+ t.Fatalf("expected one shim declaration, got %d", len(customDecls))
+ }
+ if got := gjson.GetBytes(customDecls[0], "parametersJsonSchema.properties.goal.type").String(); got != "string" {
+ t.Fatalf("custom goal property not preserved: %s", customDecls[0])
+ }
+ if gjson.GetBytes(customDecls[0], "parametersJsonSchema.properties.query").Exists() {
+ t.Fatalf("custom schema must not be mutated to inject query: %s", customDecls[0])
+ }
+ if gjson.GetBytes(customDecls[0], `parametersJsonSchema.required.#(=="query")`).Exists() {
+ t.Fatalf("custom required array must not inject query: %s", customDecls[0])
+ }
+ })
+ t.Run("server executed search is not emulated", func(t *testing.T) {
+ raw := `{"tools": [{"type": "tool_search"}, {"type": "tool_search", "execution": "server"}]}`
+ declarations, _, reverseMap := BuildGeminiFunctionDeclarations(gjson.Parse(raw))
+ if len(declarations) != 0 || len(reverseMap) != 0 {
+ t.Fatalf("server-executed tool_search must be dropped, got %s", declarations)
+ }
+ })
+ t.Run("user function and client tool_search coexist in both orders", func(t *testing.T) {
+ for _, userFnName := range []string{"tool_search", "__cpa_client_tool_search__"} {
+ for _, tc := range []struct {
+ name string
+ raw string
+ }{
+ {
+ name: userFnName + "/user function first",
+ raw: `{"tools": [{"type": "function", "name": "` + userFnName + `", "description": "user tool"}, {"type": "tool_search", "execution": "client", "description": "client search"}, {"type": "function", "name": "deferred_fn", "defer_loading": true}]}`,
+ },
+ {
+ name: userFnName + "/client tool_search first",
+ raw: `{"tools": [{"type": "tool_search", "execution": "client", "description": "client search"}, {"type": "function", "name": "` + userFnName + `", "description": "user tool"}, {"type": "function", "name": "deferred_fn", "defer_loading": true}]}`,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ declarations, forwardMap, reverseMap := BuildGeminiFunctionDeclarations(gjson.Parse(tc.raw))
+ if len(declarations) != 2 {
+ t.Fatalf("expected both user %s and client tool_search shim (with deferred_fn hidden), got %d declarations: %s", userFnName, len(declarations), declarations)
+ }
+ userGeminiName := forwardMap[userFnName]
+ shimGeminiName := MapResponsesClientToolSearchName(forwardMap)
+ if userGeminiName != userFnName {
+ t.Fatalf("user function Gemini name = %q, want %s", userGeminiName, userFnName)
+ }
+ if shimGeminiName == "" || shimGeminiName == userGeminiName {
+ t.Fatalf("shim Gemini name = %q, must be non-empty and distinct from %q", shimGeminiName, userGeminiName)
+ }
+ if reverseMap[userGeminiName].ToolSearch || reverseMap[userGeminiName].Name != userFnName {
+ t.Fatalf("user function identity = %+v, want non-ToolSearch function named %s", reverseMap[userGeminiName], userFnName)
+ }
+ if !reverseMap[shimGeminiName].ToolSearch {
+ t.Fatalf("shim identity = %+v, want ToolSearch=true", reverseMap[shimGeminiName])
+ }
+ })
+ }
+ }
+ })
+ t.Run("sanitized name collision with tool.search keeps both", func(t *testing.T) {
+ raw := `{"tools": [{"type": "function", "name": "tool.search", "description": "dotted user tool"}, {"type": "tool_search", "execution": "client", "description": "client search"}]}`
+ declarations, forwardMap, reverseMap := BuildGeminiFunctionDeclarations(gjson.Parse(raw))
+ if len(declarations) != 2 {
+ t.Fatalf("expected 2 declarations, got %d: %s", len(declarations), declarations)
+ }
+ dottedGeminiName := forwardMap["tool.search"]
+ shimGeminiName := MapResponsesClientToolSearchName(forwardMap)
+ if shimGeminiName != "tool_search" {
+ t.Fatalf("shim Gemini name = %q, want tool_search", shimGeminiName)
+ }
+ if dottedGeminiName == "" || dottedGeminiName == shimGeminiName {
+ t.Fatalf("dotted user tool Gemini name = %q, must be distinct from %q", dottedGeminiName, shimGeminiName)
+ }
+ if reverseMap[dottedGeminiName].ToolSearch || reverseMap[dottedGeminiName].Name != "tool.search" {
+ t.Fatalf("dotted tool identity = %+v", reverseMap[dottedGeminiName])
+ }
+ if !reverseMap[shimGeminiName].ToolSearch {
+ t.Fatalf("shim identity = %+v", reverseMap[shimGeminiName])
+ }
+ })
+ t.Run("shared CollectResponsesToolDescriptors keeps deferred tools for non-search callers", func(t *testing.T) {
+ raw := `{"tools": [{"type": "tool_search", "execution": "client"}, {"type": "function", "name": "deferred_fn", "defer_loading": true}, {"type": "function", "name": "eager_fn"}]}`
+ descriptors := CollectResponsesToolDescriptors(gjson.Parse(raw))
+ if len(descriptors) != 2 || descriptors[0].Name != "deferred_fn" || descriptors[1].Name != "eager_fn" {
+ t.Fatalf("CollectResponsesToolDescriptors = %+v, want [deferred_fn eager_fn]", descriptors)
+ }
+ })
+ t.Run("additional_tools declaration", func(t *testing.T) {
+ raw := `{"input": [{"type": "additional_tools", "tools": [{"type": "tool_search", "execution": "client"}]}]}`
+ _, _, reverseMap := BuildGeminiFunctionDeclarations(gjson.Parse(raw))
+ if !reverseMap[ResponsesToolSearchFunctionName].ToolSearch {
+ t.Fatalf("tool_search inside additional_tools must be exposed: %+v", reverseMap)
+ }
+ })
+}
+
+func TestBuildGeminiFunctionDeclarations_CollectsToolSearchOutputTools(t *testing.T) {
+ raw := `{
+ "tools": [
+ {"type": "namespace", "name": "mcp__cua_repl", "tools": [{"type": "function", "name": "js", "description": "current"}]},
+ {"type": "tool_search", "execution": "client"},
+ {"type": "function", "name": "deferred_fn", "defer_loading": true, "description": "top-level deferred"}
+ ],
+ "input": [
+ {"type": "tool_search_call", "execution": "client", "call_id": "search_1", "status": "completed", "arguments": {"query": "fixture"}},
+ {"type": "tool_search_output", "execution": "client", "call_id": "search_1", "status": "completed", "tools": [
+ {"type": "namespace", "name": "fixture_tools", "description": "fixture", "tools": [
+ {"type": "function", "name": "ping", "defer_loading": true, "parameters": {"type": "object", "properties": {}, "additionalProperties": false}}
+ ]},
+ {"type": "namespace", "name": "mcp__cua_repl", "tools": [{"type": "function", "name": "js", "description": "discovered"}]}
+ ]},
+ {"type": "tool_search_output", "execution": "client", "call_id": "search_2", "status": "completed", "tools": [
+ {"type": "namespace", "name": "fixture_tools", "tools": [{"type": "function", "name": "ping", "description": "again"}]}
+ ]}
+ ]
+ }`
+ declarations, forwardMap, reverseMap := BuildGeminiFunctionDeclarations(gjson.Parse(raw))
+
+ names := make([]string, 0, len(declarations))
+ for _, declaration := range declarations {
+ names = append(names, gjson.GetBytes(declaration, "name").String())
+ }
+ if len(names) != 3 || names[0] != "mcp__cua_repl__js" || names[1] != "tool_search" || names[2] != "fixture_tools__ping" {
+ t.Fatalf("declarations = %v, want [mcp__cua_repl__js tool_search fixture_tools__ping]", names)
+ }
+ if got := gjson.GetBytes(declarations[0], "description").String(); got != "current" {
+ t.Fatalf("top-level declaration must win over a discovered duplicate, got %q", got)
+ }
+ if got := forwardMap["fixture_tools__ping"]; got != "fixture_tools__ping" {
+ t.Fatalf("forward map for discovered tool = %q", got)
+ }
+ identity := reverseMap["fixture_tools__ping"]
+ if identity.Name != "ping" || identity.Namespace != "fixture_tools" {
+ t.Fatalf("discovered identity = %+v, want ping in fixture_tools", identity)
+ }
+}
diff --git a/internal/util/sanitize_test.go b/internal/util/sanitize_test.go
index ddd345fd7d8..a7a8955fe3a 100644
--- a/internal/util/sanitize_test.go
+++ b/internal/util/sanitize_test.go
@@ -250,3 +250,397 @@ func TestRestoreSanitizedToolName(t *testing.T) {
t.Errorf("expected empty for empty name, got %q", got)
}
}
+
+func TestCleanClaudeToolArguments(t *testing.T) {
+ origRequest := []byte(`{
+ "tools": [
+ {"name": "EnterPlanMode", "input_schema": {"type": "object", "properties": {}}},
+ {"name": "CronList", "input_schema": {"type": "object"}},
+ {"name": "Bash", "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
+ {"name": "CustomToolWithReason", "input_schema": {"type": "object", "properties": {"reason": {"type": "string"}}}},
+ {"name": "DynamicTool", "input_schema": {"type": "object", "properties": {"name": {"type": "string"}}, "additionalProperties": true}},
+ {"name": "UnionTool", "input_schema": {"anyOf": [{"type": "object", "properties": {"reason": {"type": "string"}}}]}}
+ ]
+ }`)
+
+ dynamicRequest := []byte(`{
+ "tools": [
+ {"name": "MapTool", "input_schema": {"type": "object", "additionalProperties": true}},
+ {"name": "TypedMapTool", "input_schema": {"type": "object", "properties": {}, "additionalProperties": {"type": "string"}}}
+ ]
+ }`)
+ caseCollisionRequest := []byte(`{
+ "tools": [
+ {"name": "Read", "input_schema": {"type": "object", "properties": {}}},
+ {"name": "read", "input_schema": {"type": "object", "properties": {"path": {"type": "string"}}}}
+ ]
+ }`)
+ composedRequest := []byte(`{
+ "tools": [
+ {"name": "AllOfEmpty", "input_schema": {"type": "object", "allOf": [{"type": "object", "properties": {}}]}},
+ {"name": "AllOfArgs", "input_schema": {"type": "object", "allOf": [{"type": "object", "properties": {"path": {"type": "string"}}}]}},
+ {"name": "RefEmpty", "input_schema": {"$ref": "#/$defs/empty", "$defs": {"empty": {"type": "object", "properties": {}}}}},
+ {"name": "NestedTool", "input_schema": {"type": "object", "properties": {"opts": {"type": "object", "properties": {}}, "cfg": {"type": "object", "properties": {"mode": {"type": "string"}}}}}}
+ ]
+ }`)
+ transferredRequest := []byte(`{
+ "model": "gemini-3.8-flash-high",
+ "request": {"tools": [{"functionDeclarations": [{"name": "Bash", "parametersJsonSchema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}]}]}
+ }`)
+ translatedReasonRequired := []byte(`{
+ "model": "gemini-3.8-flash-high",
+ "request": {"tools": [{"functionDeclarations": [{"name": "Bash", "parametersJsonSchema": {"type": "object", "properties": {"command": {"type": "string"}, "reason": {"type": "string"}}, "required": ["command", "reason"]}}]}]}
+ }`)
+ uniqueItemsRequest := []byte(`{
+ "tools": [
+ {"name": "BatchUnique", "input_schema": {"type": "object", "properties": {"rows": {"type": "array", "uniqueItems": true, "items": {"type": "object", "properties": {"tag": {"type": "string"}}}}}, "required": ["rows"], "additionalProperties": false}}
+ ]
+ }`)
+ translatedUniqueItems := []byte(`{
+ "model": "claude-sonnet-4-6",
+ "request": {"tools": [{"functionDeclarations": [
+ {"name": "BatchUnique", "parametersJsonSchema": {"type": "object", "properties": {"rows": {"type": "array", "items": {"type": "object", "properties": {"tag": {"type": "string"}, "_": {"type": "boolean"}}, "required": ["_"]}}}, "required": ["rows"]}}
+ ]}]}
+ }`)
+ minPropsRequest := []byte(`{
+ "tools": [
+ {"name": "StoreMetadata", "input_schema": {"type": "object", "properties": {}, "minProperties": 1}}
+ ]
+ }`)
+ translatedMinProps := []byte(`{
+ "model": "claude-sonnet-4-6",
+ "request": {"tools": [{"functionDeclarations": [
+ {"name": "StoreMetadata", "parametersJsonSchema": {"type": "object", "properties": {"reason": {"type": "string"}}, "required": ["reason"], "minProperties": 1}}
+ ]}]}
+ }`)
+ siblingAllOfRequest := []byte(`{
+ "tools": [
+ {"name": "ConfigureSibling", "input_schema": {"type": "object", "required": ["cfg"], "anyOf": [{"type": "object", "properties": {"cfg": {"type": "object", "properties": {}}}, "required": ["cfg"], "additionalProperties": false}, {"type": "object", "properties": {"other": {"type": "string"}}, "required": ["other"], "additionalProperties": false}], "allOf": [{"properties": {"cfg": {"type": "object", "properties": {"reason": {"type": "string"}}, "required": ["reason"]}}}]}}
+ ]
+ }`)
+ translatedSiblingAllOf := []byte(`{
+ "model": "gemini-2.5-pro",
+ "tools": [{"functionDeclarations": [
+ {"name": "ConfigureSibling", "parametersJsonSchema": {"type": "object", "properties": {"cfg": {"type": "object", "properties": {"reason": {"type": "string"}}, "required": ["reason"]}}, "required": ["cfg"]}}
+ ]}]
+ }`)
+ arrayContainsRequest := []byte(`{
+ "tools": [
+ {"name": "BatchContains", "input_schema": {"type": "object", "properties": {"rows": {"type": "array", "items": {"type": "object", "properties": {}}, "contains": {"type": "object", "properties": {"reason": {"type": "string"}}, "required": ["reason"]}}}, "required": ["rows"], "additionalProperties": false}}
+ ]
+ }`)
+ translatedArrayContains := []byte(`{
+ "model": "claude-sonnet-4-6",
+ "request": {"tools": [{"functionDeclarations": [
+ {"name": "BatchContains", "parametersJsonSchema": {"type": "object", "properties": {"rows": {"type": "array", "items": {"type": "object", "properties": {"reason": {"type": "string"}}, "required": ["reason"]}}}, "required": ["rows"]}}
+ ]}]}
+ }`)
+ arrayEnumRequest := []byte(`{
+ "tools": [
+ {"name": "BatchEnum", "input_schema": {"type": "object", "properties": {"rows": {"type": "array", "items": {"type": "object", "properties": {}}, "enum": [[{"reason": "business"}]]}}, "required": ["rows"], "additionalProperties": false}}
+ ]
+ }`)
+ translatedArrayEnum := []byte(`{
+ "model": "claude-sonnet-4-6",
+ "request": {"tools": [{"functionDeclarations": [
+ {"name": "BatchEnum", "parametersJsonSchema": {"type": "object", "properties": {"rows": {"type": "array", "items": {"type": "object", "properties": {"reason": {"type": "string"}}, "required": ["reason"]}}}, "required": ["rows"]}}
+ ]}]}
+ }`)
+ enumUnionRequest := []byte(`{
+ "tools": [
+ {"name": "ExplainFixed", "input_schema": {"type": "object", "enum": [{"reason": "business"}], "anyOf": [{"type": "object", "properties": {}}]}}
+ ]
+ }`)
+ enumRequest := []byte(`{
+ "tools": [
+ {"name": "ExplainFixed", "input_schema": {"type": "object", "enum": [{"reason": "business"}]}},
+ {"name": "ConstTool", "input_schema": {"type": "object", "const": {"reason": "fixed"}}}
+ ]
+ }`)
+ translatedEnum := []byte(`{
+ "model": "claude-sonnet-4-6",
+ "request": {"tools": [{"functionDeclarations": [
+ {"name": "ExplainFixed", "parametersJsonSchema": {"type": "object", "properties": {"reason": {"type": "string"}}, "required": ["reason"]}},
+ {"name": "ConstTool", "parametersJsonSchema": {"type": "object", "properties": {"reason": {"type": "string"}}, "required": ["reason"]}}
+ ]}]}
+ }`)
+ translatedCollisionDynamic := func() []byte {
+ collisionSeed := []byte(`{"tools":[{"name":"1"},{"name":"_1"}]}`)
+ sentName := SanitizedFunctionNameMap(collisionSeed)["1"]
+ return []byte(`{"model":"claude-sonnet-4-6","request":{"tools":[{"functionDeclarations":[{"name":"` + sentName + `","parametersJsonSchema":{"type":"object","properties":{"reason":{"type":"string"}},"required":["reason"]}}]}]}}`)
+ }()
+ collisionRequest := []byte(`{
+ "tools": [
+ {"name": "1", "input_schema": {"type": "object", "properties": {}, "required": ["reason"]}},
+ {"name": "_1", "input_schema": {"type": "object", "properties": {}}}
+ ]
+ }`)
+ round15Request := []byte(`{
+ "tools": [
+ {"name": "Batch", "input_schema": {"type": "object", "anyOf": [{"type": "object", "properties": {"rows": {"type": "array", "items": {"type": "object", "properties": {}, "additionalProperties": false}}}, "required": ["rows"], "additionalProperties": false}, {"type": "object", "properties": {"rows": {}}, "required": ["rows"], "additionalProperties": false}]}},
+ {"name": "BatchStrict", "input_schema": {"type": "object", "properties": {"rows": {"type": "array", "items": {"type": "object", "properties": {}, "additionalProperties": false}}}, "required": ["rows"]}}
+ ]
+ }`)
+ translatedRound15 := []byte(`{
+ "model": "gemini-2.5-pro",
+ "tools": [{"functionDeclarations": [
+ {"name": "Batch", "parametersJsonSchema": {"type": "object", "anyOf": [{"type": "object", "properties": {"rows": {"type": "array", "items": {"type": "object", "properties": {}}}}, "required": ["rows"]}, {"type": "object", "properties": {"rows": {}}, "required": ["rows"]}]}},
+ {"name": "BatchStrict", "parametersJsonSchema": {"type": "object", "properties": {"rows": {"type": "array", "items": {"type": "object", "properties": {"reason": {"type": "string"}}, "required": ["reason"]}}}, "required": ["rows"]}}
+ ]}]
+ }`)
+ round14Request := []byte(`{
+ "tools": [
+ {"name": "ConfigureAltFree", "input_schema": {"type": "object", "anyOf": [{"type": "object", "properties": {"cfg": {"type": "object", "properties": {}, "additionalProperties": false}}, "required": ["cfg"], "additionalProperties": false}, {"type": "object", "properties": {"cfg": {}}, "required": ["cfg"], "additionalProperties": false}]}}
+ ]
+ }`)
+ translatedRound14 := []byte(`{
+ "model": "gemini-2.5-pro",
+ "tools": [{"functionDeclarations": [
+ {"name": "ConfigureAltFree", "parametersJsonSchema": {"type": "object", "anyOf": [{"type": "object", "properties": {"cfg": {"type": "object", "properties": {}}}, "required": ["cfg"]}, {"type": "object", "properties": {"cfg": {}}, "required": ["cfg"]}]}}
+ ]}]
+ }`)
+ round13Request := []byte(`{
+ "tools": [
+ {"name": "DeepAlt", "input_schema": {"type": "object", "properties": {"choice": {"anyOf": [{"type": "object", "properties": {"cfg": {"type": "object", "properties": {"opts": {"type": "object", "properties": {"mode": {"type": "string"}}, "additionalProperties": false}}, "required": ["opts"], "additionalProperties": false}}, "required": ["cfg"], "additionalProperties": false}, {"type": "object", "properties": {"cfg": {"type": "object", "properties": {"tag": {"type": "string"}}, "required": ["tag"]}}, "required": ["cfg"], "additionalProperties": false}]}}, "required": ["choice"], "additionalProperties": false}}
+ ]
+ }`)
+ translatedRound13 := []byte(`{
+ "model": "gemini-3-pro-high",
+ "request": {"tools": [{"functionDeclarations": [
+ {"name": "DeepAlt", "parametersJsonSchema": {"type": "object", "properties": {"choice": {"type": "object", "properties": {"cfg": {"type": "object", "properties": {"opts": {"type": "object", "properties": {"mode": {"type": "string"}, "_": {"type": "boolean"}}, "required": ["_"]}}, "required": ["opts"]}}, "required": ["cfg"]}}, "required": ["choice"]}}
+ ]}]}
+ }`)
+ round12Request := []byte(`{
+ "tools": [
+ {"name": "ConfigureAlt", "input_schema": {"type": "object", "properties": {"choice": {"anyOf": [{"type": "object", "properties": {"cfg": {"type": "object", "properties": {}, "additionalProperties": false}}, "required": ["cfg"], "additionalProperties": false}, {"type": "object", "properties": {"cfg": {"type": "object", "properties": {"mode": {"type": "string"}}, "required": ["mode"]}}, "required": ["cfg"], "additionalProperties": false}]}}, "required": ["choice"], "additionalProperties": false}}
+ ]
+ }`)
+ translatedRound12 := []byte(`{
+ "model": "gemini-3-pro-high",
+ "request": {"tools": [{"functionDeclarations": [
+ {"name": "ConfigureAlt", "parametersJsonSchema": {"type": "object", "properties": {"choice": {"anyOf": [{"type": "object", "properties": {"cfg": {"type": "object", "properties": {"reason": {"type": "string"}}, "required": ["reason"]}}, "required": ["cfg"]}, {"type": "object", "properties": {"cfg": {"type": "object", "properties": {"mode": {"type": "string"}}, "required": ["mode"]}}, "required": ["cfg"]}]}}, "required": ["choice"]}}
+ ]}]}
+ }`)
+ round11Request := []byte(`{
+ "tools": [
+ {"name": "ChooseImplicit", "input_schema": {"type": "object", "properties": {"choice": {"anyOf": [{"type": "object", "properties": {}, "additionalProperties": false}, {"type": "object", "properties": {"tag": {"type": "string"}}, "required": ["tag"]}]}}, "required": ["choice"]}}
+ ]
+ }`)
+ translatedRound11 := []byte(`{
+ "model": "gemini-3-pro-high",
+ "request": {"tools": [{"functionDeclarations": [
+ {"name": "ChooseImplicit", "parametersJsonSchema": {"type": "object", "properties": {"choice": {"anyOf": [{"type": "object", "properties": {"reason": {"type": "string"}}, "required": ["reason"]}, {"type": "object", "properties": {"tag": {"type": "string"}}, "required": ["tag"]}]}}, "required": ["choice"]}}
+ ]}]}
+ }`)
+ round10Request := []byte(`{
+ "tools": [
+ {"name": "ImplicitBranch", "input_schema": {"type": "object", "properties": {"choice": {"anyOf": [{"type": "object", "properties": {"cfg": {"type": "object", "properties": {"mode": {"type": "string"}}, "additionalProperties": false}}, "required": ["cfg"], "additionalProperties": false}, {"type": "object", "properties": {"tag": {"type": "string"}}, "required": ["tag"]}]}}, "required": ["choice"], "additionalProperties": false}}
+ ]
+ }`)
+ translatedRound10 := []byte(`{
+ "model": "gemini-3.1-pro",
+ "request": {"tools": [{"functionDeclarations": [
+ {"name": "ImplicitBranch", "parametersJsonSchema": {"type": "object", "properties": {"choice": {"type": "object", "properties": {"cfg": {"type": "object", "properties": {"mode": {"type": "string"}, "_": {"type": "boolean"}}, "required": ["_"]}}, "required": ["cfg"]}}, "required": ["choice"]}}
+ ]}]}
+ }`)
+ round9Request := []byte(`{
+ "tools": [
+ {"name": "ConfigureNested", "input_schema": {"type": "object", "properties": {"choice": {"anyOf": [{"type": "object", "properties": {"cfg": {"type": "object", "properties": {"mode": {"type": "string"}}, "additionalProperties": false}}, "required": ["cfg"], "additionalProperties": false}, {"type": "object", "patternProperties": {"^cfg$": {"type": "object", "additionalProperties": true}}, "additionalProperties": false}]}}, "required": ["choice"]}}
+ ]
+ }`)
+ translatedRound9 := []byte(`{
+ "model": "gemini-3.1-pro",
+ "request": {"tools": [{"functionDeclarations": [
+ {"name": "ConfigureNested", "parametersJsonSchema": {"type": "object", "properties": {"choice": {"type": "object", "properties": {"cfg": {"type": "object", "properties": {"mode": {"type": "string"}, "_": {"type": "boolean"}}, "required": ["_"]}}, "required": ["cfg"]}}, "required": ["choice"]}}
+ ]}]}
+ }`)
+ round8Request := []byte(`{
+ "tools": [
+ {"name": "ExplainPattern", "input_schema": {"type": "object", "properties": {}, "patternProperties": {"^reason$": {"type": "string"}}, "additionalProperties": false}},
+ {"name": "PatternData", "input_schema": {"type": "object", "properties": {}, "patternProperties": {"^x-": {"type": "string"}}, "additionalProperties": false}}
+ ]
+ }`)
+ translatedRound8 := []byte(`{
+ "model": "gemini-3.1-pro",
+ "request": {"tools": [{"functionDeclarations": [
+ {"name": "ExplainPattern", "parametersJsonSchema": {"type": "object", "properties": {"reason": {"type": "string"}}, "required": ["reason"]}},
+ {"name": "PatternData", "parametersJsonSchema": {"type": "object", "properties": {"reason": {"type": "string"}}, "required": ["reason"]}}
+ ]}]}
+ }`)
+ round7Request := []byte(`{
+ "tools": [
+ {"name": "Configure", "input_schema": {"type": "object", "properties": {"cfg": {"anyOf": [{"type": "object", "properties": {}, "additionalProperties": false}, {"type": "object", "additionalProperties": true}]}}, "required": ["cfg"]}}
+ ]
+ }`)
+ translatedRound7 := []byte(`{
+ "model": "gemini-3.1-pro",
+ "request": {"tools": [{"functionDeclarations": [
+ {"name": "Configure", "parametersJsonSchema": {"type": "object", "properties": {"cfg": {"anyOf": [{"type": "object", "properties": {"reason": {"type": "string"}}, "required": ["reason"]}, {"type": "object", "properties": {"reason": {"type": "string"}}, "required": ["reason"]}]}}, "required": ["cfg"]}}
+ ]}]}
+ }`)
+ round6Request := []byte(`{
+ "tools": [
+ {"name": "PlainOptions", "input_schema": {"type": "object", "properties": {"flag": {"type": "string"}}}},
+ {"name": "StrictOptions", "input_schema": {"type": "object", "properties": {"flag": {"type": "string"}}, "additionalProperties": false}},
+ {"name": "BranchChoice", "input_schema": {"type": "object", "properties": {"choice": {"anyOf": [{"type": "object", "properties": {"cfg": {"type": "object", "additionalProperties": true}}, "required": ["cfg"]}, {"type": "object", "properties": {"cfg": {"type": "object", "properties": {}, "additionalProperties": false}}, "required": ["cfg"]}]}}, "required": ["choice"]}}
+ ]
+ }`)
+ translatedRound6 := []byte(`{
+ "model": "gemini-2.5-pro",
+ "tools": [{"functionDeclarations": [
+ {"name": "PlainOptions", "parametersJsonSchema": {"type": "object", "properties": {"flag": {"type": "string"}}}},
+ {"name": "StrictOptions", "parametersJsonSchema": {"type": "object", "properties": {"flag": {"type": "string"}}}},
+ {"name": "BranchChoice", "parametersJsonSchema": {"type": "object", "properties": {"choice": {"anyOf": [{"type": "object", "properties": {"cfg": {"type": "object", "additionalProperties": true}}, "required": ["cfg"]}, {"type": "object", "properties": {"cfg": {"type": "object", "properties": {}}}, "required": ["cfg"]}]}}, "required": ["choice"]}}
+ ]}]
+ }`)
+ round5Request := []byte(`{
+ "tools": [
+ {"name": "Explain", "input_schema": {"type": "object", "required": ["reason"], "additionalProperties": true}},
+ {"name": "Choose", "input_schema": {"type": "object", "properties": {"choice": {"anyOf": [{"type": "object", "properties": {"opts": {"type": "object", "properties": {}, "additionalProperties": false}}, "required": ["opts"]}, {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}]}}, "required": ["choice"]}},
+ {"name": "Options", "input_schema": {"type": "object", "properties": {"flag": {"type": "string"}}, "additionalProperties": true}}
+ ]
+ }`)
+ translatedRound5 := []byte(`{
+ "model": "gemini-3.8-flash-high",
+ "tools": [{"functionDeclarations": [
+ {"name": "Explain", "parametersJsonSchema": {"type": "object", "properties": {"reason": {"type": "string"}}, "required": ["reason"]}},
+ {"name": "Choose", "parametersJsonSchema": {"type": "object", "properties": {"choice": {"type": "object", "properties": {"opts": {"type": "object", "properties": {"reason": {"type": "string"}}, "required": ["reason"]}}, "required": ["opts"]}}, "required": ["choice"]}},
+ {"name": "Options", "parametersJsonSchema": {"type": "object", "properties": {"flag": {"type": "string"}}, "additionalProperties": true}}
+ ]}]
+ }`)
+ round4Request := []byte(`{
+ "tools": [
+ {"name": "UnionChoice", "input_schema": {"type": "object", "properties": {"choice": {"anyOf": [{"type": "object", "properties": {"reason": {"type": "string"}}, "required": ["reason"]}, {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}]}}, "required": ["choice"]}},
+ {"name": "AllOfChildMerge", "input_schema": {"type": "object", "properties": {"cfg": {"type": "object", "properties": {"mode": {"type": "string"}}}}, "allOf": [{"properties": {"cfg": {"type": "object", "properties": {}}}}]}},
+ {"name": "NestedRefTool", "input_schema": {"type": "object", "properties": {"opts": {"$ref": "#/$defs/Empty"}}, "$defs": {"Empty": {"type": "object", "properties": {}, "additionalProperties": false}}}},
+ {"name": "WrappedPlaceholder", "input_schema": {"type": "object", "properties": {"flag": {"type": "string"}}, "additionalProperties": false}}
+ ]
+ }`)
+ translatedRound4 := []byte(`{
+ "model": "gemini-3.8-flash-high",
+ "request": {"tools": [{"functionDeclarations": [
+ {"name": "UnionChoice", "parametersJsonSchema": {"type": "object", "properties": {"choice": {"anyOf": [{"type": "object", "properties": {"reason": {"type": "string"}}, "required": ["reason"]}, {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}]}}, "required": ["choice"]}},
+ {"name": "AllOfChildMerge", "parametersJsonSchema": {"type": "object", "properties": {"cfg": {"type": "object", "properties": {"mode": {"type": "string"}}}}}},
+ {"name": "NestedRefTool", "parametersJsonSchema": {"type": "object", "properties": {"opts": {"type": "object", "properties": {"reason": {"type": "string"}}, "required": ["reason"]}}}},
+ {"name": "WrappedPlaceholder", "parametersJsonSchema": {"type": "object", "properties": {"flag": {"type": "string"}}}}
+ ]}]}
+ }`)
+ extraRequest := []byte(`{
+ "tools": [
+ {"name": "AllOfDynamicConflict", "input_schema": {"type": "object", "properties": {}, "additionalProperties": false, "allOf": [{"additionalProperties": true}]}},
+ {"name": "MultiUnionEmpty", "input_schema": {"anyOf": [{"type": "object", "properties": {}, "additionalProperties": false}, {"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"], "additionalProperties": false}]}},
+ {"name": "DupTool", "input_schema": {"type": "object", "properties": {}}},
+ {"name": "DupTool", "input_schema": {"type": "object", "properties": {}}},
+ {"name": "ConflictingDup", "input_schema": {"type": "object", "properties": {}}},
+ {"name": "ConflictingDup", "input_schema": {"type": "object", "properties": {"path": {"type": "string"}}}},
+ {"name": "MatrixTool", "input_schema": {"type": "object", "properties": {"rows": {"type": "array", "items": {"type": "array", "items": {"type": "object", "properties": {}}}}}}}
+ ]
+ }`)
+ translatedExtra := []byte(`{
+ "model": "gemini-3.8-flash-high",
+ "request": {"tools": [{"functionDeclarations": [
+ {"name": "AllOfDynamicConflict", "parametersJsonSchema": {"type": "object", "properties": {"reason": {"type": "string"}}, "required": ["reason"]}},
+ {"name": "MultiUnionEmpty", "parametersJsonSchema": {"anyOf": [{"type": "object", "properties": {"reason": {"type": "string"}}, "required": ["reason"]}, {"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]}]}},
+ {"name": "MatrixTool", "parametersJsonSchema": {"type": "object", "properties": {"rows": {"type": "array", "items": {"type": "array", "items": {"type": "object", "properties": {"reason": {"type": "string"}}, "required": ["reason"]}}}}}}
+ ]}]}
+ }`)
+ deepRequest := []byte(`{
+ "tools": [
+ {"name": "FreeFormNested", "input_schema": {"type": "object", "properties": {"payload": {}}, "required": ["payload"]}},
+ {"name": "AllOfMerge", "input_schema": {"type": "object", "properties": {"cfg": {"type": "object", "properties": {"mode": {"type": "string"}}}}, "allOf": [{"type": "object", "properties": {"cfg": {"type": "object", "properties": {"reason": {"type": "string"}}}}}]}},
+ {"name": "SingleUnionEmpty", "input_schema": {"anyOf": [{"type": "object", "properties": {}}]}},
+ {"name": "DynamicParent", "input_schema": {"type": "object", "properties": {"opts": {"type": "object", "properties": {}}}, "additionalProperties": true}},
+ {"name": "RefArray", "input_schema": {"type": "object", "properties": {"items": {"$ref": "#/$defs/list"}}, "$defs": {"list": {"type": "array", "items": {"type": "object", "properties": {"v": {"type": "string"}}}}}}},
+ {"name": "PropRef", "input_schema": {"type": "object", "properties": {"template": {"type": "object", "properties": {}}, "copy": {"$ref": "#/properties/template"}}}}
+ ]
+ }`)
+
+ tests := []struct {
+ name string
+ rawArgs string
+ toolName string
+ origReq []byte
+ want string
+ transferred []byte
+ }{
+ {"parameterless empty properties", `{"reason":"Entering plan mode"}`, "EnterPlanMode", origRequest, "{}", nil},
+ {"parameterless missing properties", `{"reason":"List jobs"}`, "CronList", origRequest, "{}", nil},
+ {"declared args preserved", `{"command":"ls -la"}`, "Bash", origRequest, `{"command":"ls -la"}`, nil},
+ {"undeclared reason stripped", `{"command":"ls -la","reason":"why"}`, "Bash", origRequest, `{"command":"ls -la"}`, nil},
+ {"reason kept when translated schema lacks placeholder", `{"command":"ls -la","reason":"why"}`, "Bash", origRequest, `{"command":"ls -la","reason":"why"}`, transferredRequest},
+ {"reason stripped when translated schema marks placeholder", `{"command":"ls -la","reason":"why"}`, "Bash", origRequest, `{"command":"ls -la"}`, translatedReasonRequired},
+ {"undeclared underscore stripped", `{"command":"ls -la","_":true}`, "Bash", origRequest, `{"command":"ls -la"}`, nil},
+ {"declared reason preserved", `{"reason":"valid"}`, "CustomToolWithReason", origRequest, `{"reason":"valid"}`, nil},
+ {"additional properties preserved", `{"name":"x","reason":"dynamic"}`, "DynamicTool", origRequest, `{"name":"x","reason":"dynamic"}`, nil},
+ {"union schema passthrough", `{"reason":"x"}`, "UnionTool", origRequest, `{"reason":"x"}`, nil},
+ {"unknown tool passthrough", `{"reason":"x"}`, "EnterPlanMode", nil, `{"reason":"x"}`, nil},
+ {"empty args", "", "EnterPlanMode", origRequest, "{}", nil},
+ {"empty object args", "{}", "EnterPlanMode", origRequest, "{}", nil},
+ {"dynamic map tool preserved", `{"key":"value"}`, "MapTool", dynamicRequest, `{"key":"value"}`, nil},
+ {"typed map tool preserved", `{"key":"value"}`, "TypedMapTool", dynamicRequest, `{"key":"value"}`, nil},
+ {"case collision exact match keeps args", `{"path":"/tmp/a"}`, "read", caseCollisionRequest, `{"path":"/tmp/a"}`, nil},
+ {"case collision exact match parameterless", `{"reason":"x"}`, "Read", caseCollisionRequest, "{}", nil},
+ {"allOf empty normalized", `{"reason":"x"}`, "AllOfEmpty", composedRequest, "{}", nil},
+ {"allOf declared args preserved", `{"path":"/tmp/a"}`, "AllOfArgs", composedRequest, `{"path":"/tmp/a"}`, nil},
+ {"ref to empty normalized", `{"reason":"x"}`, "RefEmpty", composedRequest, "{}", nil},
+ {"nested placeholders stripped", `{"opts":{"reason":"x"},"cfg":{"mode":"fast","_":true}}`, "NestedTool", composedRequest, `{"opts":{},"cfg":{"mode":"fast"}}`, nil},
+ {"free-form nested preserved", `{"payload":{"value":1}}`, "FreeFormNested", deepRequest, `{"payload":{"value":1}}`, nil},
+ {"allOf deep merge keeps declared reason", `{"cfg":{"mode":"a","reason":"b"}}`, "AllOfMerge", deepRequest, `{"cfg":{"mode":"a","reason":"b"}}`, nil},
+ {"single union empty normalized", `{"reason":"x"}`, "SingleUnionEmpty", deepRequest, "{}", nil},
+ {"dynamic parent still cleans declared child", `{"extra":1,"opts":{"reason":"x"}}`, "DynamicParent", deepRequest, `{"extra":1,"opts":{}}`, nil},
+ {"ref array items cleaned", `{"items":[{"v":"a","_":true}]}`, "RefArray", deepRequest, `{"items":[{"v":"a"}]}`, nil},
+ {"property ref cleaned", `{"copy":{"reason":"x"},"template":{}}`, "PropRef", deepRequest, `{"copy":{},"template":{}}`, nil},
+ {"allOf dynamic conflict uses translated schema", `{"reason":"x"}`, "AllOfDynamicConflict", extraRequest, "{}", translatedExtra},
+ {"multi-branch union uses translated schema", `{"reason":"x"}`, "MultiUnionEmpty", extraRequest, "{}", translatedExtra},
+ {"identical duplicates cleaned", `{"reason":"x"}`, "DupTool", extraRequest, "{}", nil},
+ {"conflicting duplicates passthrough", `{"path":"/tmp/a"}`, "ConflictingDup", extraRequest, `{"path":"/tmp/a"}`, nil},
+ {"conflicting duplicates keep first-declared cleaning", `{"reason":"x"}`, "ConflictingDup", extraRequest, `{"reason":"x"}`, nil},
+ {"matrix nested arrays cleaned", `{"rows":[[{"reason":"x"}]]}`, "MatrixTool", extraRequest, `{"rows":[[{}]]}`, translatedExtra},
+ {"union declared required reason preserved", `{"choice":{"reason":"maintenance"}}`, "UnionChoice", round4Request, `{"choice":{"reason":"maintenance"}}`, translatedRound4},
+ {"allOf child merge keeps mode", `{"cfg":{"mode":"fast"}}`, "AllOfChildMerge", round4Request, `{"cfg":{"mode":"fast"}}`, translatedRound4},
+ {"nested ref placeholder cleaned", `{"opts":{"reason":"initialize"}}`, "NestedRefTool", round4Request, `{"opts":{}}`, translatedRound4},
+ {"required-only reason preserved", `{"reason":"maintenance"}`, "Explain", round5Request, `{"reason":"maintenance"}`, translatedRound5},
+ {"union branch nested placeholder cleaned", `{"choice":{"opts":{"reason":"initialize"}}}`, "Choose", round5Request, `{"choice":{"opts":{}}}`, translatedRound5},
+ {"gemini route keeps dynamic underscore", `{"flag":"x","_":true}`, "Options", round5Request, `{"flag":"x","_":true}`, translatedRound5},
+ {"implicit additional properties keeps underscore", `{"flag":"x","_":true}`, "PlainOptions", round6Request, `{"flag":"x","_":true}`, translatedRound6},
+ {"closed object still strips executor underscore", `{"flag":"x","_":true}`, "StrictOptions", round6Request, `{"flag":"x"}`, translatedRound6},
+ {"alternative branch dynamic data preserved", `{"choice":{"cfg":{"value":1}}}`, "BranchChoice", round6Request, `{"choice":{"cfg":{"value":1}}}`, translatedRound6},
+ {"alternative branch dynamic reason preserved", `{"cfg":{"reason":"business"}}`, "Configure", round7Request, `{"cfg":{"reason":"business"}}`, translatedRound7},
+ {"pattern property reason preserved", `{"reason":"business"}`, "ExplainPattern", round8Request, `{"reason":"business"}`, translatedRound8},
+ {"pattern property non-placeholder data preserved", `{"x-note":"keep"}`, "PatternData", round8Request, `{"x-note":"keep"}`, translatedRound8},
+ {"union branch pattern child preserved", `{"choice":{"cfg":{"mode":"fast","_":true}}}`, "ConfigureNested", round9Request, `{"choice":{"cfg":{"mode":"fast","_":true}}}`, translatedRound9},
+ {"implicit open branch child preserved", `{"choice":{"tag":"keep","cfg":{"mode":"fast","_":true}}}`, "ImplicitBranch", round10Request, `{"choice":{"tag":"keep","cfg":{"mode":"fast","_":true}}}`, translatedRound10},
+ {"implicit open branch field preserved", `{"choice":{"tag":"keep","reason":"business"}}`, "ChooseImplicit", round11Request, `{"choice":{"tag":"keep","reason":"business"}}`, translatedRound11},
+ {"implicit open branch non-matching object cleaned", `{"choice":{"reason":"x"}}`, "ChooseImplicit", round11Request, `{"choice":{}}`, translatedRound11},
+ {"alternative child candidates keep matched data", `{"choice":{"cfg":{"mode":"fast","reason":"business"}}}`, "ConfigureAlt", round12Request, `{"choice":{"cfg":{"mode":"fast","reason":"business"}}}`, translatedRound12},
+ {"alternative child candidates clean unmatched placeholder", `{"choice":{"cfg":{"reason":"x"}}}`, "ConfigureAlt", round12Request, `{"choice":{"cfg":{}}}`, translatedRound12},
+ {"deep alternative candidates keep matched subtree", `{"choice":{"cfg":{"tag":"keep","opts":{"mode":"fast","_":true}}}}`, "DeepAlt", round13Request, `{"choice":{"cfg":{"tag":"keep","opts":{"mode":"fast","_":true}}}}`, translatedRound13},
+ {"deep alternative candidates clean unmatched subtree", `{"choice":{"cfg":{"opts":{"mode":"fast","_":true}}}}`, "DeepAlt", round13Request, `{"choice":{"cfg":{"opts":{"mode":"fast"}}}}`, translatedRound13},
+ {"free-form alternative child preserved", `{"cfg":{"reason":"business","value":1}}`, "ConfigureAltFree", round14Request, `{"cfg":{"reason":"business","value":1}}`, translatedRound14},
+ {"free-form array candidate preserved", `{"rows":[{"value":1}]}`, "Batch", round15Request, `{"rows":[{"value":1}]}`, translatedRound15},
+ {"strict array placeholder cleaned", `{"rows":[{"reason":"x"}]}`, "BatchStrict", round15Request, `{"rows":[{}]}`, translatedRound15},
+ {"fallback keeps required-only reason", `{"reason":"business"}`, "1", collisionRequest, `{"reason":"business"}`, nil},
+ {"object enum value preserved", `{"reason":"business"}`, "ExplainFixed", enumRequest, `{"reason":"business"}`, translatedEnum},
+ {"object enum with union preserved", `{"reason":"business"}`, "ExplainFixed", enumUnionRequest, `{"reason":"business"}`, translatedEnum},
+ {"array enum elements preserved", `{"rows":[{"reason":"business"}]}`, "BatchEnum", arrayEnumRequest, `{"rows":[{"reason":"business"}]}`, translatedArrayEnum},
+ {"array enum elements preserved fallback", `{"rows":[{"reason":"business"}]}`, "BatchEnum", arrayEnumRequest, `{"rows":[{"reason":"business"}]}`, nil},
+ {"array contains elements preserved", `{"rows":[{"reason":"business"}]}`, "BatchContains", arrayContainsRequest, `{"rows":[{"reason":"business"}]}`, translatedArrayContains},
+ {"array contains elements preserved fallback", `{"rows":[{"reason":"business"}]}`, "BatchContains", arrayContainsRequest, `{"rows":[{"reason":"business"}]}`, nil},
+ {"sibling allOf declaration preserved", `{"cfg":{"reason":"business"}}`, "ConfigureSibling", siblingAllOfRequest, `{"cfg":{"reason":"business"}}`, translatedSiblingAllOf},
+ {"minProperties object preserved", `{"reason":"store","label":"keep"}`, "StoreMetadata", minPropsRequest, `{"reason":"store","label":"keep"}`, translatedMinProps},
+ {"minProperties object preserved fallback", `{"reason":"store","label":"keep"}`, "StoreMetadata", minPropsRequest, `{"reason":"store","label":"keep"}`, nil},
+ {"uniqueItems elements preserved", `{"rows":[{"tag":"same","_":true},{"tag":"same","_":false}]}`, "BatchUnique", uniqueItemsRequest, `{"rows":[{"tag":"same","_":true},{"tag":"same","_":false}]}`, translatedUniqueItems},
+ {"uniqueItems elements preserved fallback", `{"rows":[{"tag":"same","_":true},{"tag":"same","_":false}]}`, "BatchUnique", uniqueItemsRequest, `{"rows":[{"tag":"same","_":true},{"tag":"same","_":false}]}`, nil},
+ {"object enum value preserved fallback", `{"reason":"business"}`, "ExplainFixed", enumRequest, `{"reason":"business"}`, nil},
+ {"object const value preserved", `{"reason":"fixed"}`, "ConstTool", enumRequest, `{"reason":"fixed"}`, translatedEnum},
+ {"disambiguated name uses translated schema", `{"reason":"business"}`, "1", collisionRequest, `{"reason":"business"}`, translatedCollisionDynamic},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := CleanClaudeToolArgumentsWithTranslated(tt.rawArgs, tt.toolName, tt.origReq, tt.transferred); got != tt.want {
+ t.Errorf("CleanClaudeToolArguments(%q, %q) = %q, want %q", tt.rawArgs, tt.toolName, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/util/translator.go b/internal/util/translator.go
index 42596c1c24a..945c9867568 100644
--- a/internal/util/translator.go
+++ b/internal/util/translator.go
@@ -9,6 +9,7 @@ import (
"encoding/hex"
"fmt"
"sort"
+ "strconv"
"strings"
log "github.com/sirupsen/logrus"
@@ -28,6 +29,19 @@ import (
// The function works recursively, building dot-notation paths to each occurrence
// of the specified field throughout the JSON structure.
func Walk(value gjson.Result, path, field string, paths *[]string) {
+ walk(value, path, field, paths, false)
+}
+
+// WalkSchema is Walk for JSON Schema documents: it records the field wherever a schema keyword
+// may appear, but does not descend into literal values (default, example, examples, const, enum),
+// whose contents are client data rather than subschemas. The literal keyword itself is still
+// recorded when it is the searched field, and under a property-name map a key spelled like one is
+// a property name whose subschema is still walked.
+func WalkSchema(value gjson.Result, path, field string, paths *[]string) {
+ walk(value, path, field, paths, true)
+}
+
+func walk(value gjson.Result, path, field string, paths *[]string, schemaAware bool) {
switch value.Type {
case gjson.JSON:
// For JSON objects and arrays, iterate through each child
@@ -48,7 +62,10 @@ func Walk(value gjson.Result, path, field string, paths *[]string) {
if keyStr == field {
*paths = append(*paths, childPath)
}
- Walk(val, childPath, field, paths)
+ if schemaAware && isLiteralValueKeyword(keyStr) && !isPropertyDefinition(path) {
+ return true
+ }
+ walk(val, childPath, field, paths, schemaAware)
return true
})
case gjson.String, gjson.Number, gjson.True, gjson.False, gjson.Null:
@@ -279,6 +296,39 @@ func MapToolName(toolNameMap map[string]string, name string) string {
return name
}
+// MapClaudeToolName restores the client-facing tool name while preferring an exact
+// declared name over canonical (case/leading-underscore-insensitive) restoration.
+// This prevents distinct tools such as "Read" and "read" from being collapsed to
+// the first declaration when the upstream already returned an exact name.
+func MapClaudeToolName(toolNameMap map[string]string, originalRequestRawJSON []byte, name string) string {
+ if name == "" {
+ return name
+ }
+ if len(originalRequestRawJSON) > 0 && gjson.ValidBytes(originalRequestRawJSON) {
+ tools := gjson.GetBytes(originalRequestRawJSON, "tools")
+ if tools.IsArray() {
+ canonical := CanonicalToolName(name)
+ canonicalNames := map[string]bool{}
+ for _, tool := range tools.Array() {
+ declared := strings.TrimSpace(tool.Get("name").String())
+ if declared == "" {
+ declared = strings.TrimSpace(tool.Get("function.name").String())
+ }
+ if declared == name {
+ return name
+ }
+ if declared != "" && CanonicalToolName(declared) == canonical {
+ canonicalNames[declared] = true
+ }
+ }
+ if len(canonicalNames) > 1 {
+ return name
+ }
+ }
+ }
+ return MapToolName(toolNameMap, name)
+}
+
// SanitizedFunctionNameMap builds an original-name → sanitized-name map from request tools.
// Exact duplicate names share a mapping. Distinct names that sanitize to the same value receive
// deterministic hash suffixes so every declaration remains addressable within the 64-byte limit.
@@ -494,3 +544,1251 @@ func RestoreSanitizedToolName(toolNameMap map[string]string, sanitizedName strin
}
return sanitizedName
}
+
+type claudeObjectSchemaInfo struct {
+ properties map[string][]gjson.Result
+ required map[string]bool
+ hasProperties bool
+ allowsDynamic bool
+ forbidsDynamic bool
+ hasPatternProperties bool
+ complex bool
+ unsafeComplex bool
+ declaresObject bool
+ branchInfos []claudeObjectSchemaInfo
+}
+
+func claudeSchemaAllowsDynamic(schema gjson.Result) bool {
+ // patternProperties permit matching keys even when additionalProperties is false
+ // (the latter only restricts keys matching no pattern), so pattern permission is
+ // never cancelled by an additionalProperties: false sibling.
+ if schema.Get("patternProperties").IsObject() && len(schema.Get("patternProperties").Map()) > 0 {
+ return true
+ }
+ additionalProperties := schema.Get("additionalProperties")
+ if additionalProperties.Exists() && additionalProperties.Type != gjson.False {
+ return true
+ }
+ return false
+}
+
+func claudeSchemaHasPatternProperties(schema gjson.Result) bool {
+ return schema.Get("patternProperties").IsObject() && len(schema.Get("patternProperties").Map()) > 0
+}
+
+func claudeSchemaForbidsDynamic(schema gjson.Result) bool {
+ additionalProperties := schema.Get("additionalProperties")
+ return additionalProperties.Exists() && additionalProperties.Type == gjson.False
+}
+
+func claudeResolveLocalRef(root gjson.Result, ref string) (gjson.Result, bool) {
+ if !strings.HasPrefix(ref, "#/") {
+ return gjson.Result{}, false
+ }
+ current := root
+ for _, segment := range strings.Split(strings.TrimPrefix(ref, "#/"), "/") {
+ segment = strings.ReplaceAll(strings.ReplaceAll(segment, "~1", "/"), "~0", "~")
+ next, ok := current.Map()[segment]
+ if !ok {
+ if current.IsArray() {
+ index, errAtoi := strconv.Atoi(segment)
+ if errAtoi != nil || index < 0 || index >= len(current.Array()) {
+ return gjson.Result{}, false
+ }
+ next = current.Array()[index]
+ } else {
+ return gjson.Result{}, false
+ }
+ }
+ current = next
+ }
+ return current, true
+}
+
+func analyzeClaudeObjectSchema(schema gjson.Result, root gjson.Result) claudeObjectSchemaInfo {
+ return analyzeClaudeObjectSchemaDepth(schema, root, 0, map[string]bool{})
+}
+
+func claudeSchemaDeclaresObject(schema gjson.Result) bool {
+ typeValue := schema.Get("type")
+ if typeValue.String() == "object" {
+ return true
+ }
+ if typeValue.IsArray() {
+ for _, item := range typeValue.Array() {
+ if item.String() == "object" {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func claudeUnionBranches(schema gjson.Result) ([]gjson.Result, bool) {
+ var branches []gjson.Result
+ seen := map[string]bool{}
+ for _, keyword := range []string{"anyOf", "oneOf"} {
+ union := schema.Get(keyword)
+ if !union.Exists() {
+ continue
+ }
+ if !union.IsArray() {
+ return nil, false
+ }
+ for _, branch := range union.Array() {
+ branchType := branch.Get("type")
+ if branchType.String() == "null" {
+ continue
+ }
+ if branchType.IsArray() && len(branchType.Array()) == 1 && branchType.Array()[0].String() == "null" {
+ continue
+ }
+ if seen[branch.Raw] {
+ continue
+ }
+ seen[branch.Raw] = true
+ branches = append(branches, branch)
+ }
+ }
+ return branches, true
+}
+
+var claudeAnalyzedSchemaKeywords = map[string]bool{
+ "type": true, "description": true, "title": true, "properties": true,
+ "required": true, "items": true, "additionalProperties": true,
+ "patternProperties": true, "anyOf": true, "oneOf": true, "allOf": true,
+ "$ref": true, "$defs": true, "definitions": true, "nullable": true,
+ "minLength": true, "maxLength": true, "minimum": true, "maximum": true,
+ "exclusiveMinimum": true, "exclusiveMaximum": true, "multipleOf": true,
+ "pattern": true, "format": true, "default": true, "examples": true,
+ "minItems": true, "maxItems": true, "maxProperties": true,
+ "$schema": true, "$id": true, "id": true,
+ "$comment": true, "deprecated": true, "readOnly": true, "writeOnly": true,
+ "example": true, "contentEncoding": true, "contentMediaType": true,
+}
+
+func claudeSchemaHasUnanalyzedKeyword(schema gjson.Result) bool {
+ for key := range schema.Map() {
+ if strings.HasPrefix(key, "x-") {
+ continue
+ }
+ if !claudeAnalyzedSchemaKeywords[key] {
+ return true
+ }
+ }
+ return false
+}
+
+func analyzeClaudeObjectSchemaDepth(schema gjson.Result, root gjson.Result, depth int, visitedRefs map[string]bool) claudeObjectSchemaInfo {
+ info := claudeObjectSchemaInfo{properties: map[string][]gjson.Result{}, required: map[string]bool{}}
+ if depth > 16 || !schema.Exists() || !schema.IsObject() {
+ info.complex = true
+ return info
+ }
+ if claudeSchemaDeclaresObject(schema) {
+ info.declaresObject = true
+ }
+ if claudeSchemaHasUnanalyzedKeyword(schema) {
+ // Any keyword outside the explicitly analyzed set (enum, const, contains,
+ // conditionals, unevaluated*, $dynamicRef, vendor applicators, ...) may
+ // constrain legal values in ways this analyzer does not model. Such schemas
+ // are unsafe-complex (unlike multi-branch unions, whose branches are
+ // collected): no destructive cleaning may run on them at all, even when a
+ // union keyword is also present.
+ info.complex = true
+ info.unsafeComplex = true
+ return info
+ }
+ if schema.Get("anyOf").Exists() || schema.Get("oneOf").Exists() {
+ branches, okBranches := claudeUnionBranches(schema)
+ if !okBranches || len(branches) == 0 {
+ info.complex = true
+ return info
+ }
+ if len(branches) > 1 {
+ // Multi-branch unions stay complex so this level never normalizes arguments,
+ // but collect every branch's declared properties so child recursion and
+ // placeholder protection still see the full original declaration. The
+ // per-branch infos are kept separately because union branches are
+ // alternatives: their dynamic-property policies must not be combined as
+ // simultaneous constraints.
+ for _, branch := range branches {
+ branchInfo := analyzeClaudeObjectSchemaDepth(branch, root, depth+1, visitedRefs)
+ info.branchInfos = append(info.branchInfos, branchInfo)
+ mergeClaudeObjectSchemaInfo(&info, branchInfo)
+ }
+ if claudeSchemaAllowsDynamic(schema) {
+ info.allowsDynamic = true
+ }
+ if claudeSchemaForbidsDynamic(schema) {
+ info.forbidsDynamic = true
+ }
+ if claudeSchemaHasPatternProperties(schema) {
+ info.hasPatternProperties = true
+ }
+ for _, requiredName := range schema.Get("required").Array() {
+ info.required[requiredName.String()] = true
+ }
+ props := schema.Get("properties")
+ if props.IsObject() {
+ info.hasProperties = true
+ info.declaresObject = true
+ for key, value := range props.Map() {
+ info.properties[key] = appendUniqueSchema(info.properties[key], value)
+ }
+ }
+ // Sibling $ref/allOf applicators still constrain this node alongside the
+ // union; collect them too or their declared child properties would be
+ // invisible to protection and child recursion.
+ if ref := schema.Get("$ref"); ref.Exists() {
+ refValue := ref.String()
+ if !visitedRefs[refValue] {
+ if target, ok := claudeResolveLocalRef(root, refValue); ok {
+ visitedRefs[refValue] = true
+ resolved := analyzeClaudeObjectSchemaDepth(target, root, depth+1, visitedRefs)
+ delete(visitedRefs, refValue)
+ mergeClaudeObjectSchemaInfo(&info, resolved)
+ }
+ }
+ }
+ if allOf := schema.Get("allOf"); allOf.IsArray() {
+ for _, branch := range allOf.Array() {
+ branchInfo := analyzeClaudeObjectSchemaDepth(branch, root, depth+1, visitedRefs)
+ mergeClaudeObjectSchemaInfo(&info, branchInfo)
+ }
+ }
+ info.complex = true
+ return info
+ }
+ branchInfo := analyzeClaudeObjectSchemaDepth(branches[0], root, depth+1, visitedRefs)
+ mergeClaudeObjectSchemaInfo(&info, branchInfo)
+ }
+ if ref := schema.Get("$ref"); ref.Exists() {
+ refValue := ref.String()
+ if visitedRefs[refValue] {
+ info.complex = true
+ return info
+ }
+ target, ok := claudeResolveLocalRef(root, refValue)
+ if !ok {
+ info.complex = true
+ return info
+ }
+ visitedRefs[refValue] = true
+ resolved := analyzeClaudeObjectSchemaDepth(target, root, depth+1, visitedRefs)
+ delete(visitedRefs, refValue)
+ mergeClaudeObjectSchemaInfo(&info, resolved)
+ }
+ if allOf := schema.Get("allOf"); allOf.IsArray() {
+ for _, branch := range allOf.Array() {
+ branchInfo := analyzeClaudeObjectSchemaDepth(branch, root, depth+1, visitedRefs)
+ mergeClaudeObjectSchemaInfo(&info, branchInfo)
+ }
+ } else if allOf.Exists() {
+ info.complex = true
+ }
+ if claudeSchemaAllowsDynamic(schema) {
+ info.allowsDynamic = true
+ }
+ if claudeSchemaForbidsDynamic(schema) {
+ info.forbidsDynamic = true
+ }
+ if claudeSchemaHasPatternProperties(schema) {
+ info.hasPatternProperties = true
+ }
+ props := schema.Get("properties")
+ if props.Exists() {
+ if !props.IsObject() {
+ info.complex = true
+ } else {
+ info.hasProperties = true
+ info.declaresObject = true
+ for key, value := range props.Map() {
+ info.properties[key] = appendUniqueSchema(info.properties[key], value)
+ }
+ }
+ }
+ for _, requiredName := range schema.Get("required").Array() {
+ info.required[requiredName.String()] = true
+ }
+ return info
+}
+
+func appendUniqueSchema(schemas []gjson.Result, value gjson.Result) []gjson.Result {
+ for _, existing := range schemas {
+ if existing.Raw == value.Raw {
+ return schemas
+ }
+ }
+ return append(schemas, value)
+}
+
+func mergeClaudeObjectSchemaInfo(dst *claudeObjectSchemaInfo, src claudeObjectSchemaInfo) {
+ if dst.properties == nil {
+ dst.properties = map[string][]gjson.Result{}
+ }
+ if dst.required == nil {
+ dst.required = map[string]bool{}
+ }
+ if src.hasProperties {
+ dst.hasProperties = true
+ for key, values := range src.properties {
+ for _, value := range values {
+ dst.properties[key] = appendUniqueSchema(dst.properties[key], value)
+ }
+ }
+ }
+ for name := range src.required {
+ dst.required[name] = true
+ }
+ dst.branchInfos = append(dst.branchInfos, src.branchInfos...)
+ dst.allowsDynamic = dst.allowsDynamic || src.allowsDynamic
+ dst.forbidsDynamic = dst.forbidsDynamic || src.forbidsDynamic
+ dst.hasPatternProperties = dst.hasPatternProperties || src.hasPatternProperties
+ dst.complex = dst.complex || src.complex
+ dst.unsafeComplex = dst.unsafeComplex || src.unsafeComplex
+ dst.declaresObject = dst.declaresObject || src.declaresObject
+}
+
+func findClaudeToolSchema(originalRequestRawJSON []byte, toolName string) (gjson.Result, gjson.Result, bool) {
+ if toolName == "" || len(originalRequestRawJSON) == 0 || !gjson.ValidBytes(originalRequestRawJSON) {
+ return gjson.Result{}, gjson.Result{}, false
+ }
+ root := gjson.ParseBytes(originalRequestRawJSON)
+ tools := root.Get("tools")
+ if !tools.IsArray() {
+ return gjson.Result{}, root, false
+ }
+ type candidate struct {
+ name string
+ schema gjson.Result
+ found bool
+ }
+ var exact []candidate
+ canonicalToolName := CanonicalToolName(toolName)
+ canonicalMatches := map[string][]candidate{}
+ for _, tool := range tools.Array() {
+ name := strings.TrimSpace(tool.Get("name").String())
+ if name == "" {
+ name = strings.TrimSpace(tool.Get("function.name").String())
+ }
+ if name == "" {
+ continue
+ }
+ schema := tool.Get("input_schema")
+ if !schema.Exists() {
+ schema = tool.Get("parameters")
+ }
+ if !schema.Exists() {
+ schema = tool.Get("function.parameters")
+ }
+ c := candidate{name: name, schema: schema, found: true}
+ if name == toolName {
+ exact = append(exact, c)
+ }
+ if CanonicalToolName(name) == canonicalToolName {
+ canonicalMatches[name] = append(canonicalMatches[name], c)
+ }
+ }
+ if len(exact) == 1 {
+ return exact[0].schema, root, true
+ }
+ if len(exact) > 1 {
+ firstRaw := exact[0].schema.Raw
+ identical := true
+ for _, other := range exact[1:] {
+ if other.schema.Raw != firstRaw {
+ identical = false
+ break
+ }
+ }
+ if identical {
+ return exact[0].schema, root, true
+ }
+ return gjson.Result{}, root, false
+ }
+ if len(canonicalMatches) == 1 {
+ for _, matches := range canonicalMatches {
+ if len(matches) == 1 {
+ return matches[0].schema, root, true
+ }
+ firstRaw := matches[0].schema.Raw
+ identical := true
+ for _, other := range matches[1:] {
+ if other.schema.Raw != firstRaw {
+ identical = false
+ break
+ }
+ }
+ if identical {
+ return matches[0].schema, root, true
+ }
+ }
+ }
+ return gjson.Result{}, root, false
+}
+
+// ClaudeToolIsParameterless reports whether the tool was declared in the original
+// Claude request with an empty or missing object schema. Such tools had a placeholder
+// "reason" property injected for Gemini/Antigravity schema validation, so any returned
+// arguments must be normalized to "{}" before they reach the Claude client. Schemas that
+// allow dynamic additional or pattern properties are never parameterless.
+func ClaudeToolIsParameterless(toolName string, originalRequestRawJSON []byte) bool {
+ schema, root, found := findClaudeToolSchema(originalRequestRawJSON, toolName)
+ if !found {
+ return false
+ }
+ if !schema.Exists() {
+ return true
+ }
+ _ = root
+ info := analyzeClaudeObjectSchema(schema, schema)
+ if info.complex || claudeInfoAnyBranchAllowsDynamic(info) || !info.declaresObject {
+ return false
+ }
+ return len(info.required) == 0 && (!info.hasProperties || len(info.properties) == 0)
+}
+
+func resolveClaudeArrayItemsSchema(schema gjson.Result, root gjson.Result, depth int, visitedRefs map[string]bool) ([]gjson.Result, bool) {
+ if depth > 16 || !schema.Exists() || !schema.IsObject() {
+ return nil, false
+ }
+ var items []gjson.Result
+ deterministic := true
+ collect := func(candidate gjson.Result) {
+ items = appendUniqueSchema(items, candidate)
+ }
+ if ref := schema.Get("$ref"); ref.Exists() {
+ refValue := ref.String()
+ if visitedRefs[refValue] {
+ return nil, false
+ }
+ target, ok := claudeResolveLocalRef(root, refValue)
+ if !ok {
+ return nil, false
+ }
+ visitedRefs[refValue] = true
+ targetItems, targetOK := resolveClaudeArrayItemsSchema(target, root, depth+1, visitedRefs)
+ delete(visitedRefs, refValue)
+ if !targetOK {
+ deterministic = false
+ } else {
+ for _, item := range targetItems {
+ collect(item)
+ }
+ }
+ }
+ if schema.Get("anyOf").Exists() || schema.Get("oneOf").Exists() {
+ branches, okBranches := claudeUnionBranches(schema)
+ if !okBranches || len(branches) != 1 {
+ deterministic = false
+ } else {
+ branchItems, branchOK := resolveClaudeArrayItemsSchema(branches[0], root, depth+1, visitedRefs)
+ if !branchOK {
+ deterministic = false
+ } else {
+ for _, item := range branchItems {
+ collect(item)
+ }
+ }
+ }
+ }
+ if allOf := schema.Get("allOf"); allOf.IsArray() {
+ for _, branch := range allOf.Array() {
+ branchItems, branchOK := resolveClaudeArrayItemsSchema(branch, root, depth+1, visitedRefs)
+ if !branchOK {
+ deterministic = false
+ continue
+ }
+ for _, item := range branchItems {
+ collect(item)
+ }
+ }
+ } else if allOf.Exists() {
+ deterministic = false
+ }
+ if directItems := schema.Get("items"); directItems.Exists() {
+ collect(directItems)
+ }
+ return items, deterministic
+}
+
+func claudeDeclaredPropertyPlaceholders(info claudeObjectSchemaInfo) (bool, bool) {
+ _, reasonDeclared := info.properties["reason"]
+ _, underscoreDeclared := info.properties["_"]
+ return reasonDeclared, underscoreDeclared
+}
+
+func claudeArraySchemaUnsafe(schema gjson.Result, root gjson.Result) bool {
+ return analyzeClaudeObjectSchema(schema, root).unsafeComplex
+}
+
+func cleanClaudeToolArray(argsJSON string, schemas []gjson.Result, root gjson.Result, path string) string {
+ for _, schema := range schemas {
+ if claudeArraySchemaUnsafe(schema, root) {
+ // Value-constraining keywords on the array itself (e.g. enum of whole
+ // arrays) are not analyzed; keep the array untouched.
+ return argsJSON
+ }
+ }
+ var itemSchemas []gjson.Result
+ deterministic := true
+ for _, schema := range schemas {
+ resolvedItems, okItems := resolveClaudeArrayItemsSchema(schema, root, 0, map[string]bool{})
+ if !okItems {
+ deterministic = false
+ continue
+ }
+ if len(resolvedItems) == 0 {
+ // A candidate without an items schema (e.g. free-form "{}") accepts
+ // arbitrary elements; it must stay in the candidate set so element
+ // cleaning sees the free alternative instead of only strict siblings.
+ itemSchemas = appendUniqueSchema(itemSchemas, schema)
+ continue
+ }
+ for _, item := range resolvedItems {
+ itemSchemas = appendUniqueSchema(itemSchemas, item)
+ }
+ }
+ if !deterministic || len(itemSchemas) == 0 {
+ return argsJSON
+ }
+ cleaned := argsJSON
+ elementCount := len(gjson.Get(cleaned, path).Array())
+ for index := 0; index < elementCount; index++ {
+ elementPath := joinPath(path, strconv.Itoa(index))
+ element := gjson.Get(cleaned, elementPath)
+ if element.IsObject() {
+ cleaned = cleanClaudeToolObject(cleaned, itemSchemas, root, elementPath, false)
+ } else if element.IsArray() {
+ cleaned = cleanClaudeToolArray(cleaned, itemSchemas, root, elementPath)
+ }
+ }
+ return cleaned
+}
+
+func cleanClaudeToolObject(argsJSON string, schemas []gjson.Result, root gjson.Result, path string, topLevel bool) string {
+ argsResult := gjson.Get(argsJSON, path)
+ if path == "" {
+ argsResult = gjson.Parse(argsJSON)
+ }
+ if !argsResult.IsObject() || len(schemas) == 0 {
+ return argsJSON
+ }
+ merged := claudeObjectSchemaInfo{properties: map[string][]gjson.Result{}}
+ for _, schema := range schemas {
+ mergeClaudeObjectSchemaInfo(&merged, analyzeClaudeObjectSchema(schema, root))
+ }
+ if merged.complex || !merged.declaresObject {
+ return argsJSON
+ }
+ isEmptySchema := len(merged.required) == 0 && (!merged.hasProperties || len(merged.properties) == 0)
+ allSchemasDeclareObject := true
+ for _, schema := range schemas {
+ if !analyzeClaudeObjectSchema(schema, root).declaresObject {
+ allSchemasDeclareObject = false
+ break
+ }
+ }
+ if isEmptySchema && allSchemasDeclareObject && !claudeInfoAnyBranchAllowsDynamic(merged) && !claudeOriginalsAnyBranchAllowsDynamic(schemas, root) && !claudeOriginalsAnyCandidateAcceptsImplicitly(schemas, root, argsResult) {
+ if len(argsResult.Map()) == 0 {
+ return argsJSON
+ }
+ if topLevel {
+ return "{}"
+ }
+ updated, _ := sjson.SetRaw(argsJSON, path, "{}")
+ return updated
+ }
+ keys := make([]string, 0, len(merged.properties))
+ for key := range merged.properties {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ cleaned := argsJSON
+ for _, key := range keys {
+ propertySchemas := merged.properties[key]
+ childPath := joinPath(path, escapeGJSONPathKey(key))
+ child := gjson.Get(cleaned, childPath)
+ if !child.Exists() {
+ continue
+ }
+ currentForBranch := gjson.Parse(cleaned)
+ if path != "" {
+ currentForBranch = gjson.Get(cleaned, path)
+ }
+ if claudeParentBranchAcceptsChild(merged, key, currentForBranch) || claudeOriginalsMayPermitChild(schemas, root, key, currentForBranch) {
+ continue
+ }
+ if child.IsObject() {
+ cleaned = cleanClaudeToolObject(cleaned, propertySchemas, root, childPath, false)
+ } else if child.IsArray() {
+ cleaned = cleanClaudeToolArray(cleaned, propertySchemas, root, childPath)
+ }
+ }
+ if claudeInfoAnyBranchAllowsDynamic(merged) {
+ return cleaned
+ }
+ current := gjson.Parse(cleaned)
+ if path != "" {
+ current = gjson.Get(cleaned, path)
+ }
+ if !current.IsObject() {
+ return cleaned
+ }
+ reasonDeclared, underscoreDeclared := claudeDeclaredPropertyPlaceholders(merged)
+ for _, placeholder := range []string{"reason", "_"} {
+ if placeholder == "reason" && reasonDeclared {
+ continue
+ }
+ if placeholder == "_" && underscoreDeclared {
+ continue
+ }
+ if merged.required[placeholder] {
+ continue
+ }
+ if claudeInfoAnyBranchAllowsDynamic(merged) || claudeOriginalsAnyBranchAllowsDynamic(schemas, root) {
+ continue
+ }
+ if claudeParentBranchAcceptsChild(merged, placeholder, current) {
+ continue
+ }
+ if claudeOriginalsAnyCandidateAcceptsImplicitly(schemas, root, current) {
+ continue
+ }
+ if _, ok := current.Map()[placeholder]; ok {
+ placeholderPath := joinPath(path, placeholder)
+ cleaned, _ = sjson.Delete(cleaned, placeholderPath)
+ }
+ }
+ parsedCleaned := gjson.Parse(cleaned)
+ if path != "" {
+ parsedCleaned = gjson.Get(cleaned, path)
+ }
+ if parsedCleaned.IsObject() && len(parsedCleaned.Map()) == 0 && path == "" {
+ return "{}"
+ }
+ return cleaned
+}
+
+func findTranslatedClaudeToolSchemas(translatedRequestRawJSON []byte, toolName string, originalRequestRawJSON []byte) []gjson.Result {
+ if toolName == "" || len(translatedRequestRawJSON) == 0 || !gjson.ValidBytes(translatedRequestRawJSON) {
+ return nil
+ }
+ root := gjson.ParseBytes(translatedRequestRawJSON)
+ type namedSchema struct {
+ name string
+ schema gjson.Result
+ }
+ var named []namedSchema
+ collectNamed := func(tools gjson.Result) {
+ if !tools.IsArray() {
+ return
+ }
+ for _, toolGroup := range tools.Array() {
+ decls := toolGroup.Get("functionDeclarations")
+ if !decls.IsArray() {
+ continue
+ }
+ for _, decl := range decls.Array() {
+ name := strings.TrimSpace(decl.Get("name").String())
+ if name == "" {
+ continue
+ }
+ schema := decl.Get("parametersJsonSchema")
+ if !schema.Exists() {
+ schema = decl.Get("parameters")
+ }
+ if schema.Exists() && schema.IsObject() {
+ named = append(named, namedSchema{name: name, schema: schema})
+ }
+ }
+ }
+ }
+ collectNamed(root.Get("tools"))
+ collectNamed(root.Get("request.tools"))
+ if len(named) == 0 {
+ return nil
+ }
+ canonicalToolName := CanonicalToolName(toolName)
+ sanitizedToolName := SanitizeFunctionName(toolName)
+ // Request translation renames colliding tool names via the request-wide
+ // disambiguated map (hash suffixes); the response name is the restored
+ // client name, so match the actually-sent name when it can be derived.
+ sentName := ""
+ if forward := SanitizedFunctionNameMap(originalRequestRawJSON); forward != nil {
+ sentName = forward[toolName]
+ }
+ var exact []gjson.Result
+ canonicalByName := map[string][]gjson.Result{}
+ for _, entry := range named {
+ if entry.name == toolName || (sentName != "" && entry.name == sentName) {
+ exact = append(exact, entry.schema)
+ }
+ if CanonicalToolName(entry.name) == canonicalToolName || entry.name == sanitizedToolName || SanitizeFunctionName(entry.name) == sanitizedToolName {
+ canonicalByName[entry.name] = append(canonicalByName[entry.name], entry.schema)
+ }
+ }
+ if len(exact) > 0 {
+ return exact
+ }
+ if len(canonicalByName) == 1 {
+ for _, schemas := range canonicalByName {
+ return schemas
+ }
+ }
+ return nil
+}
+
+func claudeOriginalDeclaresPropertyDeep(schema gjson.Result, root gjson.Result, property string, depth int, visitedRefs map[string]bool) bool {
+ if depth > 16 || !schema.Exists() || !schema.IsObject() {
+ return false
+ }
+ if ref := schema.Get("$ref"); ref.Exists() {
+ refValue := ref.String()
+ if !visitedRefs[refValue] {
+ if target, ok := claudeResolveLocalRef(root, refValue); ok {
+ visitedRefs[refValue] = true
+ declared := claudeOriginalDeclaresPropertyDeep(target, root, property, depth+1, visitedRefs)
+ delete(visitedRefs, refValue)
+ if declared {
+ return true
+ }
+ }
+ }
+ }
+ if _, ok := schema.Get("properties").Map()[property]; ok {
+ return true
+ }
+ for _, requiredName := range schema.Get("required").Array() {
+ if requiredName.String() == property {
+ return true
+ }
+ }
+ for _, keyword := range []string{"allOf", "anyOf", "oneOf"} {
+ for _, branch := range schema.Get(keyword).Array() {
+ if claudeOriginalDeclaresPropertyDeep(branch, root, property, depth+1, visitedRefs) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func claudeInfoAnyBranchAllowsDynamic(info claudeObjectSchemaInfo) bool {
+ if info.hasPatternProperties {
+ return true
+ }
+ if len(info.branchInfos) == 0 {
+ return info.allowsDynamic && !info.forbidsDynamic
+ }
+ for _, branch := range info.branchInfos {
+ if claudeInfoAnyBranchAllowsDynamic(branch) {
+ return true
+ }
+ }
+ return info.allowsDynamic && !info.forbidsDynamic
+}
+
+func claudeBranchMayAccept(info claudeObjectSchemaInfo, key string, current gjson.Result) bool {
+ // patternProperties permit matching keys regardless of additionalProperties.
+ if info.hasPatternProperties {
+ return true
+ }
+ // A branch that does not explicitly forbid additional properties accepts the
+ // key by JSON Schema default. To avoid treating every unconstrained sibling
+ // branch as permission (which would mask genuine placeholder cleaning), this
+ // implicit permission only counts when the current object matches the branch
+ // by carrying every property the branch requires.
+ if !info.forbidsDynamic {
+ matches := true
+ for requiredName := range info.required {
+ if _, ok := current.Map()[requiredName]; !ok {
+ matches = false
+ break
+ }
+ }
+ if matches {
+ return true
+ }
+ }
+ if info.allowsDynamic && !info.forbidsDynamic {
+ return true
+ }
+ for _, branch := range info.branchInfos {
+ _, branchDeclaresKey := branch.properties[key]
+ if branchDeclaresKey {
+ // This branch constrains the key with its own explicit schema,
+ // which is already part of the collected candidates.
+ continue
+ }
+ if claudeBranchMayAccept(branch, key, current) {
+ return true
+ }
+ }
+ return false
+}
+
+func claudeParentBranchAcceptsChild(originalInfo claudeObjectSchemaInfo, key string, current gjson.Result) bool {
+ if len(originalInfo.branchInfos) == 0 {
+ return false
+ }
+ for _, branch := range originalInfo.branchInfos {
+ _, branchDeclaresKey := branch.properties[key]
+ if branchDeclaresKey {
+ continue
+ }
+ if claudeBranchMayAccept(branch, key, current) {
+ return true
+ }
+ }
+ return false
+}
+
+func claudeCandidateMayPermitChild(info claudeObjectSchemaInfo, key string, current gjson.Result) bool {
+ if _, declaresKey := info.properties[key]; declaresKey {
+ return false
+ }
+ if info.hasPatternProperties {
+ return true
+ }
+ if info.forbidsDynamic {
+ return false
+ }
+ for requiredName := range info.required {
+ if _, ok := current.Map()[requiredName]; !ok {
+ return false
+ }
+ }
+ return true
+}
+
+func claudeOriginalsMayPermitChild(originals []gjson.Result, root gjson.Result, key string, current gjson.Result) bool {
+ if len(originals) <= 1 {
+ return false
+ }
+ for _, original := range originals {
+ info := analyzeClaudeObjectSchema(original, root)
+ if info.complex {
+ continue
+ }
+ if claudeCandidateMayPermitChild(info, key, current) {
+ return true
+ }
+ }
+ return false
+}
+
+func claudeOriginalsAnyCandidateAcceptsImplicitly(originals []gjson.Result, root gjson.Result, current gjson.Result) bool {
+ if len(originals) <= 1 {
+ return false
+ }
+ for _, original := range originals {
+ info := analyzeClaudeObjectSchema(original, root)
+ if info.complex || info.forbidsDynamic {
+ continue
+ }
+ matches := true
+ for requiredName := range info.required {
+ if _, ok := current.Map()[requiredName]; !ok {
+ matches = false
+ break
+ }
+ }
+ if matches {
+ return true
+ }
+ }
+ return false
+}
+
+func claudeOriginalsAnyBranchAllowsDynamic(originals []gjson.Result, root gjson.Result) bool {
+ for _, original := range originals {
+ if info := analyzeClaudeObjectSchema(original, root); claudeInfoAnyBranchAllowsDynamic(info) {
+ return true
+ }
+ }
+ return false
+}
+
+func mergedClaudeOriginalInfo(originals []gjson.Result, root gjson.Result) claudeObjectSchemaInfo {
+ merged := claudeObjectSchemaInfo{properties: map[string][]gjson.Result{}}
+ for _, original := range originals {
+ mergeClaudeObjectSchemaInfo(&merged, analyzeClaudeObjectSchema(original, root))
+ }
+ return merged
+}
+
+func cleanClaudeToolArgumentsByTranslatedSchema(argsJSON string, translatedSchemas []gjson.Result, originals []gjson.Result, root gjson.Result, path string, topLevel bool) string {
+ current := gjson.Parse(argsJSON)
+ if path != "" {
+ current = gjson.Get(argsJSON, path)
+ }
+ if !current.IsObject() || len(translatedSchemas) == 0 || len(originals) == 0 {
+ return argsJSON
+ }
+ for _, original := range originals {
+ if !original.IsObject() {
+ return argsJSON
+ }
+ }
+ originalInfo := mergedClaudeOriginalInfo(originals, root)
+ if originalInfo.unsafeComplex {
+ return argsJSON
+ }
+ if originalInfo.complex {
+ // Multi-branch unions in the original cannot be analyzed declaratively; fall through
+ // and rely on the translated schema, which records the actually-sent branches.
+ hasUnion := false
+ for _, original := range originals {
+ if original.Get("anyOf").IsArray() || original.Get("oneOf").IsArray() {
+ hasUnion = true
+ }
+ }
+ if !hasUnion {
+ return argsJSON
+ }
+ }
+ cleaned := argsJSON
+ // Recurse into declared children using matching translated child schemas.
+ if originalInfo.hasProperties {
+ keys := make([]string, 0, len(originalInfo.properties))
+ for key := range originalInfo.properties {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ for _, key := range keys {
+ childPath := joinPath(path, escapeGJSONPathKey(key))
+ child := gjson.Get(cleaned, childPath)
+ if !child.Exists() {
+ continue
+ }
+ var translatedChildren []gjson.Result
+ for _, translated := range translatedSchemas {
+ translatedInfo := analyzeClaudeObjectSchema(translated, translated)
+ for _, childSchema := range translatedInfo.properties[key] {
+ translatedChildren = appendUniqueSchema(translatedChildren, childSchema)
+ }
+ }
+ if len(translatedChildren) == 0 {
+ continue
+ }
+ originalChildren := originalInfo.properties[key]
+ currentForBranch := gjson.Parse(cleaned)
+ if path != "" {
+ currentForBranch = gjson.Get(cleaned, path)
+ }
+ if claudeParentBranchAcceptsChild(originalInfo, key, currentForBranch) || claudeOriginalsMayPermitChild(originals, root, key, currentForBranch) {
+ // Another alternative branch/candidate may accept this key without an
+ // explicit property schema (dynamic, pattern, or implicit-open matched
+ // permission); the collected explicit candidates do not constrain it
+ // then, so skip destructive recursion into this subtree.
+ continue
+ }
+ if child.IsObject() && len(originalChildren) > 0 {
+ cleaned = cleanClaudeToolArgumentsByTranslatedSchema(cleaned, translatedChildren, originalChildren, root, childPath, false)
+ } else if child.IsArray() && len(originalChildren) > 0 {
+ cleaned = cleanClaudeTranslatedArray(cleaned, translatedChildren, originalChildren, root, childPath)
+ }
+ }
+ }
+ currentAfterChildren := gjson.Parse(cleaned)
+ if path != "" {
+ currentAfterChildren = gjson.Get(cleaned, path)
+ }
+ if !currentAfterChildren.IsObject() {
+ return cleaned
+ }
+ for _, placeholder := range []string{"reason", "_"} {
+ if _, declared := originalInfo.properties[placeholder]; declared {
+ continue
+ }
+ if originalInfo.required[placeholder] {
+ continue
+ }
+ if claudeInfoAnyBranchAllowsDynamic(originalInfo) || claudeOriginalsAnyBranchAllowsDynamic(originals, root) {
+ // Some alternative branch (or candidate schema) accepts dynamic keys, so
+ // this value may be legitimate user data under that branch even though
+ // another branch received an injected placeholder. Never delete it.
+ continue
+ }
+ if claudeOriginalsAnyCandidateAcceptsImplicitly(originals, root, currentAfterChildren) {
+ // Alternative candidate child schemas extracted from different union
+ // branches: one candidate that the current object matches accepts the
+ // key implicitly, so the value may be legitimate under that candidate.
+ continue
+ }
+ if claudeParentBranchAcceptsChild(originalInfo, placeholder, currentAfterChildren) {
+ // An alternative branch that the current object matches (it carries all
+ // of the branch's required keys) accepts this key by implicit-open or
+ // pattern permission, so the value may be legitimate under that branch.
+ continue
+ }
+ declaredDeep := false
+ for _, original := range originals {
+ if claudeOriginalDeclaresPropertyDeep(original, root, placeholder, 0, map[string]bool{}) {
+ declaredDeep = true
+ break
+ }
+ }
+ if declaredDeep {
+ continue
+ }
+ if _, ok := currentAfterChildren.Map()[placeholder]; !ok {
+ continue
+ }
+ injected := false
+ for _, translated := range translatedSchemas {
+ translatedInfo := analyzeClaudeObjectSchema(translated, translated)
+ placeholderSchemas, declaredTranslated := translatedInfo.properties[placeholder]
+ if !declaredTranslated {
+ continue
+ }
+ if placeholder == "reason" {
+ for _, placeholderSchema := range placeholderSchemas {
+ required := false
+ for _, req := range translated.Get("required").Array() {
+ if req.String() == "reason" {
+ required = true
+ }
+ }
+ if required || placeholderSchema.Get("description").String() == placeholderReasonDescription {
+ injected = true
+ }
+ }
+ // Also check required on union branches flattened into the analyzed info.
+ if !injected {
+ for _, req := range translated.Get("required").Array() {
+ if req.String() == "reason" {
+ injected = true
+ }
+ }
+ }
+ } else {
+ for _, req := range translated.Get("required").Array() {
+ if req.String() == "_" {
+ injected = true
+ }
+ }
+ // The "_" placeholder is also marked required by addEmptySchemaPlaceholder;
+ // when branch flattening drops required, its boolean type is the fallback signal
+ // only for nested objects. At the top level, the Antigravity executor wraps tool
+ // schemas before its second sanitization pass, which can also inject "_" after
+ // translation; that post-translation injection is handled below by re-running
+ // the same schema cleaning on the translated schema.
+ if !injected && !topLevel {
+ for _, placeholderSchema := range placeholderSchemas {
+ if placeholderSchema.Get("type").String() == "boolean" {
+ injected = true
+ }
+ }
+ }
+ }
+ }
+ // Union branches: analyzeClaudeObjectSchema only resolves single-branch unions,
+ // so also scan raw union branches. A required placeholder alone does not prove
+ // injection (the original may legitimately require it in a branch, which the
+ // deep-declaration check above already excluded); here the original declares the
+ // field nowhere, so a required occurrence in a sent branch is the injected one.
+ if !injected {
+ for _, translated := range translatedSchemas {
+ for _, keyword := range []string{"anyOf", "oneOf"} {
+ for _, branch := range translated.Get(keyword).Array() {
+ branchInfo := analyzeClaudeObjectSchema(branch, branch)
+ if _, declaredBranch := branchInfo.properties[placeholder]; !declaredBranch {
+ continue
+ }
+ for _, req := range branch.Get("required").Array() {
+ if req.String() == placeholder {
+ injected = true
+ }
+ }
+ }
+ }
+ }
+ }
+ // The Antigravity executor re-sanitizes tool schemas after translation (wrapping
+ // them so top-level objects become nested), which can inject placeholders that the
+ // translated request seen here does not yet contain. Re-running the same cleaning
+ // on the translated schema reproduces that final sent schema; if the placeholder
+ // appears there as required, it was injected post-translation. This simulation is
+ // only applied to closed objects (additionalProperties: false): open objects may
+ // legitimately carry the key as user data on routes without executor injection,
+ // and deleting based on a simulated injection would lose real arguments there.
+ if !injected && topLevel && originalInfo.forbidsDynamic {
+ for _, translated := range translatedSchemas {
+ // Mirror the executor's cleanNestedSchema wrapping: the schema is nested
+ // under a wrapper key before cleaning, so its top-level object counts as
+ // nested and can receive the "_" placeholder.
+ wrapped, errWrap := sjson.SetRaw("{}", "schema", translated.Raw)
+ if errWrap != nil {
+ continue
+ }
+ recleaned := gjson.Get(CleanJSONSchemaForAntigravity(wrapped), "schema")
+ if !recleaned.Exists() {
+ continue
+ }
+ recleanedInfo := analyzeClaudeObjectSchema(recleaned, recleaned)
+ if _, declaredRecleaned := recleanedInfo.properties[placeholder]; !declaredRecleaned {
+ continue
+ }
+ for _, req := range recleaned.Get("required").Array() {
+ if req.String() == placeholder {
+ injected = true
+ }
+ }
+ }
+ }
+ if injected {
+ cleaned, _ = sjson.Delete(cleaned, joinPath(path, placeholder))
+ }
+ }
+ // Parameterless tools: if the original declared no properties and no candidate
+ // schema (including alternative union branches that supplied this child) allows
+ // dynamic data, any remaining keys can only be injected placeholders; normalize
+ // to "{}". A free-form alternative candidate ("{}" or any candidate that does
+ // not declare an object at all) accepts arbitrary data, so the whole-object
+ // normalization must not run when one is present.
+ allCandidatesDeclareObject := true
+ for _, original := range originals {
+ if !analyzeClaudeObjectSchema(original, root).declaresObject {
+ allCandidatesDeclareObject = false
+ break
+ }
+ }
+ currentForNormalize := gjson.Parse(cleaned)
+ if path != "" {
+ currentForNormalize = gjson.Get(cleaned, path)
+ }
+ if !originalInfo.complex && allCandidatesDeclareObject && !claudeInfoAnyBranchAllowsDynamic(originalInfo) && !claudeOriginalsAnyBranchAllowsDynamic(originals, root) && !claudeOriginalsAnyCandidateAcceptsImplicitly(originals, root, currentForNormalize) && originalInfo.declaresObject && len(originalInfo.required) == 0 && (!originalInfo.hasProperties || len(originalInfo.properties) == 0) {
+ if topLevel {
+ return "{}"
+ }
+ updated, _ := sjson.SetRaw(cleaned, path, "{}")
+ return updated
+ }
+ parsedCleaned := gjson.Parse(cleaned)
+ if path != "" {
+ parsedCleaned = gjson.Get(cleaned, path)
+ }
+ if parsedCleaned.IsObject() && len(parsedCleaned.Map()) == 0 && path == "" {
+ return "{}"
+ }
+ _ = root
+ return cleaned
+}
+
+func cleanClaudeTranslatedArray(argsJSON string, translatedArraySchemas []gjson.Result, originalArraySchemas []gjson.Result, root gjson.Result, path string) string {
+ for _, schema := range originalArraySchemas {
+ if claudeArraySchemaUnsafe(schema, root) {
+ return argsJSON
+ }
+ }
+ var translatedItems []gjson.Result
+ var originalItems []gjson.Result
+ deterministic := true
+ for _, schema := range translatedArraySchemas {
+ items, okItems := resolveClaudeArrayItemsSchema(schema, schema, 0, map[string]bool{})
+ if !okItems {
+ deterministic = false
+ }
+ for _, item := range items {
+ translatedItems = appendUniqueSchema(translatedItems, item)
+ }
+ }
+ for _, schema := range originalArraySchemas {
+ items, okItems := resolveClaudeArrayItemsSchema(schema, root, 0, map[string]bool{})
+ if !okItems {
+ deterministic = false
+ }
+ if okItems && len(items) == 0 {
+ originalItems = appendUniqueSchema(originalItems, schema)
+ continue
+ }
+ for _, item := range items {
+ originalItems = appendUniqueSchema(originalItems, item)
+ }
+ }
+ if !deterministic || len(translatedItems) == 0 || len(originalItems) == 0 {
+ return argsJSON
+ }
+ cleaned := argsJSON
+ elementCount := len(gjson.Get(cleaned, path).Array())
+ for index := 0; index < elementCount; index++ {
+ elementPath := joinPath(path, strconv.Itoa(index))
+ element := gjson.Get(cleaned, elementPath)
+ if element.IsObject() {
+ cleaned = cleanClaudeToolArgumentsByTranslatedSchema(cleaned, translatedItems, originalItems, root, elementPath, false)
+ } else if element.IsArray() {
+ cleaned = cleanClaudeTranslatedArray(cleaned, translatedItems, originalItems, root, elementPath)
+ }
+ }
+ return cleaned
+}
+
+// CleanClaudeToolArgumentsWithTranslated strips placeholder arguments ("reason" or "_")
+// only when the translated (actually-sent) tool schema shows that the placeholder was
+// injected for that object (present in the translated schema with the placeholder
+// description or marked required, while absent from the original declaration). When no
+// translated schema is available it falls back to the original-schema inference used by
+// CleanClaudeToolArguments. Parameterless tools are still normalized to "{}" because a
+// strict Claude client rejects any argument for them.
+func CleanClaudeToolArgumentsWithTranslated(rawArgs string, toolName string, originalRequestRawJSON []byte, translatedRequestRawJSON []byte) string {
+ if translatedSchemas := findTranslatedClaudeToolSchemas(translatedRequestRawJSON, toolName, originalRequestRawJSON); len(translatedSchemas) > 0 {
+ rawArgs = strings.TrimSpace(rawArgs)
+ if rawArgs == "" || rawArgs == "{}" {
+ return "{}"
+ }
+ if !gjson.Valid(rawArgs) || !gjson.Parse(rawArgs).IsObject() {
+ return rawArgs
+ }
+ schema, _, found := findClaudeToolSchema(originalRequestRawJSON, toolName)
+ if !found {
+ return rawArgs
+ }
+ if !schema.Exists() {
+ return "{}"
+ }
+ return cleanClaudeToolArgumentsByTranslatedSchema(rawArgs, translatedSchemas, []gjson.Result{schema}, schema, "", true)
+ }
+ return CleanClaudeToolArguments(rawArgs, toolName, originalRequestRawJSON)
+}
+
+// CleanClaudeToolArguments strips placeholder arguments ("reason" or "_") injected during
+// schema normalization for empty object schemas when returning responses to Claude clients.
+// If the tool was declared with empty or missing properties, the arguments are normalized
+// to "{}". Placeholder fields are also stripped recursively from declared nested object
+// properties when they were not declared there. Free-form nested schemas that do not declare
+// an object type or properties are preserved because no placeholder is injected for them.
+// Tools that legitimately declared those fields or use union schemas that cannot be resolved
+// deterministically are left untouched. Deterministic allOf, single-branch unions, and local
+// $ref schemas are resolved within the tool schema scope, matching request translation.
+func CleanClaudeToolArguments(rawArgs string, toolName string, originalRequestRawJSON []byte) string {
+ rawArgs = strings.TrimSpace(rawArgs)
+ if rawArgs == "" || rawArgs == "{}" {
+ return "{}"
+ }
+ if !gjson.Valid(rawArgs) {
+ return rawArgs
+ }
+ schema, root, found := findClaudeToolSchema(originalRequestRawJSON, toolName)
+ if !found {
+ return rawArgs
+ }
+ if !schema.Exists() {
+ return "{}"
+ }
+ argsResult := gjson.Parse(rawArgs)
+ if !argsResult.IsObject() {
+ return rawArgs
+ }
+ _ = root
+ info := analyzeClaudeObjectSchema(schema, schema)
+ if info.complex || !info.declaresObject {
+ return rawArgs
+ }
+ if len(info.required) == 0 && (!info.hasProperties || len(info.properties) == 0) && !claudeInfoAnyBranchAllowsDynamic(info) {
+ return "{}"
+ }
+ return cleanClaudeToolObject(rawArgs, []gjson.Result{schema}, schema, "", true)
+}
diff --git a/internal/watcher/auth_file_snapshot.go b/internal/watcher/auth_file_snapshot.go
new file mode 100644
index 00000000000..4162e6060db
--- /dev/null
+++ b/internal/watcher/auth_file_snapshot.go
@@ -0,0 +1,51 @@
+package watcher
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/watcher/synthesizer"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+)
+
+// SnapshotAuthFile re-synthesizes one source file without consulting cached auth state.
+// Parse errors are surfaced so deletion reconciliation never relies on fallback auths.
+func (w *Watcher) SnapshotAuthFile(path string) ([]*coreauth.Auth, error) {
+ if w == nil {
+ return nil, fmt.Errorf("file auth snapshot is unavailable")
+ }
+ w.clientsMutex.RLock()
+ cfg := w.config
+ authDir := w.authDir
+ parser := w.pluginAuthParser
+ w.clientsMutex.RUnlock()
+ if cfg == nil || authDir == "" {
+ return nil, fmt.Errorf("file auth snapshot is not configured")
+ }
+ // Management uploads use absolute paths even when authDir is configured
+ // relatively. Keep both absolute so synthesis retains file-relative IDs.
+ absoluteAuthDir, errAbsAuthDir := filepath.Abs(authDir)
+ if errAbsAuthDir != nil {
+ return nil, fmt.Errorf("resolve auth directory: %w", errAbsAuthDir)
+ }
+ absolutePath, errAbsPath := filepath.Abs(path)
+ if errAbsPath != nil {
+ return nil, fmt.Errorf("resolve auth file path: %w", errAbsPath)
+ }
+ authDir = filepath.Clean(absoluteAuthDir)
+ path = filepath.Clean(absolutePath)
+ data, errReadFile := os.ReadFile(path)
+ if errReadFile != nil {
+ return nil, fmt.Errorf("read auth file: %w", errReadFile)
+ }
+ return synthesizer.SynthesizeAuthFile(&synthesizer.SynthesisContext{
+ Config: cfg,
+ AuthDir: authDir,
+ Now: time.Now(),
+ IDGenerator: synthesizer.NewStableIDGenerator(),
+ PluginAuthParser: parser,
+ StrictFileParsing: true,
+ }, path, data)
+}
diff --git a/internal/watcher/auth_file_snapshot_test.go b/internal/watcher/auth_file_snapshot_test.go
new file mode 100644
index 00000000000..5eca6e4f0db
--- /dev/null
+++ b/internal/watcher/auth_file_snapshot_test.go
@@ -0,0 +1,94 @@
+package watcher
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/watcher/synthesizer"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+)
+
+func TestSnapshotAuthFileRelativeAuthDir(t *testing.T) {
+ // Keep the fixture on the working directory's volume without changing cwd.
+ authDir, errMkdirTemp := os.MkdirTemp(".", "snapshot-relative-authdir-")
+ if errMkdirTemp != nil {
+ t.Fatal(errMkdirTemp)
+ }
+ t.Cleanup(func() {
+ if errRemoveAll := os.RemoveAll(authDir); errRemoveAll != nil {
+ t.Errorf("remove auth directory: %v", errRemoveAll)
+ }
+ })
+ absoluteAuthDir, errAbs := filepath.Abs(authDir)
+ if errAbs != nil {
+ t.Fatal(errAbs)
+ }
+ absolutePath := filepath.Join(absoluteAuthDir, "account.json")
+ if errWriteFile := os.WriteFile(absolutePath, []byte(`{"type":"codex"}`), 0o600); errWriteFile != nil {
+ t.Fatal(errWriteFile)
+ }
+ w := &Watcher{authDir: authDir, config: &config.Config{AuthDir: authDir}}
+ for _, path := range []string{absolutePath, filepath.Join(authDir, "account.json")} {
+ auths, errSnapshotAuthFile := w.SnapshotAuthFile(path)
+ if errSnapshotAuthFile != nil {
+ t.Fatal(errSnapshotAuthFile)
+ }
+ if len(auths) != 1 {
+ t.Fatalf("snapshot path=%q: got %d auths, want 1", path, len(auths))
+ }
+ if auths[0].ID != "account.json" || auths[0].Attributes[coreauth.AttributePath] != absolutePath {
+ t.Fatalf("snapshot path=%q: ID=%q, source=%q; want account.json from %q", path, auths[0].ID, auths[0].Attributes[coreauth.AttributePath], absolutePath)
+ }
+ }
+}
+
+func TestSnapshotAuthFile(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ data string
+ parser synthesizer.PluginAuthParser
+ wantID string
+ wantErr bool
+ missing bool
+ }{
+ {name: "builtin_relative_id", data: `{"type":"codex"}`, wantID: "account.json"},
+ {name: "plugin_id", data: `{"type":"plugin","ids":["b"]}`, parser: &fileDeleteTestParser{}, wantID: "b"},
+ {name: "empty", data: ""},
+ {name: "malformed_json", data: `{"type":`, wantErr: true},
+ {name: "empty_plugin_result", data: `{"type":"plugin","ids":[]}`, parser: &fileDeleteTestParser{}},
+ {name: "plugin_error_no_builtin_fallback", data: `{"type":"codex"}`, parser: &fileDeleteTestParser{err: fmt.Errorf("simulated parser failure")}, wantErr: true},
+ {name: "missing_file", missing: true, wantErr: true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "account.json")
+ if !tc.missing {
+ if errWriteFile := os.WriteFile(path, []byte(tc.data), 0o600); errWriteFile != nil {
+ t.Fatal(errWriteFile)
+ }
+ }
+ // Another file must never contribute IDs to the single-file snapshot.
+ otherPath := filepath.Join(dir, "other.json")
+ if errWriteFile := os.WriteFile(otherPath, []byte(`{"type":"plugin","ids":["a"]}`), 0o600); errWriteFile != nil {
+ t.Fatal(errWriteFile)
+ }
+ w := &Watcher{authDir: dir, config: &config.Config{AuthDir: dir}, pluginAuthParser: tc.parser}
+ auths, errSnapshotAuthFile := w.SnapshotAuthFile(path)
+ if (errSnapshotAuthFile != nil) != tc.wantErr {
+ t.Fatalf("snapshot error=%v, want error=%t", errSnapshotAuthFile, tc.wantErr)
+ }
+ if tc.wantID == "" {
+ if len(auths) != 0 {
+ t.Fatalf("expected no reliable auths, got %+v", auths)
+ }
+ return
+ }
+ if len(auths) != 1 || auths[0].ID != tc.wantID || auths[0].Attributes[coreauth.AttributePath] != path {
+ t.Fatalf("snapshot=%+v, want only ID %q from %q", auths, tc.wantID, path)
+ }
+ })
+ }
+}
diff --git a/internal/watcher/clients.go b/internal/watcher/clients.go
index 1f4800e0e3e..5fb0c1969c2 100644
--- a/internal/watcher/clients.go
+++ b/internal/watcher/clients.go
@@ -8,6 +8,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
+ "maps"
"os"
"path/filepath"
"strings"
@@ -38,20 +39,24 @@ func (w *Watcher) reloadClients(rescanAuth bool, affectedOAuthProviders []string
w.clientsMutex.Lock()
if w.currentAuths != nil {
filtered := make(map[string]*coreauth.Auth, len(w.currentAuths))
+ pendingReload := make(map[string]*coreauth.Auth)
for id, auth := range w.currentAuths {
if auth == nil {
continue
}
provider := strings.ToLower(strings.TrimSpace(auth.Provider))
if _, match := matchProvider(provider, affectedOAuthProviders); match {
+ pendingReload[id] = auth
continue
}
filtered[id] = auth
}
w.currentAuths = filtered
+ w.pendingProviderReloadAuths = pendingReload
log.Debugf("applying oauth-excluded-models to providers %v", affectedOAuthProviders)
} else {
w.currentAuths = nil
+ w.pendingProviderReloadAuths = nil
}
w.clientsMutex.Unlock()
}
@@ -80,6 +85,7 @@ func (w *Watcher) reloadClients(rescanAuth bool, affectedOAuthProviders []string
newAuthContents = make(map[string]*coreauth.Auth)
}
newFileAuthsByPath := make(map[string]map[string]*coreauth.Auth)
+ var incompleteRescanPaths map[string]struct{}
w.clientsMutex.RLock()
parser := w.pluginAuthParser
@@ -101,37 +107,74 @@ func (w *Watcher) reloadClients(rescanAuth bool, affectedOAuthProviders []string
continue
}
fullPath := filepath.Join(resolvedAuthDir, name)
- if data, errReadFile := os.ReadFile(fullPath); errReadFile == nil && len(data) > 0 {
- sum := sha256.Sum256(data)
- normalizedPath := w.normalizeAuthPath(fullPath)
- newAuthHashes[normalizedPath] = hex.EncodeToString(sum[:])
- // Parse and cache auth content for future diff comparisons (debug only).
- if cacheAuthContents {
- var auth coreauth.Auth
- if errParse := json.Unmarshal(data, &auth); errParse == nil {
- newAuthContents[normalizedPath] = &auth
- }
+ data, errReadFile := os.ReadFile(fullPath)
+ if errReadFile != nil {
+ continue
+ }
+ normalizedPath := w.normalizeAuthPath(fullPath)
+ if !json.Valid(data) {
+ if incompleteRescanPaths == nil {
+ incompleteRescanPaths = make(map[string]struct{})
}
- ctx := &synthesizer.SynthesisContext{
- Config: cfg,
- AuthDir: resolvedAuthDir,
- Now: time.Now(),
- IDGenerator: synthesizer.NewStableIDGenerator(),
- PluginAuthParser: parser,
+ incompleteRescanPaths[normalizedPath] = struct{}{}
+ continue
+ }
+ sum := sha256.Sum256(data)
+ newAuthHashes[normalizedPath] = hex.EncodeToString(sum[:])
+ // Parse and cache auth content for future diff comparisons (debug only).
+ if cacheAuthContents {
+ var auth coreauth.Auth
+ if errParse := json.Unmarshal(data, &auth); errParse == nil {
+ newAuthContents[normalizedPath] = &auth
}
- generated, errSynthesize := synthesizer.SynthesizeAuthFile(ctx, fullPath, data)
- if errSynthesize != nil {
- log.WithError(errSynthesize).Warnf("skipping auth file %s", name)
- } else if len(generated) > 0 {
- if pathAuths := authSliceToMap(generated); len(pathAuths) > 0 {
- newFileAuthsByPath[normalizedPath] = authIDSet(pathAuths)
- }
+ }
+ ctx := &synthesizer.SynthesisContext{
+ Config: cfg,
+ AuthDir: resolvedAuthDir,
+ Now: time.Now(),
+ IDGenerator: synthesizer.NewStableIDGenerator(),
+ PluginAuthParser: parser,
+ }
+ generated, errSynthesize := synthesizer.SynthesizeAuthFile(ctx, fullPath, data)
+ if errSynthesize != nil {
+ log.WithError(errSynthesize).Warnf("skipping auth file %s", name)
+ } else if len(generated) > 0 {
+ if pathAuths := authSliceToMap(generated); len(pathAuths) > 0 {
+ newFileAuthsByPath[normalizedPath] = authIDSet(pathAuths)
}
}
}
}
}
w.clientsMutex.Lock()
+ for normalizedPath := range incompleteRescanPaths {
+ if _, ok := w.lastAuthHashes[normalizedPath]; ok {
+ newAuthHashes[normalizedPath] = ""
+ }
+ if cacheAuthContents && w.lastAuthContents != nil {
+ if prevContent, ok := w.lastAuthContents[normalizedPath]; ok {
+ newAuthContents[normalizedPath] = prevContent
+ }
+ }
+ if prevAuths, ok := w.fileAuthsByPath[normalizedPath]; ok && len(prevAuths) > 0 {
+ newFileAuthsByPath[normalizedPath] = maps.Clone(prevAuths)
+ }
+ }
+ // Keep IDs that are still in currentAuths or pending provider reload indexed
+ // under their file path until refreshAuthState commits the new state.
+ for path, prevAuths := range w.fileAuthsByPath {
+ for id := range prevAuths {
+ _, stillCurrent := w.currentAuths[id]
+ _, pendingReload := w.pendingProviderReloadAuths[id]
+ if !stillCurrent && !pendingReload {
+ continue
+ }
+ if newFileAuthsByPath[path] == nil {
+ newFileAuthsByPath[path] = make(map[string]*coreauth.Auth)
+ }
+ newFileAuthsByPath[path][id] = nil
+ }
+ }
w.lastAuthHashes = newAuthHashes
w.lastAuthContents = newAuthContents
w.fileAuthsByPath = newFileAuthsByPath
@@ -296,6 +339,13 @@ func (w *Watcher) removeClientLocked(path string) {
delete(w.fileAuthsByPath, normalized)
updates := w.computePerPathUpdatesLocked(oldByID, map[string]*coreauth.Auth{})
+ // Only the whole-file removal path gets this mark. Content diffs may drop
+ // individual plugin auth IDs while their source file still exists.
+ for index := range updates {
+ if updates[index].Action == AuthUpdateActionDelete {
+ updates[index].SetFileRemoval(true)
+ }
+ }
w.clientsMutex.Unlock()
w.persistAuthAsync(fmt.Sprintf("Remove auth %s", filepath.Base(path)), path)
@@ -325,6 +375,9 @@ func (w *Watcher) computePerPathUpdatesLocked(oldByID, newByID map[string]*corea
continue
}
delete(w.currentAuths, id)
+ if w.pendingProviderReloadAuths != nil {
+ delete(w.pendingProviderReloadAuths, id)
+ }
updates = append(updates, AuthUpdate{Action: AuthUpdateActionDelete, ID: id})
}
w.stampAuthUpdatesLocked(updates)
diff --git a/internal/watcher/clients_delete_test.go b/internal/watcher/clients_delete_test.go
new file mode 100644
index 00000000000..622849ca50b
--- /dev/null
+++ b/internal/watcher/clients_delete_test.go
@@ -0,0 +1,141 @@
+package watcher
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
+)
+
+func TestAuthDeleteFileRemovalClassification(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "accounts.json")
+ w := &Watcher{
+ authDir: dir,
+ config: &config.Config{AuthDir: dir},
+ authQueue: make(chan AuthUpdate, 8),
+ pluginAuthParser: &fileDeleteTestParser{},
+ }
+ write := func(data string) {
+ t.Helper()
+ if errWriteFile := os.WriteFile(path, []byte(data), 0o600); errWriteFile != nil {
+ t.Fatal(errWriteFile)
+ }
+ w.addOrUpdateClient(path)
+ }
+ assertDelete := func(id string, fileRemoval bool) {
+ t.Helper()
+ update := w.pendingUpdates[id]
+ if update.Action != AuthUpdateActionDelete || update.Auth != nil || update.IsFileRemoval() != fileRemoval || update.Revision() == 0 {
+ t.Fatalf("delete %s: got %+v, want file removal=%t", id, update, fileRemoval)
+ }
+ if w.currentAuths[id] != nil {
+ t.Fatalf("deleted auth %s is still in watcher state", id)
+ }
+ }
+
+ write(`{"type":"plugin","ids":["a","b"]}`)
+ if !coreauth.IsPluginVirtualAuth(w.currentAuths["a"]) {
+ t.Fatal("expected multi-auth synthesis to mark plugin virtual auths")
+ }
+ initialRevision := w.pendingUpdates["a"].Revision()
+ // Removing one ID from an existing file is not a whole-file removal.
+ write(`{"type":"plugin","ids":["b","c"]}`)
+ assertDelete("a", false)
+ if w.pendingUpdates["a"].Revision() <= initialRevision {
+ t.Fatal("content delete did not advance the revision")
+ }
+ if _, errStat := os.Stat(path); errStat != nil {
+ t.Fatalf("source file must still exist: %v", errStat)
+ }
+
+ if errRemove := os.Remove(path); errRemove != nil {
+ t.Fatal(errRemove)
+ }
+ w.removeClient(path)
+ assertDelete("b", true)
+ assertDelete("c", true)
+ removalRevision := w.pendingUpdates["b"].Revision()
+
+ // Re-create only B without a create event. Both snapshot APIs must read the
+ // source file rather than the cleared watcher state or queued tombstones.
+ if errWriteFile := os.WriteFile(path, []byte(`{"type":"plugin","ids":["b"]}`), 0o600); errWriteFile != nil {
+ t.Fatal(errWriteFile)
+ }
+ fullSnapshot := w.SnapshotCoreAuths()
+ if len(fullSnapshot) != 1 || fullSnapshot[0].ID != "b" || fullSnapshot[0].Attributes[coreauth.AttributePath] != path {
+ t.Fatalf("expected only B from the re-created file, got %+v", fullSnapshot)
+ }
+ fileSnapshot, errSnapshotAuthFile := w.SnapshotAuthFile(path)
+ if errSnapshotAuthFile != nil || len(fileSnapshot) != 1 || fileSnapshot[0].ID != "b" || fileSnapshot[0].Attributes[coreauth.AttributePath] != path {
+ t.Fatalf("expected only B in the single-file snapshot, got %+v, err=%v", fileSnapshot, errSnapshotAuthFile)
+ }
+
+ // Pending updates are keyed by ID: a newer add replaces the marked delete,
+ // and a later content delete must not inherit its file-removal mark.
+ write(`{"type":"plugin","ids":["a","b"]}`)
+ addition := w.pendingUpdates["b"]
+ if addition.Action != AuthUpdateActionAdd || addition.IsFileRemoval() || addition.Revision() <= removalRevision {
+ t.Fatalf("expected newer unmarked add, got %+v", addition)
+ }
+ write(`{"type":"plugin","ids":["a"]}`)
+ assertDelete("b", false)
+}
+
+func TestAuthDeleteFullScanIsNotFileRemoval(t *testing.T) {
+ for _, filePresent := range []bool{true, false} {
+ name := "file_absent"
+ if filePresent {
+ name = "file_present"
+ }
+ t.Run(name, func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "accounts.json")
+ if filePresent {
+ if errWriteFile := os.WriteFile(path, []byte(`{"type":"plugin"}`), 0o600); errWriteFile != nil {
+ t.Fatal(errWriteFile)
+ }
+ }
+ auth := &coreauth.Auth{ID: "virtual", Attributes: map[string]string{coreauth.AttributePath: path}}
+ coreauth.MarkPluginVirtualAuth(auth, path, 0)
+ w := &Watcher{currentAuths: map[string]*coreauth.Auth{auth.ID: auth}, authQueue: make(chan AuthUpdate, 1)}
+ updates := w.prepareAuthUpdatesLocked(nil, false)
+ if len(updates) != 1 || updates[0].Action != AuthUpdateActionDelete || updates[0].IsFileRemoval() || updates[0].Auth != nil {
+ t.Fatalf("expected unmarked full-scan delete, got %+v", updates)
+ }
+ })
+ }
+}
+
+type fileDeleteTestParser struct {
+ err error
+}
+
+func (p *fileDeleteTestParser) ParseAuth(ctx context.Context, req pluginapi.AuthParseRequest) (*coreauth.Auth, bool, error) {
+ auths, handled, errParseAuths := p.ParseAuths(ctx, req)
+ if errParseAuths != nil || len(auths) == 0 {
+ return nil, handled, errParseAuths
+ }
+ return auths[0], handled, nil
+}
+
+func (p *fileDeleteTestParser) ParseAuths(_ context.Context, req pluginapi.AuthParseRequest) ([]*coreauth.Auth, bool, error) {
+ if p.err != nil {
+ return nil, true, p.err
+ }
+ var payload struct {
+ IDs []string `json:"ids"`
+ }
+ if errUnmarshal := json.Unmarshal(req.RawJSON, &payload); errUnmarshal != nil {
+ return nil, true, errUnmarshal
+ }
+ auths := make([]*coreauth.Auth, 0, len(payload.IDs))
+ for _, id := range payload.IDs {
+ auths = append(auths, &coreauth.Auth{ID: id, Provider: "codex", Status: coreauth.StatusActive})
+ }
+ return auths, true, nil
+}
diff --git a/internal/watcher/config_reload.go b/internal/watcher/config_reload.go
index 6992b4bf510..b9d5aabbfe0 100644
--- a/internal/watcher/config_reload.go
+++ b/internal/watcher/config_reload.go
@@ -17,6 +17,8 @@ import (
log "github.com/sirupsen/logrus"
)
+var loadConfigFileFunc = config.LoadConfig
+
func (w *Watcher) stopConfigReloadTimer() {
w.configReloadMu.Lock()
if w.configReloadTimer != nil {
@@ -49,6 +51,11 @@ func (w *Watcher) ReloadConfigIfChanged() {
}
func (w *Watcher) reloadConfigIfChanged() {
+ // File events and post-save reloads may arrive together; an older read must
+ // not apply after a newer one.
+ w.configApplyMu.Lock()
+ defer w.configApplyMu.Unlock()
+
data, err := os.ReadFile(w.configPath)
if err != nil {
log.Errorf("failed to read config file for hash check: %v", err)
@@ -71,15 +78,15 @@ func (w *Watcher) reloadConfigIfChanged() {
}
log.Infof("config file changed, reloading: %s", w.configPath)
if w.reloadConfig() {
- finalHash := newHash
- if updatedData, errRead := os.ReadFile(w.configPath); errRead == nil && len(updatedData) > 0 {
- sumUpdated := sha256.Sum256(updatedData)
- finalHash = hex.EncodeToString(sumUpdated[:])
- } else if errRead != nil {
- log.WithError(errRead).Debug("failed to compute updated config hash after reload")
- }
+ // Record the bytes the reload actually parsed, not the change-check read or a
+ // later re-read: a write landing between them must not count as applied. A
+ // write-back during load only costs one extra reload.
w.clientsMutex.Lock()
- w.lastConfigHash = finalHash
+ appliedHash := newHash
+ if w.config != nil && w.config.SourceHash != "" {
+ appliedHash = w.config.SourceHash
+ }
+ w.lastConfigHash = appliedHash
w.clientsMutex.Unlock()
w.persistConfigAsync()
}
@@ -89,7 +96,7 @@ func (w *Watcher) reloadConfig() bool {
log.Debug("=========================== CONFIG RELOAD ============================")
log.Debugf("starting config reload from: %s", w.configPath)
- newConfig, errLoadConfig := config.LoadConfig(w.configPath)
+ newConfig, errLoadConfig := loadConfigFileFunc(w.configPath)
if errLoadConfig != nil {
log.Errorf("failed to reload config: %v", errLoadConfig)
return false
diff --git a/internal/watcher/config_reload_ordering_test.go b/internal/watcher/config_reload_ordering_test.go
new file mode 100644
index 00000000000..cb35554e551
--- /dev/null
+++ b/internal/watcher/config_reload_ordering_test.go
@@ -0,0 +1,182 @@
+package watcher
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "reflect"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+)
+
+func newIssue6631ConfigFile(t *testing.T) (configPath, authDir string) {
+ t.Helper()
+ tmpDir := t.TempDir()
+ authDir = filepath.Join(tmpDir, "auth")
+ if errMkdir := os.MkdirAll(authDir, 0o755); errMkdir != nil {
+ t.Fatal(errMkdir)
+ }
+ return filepath.Join(tmpDir, "config.yaml"), authDir
+}
+
+// writeIssue6631Config returns an error instead of failing the test so reload
+// callbacks running on other goroutines can use it.
+func writeIssue6631Config(configPath, authDir string, requestRetry int) error {
+ raw := fmt.Sprintf("auth-dir: %q\nrequest-retry: %d\n", filepath.ToSlash(authDir), requestRetry)
+ return os.WriteFile(configPath, []byte(raw), 0o600)
+}
+
+func TestReloadConfigIfChangedReloadsWriteLandingDuringReload_Issue6631(t *testing.T) {
+ configPath, authDir := newIssue6631ConfigFile(t)
+ if errWrite := writeIssue6631Config(configPath, authDir, 1); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+
+ var applied []int
+ var errSave error
+ w := &Watcher{
+ configPath: configPath,
+ authDir: authDir,
+ reloadCallback: func(cfg *config.Config) {
+ applied = append(applied, cfg.RequestRetry)
+ if len(applied) == 1 {
+ // A management save lands while this reload is still applying.
+ errSave = writeIssue6631Config(configPath, authDir, 2)
+ }
+ },
+ }
+
+ w.ReloadConfigIfChanged()
+ if errSave != nil {
+ t.Fatal(errSave)
+ }
+ // The reload requested by that save must apply it.
+ w.ReloadConfigIfChanged()
+
+ if want := []int{1, 2}; !reflect.DeepEqual(applied, want) {
+ t.Fatalf("applied request-retry values = %v, want %v", applied, want)
+ }
+}
+
+func TestReloadConfigIfChangedSerializesConcurrentReloads_Issue6631(t *testing.T) {
+ configPath, authDir := newIssue6631ConfigFile(t)
+ if errWrite := writeIssue6631Config(configPath, authDir, 1); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+
+ var mu sync.Mutex
+ var applied []int
+ active, maxActive := 0, 0
+ firstEntered := make(chan struct{})
+ releaseFirst := make(chan struct{})
+ secondEntered := make(chan struct{})
+ w := &Watcher{
+ configPath: configPath,
+ authDir: authDir,
+ reloadCallback: func(cfg *config.Config) {
+ mu.Lock()
+ applied = append(applied, cfg.RequestRetry)
+ call := len(applied)
+ active++
+ if active > maxActive {
+ maxActive = active
+ }
+ mu.Unlock()
+ switch call {
+ case 1:
+ close(firstEntered)
+ <-releaseFirst
+ case 2:
+ close(secondEntered)
+ }
+ mu.Lock()
+ active--
+ mu.Unlock()
+ },
+ }
+ release := sync.OnceFunc(func() { close(releaseFirst) })
+ t.Cleanup(release)
+
+ var wg sync.WaitGroup
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ w.ReloadConfigIfChanged()
+ }()
+ select {
+ case <-firstEntered:
+ case <-time.After(5 * time.Second):
+ t.Fatal("first reload did not reach the reload callback")
+ }
+
+ // A newer save lands and requests its own reload while the first one is applying.
+ if errWrite := writeIssue6631Config(configPath, authDir, 2); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ w.ReloadConfigIfChanged()
+ }()
+ // The bounded wait only gives an unserialized second reload the chance to
+ // overlap; a serialized reload stays blocked, so this cannot fail it.
+ select {
+ case <-secondEntered:
+ case <-time.After(200 * time.Millisecond):
+ }
+ release()
+ wg.Wait()
+
+ mu.Lock()
+ defer mu.Unlock()
+ if maxActive != 1 {
+ t.Fatalf("config reloads overlapped: %d applied at the same time", maxActive)
+ }
+ if want := []int{1, 2}; !reflect.DeepEqual(applied, want) {
+ t.Fatalf("applied request-retry values = %v, want %v", applied, want)
+ }
+}
+
+func TestReloadConfigIfChangedReloadsAfterWriteBetweenHashAndLoad_Issue6631(t *testing.T) {
+ configPath, authDir := newIssue6631ConfigFile(t)
+ if errWrite := writeIssue6631Config(configPath, authDir, 1); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+
+ originalLoad := loadConfigFileFunc
+ t.Cleanup(func() { loadConfigFileFunc = originalLoad })
+ var errSave error
+ loads := 0
+ loadConfigFileFunc = func(path string) (*config.Config, error) {
+ loads++
+ if loads == 1 {
+ // A management save lands after the change check read version 1.
+ errSave = writeIssue6631Config(configPath, authDir, 2)
+ }
+ return originalLoad(path)
+ }
+
+ var applied []int
+ w := &Watcher{
+ configPath: configPath,
+ authDir: authDir,
+ reloadCallback: func(cfg *config.Config) { applied = append(applied, cfg.RequestRetry) },
+ }
+
+ w.ReloadConfigIfChanged()
+ if errSave != nil {
+ t.Fatal(errSave)
+ }
+ // A later save restores the bytes of version 1, which were never applied.
+ if errWrite := writeIssue6631Config(configPath, authDir, 1); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ w.ReloadConfigIfChanged()
+
+ if want := []int{2, 1}; !reflect.DeepEqual(applied, want) {
+ t.Fatalf("applied request-retry values = %v, want %v", applied, want)
+ }
+}
diff --git a/internal/watcher/diff/config_diff.go b/internal/watcher/diff/config_diff.go
index ee68350e7f3..cf259219b68 100644
--- a/internal/watcher/diff/config_diff.go
+++ b/internal/watcher/diff/config_diff.go
@@ -152,6 +152,15 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
if oldCfg.Client.Codex.OptimizeMultiAgentV2 != newCfg.Client.Codex.OptimizeMultiAgentV2 {
changes = append(changes, fmt.Sprintf("client.codex.optimize-multi-agent-v2: %t -> %t", oldCfg.Client.Codex.OptimizeMultiAgentV2, newCfg.Client.Codex.OptimizeMultiAgentV2))
}
+ if oldCfg.Client.Codex.WebSearchResultCache.Enabled != newCfg.Client.Codex.WebSearchResultCache.Enabled {
+ changes = append(changes, fmt.Sprintf("client.codex.web-search-result-cache.enabled: %t -> %t", oldCfg.Client.Codex.WebSearchResultCache.Enabled, newCfg.Client.Codex.WebSearchResultCache.Enabled))
+ }
+ if strings.TrimSpace(oldCfg.Client.Codex.WebSearchResultCache.TTL) != strings.TrimSpace(newCfg.Client.Codex.WebSearchResultCache.TTL) {
+ changes = append(changes, fmt.Sprintf("client.codex.web-search-result-cache.ttl: %s -> %s", strings.TrimSpace(oldCfg.Client.Codex.WebSearchResultCache.TTL), strings.TrimSpace(newCfg.Client.Codex.WebSearchResultCache.TTL)))
+ }
+ if oldCfg.Client.Codex.WebSearchResultCache.Limit != newCfg.Client.Codex.WebSearchResultCache.Limit {
+ changes = append(changes, fmt.Sprintf("client.codex.web-search-result-cache.limit: %d -> %d", oldCfg.Client.Codex.WebSearchResultCache.Limit, newCfg.Client.Codex.WebSearchResultCache.Limit))
+ }
if oldCfg.Codex.OrphanDelegationCompatibility != newCfg.Codex.OrphanDelegationCompatibility {
changes = append(changes, fmt.Sprintf("codex.orphan-delegation-compatibility: %t -> %t", oldCfg.Codex.OrphanDelegationCompatibility, newCfg.Codex.OrphanDelegationCompatibility))
}
diff --git a/internal/watcher/diff/config_diff_test.go b/internal/watcher/diff/config_diff_test.go
index c5a9c9869b4..4ad39f2f306 100644
--- a/internal/watcher/diff/config_diff_test.go
+++ b/internal/watcher/diff/config_diff_test.go
@@ -48,6 +48,32 @@ func TestBuildConfigChangeDetailsClientCodexOptimizeMultiAgentV2(t *testing.T) {
}
}
+func TestBuildConfigChangeDetailsClientCodexWebSearchResultCache(t *testing.T) {
+ oldCfg, newCfg := &config.Config{}, &config.Config{}
+ newCfg.Client.Codex.WebSearchResultCache = config.CodexWebSearchResultCacheConfig{
+ Enabled: true,
+ TTL: "30m",
+ Limit: 256,
+ }
+ changes := BuildConfigChangeDetails(oldCfg, newCfg)
+ want := []string{
+ "client.codex.web-search-result-cache.enabled: false -> true",
+ "client.codex.web-search-result-cache.ttl: -> 30m",
+ "client.codex.web-search-result-cache.limit: 0 -> 256",
+ }
+ if len(changes) != len(want) {
+ t.Fatalf("changes = %v, want %v", changes, want)
+ }
+ for i := range want {
+ if changes[i] != want[i] {
+ t.Fatalf("changes[%d] = %q, want %q", i, changes[i], want[i])
+ }
+ }
+ if unchanged := BuildConfigChangeDetails(newCfg, newCfg); len(unchanged) != 0 {
+ t.Fatalf("unchanged web-search-result-cache produced changes: %v", unchanged)
+ }
+}
+
func TestBuildConfigChangeDetails(t *testing.T) {
oldCfg := &config.Config{
Port: 8080,
diff --git a/internal/watcher/dispatcher.go b/internal/watcher/dispatcher.go
index 2694f8df3db..dc81f181522 100644
--- a/internal/watcher/dispatcher.go
+++ b/internal/watcher/dispatcher.go
@@ -6,7 +6,9 @@ import (
"context"
"fmt"
"maps"
+ "path/filepath"
"reflect"
+ "strings"
"sync"
"time"
@@ -15,7 +17,7 @@ import (
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
)
-var snapshotCoreAuthsFunc = snapshotCoreAuths
+var snapshotCoreAuthsFunc = snapshotCoreAuthsWithIncomplete
func (w *Watcher) setAuthUpdateQueue(queue chan<- AuthUpdate) {
w.clientsMutex.Lock()
@@ -68,6 +70,9 @@ func (w *Watcher) dispatchRuntimeAuthUpdate(update AuthUpdate) bool {
if w.currentAuths != nil {
delete(w.currentAuths, id)
}
+ if w.pendingProviderReloadAuths != nil {
+ delete(w.pendingProviderReloadAuths, id)
+ }
}
}
updates := []AuthUpdate{update}
@@ -144,29 +149,127 @@ func (w *Watcher) refreshAuthState(force bool) {
previous := maps.Clone(w.authRevisions)
previousFiles := maps.Clone(w.fileObservations)
w.clientsMutex.Unlock()
- auths := snapshotCoreAuthsFunc(cfg, authDir, parser)
+ auths, incompleteFiles := snapshotCoreAuthsFunc(cfg, authDir, parser)
w.clientsMutex.Lock()
w.activeAuthScans--
- // A full scan may finish after a newer file or persisted-auth update. Keep
- // those concurrent changes, including deletions, instead of publishing stale data.
- changedDuringScan := func(auth *coreauth.Auth) bool {
+ var incompletePaths map[string]struct{}
+ if len(incompleteFiles) > 0 {
+ incompletePaths = make(map[string]struct{}, len(incompleteFiles)*2)
+ for path := range incompleteFiles {
+ normalized := w.normalizeAuthPath(path)
+ if normalized == "" {
+ continue
+ }
+ incompletePaths[normalized] = struct{}{}
+ var normalizedAbs string
+ if abs, errAbs := filepath.Abs(normalized); errAbs == nil {
+ normalizedAbs = w.normalizeAuthPath(abs)
+ if normalizedAbs != "" {
+ incompletePaths[normalizedAbs] = struct{}{}
+ }
+ }
+ if previousFiles[normalized] == w.fileObservations[normalized] &&
+ (normalizedAbs == "" || previousFiles[normalizedAbs] == w.fileObservations[normalizedAbs]) {
+ if _, known := w.lastAuthHashes[normalized]; known {
+ w.lastAuthHashes[normalized] = ""
+ }
+ if normalizedAbs != "" {
+ if _, known := w.lastAuthHashes[normalizedAbs]; known {
+ w.lastAuthHashes[normalizedAbs] = ""
+ }
+ }
+ }
+ }
+ }
+ authPath := func(auth *coreauth.Auth) string {
path := auth.Attributes[coreauth.AttributePath]
if path == "" {
path = auth.Attributes[coreauth.AttributeSource]
}
- normalized := w.normalizeAuthPath(path)
+ return w.normalizeAuthPath(path)
+ }
+ // A full scan may finish after a newer file or persisted-auth update. Keep
+ // those concurrent changes, including deletions, instead of publishing stale data.
+ changedDuringScan := func(auth *coreauth.Auth) bool {
+ normalized := authPath(auth)
return previous[auth.ID] != w.authRevisions[auth.ID] ||
previousFiles[normalized] != w.fileObservations[normalized]
}
+ incompleteDuringScan := func(auth *coreauth.Auth) bool {
+ if len(incompletePaths) == 0 {
+ return false
+ }
+ normalized := authPath(auth)
+ if normalized == "" {
+ return false
+ }
+ if _, ok := incompletePaths[normalized]; ok {
+ return true
+ }
+ if abs, errAbs := filepath.Abs(normalized); errAbs == nil {
+ if _, ok := incompletePaths[w.normalizeAuthPath(abs)]; ok {
+ return true
+ }
+ }
+ return false
+ }
+ isFileBackedAuth := func(auth *coreauth.Auth) bool {
+ if auth == nil || len(auth.Attributes) == 0 {
+ return false
+ }
+ if auth.Attributes[coreauth.AttributePath] != "" {
+ return true
+ }
+ source := auth.Attributes[coreauth.AttributeSource]
+ return source != "" && !strings.HasPrefix(source, "config:")
+ }
for index, auth := range auths {
- if auth != nil && changedDuringScan(auth) {
+ if auth != nil && (changedDuringScan(auth) || incompleteDuringScan(auth)) {
auths[index] = nil
}
}
+ providerReloadAuths := w.pendingProviderReloadAuths
+ w.pendingProviderReloadAuths = nil
+ if len(providerReloadAuths) > 0 {
+ scannedIDs := make(map[string]struct{}, len(auths))
+ for _, auth := range auths {
+ if auth != nil && auth.ID != "" {
+ scannedIDs[auth.ID] = struct{}{}
+ }
+ }
+ for id, auth := range providerReloadAuths {
+ if auth == nil || previous[id] != w.authRevisions[id] {
+ continue
+ }
+ if _, exists := w.currentAuths[id]; exists {
+ continue
+ }
+ if incompleteDuringScan(auth) {
+ if isFileBackedAuth(auth) {
+ w.indexFileBackedAuthLocked(auth)
+ }
+ auths = append(auths, auth.Clone())
+ continue
+ }
+ if _, scanned := scannedIDs[id]; !scanned {
+ if w.currentAuths == nil {
+ w.currentAuths = make(map[string]*coreauth.Auth)
+ }
+ w.currentAuths[id] = auth
+ }
+ }
+ }
for _, auth := range w.currentAuths {
- if auth != nil && changedDuringScan(auth) {
- auths = append(auths, auth.Clone())
+ if auth == nil {
+ continue
}
+ if !changedDuringScan(auth) && !incompleteDuringScan(auth) {
+ continue
+ }
+ if isFileBackedAuth(auth) {
+ w.indexFileBackedAuthLocked(auth)
+ }
+ auths = append(auths, auth.Clone())
}
if len(w.runtimeAuths) > 0 {
for _, a := range w.runtimeAuths {
@@ -180,6 +283,39 @@ func (w *Watcher) refreshAuthState(force bool) {
w.dispatchAuthUpdates(updates)
}
+func (w *Watcher) indexFileBackedAuthLocked(auth *coreauth.Auth) {
+ if auth == nil || auth.ID == "" || len(auth.Attributes) == 0 {
+ return
+ }
+ path := auth.Attributes[coreauth.AttributePath]
+ if path == "" {
+ source := auth.Attributes[coreauth.AttributeSource]
+ if !strings.HasPrefix(source, "config:") {
+ path = source
+ }
+ }
+ normalized := w.normalizeAuthPath(path)
+ if normalized == "" {
+ return
+ }
+ if w.fileAuthsByPath == nil {
+ w.fileAuthsByPath = make(map[string]map[string]*coreauth.Auth)
+ }
+ pathAuths := w.fileAuthsByPath[normalized]
+ if pathAuths == nil {
+ pathAuths = make(map[string]*coreauth.Auth)
+ w.fileAuthsByPath[normalized] = pathAuths
+ }
+ pathAuths[auth.ID] = nil
+ if abs, errAbs := filepath.Abs(normalized); errAbs == nil {
+ if normalizedAbs := w.normalizeAuthPath(abs); normalizedAbs != "" && normalizedAbs != normalized {
+ if absAuths := w.fileAuthsByPath[normalizedAbs]; absAuths != nil {
+ absAuths[auth.ID] = nil
+ }
+ }
+ }
+}
+
func (w *Watcher) prepareAuthUpdatesLocked(auths []*coreauth.Auth, force bool) []AuthUpdate {
newState := make(map[string]*coreauth.Auth, len(auths))
orderedIDs := make([]string, 0, len(auths))
@@ -190,7 +326,9 @@ func (w *Watcher) prepareAuthUpdatesLocked(auths []*coreauth.Auth, force bool) [
if _, exists := newState[auth.ID]; !exists {
orderedIDs = append(orderedIDs, auth.ID)
}
- newState[auth.ID] = auth.Clone()
+ cloned := auth.Clone()
+ newState[auth.ID] = cloned
+ w.indexFileBackedAuthLocked(cloned)
}
updates := make([]AuthUpdate, 0, len(newState)+len(w.currentAuths))
for _, id := range orderedIDs {
@@ -204,11 +342,23 @@ func (w *Watcher) prepareAuthUpdatesLocked(auths []*coreauth.Auth, force bool) [
updates = append(updates, AuthUpdate{Action: AuthUpdateActionModify, ID: id, Auth: auth.Clone()})
}
}
+ // A missing ID in a full scan does not imply its source file was removed.
+ // Leave these deletes unmarked so content/config changes still remove auths.
for id := range w.currentAuths {
if _, ok := newState[id]; !ok {
updates = append(updates, AuthUpdate{Action: AuthUpdateActionDelete, ID: id})
}
}
+ for path, pathAuths := range w.fileAuthsByPath {
+ for id := range pathAuths {
+ if _, ok := newState[id]; !ok {
+ delete(pathAuths, id)
+ }
+ }
+ if len(pathAuths) == 0 {
+ delete(w.fileAuthsByPath, path)
+ }
+ }
w.currentAuths = newState
w.stampAuthUpdatesLocked(updates)
if w.authQueue == nil {
@@ -346,6 +496,9 @@ func (w *Watcher) stopDispatch() {
}
func authEqual(a, b *coreauth.Auth) bool {
+ if a != nil && b != nil && a.HasFileSnapshot() && b.HasFileSnapshot() && !a.SameFileSnapshot(b) {
+ return false
+ }
return reflect.DeepEqual(normalizeAuth(a), normalizeAuth(b))
}
@@ -354,6 +507,7 @@ func normalizeAuth(a *coreauth.Auth) *coreauth.Auth {
return nil
}
clone := a.Clone()
+ clone.ClearFileSnapshot()
clone.CreatedAt = time.Time{}
clone.UpdatedAt = time.Time{}
clone.LastRefreshedAt = time.Time{}
@@ -367,6 +521,11 @@ func normalizeAuth(a *coreauth.Auth) *coreauth.Auth {
}
func snapshotCoreAuths(cfg *config.Config, authDir string, parser synthesizer.PluginAuthParser) []*coreauth.Auth {
+ auths, _ := snapshotCoreAuthsWithIncomplete(cfg, authDir, parser)
+ return auths
+}
+
+func snapshotCoreAuthsWithIncomplete(cfg *config.Config, authDir string, parser synthesizer.PluginAuthParser) ([]*coreauth.Auth, map[string]struct{}) {
ctx := &synthesizer.SynthesisContext{
Config: cfg,
AuthDir: authDir,
@@ -387,5 +546,5 @@ func snapshotCoreAuths(cfg *config.Config, authDir string, parser synthesizer.Pl
out = append(out, auths...)
}
- return out
+ return out, ctx.IncompleteFiles
}
diff --git a/internal/watcher/dispatcher_snapshot_test.go b/internal/watcher/dispatcher_snapshot_test.go
index 7723ace6b1c..339ae7861fc 100644
--- a/internal/watcher/dispatcher_snapshot_test.go
+++ b/internal/watcher/dispatcher_snapshot_test.go
@@ -13,6 +13,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/internal/watcher/synthesizer"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v8/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
)
// pauseAuthSnapshot suspends a real scan after reading files, before publishing them.
@@ -22,11 +23,11 @@ func pauseAuthSnapshot(t *testing.T, w *Watcher, force bool) func() {
scanned := make(chan struct{})
resume := make(chan struct{})
finished := make(chan struct{})
- snapshotCoreAuthsFunc = func(cfg *config.Config, authDir string, parser synthesizer.PluginAuthParser) []*coreauth.Auth {
- auths := originalSnapshot(cfg, authDir, parser)
+ snapshotCoreAuthsFunc = func(cfg *config.Config, authDir string, parser synthesizer.PluginAuthParser) ([]*coreauth.Auth, map[string]struct{}) {
+ auths, incomplete := originalSnapshot(cfg, authDir, parser)
close(scanned)
<-resume
- return auths
+ return auths, incomplete
}
var finishOnce sync.Once
finish := func() {
@@ -129,13 +130,13 @@ func TestRefreshAuthStatePreservesConcurrentRoundTrip(t *testing.T) {
w.authQueue = make(chan AuthUpdate, 10)
originalSnapshot := snapshotCoreAuthsFunc
t.Cleanup(func() { snapshotCoreAuthsFunc = originalSnapshot })
- snapshotCoreAuthsFunc = func(cfg *config.Config, authDir string, parser synthesizer.PluginAuthParser) []*coreauth.Auth {
+ snapshotCoreAuthsFunc = func(cfg *config.Config, authDir string, parser synthesizer.PluginAuthParser) ([]*coreauth.Auth, map[string]struct{}) {
// Deliver two events during the scan, ending at the starting content.
if errWrite := os.WriteFile(path, []byte(`{"type":"codex","proxy_url":"http://intermediate.proxy:8080"}`), 0o600); errWrite != nil {
t.Fatal(errWrite)
}
w.addOrUpdateClient(path)
- auths := originalSnapshot(cfg, authDir, parser)
+ auths, incomplete := originalSnapshot(cfg, authDir, parser)
if initiallyPresent {
if errWrite := os.WriteFile(path, []byte(`{"type":"codex"}`), 0o600); errWrite != nil {
t.Fatal(errWrite)
@@ -147,7 +148,7 @@ func TestRefreshAuthStatePreservesConcurrentRoundTrip(t *testing.T) {
}
w.removeClient(path)
}
- return auths
+ return auths, incomplete
}
w.refreshAuthState(false)
current := w.currentAuths["account.json"]
@@ -189,8 +190,8 @@ func TestRefreshAuthStatePreservesUnchangedFileObservation(t *testing.T) {
}
originalSnapshot := snapshotCoreAuthsFunc
t.Cleanup(func() { snapshotCoreAuthsFunc = originalSnapshot })
- snapshotCoreAuthsFunc = func(cfg *config.Config, authDir string, parser synthesizer.PluginAuthParser) []*coreauth.Auth {
- auths := originalSnapshot(cfg, authDir, parser)
+ snapshotCoreAuthsFunc = func(cfg *config.Config, authDir string, parser synthesizer.PluginAuthParser) ([]*coreauth.Auth, map[string]struct{}) {
+ auths, incomplete := originalSnapshot(cfg, authDir, parser)
latest := original
if testCase.formatted {
latest = []byte(`{ "type": "codex", "proxy_url": "http://latest.proxy:8080" }`)
@@ -203,7 +204,7 @@ func TestRefreshAuthStatePreservesUnchangedFileObservation(t *testing.T) {
} else {
w.addOrUpdateClient(path)
}
- return auths
+ return auths, incomplete
}
w.refreshAuthState(false)
current := w.currentAuths["account.json"]
@@ -233,8 +234,8 @@ func TestRefreshAuthStateAppliesHashCachedFileObservation(t *testing.T) {
}
originalSnapshot := snapshotCoreAuthsFunc
t.Cleanup(func() { snapshotCoreAuthsFunc = originalSnapshot })
- snapshotCoreAuthsFunc = func(cfg *config.Config, authDir string, parser synthesizer.PluginAuthParser) []*coreauth.Auth {
- auths := originalSnapshot(cfg, authDir, parser)
+ snapshotCoreAuthsFunc = func(cfg *config.Config, authDir string, parser synthesizer.PluginAuthParser) ([]*coreauth.Auth, map[string]struct{}) {
+ auths, incomplete := originalSnapshot(cfg, authDir, parser)
// reloadClients cached the new hash before publishing its matching auth.
if phase == "after_scan" {
// Suspend the event after observation, before content processing.
@@ -242,7 +243,7 @@ func TestRefreshAuthStateAppliesHashCachedFileObservation(t *testing.T) {
} else {
w.handleEvent(fsnotify.Event{Name: path, Op: fsnotify.Write})
}
- return auths
+ return auths, incomplete
}
w.reloadClients(true, nil, false)
if phase == "after_scan" {
@@ -266,13 +267,13 @@ func TestRefreshAuthStateDoesNotResurrectUnregisteredFile(t *testing.T) {
w := &Watcher{authDir: dir, config: &config.Config{AuthDir: dir}, authQueue: make(chan AuthUpdate, 10)}
originalSnapshot := snapshotCoreAuthsFunc
t.Cleanup(func() { snapshotCoreAuthsFunc = originalSnapshot })
- snapshotCoreAuthsFunc = func(cfg *config.Config, authDir string, parser synthesizer.PluginAuthParser) []*coreauth.Auth {
- auths := originalSnapshot(cfg, authDir, parser)
+ snapshotCoreAuthsFunc = func(cfg *config.Config, authDir string, parser synthesizer.PluginAuthParser) ([]*coreauth.Auth, map[string]struct{}) {
+ auths, incomplete := originalSnapshot(cfg, authDir, parser)
if errRemove := os.Remove(path); errRemove != nil {
t.Fatal(errRemove)
}
w.removeClient(path)
- return auths
+ return auths, incomplete
}
w.refreshAuthState(false)
if w.currentAuths["account.json"] != nil || len(w.pendingOrder) != 0 {
@@ -423,3 +424,467 @@ func TestRefreshAuthStatePreservesConcurrentProxyUpdate(t *testing.T) {
})
}
}
+
+type snapshotTestPluginParser func(context.Context, pluginapi.AuthParseRequest) (*coreauth.Auth, bool, error)
+
+func (f snapshotTestPluginParser) ParseAuth(ctx context.Context, req pluginapi.AuthParseRequest) (*coreauth.Auth, bool, error) {
+ return f(ctx, req)
+}
+
+func TestWatcherFileSnapshotReformatAndUnmappedChangeDoNotDropUpdate(t *testing.T) {
+ t.Run("reformatted_before_consume", func(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "account.json")
+ if errWrite := os.WriteFile(path, []byte(`{"type":"codex","prefix":"pfx-initial"}`), 0o600); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ w := &Watcher{authDir: dir, config: &config.Config{AuthDir: dir}, authQueue: make(chan AuthUpdate, 10)}
+ w.addOrUpdateClient(path)
+ initBatch, okInit := w.nextPendingBatch(context.Background())
+ if !okInit || len(initBatch) != 1 {
+ t.Fatalf("expected 1 initial update, got %v", initBatch)
+ }
+ ctx := coreauth.WithSkipPersist(context.Background())
+ manager := coreauth.NewManager(nil, nil, nil)
+ if _, errRegister := manager.Register(ctx, initBatch[0].Auth); errRegister != nil {
+ t.Fatal(errRegister)
+ }
+
+ // Step 1: Edit file to B; Watcher queues snapshot B.
+ if errWrite := os.WriteFile(path, []byte(`{"type":"codex","prefix":"pfx-edited"}`), 0o600); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ w.addOrUpdateClient(path)
+ batchB, okB := w.nextPendingBatch(context.Background())
+ if !okB || len(batchB) != 1 {
+ t.Fatalf("expected 1 update for B, got %v", batchB)
+ }
+
+ // Step 2: Before B is consumed by manager.Update, reformat file on disk with
+ // whitespace and reversed key order (B'). Watcher deduplicates B'.
+ if errWrite := os.WriteFile(path, []byte("{\n \"prefix\": \"pfx-edited\",\n \"type\": \"codex\"\n}\n"), 0o600); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ w.addOrUpdateClient(path)
+ if len(w.pendingOrder) != 0 {
+ t.Fatalf("expected reformatted B' to be deduplicated, got pending=%v", w.pendingOrder)
+ }
+
+ // Step 3: Consume B into manager.Update; canonical digest matches B' on disk.
+ if _, errUpdate := manager.Update(ctx, batchB[0].Auth); errUpdate != nil {
+ t.Fatal(errUpdate)
+ }
+ current, _ := manager.GetByID("account.json")
+ if current == nil || current.Prefix != "pfx-edited" {
+ t.Fatalf("reformatted file caused edit to be dropped: got=%#v, want prefix=pfx-edited", current)
+ }
+ })
+
+ t.Run("plugin_unmapped_field_change_before_consume", func(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "plugin.json")
+ parser := snapshotTestPluginParser(func(_ context.Context, req pluginapi.AuthParseRequest) (*coreauth.Auth, bool, error) {
+ return &coreauth.Auth{
+ ID: req.FileName,
+ Provider: "gemini-cli",
+ Metadata: map[string]any{"type": "gemini-cli"},
+ }, true, nil
+ })
+ if errWrite := os.WriteFile(path, []byte(`{"type":"gemini-cli","prefix":"pfx-initial","ignored":"v0"}`), 0o600); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ w := &Watcher{authDir: dir, config: &config.Config{AuthDir: dir}, pluginAuthParser: parser, authQueue: make(chan AuthUpdate, 10)}
+ w.addOrUpdateClient(path)
+ initBatch, okInit := w.nextPendingBatch(context.Background())
+ if !okInit || len(initBatch) != 1 {
+ t.Fatalf("expected 1 initial update, got %v", initBatch)
+ }
+ ctx := coreauth.WithSkipPersist(context.Background())
+ manager := coreauth.NewManager(nil, nil, nil)
+ if _, errRegister := manager.Register(ctx, initBatch[0].Auth); errRegister != nil {
+ t.Fatal(errRegister)
+ }
+
+ // Step 1: Edit file to B (prefix=pfx-edited, ignored=v1) and pop batchB.
+ if errWrite := os.WriteFile(path, []byte(`{"type":"gemini-cli","prefix":"pfx-edited","ignored":"v1"}`), 0o600); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ w.addOrUpdateClient(path)
+ batchB, okB := w.nextPendingBatch(context.Background())
+ if !okB || len(batchB) != 1 {
+ t.Fatalf("expected 1 update for B, got %v", batchB)
+ }
+
+ // Step 2: Before batchB is consumed, change only the unmapped JSON field (ignored=v2).
+ // Because the file snapshot digest changed, Watcher must emit a follow-up update.
+ if errWrite := os.WriteFile(path, []byte(`{"type":"gemini-cli","prefix":"pfx-edited","ignored":"v2"}`), 0o600); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ w.addOrUpdateClient(path)
+ batchB2, okB2 := w.nextPendingBatch(context.Background())
+ if !okB2 || len(batchB2) != 1 {
+ t.Fatalf("expected follow-up update when file snapshot digest changed, got %v", batchB2)
+ }
+
+ // Step 3: Applying batchB (superseded on disk) followed by batchB2 commits pfx-edited.
+ if _, errUpdate := manager.Update(ctx, batchB[0].Auth); errUpdate != nil {
+ t.Fatal(errUpdate)
+ }
+ if _, errUpdate := manager.Update(ctx, batchB2[0].Auth); errUpdate != nil {
+ t.Fatal(errUpdate)
+ }
+ current, _ := manager.GetByID("plugin.json")
+ if current == nil || current.Prefix != "pfx-edited" {
+ t.Fatalf("unmapped field change caused edit to be dropped: got=%#v, want prefix=pfx-edited", current)
+ }
+ })
+}
+
+func TestRefreshAuthStatePreservesIncompleteFileDuringInPlaceWrite(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ midContent string
+ fullReload bool
+ }{
+ {name: "truncated_empty_refresh", midContent: ""},
+ {name: "partial_json_refresh", midContent: `{"type":"plugin","ids":["b"`},
+ {name: "truncated_empty_reload_clients", midContent: "", fullReload: true},
+ {name: "partial_json_reload_clients", midContent: `{"type":"plugin","ids":["b"`, fullReload: true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "accounts.json")
+ w := &Watcher{
+ authDir: dir,
+ config: &config.Config{AuthDir: dir},
+ authQueue: make(chan AuthUpdate, 16),
+ pluginAuthParser: &fileDeleteTestParser{},
+ }
+ if errWrite := os.WriteFile(path, []byte(`{"type":"plugin","ids":["a","b"]}`), 0o600); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ w.addOrUpdateClient(path)
+ if _, okBatch := w.nextPendingBatch(context.Background()); !okBatch {
+ t.Fatal("expected initial add batch")
+ }
+
+ // Simulate an in-place writer opening with O_WRONLY|O_TRUNC and optionally flushing partial JSON.
+ file, errOpen := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600)
+ if errOpen != nil {
+ t.Fatal(errOpen)
+ }
+ if tc.midContent != "" {
+ if _, errWrite := file.WriteString(tc.midContent); errWrite != nil {
+ _ = file.Close()
+ t.Fatal(errWrite)
+ }
+ }
+
+ if tc.fullReload {
+ w.reloadClients(true, nil, false)
+ } else {
+ w.refreshAuthState(false)
+ }
+
+ if w.currentAuths["a"] == nil || w.currentAuths["b"] == nil {
+ _ = file.Close()
+ t.Fatalf("full scan unregistered credentials during in-place write: currentAuths=%#v, pending=%#v", w.currentAuths, w.pendingUpdates)
+ }
+ if len(w.pendingOrder) != 0 {
+ _ = file.Close()
+ t.Fatalf("expected no queued deletes during in-place write, got %#v", w.pendingUpdates)
+ }
+
+ // Finish the in-place write with ["b","c"] (dropping "a" and adding "c").
+ if _, errSeek := file.Seek(0, 0); errSeek != nil {
+ _ = file.Close()
+ t.Fatal(errSeek)
+ }
+ if errTrunc := file.Truncate(0); errTrunc != nil {
+ _ = file.Close()
+ t.Fatal(errTrunc)
+ }
+ if _, errWrite := file.WriteString(`{"type":"plugin","ids":["b","c"]}`); errWrite != nil {
+ _ = file.Close()
+ t.Fatal(errWrite)
+ }
+ if errClose := file.Close(); errClose != nil {
+ t.Fatal(errClose)
+ }
+
+ w.handleEvent(fsnotify.Event{Name: path, Op: fsnotify.Write})
+
+ if w.currentAuths["a"] != nil {
+ t.Fatalf("expected writer's final event to remove dropped child a, got %#v", w.currentAuths["a"])
+ }
+ if w.currentAuths["b"] == nil || w.currentAuths["c"] == nil {
+ t.Fatalf("expected b and c in currentAuths after write finished, got %#v", w.currentAuths)
+ }
+ if gotDelete := w.pendingUpdates["a"]; gotDelete.Action != AuthUpdateActionDelete {
+ t.Fatalf("expected delete update for dropped child a, got %+v", gotDelete)
+ }
+ if gotAdd := w.pendingUpdates["c"]; gotAdd.Action != AuthUpdateActionAdd {
+ t.Fatalf("expected add update for new child c, got %+v", gotAdd)
+ }
+ })
+ }
+
+ t.Run("reload_clients_pass_divergence_retains_file_auth_index", func(t *testing.T) {
+ for _, mode := range []string{"without_early_event", "with_early_truncated_write_event", "final_write_event_during_scan"} {
+ t.Run(mode, func(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "accounts.json")
+ w := &Watcher{
+ authDir: dir,
+ config: &config.Config{AuthDir: dir},
+ authQueue: make(chan AuthUpdate, 16),
+ pluginAuthParser: &fileDeleteTestParser{},
+ }
+ if errWrite := os.WriteFile(path, []byte(`{"type":"plugin","ids":["a","b"]}`), 0o600); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ w.addOrUpdateClient(path)
+ if _, okBatch := w.nextPendingBatch(context.Background()); !okBatch {
+ t.Fatal("expected initial add batch")
+ }
+
+ // reloadClients pass 1 reads complete ["b","c"], then an in-place write truncates
+ // the file right before refreshAuthState runs snapshotCoreAuthsFunc.
+ if errWrite := os.WriteFile(path, []byte(`{"type":"plugin","ids":["b","c"]}`), 0o600); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ originalSnapshot := snapshotCoreAuthsFunc
+ t.Cleanup(func() { snapshotCoreAuthsFunc = originalSnapshot })
+ var file *os.File
+ snapshotCoreAuthsFunc = func(cfg *config.Config, authDir string, parser synthesizer.PluginAuthParser) ([]*coreauth.Auth, map[string]struct{}) {
+ var errOpen error
+ file, errOpen = os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600)
+ if errOpen != nil {
+ t.Fatal(errOpen)
+ }
+ auths, incomplete := originalSnapshot(cfg, authDir, parser)
+ switch mode {
+ case "with_early_truncated_write_event":
+ // An early write event for the truncated file increments fileObservations
+ // during the scan but returns early without updating fileAuthsByPath.
+ w.handleEvent(fsnotify.Event{Name: path, Op: fsnotify.Write})
+ case "final_write_event_during_scan":
+ if _, errWrite := file.WriteString(`{"type":"plugin","ids":["b","c"]}`); errWrite != nil {
+ _ = file.Close()
+ t.Fatal(errWrite)
+ }
+ if errClose := file.Close(); errClose != nil {
+ t.Fatal(errClose)
+ }
+ file = nil
+ w.handleEvent(fsnotify.Event{Name: path, Op: fsnotify.Write})
+ }
+ return auths, incomplete
+ }
+
+ w.reloadClients(true, nil, false)
+ snapshotCoreAuthsFunc = originalSnapshot
+
+ if file != nil {
+ if w.currentAuths["a"] == nil || w.currentAuths["b"] == nil {
+ _ = file.Close()
+ t.Fatalf("expected a and b to survive incomplete refresh scan, got %#v", w.currentAuths)
+ }
+
+ if _, errWrite := file.WriteString(`{"type":"plugin","ids":["b","c"]}`); errWrite != nil {
+ _ = file.Close()
+ t.Fatal(errWrite)
+ }
+ if errClose := file.Close(); errClose != nil {
+ t.Fatal(errClose)
+ }
+
+ w.handleEvent(fsnotify.Event{Name: path, Op: fsnotify.Write})
+ }
+
+ if w.currentAuths["a"] != nil {
+ t.Fatalf("expected writer's final event to remove retained child a, got %#v", w.currentAuths["a"])
+ }
+ if w.currentAuths["b"] == nil || w.currentAuths["c"] == nil {
+ t.Fatalf("expected b and c in currentAuths after write finished, got %#v", w.currentAuths)
+ }
+ if gotDelete := w.pendingUpdates["a"]; gotDelete.Action != AuthUpdateActionDelete {
+ t.Fatalf("expected delete update for dropped child a, got %+v", gotDelete)
+ }
+ if gotAdd := w.pendingUpdates["c"]; gotAdd.Action != AuthUpdateActionAdd {
+ t.Fatalf("expected add update for new child c, got %+v", gotAdd)
+ }
+ })
+ }
+ })
+
+ t.Run("affected_oauth_provider_reload_during_in_place_write", func(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "account.json")
+ w := &Watcher{
+ authDir: dir,
+ config: &config.Config{AuthDir: dir},
+ authQueue: make(chan AuthUpdate, 16),
+ }
+ if errWrite := os.WriteFile(path, []byte(`{"type":"codex"}`), 0o600); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ w.addOrUpdateClient(path)
+ if _, okBatch := w.nextPendingBatch(context.Background()); !okBatch {
+ t.Fatal("expected initial add batch")
+ }
+
+ file, errOpen := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600)
+ if errOpen != nil {
+ t.Fatal(errOpen)
+ }
+ _ = file.Close()
+
+ w.reloadClients(false, []string{"codex"}, false)
+
+ if w.currentAuths["account.json"] == nil {
+ t.Fatalf("expected codex auth to survive incomplete scan during provider reload, got %#v", w.currentAuths)
+ }
+ if errRemove := os.Remove(path); errRemove != nil {
+ t.Fatal(errRemove)
+ }
+ w.removeClient(path)
+ if gotDelete := w.pendingUpdates["account.json"]; gotDelete.Action != AuthUpdateActionDelete {
+ t.Fatalf("expected removeClient to emit delete after incomplete provider reload, got %+v", gotDelete)
+ }
+ })
+
+ t.Run("affected_oauth_provider_reload_does_not_resurrect_deleted_auth", func(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "account.json")
+ w := &Watcher{
+ authDir: dir,
+ config: &config.Config{AuthDir: dir},
+ authQueue: make(chan AuthUpdate, 16),
+ }
+ if errWrite := os.WriteFile(path, []byte(`{"type":"codex"}`), 0o600); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ w.addOrUpdateClient(path)
+ w.dispatchRuntimeAuthUpdate(AuthUpdate{
+ Action: AuthUpdateActionAdd,
+ ID: "ws-codex",
+ Auth: &coreauth.Auth{ID: "ws-codex", Provider: "codex", Status: coreauth.StatusActive},
+ })
+ if _, okBatch := w.nextPendingBatch(context.Background()); !okBatch {
+ t.Fatal("expected initial add batch")
+ }
+
+ file, errOpen := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600)
+ if errOpen != nil {
+ t.Fatal(errOpen)
+ }
+ _ = file.Close()
+
+ originalSnapshot := snapshotCoreAuthsFunc
+ t.Cleanup(func() { snapshotCoreAuthsFunc = originalSnapshot })
+ snapshotCoreAuthsFunc = func(cfg *config.Config, authDir string, parser synthesizer.PluginAuthParser) ([]*coreauth.Auth, map[string]struct{}) {
+ auths, incomplete := originalSnapshot(cfg, authDir, parser)
+ w.dispatchRuntimeAuthUpdate(AuthUpdate{Action: AuthUpdateActionDelete, ID: "ws-codex"})
+ if errRemove := os.Remove(path); errRemove != nil {
+ t.Fatal(errRemove)
+ }
+ w.removeClient(path)
+ return auths, incomplete
+ }
+
+ w.reloadClients(false, []string{"codex"}, false)
+
+ if w.currentAuths["ws-codex"] != nil || w.pendingUpdates["ws-codex"].Action != AuthUpdateActionDelete {
+ t.Fatalf("provider reload resurrected deleted runtime auth: current=%#v, pending=%+v", w.currentAuths["ws-codex"], w.pendingUpdates["ws-codex"])
+ }
+ if w.currentAuths["account.json"] != nil || w.pendingUpdates["account.json"].Action != AuthUpdateActionDelete {
+ t.Fatalf("provider reload resurrected deleted file auth: current=%#v, pending=%+v", w.currentAuths["account.json"], w.pendingUpdates["account.json"])
+ }
+ })
+
+ t.Run("affected_oauth_provider_reload_deletes_removed_or_zero_auth_file", func(t *testing.T) {
+ for _, mode := range []string{"removed_file", "valid_empty_object"} {
+ t.Run(mode, func(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "account.json")
+ w := &Watcher{
+ authDir: dir,
+ config: &config.Config{AuthDir: dir},
+ authQueue: make(chan AuthUpdate, 16),
+ }
+ if errWrite := os.WriteFile(path, []byte(`{"type":"codex"}`), 0o600); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ w.addOrUpdateClient(path)
+ if _, okBatch := w.nextPendingBatch(context.Background()); !okBatch {
+ t.Fatal("expected initial add batch")
+ }
+
+ if mode == "removed_file" {
+ if errRemove := os.Remove(path); errRemove != nil {
+ t.Fatal(errRemove)
+ }
+ } else {
+ if errWrite := os.WriteFile(path, []byte(`{}`), 0o600); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ }
+
+ w.reloadClients(false, []string{"codex"}, false)
+
+ if w.currentAuths["account.json"] != nil {
+ t.Fatalf("expected %s to remove codex auth during provider reload, got %#v", mode, w.currentAuths["account.json"])
+ }
+ if gotDelete := w.pendingUpdates["account.json"]; gotDelete.Action != AuthUpdateActionDelete {
+ t.Fatalf("expected delete update for %s during provider reload, got %+v", mode, gotDelete)
+ }
+ })
+ }
+ })
+
+ t.Run("control_valid_zero_auth_and_removed_file_still_delete", func(t *testing.T) {
+ for _, mode := range []string{"valid_empty_object", "valid_plugin_empty", "removed_file"} {
+ t.Run(mode, func(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "accounts.json")
+ w := &Watcher{
+ authDir: dir,
+ config: &config.Config{AuthDir: dir},
+ authQueue: make(chan AuthUpdate, 16),
+ pluginAuthParser: &fileDeleteTestParser{},
+ }
+ if errWrite := os.WriteFile(path, []byte(`{"type":"plugin","ids":["a"]}`), 0o600); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ w.addOrUpdateClient(path)
+ if _, okBatch := w.nextPendingBatch(context.Background()); !okBatch {
+ t.Fatal("expected initial add batch")
+ }
+
+ switch mode {
+ case "valid_empty_object":
+ if errWrite := os.WriteFile(path, []byte(`{}`), 0o600); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ case "valid_plugin_empty":
+ if errWrite := os.WriteFile(path, []byte(`{"type":"plugin","ids":[]}`), 0o600); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ case "removed_file":
+ if errRemove := os.Remove(path); errRemove != nil {
+ t.Fatal(errRemove)
+ }
+ }
+
+ w.refreshAuthState(false)
+ if w.currentAuths["a"] != nil {
+ t.Fatalf("expected %s to remove auth a, got %#v", mode, w.currentAuths["a"])
+ }
+ if got := w.pendingUpdates["a"]; got.Action != AuthUpdateActionDelete {
+ t.Fatalf("expected delete update for %s, got %+v", mode, got)
+ }
+ })
+ }
+ })
+}
diff --git a/internal/watcher/synthesizer/config.go b/internal/watcher/synthesizer/config.go
index 0fc50759437..14de2a3f2b4 100644
--- a/internal/watcher/synthesizer/config.go
+++ b/internal/watcher/synthesizer/config.go
@@ -73,7 +73,6 @@ func (s *ConfigSynthesizer) synthesizeInteractionsKeys(ctx *SynthesisContext) []
}
func (s *ConfigSynthesizer) synthesizeGeminiKeyEntries(ctx *SynthesisContext, entries []config.GeminiKey, idKind, sourceName, label, provider string) []*coreauth.Auth {
- cfg := ctx.Config
now := ctx.Now
idGen := ctx.IDGenerator
@@ -124,7 +123,7 @@ func (s *ConfigSynthesizer) synthesizeGeminiKeyEntries(ctx *SynthesisContext, en
CreatedAt: now,
UpdatedAt: now,
}
- ApplyAuthExcludedModelsMeta(a, cfg, entry.ExcludedModels, "apikey")
+ ApplyAuthExcludedModelsMeta(a, entry.ExcludedModels, "apikey")
if len(a.Metadata) == 0 {
a.Metadata = nil
}
@@ -195,7 +194,7 @@ func (s *ConfigSynthesizer) synthesizeClaudeKeys(ctx *SynthesisContext) []*corea
CreatedAt: now,
UpdatedAt: now,
}
- ApplyAuthExcludedModelsMeta(a, cfg, ck.ExcludedModels, "apikey")
+ ApplyAuthExcludedModelsMeta(a, ck.ExcludedModels, "apikey")
if len(a.Metadata) == 0 {
a.Metadata = nil
}
@@ -220,7 +219,6 @@ func (s *ConfigSynthesizer) synthesizeMetaKeys(ctx *SynthesisContext) []*coreaut
}
func (s *ConfigSynthesizer) synthesizeCodexStyleKeys(ctx *SynthesisContext, entries []config.CodexKey, provider string) []*coreauth.Auth {
- cfg := ctx.Config
now := ctx.Now
idGen := ctx.IDGenerator
@@ -280,7 +278,7 @@ func (s *ConfigSynthesizer) synthesizeCodexStyleKeys(ctx *SynthesisContext, entr
CreatedAt: now,
UpdatedAt: now,
}
- ApplyAuthExcludedModelsMeta(a, cfg, entry.ExcludedModels, "apikey")
+ ApplyAuthExcludedModelsMeta(a, entry.ExcludedModels, "apikey")
if len(a.Metadata) == 0 {
a.Metadata = nil
}
@@ -458,7 +456,7 @@ func (s *ConfigSynthesizer) synthesizeVertexCompat(ctx *SynthesisContext) []*cor
CreatedAt: now,
UpdatedAt: now,
}
- ApplyAuthExcludedModelsMeta(a, cfg, compat.ExcludedModels, "apikey")
+ ApplyAuthExcludedModelsMeta(a, compat.ExcludedModels, "apikey")
if len(a.Metadata) == 0 {
a.Metadata = nil
}
diff --git a/internal/watcher/synthesizer/context.go b/internal/watcher/synthesizer/context.go
index dad004acac9..10d0be2306e 100644
--- a/internal/watcher/synthesizer/context.go
+++ b/internal/watcher/synthesizer/context.go
@@ -32,4 +32,20 @@ type SynthesisContext struct {
IDGenerator *StableIDGenerator
// PluginAuthParser parses plugin-owned auth files
PluginAuthParser PluginAuthParser
+ // StrictFileParsing surfaces parse errors instead of silently skipping or falling back.
+ StrictFileParsing bool
+ // IncompleteFiles records existing auth JSON file paths whose contents were
+ // empty or not valid JSON during a non-strict directory scan.
+ IncompleteFiles map[string]struct{}
+}
+
+// RecordIncompleteFile records an existing auth JSON file whose content is not yet complete JSON.
+func (c *SynthesisContext) RecordIncompleteFile(path string) {
+ if c == nil || path == "" {
+ return
+ }
+ if c.IncompleteFiles == nil {
+ c.IncompleteFiles = make(map[string]struct{})
+ }
+ c.IncompleteFiles[path] = struct{}{}
}
diff --git a/internal/watcher/synthesizer/excluded_models_test.go b/internal/watcher/synthesizer/excluded_models_test.go
new file mode 100644
index 00000000000..d9960dad39b
--- /dev/null
+++ b/internal/watcher/synthesizer/excluded_models_test.go
@@ -0,0 +1,190 @@
+package synthesizer
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+ "time"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
+)
+
+func mustSynthesizeAuthFile(t *testing.T, ctx *SynthesisContext, fullPath string, raw string) []*coreauth.Auth {
+ t.Helper()
+ auths, errSynthesize := SynthesizeAuthFile(ctx, fullPath, []byte(raw))
+ if errSynthesize != nil {
+ t.Fatalf("SynthesizeAuthFile(%s) error = %v", fullPath, errSynthesize)
+ }
+ return auths
+}
+
+func TestExtractExcludedModelsFromMetadata(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ metadata map[string]any
+ want []string
+ }{
+ {name: "absent", metadata: map[string]any{}},
+ {name: "null", metadata: map[string]any{"excluded_models": nil}},
+ {name: "empty array", metadata: map[string]any{"excluded_models": []any{}}, want: []string{}},
+ {name: "array", metadata: map[string]any{"excluded_models": []any{" a ", "", "b"}}, want: []string{"a", "b"}},
+ {name: "string slice", metadata: map[string]any{"excluded_models": []string{"a"}}, want: []string{"a"}},
+ {name: "single string", metadata: map[string]any{"excluded_models": "gemini-pro-agent"}, want: []string{"gemini-pro-agent"}},
+ {name: "comma string", metadata: map[string]any{"excluded_models": " gemini-pro-agent , ,gemini-3-flash"}, want: []string{"gemini-pro-agent", "gemini-3-flash"}},
+ {name: "blank string", metadata: map[string]any{"excluded_models": " "}, want: []string{}},
+ {name: "legacy key string", metadata: map[string]any{"excluded-models": "a,b"}, want: []string{"a", "b"}},
+ {name: "canonical key wins", metadata: map[string]any{"excluded_models": []any{}, "excluded-models": []any{"legacy"}}, want: []string{}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ got, errExtract := ExtractExcludedModelsFromMetadata(tc.metadata)
+ if errExtract != nil {
+ t.Fatalf("ExtractExcludedModelsFromMetadata() error = %v", errExtract)
+ }
+ if !reflect.DeepEqual(got, tc.want) {
+ t.Fatalf("ExtractExcludedModelsFromMetadata() = %#v, want %#v", got, tc.want)
+ }
+ })
+ }
+ for _, tc := range []struct {
+ value any
+ want []string
+ }{
+ {value: 123},
+ {value: true},
+ {value: map[string]any{}},
+ {value: []any{"a", 123, " b "}, want: []string{"a", "b"}},
+ {value: []any{nil}, want: []string{}},
+ } {
+ got, errExtract := ExtractExcludedModelsFromMetadata(map[string]any{"excluded_models": tc.value})
+ if errExtract == nil {
+ t.Fatalf("ExtractExcludedModelsFromMetadata(%#v) = %v, want error", tc.value, got)
+ }
+ // Valid string entries are still returned for lenient readers.
+ if !reflect.DeepEqual(got, tc.want) {
+ t.Fatalf("ExtractExcludedModelsFromMetadata(%#v) = %#v, want %#v", tc.value, got, tc.want)
+ }
+ }
+}
+
+func TestSynthesizeAuthFileKeepsCredentialWithInvalidExcludedModels(t *testing.T) {
+ authDir := t.TempDir()
+ ctx := &SynthesisContext{Config: &config.Config{}, AuthDir: authDir, Now: time.Date(2026, time.October, 10, 12, 0, 0, 0, time.UTC)}
+ badPath := filepath.Join(authDir, "bad.json")
+
+ // Existing credentials must keep loading; only the invalid rules are ignored.
+ for _, tc := range []struct {
+ raw string
+ want string
+ }{
+ {raw: `{"type":"claude","email":"bad@example.test","excluded_models":123}`},
+ {raw: `{"type":"claude","email":"bad@example.test","excluded_models":{"x":1}}`},
+ {raw: `{"type":"claude","email":"bad@example.test","excluded_models":[null]}`},
+ {raw: `{"type":"claude","email":"bad@example.test","excluded_models":["Model-A",null,1]}`, want: "model-a"},
+ } {
+ auths := mustSynthesizeAuthFile(t, ctx, badPath, tc.raw)
+ if len(auths) != 1 {
+ t.Fatalf("%s: len(auths) = %d, want 1", tc.raw, len(auths))
+ }
+ if got := auths[0].Attributes["excluded_models"]; got != tc.want {
+ t.Fatalf("%s: excluded_models = %q, want %q", tc.raw, got, tc.want)
+ }
+ }
+
+ pluginCtx := *ctx
+ pluginCtx.PluginAuthParser = multiAuthParserFunc(func(context.Context, pluginapi.AuthParseRequest) ([]*coreauth.Auth, bool, error) {
+ return []*coreauth.Auth{{ID: "plugin.json", Provider: "acme"}}, true, nil
+ })
+ pluginAuths := mustSynthesizeAuthFile(t, &pluginCtx, filepath.Join(authDir, "plugin.json"), `{"type":"acme","excluded_models":["a",false]}`)
+ if len(pluginAuths) != 1 || pluginAuths[0].Attributes["excluded_models"] != "a" {
+ t.Fatalf("plugin auths = %+v, want one auth keeping the valid rule", pluginAuths)
+ }
+
+ // A directory scan keeps both credentials.
+ if errWrite := os.WriteFile(badPath, []byte(`{"type":"claude","email":"bad@example.test","excluded_models":123}`), 0o600); errWrite != nil {
+ t.Fatalf("write bad auth file: %v", errWrite)
+ }
+ if errWrite := os.WriteFile(filepath.Join(authDir, "good.json"), []byte(`{"type":"claude","email":"good@example.test"}`), 0o600); errWrite != nil {
+ t.Fatalf("write good auth file: %v", errWrite)
+ }
+ scanned, errScan := NewFileSynthesizer().Synthesize(ctx)
+ if errScan != nil || len(scanned) != 2 {
+ t.Fatalf("FileSynthesizer.Synthesize() = (%v, %v), want bad.json and good.json", scanned, errScan)
+ }
+
+ auths := mustSynthesizeAuthFile(t, ctx, filepath.Join(authDir, "legacy.json"), `{"type":"claude","email":"legacy@example.test","excluded_models":"Model-A,model-b"}`)
+ if len(auths) != 1 || auths[0].Attributes["excluded_models"] != "model-a,model-b" {
+ t.Fatalf("legacy string auths = %+v, want comma string split into two rules", auths)
+ }
+}
+
+func TestSynthesizeAuthFileKeepsPluginExcludedModelsWithoutFileRules(t *testing.T) {
+ authDir := t.TempDir()
+ ctx := &SynthesisContext{
+ Config: &config.Config{},
+ AuthDir: authDir,
+ Now: time.Date(2026, time.October, 10, 12, 0, 0, 0, time.UTC),
+ PluginAuthParser: multiAuthParserFunc(func(context.Context, pluginapi.AuthParseRequest) ([]*coreauth.Auth, bool, error) {
+ return []*coreauth.Auth{{ID: "plugin.json", Provider: "acme", Attributes: map[string]string{
+ "excluded_models": "plugin-model", "excluded_models_hash": "plugin-hash",
+ }}}, true, nil
+ }),
+ }
+ path := filepath.Join(authDir, "plugin.json")
+
+ for _, tc := range []struct {
+ raw string
+ wantRule string
+ }{
+ // Without file rules, the plugin-provided attributes stay as returned by the parser.
+ {raw: `{"type":"acme"}`, wantRule: "plugin-model"},
+ {raw: `{"type":"acme","excluded_models":[]}`, wantRule: "plugin-model"},
+ // File rules replace the plugin-provided list.
+ {raw: `{"type":"acme","excluded_models":["File-Model"]}`, wantRule: "file-model"},
+ } {
+ auths := mustSynthesizeAuthFile(t, ctx, path, tc.raw)
+ if len(auths) != 1 {
+ t.Fatalf("%s: len(auths) = %d, want 1", tc.raw, len(auths))
+ }
+ if got := auths[0].Attributes["excluded_models"]; got != tc.wantRule {
+ t.Fatalf("%s: excluded_models = %q, want %q", tc.raw, got, tc.wantRule)
+ }
+ }
+}
+
+func TestSynthesizeAuthFileDoesNotPersistGlobalOAuthExclusions(t *testing.T) {
+ authDir := t.TempDir()
+ ctx := &SynthesisContext{
+ Config: &config.Config{OAuthExcludedModels: map[string][]string{"claude": {"global-model"}}},
+ AuthDir: authDir,
+ Now: time.Date(2026, time.October, 10, 12, 0, 0, 0, time.UTC),
+ }
+
+ for _, raw := range []string{
+ `{"type":"claude","email":"inherit@example.test"}`,
+ `{"type":"claude","email":"inherit@example.test","excluded_models":[]}`,
+ `{"type":"claude","email":"inherit@example.test","excluded_models":[" ","\t"]}`,
+ } {
+ inherited := mustSynthesizeAuthFile(t, ctx, filepath.Join(authDir, "inherit.json"), raw)
+ if len(inherited) != 1 {
+ t.Fatalf("%s: len(inherited) = %d, want 1", raw, len(inherited))
+ }
+ if value, ok := inherited[0].Attributes["excluded_models"]; ok {
+ t.Fatalf("%s: inherited excluded_models = %q, want absent so global config is resolved at runtime", raw, value)
+ }
+ if value, ok := inherited[0].Attributes["excluded_models_hash"]; ok {
+ t.Fatalf("%s: inherited excluded_models_hash = %q, want absent", raw, value)
+ }
+ }
+
+ own := mustSynthesizeAuthFile(t, ctx, filepath.Join(authDir, "own.json"), `{"type":"claude","email":"own@example.test","excluded_models":["Own-Model"]}`)
+ if len(own) != 1 {
+ t.Fatalf("len(own) = %d, want 1", len(own))
+ }
+ if got, want := own[0].Attributes["excluded_models"], "own-model"; got != want {
+ t.Fatalf("own excluded_models = %q, want %q", got, want)
+ }
+}
diff --git a/internal/watcher/synthesizer/file.go b/internal/watcher/synthesizer/file.go
index 36c50915022..07ec2f55ac2 100644
--- a/internal/watcher/synthesizer/file.go
+++ b/internal/watcher/synthesizer/file.go
@@ -49,7 +49,11 @@ func (s *FileSynthesizer) Synthesize(ctx *SynthesisContext) ([]*coreauth.Auth, e
}
full := filepath.Join(ctx.AuthDir, name)
data, errRead := os.ReadFile(full)
- if errRead != nil || len(data) == 0 {
+ if errRead != nil {
+ continue
+ }
+ if !json.Valid(data) {
+ ctx.RecordIncompleteFile(full)
continue
}
auths, errSynthesize := synthesizeFileAuths(ctx, full, data)
@@ -76,15 +80,23 @@ func synthesizeFileAuths(ctx *SynthesisContext, fullPath string, data []byte) ([
return nil, nil
}
now := ctx.Now
- cfg := ctx.Config
var metadata map[string]any
if errUnmarshal := json.Unmarshal(data, &metadata); errUnmarshal != nil {
+ if ctx.StrictFileParsing {
+ return nil, fmt.Errorf("parse auth file: %w", errUnmarshal)
+ }
return nil, nil
}
coreauth.NormalizeCredentialMetadata(metadata)
if errWeight := coreauth.ValidateAuthWeight(&coreauth.Auth{Metadata: metadata}); errWeight != nil {
return nil, fmt.Errorf("invalid weight in %s: %w", filepath.Base(fullPath), errWeight)
}
+ // Invalid per-account rules must not drop an existing credential: keep the valid
+ // entries and warn. Write entry points (upload and PATCH) reject them before persisting.
+ perAccountExcluded, errExcluded := ExtractExcludedModelsFromMetadata(metadata)
+ if errExcluded != nil {
+ log.WithError(errExcluded).Warnf("ignoring invalid excluded models in auth file %s", filepath.Base(fullPath))
+ }
t, _ := metadata["type"].(string)
provider := strings.ToLower(strings.TrimSpace(t))
if provider == "gemini" {
@@ -97,12 +109,14 @@ func synthesizeFileAuths(ctx *SynthesisContext, fullPath string, data []byte) ([
FileName: filepath.Base(fullPath),
RawJSON: data,
})
+ if errParse != nil && ctx.StrictFileParsing {
+ return nil, fmt.Errorf("parse plugin auth file: %w", errParse)
+ }
if errParse == nil && handled {
auths = compactPluginAuths(auths)
if len(auths) == 0 {
return nil, nil
}
- perAccountExcluded := extractExcludedModelsFromMetadata(metadata)
perAccountModelAliases := extractOAuthModelAliasesFromMetadata(metadata)
disabled, _ := metadata["disabled"].(bool)
for index, auth := range auths {
@@ -145,9 +159,10 @@ func synthesizeFileAuths(ctx *SynthesisContext, fullPath string, data []byte) ([
}
}
coreauth.SetOAuthModelAliasesAttribute(auth, perAccountModelAliases)
- ApplyAuthExcludedModelsMeta(auth, cfg, perAccountExcluded, "oauth")
+ ApplyAuthExcludedModelsMeta(auth, perAccountExcluded, "oauth")
coreauth.ApplyCustomHeadersFromMetadata(auth)
applyFingerprintProfileAttribute(auth, metadata)
+ auth.BindFileSnapshot(fullPath, data)
}
return auths, nil
}
@@ -190,8 +205,6 @@ func synthesizeFileAuths(ctx *SynthesisContext, fullPath string, data []byte) ([
status = coreauth.StatusDisabled
}
- // Read per-account excluded models from the OAuth JSON file.
- perAccountExcluded := extractExcludedModelsFromMetadata(metadata)
perAccountModelAliases := extractOAuthModelAliasesFromMetadata(metadata)
a := &coreauth.Auth{
@@ -227,7 +240,8 @@ func synthesizeFileAuths(ctx *SynthesisContext, fullPath string, data []byte) ([
}
coreauth.ApplyCustomHeadersFromMetadata(a)
coreauth.SetOAuthModelAliasesAttribute(a, perAccountModelAliases)
- ApplyAuthExcludedModelsMeta(a, cfg, perAccountExcluded, "oauth")
+ applyOpenAPIModelsAttribute(a, metadata)
+ ApplyAuthExcludedModelsMeta(a, perAccountExcluded, "oauth")
applyFingerprintProfileAttribute(a, metadata)
// For Kimi auth files, preserve domain and base_url attributes.
if provider == "kimi" || provider == "kimi-ai" || provider == "kimi.ai" || provider == "kimi.com" {
@@ -259,6 +273,7 @@ func synthesizeFileAuths(ctx *SynthesisContext, fullPath string, data []byte) ([
}
}
}
+ a.BindFileSnapshot(fullPath, data)
return []*coreauth.Auth{a}, nil
}
@@ -295,60 +310,133 @@ func compactPluginAuths(auths []*coreauth.Auth) []*coreauth.Auth {
// extractOAuthModelAliasesFromMetadata reads per-account model aliases from OAuth JSON metadata.
// "model_aliases" is canonical; "model-aliases" remains a legacy alias.
+// Entries under "models" that specify both "name" and "alias" are also accepted.
func extractOAuthModelAliasesFromMetadata(metadata map[string]any) []config.OAuthModelAlias {
if metadata == nil {
return nil
}
- raw, ok := metadata["model_aliases"]
- if !ok {
- raw, ok = metadata["model-aliases"]
- }
- if !ok || raw == nil {
- return nil
- }
- data, errMarshal := json.Marshal(raw)
- if errMarshal != nil {
- return nil
+ var combined []config.OAuthModelAlias
+ for _, key := range []string{"model_aliases", "model-aliases", "models"} {
+ raw, ok := metadata[key]
+ if !ok || raw == nil {
+ continue
+ }
+ data, errMarshal := json.Marshal(raw)
+ if errMarshal != nil {
+ continue
+ }
+ var aliases []config.OAuthModelAlias
+ if errUnmarshal := json.Unmarshal(data, &aliases); errUnmarshal != nil {
+ continue
+ }
+ combined = append(combined, aliases...)
}
- var aliases []config.OAuthModelAlias
- if errUnmarshal := json.Unmarshal(data, &aliases); errUnmarshal != nil {
+ if len(combined) == 0 {
return nil
}
cfg := config.Config{
OAuthModelAlias: map[string][]config.OAuthModelAlias{
- "auth": aliases,
+ "auth": combined,
},
}
cfg.SanitizeOAuthModelAlias()
return cfg.OAuthModelAlias["auth"]
}
-// extractExcludedModelsFromMetadata reads per-account excluded models from the OAuth JSON metadata.
-// "excluded_models" is canonical; "excluded-models" remains a legacy alias.
-func extractExcludedModelsFromMetadata(metadata map[string]any) []string {
+func applyOpenAPIModelsAttribute(auth *coreauth.Auth, metadata map[string]any) {
+ if auth == nil || metadata == nil {
+ return
+ }
+ var names []string
+ seen := make(map[string]struct{})
+ add := func(raw string) {
+ trimmed := strings.TrimSpace(raw)
+ if trimmed == "" {
+ return
+ }
+ key := strings.ToLower(trimmed)
+ if _, exists := seen[key]; exists {
+ return
+ }
+ seen[key] = struct{}{}
+ names = append(names, trimmed)
+ }
+ for _, key := range []string{"openapi_models", "openapi-models", "models"} {
+ raw, ok := metadata[key]
+ if !ok || raw == nil {
+ continue
+ }
+ switch v := raw.(type) {
+ case string:
+ for _, part := range strings.Split(v, ",") {
+ add(part)
+ }
+ case []string:
+ for _, item := range v {
+ add(item)
+ }
+ case []any:
+ for _, item := range v {
+ switch elem := item.(type) {
+ case string:
+ add(elem)
+ case map[string]any:
+ if name, okName := elem["name"].(string); okName {
+ add(name)
+ }
+ }
+ }
+ }
+ }
+ if len(names) == 0 {
+ return
+ }
+ if auth.Attributes == nil {
+ auth.Attributes = make(map[string]string)
+ }
+ auth.Attributes["openapi_models"] = strings.Join(names, ",")
+}
+
+// ExtractExcludedModelsFromMetadata reads per-account excluded models from the OAuth JSON metadata.
+// "excluded_models" is canonical; "excluded-models" remains a legacy alias. The value must be a
+// string array; a single string is also accepted for older hand-written files and is split on
+// commas, because model IDs never contain commas. Any other type, or a non-string array element,
+// is an error. For non-string elements the valid string entries are still returned with the
+// error, so lenient readers can keep the usable rules while strict writers reject the value.
+func ExtractExcludedModelsFromMetadata(metadata map[string]any) ([]string, error) {
if metadata == nil {
- return nil
+ return nil, nil
}
- raw, ok := metadata["excluded_models"]
+ key := "excluded_models"
+ raw, ok := metadata[key]
if !ok {
- raw, ok = metadata["excluded-models"]
+ key = "excluded-models"
+ raw, ok = metadata[key]
}
if !ok || raw == nil {
- return nil
+ return nil, nil
}
var stringSlice []string
+ var errInvalid error
switch v := raw.(type) {
+ case string:
+ stringSlice = strings.Split(v, ",")
case []string:
stringSlice = v
case []interface{}:
stringSlice = make([]string, 0, len(v))
- for _, item := range v {
- if s, ok := item.(string); ok {
- stringSlice = append(stringSlice, s)
+ for index, item := range v {
+ s, okString := item.(string)
+ if !okString {
+ if errInvalid == nil {
+ errInvalid = fmt.Errorf("metadata %s[%d] must be a string, got %T", key, index, item)
+ }
+ continue
}
+ stringSlice = append(stringSlice, s)
}
default:
- return nil
+ return nil, fmt.Errorf("metadata %s must be a string array, got %T", key, raw)
}
result := make([]string, 0, len(stringSlice))
for _, s := range stringSlice {
@@ -356,5 +444,5 @@ func extractExcludedModelsFromMetadata(metadata map[string]any) []string {
result = append(result, trimmed)
}
}
- return result
+ return result, errInvalid
}
diff --git a/internal/watcher/synthesizer/file_test.go b/internal/watcher/synthesizer/file_test.go
index e93e8ed1e79..bfb65f99fea 100644
--- a/internal/watcher/synthesizer/file_test.go
+++ b/internal/watcher/synthesizer/file_test.go
@@ -790,7 +790,7 @@ func TestFileSynthesizer_Synthesize_WeightParsing(t *testing.T) {
}
}
-func TestFileSynthesizer_Synthesize_OAuthExcludedModelsMerged(t *testing.T) {
+func TestFileSynthesizer_Synthesize_OAuthExcludedModelsCredentialPriority(t *testing.T) {
tempDir := t.TempDir()
authData := map[string]any{
"type": "claude",
@@ -823,13 +823,13 @@ func TestFileSynthesizer_Synthesize_OAuthExcludedModelsMerged(t *testing.T) {
}
got := auths[0].Attributes["excluded_models"]
- want := "custom-model,model-b,shared"
+ want := "custom-model,model-b"
if got != want {
t.Fatalf("expected excluded_models %q, got %q", want, got)
}
}
-func TestFileSynthesizer_Synthesize_MetaOAuthExcludedModelsMerged(t *testing.T) {
+func TestFileSynthesizer_Synthesize_MetaOAuthExcludedModelsCredentialPriority(t *testing.T) {
tempDir := t.TempDir()
authData := map[string]any{
"type": "meta",
@@ -867,7 +867,7 @@ func TestFileSynthesizer_Synthesize_MetaOAuthExcludedModelsMerged(t *testing.T)
}
got := auths[0].Attributes["excluded_models"]
- want := "muse-spark-1.1,muse-spark-1.2"
+ want := "muse-spark-1.2"
if got != want {
t.Fatalf("expected excluded_models %q, got %q", want, got)
}
@@ -1108,3 +1108,50 @@ func TestSynthesizeAuthFile_CodexPlanType(t *testing.T) {
})
}
}
+
+func TestFileSynthesizerRecordsIncompleteFiles(t *testing.T) {
+ tempDir := t.TempDir()
+ emptyPath := filepath.Join(tempDir, "empty.json")
+ partialPath := filepath.Join(tempDir, "partial.json")
+ validZeroPath := filepath.Join(tempDir, "valid_zero.json")
+ validAuthPath := filepath.Join(tempDir, "valid_auth.json")
+
+ if errWrite := os.WriteFile(emptyPath, nil, 0o600); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ if errWrite := os.WriteFile(partialPath, []byte(`{"type":"codex"`), 0o600); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ if errWrite := os.WriteFile(validZeroPath, []byte(`{}`), 0o600); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ if errWrite := os.WriteFile(validAuthPath, []byte(`{"type":"codex"}`), 0o600); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+
+ ctx := &SynthesisContext{
+ Config: &config.Config{},
+ AuthDir: tempDir,
+ Now: time.Now(),
+ IDGenerator: NewStableIDGenerator(),
+ }
+ auths, errSynthesize := NewFileSynthesizer().Synthesize(ctx)
+ if errSynthesize != nil {
+ t.Fatalf("Synthesize() error = %v", errSynthesize)
+ }
+ if len(auths) != 1 || auths[0].ID != "valid_auth.json" {
+ t.Fatalf("Synthesize() auths = %#v, want only valid_auth.json", auths)
+ }
+ if _, ok := ctx.IncompleteFiles[emptyPath]; !ok {
+ t.Fatalf("expected empty file %s in IncompleteFiles, got %#v", emptyPath, ctx.IncompleteFiles)
+ }
+ if _, ok := ctx.IncompleteFiles[partialPath]; !ok {
+ t.Fatalf("expected partial file %s in IncompleteFiles, got %#v", partialPath, ctx.IncompleteFiles)
+ }
+ if _, ok := ctx.IncompleteFiles[validZeroPath]; ok {
+ t.Fatalf("did not expect valid zero-auth file %s in IncompleteFiles", validZeroPath)
+ }
+ if _, ok := ctx.IncompleteFiles[validAuthPath]; ok {
+ t.Fatalf("did not expect valid auth file %s in IncompleteFiles", validAuthPath)
+ }
+}
diff --git a/internal/watcher/synthesizer/helpers.go b/internal/watcher/synthesizer/helpers.go
index e9862193880..d65cd32620a 100644
--- a/internal/watcher/synthesizer/helpers.go
+++ b/internal/watcher/synthesizer/helpers.go
@@ -51,35 +51,20 @@ func (g *StableIDGenerator) Next(kind string, parts ...string) (string, string)
return fmt.Sprintf("%s:%s", kind, short), short
}
-// ApplyAuthExcludedModelsMeta applies excluded models metadata to an auth entry.
-// It computes a hash of excluded models and sets the auth_kind attribute.
-// For OAuth entries, perKey (from the JSON file's excluded-models field) is merged
-// with the global oauth-excluded-models config for the provider.
-func ApplyAuthExcludedModelsMeta(auth *coreauth.Auth, cfg *config.Config, perKey []string, authKind string) {
- if auth == nil || cfg == nil {
+// ApplyAuthExcludedModelsMeta stores the credential's own excluded models on an auth entry.
+// It computes a hash of excluded models and sets the auth_kind attribute when authKind is set.
+// Existing excluded_models attributes are never cleared, so callers rebuilding them on an
+// existing auth must drop stale values first.
+// Global oauth-excluded-models are intentionally not merged here: they are resolved at
+// model registration time, and a nonempty credential list overrides them.
+func ApplyAuthExcludedModelsMeta(auth *coreauth.Auth, perKey []string, authKind string) {
+ if auth == nil {
return
}
- authKindKey := strings.ToLower(strings.TrimSpace(authKind))
seen := make(map[string]struct{})
- add := func(list []string) {
- for _, entry := range list {
- if trimmed := strings.TrimSpace(entry); trimmed != "" {
- key := strings.ToLower(trimmed)
- if _, exists := seen[key]; exists {
- continue
- }
- seen[key] = struct{}{}
- }
- }
- }
- if authKindKey == "apikey" {
- add(perKey)
- } else {
- // For OAuth: merge per-account excluded models with global provider-level exclusions
- add(perKey)
- if cfg.OAuthExcludedModels != nil {
- providerKey := strings.ToLower(strings.TrimSpace(auth.Provider))
- add(cfg.OAuthExcludedModels[providerKey])
+ for _, entry := range perKey {
+ if trimmed := strings.TrimSpace(entry); trimmed != "" {
+ seen[strings.ToLower(trimmed)] = struct{}{}
}
}
combined := make([]string, 0, len(seen))
@@ -94,7 +79,7 @@ func ApplyAuthExcludedModelsMeta(auth *coreauth.Auth, cfg *config.Config, perKey
if hash != "" {
auth.Attributes["excluded_models_hash"] = hash
}
- // Store the combined excluded models list so that routing can read it at runtime
+ // Store only the credential's own rules so global config changes apply without rewriting credentials.
if len(combined) > 0 {
auth.Attributes["excluded_models"] = strings.Join(combined, ",")
}
diff --git a/internal/watcher/synthesizer/helpers_test.go b/internal/watcher/synthesizer/helpers_test.go
index e128caaa5e2..2a834eb9791 100644
--- a/internal/watcher/synthesizer/helpers_test.go
+++ b/internal/watcher/synthesizer/helpers_test.go
@@ -5,7 +5,6 @@ import (
"strings"
"testing"
- "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/watcher/diff"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
)
@@ -110,7 +109,6 @@ func TestApplyAuthExcludedModelsMeta(t *testing.T) {
tests := []struct {
name string
auth *coreauth.Auth
- cfg *config.Config
perKey []string
authKind string
wantHash bool
@@ -122,38 +120,25 @@ func TestApplyAuthExcludedModelsMeta(t *testing.T) {
Provider: "gemini",
Attributes: make(map[string]string),
},
- cfg: &config.Config{},
perKey: []string{"model-a", "model-b"},
authKind: "apikey",
wantHash: true,
wantKind: "apikey",
},
{
- name: "oauth with provider excluded models",
+ name: "oauth without credential rules",
auth: &coreauth.Auth{
Provider: "claude",
Attributes: make(map[string]string),
},
- cfg: &config.Config{
- OAuthExcludedModels: map[string][]string{
- "claude": {"claude-2.0"},
- },
- },
perKey: nil,
authKind: "oauth",
- wantHash: true,
+ wantHash: false,
wantKind: "oauth",
},
{
name: "nil auth",
auth: nil,
- cfg: &config.Config{},
- },
- {
- name: "nil config",
- auth: &coreauth.Auth{Provider: "test"},
- cfg: nil,
- authKind: "apikey",
},
{
name: "nil attributes initialized",
@@ -161,7 +146,6 @@ func TestApplyAuthExcludedModelsMeta(t *testing.T) {
Provider: "gemini",
Attributes: nil,
},
- cfg: &config.Config{},
perKey: []string{"model-x"},
authKind: "apikey",
wantHash: true,
@@ -173,7 +157,6 @@ func TestApplyAuthExcludedModelsMeta(t *testing.T) {
Provider: "gemini",
Attributes: make(map[string]string),
},
- cfg: &config.Config{},
perKey: []string{"model-a", "MODEL-A", "model-b", "model-a"},
authKind: "apikey",
wantHash: true,
@@ -183,13 +166,11 @@ func TestApplyAuthExcludedModelsMeta(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- ApplyAuthExcludedModelsMeta(tt.auth, tt.cfg, tt.perKey, tt.authKind)
+ ApplyAuthExcludedModelsMeta(tt.auth, tt.perKey, tt.authKind)
- if tt.auth != nil && tt.cfg != nil {
- if tt.wantHash {
- if _, ok := tt.auth.Attributes["excluded_models_hash"]; !ok {
- t.Error("expected excluded_models_hash in attributes")
- }
+ if tt.auth != nil {
+ if _, ok := tt.auth.Attributes["excluded_models_hash"]; ok != tt.wantHash {
+ t.Errorf("excluded_models_hash present = %t, want %t", ok, tt.wantHash)
}
if tt.wantKind != "" {
if got := tt.auth.Attributes["auth_kind"]; got != tt.wantKind {
@@ -201,30 +182,43 @@ func TestApplyAuthExcludedModelsMeta(t *testing.T) {
}
}
-func TestApplyAuthExcludedModelsMeta_OAuthMergeWritesCombinedModels(t *testing.T) {
+func TestApplyAuthExcludedModelsMeta_StoresOnlyCredentialRules(t *testing.T) {
auth := &coreauth.Auth{
Provider: "claude",
Attributes: make(map[string]string),
}
- cfg := &config.Config{
- OAuthExcludedModels: map[string][]string{
- "claude": {"global-a", "shared"},
- },
- }
- ApplyAuthExcludedModelsMeta(auth, cfg, []string{"per", "SHARED"}, "oauth")
+ ApplyAuthExcludedModelsMeta(auth, []string{"per", "SHARED", "shared"}, "oauth")
- const wantCombined = "global-a,per,shared"
+ const wantCombined = "per,shared"
if gotCombined := auth.Attributes["excluded_models"]; gotCombined != wantCombined {
t.Fatalf("expected excluded_models=%q, got %q", wantCombined, gotCombined)
}
- expectedHash := diff.ComputeExcludedModelsHash([]string{"global-a", "per", "shared"})
+ expectedHash := diff.ComputeExcludedModelsHash([]string{"per", "shared"})
if gotHash := auth.Attributes["excluded_models_hash"]; gotHash != expectedHash {
t.Fatalf("expected excluded_models_hash=%q, got %q", expectedHash, gotHash)
}
}
+func TestApplyAuthExcludedModelsMeta_EmptyRulesKeepExistingAttributes(t *testing.T) {
+ for _, rules := range [][]string{nil, {}, {" ", "\t"}} {
+ auth := &coreauth.Auth{Provider: "codex", Attributes: map[string]string{
+ "auth_kind": "oauth", "excluded_models": "existing", "excluded_models_hash": "existing",
+ }}
+ ApplyAuthExcludedModelsMeta(auth, rules, "")
+ if got := auth.Attributes["auth_kind"]; got != "oauth" {
+ t.Fatalf("rules=%v: auth_kind = %q, want unchanged oauth", rules, got)
+ }
+ // Callers that rebuild attributes on an existing auth must clear stale values themselves.
+ for _, key := range []string{"excluded_models", "excluded_models_hash"} {
+ if got := auth.Attributes[key]; got != "existing" {
+ t.Fatalf("rules=%v: %s = %q, want existing value kept", rules, key, got)
+ }
+ }
+ }
+}
+
func TestAddConfigHeadersToAttrs(t *testing.T) {
tests := []struct {
name string
diff --git a/internal/watcher/synthesizer/stale_snapshot_regression_test.go b/internal/watcher/synthesizer/stale_snapshot_regression_test.go
new file mode 100644
index 00000000000..597df546470
--- /dev/null
+++ b/internal/watcher/synthesizer/stale_snapshot_regression_test.go
@@ -0,0 +1,161 @@
+package synthesizer
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
+)
+
+func TestFileSnapshotCannotOverwriteLaterPersistedPrefix(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "synthetic.json")
+ write := func(prefix string) []byte {
+ data := []byte(`{"type":"codex","prefix":"` + prefix + `"}`)
+ if errWrite := os.WriteFile(path, data, 0o600); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ return data
+ }
+ snapshot := func(data []byte) *coreauth.Auth {
+ items, errSynthesize := SynthesizeAuthFile(&SynthesisContext{
+ Config: &config.Config{},
+ AuthDir: dir,
+ Now: time.Now(),
+ IDGenerator: NewStableIDGenerator(),
+ }, path, data)
+ if errSynthesize != nil || len(items) != 1 {
+ t.Fatalf("snapshot: %v count=%d", errSynthesize, len(items))
+ }
+ return items[0]
+ }
+ ctx := coreauth.WithSkipPersist(context.Background())
+ manager := coreauth.NewManager(nil, nil, nil)
+ initial := snapshot(write("pfx-8"))
+ if _, errRegister := manager.Register(ctx, initial); errRegister != nil {
+ t.Fatal(errRegister)
+ }
+ stale := snapshot(write("pfx-8"))
+ latest, _ := manager.GetByID(initial.ID)
+ latest.Prefix = "pfx-9"
+ latest.Metadata["prefix"] = "pfx-9"
+ write("pfx-9")
+ if _, errUpdate := manager.Update(ctx, latest); errUpdate != nil {
+ t.Fatal(errUpdate)
+ }
+ before, _ := manager.GetByID(initial.ID)
+ if _, errUpdate := manager.Update(ctx, stale); errUpdate != nil {
+ t.Fatal(errUpdate)
+ }
+ after, _ := manager.GetByID(initial.ID)
+ if after.Prefix != "pfx-9" || after.Generation != before.Generation {
+ t.Fatalf("stale snapshot overwrote prefix: got=%s gen=%d want=pfx-9 gen=%d", after.Prefix, after.Generation, before.Generation)
+ }
+ manual := snapshot(write("manual-edit"))
+ if _, errUpdate := manager.Update(ctx, manual); errUpdate != nil {
+ t.Fatal(errUpdate)
+ }
+ after, _ = manager.GetByID(initial.ID)
+ if after.Prefix != "manual-edit" {
+ t.Fatal("legitimate file edit was lost")
+ }
+ // A deleted or unreadable source must not be replayed into live state.
+ deleted := snapshot(write("deleted-edit"))
+ if errRemove := os.Remove(path); errRemove != nil {
+ t.Fatal(errRemove)
+ }
+ before = after.Clone()
+ if _, errUpdate := manager.Update(ctx, deleted); errUpdate != nil {
+ t.Fatal(errUpdate)
+ }
+ after, _ = manager.GetByID(initial.ID)
+ if after.Prefix != before.Prefix || after.Generation != before.Generation {
+ t.Fatal("deleted file snapshot changed runtime state")
+ }
+ if errMkdir := os.Mkdir(path, 0o700); errMkdir != nil {
+ t.Fatal(errMkdir)
+ }
+ if _, errUpdate := manager.Update(ctx, deleted); errUpdate != nil {
+ t.Fatal(errUpdate)
+ }
+ after, _ = manager.GetByID(initial.ID)
+ if after.Prefix != before.Prefix || after.Generation != before.Generation {
+ t.Fatal("unreadable file snapshot changed runtime state")
+ }
+}
+
+func TestPluginFileSnapshotCannotOverwriteLaterPersistedPrefix(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "plugin.json")
+ write := func(prefix string) []byte {
+ data := []byte(`{"type":"gemini-cli","prefix":"` + prefix + `"}`)
+ if errWrite := os.WriteFile(path, data, 0o600); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ return data
+ }
+ parser := multiAuthParserFunc(func(_ context.Context, _ pluginapi.AuthParseRequest) ([]*coreauth.Auth, bool, error) {
+ return []*coreauth.Auth{
+ {
+ ID: "plugin-project-a.json",
+ Provider: "gemini-cli",
+ Metadata: map[string]any{"type": "gemini-cli", "project_id": "project-a"},
+ },
+ {
+ ID: "plugin-project-b.json",
+ Provider: "gemini-cli",
+ Metadata: map[string]any{"type": "gemini-cli", "project_id": "project-b"},
+ },
+ }, true, nil
+ })
+ snapshot := func(data []byte) []*coreauth.Auth {
+ items, errSynthesize := SynthesizeAuthFile(&SynthesisContext{
+ Config: &config.Config{},
+ AuthDir: dir,
+ Now: time.Now(),
+ IDGenerator: NewStableIDGenerator(),
+ PluginAuthParser: parser,
+ }, path, data)
+ if errSynthesize != nil || len(items) != 2 {
+ t.Fatalf("snapshot: %v count=%d", errSynthesize, len(items))
+ }
+ return items
+ }
+ ctx := coreauth.WithSkipPersist(context.Background())
+ manager := coreauth.NewManager(nil, nil, nil)
+ initial := snapshot(write("pfx-8"))
+ for _, item := range initial {
+ if _, errRegister := manager.Register(ctx, item); errRegister != nil {
+ t.Fatal(errRegister)
+ }
+ }
+ stale := snapshot(write("pfx-8"))
+ latest, _ := manager.GetByID(initial[0].ID)
+ latest.Prefix = "pfx-9"
+ latest.Metadata["prefix"] = "pfx-9"
+ write("pfx-9")
+ if _, errUpdate := manager.Update(ctx, latest); errUpdate != nil {
+ t.Fatal(errUpdate)
+ }
+ before, _ := manager.GetByID(initial[0].ID)
+ if _, errUpdate := manager.Update(ctx, stale[0]); errUpdate != nil {
+ t.Fatal(errUpdate)
+ }
+ after, _ := manager.GetByID(initial[0].ID)
+ if after.Prefix != "pfx-9" || after.Generation != before.Generation {
+ t.Fatalf("stale plugin snapshot overwrote prefix: got=%s gen=%d want=pfx-9 gen=%d", after.Prefix, after.Generation, before.Generation)
+ }
+ manual := snapshot(write("manual-edit"))
+ if _, errUpdate := manager.Update(ctx, manual[0]); errUpdate != nil {
+ t.Fatal(errUpdate)
+ }
+ after, _ = manager.GetByID(initial[0].ID)
+ if after.Prefix != "manual-edit" {
+ t.Fatalf("legitimate plugin file edit was lost: got=%s", after.Prefix)
+ }
+}
diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go
index 4d88a3eb2ba..b10e0b84819 100644
--- a/internal/watcher/watcher.go
+++ b/internal/watcher/watcher.go
@@ -31,40 +31,42 @@ type authDirProvider interface {
// Watcher manages file watching for configuration and authentication files
type Watcher struct {
- configPath string
- authDir string
- config *config.Config
- clientsMutex sync.RWMutex
- authRescanMu sync.Mutex
- configReloadMu sync.Mutex
- configReloadTimer *time.Timer
- serverUpdateMu sync.Mutex
- serverUpdateTimer *time.Timer
- serverUpdateLast time.Time
- serverUpdatePend bool
- stopped atomic.Bool
- reloadCallback func(*config.Config)
- watcher *fsnotify.Watcher
- lastAuthHashes map[string]string
- lastAuthContents map[string]*coreauth.Auth
- fileAuthsByPath map[string]map[string]*coreauth.Auth
- lastRemoveTimes map[string]time.Time
- lastConfigHash string
- authQueue chan<- AuthUpdate
- currentAuths map[string]*coreauth.Auth
- authRevisions map[string]uint64 // Includes deletion tombstones; guarded by clientsMutex.
- fileObservations map[string]uint64 // Tracks file events even when content is unchanged.
- activeAuthScans int // Guarded by clientsMutex.
- runtimeAuths map[string]*coreauth.Auth
- dispatchMu sync.Mutex
- dispatchCond *sync.Cond
- pendingUpdates map[string]AuthUpdate
- pendingOrder []string
- dispatchCancel context.CancelFunc
- storePersister storePersister
- pluginAuthParser synthesizer.PluginAuthParser
- mirroredAuthDir string
- oldConfigYaml []byte
+ configPath string
+ authDir string
+ config *config.Config
+ clientsMutex sync.RWMutex
+ authRescanMu sync.Mutex
+ configReloadMu sync.Mutex
+ configReloadTimer *time.Timer
+ configApplyMu sync.Mutex // Serializes config file reloads so they apply in file order.
+ serverUpdateMu sync.Mutex
+ serverUpdateTimer *time.Timer
+ serverUpdateLast time.Time
+ serverUpdatePend bool
+ stopped atomic.Bool
+ reloadCallback func(*config.Config)
+ watcher *fsnotify.Watcher
+ lastAuthHashes map[string]string
+ lastAuthContents map[string]*coreauth.Auth
+ fileAuthsByPath map[string]map[string]*coreauth.Auth
+ lastRemoveTimes map[string]time.Time
+ lastConfigHash string
+ authQueue chan<- AuthUpdate
+ currentAuths map[string]*coreauth.Auth
+ pendingProviderReloadAuths map[string]*coreauth.Auth
+ authRevisions map[string]uint64 // Includes deletion tombstones; guarded by clientsMutex.
+ fileObservations map[string]uint64 // Tracks file events even when content is unchanged.
+ activeAuthScans int // Guarded by clientsMutex.
+ runtimeAuths map[string]*coreauth.Auth
+ dispatchMu sync.Mutex
+ dispatchCond *sync.Cond
+ pendingUpdates map[string]AuthUpdate
+ pendingOrder []string
+ dispatchCancel context.CancelFunc
+ storePersister storePersister
+ pluginAuthParser synthesizer.PluginAuthParser
+ mirroredAuthDir string
+ oldConfigYaml []byte
}
// AuthUpdateAction represents the type of change detected in auth sources.
@@ -78,10 +80,23 @@ const (
// AuthUpdate describes an incremental change to auth configuration.
type AuthUpdate struct {
- Action AuthUpdateAction
- ID string
- Auth *coreauth.Auth
- revision uint64 // Watcher-local ordering, independent of runtime auth generations.
+ Action AuthUpdateAction
+ ID string
+ Auth *coreauth.Auth
+ revision uint64 // Watcher-local ordering, independent of runtime auth generations.
+ fileRemoval bool // The source file was removed, not just an ID from its content.
+}
+
+// IsFileRemoval reports whether this delete was observed as a whole-file removal.
+func (u AuthUpdate) IsFileRemoval() bool {
+ return u.fileRemoval
+}
+
+// SetFileRemoval marks a delete as a whole-file removal rather than a per-ID diff.
+func (u *AuthUpdate) SetFileRemoval(fileRemoval bool) {
+ if u != nil {
+ u.fileRemoval = fileRemoval
+ }
}
// Revision returns the monotonic watcher revision assigned to this update.
@@ -151,10 +166,11 @@ func (w *Watcher) Stop() error {
// SetConfig updates the current configuration
func (w *Watcher) SetConfig(cfg *config.Config) {
+ cloned := cfg.CloneForRuntime()
w.clientsMutex.Lock()
defer w.clientsMutex.Unlock()
- w.config = cfg
- w.oldConfigYaml, _ = yaml.Marshal(cfg)
+ w.config = cloned
+ w.oldConfigYaml, _ = yaml.Marshal(cloned)
}
// SetPluginAuthParser updates the plugin auth parser used for file auth synthesis.
diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go
index 09a0bcfd36a..9df65e6b7e2 100644
--- a/internal/watcher/watcher_test.go
+++ b/internal/watcher/watcher_test.go
@@ -25,10 +25,9 @@ import (
func TestApplyAuthExcludedModelsMeta_APIKey(t *testing.T) {
auth := &coreauth.Auth{Attributes: map[string]string{}}
- cfg := &config.Config{}
perKey := []string{" Model-1 ", "model-2"}
- synthesizer.ApplyAuthExcludedModelsMeta(auth, cfg, perKey, "apikey")
+ synthesizer.ApplyAuthExcludedModelsMeta(auth, perKey, "apikey")
expected := diff.ComputeExcludedModelsHash([]string{"model-1", "model-2"})
if got := auth.Attributes["excluded_models_hash"]; got != expected {
@@ -44,17 +43,14 @@ func TestApplyAuthExcludedModelsMeta_OAuthProvider(t *testing.T) {
Provider: "TestProv",
Attributes: map[string]string{},
}
- cfg := &config.Config{
- OAuthExcludedModels: map[string][]string{
- "testprov": {"A", "b"},
- },
- }
- synthesizer.ApplyAuthExcludedModelsMeta(auth, cfg, nil, "oauth")
+ synthesizer.ApplyAuthExcludedModelsMeta(auth, nil, "oauth")
- expected := diff.ComputeExcludedModelsHash([]string{"a", "b"})
- if got := auth.Attributes["excluded_models_hash"]; got != expected {
- t.Fatalf("expected hash %s, got %s", expected, got)
+ // Global oauth-excluded-models are resolved at model registration, not stored on the auth.
+ for _, key := range []string{"excluded_models", "excluded_models_hash"} {
+ if got, ok := auth.Attributes[key]; ok {
+ t.Fatalf("expected %s to be absent, got %q", key, got)
+ }
}
if got := auth.Attributes["auth_kind"]; got != "oauth" {
t.Fatalf("expected auth_kind=oauth, got %s", got)
@@ -444,7 +440,7 @@ func TestAuthFileEventsDoNotInvokeSnapshotCoreAuths(t *testing.T) {
origSnapshot := snapshotCoreAuthsFunc
var snapshotCalls int32
- snapshotCoreAuthsFunc = func(cfg *config.Config, authDir string, parser synthesizer.PluginAuthParser) []*coreauth.Auth {
+ snapshotCoreAuthsFunc = func(cfg *config.Config, authDir string, parser synthesizer.PluginAuthParser) ([]*coreauth.Auth, map[string]struct{}) {
atomic.AddInt32(&snapshotCalls, 1)
return origSnapshot(cfg, authDir, parser)
}
@@ -1477,6 +1473,67 @@ func TestReloadConfigFiltersAffectedOAuthProviders(t *testing.T) {
}
}
+func TestReloadConfigRedispatchesUnchangedAuthForGlobalOAuthExclusionChange(t *testing.T) {
+ tmpDir := t.TempDir()
+ authDir := filepath.Join(tmpDir, "auth")
+ if err := os.MkdirAll(authDir, 0o755); err != nil {
+ t.Fatalf("failed to create auth dir: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(authDir, "claude.json"), []byte(`{"type":"claude","email":"a@example.com"}`), 0o644); err != nil {
+ t.Fatalf("failed to write auth file: %v", err)
+ }
+ newConfig := func(excluded string) *config.Config {
+ return &config.Config{
+ AuthDir: authDir,
+ CredentialInFlight: config.DefaultCredentialInFlightConfig(),
+ OAuthExcludedModels: map[string][]string{"claude": {excluded}},
+ }
+ }
+ configPath := filepath.Join(tmpDir, "config.yaml")
+ data, errMarshal := yaml.Marshal(newConfig("m2"))
+ if errMarshal != nil {
+ t.Fatalf("failed to marshal config: %v", errMarshal)
+ }
+ if err := os.WriteFile(configPath, data, 0o644); err != nil {
+ t.Fatalf("failed to write config: %v", err)
+ }
+
+ queue := make(chan AuthUpdate, 8)
+ w := &Watcher{configPath: configPath, authDir: authDir, lastAuthHashes: make(map[string]string)}
+ w.SetConfig(newConfig("m1"))
+ w.SetAuthUpdateQueue(queue)
+ defer w.stopDispatch()
+
+ nextUpdate := func(stage string) AuthUpdate {
+ t.Helper()
+ select {
+ case update := <-queue:
+ if update.Auth == nil || update.Auth.Provider != "claude" {
+ t.Fatalf("%s: unexpected auth update %+v", stage, update)
+ }
+ // Global rules are resolved at registration and never stored on the credential.
+ if value, ok := update.Auth.Attributes["excluded_models"]; ok {
+ t.Fatalf("%s: excluded_models attribute = %q, want absent", stage, value)
+ }
+ return update
+ case <-time.After(2 * time.Second):
+ t.Fatalf("%s: timed out waiting for auth update", stage)
+ }
+ return AuthUpdate{}
+ }
+
+ w.refreshAuthState(false)
+ initial := nextUpdate("initial load")
+
+ // The credential is unchanged, so only the affected-provider refresh can trigger re-registration.
+ if ok := w.reloadConfig(); !ok {
+ t.Fatal("expected reloadConfig to succeed")
+ }
+ if reloaded := nextUpdate("global exclusion change"); reloaded.ID != initial.ID {
+ t.Fatalf("reloaded auth ID = %q, want %q", reloaded.ID, initial.ID)
+ }
+}
+
func TestReloadConfigTriggersCallbackForMaxRetryCredentialsChange(t *testing.T) {
tmpDir := t.TempDir()
authDir := filepath.Join(tmpDir, "auth")
diff --git a/sdk/api/handlers/claude/code_handlers.go b/sdk/api/handlers/claude/code_handlers.go
index 442ad47c69c..823decd662e 100644
--- a/sdk/api/handlers/claude/code_handlers.go
+++ b/sdk/api/handlers/claude/code_handlers.go
@@ -157,7 +157,8 @@ func rewriteClaudeDDModelInBody(rawJSON []byte) []byte {
// Parameters:
// - c: The Gin context for the request.
func (h *ClaudeCodeAPIHandler) ClaudeModels(c *gin.Context) {
- disableCloaking := h.Cfg != nil && h.Cfg.ClaudeCode.DisableCloakingModelList
+ cfg := h.CurrentConfig()
+ disableCloaking := cfg != nil && cfg.ClaudeCode.DisableCloakingModelList
h.WriteModelListResponse(c, h.HandlerType(), claudemodels.BuildResponse(h.Models(), disableCloaking))
}
@@ -261,7 +262,7 @@ func (h *ClaudeCodeAPIHandler) handleStreamingResponse(c *gin.Context, rawJSON [
flusher.Flush()
return true
}
- keepAliveC, stopKeepAlive := handlers.NewPreFirstTokenKeepAliveTicker(h.Cfg)
+ keepAliveC, stopKeepAlive := handlers.NewPreFirstTokenKeepAliveTicker(h.CurrentConfig())
defer stopKeepAlive()
dataChan, resolvedHeaders, errChan, okBootstrap := h.ExecuteStreamWithPreFTKeepAlive(
@@ -319,10 +320,9 @@ func (h *ClaudeCodeAPIHandler) handleStreamingResponse(c *gin.Context, rawJSON [
}
return
}
- // Stream closed without data? Send DONE or just headers.
+ // Stream closed without data: delegate to forwardClaudeStream with the closed dataChan to surface missing message_stop.
commitSSE()
- flusher.Flush()
- cliCancel(nil)
+ h.forwardClaudeStream(c, flusher, func(err error) { cliCancel(err) }, dataChan, errChan)
return
}
@@ -331,13 +331,16 @@ func (h *ClaudeCodeAPIHandler) handleStreamingResponse(c *gin.Context, rawJSON [
commitSSE()
// Write the first chunk
+ var streamState claudeSSEStreamState
+ streamState.trailingNewlines = 2
+ streamState.ObserveChunk(chunk)
if len(chunk) > 0 {
_, _ = c.Writer.Write(chunk)
flusher.Flush()
}
// Continue streaming the rest
- h.forwardClaudeStream(c, flusher, func(err error) { cliCancel(err) }, dataChan, errChan)
+ h.forwardClaudeStreamWithState(c, flusher, func(err error) { cliCancel(err) }, dataChan, errChan, &streamState)
return
case <-keepAliveC:
writeKeepAlive()
@@ -345,29 +348,172 @@ func (h *ClaudeCodeAPIHandler) handleStreamingResponse(c *gin.Context, rawJSON [
}
}
-func (h *ClaudeCodeAPIHandler) forwardClaudeStream(c *gin.Context, flusher http.Flusher, cancel func(error), data <-chan []byte, errs <-chan *interfaces.ErrorMessage) {
+type claudeSSEStreamState struct {
+ sawErrorEvent bool
+ sawMessageStop bool
+ sawBareMessageJSON bool
+ trailingNewlines int
+ currentEvent string
+ eventData [][]byte
+ lineBuf bytes.Buffer
+}
+
+func (s *claudeSSEStreamState) ObserveChunk(chunk []byte) {
+ if len(chunk) == 0 {
+ return
+ }
+ s.trailingNewlines = advanceClaudeSSENewlineCount(s.trailingNewlines, chunk)
+ for _, b := range chunk {
+ if b == '\n' {
+ s.observeLine(s.lineBuf.Bytes())
+ s.lineBuf.Reset()
+ continue
+ }
+ s.lineBuf.WriteByte(b)
+ }
+}
+
+func (s *claudeSSEStreamState) FlushPendingLine() {
+ if s.lineBuf.Len() > 0 {
+ s.observeLine(s.lineBuf.Bytes())
+ s.lineBuf.Reset()
+ }
+ s.flushEventData()
+}
+
+func (s *claudeSSEStreamState) flushEventData() {
+ if len(s.eventData) > 0 {
+ joined := bytes.TrimSpace(bytes.Join(s.eventData, []byte("\n")))
+ s.eventData = s.eventData[:0]
+ if len(joined) > 0 && gjson.ValidBytes(joined) {
+ s.observeJSONPayload(joined, true)
+ }
+ }
+}
+
+func (s *claudeSSEStreamState) ensureSSEEventBoundary(w io.Writer) {
+ if s.sawBareMessageJSON {
+ return
+ }
+ switch s.trailingNewlines {
+ case 0:
+ _, _ = w.Write([]byte("\n\n"))
+ case 1:
+ _, _ = w.Write([]byte("\n"))
+ }
+ s.trailingNewlines = 2
+}
+
+func (s *claudeSSEStreamState) observeLine(line []byte) {
+ trimmed := bytes.TrimSpace(line)
+ if len(trimmed) == 0 {
+ s.flushEventData()
+ s.currentEvent = ""
+ return
+ }
+ if bytes.HasPrefix(trimmed, []byte("event:")) {
+ s.currentEvent = strings.TrimSpace(string(trimmed[len("event:"):]))
+ return
+ }
+ if bytes.HasPrefix(trimmed, []byte("data:")) {
+ payload := bytes.TrimSpace(trimmed[len("data:"):])
+ if len(payload) > 0 {
+ s.eventData = append(s.eventData, append([]byte(nil), payload...))
+ }
+ return
+ }
+ if !bytes.HasPrefix(trimmed, []byte("{")) || !gjson.ValidBytes(trimmed) {
+ return
+ }
+ s.observeJSONPayload(trimmed, false)
+}
+
+func (s *claudeSSEStreamState) observeJSONPayload(payload []byte, hasDataPrefix bool) {
+ root := gjson.ParseBytes(payload)
+ switch root.Get("type").String() {
+ case "message_stop":
+ s.sawMessageStop = true
+ case "error":
+ s.sawErrorEvent = true
+ case "message":
+ if !hasDataPrefix && strings.TrimSpace(root.Get("stop_reason").String()) != "" {
+ s.sawMessageStop = true
+ s.sawBareMessageJSON = true
+ }
+ default:
+ if hasDataPrefix && s.currentEvent == "error" && root.Get("error").Exists() {
+ s.sawErrorEvent = true
+ }
+ }
+}
+
+func advanceClaudeSSENewlineCount(prev int, chunk []byte) int {
+ count := prev
+ for _, b := range chunk {
+ switch b {
+ case '\r':
+ continue
+ case '\n':
+ if count < 2 {
+ count++
+ }
+ default:
+ count = 0
+ }
+ }
+ return count
+}
+
+func (h *ClaudeCodeAPIHandler) forwardClaudeStream(c *gin.Context, flusher http.Flusher, cancel func(error), data <-chan []byte, errs <-chan *interfaces.ErrorMessage, initialErrorWritten ...bool) {
+ state := &claudeSSEStreamState{
+ sawErrorEvent: len(initialErrorWritten) > 0 && initialErrorWritten[0],
+ trailingNewlines: 2,
+ }
+ h.forwardClaudeStreamWithState(c, flusher, cancel, data, errs, state)
+}
+
+func (h *ClaudeCodeAPIHandler) forwardClaudeStreamWithState(c *gin.Context, flusher http.Flusher, cancel func(error), data <-chan []byte, errs <-chan *interfaces.ErrorMessage, state *claudeSSEStreamState) {
+ if state == nil {
+ state = &claudeSSEStreamState{trailingNewlines: 2}
+ }
h.ForwardStream(c, flusher, cancel, data, errs, handlers.StreamForwardOptions{
WriteChunk: func(chunk []byte) {
if len(chunk) == 0 {
return
}
+ state.ObserveChunk(chunk)
_, _ = c.Writer.Write(chunk)
},
WriteTerminalError: func(errMsg *interfaces.ErrorMessage) {
+ state.FlushPendingLine()
if errMsg == nil {
return
}
- if !c.Writer.Written() {
- status := http.StatusInternalServerError
- if errMsg.StatusCode > 0 {
- status = errMsg.StatusCode
- }
- c.Status(status)
+ if state.sawErrorEvent {
+ state.ensureSSEEventBoundary(c.Writer)
+ return
}
+ state.ensureSSEEventBoundary(c.Writer)
errorBytes, _ := json.Marshal(h.toClaudeError(errMsg))
_, _ = fmt.Fprintf(c.Writer, "event: error\ndata: %s\n\n", errorBytes)
},
+ CloseError: func() *interfaces.ErrorMessage {
+ state.FlushPendingLine()
+ if state.sawMessageStop || state.sawErrorEvent {
+ return nil
+ }
+ return &interfaces.ErrorMessage{
+ StatusCode: http.StatusBadGateway,
+ Error: fmt.Errorf("upstream stream closed before message_stop"),
+ }
+ },
+ WriteDone: func() {
+ state.FlushPendingLine()
+ if state.sawMessageStop || state.sawErrorEvent {
+ state.ensureSSEEventBoundary(c.Writer)
+ }
+ },
})
}
@@ -451,7 +597,7 @@ func (h *ClaudeCodeAPIHandler) WriteErrorResponse(c *gin.Context, msg *interface
c.Writer.Header().Add("Retry-After", value)
}
}
- if msg != nil && msg.Addon != nil && handlers.PassthroughHeadersEnabled(h.Cfg) {
+ if msg != nil && msg.Addon != nil && handlers.PassthroughHeadersEnabled(h.CurrentConfig()) {
for key, values := range msg.Addon {
if len(values) == 0 || handlers.IsCPAReservedResponseHeader(key) {
continue
@@ -532,6 +678,8 @@ func claudeErrorTypeFromStatus(status int) string {
return "permission_error"
case http.StatusNotFound:
return "not_found_error"
+ case http.StatusConflict:
+ return "conflict_error"
case http.StatusRequestEntityTooLarge:
return "request_too_large"
case http.StatusTooManyRequests:
diff --git a/sdk/api/handlers/claude/code_handlers_error_test.go b/sdk/api/handlers/claude/code_handlers_error_test.go
index 44b41f2dae0..3ff08c3c105 100644
--- a/sdk/api/handlers/claude/code_handlers_error_test.go
+++ b/sdk/api/handlers/claude/code_handlers_error_test.go
@@ -26,6 +26,7 @@ func TestClaudeErrorTypeFromStatus(t *testing.T) {
want string
}{
{http.StatusBadRequest, "invalid_request_error"},
+ {http.StatusConflict, "conflict_error"},
{http.StatusRequestTimeout, "timeout_error"},
{http.StatusTooManyRequests, "rate_limit_error"},
{http.StatusInternalServerError, "api_error"},
@@ -386,3 +387,523 @@ func TestClaudeMessagesHandlerSendKeepAliveBeforeFTWritesSSEErrorOnFailure(t *te
t.Fatalf("missing Claude SSE JSON error event after keepalive: %q", body)
}
}
+
+func TestClaudeMessagesHandlerDeduplicatesTranslatedSSEErrorAndTerminalChunkError(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ chunks := make(chan coreexecutor.StreamChunk, 2)
+ executor := &preFTKeepAliveClaudeExecutor{chunks: chunks}
+ manager := coreauth.NewManager(nil, nil, nil)
+ manager.RegisterExecutor(executor)
+ model := "dedup-sse-error-claude-model"
+ auth := &coreauth.Auth{ID: "dedup-sse-error-claude-auth", Provider: executor.Identifier(), Status: coreauth.StatusActive}
+ if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
+ t.Fatalf("register auth: %v", errRegister)
+ }
+ registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: model}})
+ t.Cleanup(func() {
+ registry.GetGlobalRegistry().UnregisterClient(auth.ID)
+ })
+
+ const sseErrorChunk = "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"m\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":0}}}\n\nevent: error\ndata: {\"type\":\"error\",\"error\":{\"type\":\"api_error\",\"message\":\"Gemini response finished with MALFORMED_FUNCTION_CALL\"}}\n\n"
+ chunks <- coreexecutor.StreamChunk{Payload: []byte(sseErrorChunk)}
+ chunks <- coreexecutor.StreamChunk{Err: errors.New(`{"type":"error","error":{"type":"api_error","message":"Gemini response finished with MALFORMED_FUNCTION_CALL"}}`)}
+ close(chunks)
+
+ base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
+ h := NewClaudeCodeAPIHandler(base)
+ router := gin.New()
+ router.POST("/v1/messages", h.ClaudeMessages)
+
+ request := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader("{\"model\":\""+model+"\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"stream\":true}"))
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, request)
+
+ body := recorder.Body.String()
+ if got := strings.Count(body, "event: error\n"); got != 1 {
+ t.Fatalf("SSE error event count = %d, want 1; body=%q", got, body)
+ }
+}
+
+func TestClaudeMessagesHandlerTruncatedStreamWithoutMessageStopWritesSSEError(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ chunks := make(chan coreexecutor.StreamChunk, 2)
+ executor := &preFTKeepAliveClaudeExecutor{chunks: chunks}
+ manager := coreauth.NewManager(nil, nil, nil)
+ manager.RegisterExecutor(executor)
+ model := "truncated-stream-claude-model"
+ auth := &coreauth.Auth{ID: "truncated-stream-claude-auth", Provider: executor.Identifier(), Status: coreauth.StatusActive}
+ if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
+ t.Fatalf("register auth: %v", errRegister)
+ }
+ registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: model}})
+ t.Cleanup(func() {
+ registry.GetGlobalRegistry().UnregisterClient(auth.ID)
+ })
+
+ chunks <- coreexecutor.StreamChunk{Payload: []byte("event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"m\",\"stop_reason\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":1}}}\n\n")}
+ chunks <- coreexecutor.StreamChunk{Payload: []byte("event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"partial\"}}\n\n")}
+ close(chunks)
+
+ base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
+ h := NewClaudeCodeAPIHandler(base)
+ router := gin.New()
+ router.POST("/v1/messages", h.ClaudeMessages)
+
+ request := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader("{\"model\":\""+model+"\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"stream\":true}"))
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, request)
+
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%q", recorder.Code, recorder.Body.String())
+ }
+ body := recorder.Body.String()
+ if !strings.Contains(body, "event: error\ndata: ") {
+ t.Fatalf("expected terminal SSE error event on truncated stream; body=%q", body)
+ }
+ if !strings.Contains(body, "message_stop") {
+ t.Fatalf("expected terminal error message to mention missing message_stop; body=%q", body)
+ }
+}
+
+func TestClaudeStreamTerminalErrorDoesNotOverwriteSSEStatusWhenUnwritten(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
+ c.Header("Content-Type", "text/event-stream")
+
+ handler := NewClaudeCodeAPIHandler(&handlers.BaseAPIHandler{})
+ errs := make(chan *interfaces.ErrorMessage, 1)
+ errs <- &interfaces.ErrorMessage{
+ StatusCode: http.StatusBadGateway,
+ Error: errors.New("upstream failed after SSE commit"),
+ }
+ close(errs)
+
+ handler.forwardClaudeStream(c, c.Writer, func(error) {}, nil, errs)
+
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200 for committed SSE stream; body=%q", recorder.Code, recorder.Body.String())
+ }
+ if !strings.HasPrefix(recorder.Body.String(), "event: error\ndata: ") {
+ t.Fatalf("unexpected SSE error body: %q", recorder.Body.String())
+ }
+}
+
+func TestClaudeStreamTerminalErrorAfterPartialLineStartsOnNewLine(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
+ c.Header("Content-Type", "text/event-stream")
+
+ handler := NewClaudeCodeAPIHandler(&handlers.BaseAPIHandler{})
+ data := make(chan []byte)
+ errs := make(chan *interfaces.ErrorMessage)
+ go func() {
+ data <- []byte("event: content_block_delta\ndata: {\"type\":\"content_block_delta\"")
+ errs <- &interfaces.ErrorMessage{
+ StatusCode: http.StatusBadGateway,
+ Error: errors.New("stream cut mid-line"),
+ }
+ close(data)
+ close(errs)
+ }()
+
+ handler.forwardClaudeStream(c, c.Writer, func(error) {}, data, errs)
+
+ body := recorder.Body.String()
+ if !strings.Contains(body, "\nevent: error\ndata: ") {
+ t.Fatalf("expected event: error to start on a fresh line after partial chunk; body=%q", body)
+ }
+}
+
+func TestClaudeStreamCompleteWithMessageStopDoesNotEmitErrorOnClose(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ chunks := make(chan coreexecutor.StreamChunk, 2)
+ executor := &preFTKeepAliveClaudeExecutor{chunks: chunks}
+ manager := coreauth.NewManager(nil, nil, nil)
+ manager.RegisterExecutor(executor)
+ model := "complete-stream-claude-model"
+ auth := &coreauth.Auth{ID: "complete-stream-claude-auth", Provider: executor.Identifier(), Status: coreauth.StatusActive}
+ if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
+ t.Fatalf("register auth: %v", errRegister)
+ }
+ registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: model}})
+ t.Cleanup(func() {
+ registry.GetGlobalRegistry().UnregisterClient(auth.ID)
+ })
+
+ chunks <- coreexecutor.StreamChunk{Payload: []byte("event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"m\",\"stop_reason\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":1}}}\n\n")}
+ chunks <- coreexecutor.StreamChunk{Payload: []byte("event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")}
+ close(chunks)
+
+ base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
+ h := NewClaudeCodeAPIHandler(base)
+ router := gin.New()
+ router.POST("/v1/messages", h.ClaudeMessages)
+
+ request := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader("{\"model\":\""+model+"\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"stream\":true}"))
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, request)
+
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%q", recorder.Code, recorder.Body.String())
+ }
+ if strings.Contains(recorder.Body.String(), "event: error") {
+ t.Fatalf("unexpected error event on completed stream: %q", recorder.Body.String())
+ }
+}
+
+func TestClaudeStreamTerminalErrorAfterSingleNewlineInsertsBlankLine(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
+ c.Header("Content-Type", "text/event-stream")
+
+ handler := NewClaudeCodeAPIHandler(&handlers.BaseAPIHandler{})
+ data := make(chan []byte)
+ errs := make(chan *interfaces.ErrorMessage)
+ go func() {
+ // Ends with a single '\n' (complete line, but missing the blank-line SSE event separator).
+ data <- []byte("event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}\n")
+ errs <- &interfaces.ErrorMessage{
+ StatusCode: http.StatusBadGateway,
+ Error: errors.New("stream cut before blank line"),
+ }
+ close(data)
+ close(errs)
+ }()
+
+ handler.forwardClaudeStream(c, c.Writer, func(error) {}, data, errs)
+
+ body := recorder.Body.String()
+ if !strings.Contains(body, "\n\nevent: error\ndata: ") {
+ t.Fatalf("expected blank-line SSE separator (\\n\\n) before event: error when prior chunk ended with a single newline; body=%q", body)
+ }
+}
+
+func TestClaudeStreamBareCompletedMessageJSONDoesNotEmitErrorOnClose(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ chunks := make(chan coreexecutor.StreamChunk, 1)
+ executor := &preFTKeepAliveClaudeExecutor{chunks: chunks}
+ manager := coreauth.NewManager(nil, nil, nil)
+ manager.RegisterExecutor(executor)
+ model := "bare-json-stream-claude-model"
+ auth := &coreauth.Auth{ID: "bare-json-stream-claude-auth", Provider: executor.Identifier(), Status: coreauth.StatusActive}
+ if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
+ t.Fatalf("register auth: %v", errRegister)
+ }
+ registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: model}})
+ t.Cleanup(func() {
+ registry.GetGlobalRegistry().UnregisterClient(auth.ID)
+ })
+
+ chunks <- coreexecutor.StreamChunk{Payload: []byte("{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"m\",\"content\":[{\"type\":\"text\",\"text\":\"ok\"}],\"stop_reason\":\"end_turn\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}\n")}
+ close(chunks)
+
+ base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
+ h := NewClaudeCodeAPIHandler(base)
+ router := gin.New()
+ router.POST("/v1/messages", h.ClaudeMessages)
+
+ request := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader("{\"model\":\""+model+"\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"stream\":true}"))
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, request)
+
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%q", recorder.Code, recorder.Body.String())
+ }
+ if strings.Contains(recorder.Body.String(), "event: error") {
+ t.Fatalf("unexpected error event after complete bare message JSON: %q", recorder.Body.String())
+ }
+}
+
+func TestClaudeStreamMessageStopWithArbitraryJSONFormattingCompletesCleanly(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ chunks := make(chan coreexecutor.StreamChunk, 2)
+ executor := &preFTKeepAliveClaudeExecutor{chunks: chunks}
+ manager := coreauth.NewManager(nil, nil, nil)
+ manager.RegisterExecutor(executor)
+ model := "formatted-message-stop-claude-model"
+ auth := &coreauth.Auth{ID: "formatted-message-stop-claude-auth", Provider: executor.Identifier(), Status: coreauth.StatusActive}
+ if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
+ t.Fatalf("register auth: %v", errRegister)
+ }
+ registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: model}})
+ t.Cleanup(func() {
+ registry.GetGlobalRegistry().UnregisterClient(auth.ID)
+ })
+
+ chunks <- coreexecutor.StreamChunk{Payload: []byte("event:message_start\ndata: {\"type\" : \"message_start\", \"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"m\",\"stop_reason\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":1}}}\n\n")}
+ chunks <- coreexecutor.StreamChunk{Payload: []byte("event:message_stop\ndata: {\"type\" : \"message_stop\"}\n\n")}
+ close(chunks)
+
+ base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
+ h := NewClaudeCodeAPIHandler(base)
+ router := gin.New()
+ router.POST("/v1/messages", h.ClaudeMessages)
+
+ request := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader("{\"model\":\""+model+"\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"stream\":true}"))
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, request)
+
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%q", recorder.Code, recorder.Body.String())
+ }
+ if strings.Contains(recorder.Body.String(), "event: error") {
+ t.Fatalf("unexpected error event when valid message_stop used custom spacing: %q", recorder.Body.String())
+ }
+}
+
+func TestClaudeStreamTextDeltaMentioningMessageStopStillFailsWhenTruncated(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ chunks := make(chan coreexecutor.StreamChunk, 2)
+ executor := &preFTKeepAliveClaudeExecutor{chunks: chunks}
+ manager := coreauth.NewManager(nil, nil, nil)
+ manager.RegisterExecutor(executor)
+ model := "false-positive-message-stop-claude-model"
+ auth := &coreauth.Auth{ID: "false-positive-message-stop-claude-auth", Provider: executor.Identifier(), Status: coreauth.StatusActive}
+ if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
+ t.Fatalf("register auth: %v", errRegister)
+ }
+ registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: model}})
+ t.Cleanup(func() {
+ registry.GetGlobalRegistry().UnregisterClient(auth.ID)
+ })
+
+ chunks <- coreexecutor.StreamChunk{Payload: []byte("event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"m\",\"stop_reason\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":1}}}\n\n")}
+ chunks <- coreexecutor.StreamChunk{Payload: []byte("event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_1\",\"name\":\"emit\",\"input\":{\"type\":\"message_stop\"}}}\n\n")}
+ close(chunks)
+
+ base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
+ h := NewClaudeCodeAPIHandler(base)
+ router := gin.New()
+ router.POST("/v1/messages", h.ClaudeMessages)
+
+ request := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader("{\"model\":\""+model+"\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"stream\":true}"))
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, request)
+
+ if !strings.Contains(recorder.Body.String(), "event: error\ndata: ") {
+ t.Fatalf("expected truncated stream to emit terminal SSE error even if text delta mentioned message_stop; body=%q", recorder.Body.String())
+ }
+}
+
+func TestClaudeStreamTruncatedErrorEventHeaderDoesNotSuppressTerminalError(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
+ c.Header("Content-Type", "text/event-stream")
+
+ handler := NewClaudeCodeAPIHandler(&handlers.BaseAPIHandler{})
+ data := make(chan []byte)
+ errs := make(chan *interfaces.ErrorMessage)
+ go func() {
+ // Upstream started an error event header but dropped before sending a valid data JSON frame.
+ data <- []byte("event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"m\",\"stop_reason\":null,\"usage\":{\"input_tokens\":10,\"output_tokens\":1}}}\n\nevent: error\n")
+ errs <- &interfaces.ErrorMessage{
+ StatusCode: http.StatusBadGateway,
+ Error: errors.New("upstream stream closed before message_stop"),
+ }
+ close(data)
+ close(errs)
+ }()
+
+ handler.forwardClaudeStream(c, c.Writer, func(error) {}, data, errs)
+
+ body := recorder.Body.String()
+ if !strings.Contains(body, "event: error\ndata: {\"type\":\"error\"") {
+ t.Fatalf("expected valid terminal SSE error frame after truncated event: error header; body=%q", body)
+ }
+}
+
+func TestClaudeStreamErrorEventWithoutTrailingBlankLineIsTerminatedOnEOF(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ suffix string
+ }{
+ {name: "zero trailing newlines", suffix: ""},
+ {name: "single trailing newline", suffix: "\n"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
+ c.Header("Content-Type", "text/event-stream")
+
+ handler := NewClaudeCodeAPIHandler(&handlers.BaseAPIHandler{})
+ data := make(chan []byte)
+ errs := make(chan *interfaces.ErrorMessage)
+ go func() {
+ data <- []byte("event: error\ndata: {\"type\":\"error\",\"error\":{\"type\":\"api_error\",\"message\":\"failed\"}}" + tc.suffix)
+ errs <- &interfaces.ErrorMessage{
+ StatusCode: http.StatusBadGateway,
+ Error: errors.New(`{"type":"error","error":{"type":"api_error","message":"failed"}}`),
+ }
+ close(data)
+ close(errs)
+ }()
+
+ handler.forwardClaudeStream(c, c.Writer, func(error) {}, data, errs)
+
+ body := recorder.Body.String()
+ want := "event: error\ndata: {\"type\":\"error\",\"error\":{\"type\":\"api_error\",\"message\":\"failed\"}}\n\n"
+ if body != want {
+ t.Fatalf("SSE body = %q, want %q", body, want)
+ }
+ })
+ }
+}
+
+func TestClaudeStreamMessageStopWithoutTrailingBlankLineIsTerminatedOnEOF(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ suffix string
+ }{
+ {name: "zero trailing newlines", suffix: ""},
+ {name: "single trailing newline", suffix: "\n"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
+ c.Header("Content-Type", "text/event-stream")
+
+ handler := NewClaudeCodeAPIHandler(&handlers.BaseAPIHandler{})
+ data := make(chan []byte, 1)
+ data <- []byte("event: message_stop\ndata: {\"type\":\"message_stop\"}" + tc.suffix)
+ close(data)
+
+ handler.forwardClaudeStream(c, c.Writer, func(error) {}, data, nil)
+
+ body := recorder.Body.String()
+ want := "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
+ if body != want {
+ t.Fatalf("SSE body = %q, want %q", body, want)
+ }
+ })
+ }
+}
+
+func TestClaudeMessagesHandlerEmptyStreamClosesWithSSEError(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
+ c.Header("Content-Type", "text/event-stream")
+
+ handler := NewClaudeCodeAPIHandler(&handlers.BaseAPIHandler{})
+ data := make(chan []byte)
+ close(data)
+ errs := make(chan *interfaces.ErrorMessage)
+ close(errs)
+
+ done := make(chan struct{})
+ go func() {
+ handler.forwardClaudeStream(c, c.Writer, func(error) {}, data, errs)
+ close(done)
+ }()
+
+ select {
+ case <-done:
+ case <-time.After(3 * time.Second):
+ t.Fatal("forwardClaudeStream hung when data and errs channels closed without chunks")
+ }
+
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%q", recorder.Code, recorder.Body.String())
+ }
+ body := recorder.Body.String()
+ if !strings.Contains(body, "event: error\ndata: ") || !strings.Contains(body, "message_stop") {
+ t.Fatalf("expected terminal SSE error event mentioning message_stop on empty stream; body=%q", body)
+ }
+}
+
+func TestClaudeStreamMultiLineDataEventsAreRecognizedAcrossChunks(t *testing.T) {
+ t.Run("multiline message_stop split across chunks", func(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
+ c.Header("Content-Type", "text/event-stream")
+
+ handler := NewClaudeCodeAPIHandler(&handlers.BaseAPIHandler{})
+ data := make(chan []byte, 2)
+ data <- []byte("event: message_stop\ndata: {\"type\":\n")
+ data <- []byte("data: \"message_stop\"}\n\n")
+ close(data)
+
+ handler.forwardClaudeStream(c, c.Writer, func(error) {}, data, nil)
+
+ body := recorder.Body.String()
+ if strings.Contains(body, "event: error") {
+ t.Fatalf("unexpected error event after valid multi-line message_stop; body=%q", body)
+ }
+ })
+
+ t.Run("multiline error event split across chunks", func(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
+ c.Header("Content-Type", "text/event-stream")
+
+ handler := NewClaudeCodeAPIHandler(&handlers.BaseAPIHandler{})
+ data := make(chan []byte)
+ errs := make(chan *interfaces.ErrorMessage)
+ go func() {
+ data <- []byte("event: error\ndata: {\"type\":\"error\",\n")
+ data <- []byte("data: \"error\":{\"type\":\"api_error\",\"message\":\"boom\"}}\n\n")
+ errs <- &interfaces.ErrorMessage{
+ StatusCode: http.StatusBadGateway,
+ Error: errors.New("boom"),
+ }
+ close(data)
+ close(errs)
+ }()
+
+ handler.forwardClaudeStream(c, c.Writer, func(error) {}, data, errs)
+
+ body := recorder.Body.String()
+ if got := strings.Count(body, "event: error"); got != 1 {
+ t.Fatalf("event: error count = %d, want 1; body=%q", got, body)
+ }
+ })
+
+ t.Run("multiline tool_use with standalone nested message_stop line still fails when truncated", func(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
+ c.Header("Content-Type", "text/event-stream")
+
+ handler := NewClaudeCodeAPIHandler(&handlers.BaseAPIHandler{})
+ data := make(chan []byte, 1)
+ data <- []byte("event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_1\",\"name\":\"emit\",\"input\":\ndata: {\"type\":\"message_stop\"}\ndata: }}\n\n")
+ close(data)
+
+ handler.forwardClaudeStream(c, c.Writer, func(error) {}, data, nil)
+
+ body := recorder.Body.String()
+ if !strings.Contains(body, "event: error\ndata: ") {
+ t.Fatalf("expected terminal error event when stream truncated after multiline tool_use containing nested message_stop line; body=%q", body)
+ }
+ })
+}
diff --git a/sdk/api/handlers/gemini/gemini_handlers.go b/sdk/api/handlers/gemini/gemini_handlers.go
index 4b9f0475360..4226cc9d063 100644
--- a/sdk/api/handlers/gemini/gemini_handlers.go
+++ b/sdk/api/handlers/gemini/gemini_handlers.go
@@ -225,7 +225,7 @@ func (h *GeminiAPIHandler) handleStreamGenerateContent(c *gin.Context, modelName
var keepAliveC <-chan time.Time
stopKeepAlive := func() {}
if alt == "" {
- keepAliveC, stopKeepAlive = handlers.NewPreFirstTokenKeepAliveTicker(h.Cfg)
+ keepAliveC, stopKeepAlive = handlers.NewPreFirstTokenKeepAliveTicker(h.CurrentConfig())
}
defer stopKeepAlive()
diff --git a/sdk/api/handlers/gemini/interactions_handlers.go b/sdk/api/handlers/gemini/interactions_handlers.go
index a7e47875433..080b3a58f4c 100644
--- a/sdk/api/handlers/gemini/interactions_handlers.go
+++ b/sdk/api/handlers/gemini/interactions_handlers.go
@@ -151,7 +151,7 @@ func (h *GeminiAPIHandler) handleInteractionsStream(c *gin.Context, cliCtx conte
handlers.WriteUpstreamHeaders(c.Writer.Header(), upstreamHeaders)
committed = true
}
- keepAliveC, stopKeepAlive := handlers.NewPreFirstTokenKeepAliveTicker(h.Cfg)
+ keepAliveC, stopKeepAlive := handlers.NewPreFirstTokenKeepAliveTicker(h.CurrentConfig())
defer stopKeepAlive()
var stream handlers.ModelExecutionStream
diff --git a/sdk/api/handlers/handlers.go b/sdk/api/handlers/handlers.go
index 5e4a5812d88..5e8df3a7cbc 100644
--- a/sdk/api/handlers/handlers.go
+++ b/sdk/api/handlers/handlers.go
@@ -25,6 +25,7 @@ import (
coresession "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/session"
coreusage "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/httptransport"
"github.com/tidwall/gjson"
"golang.org/x/net/context"
)
@@ -407,6 +408,8 @@ func setGenerateMetadata(meta map[string]any, rawJSON []byte) {
// It holds a pool of clients to interact with the backend service and manages
// load balancing, client selection, and configuration.
type BaseAPIHandler struct {
+ runtimeMu sync.RWMutex
+
// AuthManager manages auth lifecycle and execution in the new architecture.
AuthManager *coreauth.Manager
@@ -443,7 +446,25 @@ func NewBaseAPIHandlers(cfg *config.SDKConfig, authManager *coreauth.Manager) *B
// Parameters:
// - clients: The new slice of AI service clients
// - cfg: The new application configuration
-func (h *BaseAPIHandler) UpdateClients(cfg *config.SDKConfig) { h.Cfg = cfg }
+func (h *BaseAPIHandler) UpdateClients(cfg *config.SDKConfig) {
+ if h == nil {
+ return
+ }
+ h.runtimeMu.Lock()
+ h.Cfg = cfg
+ h.runtimeMu.Unlock()
+}
+
+// CurrentConfig returns the current application configuration snapshot.
+func (h *BaseAPIHandler) CurrentConfig() *config.SDKConfig {
+ if h == nil {
+ return nil
+ }
+ h.runtimeMu.RLock()
+ cfg := h.Cfg
+ h.runtimeMu.RUnlock()
+ return cfg
+}
// SetPluginHost configures the optional plugin interceptor host.
func (h *BaseAPIHandler) SetPluginHost(host PluginInterceptorHost) {
@@ -451,10 +472,11 @@ func (h *BaseAPIHandler) SetPluginHost(host PluginInterceptorHost) {
return
}
if isNilPluginInterceptorHost(host) {
- h.PluginHost = nil
- return
+ host = nil
}
+ h.runtimeMu.Lock()
h.PluginHost = host
+ h.runtimeMu.Unlock()
}
// SetModelRouterHost configures the optional plugin model router host.
@@ -463,10 +485,25 @@ func (h *BaseAPIHandler) SetModelRouterHost(host PluginModelRouterHost) {
return
}
if isNilPluginModelRouterHost(host) {
- h.ModelRouterHost = nil
- return
+ host = nil
}
+ h.runtimeMu.Lock()
h.ModelRouterHost = host
+ h.runtimeMu.Unlock()
+}
+
+// ModelRouterHostSnapshot returns the configured plugin model router host snapshot.
+func (h *BaseAPIHandler) ModelRouterHostSnapshot() PluginModelRouterHost {
+ if h == nil {
+ return nil
+ }
+ h.runtimeMu.RLock()
+ host := h.ModelRouterHost
+ h.runtimeMu.RUnlock()
+ if isNilPluginModelRouterHost(host) {
+ return nil
+ }
+ return host
}
func isNilPluginInterceptorHost(host PluginInterceptorHost) bool {
@@ -535,6 +572,9 @@ func (h *BaseAPIHandler) GetContextWithCancel(handler interfaces.APIHandler, c *
requestCtx = c.Request.Context()
}
+ if requestCtx != nil {
+ parentCtx = httptransport.InheritDecorator(parentCtx, requestCtx)
+ }
if requestCtx != nil && logging.GetRequestID(parentCtx) == "" {
if requestID := logging.GetRequestID(requestCtx); requestID != "" {
parentCtx = logging.WithRequestID(parentCtx, requestID)
@@ -592,7 +632,7 @@ func (h *BaseAPIHandler) GetContextWithCancel(handler interfaces.APIHandler, c *
if c != nil {
logging.SetResponseStatus(cancelCtx, c.Writer.Status())
}
- if h.Cfg.RequestLog && len(params) == 1 {
+ if cfg := h.CurrentConfig(); cfg != nil && cfg.RequestLog && len(params) == 1 {
if captured, exists := c.Get(logging.APIResponseCapturedContextKey); exists {
if capturedBool, ok := captured.(bool); ok && capturedBool {
cancel()
@@ -644,7 +684,7 @@ func (h *BaseAPIHandler) StartNonStreamingKeepAlive(c *gin.Context, ctx context.
if h == nil || c == nil {
return func() {}
}
- interval := NonStreamingKeepAliveInterval(h.Cfg)
+ interval := NonStreamingKeepAliveInterval(h.CurrentConfig())
if interval <= 0 {
return func() {}
}
diff --git a/sdk/api/handlers/handlers_decorator_test.go b/sdk/api/handlers/handlers_decorator_test.go
new file mode 100644
index 00000000000..21fa1723964
--- /dev/null
+++ b/sdk/api/handlers/handlers_decorator_test.go
@@ -0,0 +1,50 @@
+package handlers
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/httptransport"
+)
+
+type handlerTestTransport struct {
+ http.RoundTripper
+ source string
+}
+
+func TestGetContextWithCancelInheritsHTTPTransportDecorator(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ ginCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
+ req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
+ reqCtx := httptransport.WithDecorator(req.Context(), func(next http.RoundTripper) http.RoundTripper {
+ return &handlerTestTransport{RoundTripper: next, source: "request"}
+ })
+ ginCtx.Request = req.WithContext(reqCtx)
+
+ handler := &BaseAPIHandler{Cfg: &config.SDKConfig{}}
+ ctx, cancel := handler.GetContextWithCancel(nil, ginCtx, context.Background())
+ defer cancel()
+
+ base := http.DefaultTransport.(*http.Transport).Clone()
+ client := httptransport.Decorate(ctx, &http.Client{Transport: base})
+ wrapped, ok := client.Transport.(*handlerTestTransport)
+ if !ok || wrapped.source != "request" || wrapped.RoundTripper != base {
+ t.Fatalf("expected request decorator to propagate through GetContextWithCancel, got %T", client.Transport)
+ }
+
+ parentCtx := httptransport.WithDecorator(context.Background(), func(next http.RoundTripper) http.RoundTripper {
+ return &handlerTestTransport{RoundTripper: next, source: "parent"}
+ })
+ ctxParent, cancelParent := handler.GetContextWithCancel(nil, ginCtx, parentCtx)
+ defer cancelParent()
+
+ clientParent := httptransport.Decorate(ctxParent, &http.Client{Transport: base})
+ wrappedParent, ok := clientParent.Transport.(*handlerTestTransport)
+ if !ok || wrappedParent.source != "parent" {
+ t.Fatalf("expected explicit parent decorator to win, got %T (%v)", clientParent.Transport, wrappedParent)
+ }
+}
diff --git a/sdk/api/handlers/handlers_errors.go b/sdk/api/handlers/handlers_errors.go
index a35f2110d08..7893da08e4a 100644
--- a/sdk/api/handlers/handlers_errors.go
+++ b/sdk/api/handlers/handlers_errors.go
@@ -128,7 +128,7 @@ func (h *BaseAPIHandler) WriteErrorResponse(c *gin.Context, msg *interfaces.Erro
c.Writer.Header().Add("Retry-After", value)
}
}
- if msg != nil && msg.Addon != nil && PassthroughHeadersEnabled(h.Cfg) {
+ if msg != nil && msg.Addon != nil && PassthroughHeadersEnabled(h.CurrentConfig()) {
for key, values := range msg.Addon {
if len(values) == 0 || IsCPAReservedResponseHeader(key) {
continue
@@ -196,7 +196,7 @@ func writeDirectErrorResponse(c *gin.Context, status int, msg *interfaces.ErrorM
}
func (h *BaseAPIHandler) LoggingAPIResponseError(ctx context.Context, err *interfaces.ErrorMessage) {
- if h.Cfg.RequestLog {
+ if cfg := h.CurrentConfig(); cfg != nil && cfg.RequestLog {
if ginContext, ok := ctx.Value("gin").(*gin.Context); ok {
if apiResponseErrors, isExist := ginContext.Get("API_RESPONSE_ERROR"); isExist {
if slicesAPIResponseError, isOk := apiResponseErrors.([]*interfaces.ErrorMessage); isOk {
diff --git a/sdk/api/handlers/handlers_execution.go b/sdk/api/handlers/handlers_execution.go
index 16c0f4f781c..5e4c1962cc7 100644
--- a/sdk/api/handlers/handlers_execution.go
+++ b/sdk/api/handlers/handlers_execution.go
@@ -112,7 +112,7 @@ func (h *BaseAPIHandler) executeWithAuthManagerFormats(ctx context.Context, entr
executedReq, executedOpts := afterAuthCapture.apply(req, opts)
ctx = enrichContextWithSessionHierarchy(ctx, executedOpts.Headers, executedReq.Payload, executedOpts.Metadata)
rawResponseHeaders := cloneHeader(resp.Headers)
- passthroughHeaders := executionPassthroughHeaders(h.Cfg, execOptions.InternalSource)
+ passthroughHeaders := executionPassthroughHeaders(h.CurrentConfig(), execOptions.InternalSource)
responseHeaders := downstreamHeadersFromExecutor(rawResponseHeaders, passthroughHeaders)
body, responseHeaders := h.applyResponseInterceptors(ctx, lifecycle.requestID(), responseProtocol, normalizedModel, originalRequestedModel, executedOpts, rawResponseHeaders, responseHeaders, executedOpts.OriginalRequest, executedReq.Payload, resp.Payload, http.StatusOK, execOptions.SkipInterceptorPluginID, passthroughHeaders)
lifecycle.complete(pluginapi.RequestCompletionSucceeded, http.StatusOK, nil)
@@ -182,7 +182,7 @@ func (h *BaseAPIHandler) executeCountWithAuthManager(ctx context.Context, handle
executedReq, executedOpts := afterAuthCapture.apply(req, opts)
ctx = enrichContextWithSessionHierarchy(ctx, executedOpts.Headers, executedReq.Payload, executedOpts.Metadata)
rawResponseHeaders := cloneHeader(resp.Headers)
- passthroughHeaders := executionPassthroughHeaders(h.Cfg, execOptions.InternalSource)
+ passthroughHeaders := executionPassthroughHeaders(h.CurrentConfig(), execOptions.InternalSource)
responseHeaders := downstreamHeadersFromExecutor(rawResponseHeaders, passthroughHeaders)
body, responseHeaders := h.applyResponseInterceptors(ctx, lifecycle.requestID(), handlerType, normalizedModel, originalRequestedModel, executedOpts, rawResponseHeaders, responseHeaders, executedOpts.OriginalRequest, executedReq.Payload, resp.Payload, http.StatusOK, execOptions.SkipInterceptorPluginID, passthroughHeaders)
lifecycle.complete(pluginapi.RequestCompletionSucceeded, http.StatusOK, nil)
@@ -232,7 +232,7 @@ func (h *BaseAPIHandler) executeWithPluginExecutor(ctx context.Context, entryPro
reporter.EnsurePublished(execCtx)
}
rawResponseHeaders := cloneHeader(resp.Headers)
- passthroughHeaders := executionPassthroughHeaders(h.Cfg, execOptions.InternalSource)
+ passthroughHeaders := executionPassthroughHeaders(h.CurrentConfig(), execOptions.InternalSource)
responseHeaders := downstreamHeadersFromExecutor(rawResponseHeaders, passthroughHeaders)
body, responseHeaders := h.applyResponseInterceptors(execCtx, lifecycle.requestID(), responseProtocol, modelName, originalRequestedModel, opts, rawResponseHeaders, responseHeaders, opts.OriginalRequest, req.Payload, resp.Payload, http.StatusOK, execOptions.SkipInterceptorPluginID, passthroughHeaders)
lifecycle.complete(pluginapi.RequestCompletionSucceeded, http.StatusOK, nil)
@@ -268,7 +268,7 @@ func (h *BaseAPIHandler) countWithPluginExecutor(ctx context.Context, handlerTyp
return nil, nil, errMsg
}
rawResponseHeaders := cloneHeader(resp.Headers)
- passthroughHeaders := executionPassthroughHeaders(h.Cfg, execOptions.InternalSource)
+ passthroughHeaders := executionPassthroughHeaders(h.CurrentConfig(), execOptions.InternalSource)
responseHeaders := downstreamHeadersFromExecutor(rawResponseHeaders, passthroughHeaders)
body, responseHeaders := h.applyResponseInterceptors(ctx, lifecycle.requestID(), handlerType, modelName, originalRequestedModel, opts, rawResponseHeaders, responseHeaders, opts.OriginalRequest, req.Payload, resp.Payload, http.StatusOK, execOptions.SkipInterceptorPluginID, passthroughHeaders)
lifecycle.complete(pluginapi.RequestCompletionSucceeded, http.StatusOK, nil)
@@ -400,11 +400,19 @@ func (h *BaseAPIHandler) pluginExecutorHost() PluginExecutorHost {
if h == nil {
return nil
}
- if executorHost, ok := h.ModelRouterHost.(PluginExecutorHost); ok && executorHost != nil {
- return executorHost
+ h.runtimeMu.RLock()
+ routerHost := h.ModelRouterHost
+ pluginHost := h.PluginHost
+ h.runtimeMu.RUnlock()
+ if !isNilPluginModelRouterHost(routerHost) {
+ if executorHost, ok := routerHost.(PluginExecutorHost); ok && executorHost != nil {
+ return executorHost
+ }
}
- if executorHost, ok := h.PluginHost.(PluginExecutorHost); ok && executorHost != nil {
- return executorHost
+ if !isNilPluginInterceptorHost(pluginHost) {
+ if executorHost, ok := pluginHost.(PluginExecutorHost); ok && executorHost != nil {
+ return executorHost
+ }
}
return nil
}
diff --git a/sdk/api/handlers/handlers_interceptors.go b/sdk/api/handlers/handlers_interceptors.go
index 734232bc92a..b7aa2a5922a 100644
--- a/sdk/api/handlers/handlers_interceptors.go
+++ b/sdk/api/handlers/handlers_interceptors.go
@@ -340,7 +340,13 @@ func (h *BaseAPIHandler) interceptorHost() PluginInterceptorHost {
if h == nil {
return nil
}
- return h.PluginHost
+ h.runtimeMu.RLock()
+ host := h.PluginHost
+ h.runtimeMu.RUnlock()
+ if isNilPluginInterceptorHost(host) {
+ return nil
+ }
+ return host
}
func streamInterceptorsEnabled(host PluginInterceptorHost) bool {
diff --git a/sdk/api/handlers/handlers_routing.go b/sdk/api/handlers/handlers_routing.go
index e29a1a89ada..0ddd28a911a 100644
--- a/sdk/api/handlers/handlers_routing.go
+++ b/sdk/api/handlers/handlers_routing.go
@@ -301,14 +301,17 @@ func (h *BaseAPIHandler) modelRouterHost() PluginModelRouterHost {
if h == nil {
return nil
}
- if !isNilPluginModelRouterHost(h.ModelRouterHost) {
- return h.ModelRouterHost
- }
- host := h.interceptorHost()
- if host == nil {
+ h.runtimeMu.RLock()
+ routerHost := h.ModelRouterHost
+ pluginHost := h.PluginHost
+ h.runtimeMu.RUnlock()
+ if !isNilPluginModelRouterHost(routerHost) {
+ return routerHost
+ }
+ if isNilPluginInterceptorHost(pluginHost) {
return nil
}
- router, ok := host.(PluginModelRouterHost)
+ router, ok := pluginHost.(PluginModelRouterHost)
if !ok {
return nil
}
diff --git a/sdk/api/handlers/handlers_stream.go b/sdk/api/handlers/handlers_stream.go
index 9e637964b42..6f0bfbc931b 100644
--- a/sdk/api/handlers/handlers_stream.go
+++ b/sdk/api/handlers/handlers_stream.go
@@ -92,7 +92,7 @@ func (h *BaseAPIHandler) streamWithPluginExecutor(ctx context.Context, entryProt
return nil, nil, errChan
}
- passthroughHeadersEnabled := executionPassthroughHeaders(h.Cfg, execOptions.InternalSource)
+ passthroughHeadersEnabled := executionPassthroughHeaders(h.CurrentConfig(), execOptions.InternalSource)
interceptorHost := h.interceptorHost()
streamInterceptorsActive := streamInterceptorsEnabled(interceptorHost)
rawStreamHeaders := cloneHeader(streamResult.Headers)
@@ -375,7 +375,7 @@ func (h *BaseAPIHandler) executeStreamWithAuthManagerFormats(ctx context.Context
if executedReq, executedOpts := executedRequest(); len(executedOpts.Headers) > 0 || len(executedReq.Payload) > 0 || len(executedOpts.Metadata) > 0 {
ctx = enrichContextWithSessionHierarchy(ctx, executedOpts.Headers, executedReq.Payload, executedOpts.Metadata)
}
- passthroughHeadersEnabled := executionPassthroughHeaders(h.Cfg, execOptions.InternalSource)
+ passthroughHeadersEnabled := executionPassthroughHeaders(h.CurrentConfig(), execOptions.InternalSource)
interceptorHost := h.interceptorHost()
streamInterceptorsActive := streamInterceptorsEnabled(interceptorHost)
// Resolve bootstrap retries and header initialization before returning so the
@@ -539,7 +539,7 @@ func (h *BaseAPIHandler) executeStreamWithAuthManagerFormats(ctx context.Context
}
}
- maxBootstrapRetries := StreamingBootstrapRetries(h.Cfg)
+ maxBootstrapRetries := StreamingBootstrapRetries(h.CurrentConfig())
if h.AuthManager.HomeEnabled() {
maxBootstrapRetries = 0
}
diff --git a/sdk/api/handlers/openai/codex_client_models.go b/sdk/api/handlers/openai/codex_client_models.go
index f098c267d34..2b2e21859e3 100644
--- a/sdk/api/handlers/openai/codex_client_models.go
+++ b/sdk/api/handlers/openai/codex_client_models.go
@@ -11,10 +11,15 @@ func (h *OpenAIAPIHandler) codexClientModelsResponse(clientVersion ...string) ma
if len(clientVersion) > 0 {
version = clientVersion[0]
}
- optimizeMultiAgentV2 := h != nil && h.Cfg != nil && h.Cfg.Client.Codex.OptimizeMultiAgentV2
+ optimizeMultiAgentV2 := false
var applyPatchCapabilityForModel codexmodels.ApplyPatchCapabilityForModelFunc
- if h != nil && h.Cfg != nil && h.Cfg.Client.Codex.EnableApplyPatch {
- applyPatchCapabilityForModel = h.SupportsApplyPatchModel
+ if h != nil {
+ if cfg := h.CurrentConfig(); cfg != nil {
+ optimizeMultiAgentV2 = cfg.Client.Codex.OptimizeMultiAgentV2
+ if cfg.Client.Codex.EnableApplyPatch {
+ applyPatchCapabilityForModel = h.SupportsApplyPatchModel
+ }
+ }
}
var toolSearchCapabilityForModel codexmodels.ToolSearchCapabilityForModelFunc
if h != nil && responsesclaude.ClientToolSearchEnabled() {
diff --git a/sdk/api/handlers/openai/openai_handlers.go b/sdk/api/handlers/openai/openai_handlers.go
index 4077ee5b8c1..f5114e54f53 100644
--- a/sdk/api/handlers/openai/openai_handlers.go
+++ b/sdk/api/handlers/openai/openai_handlers.go
@@ -507,7 +507,7 @@ func (h *OpenAIAPIHandler) handleStreamingResponse(c *gin.Context, rawJSON []byt
flusher.Flush()
return true
}
- keepAliveC, stopKeepAlive := handlers.NewPreFirstTokenKeepAliveTicker(h.Cfg)
+ keepAliveC, stopKeepAlive := handlers.NewPreFirstTokenKeepAliveTicker(h.CurrentConfig())
defer stopKeepAlive()
dataChan, resolvedHeaders, errChan, okBootstrap := h.ExecuteStreamWithPreFTKeepAlive(
@@ -666,7 +666,7 @@ func (h *OpenAIAPIHandler) handleCompletionsStreamingResponse(c *gin.Context, ra
flusher.Flush()
return true
}
- keepAliveC, stopKeepAlive := handlers.NewPreFirstTokenKeepAliveTicker(h.Cfg)
+ keepAliveC, stopKeepAlive := handlers.NewPreFirstTokenKeepAliveTicker(h.CurrentConfig())
defer stopKeepAlive()
dataChan, resolvedHeaders, errChan, okBootstrap := h.ExecuteStreamWithPreFTKeepAlive(
diff --git a/sdk/api/handlers/openai/openai_images_handlers.go b/sdk/api/handlers/openai/openai_images_handlers.go
index 1874556fab0..10c8a57a3ca 100644
--- a/sdk/api/handlers/openai/openai_images_handlers.go
+++ b/sdk/api/handlers/openai/openai_images_handlers.go
@@ -79,7 +79,7 @@ func (h *OpenAIAPIHandler) newImagesStreamKeepAliveTicker() (func(), <-chan time
if h == nil || h.BaseAPIHandler == nil {
return func() {}, nil
}
- keepAliveC, stop := handlers.NewStreamingKeepAliveTicker(handlers.StreamingKeepAliveInterval(h.Cfg))
+ keepAliveC, stop := handlers.NewStreamingKeepAliveTicker(handlers.StreamingKeepAliveInterval(h.CurrentConfig()))
return stop, keepAliveC
}
@@ -87,7 +87,7 @@ func (h *OpenAIAPIHandler) newImagesPreFirstTokenKeepAliveTicker() (func(), <-ch
if h == nil || h.BaseAPIHandler == nil {
return func() {}, nil
}
- keepAliveC, stop := handlers.NewPreFirstTokenKeepAliveTicker(h.Cfg)
+ keepAliveC, stop := handlers.NewPreFirstTokenKeepAliveTicker(h.CurrentConfig())
return stop, keepAliveC
}
@@ -230,14 +230,37 @@ func isXAIImagesBaseModel(baseModel string) bool {
}
}
+func registeredXAIImagesModel(model string) (string, bool) {
+ model = strings.TrimSpace(model)
+ if model == "" {
+ return "", false
+ }
+ modelRegistry := registry.GetGlobalRegistry()
+ for _, candidate := range []string{model, strings.ToLower(model)} {
+ for _, provider := range modelRegistry.GetModelProviders(candidate) {
+ if strings.EqualFold(strings.TrimSpace(provider), "xai") {
+ return candidate, true
+ }
+ }
+ }
+ return "", false
+}
+
func isXAIImagesModel(model string) bool {
prefix, baseModel := imagesModelParts(model)
if !isXAIImagesBaseModel(baseModel) {
return false
}
- prefix = strings.ToLower(strings.TrimSpace(prefix))
- return prefix == "" || prefix == "xai" || prefix == "x-ai" || prefix == "grok"
+ trimmedPrefix := strings.ToLower(strings.TrimSpace(prefix))
+ if trimmedPrefix == "" || trimmedPrefix == "xai" || trimmedPrefix == "x-ai" || trimmedPrefix == "grok" {
+ return true
+ }
+ if _, ok := registeredXAIImagesModel(prefix + "/" + canonicalXAIImagesModel(model)); ok {
+ return true
+ }
+ _, ok := registeredXAIImagesModel(model)
+ return ok
}
func isSupportedImagesModel(model string) bool {
@@ -298,6 +321,33 @@ func canonicalXAIImagesModel(model string) string {
}
}
+func (h *OpenAIAPIHandler) routingXAIImagesModel(model string) string {
+ canonical := canonicalXAIImagesModel(model)
+ prefix, _ := imagesModelParts(model)
+ if prefix == "" {
+ return canonical
+ }
+ prefixedModel := prefix + "/" + canonical
+ if resolved, ok := registeredXAIImagesModel(prefixedModel); ok {
+ return resolved
+ }
+ if resolved, ok := registeredXAIImagesModel(model); ok {
+ return resolved
+ }
+ if h != nil {
+ if cfg := h.CurrentConfig(); cfg != nil && cfg.ForceModelPrefix {
+ return prefixedModel
+ }
+ }
+ if h != nil && h.BaseAPIHandler != nil && h.AuthManager != nil && h.AuthManager.HomeEnabled() {
+ return prefixedModel
+ }
+ if _, ok := registeredXAIImagesModel(canonical); !ok {
+ return prefixedModel
+ }
+ return canonical
+}
+
func xaiImagesAspectRatio(raw string, fallback string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "1:1", "square":
@@ -624,9 +674,11 @@ func parseBoolField(raw string, fallback bool) bool {
}
func (h *OpenAIAPIHandler) ImagesGenerations(c *gin.Context) {
- if h != nil && h.BaseAPIHandler != nil && h.BaseAPIHandler.Cfg != nil && h.BaseAPIHandler.Cfg.DisableImageGeneration == internalconfig.DisableImageGenerationAll {
- c.AbortWithStatus(http.StatusNotFound)
- return
+ if h != nil {
+ if cfg := h.CurrentConfig(); cfg != nil && cfg.DisableImageGeneration == internalconfig.DisableImageGenerationAll {
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
}
rawJSON, err := handlers.ReadRequestBody(c)
@@ -681,7 +733,7 @@ func (h *OpenAIAPIHandler) ImagesGenerations(c *gin.Context) {
}
if isXAIImagesModel(imageModel) {
xaiReq := buildXAIImagesGenerationsRequest(rawJSON, imageModel, responseFormat)
- h.handleXAIImages(c, xaiReq, responseFormat, "image_generation", stream)
+ h.handleXAIImages(c, xaiReq, imageModel, responseFormat, "image_generation", stream)
return
}
if isOpenAICompatImagesModel(imageModel) {
@@ -728,9 +780,11 @@ func (h *OpenAIAPIHandler) ImagesGenerations(c *gin.Context) {
}
func (h *OpenAIAPIHandler) ImagesEdits(c *gin.Context) {
- if h != nil && h.BaseAPIHandler != nil && h.BaseAPIHandler.Cfg != nil && h.BaseAPIHandler.Cfg.DisableImageGeneration == internalconfig.DisableImageGenerationAll {
- c.AbortWithStatus(http.StatusNotFound)
- return
+ if h != nil {
+ if cfg := h.CurrentConfig(); cfg != nil && cfg.DisableImageGeneration == internalconfig.DisableImageGenerationAll {
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
}
contentType := strings.ToLower(strings.TrimSpace(c.GetHeader("Content-Type")))
@@ -841,7 +895,7 @@ func (h *OpenAIAPIHandler) imagesEditsFromMultipart(c *gin.Context) {
quality := strings.TrimSpace(c.PostForm("quality"))
n := parseIntField(c.PostForm("n"), 0)
xaiReq := buildXAIImagesEditRequest(imageModel, prompt, images, responseFormat, aspectRatio, resolution, quality, n)
- h.handleXAIImages(c, xaiReq, responseFormat, "image_edit", stream)
+ h.handleXAIImages(c, xaiReq, imageModel, responseFormat, "image_edit", stream)
return
}
if isOpenAICompatImagesModel(imageModel) {
@@ -980,7 +1034,7 @@ func (h *OpenAIAPIHandler) imagesEditsFromJSON(c *gin.Context) {
}
aspectRatio, resolution, quality, n := xaiImagesEditOptionsFromJSON(rawJSON)
xaiReq := buildXAIImagesEditRequest(imageModel, prompt, images, responseFormat, aspectRatio, resolution, quality, n)
- h.handleXAIImages(c, xaiReq, responseFormat, "image_edit", stream)
+ h.handleXAIImages(c, xaiReq, imageModel, responseFormat, "image_edit", stream)
return
}
if isOpenAICompatImagesModel(imageModel) {
@@ -1167,12 +1221,12 @@ func buildImagesAPIResponseFromXAI(payload []byte, responseFormat string) ([]byt
return out, nil
}
-func (h *OpenAIAPIHandler) handleXAIImages(c *gin.Context, xaiReq []byte, responseFormat string, streamPrefix string, stream bool) {
+func (h *OpenAIAPIHandler) handleXAIImages(c *gin.Context, xaiReq []byte, imageModel string, responseFormat string, streamPrefix string, stream bool) {
if stream {
- h.streamXAIImages(c, xaiReq, responseFormat, streamPrefix)
+ h.streamXAIImages(c, xaiReq, imageModel, responseFormat, streamPrefix)
return
}
- h.collectXAIImages(c, xaiReq, responseFormat)
+ h.collectXAIImages(c, xaiReq, imageModel, responseFormat)
}
func (h *OpenAIAPIHandler) handleOpenAICompatImages(c *gin.Context, compatReq []byte, imageModel string, responseFormat string, streamPrefix string, stream bool) {
@@ -1459,9 +1513,12 @@ func (h *OpenAIAPIHandler) streamOpenAICompatImages(c *gin.Context, compatReq []
}
}
-func (h *OpenAIAPIHandler) collectXAIImages(c *gin.Context, xaiReq []byte, responseFormat string) {
- model := strings.TrimSpace(gjson.GetBytes(xaiReq, "model").String())
- h.collectImagesWithModel(c, xaiReq, model, responseFormat)
+func (h *OpenAIAPIHandler) collectXAIImages(c *gin.Context, xaiReq []byte, imageModel string, responseFormat string) {
+ model := strings.TrimSpace(imageModel)
+ if model == "" {
+ model = strings.TrimSpace(gjson.GetBytes(xaiReq, "model").String())
+ }
+ h.collectImagesWithModel(c, xaiReq, h.routingXAIImagesModel(model), responseFormat)
}
func (h *OpenAIAPIHandler) collectImagesWithModel(c *gin.Context, imageReq []byte, model string, responseFormat string) {
@@ -1496,9 +1553,12 @@ func (h *OpenAIAPIHandler) collectImagesWithModel(c *gin.Context, imageReq []byt
cliCancel(nil)
}
-func (h *OpenAIAPIHandler) streamXAIImages(c *gin.Context, xaiReq []byte, responseFormat string, streamPrefix string) {
- model := strings.TrimSpace(gjson.GetBytes(xaiReq, "model").String())
- h.streamImagesWithModel(c, xaiReq, model, responseFormat, streamPrefix)
+func (h *OpenAIAPIHandler) streamXAIImages(c *gin.Context, xaiReq []byte, imageModel string, responseFormat string, streamPrefix string) {
+ model := strings.TrimSpace(imageModel)
+ if model == "" {
+ model = strings.TrimSpace(gjson.GetBytes(xaiReq, "model").String())
+ }
+ h.streamImagesWithModel(c, xaiReq, h.routingXAIImagesModel(model), responseFormat, streamPrefix)
}
func (h *OpenAIAPIHandler) streamImagesWithModel(c *gin.Context, imageReq []byte, model string, responseFormat string, streamPrefix string) {
diff --git a/sdk/api/handlers/openai/openai_images_handlers_test.go b/sdk/api/handlers/openai/openai_images_handlers_test.go
index d21a31dbf2f..9334acbe6ab 100644
--- a/sdk/api/handlers/openai/openai_images_handlers_test.go
+++ b/sdk/api/handlers/openai/openai_images_handlers_test.go
@@ -11,6 +11,7 @@ import (
"net/http/httptest"
"net/textproto"
"strings"
+ "sync"
"testing"
"github.com/gin-gonic/gin"
@@ -18,6 +19,8 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ coreexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
"github.com/tidwall/gjson"
)
@@ -669,3 +672,285 @@ func TestSSEFrameAccumulatorKeepsMultipleFramesDistinct(t *testing.T) {
t.Fatalf("frames were overwritten during buffer compaction: %q", frames)
}
}
+
+type xaiImagesCaptureCall struct {
+ authID string
+ reqModel string
+ requestedModel string
+ payloadModel string
+}
+
+type xaiImagesCaptureExecutor struct {
+ mu sync.Mutex
+ calls []xaiImagesCaptureCall
+}
+
+func (e *xaiImagesCaptureExecutor) Identifier() string { return "xai" }
+
+func (e *xaiImagesCaptureExecutor) Execute(_ context.Context, auth *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (coreexecutor.Response, error) {
+ authID := ""
+ if auth != nil {
+ authID = auth.ID
+ }
+ requestedModel := ""
+ if opts.Metadata != nil {
+ if v, ok := opts.Metadata[coreexecutor.RequestedModelMetadataKey].(string); ok {
+ requestedModel = v
+ }
+ }
+ e.mu.Lock()
+ e.calls = append(e.calls, xaiImagesCaptureCall{
+ authID: authID,
+ reqModel: req.Model,
+ requestedModel: requestedModel,
+ payloadModel: strings.TrimSpace(gjson.GetBytes(req.Payload, "model").String()),
+ })
+ e.mu.Unlock()
+ return coreexecutor.Response{
+ Payload: []byte(`{"created":123,"data":[{"b64_json":"aW1hZ2U=","revised_prompt":"ok","mime_type":"image/png"}]}`),
+ }, nil
+}
+
+func (e *xaiImagesCaptureExecutor) ExecuteStream(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (*coreexecutor.StreamResult, error) {
+ return nil, &coreauth.Error{Code: "not_implemented", Message: "ExecuteStream not implemented"}
+}
+
+func (e *xaiImagesCaptureExecutor) Refresh(_ context.Context, auth *coreauth.Auth) (*coreauth.Auth, error) {
+ return auth, nil
+}
+
+func (e *xaiImagesCaptureExecutor) CountTokens(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {
+ return coreexecutor.Response{}, &coreauth.Error{Code: "not_implemented", Message: "CountTokens not implemented"}
+}
+
+func (e *xaiImagesCaptureExecutor) HttpRequest(context.Context, *coreauth.Auth, *http.Request) (*http.Response, error) {
+ return nil, &coreauth.Error{Code: "not_implemented", Message: "HttpRequest not implemented"}
+}
+
+func (e *xaiImagesCaptureExecutor) Calls() []xaiImagesCaptureCall {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ out := make([]xaiImagesCaptureCall, len(e.calls))
+ copy(out, e.calls)
+ return out
+}
+
+func newXAIImagesRoutingTestHandler(t *testing.T, forceModelPrefix bool, authID string, prefix string, modelIDs []string) (*OpenAIAPIHandler, *xaiImagesCaptureExecutor) {
+ t.Helper()
+
+ executor := &xaiImagesCaptureExecutor{}
+ manager := coreauth.NewManager(nil, &coreauth.RoundRobinSelector{}, nil)
+ manager.RegisterExecutor(executor)
+ auth := &coreauth.Auth{
+ ID: authID,
+ Provider: "xai",
+ Prefix: prefix,
+ Status: coreauth.StatusActive,
+ }
+ if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
+ t.Fatalf("manager.Register: %v", errRegister)
+ }
+
+ models := make([]*registry.ModelInfo, 0, len(modelIDs))
+ for _, id := range modelIDs {
+ models = append(models, ®istry.ModelInfo{
+ ID: id,
+ Object: "model",
+ OwnedBy: "xai",
+ Type: "xai",
+ })
+ }
+ modelRegistry := registry.GetGlobalRegistry()
+ modelRegistry.RegisterClient(auth.ID, auth.Provider, models)
+ manager.RefreshSchedulerEntry(auth.ID)
+ t.Cleanup(func() {
+ modelRegistry.UnregisterClient(auth.ID)
+ })
+
+ base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{ForceModelPrefix: forceModelPrefix}, manager)
+ return NewOpenAIAPIHandler(base), executor
+}
+
+func TestXAIImagesRoutesWithPrefixedModelWhenForceModelPrefixEnabled(t *testing.T) {
+ handler, executor := newXAIImagesRoutingTestHandler(t, true, "xai-prefixed-auth", "xai", []string{
+ "xai/" + defaultXAIImagesModel,
+ "xai/" + xaiImagesQualityModel,
+ "xai/" + xaiImages20Model,
+ })
+
+ t.Run("generations non-stream", func(t *testing.T) {
+ before := len(executor.Calls())
+ body := strings.NewReader(`{"model":"xai/grok-imagine-image-2.0","prompt":"a red square","response_format":"b64_json"}`)
+ resp := performImagesEndpointRequest(t, imagesGenerationsPath, "application/json", body, handler.ImagesGenerations)
+ if resp.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusOK, resp.Body.String())
+ }
+ if got := gjson.GetBytes(resp.Body.Bytes(), "data.0.b64_json").String(); got != "aW1hZ2U=" {
+ t.Fatalf("data.0.b64_json = %q, want aW1hZ2U=", got)
+ }
+ calls := executor.Calls()
+ if len(calls) != before+1 {
+ t.Fatalf("executor calls = %d, want %d", len(calls), before+1)
+ }
+ last := calls[len(calls)-1]
+ if last.authID != "xai-prefixed-auth" {
+ t.Fatalf("authID = %q, want xai-prefixed-auth", last.authID)
+ }
+ if last.requestedModel != "xai/grok-imagine-image-2.0" {
+ t.Fatalf("requestedModel = %q, want xai/grok-imagine-image-2.0", last.requestedModel)
+ }
+ if last.reqModel != xaiImages20Model {
+ t.Fatalf("req.Model = %q, want %s", last.reqModel, xaiImages20Model)
+ }
+ if last.payloadModel != xaiImages20Model {
+ t.Fatalf("payload model = %q, want %s", last.payloadModel, xaiImages20Model)
+ }
+ })
+
+ t.Run("generations stream", func(t *testing.T) {
+ before := len(executor.Calls())
+ body := strings.NewReader(`{"model":"xai/grok-imagine-image-2.0","prompt":"a red square","response_format":"b64_json","stream":true}`)
+ resp := performImagesEndpointRequest(t, imagesGenerationsPath, "application/json", body, handler.ImagesGenerations)
+ if resp.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusOK, resp.Body.String())
+ }
+ if !strings.Contains(resp.Body.String(), `"type":"image_generation.completed"`) {
+ t.Fatalf("stream body = %q, want image_generation.completed event", resp.Body.String())
+ }
+ calls := executor.Calls()
+ if len(calls) != before+1 {
+ t.Fatalf("executor calls = %d, want %d", len(calls), before+1)
+ }
+ last := calls[len(calls)-1]
+ if last.requestedModel != "xai/grok-imagine-image-2.0" || last.payloadModel != xaiImages20Model {
+ t.Fatalf("call = %+v, want requestedModel=xai/grok-imagine-image-2.0 payloadModel=%s", last, xaiImages20Model)
+ }
+ })
+
+ t.Run("edits JSON", func(t *testing.T) {
+ before := len(executor.Calls())
+ body := strings.NewReader(`{"model":"xai/grok-imagine-image-2.0","prompt":"edit this","images":[{"image_url":"data:image/png;base64,AA=="}]}`)
+ resp := performImagesEndpointRequest(t, imagesEditsPath, "application/json", body, handler.ImagesEdits)
+ if resp.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusOK, resp.Body.String())
+ }
+ calls := executor.Calls()
+ if len(calls) != before+1 {
+ t.Fatalf("executor calls = %d, want %d", len(calls), before+1)
+ }
+ last := calls[len(calls)-1]
+ if last.requestedModel != "xai/grok-imagine-image-2.0" || last.payloadModel != xaiImages20Model {
+ t.Fatalf("call = %+v, want requestedModel=xai/grok-imagine-image-2.0 payloadModel=%s", last, xaiImages20Model)
+ }
+ })
+
+ t.Run("edits multipart", func(t *testing.T) {
+ before := len(executor.Calls())
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+ if err := writer.WriteField("model", "xai/grok-imagine-image-2.0"); err != nil {
+ t.Fatalf("write model field: %v", err)
+ }
+ if err := writer.WriteField("prompt", "edit this"); err != nil {
+ t.Fatalf("write prompt field: %v", err)
+ }
+ header := make(textproto.MIMEHeader)
+ header.Set("Content-Disposition", multipart.FileContentDisposition("image", "image.png"))
+ header.Set("Content-Type", "image/png")
+ part, err := writer.CreatePart(header)
+ if err != nil {
+ t.Fatalf("create image part: %v", err)
+ }
+ if _, err := part.Write([]byte("png-bytes")); err != nil {
+ t.Fatalf("write image part: %v", err)
+ }
+ if errClose := writer.Close(); errClose != nil {
+ t.Fatalf("close multipart writer: %v", errClose)
+ }
+
+ resp := performImagesEndpointRequest(t, imagesEditsPath, writer.FormDataContentType(), &body, handler.ImagesEdits)
+ if resp.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusOK, resp.Body.String())
+ }
+ calls := executor.Calls()
+ if len(calls) != before+1 {
+ t.Fatalf("executor calls = %d, want %d", len(calls), before+1)
+ }
+ last := calls[len(calls)-1]
+ if last.requestedModel != "xai/grok-imagine-image-2.0" || last.payloadModel != xaiImages20Model {
+ t.Fatalf("call = %+v, want requestedModel=xai/grok-imagine-image-2.0 payloadModel=%s", last, xaiImages20Model)
+ }
+ })
+
+ t.Run("case-insensitive prefixed model routes and strips prefix for upstream", func(t *testing.T) {
+ before := len(executor.Calls())
+ body := strings.NewReader(`{"model":"XAI/Grok-Imagine-Image-2.0","prompt":"a red square"}`)
+ resp := performImagesEndpointRequest(t, imagesGenerationsPath, "application/json", body, handler.ImagesGenerations)
+ if resp.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusOK, resp.Body.String())
+ }
+ calls := executor.Calls()
+ if len(calls) != before+1 {
+ t.Fatalf("executor calls = %d, want %d", len(calls), before+1)
+ }
+ last := calls[len(calls)-1]
+ if last.reqModel != xaiImages20Model || last.payloadModel != xaiImages20Model {
+ t.Fatalf("call = %+v, want reqModel=%s payloadModel=%s", last, xaiImages20Model, xaiImages20Model)
+ }
+ })
+
+ t.Run("unprefixed model rejected when force-model-prefix is enabled", func(t *testing.T) {
+ before := len(executor.Calls())
+ body := strings.NewReader(`{"model":"grok-imagine-image-2.0","prompt":"a red square"}`)
+ resp := performImagesEndpointRequest(t, imagesGenerationsPath, "application/json", body, handler.ImagesGenerations)
+ if resp.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusBadRequest, resp.Body.String())
+ }
+ if got := gjson.GetBytes(resp.Body.Bytes(), "error.message").String(); got != "unknown provider for model grok-imagine-image-2.0" {
+ t.Fatalf("error.message = %q, want %q", got, "unknown provider for model grok-imagine-image-2.0")
+ }
+ if len(executor.Calls()) != before {
+ t.Fatalf("expected no executor call for unprefixed model")
+ }
+ })
+}
+
+func TestXAIImagesSupportsBuiltInPrefixFallbackAndCustomCredentialPrefix(t *testing.T) {
+ t.Run("built-in prefix falls back to unprefixed registered model when force-model-prefix is disabled", func(t *testing.T) {
+ handler, executor := newXAIImagesRoutingTestHandler(t, false, "xai-unprefixed-auth", "", []string{
+ defaultXAIImagesModel,
+ xaiImagesQualityModel,
+ xaiImages20Model,
+ })
+ body := strings.NewReader(`{"model":"xai/grok-imagine-image-2.0","prompt":"a red square"}`)
+ resp := performImagesEndpointRequest(t, imagesGenerationsPath, "application/json", body, handler.ImagesGenerations)
+ if resp.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusOK, resp.Body.String())
+ }
+ calls := executor.Calls()
+ if len(calls) != 1 {
+ t.Fatalf("executor calls = %d, want 1", len(calls))
+ }
+ if calls[0].payloadModel != xaiImages20Model {
+ t.Fatalf("payload model = %q, want %s", calls[0].payloadModel, xaiImages20Model)
+ }
+ })
+
+ t.Run("custom xai credential prefix routes when force-model-prefix is enabled", func(t *testing.T) {
+ handler, executor := newXAIImagesRoutingTestHandler(t, true, "xai-custom-prefix-auth", "my-xai", []string{
+ "my-xai/" + xaiImages20Model,
+ })
+ body := strings.NewReader(`{"model":"my-xai/grok-imagine-image-2.0","prompt":"a red square"}`)
+ resp := performImagesEndpointRequest(t, imagesGenerationsPath, "application/json", body, handler.ImagesGenerations)
+ if resp.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusOK, resp.Body.String())
+ }
+ calls := executor.Calls()
+ if len(calls) != 1 {
+ t.Fatalf("executor calls = %d, want 1", len(calls))
+ }
+ if calls[0].authID != "xai-custom-prefix-auth" || calls[0].requestedModel != "my-xai/grok-imagine-image-2.0" || calls[0].payloadModel != xaiImages20Model {
+ t.Fatalf("call = %+v, want authID=xai-custom-prefix-auth requestedModel=my-xai/grok-imagine-image-2.0 payloadModel=%s", calls[0], xaiImages20Model)
+ }
+ })
+}
diff --git a/sdk/api/handlers/openai/openai_responses_handlers.go b/sdk/api/handlers/openai/openai_responses_handlers.go
index c604e1960a9..e32e252396c 100644
--- a/sdk/api/handlers/openai/openai_responses_handlers.go
+++ b/sdk/api/handlers/openai/openai_responses_handlers.go
@@ -147,8 +147,8 @@ func (f *responsesSSEFramer) shouldFilterPrivateEvent(streamEvent, payloadType s
return false
}
- // Always filter internal WebSocket timing telemetry from SSE streams.
- if strings.HasPrefix(name, "responsesapi.") {
+ // Always filter internal WebSocket timing telemetry and Meta subscription usage from SSE streams.
+ if strings.HasPrefix(name, "responsesapi.") || name == "response.subscription_usage" {
return true
}
@@ -551,7 +551,11 @@ func (h *OpenAIResponsesAPIHandler) OpenAIResponsesModels(c *gin.Context) {
}
func (h *OpenAIResponsesAPIHandler) prepareCodexMultiAgentV2Tools(c *gin.Context, payload []byte) []byte {
- if h == nil || h.Cfg == nil {
+ if h == nil {
+ return payload
+ }
+ cfg := h.CurrentConfig()
+ if cfg == nil {
return payload
}
@@ -570,7 +574,7 @@ func (h *OpenAIResponsesAPIHandler) prepareCodexMultiAgentV2Tools(c *gin.Context
requestCtx,
requestHeaders,
payload,
- h.Cfg.Client.Codex.OptimizeMultiAgentV2,
+ cfg.Client.Codex.OptimizeMultiAgentV2,
homeEnabled,
)
if prepared && c != nil {
@@ -580,7 +584,11 @@ func (h *OpenAIResponsesAPIHandler) prepareCodexMultiAgentV2Tools(c *gin.Context
}
func (h *OpenAIResponsesAPIHandler) prepareCodexOrphanDelegation(c *gin.Context, payload []byte) []byte {
- if h == nil || h.Cfg == nil || !h.Cfg.CodexOrphanDelegationCompatibility {
+ if h == nil {
+ return payload
+ }
+ cfg := h.CurrentConfig()
+ if cfg == nil || !cfg.CodexOrphanDelegationCompatibility {
return payload
}
requestCtx := context.Background()
@@ -754,7 +762,7 @@ func (h *OpenAIResponsesAPIHandler) handleStreamingResponse(c *gin.Context, rawJ
}
return true
}
- keepAliveC, stopKeepAlive := handlers.NewPreFirstTokenKeepAliveTicker(h.Cfg)
+ keepAliveC, stopKeepAlive := handlers.NewPreFirstTokenKeepAliveTicker(h.CurrentConfig())
defer stopKeepAlive()
dataChan, resolvedHeaders, errChan, okBootstrap := h.ExecuteStreamWithPreFTKeepAlive(
diff --git a/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go b/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go
index 58d1349a59a..c5bb229f399 100644
--- a/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go
+++ b/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go
@@ -543,6 +543,9 @@ func TestResponsesSSEFramer_FiltersUpstreamPrivateEvents(t *testing.T) {
timingFrame := []byte("event: responsesapi.websocket_timing\ndata: {\"type\":\"responsesapi.websocket_timing\",\"timing\":{\"duration_ms\":100}}\n\n")
framer.WriteChunk(&output, timingFrame)
+ subscriptionUsageFrame := []byte("event: response.subscription_usage\ndata: {\"subscription\":{\"tier\":\"1234567890123456\",\"weekly\":{\"resets_at\":1792368000,\"used_percent\":80},\"window\":{\"resets_at\":1792272519,\"used_percent\":10,\"window_duration_mins\":300}},\"type\":\"response.subscription_usage\"}\n\n")
+ framer.WriteChunk(&output, subscriptionUsageFrame)
+
completedFrame := []byte("event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1\",\"status\":\"completed\"}}\n\n")
framer.WriteChunk(&output, completedFrame)
@@ -556,6 +559,9 @@ func TestResponsesSSEFramer_FiltersUpstreamPrivateEvents(t *testing.T) {
if strings.Contains(got, "responsesapi.websocket_timing") {
t.Fatalf("expected responsesapi.websocket_timing to be stripped, got: %q", got)
}
+ if strings.Contains(got, "response.subscription_usage") || strings.Contains(got, "1234567890123456") {
+ t.Fatalf("expected response.subscription_usage to be stripped, got: %q", got)
+ }
if !strings.Contains(got, "event: response.created") {
t.Fatalf("expected response.created in output, got: %q", got)
}
@@ -677,6 +683,9 @@ func TestResponsesSSEFramer_CodexClientFiltersRateLimitsAndTiming(t *testing.T)
timingFrame := []byte("event: responsesapi.websocket_timing\ndata: {\"type\":\"responsesapi.websocket_timing\",\"timing\":{\"duration_ms\":100}}\n\n")
framer.WriteChunk(&output, timingFrame)
+ subscriptionUsageFrame := []byte("event: response.subscription_usage\ndata: {\"subscription\":{\"tier\":\"1234567890123456\",\"weekly\":{\"resets_at\":1792368000,\"used_percent\":80},\"window\":{\"resets_at\":1792272519,\"used_percent\":10,\"window_duration_mins\":300}},\"type\":\"response.subscription_usage\"}\n\n")
+ framer.WriteChunk(&output, subscriptionUsageFrame)
+
createdFrame := []byte("event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp-1\"}}\n\n")
framer.WriteChunk(&output, createdFrame)
@@ -690,6 +699,9 @@ func TestResponsesSSEFramer_CodexClientFiltersRateLimitsAndTiming(t *testing.T)
if strings.Contains(got, "responsesapi.websocket_timing") {
t.Fatalf("expected responsesapi.websocket_timing to be stripped for Codex client, got: %q", got)
}
+ if strings.Contains(got, "response.subscription_usage") || strings.Contains(got, "1234567890123456") {
+ t.Fatalf("expected response.subscription_usage to be stripped for Codex client, got: %q", got)
+ }
if !strings.Contains(got, "codex.response.metadata") {
t.Fatalf("expected codex.response.metadata to be preserved for official Codex client, got: %q", got)
}
diff --git a/sdk/api/handlers/openai/openai_responses_websocket.go b/sdk/api/handlers/openai/openai_responses_websocket.go
index 08d62ceb16a..2b5ab5e034f 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket.go
@@ -278,14 +278,21 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
c.Request = c.Request.WithContext(socketCtx)
// The reader owns the client socket so an in-flight interrupt is not stuck
// behind the response currently being forwarded.
- requestLogEnabled := h != nil && h.Cfg != nil && h.Cfg.RequestLog
+ requestLogEnabled := false
+ codexResponseSteering := false
+ if h != nil {
+ if cfg := h.CurrentConfig(); cfg != nil {
+ requestLogEnabled = cfg.RequestLog
+ codexResponseSteering = cfg.CodexResponseSteering
+ }
+ }
wsTimelineLog := newWebsocketTimelineLog(requestLogEnabled, websocketTimelineSourceFromContext(c))
localInterrupt := newResponsesLocalInterrupt()
input, inputDone := readResponsesWebsocketInput(socketCtx, cancelSocket, conn, func(payload []byte) error {
return h.forwardResponsesWebsocketInterrupt(socketCtx, passthroughSessionID, payload)
}, localInterrupt, writer, wsTimelineLog)
var duplexInput <-chan cliproxyexecutor.WebsocketInput
- if h != nil && h.Cfg != nil && h.Cfg.CodexResponseSteering {
+ if codexResponseSteering {
duplexInput = input
}
downstreamSessionKey := websocketDownstreamSessionKey(c.Request)
diff --git a/sdk/api/handlers/openai/openai_responses_websocket_forward.go b/sdk/api/handlers/openai/openai_responses_websocket_forward.go
index a6a6084e4c6..1333ebffdcd 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket_forward.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket_forward.go
@@ -61,7 +61,7 @@ func (h *OpenAIResponsesAPIHandler) forwardResponsesWebsocket(
var keepAliveC <-chan time.Time
keepAliveInterval := time.Duration(0)
if h != nil {
- keepAliveInterval = handlers.StreamingKeepAliveInterval(h.Cfg)
+ keepAliveInterval = handlers.StreamingKeepAliveInterval(h.CurrentConfig())
}
if opts.keepAliveInterval != nil {
keepAliveInterval = *opts.keepAliveInterval
@@ -160,7 +160,11 @@ func (h *OpenAIResponsesAPIHandler) forwardResponsesWebsocket(
payloads := websocketJSONPayloadsFromChunk(chunk)
for i := range payloads {
- if gjson.GetBytes(payloads[i], "type").String() == "response.created" {
+ eventType := gjson.GetBytes(payloads[i], "type").String()
+ if eventType == "response.subscription_usage" {
+ continue
+ }
+ if eventType == "response.created" {
responseStarted = true
completed = false
outputItemsByIndex = make(map[int64][]byte)
@@ -168,7 +172,6 @@ func (h *OpenAIResponsesAPIHandler) forwardResponsesWebsocket(
pendingToolCallIDs = make(map[string]struct{})
}
collectResponsesWebsocketOutputItem(payloads[i], outputItemsByIndex, &outputItemsFallback)
- eventType := gjson.GetBytes(payloads[i], "type").String()
if isResponsesWebsocketCompletionEvent(eventType) && (opts.preserveCompletionOutput == nil || !opts.preserveCompletionOutput()) {
payloads[i] = restoreResponsesWebsocketCompletionOutput(payloads[i], outputItemsByIndex, outputItemsFallback)
}
diff --git a/sdk/api/handlers/openai/openai_videos_handlers.go b/sdk/api/handlers/openai/openai_videos_handlers.go
index 3a1a26fc57b..55cd0a3d50b 100644
--- a/sdk/api/handlers/openai/openai_videos_handlers.go
+++ b/sdk/api/handlers/openai/openai_videos_handlers.go
@@ -296,11 +296,13 @@ func firstPostForm(c *gin.Context, keys ...string) string {
}
func (h *OpenAIAPIHandler) videoAuthBindingTTL() time.Duration {
- if h != nil && h.BaseAPIHandler != nil && h.Cfg != nil {
- raw := strings.TrimSpace(h.Cfg.VideoResultAuthCacheTTL)
- if raw != "" {
- if ttl, err := time.ParseDuration(raw); err == nil && ttl > 0 {
- return ttl
+ if h != nil {
+ if cfg := h.CurrentConfig(); cfg != nil {
+ raw := strings.TrimSpace(cfg.VideoResultAuthCacheTTL)
+ if raw != "" {
+ if ttl, err := time.ParseDuration(raw); err == nil && ttl > 0 {
+ return ttl
+ }
}
}
}
@@ -947,8 +949,10 @@ func (h *OpenAIAPIHandler) videoContentHTTPClient(c *gin.Context) *http.Client {
ctx = c.Request.Context()
}
var cfg *config.Config
- if h != nil && h.BaseAPIHandler != nil && h.Cfg != nil {
- cfg = &config.Config{SDKConfig: *h.Cfg}
+ if h != nil {
+ if sdkCfg := h.CurrentConfig(); sdkCfg != nil {
+ cfg = &config.Config{SDKConfig: *sdkCfg}
+ }
}
return helps.NewProxyAwareHTTPClient(ctx, cfg, h.videoContentDownloadAuth(c), 0)
}
diff --git a/sdk/api/handlers/stream_forwarder.go b/sdk/api/handlers/stream_forwarder.go
index eee5836da4d..fb5828fc168 100644
--- a/sdk/api/handlers/stream_forwarder.go
+++ b/sdk/api/handlers/stream_forwarder.go
@@ -148,7 +148,7 @@ func (h *BaseAPIHandler) ForwardStream(c *gin.Context, flusher http.Flusher, can
}
}
- keepAliveInterval := StreamingKeepAliveInterval(h.Cfg)
+ keepAliveInterval := StreamingKeepAliveInterval(h.CurrentConfig())
if opts.KeepAliveInterval != nil {
keepAliveInterval = *opts.KeepAliveInterval
}
diff --git a/sdk/auth/antigravity.go b/sdk/auth/antigravity.go
index 30356996fd9..aaafb708ff6 100644
--- a/sdk/auth/antigravity.go
+++ b/sdk/auth/antigravity.go
@@ -271,6 +271,18 @@ func AntigravityUserAgent() string {
return misc.AntigravityUserAgent()
}
+// StartAntigravityVersionUpdater starts background refresh for the Antigravity Hub version.
+// Pass a process-lifetime ctx; it runs only once and is not auto-started by SDK packages.
+func StartAntigravityVersionUpdater(ctx context.Context) {
+ misc.StartAntigravityVersionUpdater(ctx)
+}
+
+// AntigravityRequestUserAgent returns the User-Agent for Antigravity runtime requests,
+// falling back to AntigravityUserAgent when userAgent is empty.
+func AntigravityRequestUserAgent(userAgent string) string {
+ return misc.AntigravityRequestUserAgent(userAgent)
+}
+
// BuildAntigravityAuthURL generates the OAuth authorization URL for Antigravity.
// If redirectURI is empty, the default loopback callback URI is used.
func BuildAntigravityAuthURL(state, redirectURI string) string {
diff --git a/sdk/auth/codex.go b/sdk/auth/codex.go
index 53b9cab1f21..2d56fa07919 100644
--- a/sdk/auth/codex.go
+++ b/sdk/auth/codex.go
@@ -32,7 +32,7 @@ func (a *CodexAuthenticator) Provider() string {
}
func (a *CodexAuthenticator) RefreshLead() *time.Duration {
- return new(24 * time.Hour)
+ return new(coreauth.CodexOuterRefreshLead)
}
func (a *CodexAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {
diff --git a/sdk/auth/refresh_registry_test.go b/sdk/auth/refresh_registry_test.go
index 428ce014eab..402ef69aec5 100644
--- a/sdk/auth/refresh_registry_test.go
+++ b/sdk/auth/refresh_registry_test.go
@@ -12,7 +12,7 @@ func TestProviderRefreshLeads(t *testing.T) {
want time.Duration
wantNil bool
}{
- {name: "codex", authenticator: NewCodexAuthenticator(), want: 24 * time.Hour},
+ {name: "codex", authenticator: NewCodexAuthenticator(), want: 96 * time.Hour},
{name: "claude", authenticator: NewClaudeAuthenticator(), want: 4 * time.Hour},
{name: "antigravity", authenticator: NewAntigravityAuthenticator(), want: 30 * time.Minute},
{name: "kimi", authenticator: NewKimiAuthenticator(), want: 5 * time.Minute},
diff --git a/sdk/cliproxy/antigravity_models.go b/sdk/cliproxy/antigravity_models.go
index b15f19aca6d..1abf132560f 100644
--- a/sdk/cliproxy/antigravity_models.go
+++ b/sdk/cliproxy/antigravity_models.go
@@ -659,7 +659,7 @@ func (s *Service) antigravityModelsForHintsWithConfig(auth *coreauth.Auth, hints
}
models = applyExcludedModels(models, excluded)
models = applyOAuthModelAliasForAuth(cfg, "antigravity", auth.AuthKind(), auth.Attributes, models)
- models = s.appendPluginModels("antigravity", models)
+ models = s.appendPluginModels("antigravity", models, excluded)
models = applyOAuthSettingsForAuth(cfg, "antigravity", auth.AuthKind(), models)
return applyModelPrefixes(models, auth.Prefix, cfg.ForceModelPrefix)
}
diff --git a/sdk/cliproxy/antigravity_models_registration_test.go b/sdk/cliproxy/antigravity_models_registration_test.go
index 87df0742897..d3d01b1006e 100644
--- a/sdk/cliproxy/antigravity_models_registration_test.go
+++ b/sdk/cliproxy/antigravity_models_registration_test.go
@@ -21,6 +21,19 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
)
+// waitForAntigravityClientModel polls until the registry publishes modelID
+// for clientID, bounded so a missing publication fails instead of hanging.
+func waitForAntigravityClientModel(t *testing.T, clientID, modelID string) {
+ t.Helper()
+ deadline := time.Now().Add(2 * time.Second)
+ for !GlobalModelRegistry().ClientSupportsModel(clientID, modelID) {
+ if time.Now().After(deadline) {
+ t.Fatalf("client %s model %s was not published within 2s", clientID, modelID)
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+}
+
func TestAntigravityDynamicRegistrationLifecycle(t *testing.T) {
resetAntigravityCapabilityCache()
t.Cleanup(resetAntigravityCapabilityCache)
@@ -648,6 +661,11 @@ func TestAntigravityBlockedHintPersistenceDoesNotBlockPublicationOrOtherAccounts
if r.Header.Get("Authorization") == "Bearer token-slow-b" {
_, _ = w.Write([]byte(`{"models":{"gemini-3-flash":{}}}`))
authBProbed <- struct{}{}
+ // Hold the response briefly after the server-side signal so the client
+ // deterministically receives and publishes it well after authBProbed
+ // fires; waiting only on that signal would race the probe result
+ // against later authB updates.
+ time.Sleep(200 * time.Millisecond)
return
}
model := authAModel.Load().(string)
@@ -751,6 +769,11 @@ func TestAntigravityBlockedHintPersistenceDoesNotBlockPublicationOrOtherAccounts
svc.WaitAntigravityProbes()
t.Fatal("stalled persistence on authA blocked subsequent probe for authA")
}
+ // authBProbed only proves the handler wrote the response; the probe may not
+ // have parsed it or published the catalog yet. Wait for actual registry
+ // publication before any authB update below, otherwise that update changes
+ // authB's model settings and correctly discards the late probe result.
+ waitForAntigravityClientModel(t, authB.ID, "gemini-3-flash")
// Wait for authA's refreshed persistence to preempt authA's first Save and enter Save
// (which happens after applyAntigravityModelHints published authA's updated catalog).
select {
diff --git a/sdk/cliproxy/auth/antigravity_credits.go b/sdk/cliproxy/auth/antigravity_credits.go
index 06e5d285e8d..29524e2a013 100644
--- a/sdk/cliproxy/auth/antigravity_credits.go
+++ b/sdk/cliproxy/auth/antigravity_credits.go
@@ -26,6 +26,10 @@ func AntigravityCreditsRequested(ctx context.Context) bool {
return v
}
+// AntigravityCreditsHintRefreshInterval defines how long a cached AI credits hint
+// remains fresh before it should be re-validated.
+const AntigravityCreditsHintRefreshInterval = 10 * time.Minute
+
// AntigravityCreditsHint stores the latest known AI credits state for one auth.
type AntigravityCreditsHint struct {
Known bool
@@ -36,6 +40,12 @@ type AntigravityCreditsHint struct {
UpdatedAt time.Time
}
+// IsAntigravityCreditsHintFresh reports whether a known credits hint is still
+// within AntigravityCreditsHintRefreshInterval of now.
+func IsAntigravityCreditsHintFresh(hint AntigravityCreditsHint, now time.Time) bool {
+ return hint.Known && !hint.UpdatedAt.IsZero() && now.Sub(hint.UpdatedAt) < AntigravityCreditsHintRefreshInterval
+}
+
var antigravityCreditsHintByAuth sync.Map
// SetAntigravityCreditsHint updates the latest known AI credits state for an auth.
diff --git a/sdk/cliproxy/auth/antigravity_credits_test.go b/sdk/cliproxy/auth/antigravity_credits_test.go
index 5e352b77b02..94a9448f078 100644
--- a/sdk/cliproxy/auth/antigravity_credits_test.go
+++ b/sdk/cliproxy/auth/antigravity_credits_test.go
@@ -16,13 +16,22 @@ import (
)
type antigravityCreditsFallbackExecutor struct {
- streamCreditsRequested []bool
+ executeCreditsRequested []bool
+ streamCreditsRequested []bool
}
func (e *antigravityCreditsFallbackExecutor) Identifier() string { return "antigravity" }
-func (e *antigravityCreditsFallbackExecutor) Execute(context.Context, *Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
- return cliproxyexecutor.Response{}, &Error{HTTPStatus: http.StatusNotImplemented, Message: "Execute not implemented"}
+func (e *antigravityCreditsFallbackExecutor) Execute(ctx context.Context, _ *Auth, req cliproxyexecutor.Request, _ cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
+ creditsRequested := AntigravityCreditsRequested(ctx)
+ e.executeCreditsRequested = append(e.executeCreditsRequested, creditsRequested)
+ if !creditsRequested {
+ return cliproxyexecutor.Response{}, &Error{HTTPStatus: http.StatusTooManyRequests, Message: "quota exhausted"}
+ }
+ return cliproxyexecutor.Response{
+ Payload: []byte("credits fallback"),
+ Headers: http.Header{"X-Credits": {req.Model}},
+ }, nil
}
func (e *antigravityCreditsFallbackExecutor) ExecuteStream(ctx context.Context, _ *Auth, req cliproxyexecutor.Request, _ cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {
@@ -266,3 +275,101 @@ func TestIsAuthBlockedForModel_KeepsGeminiBlockedWithoutCreditsBypass(t *testing
t.Fatalf("expected gemini model to remain blocked, got blocked=%v reason=%v", blocked, reason)
}
}
+
+func TestManagerExecuteAndStream_ExpiredNegativeCreditsHintRecoversDuringModelCooldown(t *testing.T) {
+ for _, stream := range []bool{false, true} {
+ name := "execute"
+ if stream {
+ name = "execute_stream"
+ }
+ t.Run(name, func(t *testing.T) {
+ authID := "ag-expired-negative-hint-" + name
+ const model = "claude-sonnet-4-6"
+ executor := &antigravityCreditsFallbackExecutor{}
+ manager := NewManager(nil, nil, nil)
+ manager.SetConfig(&internalconfig.Config{
+ QuotaExceeded: internalconfig.QuotaExceeded{AntigravityCredits: true},
+ })
+ manager.RegisterExecutor(executor)
+ registry.GetGlobalRegistry().RegisterClient(authID, "antigravity", []*registry.ModelInfo{{ID: model}})
+ t.Cleanup(func() {
+ registry.GetGlobalRegistry().UnregisterClient(authID)
+ SetAntigravityCreditsHint(authID, AntigravityCreditsHint{})
+ })
+
+ cooldownUntil := time.Now().Add(30 * time.Minute)
+ if _, errRegister := manager.Register(context.Background(), &Auth{
+ ID: authID,
+ Provider: "antigravity",
+ ModelStates: map[string]*ModelState{
+ model: {
+ Unavailable: true,
+ NextRetryAfter: cooldownUntil,
+ Quota: QuotaState{
+ Exceeded: true,
+ NextRecoverAt: cooldownUntil,
+ },
+ },
+ },
+ }); errRegister != nil {
+ t.Fatalf("register auth: %v", errRegister)
+ }
+
+ // Fresh negative hint must still suppress credits fallback while model is in cooldown.
+ SetAntigravityCreditsHint(authID, AntigravityCreditsHint{
+ Known: true,
+ Available: false,
+ UpdatedAt: time.Now(),
+ })
+ if stream {
+ if _, errStream := manager.ExecuteStream(context.Background(), []string{"antigravity"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{}); errStream == nil {
+ t.Fatal("ExecuteStream() succeeded with fresh negative credits hint, want cooldown error")
+ }
+ } else {
+ if _, errExec := manager.Execute(context.Background(), []string{"antigravity"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{}); errExec == nil {
+ t.Fatal("Execute() succeeded with fresh negative credits hint, want cooldown error")
+ }
+ }
+ if len(executor.executeCreditsRequested) != 0 || len(executor.streamCreditsRequested) != 0 {
+ t.Fatalf("executor calls during fresh negative hint = (%d, %d), want (0, 0)", len(executor.executeCreditsRequested), len(executor.streamCreditsRequested))
+ }
+
+ // Once the negative hint expires, execution must enter credits fallback even while model cooldown is active.
+ SetAntigravityCreditsHint(authID, AntigravityCreditsHint{
+ Known: true,
+ Available: false,
+ UpdatedAt: time.Now().Add(-AntigravityCreditsHintRefreshInterval - time.Second),
+ })
+ if stream {
+ streamResult, errStream := manager.ExecuteStream(context.Background(), []string{"antigravity"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{})
+ if errStream != nil {
+ t.Fatalf("ExecuteStream() after negative hint expiry error = %v", errStream)
+ }
+ var streamed []byte
+ for chunk := range streamResult.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("unexpected stream chunk error: %v", chunk.Err)
+ }
+ streamed = append(streamed, chunk.Payload...)
+ }
+ if string(streamed) != "credits fallback" {
+ t.Fatalf("ExecuteStream() payload = %q, want %q", string(streamed), "credits fallback")
+ }
+ if len(executor.streamCreditsRequested) != 1 || !executor.streamCreditsRequested[0] {
+ t.Fatalf("streamCreditsRequested = %v, want [true]", executor.streamCreditsRequested)
+ }
+ } else {
+ resp, errExec := manager.Execute(context.Background(), []string{"antigravity"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{})
+ if errExec != nil {
+ t.Fatalf("Execute() after negative hint expiry error = %v", errExec)
+ }
+ if string(resp.Payload) != "credits fallback" {
+ t.Fatalf("Execute() payload = %q, want %q", string(resp.Payload), "credits fallback")
+ }
+ if len(executor.executeCreditsRequested) != 1 || !executor.executeCreditsRequested[0] {
+ t.Fatalf("executeCreditsRequested = %v, want [true]", executor.executeCreditsRequested)
+ }
+ }
+ })
+ }
+}
diff --git a/sdk/cliproxy/auth/auto_refresh_loop.go b/sdk/cliproxy/auth/auto_refresh_loop.go
index 9a3927f3490..ab54ba48356 100644
--- a/sdk/cliproxy/auth/auto_refresh_loop.go
+++ b/sdk/cliproxy/auth/auto_refresh_loop.go
@@ -400,6 +400,23 @@ func nextRefreshCheckAt(now time.Time, auth *Auth, interval time.Duration) (time
return time.Time{}, false
}
+ // Quota-exhausted Devin credentials revalidate upstream status so early
+ // restoration can be detected (issue #6690); their tokens never expire,
+ // so the generic expiry/lead schedule below would drop them entirely.
+ // A custom RefreshEvaluator keeps precedence (matching shouldRefresh),
+ // otherwise an evaluator veto would reschedule an immediately-due entry.
+ if _, hasEvaluator := auth.Runtime.(RefreshEvaluator); hasEvaluator {
+ // Fall through to the evaluator branch below.
+ } else if next, due := devinQuotaRevalidateAt(auth, now); due {
+ if !auth.NextRefreshAfter.IsZero() && auth.NextRefreshAfter.After(next) {
+ next = auth.NextRefreshAfter
+ }
+ if !next.After(now) {
+ return now, true
+ }
+ return next, true
+ }
+
if !auth.NextRefreshAfter.IsZero() && now.Before(auth.NextRefreshAfter) {
return auth.NextRefreshAfter, true
}
diff --git a/sdk/cliproxy/auth/classification.go b/sdk/cliproxy/auth/classification.go
index f8de9c8c9b9..ced4d0bf8fb 100644
--- a/sdk/cliproxy/auth/classification.go
+++ b/sdk/cliproxy/auth/classification.go
@@ -125,6 +125,9 @@ func authHasOAuthMetadata(auth *Auth) bool {
if siwc, ok := auth.Metadata["siwc"].(map[string]any); ok && len(siwc) > 0 {
return true
}
+ if sa, ok := auth.Metadata["service_account"].(map[string]any); ok && len(sa) > 0 {
+ return true
+ }
return false
}
diff --git a/sdk/cliproxy/auth/codex_siwc_bind.go b/sdk/cliproxy/auth/codex_siwc_bind.go
new file mode 100644
index 00000000000..4180f485c14
--- /dev/null
+++ b/sdk/cliproxy/auth/codex_siwc_bind.go
@@ -0,0 +1,223 @@
+package auth
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/codex"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
+)
+
+// BindCodexSIWC explicitly replaces the SIWC partition of an existing file-backed
+// Codex registration. Unlike ordinary persistence, a failed write never publishes
+// credentials. expectedEpoch must be the nonzero epoch captured before OAuth.
+func (m *Manager) BindCodexSIWC(ctx context.Context, id string, expectedEpoch uint64, tokenData *codex.CodexSIWCTokenData) (*Auth, error) {
+ if m == nil || strings.TrimSpace(id) == "" || expectedEpoch == 0 || tokenData == nil || strings.TrimSpace(tokenData.AccessToken) == "" || strings.TrimSpace(tokenData.RefreshToken) == "" || strings.TrimSpace(tokenData.ClientID) == "" {
+ return nil, fmt.Errorf("bind SIWC: invalid target or token data")
+ }
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ release, err := m.lockAuthMutationContext(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ defer release()
+ m.mu.RLock()
+ base := m.auths[id].Clone()
+ m.mu.RUnlock()
+ if base == nil || base.RegistrationEpoch != expectedEpoch || !strings.EqualFold(strings.TrimSpace(base.Provider), "codex") || !codexBindingHasOuter(base.Metadata) {
+ return nil, fmt.Errorf("bind SIWC: target is missing, changed, or not a Codex OAuth credential")
+ }
+ path, err := m.codexBindingPath(base)
+ if err != nil {
+ return nil, err
+ }
+ var committed *Auth
+ cooldownChanged := false
+ err = codex.WithFileLock(path, func() error {
+ if errCtx := ctx.Err(); errCtx != nil {
+ return errCtx
+ }
+ checkedPath, errPath := m.codexBindingPath(base)
+ if errPath != nil || checkedPath != path {
+ return fmt.Errorf("bind SIWC: backing file changed")
+ }
+ info, errStat := os.Lstat(path)
+ if errStat != nil || !info.Mode().IsRegular() {
+ return fmt.Errorf("bind SIWC: backing file is not a regular file")
+ }
+ data, errRead := os.ReadFile(path)
+ if errRead != nil {
+ return fmt.Errorf("bind SIWC: read backing file: %w", errRead)
+ }
+ var disk map[string]any
+ if json.Unmarshal(data, &disk) != nil || !codexBindingHasOuter(disk) {
+ return fmt.Errorf("bind SIWC: invalid Codex OAuth file")
+ }
+ kind, _ := disk["type"].(string)
+ if !strings.EqualFold(strings.TrimSpace(kind), "codex") {
+ return fmt.Errorf("bind SIWC: backing file type changed")
+ }
+ if account, _ := base.Metadata["account_id"].(string); account != "" && disk["account_id"] != account {
+ return fmt.Errorf("bind SIWC: backing account changed")
+ }
+ // Keep arbitrary disk values (including large JSON numbers) losslessly. Only
+ // the two explicitly bound fields are replaced; no metadata normalization runs.
+ var raw map[string]json.RawMessage
+ if errJSON := json.Unmarshal(data, &raw); errJSON != nil {
+ return errJSON
+ }
+ replacement := tokenData.ToMap()
+ siwc, errJSON := json.Marshal(replacement)
+ if errJSON != nil {
+ return errJSON
+ }
+ out := make(map[string]any, len(raw)+2)
+ for key, value := range raw {
+ out[key] = value
+ }
+ out["siwc"] = json.RawMessage(siwc)
+ out["use_siwc"] = true
+ latestInfo, errStat := os.Lstat(path)
+ if errStat != nil || !os.SameFile(info, latestInfo) {
+ return fmt.Errorf("bind SIWC: backing file changed during binding")
+ }
+ if errCtx := ctx.Err(); errCtx != nil {
+ return errCtx
+ }
+ if errWrite := codex.WriteJSONFileAtomic(path, out); errWrite != nil {
+ return fmt.Errorf("bind SIWC: persist binding: %w", errWrite)
+ }
+ disk["siwc"] = replacement
+ disk["use_siwc"] = true
+ // The mutation gate fences Delete, reload, and refresh publication. Clone
+ // only now so counters and request-time runtime changes during I/O survive.
+ m.mu.Lock()
+ current := m.auths[id]
+ if current == nil || current.RegistrationEpoch != expectedEpoch {
+ m.mu.Unlock()
+ return fmt.Errorf("bind SIWC: registration changed before publication")
+ }
+ updated := current.Clone()
+ updated.Metadata = disk
+ updated.Storage = nil
+ updated.CodexRefreshPatch = nil
+ // The file owns this toggle; stale attribute overrides must not mask it
+ // or prevent a later file edit from disabling SIWC.
+ delete(updated.Attributes, "use_siwc")
+ if disabled, ok := disk["disabled"].(bool); ok {
+ updated.Disabled = disabled
+ }
+ if updated.Disabled {
+ updated.Status = StatusDisabled
+ } else if updated.Status == StatusDisabled {
+ updated.Status = StatusActive
+ }
+ now := time.Now()
+ if !updated.Disabled {
+ updated.RejectedAccessToken = ""
+ if hasUnauthorizedAuthFailure(current) || (updated.LastError != nil && isUnauthorizedError(updated.LastError)) {
+ updated.Unavailable = false
+ updated.LastError = nil
+ updated.StatusMessage = ""
+ updated.Status = StatusActive
+ updated.NextRetryAfter = time.Time{}
+ cooldownChanged = true
+ }
+ clearUnauthorizedModelStates(updated, now)
+ // The helper can clear errors while preserving a model quota block.
+ if len(updated.ModelStates) > 0 {
+ cooldownChanged = true
+ }
+ if current.Quota.Exceeded && (current.Quota.NextRecoverAt.IsZero() || current.Quota.NextRecoverAt.After(now)) {
+ updated.Quota = current.Quota.Clone()
+ updated.Unavailable = current.Unavailable
+ updated.NextRetryAfter = current.NextRetryAfter
+ updated.Status = current.Status
+ }
+ }
+ updated.Generation++
+ updated.CredentialVersion = max(current.CredentialVersion, 1) + 1
+ updated.UpdatedAt = now
+ updated.ClearFileSnapshot()
+ m.auths[id] = updated
+ m.notifyAuthChangeLocked(id)
+ committed = updated.Clone()
+ m.mu.Unlock()
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ release()
+ m.finishAuthUpdate(ctx, committed, cooldownChanged)
+ return committed, nil
+}
+
+func codexBindingHasOuter(metadata map[string]any) bool {
+ access, _ := metadata["access_token"].(string)
+ refresh, _ := metadata["refresh_token"].(string)
+ return strings.TrimSpace(access) != "" && strings.TrimSpace(refresh) != ""
+}
+
+// Resolve confinement again at commit, not just when the management flow starts.
+func (m *Manager) codexBindingPath(auth *Auth) (string, error) {
+ source := strings.ToLower(authAttribute(auth, AttributeSource))
+ if auth == nil || auth.AuthSourceKind() != AuthSourceFile || source == AuthSourceConfig || strings.HasPrefix(source, AuthSourceConfig+":") || auth.AuthKind() != AuthKindOAuth {
+ return "", fmt.Errorf("bind SIWC: target is not a file-backed OAuth credential")
+ }
+ cfg, _ := m.runtimeConfig.Load().(*config.Config)
+ if cfg == nil || strings.TrimSpace(cfg.AuthDir) == "" {
+ return "", fmt.Errorf("bind SIWC: auth directory is not configured")
+ }
+ authDir, err := util.ResolveAuthDir(cfg.AuthDir)
+ if err != nil {
+ return "", fmt.Errorf("bind SIWC: resolve auth directory: %w", err)
+ }
+ root, err := filepath.EvalSymlinks(authDir)
+ if err != nil {
+ return "", fmt.Errorf("bind SIWC: invalid auth directory")
+ }
+ root, err = filepath.Abs(root)
+ if err != nil {
+ return "", err
+ }
+ path := strings.TrimSpace(auth.Attributes["path"])
+ if path == "" {
+ path = strings.TrimSpace(auth.Attributes["source"])
+ }
+ if path == "" {
+ path = strings.TrimSpace(auth.FileName)
+ }
+ if path == "" {
+ return "", fmt.Errorf("bind SIWC: target is not file-backed")
+ }
+ if !filepath.IsAbs(path) {
+ path = filepath.Join(root, path)
+ }
+ resolved, err := filepath.EvalSymlinks(path)
+ if err != nil {
+ return "", fmt.Errorf("bind SIWC: backing file is missing or inaccessible")
+ }
+ resolved, err = filepath.Abs(resolved)
+ if err != nil {
+ return "", err
+ }
+ relative, err := filepath.Rel(root, resolved)
+ if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
+ return "", fmt.Errorf("bind SIWC: backing file is outside auth directory")
+ }
+ // Reject final-component symlinks rather than replacing their targets.
+ info, err := os.Lstat(path)
+ if err != nil || !info.Mode().IsRegular() {
+ return "", fmt.Errorf("bind SIWC: backing file is not regular")
+ }
+ return resolved, nil
+}
diff --git a/sdk/cliproxy/auth/codex_siwc_bind_test.go b/sdk/cliproxy/auth/codex_siwc_bind_test.go
new file mode 100644
index 00000000000..522fc7fab20
--- /dev/null
+++ b/sdk/cliproxy/auth/codex_siwc_bind_test.go
@@ -0,0 +1,355 @@
+package auth
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/codex"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+)
+
+type siwcBindHook struct {
+ NoopHook
+ updates int
+}
+
+func (h *siwcBindHook) OnAuthUpdated(context.Context, *Auth) { h.updates++ }
+
+func setupSIWCBinding(t *testing.T, name string) (*Manager, *Auth, string, *siwcBindHook) {
+ t.Helper()
+ dir := t.TempDir()
+ path := filepath.Join(dir, name)
+ metadata := map[string]any{"type": "codex", "access_token": "outer", "refresh_token": "outer-refresh", "account_id": "account", "notes": "old", "disabled": false, "siwc": map[string]any{"access_token": "old-siwc", "refresh_token": "old-siwc-refresh", "client_id": "client"}}
+ data, errMarshal := json.Marshal(metadata)
+ if errMarshal != nil {
+ t.Fatal(errMarshal)
+ }
+ if err := os.WriteFile(path, data, 0600); err != nil {
+ t.Fatal(err)
+ }
+ hook := &siwcBindHook{}
+ m := NewManager(nil, nil, hook)
+ m.runtimeConfig.Store(&config.Config{AuthDir: dir})
+ a, err := m.Register(context.Background(), &Auth{ID: "binding", Provider: "codex", FileName: name, Attributes: map[string]string{"path": path}, Metadata: metadata, Status: StatusActive, ProxyURL: "http://runtime-proxy", Prefix: "runtime-prefix", Success: 7})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return m, a, path, hook
+}
+func bindingTokens(access string) *codex.CodexSIWCTokenData {
+ return &codex.CodexSIWCTokenData{AccessToken: access, RefreshToken: access + "-refresh", ClientID: "client"}
+}
+func readBindingDisk(t *testing.T, path string) map[string]any {
+ t.Helper()
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var out map[string]any
+ if err = json.Unmarshal(data, &out); err != nil {
+ t.Fatal(err)
+ }
+ return out
+}
+
+func TestBindCodexSIWCPreservesLatestDiskAndRebinds(t *testing.T) {
+ m, base, path, hook := setupSIWCBinding(t, "auth.json")
+ latest := readBindingDisk(t, path)
+ latest["access_token"] = "latest-outer"
+ latest["refresh_token"] = "latest-refresh"
+ latest["notes"] = "latest notes"
+ latest["disabled"] = true
+ latest["unknown"] = json.RawMessage(`9007199254740993`)
+ if err := codex.WriteJSONFileAtomic(path, latest); err != nil {
+ t.Fatal(err)
+ }
+ for _, access := range []string{"bound", "rebound"} {
+ got, err := m.BindCodexSIWC(context.Background(), base.ID, base.RegistrationEpoch, bindingTokens(access))
+ if err != nil {
+ t.Fatal(err)
+ }
+ disk := readBindingDisk(t, path)
+ if disk["access_token"] != "latest-outer" || disk["refresh_token"] != "latest-refresh" || disk["notes"] != "latest notes" || disk["disabled"] != true || disk["use_siwc"] != true {
+ t.Fatal("binding overwrote unrelated disk fields")
+ }
+ if disk["siwc"].(map[string]any)["access_token"] != access {
+ t.Fatal("binding did not replace SIWC")
+ }
+ if got.RegistrationEpoch != base.RegistrationEpoch || got.Success != base.Success || got.ProxyURL != base.ProxyURL || got.Prefix != base.Prefix || !got.Disabled || got.Status != StatusDisabled {
+ t.Fatal("binding lost runtime settings or disk disabled state")
+ }
+ if got.CredentialVersion <= base.CredentialVersion || got.Generation <= base.Generation {
+ t.Fatal("binding did not advance versions")
+ }
+ raw, _ := os.ReadFile(path)
+ if !strings.Contains(string(raw), "9007199254740993") {
+ t.Fatal("binding rounded unrelated JSON number")
+ }
+ }
+ if hook.updates != 2 {
+ t.Fatalf("updates = %d", hook.updates)
+ }
+}
+
+func TestBindCodexSIWCFailureDoesNotPublish(t *testing.T) {
+ for _, failure := range []string{"missing", "invalid", "type", "outer", "account", "outside", "write"} {
+ t.Run(failure, func(t *testing.T) {
+ name := "auth.json"
+ // The credential and lock names fit NAME_MAX; atomic-write temporary
+ // names do not. This fails writes deterministically even when run as root.
+ if failure == "write" {
+ name = strings.Repeat("a", 244) + ".json"
+ }
+ m, base, path, hook := setupSIWCBinding(t, name)
+ switch failure {
+ case "missing":
+ if err := os.Remove(path); err != nil {
+ t.Fatal(err)
+ }
+ case "invalid":
+ if err := os.WriteFile(path, []byte("{invalid"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ case "type", "outer", "account":
+ disk := readBindingDisk(t, path)
+ if failure == "type" {
+ disk["type"] = "claude"
+ }
+ if failure == "outer" {
+ delete(disk, "refresh_token")
+ }
+ if failure == "account" {
+ disk["account_id"] = "other"
+ }
+ if err := codex.WriteJSONFileAtomic(path, disk); err != nil {
+ t.Fatal(err)
+ }
+ case "outside":
+ m.runtimeConfig.Store(&config.Config{AuthDir: t.TempDir()})
+ }
+ _, err := m.BindCodexSIWC(context.Background(), base.ID, base.RegistrationEpoch, bindingTokens("bound"))
+ if err == nil {
+ t.Fatal("binding unexpectedly succeeded")
+ }
+ got, _ := m.GetByID(base.ID)
+ if !reflect.DeepEqual(got, base) || hook.updates != 0 {
+ t.Fatal("failed binding published state")
+ }
+ })
+ }
+}
+
+func TestBindCodexSIWCRejectsDeletedAndReimportedEpoch(t *testing.T) {
+ m, base, path, _ := setupSIWCBinding(t, "auth.json")
+ m.Remove(context.Background(), base.ID)
+ if _, err := m.BindCodexSIWC(context.Background(), base.ID, base.RegistrationEpoch, bindingTokens("bound")); err == nil {
+ t.Fatal("deleted registration accepted")
+ }
+ imported, err := m.Register(context.Background(), base.Clone())
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, epoch := range []uint64{0, base.RegistrationEpoch, imported.RegistrationEpoch + 1} {
+ if _, err = m.BindCodexSIWC(context.Background(), base.ID, epoch, bindingTokens("bound")); err == nil {
+ t.Fatal("incorrect epoch accepted")
+ }
+ }
+ if readBindingDisk(t, path)["use_siwc"] == true {
+ t.Fatal("stale binding wrote disk")
+ }
+}
+
+func TestBindCodexSIWCFencesInflightRefreshAndWatcher(t *testing.T) {
+ m, base, path, _ := setupSIWCBinding(t, "auth.json")
+ m.SetStore(&bindingFileStore{path: path})
+ oldBytes, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ watcher := base.Clone()
+ watcher.Generation, watcher.RegistrationEpoch = 0, 0
+ watcher.BindFileSnapshot(path, oldBytes)
+ refreshed := base.Clone()
+ refreshed.Metadata["access_token"] = "refreshed-outer"
+ refreshed.Metadata["siwc"] = bindingTokens("stale-refreshed-siwc").ToMap()
+ refreshed.CodexRefreshPatch = codex.NewCredentialRefreshPatch(base.Metadata, refreshed.Metadata, true, true)
+ bound, err := m.BindCodexSIWC(context.Background(), base.ID, base.RegistrationEpoch, bindingTokens("bound"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ // An earlier refresh finishes after explicit binding. Its independent outer
+ // partition may commit, but it must not restore the previous SIWC identity.
+ got, err := m.UpdateRefreshedAuth(context.Background(), base, refreshed)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Metadata["siwc"].(map[string]any)["access_token"] != "bound" || got.Metadata["use_siwc"] != true {
+ t.Fatal("refresh overwrote binding")
+ }
+ disk := readBindingDisk(t, path)
+ if disk["access_token"] != "refreshed-outer" || disk["siwc"].(map[string]any)["access_token"] != "bound" {
+ t.Fatal("refresh failed to preserve bound partition on disk")
+ }
+ got, err = m.Update(context.Background(), watcher)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Metadata["siwc"].(map[string]any)["access_token"] != "bound" || got.Generation < bound.Generation {
+ t.Fatal("stale watcher overwrote binding")
+ }
+}
+
+// bindingFileStore exercises the production scoped refresh persistence path.
+type bindingFileStore struct{ path string }
+
+func (s *bindingFileStore) List(context.Context) ([]*Auth, error) { return nil, nil }
+func (s *bindingFileStore) Delete(context.Context, string) error { return os.Remove(s.path) }
+func (s *bindingFileStore) Save(ctx context.Context, a *Auth) (string, error) {
+ _, err := PersistCodexAuthFile(ctx, s.path, a)
+ return s.path, err
+}
+
+func TestBindCodexSIWCConcurrentFileRefresh(t *testing.T) {
+ m, base, path, _ := setupSIWCBinding(t, "auth.json")
+ locked := make(chan struct{})
+ finishRefresh := make(chan struct{})
+ refreshDone := make(chan error, 1)
+ go func() {
+ refreshDone <- codex.WithFileLock(path, func() error {
+ close(locked)
+ <-finishRefresh
+ disk := map[string]any{"type": "codex", "account_id": "account", "access_token": "concurrent-outer", "refresh_token": "concurrent-refresh", "notes": "concurrent notes", "disabled": true}
+ return codex.WriteJSONFileAtomic(path, disk)
+ })
+ }()
+ <-locked
+ bindDone := make(chan error, 1)
+ go func() {
+ _, err := m.BindCodexSIWC(context.Background(), base.ID, base.RegistrationEpoch, bindingTokens("bound"))
+ bindDone <- err
+ }()
+ close(finishRefresh)
+ if err := <-refreshDone; err != nil {
+ t.Fatal(err)
+ }
+ if err := <-bindDone; err != nil {
+ t.Fatal(err)
+ }
+ got, _ := m.GetByID(base.ID)
+ if got.Metadata["access_token"] != "concurrent-outer" || got.Metadata["notes"] != "concurrent notes" || !got.Disabled {
+ t.Fatal("binding did not re-read locked disk")
+ }
+ disk := readBindingDisk(t, path)
+ if disk["siwc"].(map[string]any)["access_token"] != "bound" || disk["access_token"] != "concurrent-outer" {
+ t.Fatal("binding lost concurrent refresh")
+ }
+}
+
+func TestBindCodexSIWCRejectsNonFileSources(t *testing.T) {
+ for _, attrs := range []map[string]string{
+ {"source": "config:codex[test]"}, {"source": "config"}, {"runtime_only": "true"},
+ {"source_backend": "memory"}, {"source_backend": "postgres"}, {"source_backend": "git"},
+ {"source_backend": "objectstore"}, {"auth_kind": "apikey"},
+ } {
+ t.Run(fmt.Sprint(attrs), func(t *testing.T) {
+ m, base, path, hook := setupSIWCBinding(t, "auth.json")
+ m.mu.Lock()
+ for k, v := range attrs {
+ m.auths[base.ID].Attributes[k] = v
+ }
+ m.mu.Unlock()
+ before, _ := os.ReadFile(path)
+ if _, err := m.BindCodexSIWC(context.Background(), base.ID, base.RegistrationEpoch, bindingTokens("bound")); err == nil {
+ t.Fatal("non-file OAuth target accepted")
+ }
+ after, _ := os.ReadFile(path)
+ if !reflect.DeepEqual(before, after) || hook.updates != 0 {
+ t.Fatal("rejection changed state")
+ }
+ })
+ }
+}
+
+func TestBindCodexSIWCTildeAndAttributeOverride(t *testing.T) {
+ m, base, path, _ := setupSIWCBinding(t, "auth.json")
+ t.Setenv("HOME", filepath.Dir(path))
+ m.runtimeConfig.Store(&config.Config{AuthDir: "~"})
+ m.mu.Lock()
+ m.auths[base.ID].Attributes["use_siwc"] = "false"
+ m.mu.Unlock()
+ got, err := m.BindCodexSIWC(context.Background(), base.ID, base.RegistrationEpoch, bindingTokens("bound"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !IsCodexUseSIWC(got) {
+ t.Fatal("stale attribute masks binding")
+ }
+ if _, ok := got.Attributes["use_siwc"]; ok {
+ t.Fatal("binding retained permanent override")
+ }
+ got.Metadata["use_siwc"] = false
+ if IsCodexUseSIWC(got) {
+ t.Fatal("file can no longer disable SIWC")
+ }
+}
+
+func TestBindCodexSIWCRecoversUnauthorizedPreservingQuotaAndDisabled(t *testing.T) {
+ for _, mode := range []string{"enabled", "quota", "disabled"} {
+ t.Run(mode, func(t *testing.T) {
+ m, base, path, _ := setupSIWCBinding(t, "auth.json")
+ future := time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC)
+ quota := QuotaState{Exceeded: true, Reason: "credential_quota", NextRecoverAt: future}
+ unauthorized := &Error{HTTPStatus: 401, Code: "unauthorized", Message: "401 unauthorized"}
+ m.mu.Lock()
+ a := m.auths[base.ID]
+ a.RejectedAccessToken = "bound"
+ a.Status, a.Unavailable, a.LastError = StatusError, true, unauthorized
+ a.StatusMessage = "unauthorized"
+ a.ModelStates = map[string]*ModelState{
+ "unauthorized": {Status: StatusError, Unavailable: true, LastError: unauthorized, NextRetryAfter: future},
+ "quota": {Status: StatusError, Unavailable: true, Quota: quota, NextRetryAfter: future},
+ }
+ if mode == "quota" {
+ a.Quota, a.NextRetryAfter = quota, future
+ }
+ if mode == "disabled" {
+ a.Disabled, a.Status = true, StatusDisabled
+ }
+ before := a.Clone()
+ m.mu.Unlock()
+ if mode == "disabled" {
+ disk := readBindingDisk(t, path)
+ disk["disabled"] = true
+ if err := codex.WriteJSONFileAtomic(path, disk); err != nil {
+ t.Fatal(err)
+ }
+ }
+ got, err := m.BindCodexSIWC(context.Background(), base.ID, base.RegistrationEpoch, bindingTokens("bound"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if mode == "disabled" {
+ if !got.Disabled || got.Status != StatusDisabled || !reflect.DeepEqual(got.ModelStates, before.ModelStates) {
+ t.Fatal("disabled state changed")
+ }
+ return
+ }
+ if got.RejectedAccessToken != "" || got.LastError != nil || got.ModelStates["unauthorized"].Unavailable {
+ t.Fatal("stale unauthorized state survived")
+ }
+ if !reflect.DeepEqual(got.ModelStates["quota"], before.ModelStates["quota"]) {
+ t.Fatal("model quota changed")
+ }
+ if mode == "quota" && (!reflect.DeepEqual(got.Quota, before.Quota) || got.NextRetryAfter != future || !got.Unavailable) {
+ t.Fatal("auth quota changed")
+ }
+ })
+ }
+}
diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go
index 678867dad14..6f16017bfef 100644
--- a/sdk/cliproxy/auth/conductor.go
+++ b/sdk/cliproxy/auth/conductor.go
@@ -90,6 +90,12 @@ type Result struct {
CredentialVersion uint64
// RegistrationEpoch captures the registration epoch under which the request was executed.
RegistrationEpoch uint64
+ // ExecutionSeq is the manager-local monotonic sequence assigned when the
+ // execution was dispatched upstream. It orders in-flight executions so a
+ // success dispatched before a cooldown was recorded cannot clear that
+ // cooldown. Zero means unknown (direct MarkResult callers) and is exempt
+ // from the ordering guard.
+ ExecutionSeq uint64
}
// Selector chooses an auth candidate for execution.
@@ -172,12 +178,15 @@ type Manager struct {
selectorMu sync.Mutex
configCooldownMu sync.Mutex
syncSchedulerMu sync.Mutex
- structuralEpoch atomic.Uint64
- syncedVersion atomic.Uint64
- auths map[string]*Auth
- authEpochs map[string]uint64
- authChangeWatchers map[string]map[chan struct{}]struct{}
- scheduler *authScheduler
+ // executionSeq stamps upstream dispatches with a monotonic sequence used
+ // to reject cooldown clears from executions dispatched before the cooldown.
+ executionSeq atomic.Uint64
+ structuralEpoch atomic.Uint64
+ syncedVersion atomic.Uint64
+ auths map[string]*Auth
+ authEpochs map[string]uint64
+ authChangeWatchers map[string]map[chan struct{}]struct{}
+ scheduler *authScheduler
// pluginScheduler runs outside m.mu before falling back to native selection.
pluginScheduler PluginScheduler
// homeRuntimeAuths retains legacy session auth lookups for non-execution callers.
diff --git a/sdk/cliproxy/auth/conductor_cooldown.go b/sdk/cliproxy/auth/conductor_cooldown.go
index 4a97aa56f43..13b10e2ff5b 100644
--- a/sdk/cliproxy/auth/conductor_cooldown.go
+++ b/sdk/cliproxy/auth/conductor_cooldown.go
@@ -387,6 +387,20 @@ func (m *Manager) restoreCooldownRecordLocked(record CooldownStateRecord, now ti
}
state := ensureModelState(auth, model)
+ // Candidate independent layers for a wrapped record: the live shadow, or
+ // — when the live state is still unwrapped — the live state itself (a
+ // delayed store load must never erase a newer independent cooldown that
+ // was recorded after the snapshot was taken). A legacy pre-fix reporting
+ // state (reason "quota" whose own error is the explicit daily/weekly
+ // exhaustion message) is credential-derived and must not be captured;
+ // genuine model-scoped 429s carry different messages and stay eligible.
+ liveShadow := state.credentialQuotaShadow.cloneShallow()
+ if liveShadow == nil && !state.credentialQuotaPropagated && state.Quota.Reason != "credential_quota" &&
+ isModelStateActiveCooldown(state, now) && !isDevinQuotaExhaustionMessage(devinStateErrorText(state)) {
+ liveShadow = state.cloneShallow()
+ }
+ wasLiveWrapped := state.credentialQuotaPropagated || state.Quota.Reason == "credential_quota" ||
+ (auth.Quota.Exceeded && auth.Quota.Reason == "credential_quota" && auth.Quota.NextRecoverAt.After(now))
mergeModelState(state, &ModelState{
Unavailable: true,
Status: StatusError,
@@ -396,6 +410,55 @@ func (m *Manager) restoreCooldownRecordLocked(record CooldownStateRecord, now ti
LastError: cloneError(record.LastError),
UpdatedAt: updatedAt,
})
+ // The persisted independent layer joins the merge newest-wins; a newer
+ // live independent layer (shadow or freshly captured unwrapped state,
+ // e.g. an own failure recorded during a delayed store load) is never
+ // clobbered by the older record. A wrapped record is any record whose
+ // outer reason is the propagated credential quota.
+ isWrappedRecord := record.IndependentState != nil || record.CredentialQuotaPropagated || strings.TrimSpace(quota.Reason) == "credential_quota"
+ if !isWrappedRecord && wasLiveWrapped {
+ // An unwrapped independent record landing beneath a live wrap becomes
+ // the model's independent layer (unless it is itself a legacy
+ // credential-derived exhaustion state); the merged outer fields keep
+ // presenting the propagated credential quota.
+ if !isDevinQuotaExhaustionMessage(devinRecordErrorText(record)) {
+ recordShadow := &ModelState{
+ Unavailable: true,
+ Status: StatusError,
+ StatusMessage: reason,
+ NextRetryAfter: record.NextRetryAfter,
+ Quota: quota,
+ LastError: cloneError(record.LastError),
+ UpdatedAt: updatedAt,
+ }
+ preferred := state.credentialQuotaShadow
+ if preferred == nil || (liveShadow != nil && !liveShadow.UpdatedAt.After(updatedAt)) {
+ preferred = recordShadow
+ } else if liveShadow != nil && liveShadow.UpdatedAt.After(updatedAt) {
+ preferred = liveShadow
+ }
+ state.credentialQuotaShadow = preferred
+ }
+ state.credentialQuotaPropagated = true
+ applyCooldownFields(&state.Quota, QuotaState{
+ Exceeded: true,
+ Reason: "credential_quota",
+ NextRecoverAt: auth.Quota.NextRecoverAt,
+ BackoffLevel: auth.Quota.BackoffLevel,
+ })
+ if state.NextRetryAfter.Before(auth.Quota.NextRecoverAt) {
+ state.NextRetryAfter = auth.Quota.NextRecoverAt
+ }
+ state.Unavailable = true
+ state.Status = StatusError
+ } else if isWrappedRecord {
+ state.credentialQuotaPropagated = true
+ if record.IndependentState != nil && (liveShadow == nil || !liveShadow.UpdatedAt.After(record.IndependentState.UpdatedAt)) {
+ state.credentialQuotaShadow = record.IndependentState.cloneShallow()
+ } else if liveShadow != nil {
+ state.credentialQuotaShadow = liveShadow
+ }
+ }
auth.Generation++
auth.UpdatedAt = updatedAt
updateAggregatedAvailability(auth, now)
@@ -421,10 +484,12 @@ func clearCooldownStateForAuth(auth *Auth, now time.Time) bool {
if state == nil {
continue
}
- if state.Unavailable || !state.NextRetryAfter.IsZero() || state.Quota.Exceeded || !state.Quota.NextRecoverAt.IsZero() {
+ if state.Unavailable || !state.NextRetryAfter.IsZero() || state.Quota.Exceeded || !state.Quota.NextRecoverAt.IsZero() || state.credentialQuotaShadow != nil || state.credentialQuotaPropagated {
state.Unavailable = false
state.NextRetryAfter = time.Time{}
applyCooldownFields(&state.Quota, QuotaState{})
+ state.credentialQuotaShadow = nil
+ state.credentialQuotaPropagated = false
state.UpdatedAt = now
changed = true
}
@@ -662,6 +727,9 @@ func cooldownStateRecordsEqual(a, b []CooldownStateRecord) bool {
}
func cooldownStateRecordEqual(a, b CooldownStateRecord) bool {
+ if a.CredentialQuotaPropagated != b.CredentialQuotaPropagated {
+ return false
+ }
if a.Provider != b.Provider ||
a.AuthID != b.AuthID ||
a.AuthFile != b.AuthFile ||
@@ -673,7 +741,23 @@ func cooldownStateRecordEqual(a, b CooldownStateRecord) bool {
!cooldownQuotaEqual(a.Quota, b.Quota) {
return false
}
- return cooldownErrorEqual(a.LastError, b.LastError)
+ if !cooldownErrorEqual(a.LastError, b.LastError) {
+ return false
+ }
+ return cooldownIndependentStateEqual(a.IndependentState, b.IndependentState)
+}
+
+func cooldownIndependentStateEqual(a, b *ModelState) bool {
+ if a == nil || b == nil {
+ return a == b
+ }
+ return a.Status == b.Status &&
+ a.StatusMessage == b.StatusMessage &&
+ a.Unavailable == b.Unavailable &&
+ a.NextRetryAfter.Equal(b.NextRetryAfter) &&
+ a.UpdatedAt.Equal(b.UpdatedAt) &&
+ cooldownQuotaEqual(a.Quota, b.Quota) &&
+ cooldownErrorEqual(a.LastError, b.LastError)
}
func cooldownQuotaEqual(a, b QuotaState) bool {
@@ -715,7 +799,7 @@ func modelCooldownStateRecord(auth *Auth, model string, state *ModelState, now t
if auth == nil || state == nil || model == "" || !state.Unavailable || state.NextRetryAfter.IsZero() || !state.NextRetryAfter.After(now) {
return CooldownStateRecord{}, false
}
- return CooldownStateRecord{
+ record := CooldownStateRecord{
Provider: strings.TrimSpace(auth.Provider),
AuthID: auth.ID,
AuthFile: cooldownAuthFile(auth),
@@ -726,7 +810,12 @@ func modelCooldownStateRecord(auth *Auth, model string, state *ModelState, now t
Quota: cooldownFieldsOf(state.Quota),
LastError: cloneError(state.LastError),
UpdatedAt: state.UpdatedAt,
- }, true
+ }
+ if state.credentialQuotaShadow != nil {
+ record.IndependentState = state.credentialQuotaShadow.cloneShallow()
+ }
+ record.CredentialQuotaPropagated = state.credentialQuotaPropagated
+ return record, true
}
func cooldownReason(statusMessage string, quota QuotaState, lastErr *Error) string {
@@ -747,6 +836,26 @@ func cooldownReason(statusMessage string, quota QuotaState, lastErr *Error) stri
return ""
}
+// nextExecutionSeq stamps a new upstream dispatch with the next monotonic
+// execution sequence. The stamp travels on the execution's Result so that
+// MarkResult can order the execution against cooldowns recorded meanwhile.
+func (m *Manager) nextExecutionSeq() uint64 {
+ if m == nil {
+ return 0
+ }
+ return m.executionSeq.Add(1)
+}
+
+// currentExecutionSeq returns the latest execution sequence issued so far.
+// Capturing it as the cooldown watermark when a cooldown is recorded marks
+// every execution dispatched up to that moment as predating the cooldown.
+func (m *Manager) currentExecutionSeq() uint64 {
+ if m == nil {
+ return 0
+ }
+ return m.executionSeq.Load()
+}
+
// isStaleExecutionResult reports whether an execution result originated from a superseded
// credential version or an earlier registration epoch than the currently active auth.
func isStaleExecutionResult(result Result, current *Auth) bool {
@@ -778,6 +887,7 @@ func (m *Manager) MarkResult(ctx context.Context, result Result) {
var authSnapshot *Auth
cooldownStateChanged := false
+ scheduleUnauthorizedRefresh := false
now := time.Now()
releaseMutation := m.lockAuthMutation(result.AuthID)
@@ -815,6 +925,22 @@ func (m *Manager) MarkResult(ctx context.Context, result Result) {
}
wasTerminalUnauthorized := hasUnauthorizedAuthFailure(auth)
+ // retainModelCooldown pins a still-live model cooldown against a success
+ // from an execution dispatched before that cooldown was recorded. Such a
+ // success predates the failure and proves nothing about recovery, so the
+ // state transition it normally causes is skipped entirely.
+ retainModelCooldown := modelKey != "" && !wasTerminalUnauthorized && result.ExecutionSeq > 0
+ if retainModelCooldown {
+ if existing := existingModelState(auth, modelKey); existing != nil && isModelStateActiveCooldown(existing, now) &&
+ existing.cooldownRecordedSeq > 0 && result.ExecutionSeq <= existing.cooldownRecordedSeq {
+ modelState = existing
+ } else {
+ retainModelCooldown = false
+ }
+ }
+ retainAuthCooldown := modelKey == "" && !wasTerminalUnauthorized && result.ExecutionSeq > 0 &&
+ isAuthActiveCooldown(auth, now) && auth.cooldownRecordedSeq > 0 && result.ExecutionSeq <= auth.cooldownRecordedSeq
+
if result.Success {
if wasTerminalUnauthorized {
if modelKey != "" {
@@ -824,6 +950,8 @@ func (m *Manager) MarkResult(ctx context.Context, result Result) {
}
} else if auth.Quota.Reason == "credential_quota" && auth.Quota.NextRecoverAt.After(now) {
// Retain active credential-scoped cooldown
+ } else if retainModelCooldown || retainAuthCooldown {
+ // Retain a cooldown recorded after this execution was dispatched.
} else if modelKey != "" {
state := ensureModelState(auth, modelKey)
modelState = state
@@ -846,6 +974,41 @@ func (m *Manager) MarkResult(ctx context.Context, result Result) {
}
state := ensureModelState(auth, modelKey)
modelState = state
+ // Normalize a naturally expired credential-quota wrap before
+ // applying a new result (issue #6690): reinstate the
+ // independent layer (or a clean slate) and drop stale wrap
+ // provenance so a future window snapshots fresh state.
+ if (state.credentialQuotaPropagated || state.Quota.Reason == "credential_quota") &&
+ !(auth.Quota.Exceeded && auth.Quota.Reason == "credential_quota" && auth.Quota.NextRecoverAt.After(now)) {
+ if shadow := state.credentialQuotaShadow; shadow != nil && isModelStateActiveCooldown(shadow, now) {
+ preservedSeq := shadow.cooldownRecordedSeq
+ *state = *shadow.cloneShallow()
+ state.cooldownRecordedSeq = preservedSeq
+ } else {
+ resetModelState(state, now)
+ }
+ }
+ // Two-layer handling (issue #6690): while an auth-level
+ // credential quota wraps the credential, a non-credential
+ // own failure is applied to the independent layer (shadow)
+ // so its deadline/backoff never inherit the propagated
+ // quota; the outer fields are then composed from both
+ // layers. Credential-scoped failures refresh the outer
+ // layer and are never independent.
+ outerState := state
+ savedOuterState := state.cloneShallow()
+ ownLayerPending := !result.CredentialScope &&
+ auth.Quota.Reason == "credential_quota" && auth.Quota.NextRecoverAt.After(now)
+ if ownLayerPending {
+ if state.credentialQuotaShadow != nil {
+ preservedSeq := state.credentialQuotaShadow.cooldownRecordedSeq
+ *state = *state.credentialQuotaShadow.cloneShallow()
+ state.cooldownRecordedSeq = preservedSeq
+ } else {
+ fresh := &ModelState{}
+ *state = *fresh
+ }
+ }
state.Unavailable = true
state.Status = StatusError
state.UpdatedAt = now
@@ -947,9 +1110,59 @@ func (m *Manager) MarkResult(ctx context.Context, result Result) {
NextRecoverAt: next,
BackoffLevel: backoffLevel,
})
+ // Non-credential own failures re-derive their independent layer
+ // below, so a stale shadow is dropped. A credential-scoped
+ // report must not erase an existing independent layer: it only
+ // refreshes the propagated outer layer.
+ if !result.CredentialScope {
+ state.credentialQuotaShadow = nil
+ } else if !state.credentialQuotaPropagated && savedOuterState.Quota.Reason != "credential_quota" {
+ // First wrap of the reporting model: preserve any live prior
+ // own block (including an ordinary model-scoped 429, which
+ // quota restoration does not supersede). An outer
+ // credential_quota label instead proves a prior wrap whose
+ // state must never be snapshotted as independence.
+ priorIndependent := prevModelRetryAfter.After(now) && !isDevinQuotaExhaustionMessage(devinStateErrorText(savedOuterState))
+ if priorIndependent {
+ preserved := savedOuterState.cloneShallow()
+ preserved.NextRetryAfter = prevModelRetryAfter
+ preserved.Unavailable = true
+ preserved.Status = StatusError
+ state.credentialQuotaShadow = preserved
+ } else {
+ state.credentialQuotaShadow = nil
+ }
+ }
if result.CredentialScope && !disableCooling {
for _, otherState := range auth.ModelStates {
if otherState != nil && otherState != state {
+ // Normalize a naturally expired wrap first (issue #6690) so a
+ // stale marker cannot suppress snapshotting a fresh block.
+ if (otherState.credentialQuotaPropagated || otherState.Quota.Reason == "credential_quota") &&
+ !(auth.Quota.Exceeded && auth.Quota.Reason == "credential_quota" && auth.Quota.NextRecoverAt.After(now)) {
+ if shadow := otherState.credentialQuotaShadow; shadow != nil && isModelStateActiveCooldown(shadow, now) {
+ preservedSeq := shadow.cooldownRecordedSeq
+ *otherState = *shadow.cloneShallow()
+ otherState.cooldownRecordedSeq = preservedSeq
+ } else {
+ resetModelState(otherState, now)
+ }
+ }
+ // On first wrap, preserve the sibling's independent layer
+ // beneath the propagated credential quota (issue #6690):
+ // a live own block is snapshotted; healthy/propagated-only
+ // states get none. The marker keeps repeated propagation
+ // from mistaking its own earlier layer for independence.
+ alreadyPropagated := otherState.credentialQuotaPropagated ||
+ otherState.Quota.Reason == "credential_quota"
+ if !alreadyPropagated {
+ if isModelStateActiveCooldown(otherState, now) && !isDevinQuotaExhaustionMessage(devinStateErrorText(otherState)) {
+ otherState.credentialQuotaShadow = otherState.cloneShallow()
+ } else {
+ otherState.credentialQuotaShadow = nil
+ }
+ }
+ otherState.credentialQuotaPropagated = true
otherState.Unavailable = true
otherState.Status = StatusError
otherQuotaNext := credentialNext
@@ -983,6 +1196,10 @@ func (m *Manager) MarkResult(ctx context.Context, result Result) {
auth.Quota.NextRecoverAt = authNext
auth.Quota.BackoffLevel = backoffLevel
auth.NextRetryAfter = authNext
+ // The reporting model is now part of the wrap too: mark it
+ // so a later credential report from another model cannot
+ // snapshot this credential-derived state as independent.
+ state.credentialQuotaPropagated = true
}
}
case 408, 500, 502, 503, 504, 520, 521, 522, 523, 524, 525, 526:
@@ -1008,6 +1225,56 @@ func (m *Manager) MarkResult(ctx context.Context, result Result) {
if !state.NextRetryAfter.IsZero() && prevModelRetryAfter.After(state.NextRetryAfter) && prevModelRetryAfter.After(now) {
state.NextRetryAfter = prevModelRetryAfter
}
+ // Capture the dispatch watermark before composing layers so the
+ // independent shadow carries this failure's stale-success
+ // protection (issue #6690).
+ cooldownSeq := m.currentExecutionSeq()
+ state.cooldownRecordedSeq = cooldownSeq
+ if ownLayerPending {
+ // Compose the outer presentation from the saved propagated
+ // layer and the independent layer just updated in place.
+ independent := state.cloneShallow()
+ outer := savedOuterState.cloneShallow()
+ if isModelStateActiveCooldown(independent, now) {
+ outer.credentialQuotaShadow = independent
+ } else {
+ outer.credentialQuotaShadow = nil
+ }
+ outer.cooldownRecordedSeq = cooldownSeq
+ // A composed outer state is wrapped by definition; stamp it so
+ // a later credential report cannot mistake it for unwrapped.
+ outer.credentialQuotaPropagated = true
+ outer.Unavailable = true
+ outer.Status = StatusError
+ outer.UpdatedAt = now
+ outer.LastError = cloneError(state.LastError)
+ outer.StatusMessage = state.StatusMessage
+ if state.NextRetryAfter.After(outer.NextRetryAfter) {
+ outer.NextRetryAfter = state.NextRetryAfter
+ }
+ if auth.Quota.NextRecoverAt.After(outer.NextRetryAfter) {
+ outer.NextRetryAfter = auth.Quota.NextRecoverAt
+ }
+ if !(outer.Quota.Exceeded && outer.Quota.Reason == "credential_quota") {
+ applyCooldownFields(&outer.Quota, QuotaState{
+ Exceeded: true,
+ Reason: "credential_quota",
+ NextRecoverAt: auth.Quota.NextRecoverAt,
+ BackoffLevel: auth.Quota.BackoffLevel,
+ })
+ }
+ *outerState = *outer
+ state = outerState
+ modelState = state
+ }
+ if result.CredentialScope && !disableCooling && statusCodeFromResult(result.Error) == 429 {
+ auth.cooldownRecordedSeq = cooldownSeq
+ for _, otherState := range auth.ModelStates {
+ if otherState != nil && otherState != state {
+ otherState.cooldownRecordedSeq = cooldownSeq
+ }
+ }
+ }
auth.Status = StatusError
updateAggregatedAvailability(auth, now)
}
@@ -1018,6 +1285,7 @@ func (m *Manager) MarkResult(ctx context.Context, result Result) {
}
if !wasTerminalUnauthorized {
applyAuthFailureState(auth, result.Error, result.RetryAfter, now, disableCooling)
+ auth.cooldownRecordedSeq = m.currentExecutionSeq()
}
}
}
@@ -1027,6 +1295,18 @@ func (m *Manager) MarkResult(ctx context.Context, result Result) {
auth.Status = StatusError
auth.NextRefreshAfter = time.Time{}
auth.NextRetryAfter = time.Time{}
+ } else if !result.Success && !shouldSkipCredentialCooldown(result.Error) && (statusCodeFromResult(result.Error) == http.StatusUnauthorized || isUnauthorizedError(result.Error)) && authHasRefreshCredential(auth) {
+ if currentToken := authAccessToken(auth); currentToken != "" {
+ auth.RejectedAccessToken = currentToken
+ }
+ if auth.NextRefreshAfter.IsZero() {
+ if !auth.LastRefreshedAt.IsZero() && now.Sub(auth.LastRefreshedAt) < refreshFailureBackoff {
+ auth.NextRefreshAfter = now.Add(refreshFailureBackoff)
+ } else {
+ auth.NextRefreshAfter = now
+ }
+ }
+ scheduleUnauthorizedRefresh = true
}
auth.Generation++
@@ -1049,18 +1329,28 @@ func (m *Manager) MarkResult(ctx context.Context, result Result) {
m.mu.Unlock()
releaseMutation()
if m.scheduler != nil && authSnapshot != nil {
+ credentialScopedResult := result.CredentialScope || scheduleUnauthorizedRefresh
var targetModels []string
- if !result.CredentialScope && modelKey != "" {
+ if !credentialScopedResult && modelKey != "" {
targetModels = append(targetModels, modelKey)
if routeKey := canonicalModelKey(result.RouteModel); routeKey != "" && routeKey != modelKey {
targetModels = append(targetModels, routeKey)
}
}
- m.scheduler.upsertAuthResult(authSnapshot, targetModels, result.CredentialScope)
+ m.scheduler.upsertAuthResult(authSnapshot, targetModels, credentialScopedResult)
}
if authSnapshot != nil && cooldownStateChanged {
m.persistCooldownStates(context.Background())
}
+ // A newly exhausted Devin credential must join the refresh schedule so
+ // quota revalidation can detect early restoration (issue #6690).
+ if authSnapshot != nil {
+ if _, due := devinQuotaRevalidateAt(authSnapshot, now); due {
+ m.queueRefreshReschedule(result.AuthID)
+ } else if scheduleUnauthorizedRefresh {
+ m.queueRefreshReschedule(result.AuthID)
+ }
+ }
supportedModels, regEpoch := registry.GetGlobalRegistry().GetModelsAndEpochForClient(result.AuthID)
reg := registry.GetGlobalRegistry()
@@ -1252,6 +1542,14 @@ func mergeModelState(target, source *ModelState) *ModelState {
if source.UpdatedAt.After(merged.UpdatedAt) {
merged.UpdatedAt = source.UpdatedAt
}
+ // Keep the strongest cooldown watermark from either side so merging a
+ // restored or alias-migrated state into a live auth cannot reopen the
+ // stale-success race. Both sides stay zero across a process restart,
+ // where restored cooldowns must remain clearable by any success.
+ merged.cooldownRecordedSeq = target.cooldownRecordedSeq
+ if source.cooldownRecordedSeq > merged.cooldownRecordedSeq {
+ merged.cooldownRecordedSeq = source.cooldownRecordedSeq
+ }
if merged.StatusMessage == "" {
merged.StatusMessage = fallback.StatusMessage
}
@@ -1266,6 +1564,22 @@ func mergeModelState(target, source *ModelState) *ModelState {
} else if merged.Unavailable || merged.Quota.Exceeded {
merged.Status = StatusError
}
+ // Keep the independent layer and wrap provenance from either side
+ // (newer wins) while the merged state is wrapped. The reporting model
+ // carries outer reason "quota", so wrap detection must include the
+ // marker and shadow presence, not only the propagated label.
+ if merged.Quota.Reason == "credential_quota" || merged.Quota.Reason == "quota" ||
+ target.credentialQuotaPropagated || source.credentialQuotaPropagated ||
+ target.credentialQuotaShadow != nil || source.credentialQuotaShadow != nil {
+ shadow := fallback.credentialQuotaShadow
+ if preferred.credentialQuotaShadow != nil {
+ shadow = preferred.credentialQuotaShadow
+ }
+ if shadow != nil {
+ merged.credentialQuotaShadow = shadow.cloneShallow()
+ }
+ merged.credentialQuotaPropagated = target.credentialQuotaPropagated || source.credentialQuotaPropagated
+ }
*target = merged
return target
}
@@ -1280,6 +1594,9 @@ func resetModelState(state *ModelState, now time.Time) {
state.NextRetryAfter = time.Time{}
state.LastError = nil
applyCooldownFields(&state.Quota, QuotaState{})
+ state.cooldownRecordedSeq = 0
+ state.credentialQuotaShadow = nil
+ state.credentialQuotaPropagated = false
state.UpdatedAt = now
}
@@ -1500,9 +1817,28 @@ func clearAuthStateOnSuccess(auth *Auth, now time.Time) {
auth.Quota.BackoffLevel = 0
auth.LastError = nil
auth.NextRetryAfter = time.Time{}
+ auth.cooldownRecordedSeq = 0
auth.UpdatedAt = now
}
+// isAuthActiveCooldown reports whether the auth carries a still-live
+// auth-level cooldown recorded without a model key.
+func isAuthActiveCooldown(auth *Auth, now time.Time) bool {
+ if auth == nil || auth.Disabled || auth.Status == StatusDisabled {
+ return false
+ }
+ if !auth.NextRetryAfter.IsZero() && auth.NextRetryAfter.After(now) {
+ return true
+ }
+ if !auth.Quota.NextRecoverAt.IsZero() && auth.Quota.NextRecoverAt.After(now) {
+ return true
+ }
+ if auth.Quota.Exceeded && auth.Quota.NextRecoverAt.IsZero() {
+ return true
+ }
+ return false
+}
+
func cloneError(err *Error) *Error {
if err == nil {
return nil
@@ -1567,6 +1903,12 @@ func resultErrorFromError(err error) *Error {
// Prefer true request-scoped faults (including Claude OAuth cancellation)
// over the broader connection-lifecycle classification.
resultErr.Code = requestScopedErrorCode
+ case isConfiguredTLSHandshakeTimeoutError(err):
+ // An internal TLS establishment deadline is a transient transport
+ // failure, not a caller-owned context lifecycle event.
+ if resultErr.Code == "" || resultErr.Code == transientTransportErrorCode {
+ resultErr.Code = transientTransportErrorCode
+ }
case isConnectionLifecycleError(err):
// Preserve lifecycle classification for MarkResult without making the error
// request-scoped (which would also stop credential fallback).
@@ -1592,7 +1934,7 @@ func shouldSkipCredentialCooldown(err *Error) bool {
}
// isConnectionLifecycleError reports transport/session lifecycle failures that must
-// not cool credentials: client cancellation and WebSocket close/EOF disconnects.
+// not cool credentials: client cancellation and WebSocket/HTTP/2 close/EOF disconnects.
func isConnectionLifecycleError(err error) bool {
if err == nil {
return false
@@ -1609,6 +1951,9 @@ func isConnectionLifecycleError(err error) bool {
if statusCodeFromError(err) != 0 {
return false
}
+ if isConfiguredTLSHandshakeTimeoutError(err) {
+ return false
+ }
// Client abort and request-scoped timeouts are not credential faults.
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return true
@@ -1623,6 +1968,9 @@ func isConnectionLifecycleResultError(err *Error) bool {
if err.Code == connectionLifecycleErrorCode {
return true
}
+ if err.Code == transientTransportErrorCode {
+ return false
+ }
// Message fallback only when no HTTP status is attached, so 401/429/5xx
// response bodies cannot suppress credential cooldown.
if statusCodeFromResult(err) != 0 {
@@ -1646,13 +1994,31 @@ func isConnectionLifecycleMessage(message string) bool {
strings.Contains(lower, "websocket: close 1006") {
return true
}
- // Wrapped transport EOF phrasing (e.g. "read tcp ...: unexpected EOF").
- if strings.Contains(lower, "unexpected eof") {
+ // Wrapped transport disconnects (e.g. "read tcp ...: unexpected EOF").
+ if strings.Contains(lower, "unexpected eof") || isHTTP2ClosedConnectionMessage(lower) {
return true
}
return false
}
+func isHTTP2ClosedConnectionMessage(lower string) bool {
+ // x/net/http2 keeps these errors unexported; allow common wrappers such as url.Error.
+ return strings.Contains(lower, "http2: client connection force closed via clientconn.close") ||
+ strings.Contains(lower, "http2: client conn is closed")
+}
+
+// isConfiguredTLSHandshakeTimeoutError identifies the typed internal
+// establishment deadline produced by the configured TLS handshake bound.
+// Delegating to the shared executor predicate ensures classification is
+// based on error identity rather than message text and that any
+// caller-owned context error outside a configured-timeout subtree wins.
+func isConfiguredTLSHandshakeTimeoutError(err error) bool {
+ if err == nil || statusCodeFromError(err) != 0 {
+ return false
+ }
+ return cliproxyexecutor.IsConfiguredTLSHandshakeTimeoutError(err)
+}
+
// isTransientTransportError reports pre-HTTP dial/TLS/DNS/reset failures that
// should retry under request-retry without cooling the selected credential.
func isTransientTransportError(err error) bool {
@@ -1663,6 +2029,9 @@ func isTransientTransportError(err error) bool {
if statusCodeFromError(err) != 0 {
return false
}
+ if isConfiguredTLSHandshakeTimeoutError(err) {
+ return true
+ }
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
@@ -1697,6 +2066,14 @@ func isTransientTransportResultError(err *Error) bool {
if statusCodeFromResult(err) != 0 {
return false
}
+ // Once an error has been reduced to a Result message, typed identity is
+ // no longer available. An explicit transient code assigned by
+ // resultErrorFromError is authoritative for configured handshake
+ // timeouts; matching text alone must not promote an external or
+ // caller-owned deadline that happens to use the same phrase.
+ if strings.Contains(strings.ToLower(err.Message), "tls handshake timeout") {
+ return false
+ }
return isTransientTransportMessage(err.Message)
}
@@ -1735,7 +2112,8 @@ func isTransientTransportMessage(message string) bool {
strings.Contains(lower, "broken pipe"),
strings.Contains(lower, "connection aborted"),
strings.Contains(lower, "use of closed network connection"),
- strings.Contains(lower, "unexpected eof"):
+ strings.Contains(lower, "unexpected eof"),
+ isHTTP2ClosedConnectionMessage(lower):
return true
default:
return false
diff --git a/sdk/cliproxy/auth/conductor_credits_candidates_test.go b/sdk/cliproxy/auth/conductor_credits_candidates_test.go
index 24109837664..35cd33a64ef 100644
--- a/sdk/cliproxy/auth/conductor_credits_candidates_test.go
+++ b/sdk/cliproxy/auth/conductor_credits_candidates_test.go
@@ -72,6 +72,29 @@ func TestFindAllAntigravityCreditsCandidateAuths_PrefersKnownCreditsThenUnknown(
if pinned[0].auth.ID != "aa-unknown" {
t.Fatalf("pinned[0].auth.ID = %q, want %q", pinned[0].auth.ID, "aa-unknown")
}
+
+ // Once the negative hint expires (>= 10 minutes), mm-no should be retried as an unknown candidate.
+ SetAntigravityCreditsHint("mm-no", AntigravityCreditsHint{
+ Known: true,
+ Available: false,
+ UpdatedAt: time.Now().Add(-10*time.Minute - time.Second),
+ })
+ t.Cleanup(func() {
+ SetAntigravityCreditsHint("zz-credits", AntigravityCreditsHint{})
+ SetAntigravityCreditsHint("mm-no", AntigravityCreditsHint{})
+ })
+
+ afterExpiry, errAfterExpiry := m.findAllAntigravityCreditsCandidateAuths(context.Background(), "claude-sonnet-4-6", opts)
+ if errAfterExpiry != nil {
+ t.Fatalf("findAllAntigravityCreditsCandidateAuths(after expiry) error = %v", errAfterExpiry)
+ }
+ if len(afterExpiry) != 3 {
+ t.Fatalf("afterExpiry candidates len = %d, want 3", len(afterExpiry))
+ }
+ if afterExpiry[0].auth.ID != "zz-credits" || afterExpiry[1].auth.ID != "aa-unknown" || afterExpiry[2].auth.ID != "mm-no" {
+ t.Fatalf("afterExpiry order = [%s, %s, %s], want [zz-credits, aa-unknown, mm-no]",
+ afterExpiry[0].auth.ID, afterExpiry[1].auth.ID, afterExpiry[2].auth.ID)
+ }
}
func TestFindAllAntigravityCreditsCandidateAuths_HomeKVUnavailableReturnsError(t *testing.T) {
diff --git a/sdk/cliproxy/auth/conductor_execution.go b/sdk/cliproxy/auth/conductor_execution.go
index 11f475a83da..c429b0557dd 100644
--- a/sdk/cliproxy/auth/conductor_execution.go
+++ b/sdk/cliproxy/auth/conductor_execution.go
@@ -603,6 +603,7 @@ func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req
}
execReq = attachResolvedExecutionModelInfo(routing, execReq, auth, routeModel, upstreamModel, restoreExecutionModel)
execCtx = syncMetadataSessionToContext(execCtx, execOpts.Metadata)
+ executionSeq := m.nextExecutionSeq()
startExec := time.Now()
resp, errExec := executor.Execute(execCtx, auth, execReq, execOpts)
errExec = markUpstreamExecutionAttemptFromContext(execCtx, errExec)
@@ -620,6 +621,7 @@ func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req
didRefreshOnUnauthorized = true
execCtx = newUpstreamAttemptContext(execCtx)
execCtx = syncMetadataSessionToContext(execCtx, execOpts.Metadata)
+ executionSeq = m.nextExecutionSeq()
startRetry := time.Now()
resp, errExec = executor.Execute(execCtx, auth, execReq, execOpts)
errExec = markUpstreamExecutionAttemptFromContext(execCtx, errExec)
@@ -640,7 +642,7 @@ func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req
if errCancel := claudeOAuthRequestCancellation(execCtx, auth, errExec); errCancel != nil {
return cliproxyexecutor.Response{}, errCancel
}
- result := Result{AuthID: auth.ID, Provider: provider, Model: resultModel, RouteModel: routeModel, Success: errExec == nil, Options: execOpts, CredentialVersion: auth.CredentialVersion, RegistrationEpoch: auth.RegistrationEpoch}
+ result := Result{AuthID: auth.ID, Provider: provider, Model: resultModel, RouteModel: routeModel, Success: errExec == nil, Options: execOpts, CredentialVersion: auth.CredentialVersion, RegistrationEpoch: auth.RegistrationEpoch, ExecutionSeq: executionSeq}
if errExec != nil {
result.Error = resultErrorFromError(errExec)
if ra := retryAfterFromError(errExec); ra != nil {
@@ -815,6 +817,7 @@ func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string,
}
execReq = attachResolvedExecutionModelInfo(routing, execReq, auth, routeModel, upstreamModel, restoreExecutionModel)
execCtx = syncMetadataSessionToContext(execCtx, execOpts.Metadata)
+ executionSeq := m.nextExecutionSeq()
startExec := time.Now()
resp, errExec := executor.CountTokens(execCtx, auth, execReq, execOpts)
errExec = markUpstreamExecutionAttemptFromContext(execCtx, errExec)
@@ -832,6 +835,7 @@ func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string,
didRefreshOnUnauthorized = true
execCtx = newUpstreamAttemptContext(execCtx)
execCtx = syncMetadataSessionToContext(execCtx, execOpts.Metadata)
+ executionSeq = m.nextExecutionSeq()
startRetry := time.Now()
resp, errExec = executor.CountTokens(execCtx, auth, execReq, execOpts)
errExec = markUpstreamExecutionAttemptFromContext(execCtx, errExec)
@@ -852,7 +856,7 @@ func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string,
if errCancel := claudeOAuthRequestCancellation(execCtx, auth, errExec); errCancel != nil {
return cliproxyexecutor.Response{}, errCancel
}
- result := Result{AuthID: auth.ID, Provider: provider, Model: resultModel, RouteModel: routeModel, Success: errExec == nil, Options: execOpts, SkipQuotaObservation: true, CredentialVersion: auth.CredentialVersion, RegistrationEpoch: auth.RegistrationEpoch}
+ result := Result{AuthID: auth.ID, Provider: provider, Model: resultModel, RouteModel: routeModel, Success: errExec == nil, Options: execOpts, SkipQuotaObservation: true, CredentialVersion: auth.CredentialVersion, RegistrationEpoch: auth.RegistrationEpoch, ExecutionSeq: executionSeq}
if errExec != nil {
result.Error = resultErrorFromError(errExec)
if ra := retryAfterFromError(errExec); ra != nil {
diff --git a/sdk/cliproxy/auth/conductor_home.go b/sdk/cliproxy/auth/conductor_home.go
index 3e01e3dbfb4..552e658d45a 100644
--- a/sdk/cliproxy/auth/conductor_home.go
+++ b/sdk/cliproxy/auth/conductor_home.go
@@ -1313,6 +1313,7 @@ func (m *Manager) findAllAntigravityCreditsCandidateAuths(ctx context.Context, r
}
m.mu.RUnlock()
+ now := time.Now()
var known []creditsCandidateEntry
var unknown []creditsCandidateEntry
for _, candidate := range candidates {
@@ -1322,6 +1323,10 @@ func (m *Manager) findAllAntigravityCreditsCandidateAuths(ctx context.Context, r
}
if okHint && hint.Known {
if !hint.Available {
+ if IsAntigravityCreditsHintFresh(hint, now) {
+ continue
+ }
+ unknown = append(unknown, candidate)
continue
}
known = append(known, candidate)
@@ -1426,8 +1431,9 @@ func (m *Manager) tryAntigravityCreditsExecute(ctx context.Context, req cliproxy
execReq := req
execReq.Model = upstreamModel
creditsCtx = syncMetadataSessionToContext(creditsCtx, creditsOpts.Metadata)
+ executionSeq := m.nextExecutionSeq()
resp, errExec := c.executor.Execute(creditsCtx, c.auth, execReq, creditsOpts)
- result := Result{AuthID: c.auth.ID, Provider: c.provider, Model: resultModel, RouteModel: routeModel, Success: errExec == nil, Options: creditsOpts, CredentialVersion: c.auth.CredentialVersion, RegistrationEpoch: c.auth.RegistrationEpoch}
+ result := Result{AuthID: c.auth.ID, Provider: c.provider, Model: resultModel, RouteModel: routeModel, Success: errExec == nil, Options: creditsOpts, CredentialVersion: c.auth.CredentialVersion, RegistrationEpoch: c.auth.RegistrationEpoch, ExecutionSeq: executionSeq}
if errExec != nil {
result.Error = resultErrorFromError(errExec)
if ra := retryAfterFromError(errExec); ra != nil {
diff --git a/sdk/cliproxy/auth/conductor_issue6643_test.go b/sdk/cliproxy/auth/conductor_issue6643_test.go
new file mode 100644
index 00000000000..ec2baf06515
--- /dev/null
+++ b/sdk/cliproxy/auth/conductor_issue6643_test.go
@@ -0,0 +1,343 @@
+package auth
+
+import (
+ "context"
+ "net/http"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+)
+
+// issue6643Result records one client-visible outcome of the concurrent test.
+type issue6643Result struct {
+ err error
+}
+
+// issue6643Hook releases the blocked in-flight requests once the quota
+// failure has been fully recorded by the manager.
+type issue6643Hook struct {
+ onResult func(context.Context, Result)
+}
+
+func (*issue6643Hook) OnAuthRegistered(context.Context, *Auth) {}
+
+func (*issue6643Hook) OnAuthUpdated(context.Context, *Auth) {}
+
+func (h *issue6643Hook) OnResult(ctx context.Context, result Result) {
+ if h.onResult != nil {
+ h.onResult(ctx, result)
+ }
+}
+
+// issue6643ConcurrentExecutor models the upstream from issue 6643: many
+// requests run concurrently on one credential, one of the later dispatches is
+// answered with a quota-exhausted 429, and only afterwards do the requests
+// dispatched earlier complete successfully.
+type issue6643ConcurrentExecutor struct {
+ exhausted atomic.Bool
+ dispatched atomic.Int64
+ allDispatched chan struct{}
+ quotaResultRecorded chan struct{}
+ callsAfterExhaustion atomic.Int64
+ closeDispatchedOnce sync.Once
+}
+
+const issue6643ConcurrentRequests = 6
+
+func (*issue6643ConcurrentExecutor) Identifier() string { return "antigravity" }
+
+func (e *issue6643ConcurrentExecutor) Execute(ctx context.Context, _ *Auth, req cliproxyexecutor.Request, _ cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
+ if e.exhausted.Load() {
+ // A dispatch reaching the already exhausted credential. The proxy
+ // could have avoided this upstream call entirely.
+ e.callsAfterExhaustion.Add(1)
+ return cliproxyexecutor.Response{}, &Error{Code: "quota_exhausted", Message: "QUOTA_EXHAUSTED: Resets in 140h36m", Retryable: true, HTTPStatus: http.StatusTooManyRequests}
+ }
+ call := e.dispatched.Add(1)
+ if call < issue6643ConcurrentRequests {
+ // Long-running requests dispatched before the quota error: they only
+ // complete, successfully, once the 429 result has been recorded.
+ select {
+ case <-e.quotaResultRecorded:
+ return cliproxyexecutor.Response{Payload: []byte("ok")}, nil
+ case <-ctx.Done():
+ return cliproxyexecutor.Response{}, ctx.Err()
+ }
+ }
+ // The last dispatch observes the quota exhausting right now.
+ e.exhausted.Store(true)
+ e.closeDispatchedOnce.Do(func() { close(e.allDispatched) })
+ return cliproxyexecutor.Response{}, &Error{Code: "quota_exhausted", Message: "QUOTA_EXHAUSTED: Resets in 140h36m", Retryable: true, HTTPStatus: http.StatusTooManyRequests}
+}
+
+func (e *issue6643ConcurrentExecutor) ExecuteStream(context.Context, *Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {
+ return nil, &Error{HTTPStatus: http.StatusNotImplemented, Message: "not implemented"}
+}
+
+func (*issue6643ConcurrentExecutor) Refresh(_ context.Context, auth *Auth) (*Auth, error) {
+ return auth, nil
+}
+
+func (*issue6643ConcurrentExecutor) CountTokens(context.Context, *Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
+ return cliproxyexecutor.Response{}, &Error{HTTPStatus: http.StatusNotImplemented, Message: "not implemented"}
+}
+
+func (*issue6643ConcurrentExecutor) HttpRequest(context.Context, *Auth, *http.Request) (*http.Response, error) {
+ return nil, &Error{HTTPStatus: http.StatusNotImplemented, Message: "not implemented"}
+}
+
+// issue6643SeqTestAuth registers a single auth for the MarkResult-level
+// watermark tests and returns the manager.
+func issue6643SeqTestAuth(t *testing.T, authID string) *Manager {
+ t.Helper()
+ withQuotaCooldownEnabled(t)
+ manager := NewManager(nil, nil, nil)
+ if _, errRegister := manager.Register(WithSkipPersist(context.Background()), &Auth{
+ ID: authID,
+ Provider: "antigravity",
+ Status: StatusActive,
+ }); errRegister != nil {
+ t.Fatalf("Register returned error: %v", errRegister)
+ }
+ return manager
+}
+
+func issue6643QuotaFailure(authID, model string, seq uint64) Result {
+ return Result{
+ AuthID: authID,
+ Provider: "antigravity",
+ Model: model,
+ Success: false,
+ ExecutionSeq: seq,
+ Error: &Error{Code: "quota_exhausted", Message: "QUOTA_EXHAUSTED", Retryable: true, HTTPStatus: http.StatusTooManyRequests},
+ }
+}
+
+func issue6643ModelState(t *testing.T, manager *Manager, authID, model string) *ModelState {
+ t.Helper()
+ updated, ok := manager.GetByID(authID)
+ if !ok || updated == nil || updated.ModelStates[model] == nil {
+ t.Fatalf("expected model state for %q", model)
+ }
+ return updated.ModelStates[model]
+}
+
+// TestManager_Issue6643_SuccessDispatchedAfterCooldownClearsIt pins the
+// positive half of the watermark rule: only a success stamped after the
+// cooldown was recorded proves recovery and clears it.
+func TestManager_Issue6643_SuccessDispatchedAfterCooldownClearsIt(t *testing.T) {
+ const (
+ authID = "issue6643-after-auth"
+ model = "gemini-4-argon-high"
+ )
+ manager := issue6643SeqTestAuth(t, authID)
+
+ // A long-running request is dispatched first, then a later dispatch hits
+ // the quota limit and records the cooldown.
+ staleSeq := manager.nextExecutionSeq()
+ manager.MarkResult(context.Background(), issue6643QuotaFailure(authID, model, manager.nextExecutionSeq()))
+ if state := issue6643ModelState(t, manager, authID, model); !state.Quota.Exceeded {
+ t.Fatalf("expected quota cooldown after failure, got %+v", state)
+ }
+
+ // The earlier dispatch now completes successfully. It predates the
+ // cooldown, so the cooldown must survive.
+ manager.MarkResult(context.Background(), Result{AuthID: authID, Provider: "antigravity", Model: model, Success: true, ExecutionSeq: staleSeq})
+ if state := issue6643ModelState(t, manager, authID, model); !state.Unavailable || !state.Quota.Exceeded || !state.NextRetryAfter.After(time.Now()) {
+ t.Fatalf("stale success cleared the quota cooldown: state = %+v", state)
+ }
+
+ // A request dispatched after the cooldown was recorded (for example a
+ // probe just before the reset boundary) succeeds and clears it.
+ manager.MarkResult(context.Background(), Result{AuthID: authID, Provider: "antigravity", Model: model, Success: true, ExecutionSeq: manager.nextExecutionSeq()})
+ if state := issue6643ModelState(t, manager, authID, model); state.Unavailable || state.Quota.Exceeded || !state.NextRetryAfter.IsZero() {
+ t.Fatalf("post-cooldown success did not clear the cooldown: state = %+v", state)
+ }
+}
+
+// TestManager_Issue6643_UnstampedSuccessKeepsLegacyClearBehavior pins the
+// compatibility rule: results without a dispatch stamp (direct MarkResult
+// callers, prepare-stage errors, or a zero watermark after restart) behave
+// exactly as before the ordering guard existed.
+func TestManager_Issue6643_UnstampedSuccessKeepsLegacyClearBehavior(t *testing.T) {
+ const (
+ authID = "issue6643-unstamped-auth"
+ model = "gemini-4-argon-high"
+ )
+ manager := issue6643SeqTestAuth(t, authID)
+
+ manager.MarkResult(context.Background(), issue6643QuotaFailure(authID, model, manager.nextExecutionSeq()))
+ if state := issue6643ModelState(t, manager, authID, model); !state.Quota.Exceeded {
+ t.Fatalf("expected quota cooldown after failure, got %+v", state)
+ }
+
+ // An unstamped success cannot be ordered against the cooldown, so it
+ // still clears it like any legacy caller would.
+ manager.MarkResult(context.Background(), Result{AuthID: authID, Provider: "antigravity", Model: model, Success: true})
+ if state := issue6643ModelState(t, manager, authID, model); state.Unavailable || state.Quota.Exceeded {
+ t.Fatalf("unstamped success should clear the cooldown: state = %+v", state)
+ }
+}
+
+// TestManager_Issue6643_StaleAuthLevelSuccessKeepsCooldown covers the
+// back door: a success without a model key takes the auth-level clear path
+// and must not wipe an auth-level cooldown recorded after it was dispatched.
+func TestManager_Issue6643_StaleAuthLevelSuccessKeepsCooldown(t *testing.T) {
+ const authID = "issue6643-auth-level-auth"
+ manager := issue6643SeqTestAuth(t, authID)
+
+ staleSeq := manager.nextExecutionSeq()
+ manager.MarkResult(context.Background(), Result{
+ AuthID: authID,
+ Provider: "antigravity",
+ Success: false,
+ ExecutionSeq: manager.nextExecutionSeq(),
+ Error: &Error{Code: "quota_exhausted", Message: "QUOTA_EXHAUSTED", Retryable: true, HTTPStatus: http.StatusTooManyRequests},
+ })
+ updated, ok := manager.GetByID(authID)
+ if !ok || updated == nil || !updated.Quota.Exceeded || !updated.NextRetryAfter.After(time.Now()) {
+ t.Fatalf("expected auth-level quota cooldown after failure, got %+v", updated)
+ }
+
+ manager.MarkResult(context.Background(), Result{AuthID: authID, Provider: "antigravity", Success: true, ExecutionSeq: staleSeq})
+ updated, ok = manager.GetByID(authID)
+ if !ok || updated == nil || !updated.Quota.Exceeded || !updated.NextRetryAfter.After(time.Now()) {
+ t.Fatalf("stale auth-level success cleared the cooldown: auth = %+v", updated)
+ }
+
+ manager.MarkResult(context.Background(), Result{AuthID: authID, Provider: "antigravity", Success: true, ExecutionSeq: manager.nextExecutionSeq()})
+ updated, ok = manager.GetByID(authID)
+ if !ok || updated == nil || updated.Quota.Exceeded || !updated.NextRetryAfter.IsZero() {
+ t.Fatalf("post-cooldown auth-level success did not clear the cooldown: auth = %+v", updated)
+ }
+}
+
+// TestManager_Issue6643_RestoreKeepsCooldownWatermark covers the runtime
+// restore path: configuration updates re-apply persisted cooldown records to
+// live auths, and that merge must not drop the watermark protecting a fresh
+// cooldown from stale in-flight successes.
+func TestManager_Issue6643_RestoreKeepsCooldownWatermark(t *testing.T) {
+ const (
+ authID = "issue6643-restore-auth"
+ model = "gemini-4-argon-high"
+ )
+ manager := issue6643SeqTestAuth(t, authID)
+ store := &recordingCooldownStateStore{}
+ manager.SetCooldownStateStore(store)
+
+ staleSeq := manager.nextExecutionSeq()
+ manager.MarkResult(context.Background(), issue6643QuotaFailure(authID, model, manager.nextExecutionSeq()))
+ before := issue6643ModelState(t, manager, authID, model)
+ if !before.Quota.Exceeded {
+ t.Fatalf("expected quota cooldown after failure, got %+v", before)
+ }
+ store.load = store.savedRecords()
+ if len(store.load) == 0 {
+ t.Fatal("expected the quota cooldown to be persisted for restore")
+ }
+
+ // A runtime configuration update restores the persisted cooldown into
+ // the live auth while the earlier dispatch is still in flight.
+ if errRestore := manager.RestoreCooldownStates(context.Background()); errRestore != nil {
+ t.Fatalf("RestoreCooldownStates returned error: %v", errRestore)
+ }
+
+ manager.MarkResult(context.Background(), Result{AuthID: authID, Provider: "antigravity", Model: model, Success: true, ExecutionSeq: staleSeq})
+ if state := issue6643ModelState(t, manager, authID, model); !state.Unavailable || !state.Quota.Exceeded || !state.NextRetryAfter.After(time.Now()) {
+ t.Fatalf("stale success cleared the cooldown after runtime restore: state = %+v", state)
+ }
+}
+
+// TestManager_Issue6643_InFlightSuccessesKeepFreshQuotaCooldown reproduces
+// issue 6643 end to end: successes from requests dispatched before a quota
+// cooldown was recorded must not clear that cooldown, so the exhausted
+// credential stops being selected until the cooldown expires.
+func TestManager_Issue6643_InFlightSuccessesKeepFreshQuotaCooldown(t *testing.T) {
+ withQuotaCooldownEnabled(t)
+
+ const (
+ authID = "issue6643-auth"
+ model = "gemini-4-argon-high"
+ )
+ registry.GetGlobalRegistry().RegisterClient(authID, "antigravity", []*registry.ModelInfo{{ID: model}})
+ t.Cleanup(func() { registry.GetGlobalRegistry().UnregisterClient(authID) })
+
+ executor := &issue6643ConcurrentExecutor{
+ allDispatched: make(chan struct{}),
+ quotaResultRecorded: make(chan struct{}),
+ }
+ var recordedOnce sync.Once
+ hook := &issue6643Hook{onResult: func(_ context.Context, result Result) {
+ if !result.Success && result.Error != nil && result.Error.HTTPStatus == http.StatusTooManyRequests {
+ // OnResult fires after the cooldown state transition, so the
+ // blocked in-flight requests are released only once the quota
+ // cooldown is fully recorded.
+ recordedOnce.Do(func() { close(executor.quotaResultRecorded) })
+ }
+ }}
+ manager := NewManager(nil, &RoundRobinSelector{}, hook)
+ manager.RegisterExecutor(executor)
+ if _, errRegister := manager.Register(WithSkipPersist(context.Background()), &Auth{
+ ID: authID,
+ Provider: "antigravity",
+ Status: StatusActive,
+ }); errRegister != nil {
+ t.Fatalf("Register returned error: %v", errRegister)
+ }
+
+ execCtx, cancelExec := context.WithCancel(context.Background())
+ t.Cleanup(cancelExec)
+ results := make(chan issue6643Result, issue6643ConcurrentRequests)
+ for i := 0; i < issue6643ConcurrentRequests; i++ {
+ go func() {
+ _, errExecute := manager.Execute(execCtx, []string{"antigravity"}, cliproxyexecutor.Request{Model: model, Payload: []byte("ping")}, cliproxyexecutor.Options{})
+ results <- issue6643Result{err: errExecute}
+ }()
+ }
+
+ // Every dispatched request must actually complete; a timeout here means
+ // the run never proved the stale successes were all recorded.
+ failures := 0
+ for i := 0; i < issue6643ConcurrentRequests; i++ {
+ select {
+ case outcome := <-results:
+ if outcome.err != nil {
+ failures++
+ }
+ case <-time.After(30 * time.Second):
+ cancelExec()
+ t.Fatalf("timed out waiting for concurrent result %d of %d", i+1, issue6643ConcurrentRequests)
+ }
+ }
+ // The client sees exactly one failure: the dispatch that hit the quota
+ // limit. Internal retries must find the only credential cooling down
+ // rather than reaching upstream again, and the earlier dispatches succeed.
+ if failures != 1 {
+ t.Fatalf("concurrent failures = %d, want 1", failures)
+ }
+ if got := executor.callsAfterExhaustion.Load(); got != 0 {
+ t.Fatalf("upstream calls after quota exhaustion = %d, want 0", got)
+ }
+
+ // The quota cooldown recorded by the 429 survives the successful
+ // completions of every request dispatched before it, so the exhausted
+ // credential stays excluded from selection.
+ updated, ok := manager.GetByID(authID)
+ if !ok || updated == nil {
+ t.Fatal("expected auth after concurrent run")
+ }
+ state := updated.ModelStates[model]
+ if state == nil {
+ t.Fatalf("expected model state for %q", model)
+ }
+ if !state.Unavailable || !state.Quota.Exceeded || !state.NextRetryAfter.After(time.Now()) {
+ t.Fatalf("quota cooldown was cleared by in-flight successes: state = %+v", state)
+ }
+ if blocked, _, _ := isAuthBlockedForModel(updated, model, time.Now()); !blocked {
+ t.Fatal("exhausted credential is selectable again during its quota cooldown")
+ }
+}
diff --git a/sdk/cliproxy/auth/conductor_issue6742_test.go b/sdk/cliproxy/auth/conductor_issue6742_test.go
new file mode 100644
index 00000000000..e418dee15b0
--- /dev/null
+++ b/sdk/cliproxy/auth/conductor_issue6742_test.go
@@ -0,0 +1,408 @@
+package auth
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+)
+
+func makeIssue6742JWT(exp time.Time) string {
+ header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
+ payloadBytes, _ := json.Marshal(map[string]any{
+ "exp": exp.Unix(),
+ })
+ payload := base64.RawURLEncoding.EncodeToString(payloadBytes)
+ return header + "." + payload + ".sig"
+}
+
+func TestIssue6742_CodexProactiveRefreshBeforeSevenDayInvalidation(t *testing.T) {
+ issuedAt := time.Date(2026, 9, 26, 14, 39, 9, 0, time.FixedZone("UTC+8", 8*3600))
+ jwtExpiry := issuedAt.Add(10 * 24 * time.Hour) // Oct 6 14:39:09
+ jwtToken := makeIssue6742JWT(jwtExpiry)
+
+ auth := &Auth{
+ ID: "codex-issue6742-a.json",
+ Provider: "codex",
+ Status: StatusActive,
+ LastRefreshedAt: issuedAt,
+ Metadata: map[string]any{
+ "type": "codex",
+ "access_token": jwtToken,
+ "refresh_token": "codex-refresh-a",
+ "last_refresh": issuedAt.Format(time.RFC3339),
+ "expired": jwtExpiry.Format(time.RFC3339),
+ },
+ }
+
+ mgr := NewManager(nil, nil, nil)
+
+ // At 6 days + 1 hour after issuance (still ~3.9 days before 10-day JWT expiry,
+ // and before the ~7-day upstream invalidation on Oct 3 16:33:55), proactive refresh must be due.
+ checkAt := issuedAt.Add(6*24*time.Hour + time.Hour)
+ dueAt, hasOuter, needOuter := CodexOuterNeedsRefresh(auth, checkAt)
+ if !hasOuter {
+ t.Fatal("CodexOuterNeedsRefresh hasRefresh = false, want true")
+ }
+ if !needOuter {
+ t.Fatalf("CodexOuterNeedsRefresh needsRefresh = false at %v (dueAt=%v), want true before 7-day upstream invalidation", checkAt, dueAt)
+ }
+ if !mgr.shouldRefresh(auth, checkAt) {
+ t.Fatalf("mgr.shouldRefresh = false at %v, want true before 7-day upstream invalidation", checkAt)
+ }
+ nextAt, okSchedule := nextRefreshCheckAt(issuedAt, auth, 5*time.Second)
+ if !okSchedule {
+ t.Fatal("nextRefreshCheckAt ok = false, want true")
+ }
+ sevenDayInvalidation := time.Date(2026, 10, 3, 16, 33, 55, 0, time.FixedZone("UTC+8", 8*3600))
+ if !nextAt.Before(sevenDayInvalidation) {
+ t.Fatalf("nextRefreshCheckAt = %v, want before 7-day invalidation at %v", nextAt, sevenDayInvalidation)
+ }
+}
+
+func TestIssue6742_RejectedUnexpiredJWTRecoversViaBackgroundRefreshAfterTransientFailure(t *testing.T) {
+ now := time.Now()
+ // Use 8 days remaining (> 96h proactive refresh lead) so shouldRefresh is false
+ // before rejection and can only become true via RejectedAccessToken tracking.
+ staleJWTToken := makeIssue6742JWT(now.Add(8 * 24 * time.Hour))
+ freshJWTToken := makeIssue6742JWT(now.Add(10 * 24 * time.Hour))
+
+ model := "gpt-6-astra"
+ primary := &Auth{
+ ID: "aa-codex-jwt-primary",
+ Provider: "codex",
+ Status: StatusActive,
+ Metadata: map[string]any{
+ "type": "codex",
+ "access_token": staleJWTToken,
+ "refresh_token": "primary-refresh-token",
+ "expired": now.Add(8 * 24 * time.Hour).Format(time.RFC3339),
+ },
+ }
+
+ executor := &unauthorizedRefreshExecutor{
+ id: "codex",
+ tokenInvalid: map[string]struct{}{
+ staleJWTToken: {},
+ },
+ refreshErr: errors.New("token refresh request failed: upstream 503 Service Unavailable"),
+ refreshTokens: map[string]string{
+ primary.ID: freshJWTToken,
+ },
+ }
+
+ m := NewManager(nil, &FillFirstSelector{}, nil)
+ m.RegisterExecutor(executor)
+
+ reg := registry.GetGlobalRegistry()
+ reg.RegisterClient(primary.ID, "codex", []*registry.ModelInfo{{ID: model}})
+ t.Cleanup(func() {
+ reg.UnregisterClient(primary.ID)
+ })
+
+ if _, errRegister := m.Register(context.Background(), primary); errRegister != nil {
+ t.Fatalf("register primary: %v", errRegister)
+ }
+ if m.shouldRefresh(primary, now) {
+ t.Fatal("precondition failed: shouldRefresh = true before 401 with 8 days remaining, want false")
+ }
+
+ // 1. Execute hits 401 with invalidated JWT token; on-demand refresh fails transiently.
+ _, errExec := m.Execute(context.Background(), []string{"codex"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{})
+ if errExec == nil {
+ t.Fatal("expected initial Execute to fail when on-demand refresh fails transiently")
+ }
+
+ afterFail, ok := m.GetByID(primary.ID)
+ if !ok || afterFail == nil {
+ t.Fatal("primary auth missing after failure")
+ }
+ if afterFail.RejectedAccessToken != staleJWTToken {
+ t.Fatalf("RejectedAccessToken = %q, want %q for rejected unexpired JWT", afterFail.RejectedAccessToken, staleJWTToken)
+ }
+ if afterFail.HasValidAccessToken(now) {
+ t.Fatal("HasValidAccessToken = true, want false for rejected unexpired JWT")
+ }
+ if afterFail.NextRefreshAfter.IsZero() {
+ t.Fatal("NextRefreshAfter is zero, want scheduled retry backoff")
+ }
+
+ // 2. When the retry backoff elapses, shouldRefresh must return true even though
+ // the rejected JWT's nominal exp claim is still 3 days in the future.
+ retryTime := afterFail.NextRefreshAfter.Add(time.Second)
+ if !m.shouldRefresh(afterFail, retryTime) {
+ t.Fatal("shouldRefresh = false at retryTime, want true for rejected unexpired JWT")
+ }
+
+ // 3. Transient network issue resolves; background auto-refresh loop processes the due auth.
+ executor.mu.Lock()
+ executor.refreshErr = nil
+ executor.mu.Unlock()
+
+ loop := newAuthAutoRefreshLoop(m, 5*time.Second, 1)
+ loop.handleDueAuth(context.Background(), retryTime, primary.ID)
+ select {
+ case job := <-loop.jobs:
+ if !m.beginRefreshJob(context.Background(), job) {
+ t.Fatal("beginRefreshJob returned false")
+ }
+ if _, errRefresh := m.refreshAuthForRequestAtEpoch(context.Background(), job.id, "", job.registrationEpoch); errRefresh != nil {
+ t.Fatalf("background refreshAuthForRequestAtEpoch error = %v", errRefresh)
+ }
+ m.finishRefreshJob(job, time.Time{}, false)
+ default:
+ t.Fatal("expected handleDueAuth to enqueue a refresh job for rejected unexpired JWT")
+ }
+
+ recovered, ok := m.GetByID(primary.ID)
+ if !ok || recovered == nil {
+ t.Fatal("primary auth missing after background refresh")
+ }
+ if got := authAccessToken(recovered); got != freshJWTToken {
+ t.Fatalf("recovered access_token = %q, want %q", got, freshJWTToken)
+ }
+ if recovered.Unavailable || recovered.Status != StatusActive {
+ t.Fatalf("recovered state: Unavailable=%v Status=%s, want false/StatusActive", recovered.Unavailable, recovered.Status)
+ }
+ if state := recovered.ModelStates[model]; state != nil && state.Unavailable {
+ t.Fatalf("model state remained Unavailable after background refresh: %+v", state)
+ }
+
+ // 4. Subsequent Execute succeeds automatically without manual ForceRefreshAll.
+ resp, errExec2 := m.Execute(context.Background(), []string{"codex"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{})
+ if errExec2 != nil {
+ t.Fatalf("subsequent Execute error = %v, want success", errExec2)
+ }
+ if got := string(resp.Payload); got != fmt.Sprintf("%s:%s", primary.ID, freshJWTToken) {
+ t.Fatalf("subsequent Execute payload = %q, want %q", got, fmt.Sprintf("%s:%s", primary.ID, freshJWTToken))
+ }
+}
+
+type inStreamUnauthorizedExecutor struct {
+ unauthorizedRefreshExecutor
+}
+
+func (e *inStreamUnauthorizedExecutor) ExecuteStream(_ context.Context, auth *Auth, _ cliproxyexecutor.Request, _ cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {
+ e.mu.Lock()
+ e.streamCalls = append(e.streamCalls, auth.ID)
+ token := authAccessToken(auth)
+ _, invalid := e.tokenInvalid[token]
+ e.mu.Unlock()
+
+ ch := make(chan cliproxyexecutor.StreamChunk, 2)
+ // Emit an initial handshake chunk so readStreamBootstrap commits the stream,
+ // and then deliver the 401 error in-stream via wrapStreamResult -> MarkResult.
+ ch <- cliproxyexecutor.StreamChunk{Payload: []byte(`data: {"type":"response.created"}` + "\n\n")}
+ if invalid {
+ ch <- cliproxyexecutor.StreamChunk{Err: &Error{
+ HTTPStatus: http.StatusUnauthorized,
+ Code: "auth_unavailable",
+ Message: `{"error":{"message":"Encountered invalidated oauth token for user, failing request","type":"authentication_error","code":"auth_unavailable"}}`,
+ }}
+ } else {
+ ch <- cliproxyexecutor.StreamChunk{Payload: []byte(auth.ID + ":" + token)}
+ }
+ close(ch)
+ return &cliproxyexecutor.StreamResult{Headers: http.Header{"X-Auth": {auth.ID}}, Chunks: ch}, nil
+}
+
+func TestIssue6742_InStreamUnauthorizedMarksRejectedTokenAndSchedulesRefresh(t *testing.T) {
+ now := time.Now()
+ // Use 8 days remaining (> 96h proactive refresh lead) so shouldRefresh is false
+ // before the in-stream 401 and only becomes true because MarkResult marks RejectedAccessToken.
+ staleJWTToken := makeIssue6742JWT(now.Add(8 * 24 * time.Hour))
+ freshJWTToken := makeIssue6742JWT(now.Add(10 * 24 * time.Hour))
+ model := "gpt-5.6-luna"
+
+ primary := &Auth{
+ ID: "codex-instream-401",
+ Provider: "codex",
+ Status: StatusActive,
+ Metadata: map[string]any{
+ "type": "codex",
+ "access_token": staleJWTToken,
+ "refresh_token": "primary-refresh-token",
+ "expired": now.Add(8 * 24 * time.Hour).Format(time.RFC3339),
+ },
+ }
+
+ executor := &inStreamUnauthorizedExecutor{
+ unauthorizedRefreshExecutor: unauthorizedRefreshExecutor{
+ id: "codex",
+ tokenInvalid: map[string]struct{}{
+ staleJWTToken: {},
+ },
+ refreshTokens: map[string]string{
+ primary.ID: freshJWTToken,
+ },
+ },
+ }
+
+ m := NewManager(nil, &FillFirstSelector{}, nil)
+ m.RegisterExecutor(executor)
+
+ reg := registry.GetGlobalRegistry()
+ reg.RegisterClient(primary.ID, "codex", []*registry.ModelInfo{{ID: model}})
+ t.Cleanup(func() {
+ reg.UnregisterClient(primary.ID)
+ })
+
+ if _, errRegister := m.Register(context.Background(), primary); errRegister != nil {
+ t.Fatalf("register primary: %v", errRegister)
+ }
+ if m.shouldRefresh(primary, now) {
+ t.Fatal("precondition failed: shouldRefresh = true before in-stream 401 with 8 days remaining, want false")
+ }
+
+ stream, errStream := m.ExecuteStream(context.Background(), []string{"codex"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{})
+ if errStream != nil {
+ t.Fatalf("ExecuteStream initial error = %v", errStream)
+ }
+ for range stream.Chunks {
+ }
+
+ afterStream, ok := m.GetByID(primary.ID)
+ if !ok || afterStream == nil {
+ t.Fatal("primary auth missing after in-stream 401")
+ }
+ if afterStream.RejectedAccessToken != staleJWTToken {
+ t.Fatalf("RejectedAccessToken = %q, want %q after in-stream 401", afterStream.RejectedAccessToken, staleJWTToken)
+ }
+ if !m.shouldRefresh(afterStream, time.Now()) {
+ t.Fatal("shouldRefresh = false after in-stream 401, want true")
+ }
+
+ // Background refresh loop must pick it up and recover the credential.
+ loop := newAuthAutoRefreshLoop(m, 5*time.Second, 1)
+ loop.handleDueAuth(context.Background(), time.Now(), primary.ID)
+ select {
+ case job := <-loop.jobs:
+ if !m.beginRefreshJob(context.Background(), job) {
+ t.Fatal("beginRefreshJob returned false")
+ }
+ if _, errRefresh := m.refreshAuthForRequestAtEpoch(context.Background(), job.id, "", job.registrationEpoch); errRefresh != nil {
+ t.Fatalf("background refresh error = %v", errRefresh)
+ }
+ m.finishRefreshJob(job, time.Time{}, false)
+ default:
+ t.Fatal("expected handleDueAuth to enqueue refresh job after in-stream 401")
+ }
+
+ recovered, ok := m.GetByID(primary.ID)
+ if !ok || recovered == nil {
+ t.Fatal("primary auth missing after recovery")
+ }
+ if got := authAccessToken(recovered); got != freshJWTToken {
+ t.Fatalf("recovered access_token = %q, want %q", got, freshJWTToken)
+ }
+ if recovered.Unavailable || recovered.Status != StatusActive {
+ t.Fatalf("recovered Unavailable=%v Status=%s, want false/StatusActive", recovered.Unavailable, recovered.Status)
+ }
+ if state := recovered.ModelStates[model]; state != nil && state.Unavailable {
+ t.Fatalf("model state still Unavailable after recovery: %+v", state)
+ }
+}
+
+func TestIssue6742_InStreamUnauthorizedBlocksSiblingModelShardUntilRefreshed(t *testing.T) {
+ now := time.Now()
+ staleJWTToken := makeIssue6742JWT(now.Add(3 * 24 * time.Hour))
+ freshJWTToken := makeIssue6742JWT(now.Add(10 * 24 * time.Hour))
+ backupJWTToken := makeIssue6742JWT(now.Add(10 * 24 * time.Hour))
+ modelA := "gpt-5.6-luna"
+ modelB := "gpt-6-astra"
+
+ primary := &Auth{
+ ID: "aa-codex-multimodel-primary",
+ Provider: "codex",
+ Status: StatusActive,
+ Metadata: map[string]any{
+ "type": "codex",
+ "access_token": staleJWTToken,
+ "refresh_token": "primary-refresh-token",
+ "expired": now.Add(3 * 24 * time.Hour).Format(time.RFC3339),
+ },
+ }
+ backup := &Auth{
+ ID: "bb-codex-multimodel-backup",
+ Provider: "codex",
+ Status: StatusActive,
+ Metadata: map[string]any{
+ "type": "codex",
+ "access_token": backupJWTToken,
+ "refresh_token": "backup-refresh-token",
+ "expired": now.Add(10 * 24 * time.Hour).Format(time.RFC3339),
+ },
+ }
+
+ executor := &inStreamUnauthorizedExecutor{
+ unauthorizedRefreshExecutor: unauthorizedRefreshExecutor{
+ id: "codex",
+ tokenInvalid: map[string]struct{}{},
+ refreshTokens: map[string]string{
+ primary.ID: freshJWTToken,
+ },
+ },
+ }
+
+ m := NewManager(nil, &FillFirstSelector{}, nil)
+ m.RegisterExecutor(executor)
+
+ reg := registry.GetGlobalRegistry()
+ reg.RegisterClient(primary.ID, "codex", []*registry.ModelInfo{{ID: modelA}, {ID: modelB}})
+ reg.RegisterClient(backup.ID, "codex", []*registry.ModelInfo{{ID: modelA}, {ID: modelB}})
+ t.Cleanup(func() {
+ reg.UnregisterClient(primary.ID)
+ reg.UnregisterClient(backup.ID)
+ })
+
+ if _, errRegister := m.Register(context.Background(), primary); errRegister != nil {
+ t.Fatalf("register primary: %v", errRegister)
+ }
+ if _, errRegister := m.Register(context.Background(), backup); errRegister != nil {
+ t.Fatalf("register backup: %v", errRegister)
+ }
+
+ // Pre-warm the scheduler shard for sibling modelB while primary is healthy.
+ respWarm, errWarm := m.Execute(context.Background(), []string{"codex"}, cliproxyexecutor.Request{Model: modelB}, cliproxyexecutor.Options{})
+ if errWarm != nil {
+ t.Fatalf("warm-up Execute(modelB) error = %v", errWarm)
+ }
+ if got := string(respWarm.Payload); got != primary.ID+":"+staleJWTToken {
+ t.Fatalf("warm-up Execute(modelB) payload = %q, want primary", got)
+ }
+
+ // Now upstream invalidates staleJWTToken, and modelA hits an in-stream 401.
+ executor.mu.Lock()
+ executor.tokenInvalid[staleJWTToken] = struct{}{}
+ executor.mu.Unlock()
+
+ stream, errStream := m.ExecuteStream(context.Background(), []string{"codex"}, cliproxyexecutor.Request{Model: modelA}, cliproxyexecutor.Options{})
+ if errStream != nil {
+ t.Fatalf("ExecuteStream(modelA) initial error = %v", errStream)
+ }
+ for range stream.Chunks {
+ }
+
+ // Before background refresh runs, a request for sibling modelB must NOT select primary
+ // from the pre-warmed modelB scheduler shard with the rejected token; it must route to backup.
+ execCallsBefore := len(executor.ExecuteCalls())
+ respB, errB := m.Execute(context.Background(), []string{"codex"}, cliproxyexecutor.Request{Model: modelB}, cliproxyexecutor.Options{})
+ if errB != nil {
+ t.Fatalf("Execute(modelB) before refresh error = %v, want success via backup", errB)
+ }
+ if got := string(respB.Payload); got != backup.ID+":"+backupJWTToken {
+ t.Fatalf("Execute(modelB) before refresh payload = %q, want backup %q", got, backup.ID+":"+backupJWTToken)
+ }
+ for _, id := range executor.ExecuteCalls()[execCallsBefore:] {
+ if id == primary.ID {
+ t.Fatalf("sibling modelB selected primary with rejected token before refresh completed")
+ }
+ }
+}
diff --git a/sdk/cliproxy/auth/conductor_lifecycle.go b/sdk/cliproxy/auth/conductor_lifecycle.go
index f513c6270f0..852eb5d84eb 100644
--- a/sdk/cliproxy/auth/conductor_lifecycle.go
+++ b/sdk/cliproxy/auth/conductor_lifecycle.go
@@ -138,6 +138,7 @@ func (m *Manager) Register(ctx context.Context, auth *Auth) (*Auth, error) {
m.mu.Unlock()
return nil, fmt.Errorf("auth %s changed registration during persistence", auth.ID)
}
+ auth.ClearFileSnapshot()
authClone := auth.Clone()
m.auths[auth.ID] = authClone
m.notifyAuthChangeLocked(auth.ID)
@@ -188,24 +189,127 @@ func (m *Manager) Update(ctx context.Context, auth *Auth) (*Auth, error) {
return m.updateInternal(ctx, nil, auth, updateModeReplace)
}
+// Modify loads the latest auth for id while holding the per-auth mutation gate,
+// applies edit to a clone of that auth, and commits the result through the
+// standard persistence and publication path. It returns (nil, false, nil) if
+// the auth is not present when the gate is acquired.
+func (m *Manager) Modify(ctx context.Context, id string, expectedEpoch uint64, edit func(*Auth) error) (*Auth, bool, error) {
+ id = strings.TrimSpace(id)
+ if m == nil || id == "" || edit == nil {
+ return nil, false, nil
+ }
+ releaseMutation, errMutation := m.lockAuthMutationContext(ctx, id)
+ if errMutation != nil {
+ return nil, false, errMutation
+ }
+ defer releaseMutation()
+
+ m.mu.RLock()
+ existing, ok := m.auths[id]
+ var current *Auth
+ var currentEpoch uint64
+ if ok && existing != nil {
+ current = existing.Clone()
+ currentEpoch = existing.RegistrationEpoch
+ if m.authEpochs != nil && m.authEpochs[id] > currentEpoch {
+ currentEpoch = m.authEpochs[id]
+ }
+ }
+ m.mu.RUnlock()
+ if current == nil {
+ return nil, false, nil
+ }
+ if expectedEpoch != 0 && expectedEpoch < currentEpoch {
+ return nil, false, fmt.Errorf("update auth %s: stale registration epoch %d < %d", id, expectedEpoch, currentEpoch)
+ }
+ if errEdit := edit(current); errEdit != nil {
+ return nil, false, errEdit
+ }
+ if expectedEpoch != 0 {
+ current.RegistrationEpoch = expectedEpoch
+ }
+ committedAuth, didCommit, persistCooldown, errUpdate := m.updateInternalLocked(ctx, nil, current, updateModeReplace)
+ releaseMutation()
+ if errUpdate == nil && didCommit && committedAuth != nil {
+ m.finishAuthUpdate(ctx, committedAuth, persistCooldown)
+ }
+ return committedAuth, errUpdate == nil && didCommit && committedAuth != nil, errUpdate
+}
+
func (m *Manager) updateInternal(ctx context.Context, base, auth *Auth, mode updateAuthMode) (*Auth, error) {
if auth == nil || auth.ID == "" {
return nil, nil
}
- NormalizeCredentialMetadata(auth.Metadata)
- if errWeight := ValidateAuthWeight(auth); errWeight != nil {
- return nil, fmt.Errorf("update auth: %w", errWeight)
- }
releaseMutation, errMutation := m.lockAuthMutationContext(ctx, auth.ID)
if errMutation != nil {
return nil, errMutation
}
- defer releaseMutation()
+ committedAuth, didCommit, persistCooldown, err := m.updateInternalLocked(ctx, base, auth, mode)
+ releaseMutation()
+ if err == nil && didCommit && committedAuth != nil {
+ m.finishAuthUpdate(ctx, committedAuth, persistCooldown)
+ }
+ return committedAuth, err
+}
+
+// finishAuthUpdate runs post-commit side effects after the per-auth mutation
+// gate has been released, preserving the original update ordering in which
+// hooks and scheduler updates could re-enter manager operations.
+func (m *Manager) finishAuthUpdate(ctx context.Context, committed *Auth, persistCooldown bool) {
+ if committed == nil {
+ return
+ }
+ if !shouldDeferAPIKeyModelAliasRebuild(ctx) {
+ m.rebuildAPIKeyModelAliasFromRuntimeConfig()
+ }
+ if m.scheduler != nil {
+ m.scheduler.upsertAuth(committed.Clone())
+ }
+ m.structuralEpoch.Add(1)
+ m.queueRefreshReschedule(committed.ID)
+ m.hook.OnAuthUpdated(ctx, committed.Clone())
+ if persistCooldown {
+ m.persistCooldownStates(context.Background())
+ }
+}
+
+// updateInternalLocked assumes the caller already holds the per-auth
+// mutation lock. It only commits manager state; callers run
+// finishAuthUpdate after releasing the gate. persistCooldownStates is also
+// deferred by callers because it may re-enter manager operations.
+// updateInternalLocked commits an update while the caller holds the gate.
+// The second return value reports whether this call actually committed new
+// state (versus returning the current snapshot for a discarded stale
+// update); only real commits may run post-commit side effects.
+func (m *Manager) updateInternalLocked(ctx context.Context, base, auth *Auth, mode updateAuthMode) (committedAuth *Auth, committed bool, persistCooldown bool, err error) {
+ if auth == nil || auth.ID == "" {
+ return nil, false, false, nil
+ }
+ NormalizeCredentialMetadata(auth.Metadata)
+ if errWeight := ValidateAuthWeight(auth); errWeight != nil {
+ return nil, false, false, fmt.Errorf("update auth: %w", errWeight)
+ }
+ if mode == updateModeReplace && auth.HasFileSnapshot() {
+ m.mu.RLock()
+ existing, ok := m.auths[auth.ID]
+ m.mu.RUnlock()
+ if !ok || existing == nil {
+ return nil, false, false, nil
+ }
+ // Validate file snapshot freshness under the per-auth mutation gate
+ // without holding m.mu across filesystem I/O.
+ if !auth.FileSnapshotMatchesDisk() {
+ m.mu.RLock()
+ current := m.auths[auth.ID].Clone()
+ m.mu.RUnlock()
+ return current, false, false, nil
+ }
+ }
m.mu.Lock()
existing, ok := m.auths[auth.ID]
if !ok || existing == nil {
m.mu.Unlock()
- return nil, nil
+ return nil, false, false, nil
}
if m.authEpochs == nil {
m.authEpochs = make(map[string]uint64)
@@ -215,14 +319,16 @@ func (m *Manager) updateInternal(ctx context.Context, base, auth *Auth, mode upd
}
if (mode == updateModeRefresh || mode == updateModePrepare) && base != nil && existing.RegistrationEpoch != base.RegistrationEpoch {
m.mu.Unlock()
- return nil, fmt.Errorf("update auth %s: stale registration epoch %d != %d", auth.ID, base.RegistrationEpoch, existing.RegistrationEpoch)
+ return nil, false, false, fmt.Errorf("update auth %s: stale registration epoch %d != %d", auth.ID, base.RegistrationEpoch, existing.RegistrationEpoch)
}
// Do not let an in-flight refresh overwrite credentials committed after its snapshot.
if mode == updateModeRefresh && base != nil && (existing.CredentialVersion != base.CredentialVersion || CredentialsChanged(base, existing)) {
if auth.CodexRefreshPatch == nil || !auth.CodexRefreshPatch.CanApplyTo(existing.Metadata) {
current := existing.Clone()
m.mu.Unlock()
- return current, nil
+ // Discarded stale refresh: committed=false so callers do not
+ // run finishAuthUpdate for a state this call did not change.
+ return current, false, false, nil
}
}
if mode == updateModeRefresh {
@@ -240,7 +346,7 @@ func (m *Manager) updateInternal(ctx context.Context, base, auth *Auth, mode upd
}
if auth.RegistrationEpoch != 0 && auth.RegistrationEpoch < m.authEpochs[auth.ID] {
m.mu.Unlock()
- return nil, fmt.Errorf("update auth %s: stale registration epoch %d < %d", auth.ID, auth.RegistrationEpoch, m.authEpochs[auth.ID])
+ return nil, false, false, fmt.Errorf("update auth %s: stale registration epoch %d < %d", auth.ID, auth.RegistrationEpoch, m.authEpochs[auth.ID])
}
if auth.RegistrationEpoch >= m.authEpochs[auth.ID] {
m.authEpochs[auth.ID] = auth.RegistrationEpoch
@@ -290,10 +396,20 @@ func (m *Manager) updateInternal(ctx context.Context, base, auth *Auth, mode upd
} else {
auth.RejectedAccessToken = existing.RejectedAccessToken
}
- if existing.Quota.Exceeded && existing.Quota.Reason == "credential_quota" && existing.Quota.NextRecoverAt.After(time.Now()) {
+ // A refresh that confirms quota restoration (issue #6690) releases the
+ // credential-wide cooldown early; otherwise the active credential
+ // quota below is preserved exactly as before.
+ devinQuotaRestored := mode == updateModeRefresh && applyDevinQuotaRestoration(auth, existing, time.Now())
+ if devinQuotaRestored {
+ cooldownStateChanged = true
+ } else if existing.Quota.Exceeded && existing.Quota.Reason == "credential_quota" && existing.Quota.NextRecoverAt.After(time.Now()) {
auth.Unavailable = existing.Unavailable
auth.NextRetryAfter = existing.NextRetryAfter
+ // Preserve cooldown fields; keep the newest observation snapshot.
+ observation := mergeQuotaObservation(auth.Quota, existing.Quota)
auth.Quota = existing.Quota
+ auth.Quota.ObservedAt = observation.ObservedAt
+ auth.Quota.Signals = observation.Signals
if auth.Status == StatusActive {
auth.Status = existing.Status
}
@@ -322,19 +438,20 @@ func (m *Manager) updateInternal(ctx context.Context, base, auth *Auth, mode upd
// A minted Meta key must reach the store before requests can use it.
if persistMetaMint {
m.mu.Unlock()
- return nil, fmt.Errorf("persist meta auth: %w", errPersist)
+ return nil, false, false, fmt.Errorf("persist meta auth: %w", errPersist)
}
// Ordinary persistence failures remain non-fatal, but never silent.
log.WithFields(log.Fields{"auth_id": auth.ID, "credential": auth.ID, "provider": auth.Provider}).Warnf("failed to persist updated auth %s (%s): %v", auth.Provider, auth.ID, errPersist)
}
if m.authEpochs[auth.ID] != auth.RegistrationEpoch {
m.mu.Unlock()
- return nil, fmt.Errorf("auth %s changed registration during persistence", auth.ID)
+ return nil, false, false, fmt.Errorf("auth %s changed registration during persistence", auth.ID)
}
mergeAuthSaveDelta(auth, existingBeforeSave, existing, false)
if existing.Generation >= auth.Generation {
auth.Generation = existing.Generation + 1
}
+ auth.ClearFileSnapshot()
authClone := auth.Clone()
m.auths[auth.ID] = authClone
m.notifyAuthChangeLocked(auth.ID)
@@ -344,20 +461,8 @@ func (m *Manager) updateInternal(ctx context.Context, base, auth *Auth, mode upd
schedulerSnapshot = authClone.Clone()
}
m.mu.Unlock()
- releaseMutation()
- if !shouldDeferAPIKeyModelAliasRebuild(ctx) {
- m.rebuildAPIKeyModelAliasFromRuntimeConfig()
- }
- if m.scheduler != nil {
- m.scheduler.upsertAuth(schedulerSnapshot)
- }
- m.structuralEpoch.Add(1)
- m.queueRefreshReschedule(auth.ID)
- m.hook.OnAuthUpdated(ctx, auth.Clone())
- if cooldownStateChanged {
- m.persistCooldownStates(context.Background())
- }
- return auth.Clone(), nil
+ _ = schedulerSnapshot
+ return auth.Clone(), true, cooldownStateChanged, nil
}
// Remove deletes an auth from runtime state without persisting.
diff --git a/sdk/cliproxy/auth/conductor_refresh.go b/sdk/cliproxy/auth/conductor_refresh.go
index feb47d88072..caed3ebc100 100644
--- a/sdk/cliproxy/auth/conductor_refresh.go
+++ b/sdk/cliproxy/auth/conductor_refresh.go
@@ -127,6 +127,9 @@ func (m *Manager) shouldRefresh(a *Auth, now time.Time) bool {
if evaluator, ok := a.Runtime.(RefreshEvaluator); ok && evaluator != nil {
return evaluator.ShouldRefresh(now, a)
}
+ if next, due := devinQuotaRevalidateAt(a, now); due {
+ return !next.After(now)
+ }
if _, hasSIWC, needSIWC := CodexSIWCNeedsRefresh(a, now); hasSIWC {
if needSIWC {
@@ -174,7 +177,7 @@ func (m *Manager) shouldRefresh(a *Auth, now time.Time) bool {
return false
}
if hasExpiry && !expiry.IsZero() {
- return time.Until(expiry) <= *lead
+ return expiry.Sub(now) <= *lead
}
if !lastRefresh.IsZero() {
return now.Sub(lastRefresh) >= *lead
@@ -502,6 +505,149 @@ func ClearUnauthorizedModelStates(auth *Auth, now time.Time) []string {
return clearUnauthorizedModelStates(auth, now)
}
+// devinQuotaRevalidateInterval bounds how often a quota-exhausted Devin
+// credential re-queries upstream status for early restoration (issue #6690).
+const devinQuotaRevalidateInterval = time.Hour
+
+// devinQuotaRevalidateAt returns the next quota revalidation instant for a
+// Devin auth under an active credential quota. Devin session tokens never
+// expire, so without this the auto-refresh loop would never re-observe quota
+// and early restoration could never be detected. It is scoped strictly to
+// the exhausted state; healthy credentials are unaffected.
+func devinQuotaRevalidateAt(a *Auth, now time.Time) (time.Time, bool) {
+ if a == nil || !strings.EqualFold(strings.TrimSpace(a.Provider), "devin") {
+ return time.Time{}, false
+ }
+ if !a.Quota.Exceeded || a.Quota.Reason != "credential_quota" || !a.Quota.NextRecoverAt.After(now) {
+ return time.Time{}, false
+ }
+ since := a.LastRefreshedAt
+ if since.IsZero() {
+ since = a.Quota.ObservedAt
+ }
+ if since.IsZero() {
+ return now, true
+ }
+ next := since.Add(devinQuotaRevalidateInterval)
+ if next.After(a.Quota.NextRecoverAt) {
+ next = a.Quota.NextRecoverAt
+ }
+ return next, true
+}
+
+// devinQuotaObservationRestored reports whether the refreshed auth carries a
+// newer Devin quota observation confirming daily and weekly quota are
+// available again while existing is under an active credential quota. The
+// observation must be strictly newer than both the stored snapshot and the
+// moment the exhausted state was last written, so a stale or replayed
+// refresh can never resurrect an exhausted credential.
+func devinQuotaObservationRestored(auth, existing *Auth, now time.Time) bool {
+ if auth == nil || existing == nil {
+ return false
+ }
+ if !strings.EqualFold(strings.TrimSpace(existing.Provider), "devin") {
+ return false
+ }
+ if !existing.Quota.Exceeded || existing.Quota.Reason != "credential_quota" || !existing.Quota.NextRecoverAt.After(now) {
+ return false
+ }
+ observedAt := auth.Quota.ObservedAt
+ if observedAt.IsZero() || observedAt.After(now) {
+ return false
+ }
+ if !observedAt.After(existing.Quota.ObservedAt) || !existing.UpdatedAt.IsZero() && observedAt.Before(existing.UpdatedAt) {
+ return false
+ }
+ daily, okDaily := devinRemainingPercent(auth.Quota.Signals["daily_quota_remaining_percent"])
+ weekly, okWeekly := devinRemainingPercent(auth.Quota.Signals["weekly_quota_remaining_percent"])
+ return okDaily && okWeekly && daily > 0 && weekly > 0
+}
+
+// applyDevinQuotaRestoration releases a Devin credential-wide quota cooldown
+// early when the refreshed auth carries an authoritative, newer observation
+// showing both usage quotas available again (issue #6690). It never touches
+// unauthorized failures, non-quota failure states, or model states whose
+// block is not the auth-level credential quota propagation; unrelated
+// cooldowns survive via aggregated availability recomputation.
+func applyDevinQuotaRestoration(auth, existing *Auth, now time.Time) bool {
+ if !devinQuotaObservationRestored(auth, existing, now) {
+ return false
+ }
+ if hasUnauthorizedAuthFailure(existing) {
+ return false
+ }
+
+ applyCooldownFields(&auth.Quota, QuotaState{})
+ for _, state := range auth.ModelStates {
+ if state == nil {
+ continue
+ }
+ reason := strings.TrimSpace(state.Quota.Reason)
+ wrapped := state.credentialQuotaPropagated || reason == "credential_quota"
+ if !state.Quota.Exceeded || (reason != "quota" && !wrapped) {
+ continue
+ }
+ // A wrapped sibling carries its independent layer in the shadow.
+ // Reinstate that exact block (if still live) instead of erasing it;
+ // the reporting model ("quota" unwrapped) and pure propagation
+ // targets (no shadow) fully release. Deadline or status heuristics
+ // cannot recover this provenance reliably.
+ if shadow := state.credentialQuotaShadow; shadow != nil && isModelStateActiveCooldown(shadow, now) {
+ *state = *shadow.cloneShallow()
+ state.UpdatedAt = now
+ continue
+ }
+ resetModelState(state, now)
+ }
+ auth.Status = StatusActive
+ auth.StatusMessage = ""
+ auth.LastError = nil
+ auth.Unavailable = false
+ auth.NextRetryAfter = time.Time{}
+ if len(auth.ModelStates) > 0 {
+ updateAggregatedAvailability(auth, now)
+ }
+ return true
+}
+
+// isDevinQuotaExhaustionMessage reports whether text is Devin's explicit
+// daily/weekly usage-quota exhaustion message. It mirrors the executor's
+// narrow classifier and is used only to tell credential-derived legacy
+// "quota" states apart from genuine model-scoped rate limits.
+func isDevinQuotaExhaustionMessage(text string) bool {
+ lower := strings.ToLower(text)
+ if !strings.Contains(lower, "quota") || !strings.Contains(lower, "exhausted") {
+ return false
+ }
+ return strings.Contains(lower, "weekly") || strings.Contains(lower, "daily")
+}
+
+func devinRecordErrorText(record CooldownStateRecord) string {
+ if record.LastError != nil && strings.TrimSpace(record.LastError.Message) != "" {
+ return record.LastError.Message
+ }
+ return ""
+}
+
+func devinStateErrorText(state *ModelState) string {
+ if state == nil {
+ return ""
+ }
+ if state.LastError != nil && strings.TrimSpace(state.LastError.Message) != "" {
+ return state.LastError.Message
+ }
+ return state.StatusMessage
+}
+
+// devinRemainingPercent parses a Devin quota signal such as "55%".
+func devinRemainingPercent(raw string) (int64, bool) {
+ value, errParse := strconv.ParseInt(strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(raw), "%")), 10, 64)
+ if errParse != nil {
+ return 0, false
+ }
+ return value, true
+}
+
func clearUnauthorizedModelStates(auth *Auth, now time.Time) []string {
if auth == nil || len(auth.ModelStates) == 0 {
return nil
@@ -648,15 +794,13 @@ func (m *Manager) markRejectedAccessToken(id, failedAccessToken string) {
releaseMutation := m.lockAuthMutation(id)
defer releaseMutation()
m.mu.Lock()
- if current := m.auths[id]; current != nil && authAccessToken(current) == failedAccessToken {
- if _, hasExp := current.AccessTokenExpirationTime(); !hasExp {
- current.Generation++
- current.UpdatedAt = time.Now()
- current.RejectedAccessToken = failedAccessToken
- m.auths[id] = current
- if m.scheduler != nil {
- m.scheduler.upsertAuth(current.Clone())
- }
+ if current := m.auths[id]; current != nil && authAccessToken(current) == failedAccessToken && current.RejectedAccessToken != failedAccessToken {
+ current.Generation++
+ current.UpdatedAt = time.Now()
+ current.RejectedAccessToken = failedAccessToken
+ m.auths[id] = current
+ if m.scheduler != nil {
+ m.scheduler.upsertAuth(current.Clone())
}
}
m.mu.Unlock()
diff --git a/sdk/cliproxy/auth/conductor_selection.go b/sdk/cliproxy/auth/conductor_selection.go
index 13bc0ba2480..96981cc2831 100644
--- a/sdk/cliproxy/auth/conductor_selection.go
+++ b/sdk/cliproxy/auth/conductor_selection.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
+ "maps"
"math/rand/v2"
"net/http"
"reflect"
@@ -42,25 +43,31 @@ func (m *Manager) hasPluginScheduler() bool {
return true
}
-func (m *Manager) pluginSchedulerWantsAcrossPrioritiesLocked() bool {
- if m == nil || m.pluginScheduler == nil {
+func pluginSchedulerWantsAcrossPriorities(scheduler PluginScheduler) bool {
+ if scheduler == nil {
return false
}
- if opt, ok := m.pluginScheduler.(PluginSchedulerAcrossPriorities); ok && opt != nil {
+ if opt, ok := scheduler.(PluginSchedulerAcrossPriorities); ok && opt != nil {
return opt.SchedulerWantsAcrossPriorities()
}
return false
}
-// PluginSchedulerWantsAcrossPriorities reports whether the configured plugin scheduler
-// opted into receiving candidates across all priority tiers.
-func (m *Manager) PluginSchedulerWantsAcrossPriorities() bool {
+func (m *Manager) snapshotPluginScheduler() (PluginScheduler, bool) {
if m == nil {
- return false
+ return nil, false
}
m.mu.RLock()
- defer m.mu.RUnlock()
- return m.pluginSchedulerWantsAcrossPrioritiesLocked()
+ scheduler := m.pluginScheduler
+ m.mu.RUnlock()
+ return scheduler, pluginSchedulerWantsAcrossPriorities(scheduler)
+}
+
+// PluginSchedulerWantsAcrossPriorities reports whether the configured plugin scheduler
+// opted into receiving candidates across all priority tiers.
+func (m *Manager) PluginSchedulerWantsAcrossPriorities() bool {
+ _, wants := m.snapshotPluginScheduler()
+ return wants
}
func isBuiltInSelector(selector Selector) bool {
@@ -621,9 +628,8 @@ func (m *Manager) availableAuthsForRouteModelWithPriorityMode(auths []*Auth, pro
// the plugin scheduler, plus the candidates handed to the configured selector. Both are equal
// unless session affinity or an across-priorities scheduler is active, in which case the selector
// or scheduler additionally receives lower priority tiers.
-func (m *Manager) availableAuthsForSelector(selector Selector, auths []*Auth, provider, routeModel string, now time.Time) (priorityAuths, selectorAuths []*Auth, err error) {
+func (m *Manager) availableAuthsForSelector(selector Selector, auths []*Auth, provider, routeModel string, now time.Time, schedulerAcross bool) (priorityAuths, selectorAuths []*Auth, err error) {
_, sessionAffinity := selector.(*SessionAffinitySelector)
- schedulerAcross := m.pluginSchedulerWantsAcrossPrioritiesLocked()
if !sessionAffinity && !schedulerAcross {
priorityAuths, err = m.availableAuthsForRouteModel(auths, provider, routeModel, now)
@@ -1564,6 +1570,69 @@ func (m *Manager) GetByID(id string) (*Auth, bool) {
return auth.Clone(), true
}
+// PublishIfRegistrationCurrent serializes the final model-registration
+// publication against identity mutations for this auth, then calls publish
+// only if the manager still holds auth with the same model-registration
+// inputs. It does not persist or clone auth state. publish must be fast and
+// must not re-enter the manager.
+func (m *Manager) PublishIfRegistrationCurrent(id string, registrationEpoch uint64, prefix, provider string, disabled bool, authKind string, attributes map[string]string, publish func() bool) bool {
+ if m == nil || id == "" || publish == nil {
+ return false
+ }
+ releaseMutation := m.lockAuthMutation(id)
+ defer releaseMutation()
+ m.mu.RLock()
+ current, ok := m.auths[id]
+ matches := ok && current != nil && modelRegistrationInputsMatch(current, registrationEpoch, prefix, provider, disabled, authKind, attributes)
+ m.mu.RUnlock()
+ if !matches {
+ return false
+ }
+ return publish()
+}
+
+// UpdateIfRegistrationCurrent applies a plugin-returned auth update only
+// while the manager still holds the exact registration inputs the plugin
+// computation started from. It prevents an old plugin result from adopting
+// a removed or re-registered identity merely because its zero registration
+// epoch would otherwise inherit the current epoch during Update.
+func (m *Manager) UpdateIfRegistrationCurrent(ctx context.Context, base *Auth, updated *Auth) (*Auth, bool, error) {
+ if m == nil || base == nil || updated == nil || base.ID == "" || updated.ID != base.ID {
+ return nil, false, nil
+ }
+ releaseMutation, errMutation := m.lockAuthMutationContext(ctx, base.ID)
+ if errMutation != nil {
+ return nil, false, errMutation
+ }
+ defer releaseMutation()
+ m.mu.RLock()
+ current, ok := m.auths[base.ID]
+ matches := ok && current != nil && modelRegistrationInputsMatch(current, base.RegistrationEpoch, base.Prefix, base.Provider, base.Disabled || base.Status == StatusDisabled, base.AuthKind(), base.Attributes)
+ m.mu.RUnlock()
+ if !matches {
+ return nil, false, nil
+ }
+ committedAuth, didCommit, persistCooldown, errUpdate := m.updateInternalLocked(ctx, nil, updated, updateModeReplace)
+ releaseMutation()
+ if errUpdate == nil && didCommit && committedAuth != nil {
+ m.finishAuthUpdate(ctx, committedAuth, persistCooldown)
+ }
+ return committedAuth, errUpdate == nil && didCommit && committedAuth != nil, errUpdate
+}
+
+func modelRegistrationInputsMatch(current *Auth, registrationEpoch uint64, prefix, provider string, disabled bool, authKind string, attributes map[string]string) bool {
+ if current == nil {
+ return false
+ }
+ currentDisabled := current.Disabled || current.Status == StatusDisabled
+ return current.RegistrationEpoch == registrationEpoch &&
+ current.Prefix == prefix &&
+ current.Provider == provider &&
+ currentDisabled == disabled &&
+ current.AuthKind() == authKind &&
+ maps.Equal(current.Attributes, attributes)
+}
+
// GetExecutionSessionAuthByID retrieves a Home runtime auth scoped to an execution session.
func (m *Manager) GetExecutionSessionAuthByID(sessionID string, authID string) (*Auth, bool) {
sessionID = strings.TrimSpace(sessionID)
@@ -1759,11 +1828,11 @@ func (m *Manager) pickNextLegacy(ctx context.Context, provider, model string, op
pinnedAuthID := pinnedAuthIDFromMetadata(opts.Metadata)
eligibility := authSelectionEligibilityForRequest(ctx, opts)
+ pluginScheduler, schedulerAcross := m.snapshotPluginScheduler()
m.mu.RLock()
selector := m.selector
opts.Metadata[cliproxyexecutor.SessionAffinityModelMetadataKey] = selectionArgForSelector(selector, model)
- pluginScheduler := m.pluginScheduler
executor, okExecutor := m.executorLocked(provider)
if !okExecutor {
m.mu.RUnlock()
@@ -1812,7 +1881,7 @@ func (m *Manager) pickNextLegacy(ctx context.Context, provider, model string, op
return nil, nil, &Error{Code: "auth_not_found", Message: "no auth available"}
}
now := time.Now()
- available, selectorAuths, errAvailable := m.availableAuthsForSelector(selector, candidates, provider, model, now)
+ available, selectorAuths, errAvailable := m.availableAuthsForSelector(selector, candidates, provider, model, now, schedulerAcross)
if errAvailable != nil {
m.mu.RUnlock()
m.warnLogAuthUnavailable(ctx, []string{provider}, model, opts, tried, errAvailable)
@@ -2103,11 +2172,11 @@ func (m *Manager) pickNextMixedLegacy(ctx context.Context, providers []string, m
if len(providerSet) == 0 {
return nil, nil, "", &Error{Code: "provider_not_found", Message: "no provider supplied"}
}
+ pluginScheduler, schedulerAcross := m.snapshotPluginScheduler()
m.mu.RLock()
selector := m.selector
opts.Metadata[cliproxyexecutor.SessionAffinityModelMetadataKey] = selectionArgForSelector(selector, model)
- pluginScheduler := m.pluginScheduler
candidates := make([]*Auth, 0, len(m.auths))
modelKey := strings.TrimSpace(model)
// Always use base model name (without thinking suffix) for auth matching.
@@ -2160,7 +2229,7 @@ func (m *Manager) pickNextMixedLegacy(ctx context.Context, providers []string, m
return nil, nil, "", &Error{Code: "auth_not_found", Message: "no auth available"}
}
now := time.Now()
- available, selectorAuths, errAvailable := m.availableAuthsForSelector(selector, candidates, "mixed", model, now)
+ available, selectorAuths, errAvailable := m.availableAuthsForSelector(selector, candidates, "mixed", model, now, schedulerAcross)
if errAvailable != nil {
m.mu.RUnlock()
m.warnLogAuthUnavailable(ctx, providers, model, opts, tried, errAvailable)
diff --git a/sdk/cliproxy/auth/conductor_stream.go b/sdk/cliproxy/auth/conductor_stream.go
index 9558f813edf..2c1daabb4f9 100644
--- a/sdk/cliproxy/auth/conductor_stream.go
+++ b/sdk/cliproxy/auth/conductor_stream.go
@@ -130,19 +130,19 @@ func readStreamBootstrap(ctx context.Context, ch <-chan cliproxyexecutor.StreamC
// RetryAfter) when the request context was canceled while the executor was returning, for
// example because the downstream connection was closed in response to the same upstream error.
// Errors without RetryAfter, such as plain client cancellations, are not recorded.
-func (m *Manager) recordCanceledUpstreamCooldown(ctx context.Context, auth *Auth, provider, resultModel, routeModel string, opts cliproxyexecutor.Options, err error, ephemeral bool) {
+func (m *Manager) recordCanceledUpstreamCooldown(ctx context.Context, auth *Auth, provider, resultModel, routeModel string, opts cliproxyexecutor.Options, err error, ephemeral bool, executionSeq uint64) {
retryAfter := retryAfterFromError(err)
if m == nil || auth == nil || retryAfter == nil {
return
}
action, okAction := matchRequestScopedErrorAction(auth, err, m.runtimeConfigSnapshot())
- result := Result{AuthID: auth.ID, Provider: provider, Model: resultModel, RouteModel: routeModel, Success: false, Error: resultErrorFromError(err), Options: opts, RetryAfter: retryAfter, CredentialVersion: auth.CredentialVersion, RegistrationEpoch: auth.RegistrationEpoch}
+ result := Result{AuthID: auth.ID, Provider: provider, Model: resultModel, RouteModel: routeModel, Success: false, Error: resultErrorFromError(err), Options: opts, RetryAfter: retryAfter, CredentialVersion: auth.CredentialVersion, RegistrationEpoch: auth.RegistrationEpoch, ExecutionSeq: executionSeq}
result.CredentialScope = isCredentialScopedError(err)
applyRequestScopedActionToResult(action, okAction, &result)
m.recordExecutionResult(context.WithoutCancel(ctx), result, auth, ephemeral)
}
-func (m *Manager) wrapStreamResult(ctx context.Context, auth *Auth, provider, resultModel, routeModel string, headers http.Header, buffered []cliproxyexecutor.StreamChunk, remaining <-chan cliproxyexecutor.StreamChunk, aliasResult OAuthModelAliasResult, ephemeralResult bool, opts cliproxyexecutor.Options) *cliproxyexecutor.StreamResult {
+func (m *Manager) wrapStreamResult(ctx context.Context, auth *Auth, provider, resultModel, routeModel string, headers http.Header, buffered []cliproxyexecutor.StreamChunk, remaining <-chan cliproxyexecutor.StreamChunk, aliasResult OAuthModelAliasResult, ephemeralResult bool, opts cliproxyexecutor.Options, executionSeq uint64) *cliproxyexecutor.StreamResult {
out := make(chan cliproxyexecutor.StreamChunk)
streamStart := time.Now()
go func() {
@@ -160,7 +160,7 @@ func (m *Manager) wrapStreamResult(ctx context.Context, auth *Auth, provider, re
warnLogUpstreamFailure(ctx, entry, provider, resultModel, auth, time.Since(streamStart), chunk.Err)
rerr := resultErrorFromError(chunk.Err)
action, okAction := matchRequestScopedErrorAction(auth, chunk.Err, m.runtimeConfigSnapshot())
- result := Result{AuthID: auth.ID, Provider: provider, Model: resultModel, RouteModel: routeModel, Success: false, Error: rerr, Options: opts, CredentialVersion: auth.CredentialVersion, RegistrationEpoch: auth.RegistrationEpoch}
+ result := Result{AuthID: auth.ID, Provider: provider, Model: resultModel, RouteModel: routeModel, Success: false, Error: rerr, Options: opts, CredentialVersion: auth.CredentialVersion, RegistrationEpoch: auth.RegistrationEpoch, ExecutionSeq: executionSeq}
result.RetryAfter = retryAfterFromError(chunk.Err)
result.CredentialScope = isCredentialScopedError(chunk.Err)
applyRequestScopedActionToResult(action, okAction, &result)
@@ -221,7 +221,7 @@ func (m *Manager) wrapStreamResult(ctx context.Context, auth *Auth, provider, re
}
}
if !failed && (ephemeralResult || claudeOAuthRequestCancellation(ctx, auth, nil) == nil) {
- m.recordExecutionResult(ctx, Result{AuthID: auth.ID, Provider: provider, Model: resultModel, RouteModel: routeModel, Success: true, Options: opts, CredentialVersion: auth.CredentialVersion, RegistrationEpoch: auth.RegistrationEpoch}, auth, ephemeralResult)
+ m.recordExecutionResult(ctx, Result{AuthID: auth.ID, Provider: provider, Model: resultModel, RouteModel: routeModel, Success: true, Options: opts, CredentialVersion: auth.CredentialVersion, RegistrationEpoch: auth.RegistrationEpoch, ExecutionSeq: executionSeq}, auth, ephemeralResult)
}
}()
return &cliproxyexecutor.StreamResult{Headers: headers, Chunks: out}
@@ -261,6 +261,7 @@ func (m *Manager) executeStreamWithModelPool(ctx context.Context, executor Provi
}
execOpts.Metadata = ensureCanonicalSessionMetadata(execOpts.Metadata, execOpts.Headers, payload)
ctx = syncMetadataSessionToContext(ctx, execOpts.Metadata)
+ executionSeq := m.nextExecutionSeq()
startStream := time.Now()
streamResult, errStream := executor.ExecuteStream(ctx, auth, execReq, execOpts)
errStream = markUpstreamExecutionAttemptFromContext(ctx, errStream)
@@ -270,7 +271,7 @@ func (m *Manager) executeStreamWithModelPool(ctx context.Context, executor Provi
durationStream := time.Since(startStream)
if errStream != nil {
if errCtx := ctx.Err(); errCtx != nil {
- m.recordCanceledUpstreamCooldown(ctx, auth, provider, resultModel, routeModel, execOpts, errStream, ephemeralResult)
+ m.recordCanceledUpstreamCooldown(ctx, auth, provider, resultModel, routeModel, execOpts, errStream, ephemeralResult, executionSeq)
return nil, errCtx
}
if allowRetry && !ephemeralResult {
@@ -282,6 +283,7 @@ func (m *Manager) executeStreamWithModelPool(ctx context.Context, executor Provi
didRefreshOnUnauthorized = true
ctx = newUpstreamAttemptContext(ctx)
ctx = syncMetadataSessionToContext(ctx, execOpts.Metadata)
+ executionSeq = m.nextExecutionSeq()
startRetry := time.Now()
streamResult, errStream = executor.ExecuteStream(ctx, auth, execReq, execOpts)
errStream = markUpstreamExecutionAttemptFromContext(ctx, errStream)
@@ -292,7 +294,7 @@ func (m *Manager) executeStreamWithModelPool(ctx context.Context, executor Provi
if errStream != nil {
warnLogUpstreamFailure(ctx, entry, provider, execModel, auth, durationRetry, errStream)
if errCtx := ctx.Err(); errCtx != nil {
- m.recordCanceledUpstreamCooldown(ctx, auth, provider, resultModel, routeModel, execOpts, errStream, ephemeralResult)
+ m.recordCanceledUpstreamCooldown(ctx, auth, provider, resultModel, routeModel, execOpts, errStream, ephemeralResult, executionSeq)
return nil, errCtx
}
}
@@ -313,7 +315,7 @@ func (m *Manager) executeStreamWithModelPool(ctx context.Context, executor Provi
if errStream != nil {
rerr := resultErrorFromError(errStream)
action, okAction := matchRequestScopedErrorAction(auth, errStream, m.runtimeConfigSnapshot())
- result := Result{AuthID: auth.ID, Provider: provider, Model: resultModel, RouteModel: routeModel, Success: false, Error: rerr, Options: execOpts, CredentialVersion: auth.CredentialVersion, RegistrationEpoch: auth.RegistrationEpoch}
+ result := Result{AuthID: auth.ID, Provider: provider, Model: resultModel, RouteModel: routeModel, Success: false, Error: rerr, Options: execOpts, CredentialVersion: auth.CredentialVersion, RegistrationEpoch: auth.RegistrationEpoch, ExecutionSeq: executionSeq}
result.RetryAfter = retryAfterFromError(errStream)
if isCredentialScopedError(errStream) {
result.CredentialScope = true
@@ -347,7 +349,7 @@ func (m *Manager) executeStreamWithModelPool(ctx context.Context, executor Provi
}
if bootstrapErr != nil {
if errCtx := ctx.Err(); errCtx != nil {
- m.recordCanceledUpstreamCooldown(ctx, auth, provider, resultModel, routeModel, execOpts, bootstrapErr, ephemeralResult)
+ m.recordCanceledUpstreamCooldown(ctx, auth, provider, resultModel, routeModel, execOpts, bootstrapErr, ephemeralResult, executionSeq)
discardStreamChunks(streamResult.Chunks)
return nil, errCtx
}
@@ -360,6 +362,7 @@ func (m *Manager) executeStreamWithModelPool(ctx context.Context, executor Provi
publishSelectedAuthMetadata(execOpts.Metadata, auth)
didRefreshOnUnauthorized = true
ctx = newUpstreamAttemptContext(ctx)
+ executionSeq = m.nextExecutionSeq()
startRetry := time.Now()
retryStream, retryErr := executor.ExecuteStream(ctx, auth, execReq, execOpts)
retryErr = markUpstreamExecutionAttemptFromContext(ctx, retryErr)
@@ -367,7 +370,7 @@ func (m *Manager) executeStreamWithModelPool(ctx context.Context, executor Provi
retryErr = markUpstreamExecutionAttemptFromContext(ctx, retryErr)
if retryErr != nil {
if errCtx := ctx.Err(); errCtx != nil {
- m.recordCanceledUpstreamCooldown(ctx, auth, provider, resultModel, routeModel, execOpts, retryErr, ephemeralResult)
+ m.recordCanceledUpstreamCooldown(ctx, auth, provider, resultModel, routeModel, execOpts, retryErr, ephemeralResult, executionSeq)
return nil, errCtx
}
bootstrapErr = retryErr
@@ -401,7 +404,7 @@ func (m *Manager) executeStreamWithModelPool(ctx context.Context, executor Provi
action, okAction := matchRequestScopedErrorAction(auth, bootstrapErr, m.runtimeConfigSnapshot())
if okAction {
rerr := resultErrorFromError(bootstrapErr)
- result := Result{AuthID: auth.ID, Provider: provider, Model: resultModel, RouteModel: routeModel, Success: false, Error: rerr, Options: execOpts, CredentialVersion: auth.CredentialVersion, RegistrationEpoch: auth.RegistrationEpoch}
+ result := Result{AuthID: auth.ID, Provider: provider, Model: resultModel, RouteModel: routeModel, Success: false, Error: rerr, Options: execOpts, CredentialVersion: auth.CredentialVersion, RegistrationEpoch: auth.RegistrationEpoch, ExecutionSeq: executionSeq}
result.RetryAfter = retryAfterFromError(bootstrapErr)
if isCredentialScopedError(bootstrapErr) {
result.CredentialScope = true
@@ -421,7 +424,7 @@ func (m *Manager) executeStreamWithModelPool(ctx context.Context, executor Provi
}
if isRequestInvalidError(bootstrapErr) {
rerr := resultErrorFromError(bootstrapErr)
- result := Result{AuthID: auth.ID, Provider: provider, Model: resultModel, RouteModel: routeModel, Success: false, Error: rerr, Options: execOpts, CredentialVersion: auth.CredentialVersion, RegistrationEpoch: auth.RegistrationEpoch}
+ result := Result{AuthID: auth.ID, Provider: provider, Model: resultModel, RouteModel: routeModel, Success: false, Error: rerr, Options: execOpts, CredentialVersion: auth.CredentialVersion, RegistrationEpoch: auth.RegistrationEpoch, ExecutionSeq: executionSeq}
result.RetryAfter = retryAfterFromError(bootstrapErr)
if isCredentialScopedError(bootstrapErr) {
result.CredentialScope = true
@@ -432,7 +435,7 @@ func (m *Manager) executeStreamWithModelPool(ctx context.Context, executor Provi
}
if idx < len(execModels)-1 {
rerr := resultErrorFromError(bootstrapErr)
- result := Result{AuthID: auth.ID, Provider: provider, Model: resultModel, RouteModel: routeModel, Success: false, Error: rerr, Options: execOpts, CredentialVersion: auth.CredentialVersion, RegistrationEpoch: auth.RegistrationEpoch}
+ result := Result{AuthID: auth.ID, Provider: provider, Model: resultModel, RouteModel: routeModel, Success: false, Error: rerr, Options: execOpts, CredentialVersion: auth.CredentialVersion, RegistrationEpoch: auth.RegistrationEpoch, ExecutionSeq: executionSeq}
result.RetryAfter = retryAfterFromError(bootstrapErr)
if isCredentialScopedError(bootstrapErr) {
result.CredentialScope = true
@@ -447,7 +450,7 @@ func (m *Manager) executeStreamWithModelPool(ctx context.Context, executor Provi
continue
}
rerr := resultErrorFromError(bootstrapErr)
- result := Result{AuthID: auth.ID, Provider: provider, Model: resultModel, RouteModel: routeModel, Success: false, Error: rerr, Options: execOpts, CredentialVersion: auth.CredentialVersion, RegistrationEpoch: auth.RegistrationEpoch}
+ result := Result{AuthID: auth.ID, Provider: provider, Model: resultModel, RouteModel: routeModel, Success: false, Error: rerr, Options: execOpts, CredentialVersion: auth.CredentialVersion, RegistrationEpoch: auth.RegistrationEpoch, ExecutionSeq: executionSeq}
result.RetryAfter = retryAfterFromError(bootstrapErr)
if isCredentialScopedError(bootstrapErr) {
result.CredentialScope = true
@@ -465,7 +468,7 @@ func (m *Manager) executeStreamWithModelPool(ctx context.Context, executor Provi
upstreamErr = currentErr
}
warnLogUpstreamFailure(ctx, entry, provider, execModel, auth, time.Since(startStream), emptyErr)
- result := Result{AuthID: auth.ID, Provider: provider, Model: resultModel, RouteModel: routeModel, Success: false, Error: resultErrorFromError(emptyErr), Options: execOpts, CredentialVersion: auth.CredentialVersion, RegistrationEpoch: auth.RegistrationEpoch}
+ result := Result{AuthID: auth.ID, Provider: provider, Model: resultModel, RouteModel: routeModel, Success: false, Error: resultErrorFromError(emptyErr), Options: execOpts, CredentialVersion: auth.CredentialVersion, RegistrationEpoch: auth.RegistrationEpoch, ExecutionSeq: executionSeq}
m.recordExecutionResult(ctx, result, auth, ephemeralResult)
if idx < len(execModels)-1 {
lastErr = emptyErr
@@ -481,7 +484,7 @@ func (m *Manager) executeStreamWithModelPool(ctx context.Context, executor Provi
remaining = closedCh
}
attemptAliasResult := resolveAttemptAliasResult(routing, auth, routeModel, execModel, aliasResult)
- return m.wrapStreamResult(ctx, auth.Clone(), provider, resultModel, routeModel, streamResult.Headers, buffered, remaining, attemptAliasResult, ephemeralResult, execOpts), nil
+ return m.wrapStreamResult(ctx, auth.Clone(), provider, resultModel, routeModel, streamResult.Headers, buffered, remaining, attemptAliasResult, ephemeralResult, execOpts, executionSeq), nil
}
if lastErr == nil {
lastErr = &Error{Code: "auth_not_found", Message: "no upstream model available"}
diff --git a/sdk/cliproxy/auth/conductor_transport_retry_test.go b/sdk/cliproxy/auth/conductor_transport_retry_test.go
index 3fdb83711fe..c1a3fb9693b 100644
--- a/sdk/cliproxy/auth/conductor_transport_retry_test.go
+++ b/sdk/cliproxy/auth/conductor_transport_retry_test.go
@@ -61,6 +61,8 @@ func TestManager_ShouldRetryAfterError_RetriesPreHTTPTransportFailure(t *testing
{name: "windows tls handshake", err: windowsCodexTLSHandshakeError(), want: true},
{name: "dial refused", err: dialRefusedError(), want: true},
{name: "unexpected eof", err: io.ErrUnexpectedEOF, want: true},
+ {name: "http2 force closed", err: errors.New("http2: client connection force closed via ClientConn.Close"), want: true},
+ {name: "http2 conn closed", err: &url.Error{Op: "Post", URL: "https://example.com", Err: errors.New("http2: client conn is closed")}, want: true},
{name: "unauthorized", err: &Error{HTTPStatus: http.StatusUnauthorized, Message: "unauthorized"}, want: false},
{name: "canceled", err: context.Canceled, want: false},
{name: "certificate", err: &url.Error{Op: "Post", URL: "https://chatgpt.com/backend-api/codex/responses", Err: x509.UnknownAuthorityError{}}, want: false},
@@ -83,6 +85,123 @@ func TestManager_ShouldRetryAfterError_RetriesPreHTTPTransportFailure(t *testing
}
}
+// TestConfiguredTLSHandshakeTimeoutIsRetryable pins the Issue #6615 retry
+// contract: an internal establishment deadline is a transient pre-HTTP
+// failure even though its chain contains context.DeadlineExceeded, while
+// caller-owned deadline and cancellation shapes remain non-retryable.
+func TestConfiguredTLSHandshakeTimeoutIsRetryable(t *testing.T) {
+ configuredClaudeErr := &url.Error{
+ Op: "Post",
+ URL: "https://api.anthropic.com/v1/messages",
+ Err: fmt.Errorf("claude tls: handshake upstream: %w", cliproxyexecutor.NewConfiguredTLSHandshakeTimeoutError(context.DeadlineExceeded)),
+ }
+ configuredChromeErr := &url.Error{
+ Op: "Post",
+ URL: "https://chatgpt.com/backend-api/codex/responses",
+ Err: fmt.Errorf("utls: TLS handshake: %w", cliproxyexecutor.NewConfiguredTLSHandshakeTimeoutError(context.DeadlineExceeded)),
+ }
+ tests := []struct {
+ name string
+ err error
+ wantRetry bool
+ }{
+ {
+ name: "claude configured handshake timeout",
+ err: configuredClaudeErr,
+ wantRetry: true,
+ },
+ {
+ name: "chrome configured handshake timeout",
+ err: configuredChromeErr,
+ wantRetry: true,
+ },
+ {
+ name: "caller deadline stays non retryable",
+ err: &url.Error{
+ Op: "Post",
+ URL: "https://api.anthropic.com/v1/messages",
+ Err: context.DeadlineExceeded,
+ },
+ wantRetry: false,
+ },
+ {
+ name: "percent encoded marker is not trusted",
+ err: &url.Error{
+ Op: "Post",
+ URL: "https://api.anthropic.com/v1/messages",
+ Err: fmt.Errorf("tls%%20handshake%%20timeout: %w", context.DeadlineExceeded),
+ },
+ wantRetry: false,
+ },
+ {
+ name: "plaintext timeout phrase is not trusted",
+ err: &url.Error{
+ Op: "Post",
+ URL: "https://api.anthropic.com/v1/messages",
+ Err: fmt.Errorf("external transport tls handshake timeout: %w", context.DeadlineExceeded),
+ },
+ wantRetry: false,
+ },
+ {
+ name: "caller cancellation stays non retryable",
+ err: &url.Error{
+ Op: "Post",
+ URL: "https://api.anthropic.com/v1/messages",
+ Err: context.Canceled,
+ },
+ wantRetry: false,
+ },
+ {
+ name: "caller cancellation joined with configured timeout stays non retryable",
+ err: &url.Error{
+ Op: "Post",
+ URL: "https://api.anthropic.com/v1/messages",
+ Err: errors.Join(cliproxyexecutor.NewConfiguredTLSHandshakeTimeoutError(context.DeadlineExceeded), context.Canceled),
+ },
+ wantRetry: false,
+ },
+ {
+ name: "caller deadline joined with configured timeout stays non retryable",
+ err: &url.Error{
+ Op: "Post",
+ URL: "https://api.anthropic.com/v1/messages",
+ Err: errors.Join(cliproxyexecutor.NewConfiguredTLSHandshakeTimeoutError(context.DeadlineExceeded), context.DeadlineExceeded),
+ },
+ wantRetry: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := isTransientTransportError(tt.err); got != tt.wantRetry {
+ t.Fatalf("isTransientTransportError(%v) = %t, want %t", tt.err, got, tt.wantRetry)
+ }
+ if got := isRequestRetryRoundError(tt.err); got != tt.wantRetry {
+ t.Fatalf("isRequestRetryRoundError(%v) = %t, want %t", tt.err, got, tt.wantRetry)
+ }
+ resultErr := resultErrorFromError(tt.err)
+ if resultErr == nil {
+ t.Fatalf("resultErrorFromError(%v) = nil", tt.err)
+ }
+ if tt.wantRetry {
+ if resultErr.Code != transientTransportErrorCode {
+ t.Fatalf("result code = %q, want %q", resultErr.Code, transientTransportErrorCode)
+ }
+ if !isTransientTransportResultError(resultErr) {
+ t.Fatalf("isTransientTransportResultError(%#v) = false, want true", resultErr)
+ }
+ if isConnectionLifecycleResultError(resultErr) {
+ t.Fatalf("configured handshake timeout became a connection-lifecycle result: %#v", resultErr)
+ }
+ } else if resultErr.Code == transientTransportErrorCode {
+ t.Fatalf("caller-owned context error became transient transport: %#v", resultErr)
+ }
+ if !tt.wantRetry && isTransientTransportResultError(resultErr) {
+ t.Fatalf("message-only fallback promoted a non-retryable error: %#v", resultErr)
+ }
+ })
+ }
+}
+
func TestManager_MarkResult_PreHTTPTransportFailureDoesNotCooldown(t *testing.T) {
previous := quotaCooldownDisabled.Load()
quotaCooldownDisabled.Store(false)
@@ -153,6 +272,74 @@ func TestExecuteRetriesPreHTTPTransportFailureWithoutCooling(t *testing.T) {
assertNoCooldown(t, manager, authID, model)
}
+func TestManager_HTTP2ClosedConnectionRespectsRequestRetry(t *testing.T) {
+ previous := quotaCooldownDisabled.Load()
+ quotaCooldownDisabled.Store(false)
+ t.Cleanup(func() { quotaCooldownDisabled.Store(previous) })
+
+ for _, message := range []string{
+ "http2: client connection force closed via ClientConn.Close",
+ "http2: client conn is closed",
+ } {
+ for _, kind := range []string{"execute", "stream", "count"} {
+ for _, retries := range []int{0, 1} {
+ t.Run(fmt.Sprintf("%s/%s/retries=%d", message, kind, retries), func(t *testing.T) {
+ m := NewManager(nil, nil, nil)
+ m.SetRetryConfig(retries, 0, 0)
+ executor := &transportThenSuccessExecutor{identifier: "openai-compatibility", fail: errors.New(message)}
+ m.RegisterExecutor(executor)
+ model := "http2-retry-" + uuid.NewString()
+ authID := "http2-auth-" + uuid.NewString()
+ registry.GetGlobalRegistry().RegisterClient(authID, executor.Identifier(), []*registry.ModelInfo{{ID: model}})
+ t.Cleanup(func() { registry.GetGlobalRegistry().UnregisterClient(authID) })
+ if _, errRegister := m.Register(context.Background(), &Auth{ID: authID, Provider: executor.Identifier()}); errRegister != nil {
+ t.Fatalf("register auth: %v", errRegister)
+ }
+ call := func() error {
+ providers := []string{executor.Identifier()}
+ req := cliproxyexecutor.Request{Model: model}
+ switch kind {
+ case "execute":
+ _, errExecute := m.Execute(context.Background(), providers, req, cliproxyexecutor.Options{})
+ return errExecute
+ case "stream":
+ stream, errStream := m.ExecuteStream(context.Background(), providers, req, cliproxyexecutor.Options{})
+ if errStream != nil {
+ return errStream
+ }
+ for chunk := range stream.Chunks {
+ if chunk.Err != nil {
+ return chunk.Err
+ }
+ }
+ return nil
+ default:
+ _, errCount := m.ExecuteCount(context.Background(), providers, req, cliproxyexecutor.Options{})
+ return errCount
+ }
+ }
+ errCall := call()
+ assertNoCooldown(t, m, authID, model)
+ if retries == 0 {
+ if !errors.Is(errCall, executor.fail) || executor.callCount() != 1 {
+ t.Fatalf("retry disabled: error = %v, calls = %d; want original error and one call", errCall, executor.callCount())
+ }
+ if errNext := call(); errNext != nil {
+ t.Fatalf("next request on the same credential: %v", errNext)
+ }
+ } else if errCall != nil {
+ t.Fatalf("request failed instead of using its retry round: %v", errCall)
+ }
+ if executor.callCount() != 2 {
+ t.Fatalf("executor calls = %d, want 2", executor.callCount())
+ }
+ assertNoCooldown(t, m, authID, model)
+ })
+ }
+ }
+ }
+}
+
func TestExecuteDoesNotPoisonCredentialOnPreHTTPTransportFailure(t *testing.T) {
previous := quotaCooldownDisabled.Load()
quotaCooldownDisabled.Store(false)
@@ -194,6 +381,90 @@ func TestExecuteDoesNotPoisonCredentialOnPreHTTPTransportFailure(t *testing.T) {
}
}
+// alwaysFailExecutor is local to the configured-handshake retry-round test
+// because transportThenSuccessExecutor intentionally fails only once.
+type alwaysFailExecutor struct {
+ identifier string
+ fail error
+ calls int
+}
+
+func (e *alwaysFailExecutor) Identifier() string { return e.identifier }
+
+func (e *alwaysFailExecutor) Execute(context.Context, *Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
+ e.calls++
+ return cliproxyexecutor.Response{}, e.fail
+}
+
+func (e *alwaysFailExecutor) ExecuteStream(context.Context, *Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {
+ e.calls++
+ return nil, e.fail
+}
+
+func (*alwaysFailExecutor) Refresh(_ context.Context, auth *Auth) (*Auth, error) {
+ return auth, nil
+}
+
+func (e *alwaysFailExecutor) CountTokens(context.Context, *Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
+ e.calls++
+ return cliproxyexecutor.Response{}, e.fail
+}
+
+func (*alwaysFailExecutor) HttpRequest(context.Context, *Auth, *http.Request) (*http.Response, error) {
+ return nil, nil
+}
+
+func (e *alwaysFailExecutor) callCount() int { return e.calls }
+
+// TestExecuteRetriesConfiguredClaudeHandshakeTimeoutToConfiguredRounds
+// verifies the complete request-retry round behavior with one credential:
+// request-retry 3 means the initial attempt plus three additional rounds,
+// and a caller deadline with the same retry setting still runs only once.
+func TestExecuteRetriesConfiguredClaudeHandshakeTimeoutToConfiguredRounds(t *testing.T) {
+ previous := quotaCooldownDisabled.Load()
+ quotaCooldownDisabled.Store(false)
+ t.Cleanup(func() { quotaCooldownDisabled.Store(previous) })
+
+ configuredErr := &url.Error{
+ Op: "Post",
+ URL: "https://api.anthropic.com/v1/messages",
+ Err: fmt.Errorf("claude tls: handshake upstream: %w", cliproxyexecutor.NewConfiguredTLSHandshakeTimeoutError(context.DeadlineExceeded)),
+ }
+ tests := []struct {
+ name string
+ err error
+ wantCalls int
+ }{
+ {name: "configured handshake timeout", err: configuredErr, wantCalls: 4},
+ {name: "caller deadline", err: context.DeadlineExceeded, wantCalls: 1},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ manager := NewManager(nil, nil, nil)
+ manager.SetRetryConfig(3, 0, 0)
+ executor := &alwaysFailExecutor{identifier: "claude", fail: tt.err}
+ manager.RegisterExecutor(executor)
+
+ model := "claude-handshake-retry-" + uuid.NewString()
+ authID := "claude-handshake-auth-" + uuid.NewString()
+ registry.GetGlobalRegistry().RegisterClient(authID, "claude", []*registry.ModelInfo{{ID: model}})
+ t.Cleanup(func() { registry.GetGlobalRegistry().UnregisterClient(authID) })
+ if _, errRegister := manager.Register(context.Background(), &Auth{ID: authID, Provider: "claude"}); errRegister != nil {
+ t.Fatalf("register auth: %v", errRegister)
+ }
+
+ _, errExecute := manager.Execute(context.Background(), []string{"claude"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{})
+ if errExecute == nil {
+ t.Fatal("Execute() error = nil, want final handshake failure")
+ }
+ if calls := executor.callCount(); calls != tt.wantCalls {
+ t.Fatalf("executor calls = %d, want %d", calls, tt.wantCalls)
+ }
+ assertNoCooldown(t, manager, authID, model)
+ })
+ }
+}
+
func TestHomeExecuteRetriesPreHTTPTransportFailure(t *testing.T) {
dispatcher := &retryContractHomeDispatcher{authIDs: []string{"home-retry-a"}}
executor := &transportThenSuccessExecutor{
@@ -239,7 +510,8 @@ func (e *transportThenSuccessExecutor) ExecuteStream(context.Context, *Auth, cli
if e.recordCall() == 1 {
return nil, e.fail
}
- chunks := make(chan cliproxyexecutor.StreamChunk)
+ chunks := make(chan cliproxyexecutor.StreamChunk, 1)
+ chunks <- cliproxyexecutor.StreamChunk{Payload: []byte("ok")}
close(chunks)
return &cliproxyexecutor.StreamResult{Chunks: chunks}, nil
}
diff --git a/sdk/cliproxy/auth/conductor_update_test.go b/sdk/cliproxy/auth/conductor_update_test.go
index e87b70b67c8..858aec3597f 100644
--- a/sdk/cliproxy/auth/conductor_update_test.go
+++ b/sdk/cliproxy/auth/conductor_update_test.go
@@ -2,6 +2,9 @@ package auth
import (
"context"
+ "os"
+ "path/filepath"
+ "sync"
"testing"
"time"
)
@@ -251,3 +254,108 @@ func TestManager_Update_ActiveInheritsModelStates(t *testing.T) {
t.Fatalf("expected BackoffLevel to be %d, got %d", backoffLevel, state.Quota.BackoffLevel)
}
}
+
+func TestManager_Update_FileSnapshotReadDoesNotHoldGlobalLock(t *testing.T) {
+ dir := t.TempDir()
+ slowPath := filepath.Join(dir, "slow.json")
+ slowData := []byte(`{"type":"codex","prefix":"pfx-slow"}`)
+ if errWrite := os.WriteFile(slowPath, slowData, 0o600); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+
+ ctx := WithSkipPersist(context.Background())
+ m := NewManager(nil, nil, nil)
+ if _, errReg := m.Register(ctx, &Auth{ID: "slow.json", Provider: "codex", Status: StatusActive}); errReg != nil {
+ t.Fatal(errReg)
+ }
+ if _, errReg := m.Register(ctx, &Auth{ID: "other.json", Provider: "codex", Status: StatusActive}); errReg != nil {
+ t.Fatal(errReg)
+ }
+
+ readStarted := make(chan struct{})
+ releaseRead := make(chan struct{})
+ slowDone := make(chan error, 1)
+ var releaseOnce sync.Once
+ releaseSlowRead := func() {
+ releaseOnce.Do(func() {
+ close(releaseRead)
+ })
+ }
+ origRead := readAuthSnapshotFileFunc
+ t.Cleanup(func() {
+ releaseSlowRead()
+ <-slowDone
+ readAuthSnapshotFileFunc = origRead
+ })
+ var startedOnce sync.Once
+ readAuthSnapshotFileFunc = func(path string) ([]byte, error) {
+ if path == slowPath {
+ startedOnce.Do(func() {
+ close(readStarted)
+ })
+ <-releaseRead
+ }
+ return origRead(path)
+ }
+
+ slowSnapshot := &Auth{
+ ID: "slow.json",
+ Provider: "codex",
+ Prefix: "pfx-slow",
+ Status: StatusActive,
+ Attributes: map[string]string{
+ AttributePath: slowPath,
+ },
+ }
+ slowSnapshot.BindFileSnapshot(slowPath, slowData)
+
+ go func() {
+ _, errUpdate := m.Update(ctx, slowSnapshot)
+ slowDone <- errUpdate
+ }()
+ select {
+ case <-readStarted:
+ case <-time.After(5 * time.Second):
+ t.Fatal("timed out waiting for slow snapshot read to start")
+ }
+
+ // While slow.json is blocked reading its snapshot file, operations on other.json
+ // must not be blocked on m.mu.
+ otherDone := make(chan error, 1)
+ go func() {
+ if _, ok := m.GetByID("other.json"); !ok {
+ otherDone <- os.ErrNotExist
+ return
+ }
+ _, errUpdate := m.Update(ctx, &Auth{
+ ID: "other.json",
+ Provider: "codex",
+ Prefix: "pfx-other",
+ Status: StatusActive,
+ })
+ otherDone <- errUpdate
+ }()
+ select {
+ case errOther := <-otherDone:
+ if errOther != nil {
+ t.Fatalf("other auth update failed while slow snapshot read was in flight: %v", errOther)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("other auth update blocked while slow snapshot read was in flight")
+ }
+
+ releaseSlowRead()
+ select {
+ case errSlow := <-slowDone:
+ slowDone <- errSlow
+ if errSlow != nil {
+ t.Fatalf("slow auth update failed: %v", errSlow)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("timed out waiting for slow auth update to complete")
+ }
+ slowAfter, _ := m.GetByID("slow.json")
+ if slowAfter == nil || slowAfter.Prefix != "pfx-slow" {
+ t.Fatalf("slow auth prefix = %v, want pfx-slow", slowAfter)
+ }
+}
diff --git a/sdk/cliproxy/auth/connection_lifecycle_cooldown_test.go b/sdk/cliproxy/auth/connection_lifecycle_cooldown_test.go
index d6e77570a90..3ae29c491a5 100644
--- a/sdk/cliproxy/auth/connection_lifecycle_cooldown_test.go
+++ b/sdk/cliproxy/auth/connection_lifecycle_cooldown_test.go
@@ -34,6 +34,8 @@ func TestManager_MarkResult_ConnectionLifecycleDoesNotCooldown(t *testing.T) {
{name: "unexpected EOF", err: &Error{Message: "unexpected EOF"}},
{name: "plain EOF", err: &Error{Message: "EOF"}},
{name: "wrapped unexpected EOF", err: &Error{Message: "read tcp 127.0.0.1:1->127.0.0.1:2: unexpected EOF"}},
+ {name: "http2 force closed", err: &Error{Message: "http2: client connection force closed via ClientConn.Close"}},
+ {name: "http2 conn closed", err: &Error{Message: "http2: client conn is closed"}},
{name: "typed canceled", err: resultErrorFromError(context.Canceled)},
{name: "typed deadline", err: resultErrorFromError(context.DeadlineExceeded)},
{name: "url canceled", err: resultErrorFromError(&url.Error{Op: "Post", URL: "https://example.com", Err: context.Canceled})},
@@ -97,6 +99,62 @@ func TestManager_MarkResult_ConnectionLifecycleAuthLevelDoesNotCooldown(t *testi
}
}
+func TestManager_MarkResult_HTTP2ClosedConnectionKeepsPoolAvailable(t *testing.T) {
+ previous := quotaCooldownDisabled.Load()
+ quotaCooldownDisabled.Store(false)
+ t.Cleanup(func() { quotaCooldownDisabled.Store(previous) })
+
+ prevTransient := transientErrorCooldownSeconds.Load()
+ SetTransientErrorCooldownSeconds(60)
+ t.Cleanup(func() { transientErrorCooldownSeconds.Store(prevTransient) })
+
+ cases := []struct {
+ name string
+ message string
+ }{
+ {name: "force closed", message: "http2: client connection force closed via ClientConn.Close"},
+ {name: "conn closed", message: "http2: client conn is closed"},
+ {name: "EOF control", message: "unexpected EOF"},
+ }
+ for _, tc := range cases {
+ for _, mode := range []string{"raw", "classified", "wrapped"} {
+ t.Run(tc.name+"/"+mode, func(t *testing.T) {
+ m := NewManager(nil, nil, nil)
+ const provider = "openai-compatibility"
+ const model = "sample-model"
+ resultErr := &Error{Message: tc.message}
+ switch mode {
+ case "classified":
+ resultErr = resultErrorFromError(errors.New(tc.message))
+ if resultErr.Code != connectionLifecycleErrorCode || resultErr.IsRequestScoped() {
+ t.Fatalf("expected non-request-scoped lifecycle error, got %#v", resultErr)
+ }
+ case "wrapped":
+ resultErr = resultErrorFromError(&url.Error{Op: "Post", URL: "https://example.com/v1/chat/completions", Err: errors.New(tc.message)})
+ if resultErr.Code != connectionLifecycleErrorCode || resultErr.IsRequestScoped() {
+ t.Fatalf("expected non-request-scoped lifecycle error, got %#v", resultErr)
+ }
+ }
+ for i := 0; i < 4; i++ {
+ authID := fmt.Sprintf("http2-pool-%d", i)
+ if _, errRegister := m.Register(context.Background(), &Auth{ID: authID, Provider: provider}); errRegister != nil {
+ t.Fatalf("register auth: %v", errRegister)
+ }
+ m.MarkResult(context.Background(), Result{AuthID: authID, Provider: provider, Model: model, Success: false, Error: resultErr})
+ }
+ auths := m.List()
+ available, errSelection := m.availableAuthsForRouteModel(auths, provider, model, time.Now())
+ if errSelection != nil || len(available) != 4 {
+ t.Fatalf("pool selection = (%d credentials, %v), want (4, nil)", len(available), errSelection)
+ }
+ for _, auth := range auths {
+ assertNoCooldown(t, m, auth.ID, model)
+ }
+ })
+ }
+ }
+}
+
func TestManager_MarkResult_HTTPStatusWithLifecycleTextStillCooldowns(t *testing.T) {
previous := quotaCooldownDisabled.Load()
quotaCooldownDisabled.Store(false)
@@ -116,6 +174,10 @@ func TestManager_MarkResult_HTTPStatusWithLifecycleTextStillCooldowns(t *testing
{name: "429 context canceled", httpStatus: http.StatusTooManyRequests, message: "context canceled", wantAuth: true},
{name: "500 unexpected EOF", httpStatus: http.StatusInternalServerError, message: "unexpected EOF"},
{name: "500 websocket 1006 text", httpStatus: http.StatusInternalServerError, message: "websocket: close 1006 (abnormal closure): unexpected EOF"},
+ {name: "401 http2 force closed", httpStatus: http.StatusUnauthorized, message: "http2: client connection force closed via ClientConn.Close", wantAuth: true},
+ {name: "429 http2 conn closed", httpStatus: http.StatusTooManyRequests, message: "http2: client conn is closed", wantAuth: true},
+ {name: "500 http2 force closed", httpStatus: http.StatusInternalServerError, message: "http2: client connection force closed via ClientConn.Close"},
+ {name: "500 http2 conn closed", httpStatus: http.StatusInternalServerError, message: "http2: client conn is closed"},
}
for _, tc := range cases {
@@ -222,6 +284,9 @@ func TestResultErrorFromError_ConnectionLifecycleDoesNotBecomeRequestScoped(t *t
errors.New("websocket: close 1006 (abnormal closure): unexpected EOF"),
errors.New("context deadline exceeded"),
errors.New("unexpected EOF"),
+ errors.New("http2: client connection force closed via ClientConn.Close"),
+ errors.New("http2: client conn is closed"),
+ &url.Error{Op: "Post", URL: "https://example.com", Err: errors.New("http2: client conn is closed")},
}
for _, err := range cases {
if !isConnectionLifecycleError(err) {
@@ -252,6 +317,10 @@ func TestIsConnectionLifecycleError_StatusBearingErrorsStayCoolable(t *testing.T
&statusBearingError{status: http.StatusTooManyRequests, msg: "context canceled"},
&statusBearingError{status: http.StatusInternalServerError, msg: "unexpected EOF"},
&statusBearingError{status: http.StatusBadGateway, msg: "websocket: close 1006 (abnormal closure): unexpected EOF"},
+ &statusBearingError{status: http.StatusUnauthorized, msg: "http2: client connection force closed via ClientConn.Close"},
+ &statusBearingError{status: http.StatusTooManyRequests, msg: "http2: client conn is closed"},
+ &statusBearingError{status: http.StatusInternalServerError, msg: "http2: client connection force closed via ClientConn.Close"},
+ &statusBearingError{status: http.StatusInternalServerError, msg: "http2: client conn is closed"},
}
for _, err := range cases {
if isConnectionLifecycleError(err) {
diff --git a/sdk/cliproxy/auth/cooldown_state.go b/sdk/cliproxy/auth/cooldown_state.go
index 830a2e6e91e..7c0797103e1 100644
--- a/sdk/cliproxy/auth/cooldown_state.go
+++ b/sdk/cliproxy/auth/cooldown_state.go
@@ -27,6 +27,13 @@ type CooldownStateRecord struct {
Quota QuotaState `json:"quota,omitempty"`
LastError *Error `json:"last_error,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
+ // IndependentState preserves a model's own cooldown beneath an
+ // auth-level credential-quota layer so a restart cannot lose the
+ // distinction and quota restoration keeps reinstate-exactly semantics.
+ IndependentState *ModelState `json:"independent_state,omitempty"`
+ // CredentialQuotaPropagated preserves wrap provenance for models that
+ // carry no independent layer (e.g. the reporting model itself).
+ CredentialQuotaPropagated bool `json:"credential_quota_propagated,omitempty"`
}
// CooldownStateStore persists runtime cooldown state independently from auth tokens.
diff --git a/sdk/cliproxy/auth/devin_quota_refresh_regression_test.go b/sdk/cliproxy/auth/devin_quota_refresh_regression_test.go
new file mode 100644
index 00000000000..c1c43492023
--- /dev/null
+++ b/sdk/cliproxy/auth/devin_quota_refresh_regression_test.go
@@ -0,0 +1,1258 @@
+package auth
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+)
+
+// Tests in this file cover the refresh lifecycle half of issue #6690:
+// refreshed Devin quota observations must reach the stored auth, and a
+// refresh that confirms restored daily+weekly quota must release the
+// credential-wide cooldown early.
+
+func devinRefreshAuthFixture(t *testing.T, id string) (*Manager, *Auth, string, string) {
+ t.Helper()
+ m := NewManager(nil, nil, nil)
+ model := "claude-opus-5-5"
+ sibling := "claude-haiku-5-5"
+ auth := &Auth{ID: id, Provider: "devin", Status: StatusActive}
+ registry.GetGlobalRegistry().RegisterClient(auth.ID, "devin", []*registry.ModelInfo{{ID: model}, {ID: sibling}})
+ t.Cleanup(func() { registry.GetGlobalRegistry().UnregisterClient(auth.ID) })
+ if _, err := m.Register(context.Background(), auth); err != nil {
+ t.Fatalf("register: %v", err)
+ }
+ return m, auth, model, sibling
+}
+
+func devinExhaustedAuth(t *testing.T, m *Manager, id, model, sibling string, recoverAt time.Time) *Auth {
+ t.Helper()
+ stored, ok := m.GetByID(id)
+ if !ok || stored == nil {
+ t.Fatal("auth missing")
+ }
+ stored.Unavailable = true
+ stored.Status = StatusError
+ stored.NextRetryAfter = recoverAt
+ stored.Quota = QuotaState{
+ Exceeded: true, Reason: "credential_quota", NextRecoverAt: recoverAt,
+ ObservedAt: time.Now().Add(-2 * time.Hour),
+ Signals: map[string]string{
+ "daily_quota_remaining_percent": "0%",
+ "weekly_quota_remaining_percent": "0%",
+ },
+ }
+ stored.ModelStates = map[string]*ModelState{
+ model: {
+ Status: StatusError, Unavailable: true, NextRetryAfter: recoverAt,
+ Quota: QuotaState{Exceeded: true, Reason: "quota", NextRecoverAt: recoverAt},
+ },
+ sibling: {
+ Status: StatusError, Unavailable: true, NextRetryAfter: recoverAt,
+ Quota: QuotaState{Exceeded: true, Reason: "credential_quota", NextRecoverAt: recoverAt},
+ },
+ }
+ if _, err := m.Update(context.Background(), stored); err != nil {
+ t.Fatalf("seed exhausted auth: %v", err)
+ }
+ base, _ := m.GetByID(id)
+ return base
+}
+
+func devinRefreshedObservation(base *Auth, dailyPercent, weeklyPercent string, dailyReset, weeklyReset time.Time) *Auth {
+ updated := base.Clone()
+ updated.Quota.Signals = map[string]string{
+ "daily_quota_remaining_percent": dailyPercent,
+ "weekly_quota_remaining_percent": weeklyPercent,
+ }
+ if !dailyReset.IsZero() {
+ updated.Quota.Signals["daily_quota_reset_at"] = dailyReset.Format(time.RFC3339)
+ }
+ if !weeklyReset.IsZero() {
+ updated.Quota.Signals["weekly_quota_reset_at"] = weeklyReset.Format(time.RFC3339)
+ }
+ updated.Quota.ObservedAt = time.Now()
+ updated.LastRefreshedAt = time.Now()
+ return updated
+}
+
+func TestDevinRefresh_ObservationSignalsReachStoredAuth(t *testing.T) {
+ withQuotaCooldownEnabled(t)
+ m, auth, _, _ := devinRefreshAuthFixture(t, "devin-refresh-signals-6690")
+ base, _ := m.GetByID(auth.ID)
+
+ weeklyReset := time.Now().Add(72 * time.Hour).UTC().Truncate(time.Second)
+ updated := devinRefreshedObservation(base, "0%", "0%", time.Time{}, weeklyReset)
+ if _, err := m.UpdateRefreshedAuth(context.Background(), base, updated); err != nil {
+ t.Fatalf("UpdateRefreshedAuth: %v", err)
+ }
+
+ stored, _ := m.GetByID(auth.ID)
+ if stored == nil {
+ t.Fatal("stored auth missing")
+ }
+ if got := stored.Quota.Signals["weekly_quota_reset_at"]; got != weeklyReset.Format(time.RFC3339) {
+ t.Fatalf("weekly_quota_reset_at = %q, want %q (refreshed observation must survive the refresh merge)", got, weeklyReset.Format(time.RFC3339))
+ }
+ if got := stored.Quota.Signals["weekly_quota_remaining_percent"]; got != "0%" {
+ t.Fatalf("weekly_quota_remaining_percent = %q, want 0%%", got)
+ }
+}
+
+func TestDevinQuotaExhaustionSchedulesRevalidation(t *testing.T) {
+ m, auth, model, sibling := devinRefreshAuthFixture(t, "devin-refresh-revalidate-6690")
+ recoverAt := time.Now().Add(72 * time.Hour)
+ base := devinExhaustedAuth(t, m, auth.ID, model, sibling, recoverAt)
+ // Devin session tokens are permanent (no expiry, nil refresh lead), so a
+ // plain token-refresh decision must not depend on those paths.
+ // Fresh last refresh: not yet due, but kept in the schedule.
+ base.LastRefreshedAt = time.Now()
+ if m.shouldRefresh(base, time.Now()) {
+ t.Fatal("revalidation must respect its interval right after a refresh")
+ }
+ next, ok := nextRefreshCheckAt(time.Now(), base, 5*time.Second)
+ if !ok {
+ t.Fatal("exhausted Devin credential must remain in the auto-refresh schedule")
+ }
+ if !next.After(time.Now()) || next.After(time.Now().Add(2*time.Hour)) {
+ t.Fatalf("revalidation check = %v, want within a bounded near-term horizon", next)
+ }
+ // Stale last refresh: due now.
+ base.LastRefreshedAt = time.Now().Add(-2 * time.Hour)
+ if !m.shouldRefresh(base, time.Now()) {
+ t.Fatal("exhausted Devin credential past its revalidation interval must refresh")
+ }
+
+ // A healthy Devin credential must not start polling: revalidation is
+ // scoped to the active credential-quota state only.
+ healthy := &Auth{ID: "devin-healthy", Provider: "devin", Status: StatusActive, LastRefreshedAt: time.Now()}
+ if m.shouldRefresh(healthy, time.Now()) {
+ t.Fatal("healthy Devin credential must not be refreshed for quota revalidation")
+ }
+}
+
+func TestDevinRefresh_RestoredQuotaReleasesCredentialCooldown(t *testing.T) {
+ withQuotaCooldownEnabled(t)
+ m, auth, model, sibling := devinRefreshAuthFixture(t, "devin-refresh-restored-6690")
+ base := devinExhaustedAuth(t, m, auth.ID, model, sibling, time.Now().Add(72*time.Hour))
+
+ updated := devinRefreshedObservation(base, "80%", "55%", time.Time{}, time.Time{})
+ if _, err := m.UpdateRefreshedAuth(context.Background(), base, updated); err != nil {
+ t.Fatalf("UpdateRefreshedAuth: %v", err)
+ }
+
+ stored, _ := m.GetByID(auth.ID)
+ if stored == nil {
+ t.Fatal("stored auth missing")
+ }
+ if stored.Quota.Exceeded || stored.Quota.Reason == "credential_quota" {
+ t.Fatalf("credential quota must be released after restored refresh, got %+v", stored.Quota)
+ }
+ if stored.Unavailable || !stored.NextRetryAfter.IsZero() {
+ t.Fatalf("auth must be available after restored refresh, got unavailable=%v nextRetry=%v", stored.Unavailable, stored.NextRetryAfter)
+ }
+ for _, mID := range []string{model, sibling} {
+ state := stored.ModelStates[mID]
+ if state == nil || state.Unavailable || !state.NextRetryAfter.IsZero() || state.Quota.Exceeded {
+ t.Fatalf("model %s must be released after restored refresh, got %+v", mID, state)
+ }
+ if blocked, _, _ := isAuthBlockedForModel(stored, mID, time.Now()); blocked {
+ t.Fatalf("model %s still blocked for selection after restored refresh", mID)
+ }
+ }
+}
+
+func TestDevinRefresh_StillExhaustedPreservesCooldown(t *testing.T) {
+ withQuotaCooldownEnabled(t)
+ m, auth, model, sibling := devinRefreshAuthFixture(t, "devin-refresh-exhausted-6690")
+ recoverAt := time.Now().Add(72 * time.Hour)
+ base := devinExhaustedAuth(t, m, auth.ID, model, sibling, recoverAt)
+
+ updated := devinRefreshedObservation(base, "0%", "0%", time.Time{}, recoverAt)
+ if _, err := m.UpdateRefreshedAuth(context.Background(), base, updated); err != nil {
+ t.Fatalf("UpdateRefreshedAuth: %v", err)
+ }
+
+ stored, _ := m.GetByID(auth.ID)
+ if stored == nil || !stored.Quota.Exceeded || stored.Quota.Reason != "credential_quota" {
+ t.Fatalf("credential quota must be preserved while still exhausted, got %+v", stored)
+ }
+ if !stored.NextRetryAfter.After(time.Now()) {
+ t.Fatalf("cooldown deadline must remain in the future, got %v", stored.NextRetryAfter)
+ }
+ if got := stored.Quota.Signals["weekly_quota_reset_at"]; got == "" {
+ t.Fatal("refreshed weekly reset signal must survive even while cooldown is preserved")
+ }
+}
+
+func TestDevinRefresh_StaleObservationCannotReleaseCooldown(t *testing.T) {
+ withQuotaCooldownEnabled(t)
+ m, auth, model, sibling := devinRefreshAuthFixture(t, "devin-refresh-stale-6690")
+ base := devinExhaustedAuth(t, m, auth.ID, model, sibling, time.Now().Add(72*time.Hour))
+
+ stale := devinRefreshedObservation(base, "90%", "90%", time.Time{}, time.Time{})
+ // Older than the stored snapshot: must be rejected as a release signal.
+ storedSnap, _ := m.GetByID(auth.ID)
+ stale.Quota.ObservedAt = storedSnap.Quota.ObservedAt.Add(-time.Hour)
+ if _, err := m.UpdateRefreshedAuth(context.Background(), base, stale); err != nil {
+ t.Fatalf("UpdateRefreshedAuth: %v", err)
+ }
+
+ stored, _ := m.GetByID(auth.ID)
+ if stored == nil || !stored.Quota.Exceeded || stored.Quota.Reason != "credential_quota" {
+ t.Fatalf("stale restored observation must not release the cooldown, got %+v", stored)
+ }
+ if got := stored.Quota.Signals["weekly_quota_remaining_percent"]; got != "0%" {
+ t.Fatalf("stale observation must not overwrite stored signals, weekly percent = %q", got)
+ }
+ if blocked, _, _ := isAuthBlockedForModel(stored, model, time.Now()); !blocked {
+ t.Fatal("model must remain blocked under a stale refresh")
+ }
+}
+
+func TestDevinMarkResult_EnrollsExhaustedCredentialForRevalidation(t *testing.T) {
+ withQuotaCooldownEnabled(t)
+ m, auth, model, _ := devinRefreshAuthFixture(t, "devin-enroll-6690")
+
+ loop := newAuthAutoRefreshLoop(m, 5*time.Second, 1)
+ m.refreshLoop = loop
+
+ // Healthy permanent-token credentials start unscheduled.
+ loop.applyDirty(time.Now())
+ loop.mu.Lock()
+ _, scheduled := loop.index[auth.ID]
+ loop.mu.Unlock()
+ if scheduled {
+ t.Fatal("healthy Devin credential must start unscheduled")
+ }
+
+ retryAfter := 72 * time.Hour
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: model, Success: false,
+ Error: &Error{Code: "rate_limit", Message: "weekly quota", HTTPStatus: http.StatusTooManyRequests},
+ RetryAfter: &retryAfter,
+ CredentialScope: true,
+ })
+
+ loop.applyDirty(time.Now())
+ loop.mu.Lock()
+ _, scheduled = loop.index[auth.ID]
+ loop.mu.Unlock()
+ if !scheduled {
+ t.Fatal("newly exhausted Devin credential must be enrolled for quota revalidation after MarkResult")
+ }
+}
+
+func TestDevinRefresh_PropagatedSiblingKeepsPreExistingBlock(t *testing.T) {
+ withQuotaCooldownEnabled(t)
+ m, auth, model, sibling := devinRefreshAuthFixture(t, "devin-propagated-sibling-6690")
+
+ // Sibling first earns an unrelated long block (404 -> 12h).
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: sibling, Success: false,
+ Error: &Error{Code: "not_found", Message: "model missing", HTTPStatus: http.StatusNotFound},
+ })
+ seeded, _ := m.GetByID(auth.ID)
+ sibState := seeded.ModelStates[sibling]
+ if sibState == nil || !sibState.Unavailable || !sibState.NextRetryAfter.After(time.Now().Add(time.Hour)) {
+ t.Fatalf("sibling 404 block not established: %+v", sibState)
+ }
+ unrelatedDeadline := sibState.NextRetryAfter
+
+ // The reporting model then exhausts the weekly quota (short 2h reset).
+ retryAfter := 2 * time.Hour
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: model, Success: false,
+ Error: &Error{Code: "rate_limit", Message: "weekly quota", HTTPStatus: http.StatusTooManyRequests},
+ RetryAfter: &retryAfter,
+ CredentialScope: true,
+ })
+ base, _ := m.GetByID(auth.ID)
+ if base.Quota.Reason != "credential_quota" {
+ t.Fatalf("expected credential_quota after propagation, got %+v", base.Quota)
+ }
+
+ // Restored quota must release only the quota component.
+ updated := devinRefreshedObservation(base, "80%", "55%", time.Time{}, time.Time{})
+ if _, err := m.UpdateRefreshedAuth(context.Background(), base, updated); err != nil {
+ t.Fatalf("UpdateRefreshedAuth: %v", err)
+ }
+
+ stored, _ := m.GetByID(auth.ID)
+ if stored.Quota.Reason == "credential_quota" {
+ t.Fatalf("credential quota must be released, got %+v", stored.Quota)
+ }
+ if blocked, _, _ := isAuthBlockedForModel(stored, model, time.Now()); blocked {
+ t.Fatal("reporting model must be released after restoration")
+ }
+ if blocked, _, _ := isAuthBlockedForModel(stored, sibling, time.Now()); !blocked {
+ t.Fatal("pre-existing unrelated sibling block must survive quota restoration")
+ }
+ if got := stored.ModelStates[sibling].NextRetryAfter; got.Before(unrelatedDeadline.Add(-time.Minute)) {
+ t.Fatalf("sibling deadline shortened by restoration: got %v, want ~%v", got, unrelatedDeadline)
+ }
+}
+
+func TestDevinRefresh_PreservesSiblingModelScoped429Block(t *testing.T) {
+ withQuotaCooldownEnabled(t)
+ m, auth, model, sibling := devinRefreshAuthFixture(t, "devin-sibling-429-6690")
+
+ // Sibling earns an independent model-scoped 429 block (12h).
+ longRetry := 12 * time.Hour
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: sibling, Success: false,
+ Error: &Error{Code: "rate_limit", Message: "model rate limit", HTTPStatus: http.StatusTooManyRequests},
+ RetryAfter: &longRetry,
+ })
+ // Reporting model exhausts the weekly quota (2h reset).
+ quotaRetry := 2 * time.Hour
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: model, Success: false,
+ Error: &Error{Code: "rate_limit", Message: "weekly quota", HTTPStatus: http.StatusTooManyRequests},
+ RetryAfter: "aRetry,
+ CredentialScope: true,
+ })
+ base, _ := m.GetByID(auth.ID)
+
+ updated := devinRefreshedObservation(base, "80%", "55%", time.Time{}, time.Time{})
+ if _, err := m.UpdateRefreshedAuth(context.Background(), base, updated); err != nil {
+ t.Fatalf("UpdateRefreshedAuth: %v", err)
+ }
+ stored, _ := m.GetByID(auth.ID)
+ if blocked, _, _ := isAuthBlockedForModel(stored, model, time.Now()); blocked {
+ t.Fatal("reporting model must be released after restoration")
+ }
+ if blocked, _, _ := isAuthBlockedForModel(stored, sibling, time.Now()); !blocked {
+ t.Fatal("independent model-scoped 429 block must survive quota restoration")
+ }
+}
+
+func TestDevinRefresh_PreservesSibling404UnderLongerQuota(t *testing.T) {
+ withQuotaCooldownEnabled(t)
+ m, auth, model, sibling := devinRefreshAuthFixture(t, "devin-sibling-404-long-6690")
+
+ // Sibling earns a 404 block (12h); the credential quota runs longer (72h),
+ // so propagation extends the sibling deadline. Restoration must restore
+ // the sibling to its original 404 block, not release it.
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: sibling, Success: false,
+ Error: &Error{Code: "not_found", Message: "model missing", HTTPStatus: http.StatusNotFound},
+ })
+ quotaRetry := 72 * time.Hour
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: model, Success: false,
+ Error: &Error{Code: "rate_limit", Message: "weekly quota", HTTPStatus: http.StatusTooManyRequests},
+ RetryAfter: "aRetry,
+ CredentialScope: true,
+ })
+ base, _ := m.GetByID(auth.ID)
+
+ updated := devinRefreshedObservation(base, "80%", "55%", time.Time{}, time.Time{})
+ if _, err := m.UpdateRefreshedAuth(context.Background(), base, updated); err != nil {
+ t.Fatalf("UpdateRefreshedAuth: %v", err)
+ }
+ stored, _ := m.GetByID(auth.ID)
+ if blocked, _, _ := isAuthBlockedForModel(stored, model, time.Now()); blocked {
+ t.Fatal("reporting model must be released after restoration")
+ }
+ if blocked, _, _ := isAuthBlockedForModel(stored, sibling, time.Now()); !blocked {
+ t.Fatal("pre-existing 404 block must survive under a longer quota window")
+ }
+ if got := stored.ModelStates[sibling].NextRetryAfter; got.After(time.Now().Add(24 * time.Hour)) {
+ t.Fatalf("sibling deadline = %v, want restored to the original ~12h 404 block", got)
+ }
+}
+
+func TestDevinRefresh_RepeatedQuotaExtensionsFullyRelease(t *testing.T) {
+ withQuotaCooldownEnabled(t)
+ m, auth, model, sibling := devinRefreshAuthFixture(t, "devin-repeated-quota-6690")
+
+ for _, retry := range []time.Duration{2 * time.Hour, 6 * time.Hour, 24 * time.Hour} {
+ r := retry
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: model, Success: false,
+ Error: &Error{Code: "rate_limit", Message: "weekly quota", HTTPStatus: http.StatusTooManyRequests},
+ RetryAfter: &r,
+ CredentialScope: true,
+ })
+ }
+ base, _ := m.GetByID(auth.ID)
+ updated := devinRefreshedObservation(base, "80%", "55%", time.Time{}, time.Time{})
+ if _, err := m.UpdateRefreshedAuth(context.Background(), base, updated); err != nil {
+ t.Fatalf("UpdateRefreshedAuth: %v", err)
+ }
+ stored, _ := m.GetByID(auth.ID)
+ for _, mID := range []string{model, sibling} {
+ if blocked, _, _ := isAuthBlockedForModel(stored, mID, time.Now()); blocked {
+ t.Fatalf("model %s must fully release after repeated-quota restoration", mID)
+ }
+ }
+}
+
+func TestDevinRefresh_RepeatedPropagationReleasesHealthySibling(t *testing.T) {
+ withQuotaCooldownEnabled(t)
+ m, auth, model, sibling := devinRefreshAuthFixture(t, "devin-repeat-prop-6690")
+
+ // Sibling gains a healthy ModelState from a successful request.
+ m.MarkResult(context.Background(), Result{AuthID: auth.ID, Provider: "devin", Model: sibling, Success: true})
+ // Two successive credential quota failures propagate to the sibling.
+ for _, retry := range []time.Duration{2 * time.Hour, 6 * time.Hour} {
+ r := retry
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: model, Success: false,
+ Error: &Error{Code: "rate_limit", Message: "weekly quota", HTTPStatus: http.StatusTooManyRequests},
+ RetryAfter: &r,
+ CredentialScope: true,
+ })
+ }
+ base, _ := m.GetByID(auth.ID)
+ updated := devinRefreshedObservation(base, "80%", "55%", time.Time{}, time.Time{})
+ if _, err := m.UpdateRefreshedAuth(context.Background(), base, updated); err != nil {
+ t.Fatalf("UpdateRefreshedAuth: %v", err)
+ }
+ stored, _ := m.GetByID(auth.ID)
+ if blocked, _, _ := isAuthBlockedForModel(stored, sibling, time.Now()); blocked {
+ t.Fatal("healthy sibling must fully release after repeated propagation (earlier propagated layer is not an independent block)")
+ }
+}
+
+func TestDevinRefresh_OwnFailureAfterPropagationUpdatesIndependentLayer(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ status int
+ }{
+ {name: "own model 429", status: http.StatusTooManyRequests},
+ {name: "own 404", status: http.StatusNotFound},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ withQuotaCooldownEnabled(t)
+ m, auth, model, sibling := devinRefreshAuthFixture(t, "devin-own-after-prop-"+tc.name+"-6690")
+
+ // Credential quota propagates to the sibling first.
+ quotaRetry := 2 * time.Hour
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: model, Success: false,
+ Error: &Error{Code: "rate_limit", Message: "weekly quota", HTTPStatus: http.StatusTooManyRequests},
+ RetryAfter: "aRetry,
+ CredentialScope: true,
+ })
+ // An in-flight sibling request then reports its own failure.
+ own := Result{
+ AuthID: auth.ID, Provider: "devin", Model: sibling, Success: false,
+ Error: &Error{Code: "own_failure", Message: "own", HTTPStatus: tc.status},
+ }
+ if tc.status == http.StatusTooManyRequests {
+ longRetry := 12 * time.Hour
+ own.RetryAfter = &longRetry
+ }
+ m.MarkResult(context.Background(), own)
+ base, _ := m.GetByID(auth.ID)
+
+ updated := devinRefreshedObservation(base, "80%", "55%", time.Time{}, time.Time{})
+ if _, err := m.UpdateRefreshedAuth(context.Background(), base, updated); err != nil {
+ t.Fatalf("UpdateRefreshedAuth: %v", err)
+ }
+ stored, _ := m.GetByID(auth.ID)
+ if blocked, _, _ := isAuthBlockedForModel(stored, model, time.Now()); blocked {
+ t.Fatal("reporting model must be released after restoration")
+ }
+ if blocked, _, _ := isAuthBlockedForModel(stored, sibling, time.Now()); !blocked {
+ t.Fatalf("sibling own failure (%s) must survive quota restoration", tc.name)
+ }
+ })
+ }
+}
+
+func TestDevinQuotaShadowSurvivesCooldownPersistenceReload(t *testing.T) {
+ withQuotaCooldownEnabled(t)
+ m, auth, model, sibling := devinRefreshAuthFixture(t, "devin-shadow-persist-6690")
+
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: sibling, Success: false,
+ Error: &Error{Code: "not_found", Message: "model missing", HTTPStatus: http.StatusNotFound},
+ })
+ quotaRetry := 72 * time.Hour
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: model, Success: false,
+ Error: &Error{Code: "rate_limit", Message: "weekly quota", HTTPStatus: http.StatusTooManyRequests},
+ RetryAfter: "aRetry,
+ CredentialScope: true,
+ })
+
+ // Simulate a restart: records persist, then restore into a fresh manager.
+ m.mu.RLock()
+ records := m.cooldownStateRecordsForAuthLocked(m.auths[auth.ID], time.Now())
+ m.mu.RUnlock()
+ m2 := NewManager(nil, nil, nil)
+ registry.GetGlobalRegistry().RegisterClient(auth.ID+"-reloaded", "devin", []*registry.ModelInfo{{ID: model}, {ID: sibling}})
+ t.Cleanup(func() { registry.GetGlobalRegistry().UnregisterClient(auth.ID + "-reloaded") })
+ reloaded := &Auth{ID: auth.ID, Provider: "devin", Status: StatusActive}
+ if _, err := m2.Register(context.Background(), reloaded); err != nil {
+ t.Fatalf("register reloaded: %v", err)
+ }
+ m2.mu.Lock()
+ for _, record := range records {
+ m2.restoreCooldownRecordLocked(record, time.Now())
+ }
+ m2.mu.Unlock()
+
+ base, _ := m2.GetByID(auth.ID)
+ updated := devinRefreshedObservation(base, "80%", "55%", time.Time{}, time.Time{})
+ if _, err := m2.UpdateRefreshedAuth(context.Background(), base, updated); err != nil {
+ t.Fatalf("UpdateRefreshedAuth: %v", err)
+ }
+ stored, _ := m2.GetByID(auth.ID)
+ if blocked, _, _ := isAuthBlockedForModel(stored, model, time.Now()); blocked {
+ t.Fatal("reporting model must be released after reload+restoration")
+ }
+ if blocked, _, _ := isAuthBlockedForModel(stored, sibling, time.Now()); !blocked {
+ t.Fatal("independent sibling block must survive restart and restoration")
+ }
+}
+
+func TestDevinRefresh_AlternatingCredentialReportsFullyRelease(t *testing.T) {
+ withQuotaCooldownEnabled(t)
+ m, auth, model, sibling := devinRefreshAuthFixture(t, "devin-alternating-6690")
+
+ // Model reports credential exhaustion first; an in-flight sibling request
+ // then reports the same credential exhaustion. Neither is independent.
+ for _, mID := range []string{model, sibling} {
+ r := 2 * time.Hour
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: mID, Success: false,
+ Error: &Error{Code: "rate_limit", Message: "weekly quota", HTTPStatus: http.StatusTooManyRequests},
+ RetryAfter: &r,
+ CredentialScope: true,
+ })
+ }
+ base, _ := m.GetByID(auth.ID)
+ updated := devinRefreshedObservation(base, "80%", "55%", time.Time{}, time.Time{})
+ if _, err := m.UpdateRefreshedAuth(context.Background(), base, updated); err != nil {
+ t.Fatalf("UpdateRefreshedAuth: %v", err)
+ }
+ stored, _ := m.GetByID(auth.ID)
+ for _, mID := range []string{model, sibling} {
+ if blocked, _, _ := isAuthBlockedForModel(stored, mID, time.Now()); blocked {
+ t.Fatalf("model %s must fully release (credential reports are never independent)", mID)
+ }
+ }
+}
+
+func TestDevinRefresh_OwnFailureUnderLongQuotaKeepsOwnDeadline(t *testing.T) {
+ withQuotaCooldownEnabled(t)
+ m, auth, model, sibling := devinRefreshAuthFixture(t, "devin-own-deadline-6690")
+
+ // Sibling starts with a healthy state, then a 72h credential quota wraps it.
+ m.MarkResult(context.Background(), Result{AuthID: auth.ID, Provider: "devin", Model: sibling, Success: true})
+ qr := 72 * time.Hour
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: model, Success: false,
+ Error: &Error{Code: "rate_limit", Message: "weekly quota", HTTPStatus: http.StatusTooManyRequests},
+ RetryAfter: &qr,
+ CredentialScope: true,
+ })
+ // In-flight sibling 404 arrives under the wrap (own 12h block).
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: sibling, Success: false,
+ Error: &Error{Code: "not_found", Message: "missing", HTTPStatus: http.StatusNotFound},
+ })
+ base, _ := m.GetByID(auth.ID)
+ updated := devinRefreshedObservation(base, "80%", "55%", time.Time{}, time.Time{})
+ if _, err := m.UpdateRefreshedAuth(context.Background(), base, updated); err != nil {
+ t.Fatalf("UpdateRefreshedAuth: %v", err)
+ }
+ stored, _ := m.GetByID(auth.ID)
+ if blocked, _, _ := isAuthBlockedForModel(stored, sibling, time.Now()); !blocked {
+ t.Fatal("own 404 must survive quota restoration")
+ }
+ if got := stored.ModelStates[sibling].NextRetryAfter; got.After(time.Now().Add(24 * time.Hour)) {
+ t.Fatalf("own deadline = %v, want ~12h (must not inherit the 72h propagated deadline)", got)
+ }
+}
+
+func TestDevinClearCooldown_ClearsIndependentShadow(t *testing.T) {
+ st := &ModelState{Unavailable: true, NextRetryAfter: time.Now().Add(time.Hour)}
+ st.credentialQuotaShadow = &ModelState{Unavailable: true}
+ st.credentialQuotaPropagated = true
+ authFixture := &Auth{ID: "x", Provider: "devin", Unavailable: true, NextRetryAfter: time.Now().Add(time.Hour), ModelStates: map[string]*ModelState{"m": st}}
+ clearCooldownStateForAuth(authFixture, time.Now())
+ if st.credentialQuotaShadow != nil || st.credentialQuotaPropagated {
+ t.Fatal("deliberate cooldown clear must drop the independent shadow and wrap marker")
+ }
+}
+
+func TestDevinRestoredIndependent_KeepsStaleSuccessWatermark(t *testing.T) {
+ withQuotaCooldownEnabled(t)
+ m, auth, model, sibling := devinRefreshAuthFixture(t, "devin-watermark-6690")
+
+ qr := 2 * time.Hour
+ seqQuota := m.nextExecutionSeq()
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: model, Success: false,
+ Error: &Error{Code: "rate_limit", Message: "weekly quota", HTTPStatus: http.StatusTooManyRequests},
+ RetryAfter: &qr,
+ CredentialScope: true,
+ ExecutionSeq: seqQuota,
+ })
+ // Sibling own failure dispatched after the quota failure.
+ seqOwn := m.nextExecutionSeq()
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: sibling, Success: false,
+ Error: &Error{Code: "not_found", Message: "missing", HTTPStatus: http.StatusNotFound},
+ ExecutionSeq: seqOwn,
+ })
+ base, _ := m.GetByID(auth.ID)
+ updated := devinRefreshedObservation(base, "80%", "55%", time.Time{}, time.Time{})
+ if _, err := m.UpdateRefreshedAuth(context.Background(), base, updated); err != nil {
+ t.Fatalf("UpdateRefreshedAuth: %v", err)
+ }
+ // A stale success dispatched before the own failure must not clear it.
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: sibling, Success: true, ExecutionSeq: seqQuota,
+ })
+ stored, _ := m.GetByID(auth.ID)
+ if blocked, _, _ := isAuthBlockedForModel(stored, sibling, time.Now()); !blocked {
+ t.Fatal("restored independent block must keep its stale-success watermark")
+ }
+}
+
+func TestDevinRefresh_ReportingModelKeepsIndependentBlock(t *testing.T) {
+ withQuotaCooldownEnabled(t)
+ m, auth, model, sibling := devinRefreshAuthFixture(t, "devin-reporting-own-6690")
+
+ // Model reports credential exhaustion; sibling then reports its own 404;
+ // finally the sibling itself reports credential exhaustion (in-flight).
+ qr := 2 * time.Hour
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: model, Success: false,
+ Error: &Error{Code: "rate_limit", Message: "weekly quota", HTTPStatus: http.StatusTooManyRequests},
+ RetryAfter: &qr,
+ CredentialScope: true,
+ })
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: sibling, Success: false,
+ Error: &Error{Code: "not_found", Message: "missing", HTTPStatus: http.StatusNotFound},
+ })
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: sibling, Success: false,
+ Error: &Error{Code: "rate_limit", Message: "weekly quota", HTTPStatus: http.StatusTooManyRequests},
+ RetryAfter: &qr,
+ CredentialScope: true,
+ })
+ base, _ := m.GetByID(auth.ID)
+ updated := devinRefreshedObservation(base, "80%", "55%", time.Time{}, time.Time{})
+ if _, err := m.UpdateRefreshedAuth(context.Background(), base, updated); err != nil {
+ t.Fatalf("UpdateRefreshedAuth: %v", err)
+ }
+ stored, _ := m.GetByID(auth.ID)
+ if blocked, _, _ := isAuthBlockedForModel(stored, model, time.Now()); blocked {
+ t.Fatal("first reporting model must be released after restoration")
+ }
+ if blocked, _, _ := isAuthBlockedForModel(stored, sibling, time.Now()); !blocked {
+ t.Fatal("reporting model's earlier independent 404 must survive restoration")
+ }
+}
+
+func TestDevinRefresh_TwoWindowsNormalizeStaleWrap(t *testing.T) {
+ withQuotaCooldownEnabled(t)
+ m, auth, model, sibling := devinRefreshAuthFixture(t, "devin-two-windows-6690")
+
+ // First quota window (short, expires immediately for the test clock).
+ shortRetry := time.Minute
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: model, Success: false,
+ Error: &Error{Code: "rate_limit", Message: "weekly quota", HTTPStatus: http.StatusTooManyRequests},
+ RetryAfter: &shortRetry,
+ CredentialScope: true,
+ })
+ // Force natural expiry of the first window without sleeping.
+ seed, _ := m.GetByID(auth.ID)
+ past := time.Now().Add(-time.Hour)
+ seed.NextRetryAfter = past
+ seed.Quota.NextRecoverAt = past
+ for _, state := range seed.ModelStates {
+ state.NextRetryAfter = past
+ state.Quota.NextRecoverAt = past
+ }
+ if _, err := m.Update(context.Background(), seed); err != nil {
+ t.Fatalf("expire seed: %v", err)
+ }
+ // After expiry the sibling earns a fresh independent block.
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: sibling, Success: false,
+ Error: &Error{Code: "not_found", Message: "missing", HTTPStatus: http.StatusNotFound},
+ })
+ // A second credential quota window wraps the credential again.
+ qr := 72 * time.Hour
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: model, Success: false,
+ Error: &Error{Code: "rate_limit", Message: "weekly quota", HTTPStatus: http.StatusTooManyRequests},
+ RetryAfter: &qr,
+ CredentialScope: true,
+ })
+ base, _ := m.GetByID(auth.ID)
+ updated := devinRefreshedObservation(base, "80%", "55%", time.Time{}, time.Time{})
+ if _, err := m.UpdateRefreshedAuth(context.Background(), base, updated); err != nil {
+ t.Fatalf("UpdateRefreshedAuth: %v", err)
+ }
+ stored, _ := m.GetByID(auth.ID)
+ if blocked, _, _ := isAuthBlockedForModel(stored, model, time.Now()); blocked {
+ t.Fatal("reporting model must be released in the second window")
+ }
+ if blocked, _, _ := isAuthBlockedForModel(stored, sibling, time.Now()); !blocked {
+ t.Fatal("fresh independent block must survive the second window (stale wrap marker must not suppress snapshotting)")
+ }
+}
+
+func TestDevinRefresh_AlternatingAfterReloadFullyReleases(t *testing.T) {
+ withQuotaCooldownEnabled(t)
+ m, auth, model, sibling := devinRefreshAuthFixture(t, "devin-alt-reload-6690")
+
+ qr := 72 * time.Hour
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: model, Success: false,
+ Error: &Error{Code: "rate_limit", Message: "weekly quota", HTTPStatus: http.StatusTooManyRequests},
+ RetryAfter: &qr,
+ CredentialScope: true,
+ })
+ m.mu.RLock()
+ records := m.cooldownStateRecordsForAuthLocked(m.auths[auth.ID], time.Now())
+ m.mu.RUnlock()
+
+ m2 := NewManager(nil, nil, nil)
+ registry.GetGlobalRegistry().RegisterClient(auth.ID+"-alt", "devin", []*registry.ModelInfo{{ID: model}, {ID: sibling}})
+ t.Cleanup(func() { registry.GetGlobalRegistry().UnregisterClient(auth.ID + "-alt") })
+ if _, err := m2.Register(context.Background(), &Auth{ID: auth.ID, Provider: "devin", Status: StatusActive}); err != nil {
+ t.Fatalf("register: %v", err)
+ }
+ m2.mu.Lock()
+ for _, record := range records {
+ m2.restoreCooldownRecordLocked(record, time.Now())
+ }
+ m2.mu.Unlock()
+
+ // Post-restart, the sibling reports credential exhaustion this time.
+ m2.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: sibling, Success: false,
+ Error: &Error{Code: "rate_limit", Message: "weekly quota", HTTPStatus: http.StatusTooManyRequests},
+ RetryAfter: &qr,
+ CredentialScope: true,
+ })
+ base, _ := m2.GetByID(auth.ID)
+ updated := devinRefreshedObservation(base, "80%", "55%", time.Time{}, time.Time{})
+ if _, err := m2.UpdateRefreshedAuth(context.Background(), base, updated); err != nil {
+ t.Fatalf("UpdateRefreshedAuth: %v", err)
+ }
+ stored, _ := m2.GetByID(auth.ID)
+ for _, mID := range []string{model, sibling} {
+ if blocked, _, _ := isAuthBlockedForModel(stored, mID, time.Now()); blocked {
+ t.Fatalf("model %s must fully release (reporting provenance must survive reload)", mID)
+ }
+ }
+}
+
+func TestDevinRestoreDoesNotClobberNewerShadow(t *testing.T) {
+ m := NewManager(nil, nil, nil)
+ auth := &Auth{ID: "devin-restore-shadow-6690", Provider: "devin", Status: StatusActive}
+ if _, err := m.Register(context.Background(), auth); err != nil {
+ t.Fatalf("register: %v", err)
+ }
+ // Live state already carries a newer independent shadow.
+ live, _ := m.GetByID(auth.ID)
+ newShadow := &ModelState{Unavailable: true, Status: StatusError, NextRetryAfter: time.Now().Add(12 * time.Hour), UpdatedAt: time.Now()}
+ liveState := &ModelState{
+ Unavailable: true, Status: StatusError, NextRetryAfter: time.Now().Add(72 * time.Hour),
+ Quota: QuotaState{Exceeded: true, Reason: "credential_quota", NextRecoverAt: time.Now().Add(72 * time.Hour)},
+ UpdatedAt: time.Now(),
+ }
+ liveState.credentialQuotaShadow = newShadow
+ liveState.credentialQuotaPropagated = true
+ live.ModelStates = map[string]*ModelState{"claude-opus-5-5": liveState}
+ if _, err := m.Update(context.Background(), live); err != nil {
+ t.Fatalf("seed live: %v", err)
+ }
+
+ staleShadow := &ModelState{Unavailable: true, Status: StatusError, NextRetryAfter: time.Now().Add(time.Hour), UpdatedAt: time.Now().Add(-2 * time.Hour)}
+ record := CooldownStateRecord{
+ Provider: "devin", AuthID: auth.ID, Model: "claude-opus-5-5", Status: "cooling",
+ NextRetryAfter: time.Now().Add(72 * time.Hour), Reason: "credential_quota",
+ Quota: QuotaState{Exceeded: true, Reason: "credential_quota", NextRecoverAt: time.Now().Add(72 * time.Hour)},
+ UpdatedAt: time.Now().Add(-2 * time.Hour), IndependentState: staleShadow,
+ }
+ m.mu.Lock()
+ m.restoreCooldownRecordLocked(record, time.Now())
+ m.mu.Unlock()
+
+ stored, _ := m.GetByID(auth.ID)
+ got := stored.ModelStates["claude-opus-5-5"].credentialQuotaShadow
+ if got == nil || !got.NextRetryAfter.After(time.Now().Add(6*time.Hour)) {
+ t.Fatalf("newer live shadow clobbered by stale record, shadow=%+v", got)
+ }
+}
+
+func TestCooldownRecord_IndependentStateJSONRoundTrip(t *testing.T) {
+ deadline := time.Now().Add(12 * time.Hour).UTC().Truncate(time.Second)
+ record := CooldownStateRecord{
+ Provider: "devin", AuthID: "rt", Model: "m", Status: "cooling",
+ NextRetryAfter: deadline, Reason: "credential_quota",
+ Quota: QuotaState{Exceeded: true, Reason: "credential_quota", NextRecoverAt: deadline},
+ UpdatedAt: time.Now().UTC().Truncate(time.Second),
+ CredentialQuotaPropagated: true,
+ IndependentState: &ModelState{
+ Status: StatusError, Unavailable: true, NextRetryAfter: deadline,
+ Quota: QuotaState{Exceeded: true, Reason: "quota", NextRecoverAt: deadline},
+ },
+ }
+ data, errMarshal := json.Marshal(record)
+ if errMarshal != nil {
+ t.Fatalf("marshal: %v", errMarshal)
+ }
+ var decoded CooldownStateRecord
+ if errUnmarshal := json.Unmarshal(data, &decoded); errUnmarshal != nil {
+ t.Fatalf("unmarshal: %v", errUnmarshal)
+ }
+ if !decoded.CredentialQuotaPropagated {
+ t.Fatal("credential_quota_propagated lost in round-trip")
+ }
+ if decoded.IndependentState == nil || !decoded.IndependentState.NextRetryAfter.Equal(deadline) || decoded.IndependentState.Quota.Reason != "quota" {
+ t.Fatalf("independent state lost in round-trip: %+v", decoded.IndependentState)
+ }
+ // Old records without the new fields must still load.
+ var legacy CooldownStateRecord
+ if errLegacy := json.Unmarshal([]byte(`{"auth_id":"old","next_retry_after":"2026-10-20T00:00:00Z"}`), &legacy); errLegacy != nil {
+ t.Fatalf("legacy unmarshal: %v", errLegacy)
+ }
+ if legacy.IndependentState != nil || legacy.CredentialQuotaPropagated {
+ t.Fatalf("legacy record must default cleanly, got %+v", legacy)
+ }
+}
+
+func TestDevinRefresh_InFlightCompositionKeepsExactOwnDeadline(t *testing.T) {
+ withQuotaCooldownEnabled(t)
+ m, auth, model, sibling := devinRefreshAuthFixture(t, "devin-inflight-exact-6690")
+
+ // 72h credential quota first; the (untracked) sibling then reports its
+ // own in-flight 404, and finally reports credential exhaustion itself.
+ qr := 72 * time.Hour
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: model, Success: false,
+ Error: &Error{Code: "rate_limit", Message: "weekly quota", HTTPStatus: http.StatusTooManyRequests},
+ RetryAfter: &qr,
+ CredentialScope: true,
+ })
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: sibling, Success: false,
+ Error: &Error{Code: "not_found", Message: "missing", HTTPStatus: http.StatusNotFound},
+ })
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: sibling, Success: false,
+ Error: &Error{Code: "rate_limit", Message: "weekly quota", HTTPStatus: http.StatusTooManyRequests},
+ RetryAfter: &qr,
+ CredentialScope: true,
+ })
+ base, _ := m.GetByID(auth.ID)
+ updated := devinRefreshedObservation(base, "80%", "55%", time.Time{}, time.Time{})
+ if _, err := m.UpdateRefreshedAuth(context.Background(), base, updated); err != nil {
+ t.Fatalf("UpdateRefreshedAuth: %v", err)
+ }
+ stored, _ := m.GetByID(auth.ID)
+ if blocked, _, _ := isAuthBlockedForModel(stored, sibling, time.Now()); !blocked {
+ t.Fatal("in-flight own 404 must survive restoration")
+ }
+ if got := stored.ModelStates[sibling].NextRetryAfter; got.After(time.Now().Add(24 * time.Hour)) {
+ t.Fatalf("own deadline = %v, want ~12h (composed outer must not be snapshotted as independence)", got)
+ }
+}
+
+func TestDevinRefresh_ReportingModelKeepsModelScoped429(t *testing.T) {
+ withQuotaCooldownEnabled(t)
+ m, auth, model, _ := devinRefreshAuthFixture(t, "devin-reporting-429-6690")
+
+ // The model first earns an ordinary model-scoped 429 (12h), then reports
+ // credential exhaustion itself. Restoration must keep the 12h block.
+ longRetry := 12 * time.Hour
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: model, Success: false,
+ Error: &Error{Code: "rate_limit", Message: "model rate limit", HTTPStatus: http.StatusTooManyRequests},
+ RetryAfter: &longRetry,
+ })
+ qr := 72 * time.Hour
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: model, Success: false,
+ Error: &Error{Code: "rate_limit", Message: "weekly quota", HTTPStatus: http.StatusTooManyRequests},
+ RetryAfter: &qr,
+ CredentialScope: true,
+ })
+ base, _ := m.GetByID(auth.ID)
+ updated := devinRefreshedObservation(base, "80%", "55%", time.Time{}, time.Time{})
+ if _, err := m.UpdateRefreshedAuth(context.Background(), base, updated); err != nil {
+ t.Fatalf("UpdateRefreshedAuth: %v", err)
+ }
+ stored, _ := m.GetByID(auth.ID)
+ if blocked, _, _ := isAuthBlockedForModel(stored, model, time.Now()); !blocked {
+ t.Fatal("genuine model-scoped 429 must survive quota restoration")
+ }
+ if got := stored.ModelStates[model].NextRetryAfter; got.After(time.Now().Add(24 * time.Hour)) {
+ t.Fatalf("own deadline = %v, want ~12h", got)
+ }
+}
+
+func TestDevinRefresh_LegacyLabelOnlySiblingReleases(t *testing.T) {
+ withQuotaCooldownEnabled(t)
+ m, auth, model, sibling := devinRefreshAuthFixture(t, "devin-legacy-label-6690")
+
+ // Legacy persisted shape: propagated label, no marker/shadow fields.
+ recoverAt := time.Now().Add(72 * time.Hour)
+ seed, _ := m.GetByID(auth.ID)
+ seed.Unavailable = true
+ seed.Status = StatusError
+ seed.NextRetryAfter = recoverAt
+ seed.Quota = QuotaState{Exceeded: true, Reason: "credential_quota", NextRecoverAt: recoverAt, ObservedAt: time.Now().Add(-2 * time.Hour)}
+ legacySibling := &ModelState{Status: StatusError, Unavailable: true, NextRetryAfter: recoverAt, Quota: QuotaState{Exceeded: true, Reason: "credential_quota", NextRecoverAt: recoverAt}, UpdatedAt: time.Now()}
+ seed.ModelStates = map[string]*ModelState{
+ model: {Status: StatusError, Unavailable: true, NextRetryAfter: recoverAt, Quota: QuotaState{Exceeded: true, Reason: "quota", NextRecoverAt: recoverAt}},
+ sibling: legacySibling,
+ }
+ if _, err := m.Update(context.Background(), seed); err != nil {
+ t.Fatalf("seed legacy: %v", err)
+ }
+ // The legacy sibling now reports credential exhaustion itself.
+ qr := 72 * time.Hour
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: sibling, Success: false,
+ Error: &Error{Code: "rate_limit", Message: "weekly quota", HTTPStatus: http.StatusTooManyRequests},
+ RetryAfter: &qr,
+ CredentialScope: true,
+ })
+ base, _ := m.GetByID(auth.ID)
+ updated := devinRefreshedObservation(base, "80%", "55%", time.Time{}, time.Time{})
+ if _, err := m.UpdateRefreshedAuth(context.Background(), base, updated); err != nil {
+ t.Fatalf("UpdateRefreshedAuth: %v", err)
+ }
+ stored, _ := m.GetByID(auth.ID)
+ if blocked, _, _ := isAuthBlockedForModel(stored, sibling, time.Now()); blocked {
+ t.Fatal("legacy propagated sibling must fully release (outer quota is not an independent block)")
+ }
+}
+
+func TestDevinRefresh_ExpiredWindowThenNewOwnFailurePreserved(t *testing.T) {
+ withQuotaCooldownEnabled(t)
+ m, auth, model, sibling := devinRefreshAuthFixture(t, "devin-expire-success-6690")
+
+ // Model earns a 12h independent model-scoped 429; sibling wraps it with
+ // a short 2h credential quota.
+ longRetry := 12 * time.Hour
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: model, Success: false,
+ Error: &Error{Code: "rate_limit", Message: "model rate limit", HTTPStatus: http.StatusTooManyRequests},
+ RetryAfter: &longRetry,
+ })
+ shortRetry := 2 * time.Hour
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: sibling, Success: false,
+ Error: &Error{Code: "rate_limit", Message: "weekly quota", HTTPStatus: http.StatusTooManyRequests},
+ RetryAfter: &shortRetry,
+ CredentialScope: true,
+ })
+ // Force the credential window to expire naturally (timestamps only):
+ // the auth credential deadline and every propagated deadline pass,
+ // while the model's own 12h deadline (max with the 2h window) survives.
+ seed, _ := m.GetByID(auth.ID)
+ past := time.Now().Add(-time.Hour)
+ seed.NextRetryAfter = past
+ seed.Quota.NextRecoverAt = past
+ for _, state := range seed.ModelStates {
+ if state.Quota.Reason == "credential_quota" || state.Quota.NextRecoverAt.Before(time.Now().Add(3*time.Hour)) {
+ state.Quota.NextRecoverAt = past
+ }
+ if state.Quota.Reason == "credential_quota" {
+ state.NextRetryAfter = past
+ }
+ }
+ if rep := seed.ModelStates[sibling]; rep != nil {
+ rep.NextRetryAfter = past
+ rep.Quota.NextRecoverAt = past
+ }
+ if _, err := m.Update(context.Background(), seed); err != nil {
+ t.Fatalf("expire seed: %v", err)
+ }
+ // Sibling succeeds after expiry; aggregation re-derives an ordinary
+ // auth-level "quota" from the model's still-live deadline.
+ m.MarkResult(context.Background(), Result{AuthID: auth.ID, Provider: "devin", Model: sibling, Success: true})
+ // Model reports a fresh independent 24h model-scoped 429.
+ freshRetry := 24 * time.Hour
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: model, Success: false,
+ Error: &Error{Code: "rate_limit", Message: "model rate limit again", HTTPStatus: http.StatusTooManyRequests},
+ RetryAfter: &freshRetry,
+ })
+ // A new credential quota window wraps the credential again.
+ qr := 72 * time.Hour
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: sibling, Success: false,
+ Error: &Error{Code: "rate_limit", Message: "weekly quota", HTTPStatus: http.StatusTooManyRequests},
+ RetryAfter: &qr,
+ CredentialScope: true,
+ })
+ base, _ := m.GetByID(auth.ID)
+ updated := devinRefreshedObservation(base, "80%", "55%", time.Time{}, time.Time{})
+ if _, err := m.UpdateRefreshedAuth(context.Background(), base, updated); err != nil {
+ t.Fatalf("UpdateRefreshedAuth: %v", err)
+ }
+ stored, _ := m.GetByID(auth.ID)
+ if blocked, _, _ := isAuthBlockedForModel(stored, sibling, time.Now()); blocked {
+ t.Fatal("reporting sibling must be released after restoration")
+ }
+ if blocked, _, _ := isAuthBlockedForModel(stored, model, time.Now()); !blocked {
+ t.Fatal("fresh independent cooldown must survive the second window (expired wrap must be normalized even when aggregation re-derived an auth quota)")
+ }
+}
+
+func TestDevinRestorePreservesLiveUnwrappedIndependent(t *testing.T) {
+ m := NewManager(nil, nil, nil)
+ auth := &Auth{ID: "devin-restore-unwrapped-6690", Provider: "devin", Status: StatusActive}
+ if _, err := m.Register(context.Background(), auth); err != nil {
+ t.Fatalf("register: %v", err)
+ }
+ // Live state: a fresh unwrapped independent 404 block (post-restoration).
+ live, _ := m.GetByID(auth.ID)
+ liveState := &ModelState{
+ Unavailable: true, Status: StatusError, NextRetryAfter: time.Now().Add(12 * time.Hour),
+ LastError: &Error{Code: "not_found", Message: "missing", HTTPStatus: http.StatusNotFound},
+ UpdatedAt: time.Now(),
+ }
+ live.ModelStates = map[string]*ModelState{"claude-opus-5-5": liveState}
+ live.Unavailable = true
+ live.NextRetryAfter = liveState.NextRetryAfter
+ if _, err := m.Update(context.Background(), live); err != nil {
+ t.Fatalf("seed live: %v", err)
+ }
+
+ // A stale wrapped record (loaded earlier, applied late) must not erase it.
+ staleRecord := CooldownStateRecord{
+ Provider: "devin", AuthID: auth.ID, Model: "claude-opus-5-5", Status: "cooling",
+ NextRetryAfter: time.Now().Add(72 * time.Hour), Reason: "credential_quota",
+ Quota: QuotaState{Exceeded: true, Reason: "credential_quota", NextRecoverAt: time.Now().Add(72 * time.Hour)},
+ UpdatedAt: time.Now().Add(-2 * time.Hour),
+ CredentialQuotaPropagated: true,
+ }
+ m.mu.Lock()
+ m.restoreCooldownRecordLocked(staleRecord, time.Now())
+ m.mu.Unlock()
+
+ stored, _ := m.GetByID(auth.ID)
+ restored := stored.ModelStates["claude-opus-5-5"]
+ if restored.credentialQuotaShadow == nil {
+ t.Fatal("live unwrapped independent cooldown must be captured as the shadow when a wrapped record applies")
+ }
+ if got := restored.credentialQuotaShadow.NextRetryAfter; got.After(time.Now().Add(24*time.Hour)) || got.Before(time.Now().Add(6*time.Hour)) {
+ t.Fatalf("captured shadow deadline = %v, want the ~12h independent block", got)
+ }
+}
+
+func TestDevinRestoreDoesNotCaptureCredentialDerivedLegacyQuota(t *testing.T) {
+ m := NewManager(nil, nil, nil)
+ auth := &Auth{ID: "devin-restore-legacy-derived-6690", Provider: "devin", Status: StatusActive}
+ if _, err := m.Register(context.Background(), auth); err != nil {
+ t.Fatalf("register: %v", err)
+ }
+ // Legacy (pre-fix) reporting state: reason "quota" whose own error is the
+ // explicit Devin weekly-exhaustion message — credential-derived, not an
+ // independent rate limit.
+ live, _ := m.GetByID(auth.ID)
+ liveState := &ModelState{
+ Unavailable: true, Status: StatusError, NextRetryAfter: time.Now().Add(5 * time.Minute),
+ Quota: QuotaState{Exceeded: true, Reason: "quota", NextRecoverAt: time.Now().Add(5 * time.Minute)},
+ LastError: &Error{HTTPStatus: http.StatusTooManyRequests, Message: "failed_precondition: Your weekly usage quota has been exhausted."},
+ UpdatedAt: time.Now(),
+ }
+ live.ModelStates = map[string]*ModelState{"claude-opus-5-5": liveState}
+ live.Unavailable = true
+ live.NextRetryAfter = liveState.NextRetryAfter
+ if _, err := m.Update(context.Background(), live); err != nil {
+ t.Fatalf("seed live: %v", err)
+ }
+
+ wrappedRecord := CooldownStateRecord{
+ Provider: "devin", AuthID: auth.ID, Model: "claude-opus-5-5", Status: "cooling",
+ NextRetryAfter: time.Now().Add(72 * time.Hour), Reason: "credential_quota",
+ Quota: QuotaState{Exceeded: true, Reason: "credential_quota", NextRecoverAt: time.Now().Add(72 * time.Hour)},
+ UpdatedAt: time.Now().Add(-time.Hour),
+ }
+ m.mu.Lock()
+ m.restoreCooldownRecordLocked(wrappedRecord, time.Now())
+ m.mu.Unlock()
+
+ stored, _ := m.GetByID(auth.ID)
+ if got := stored.ModelStates["claude-opus-5-5"].credentialQuotaShadow; got != nil {
+ t.Fatalf("credential-derived legacy quota must not be captured as independence, shadow=%+v", got)
+ }
+}
+
+func TestDevinRestoreIndependentRecordUnderLiveWrapPreserved(t *testing.T) {
+ withQuotaCooldownEnabled(t)
+ m, auth, model, sibling := devinRefreshAuthFixture(t, "devin-restore-under-wrap-6690")
+
+ // Live credential quota wraps the credential (model has no state yet).
+ qr := 72 * time.Hour
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: sibling, Success: false,
+ Error: &Error{Code: "rate_limit", Message: "weekly quota", HTTPStatus: http.StatusTooManyRequests},
+ RetryAfter: &qr,
+ CredentialScope: true,
+ })
+ // A delayed unwrapped independent record for the model applies late.
+ record := CooldownStateRecord{
+ Provider: "devin", AuthID: auth.ID, Model: model, Status: "cooling",
+ NextRetryAfter: time.Now().Add(12 * time.Hour), Reason: "quota",
+ Quota: QuotaState{Exceeded: true, Reason: "quota", NextRecoverAt: time.Now().Add(12 * time.Hour)},
+ LastError: &Error{Code: "rate_limit", Message: "model rate limit", HTTPStatus: http.StatusTooManyRequests},
+ UpdatedAt: time.Now().Add(-time.Minute),
+ }
+ m.mu.Lock()
+ m.restoreCooldownRecordLocked(record, time.Now())
+ m.mu.Unlock()
+
+ base, _ := m.GetByID(auth.ID)
+ updated := devinRefreshedObservation(base, "80%", "55%", time.Time{}, time.Time{})
+ if _, err := m.UpdateRefreshedAuth(context.Background(), base, updated); err != nil {
+ t.Fatalf("UpdateRefreshedAuth: %v", err)
+ }
+ stored, _ := m.GetByID(auth.ID)
+ if blocked, _, _ := isAuthBlockedForModel(stored, sibling, time.Now()); blocked {
+ t.Fatal("reporting sibling must be released after restoration")
+ }
+ if blocked, _, _ := isAuthBlockedForModel(stored, model, time.Now()); !blocked {
+ t.Fatal("restored independent record beneath a live wrap must survive quota restoration")
+ }
+}
+
+func TestDevinFirstWrapDoesNotCaptureLegacyExhaustion(t *testing.T) {
+ t.Run("sibling", func(t *testing.T) {
+ withQuotaCooldownEnabled(t)
+ m, auth, model, sibling := devinRefreshAuthFixture(t, "devin-legacy-firstwrap-sib-6690")
+ seed, _ := m.GetByID(auth.ID)
+ seed.ModelStates = map[string]*ModelState{
+ sibling: {
+ Status: StatusError, Unavailable: true, NextRetryAfter: time.Now().Add(5 * time.Minute),
+ Quota: QuotaState{Exceeded: true, Reason: "quota", NextRecoverAt: time.Now().Add(5 * time.Minute)},
+ LastError: &Error{HTTPStatus: http.StatusTooManyRequests, Message: "failed_precondition: Your weekly usage quota has been exhausted."},
+ UpdatedAt: time.Now(),
+ },
+ }
+ if _, err := m.Update(context.Background(), seed); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+ qr := 72 * time.Hour
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: model, Success: false,
+ Error: &Error{Code: "rate_limit", Message: "weekly quota", HTTPStatus: http.StatusTooManyRequests},
+ RetryAfter: &qr,
+ CredentialScope: true,
+ })
+ base, _ := m.GetByID(auth.ID)
+ updated := devinRefreshedObservation(base, "80%", "55%", time.Time{}, time.Time{})
+ if _, err := m.UpdateRefreshedAuth(context.Background(), base, updated); err != nil {
+ t.Fatalf("UpdateRefreshedAuth: %v", err)
+ }
+ stored, _ := m.GetByID(auth.ID)
+ if blocked, _, _ := isAuthBlockedForModel(stored, sibling, time.Now()); blocked {
+ t.Fatal("legacy credential-derived sibling must fully release")
+ }
+ })
+ t.Run("reporting", func(t *testing.T) {
+ withQuotaCooldownEnabled(t)
+ m, auth, model, _ := devinRefreshAuthFixture(t, "devin-legacy-firstwrap-rep-6690")
+ seed, _ := m.GetByID(auth.ID)
+ seed.ModelStates = map[string]*ModelState{
+ model: {
+ Status: StatusError, Unavailable: true, NextRetryAfter: time.Now().Add(5 * time.Minute),
+ Quota: QuotaState{Exceeded: true, Reason: "quota", NextRecoverAt: time.Now().Add(5 * time.Minute)},
+ LastError: &Error{HTTPStatus: http.StatusTooManyRequests, Message: "failed_precondition: Your weekly usage quota has been exhausted."},
+ UpdatedAt: time.Now(),
+ },
+ }
+ if _, err := m.Update(context.Background(), seed); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+ qr := 72 * time.Hour
+ m.MarkResult(context.Background(), Result{
+ AuthID: auth.ID, Provider: "devin", Model: model, Success: false,
+ Error: &Error{Code: "rate_limit", Message: "weekly quota", HTTPStatus: http.StatusTooManyRequests},
+ RetryAfter: &qr,
+ CredentialScope: true,
+ })
+ base, _ := m.GetByID(auth.ID)
+ updated := devinRefreshedObservation(base, "80%", "55%", time.Time{}, time.Time{})
+ if _, err := m.UpdateRefreshedAuth(context.Background(), base, updated); err != nil {
+ t.Fatalf("UpdateRefreshedAuth: %v", err)
+ }
+ stored, _ := m.GetByID(auth.ID)
+ if blocked, _, _ := isAuthBlockedForModel(stored, model, time.Now()); blocked {
+ t.Fatal("legacy credential-derived reporting state must fully release")
+ }
+ })
+}
+
+type devinFalseRefreshEvaluator struct{}
+
+func (devinFalseRefreshEvaluator) ShouldRefresh(time.Time, *Auth) bool { return false }
+
+func TestDevinRevalidate_EvaluatorFalseKeepsFutureSchedule(t *testing.T) {
+ m, auth, model, sibling := devinRefreshAuthFixture(t, "devin-evaluator-6690")
+ base := devinExhaustedAuth(t, m, auth.ID, model, sibling, time.Now().Add(72*time.Hour))
+ base.LastRefreshedAt = time.Now().Add(-2 * time.Hour) // revalidation due
+ base.Runtime = devinFalseRefreshEvaluator{}
+
+ if m.shouldRefresh(base, time.Now()) {
+ t.Fatal("custom evaluator false must keep precedence in shouldRefresh")
+ }
+ next, ok := nextRefreshCheckAt(time.Now(), base, 5*time.Second)
+ if !ok {
+ t.Fatal("evaluator-managed auth must remain scheduled")
+ }
+ if !next.After(time.Now()) {
+ t.Fatalf("next check = %v, must be in the future to avoid a zero-delay scheduling loop", next)
+ }
+}
+
+func TestDevinRefresh_RestoredKeepsUnrelatedModelCooldown(t *testing.T) {
+ withQuotaCooldownEnabled(t)
+ m, auth, model, sibling := devinRefreshAuthFixture(t, "devin-refresh-unrelated-6690")
+ recoverAt := time.Now().Add(72 * time.Hour)
+ base := devinExhaustedAuth(t, m, auth.ID, model, sibling, recoverAt)
+
+ // Give one model an unrelated, longer non-quota block that must survive.
+ // Mark it as a freshly written own state (not a stale in-flight result).
+ seed, _ := m.GetByID(auth.ID)
+ longBlock := time.Now().Add(120 * time.Hour)
+ seed.ModelStates[model].Quota = QuotaState{Exceeded: true, Reason: "cloudflare challenge", NextRecoverAt: longBlock}
+ seed.ModelStates[model].NextRetryAfter = longBlock
+ seed.ModelStates[model].UpdatedAt = time.Now()
+ if _, err := m.Update(context.Background(), seed); err != nil {
+ t.Fatalf("seed unrelated block: %v", err)
+ }
+
+ updated := devinRefreshedObservation(base, "80%", "55%", time.Time{}, time.Time{})
+ if _, err := m.UpdateRefreshedAuth(context.Background(), base, updated); err != nil {
+ t.Fatalf("UpdateRefreshedAuth: %v", err)
+ }
+
+ stored, _ := m.GetByID(auth.ID)
+ if stored == nil {
+ t.Fatal("stored auth missing")
+ }
+ if stored.Quota.Reason == "credential_quota" {
+ t.Fatalf("credential quota must be released, got %+v", stored.Quota)
+ }
+ if blocked, _, _ := isAuthBlockedForModel(stored, model, time.Now()); !blocked {
+ t.Fatal("unrelated cloudflare block must survive quota restoration")
+ }
+ if blocked, _, _ := isAuthBlockedForModel(stored, sibling, time.Now()); blocked {
+ t.Fatal("propagated sibling block must be released after quota restoration")
+ }
+}
diff --git a/sdk/cliproxy/auth/metadata_keys.go b/sdk/cliproxy/auth/metadata_keys.go
index e861cfedb3a..ffb662ac2c5 100644
--- a/sdk/cliproxy/auth/metadata_keys.go
+++ b/sdk/cliproxy/auth/metadata_keys.go
@@ -16,6 +16,10 @@ func CanonicalCredentialMetadataKey(key string) string {
return "fingerprint_profile"
case "model-aliases":
return "model_aliases"
+ case "openapi-location":
+ return "openapi_location"
+ case "openapi-models":
+ return "openapi_models"
case "proxy-url":
return "proxy_url"
case "request-retry":
diff --git a/sdk/cliproxy/auth/metadata_merge.go b/sdk/cliproxy/auth/metadata_merge.go
index 1bc416a267f..00df2935d39 100644
--- a/sdk/cliproxy/auth/metadata_merge.go
+++ b/sdk/cliproxy/auth/metadata_merge.go
@@ -124,7 +124,7 @@ func MergeRefreshedAuth(base, current, updated *Auth) *Auth {
merged.Status = current.Status
merged.Unavailable = current.Unavailable
merged.StatusMessage = current.StatusMessage
- } else if current.Quota.Exceeded && current.Quota.Reason == "credential_quota" && current.Quota.NextRecoverAt.After(time.Now()) {
+ } else if current.Quota.Exceeded && current.Quota.Reason == "credential_quota" && current.Quota.NextRecoverAt.After(time.Now()) && !devinQuotaObservationRestored(merged, current, time.Now()) {
// Preserve active credential quota
merged.Unavailable = current.Unavailable
merged.Status = current.Status
@@ -143,7 +143,28 @@ func MergeRefreshedAuth(base, current, updated *Auth) *Auth {
}
}
+ // 2b. Quota observations: adopt the refreshed snapshot when it is newer.
+ // Without this, executor Refresh results (e.g. Devin quota reset signals)
+ // never reach the stored auth and cannot drive scheduling. Cooldown
+ // fields stay with current so concurrent failures are never lost.
+ // The merge happens after the status branches above (they only copy
+ // scalar fields), so ordering relative to them is safe.
+ merged.Quota = mergeQuotaObservation(merged.Quota, updated.Quota)
+
// 3. ModelStates: three-way merge to preserve concurrent cooldown/quota
+ // Independent layers follow the same current-wins rule: executor refresh
+ // results are derived from the base snapshot and never move them.
+ for model, mergedState := range merged.ModelStates {
+ if mergedState == nil {
+ continue
+ }
+ if currentState := current.ModelStates[model]; currentState != nil {
+ if currentState.credentialQuotaShadow != nil && mergedState.credentialQuotaShadow == nil {
+ mergedState.credentialQuotaShadow = currentState.credentialQuotaShadow.cloneShallow()
+ }
+ mergedState.credentialQuotaPropagated = mergedState.credentialQuotaPropagated || currentState.credentialQuotaPropagated
+ }
+ }
var baseModels map[string]*ModelState
if base != nil {
baseModels = base.ModelStates
diff --git a/sdk/cliproxy/auth/scheduler.go b/sdk/cliproxy/auth/scheduler.go
index 39c138e7436..752a52d7d57 100644
--- a/sdk/cliproxy/auth/scheduler.go
+++ b/sdk/cliproxy/auth/scheduler.go
@@ -891,6 +891,12 @@ func isCredentialBlocked(auth *Auth, supportedModelCount int, now time.Time) boo
if auth.Disabled || auth.Status == StatusDisabled {
return true
}
+ if hasUnauthorizedAuthFailure(auth) {
+ return true
+ }
+ if exp, ok := auth.AccessTokenExpirationTime(); ok && !exp.IsZero() && !exp.After(now) {
+ return true
+ }
if auth.Quota.Exceeded && auth.Quota.Reason == "credential_quota" && auth.Quota.NextRecoverAt.After(now) {
return true
}
diff --git a/sdk/cliproxy/auth/scheduler_test.go b/sdk/cliproxy/auth/scheduler_test.go
index f4b64ab729d..9e2cb6dc0d1 100644
--- a/sdk/cliproxy/auth/scheduler_test.go
+++ b/sdk/cliproxy/auth/scheduler_test.go
@@ -64,16 +64,20 @@ func (schedulerTestExecutor) HttpRequest(ctx context.Context, auth *Auth, req *h
}
type fakePluginScheduler struct {
- acrossPriorities bool
- resp pluginapi.SchedulerPickResponse
- handled bool
- err error
- calls int
- requests []pluginapi.SchedulerPickRequest
- pick func(context.Context, pluginapi.SchedulerPickRequest) (pluginapi.SchedulerPickResponse, bool, error)
+ acrossPriorities bool
+ acrossPrioritiesFunc func() bool
+ resp pluginapi.SchedulerPickResponse
+ handled bool
+ err error
+ calls int
+ requests []pluginapi.SchedulerPickRequest
+ pick func(context.Context, pluginapi.SchedulerPickRequest) (pluginapi.SchedulerPickResponse, bool, error)
}
func (s *fakePluginScheduler) SchedulerWantsAcrossPriorities() bool {
+ if s.acrossPrioritiesFunc != nil {
+ return s.acrossPrioritiesFunc()
+ }
return s.acrossPriorities
}
@@ -1696,11 +1700,21 @@ func TestManagerPluginSchedulerCalledOutsideManagerLock(t *testing.T) {
t.Fatalf("Register(auth-a) error = %v", errRegister)
}
+ var currentTest *testing.T
+ acrossCalls := 0
scheduler := &fakePluginScheduler{
handled: true,
+ acrossPrioritiesFunc: func() bool {
+ acrossCalls++
+ if !manager.mu.TryLock() {
+ currentTest.Fatalf("SchedulerWantsAcrossPriorities called while manager lock is held")
+ }
+ manager.mu.Unlock()
+ return true
+ },
pick: func(ctx context.Context, req pluginapi.SchedulerPickRequest) (pluginapi.SchedulerPickResponse, bool, error) {
if !manager.mu.TryLock() {
- t.Fatalf("plugin scheduler called while manager lock is held")
+ currentTest.Fatalf("plugin scheduler called while manager lock is held")
}
manager.mu.Unlock()
return pluginapi.SchedulerPickResponse{Handled: true, AuthID: "auth-a"}, true, nil
@@ -1708,18 +1722,114 @@ func TestManagerPluginSchedulerCalledOutsideManagerLock(t *testing.T) {
}
manager.SetPluginScheduler(scheduler)
- got, _, errPick := manager.pickNext(context.Background(), "gemini", "", cliproxyexecutor.Options{}, nil)
- if errPick != nil {
- t.Fatalf("pickNext() error = %v", errPick)
- }
- if got == nil {
- t.Fatalf("pickNext() auth = nil")
+ t.Run("PluginSchedulerWantsAcrossPriorities", func(t *testing.T) {
+ currentTest = t
+ if !manager.PluginSchedulerWantsAcrossPriorities() {
+ t.Fatalf("PluginSchedulerWantsAcrossPriorities() = false, want true")
+ }
+ })
+
+ t.Run("pickNext", func(t *testing.T) {
+ currentTest = t
+ got, _, errPick := manager.pickNext(context.Background(), "gemini", "", cliproxyexecutor.Options{}, nil)
+ if errPick != nil {
+ t.Fatalf("pickNext() error = %v", errPick)
+ }
+ if got == nil {
+ t.Fatalf("pickNext() auth = nil")
+ }
+ if got.ID != "auth-a" {
+ t.Fatalf("pickNext() auth.ID = %q, want auth-a", got.ID)
+ }
+ })
+
+ t.Run("pickNextMixed", func(t *testing.T) {
+ currentTest = t
+ gotMixed, _, _, errPickMixed := manager.pickNextMixed(context.Background(), []string{"gemini"}, "", cliproxyexecutor.Options{}, nil)
+ if errPickMixed != nil {
+ t.Fatalf("pickNextMixed() error = %v", errPickMixed)
+ }
+ if gotMixed == nil {
+ t.Fatalf("pickNextMixed() auth = nil")
+ }
+ if gotMixed.ID != "auth-a" {
+ t.Fatalf("pickNextMixed() auth.ID = %q, want auth-a", gotMixed.ID)
+ }
+ })
+
+ if scheduler.calls != 2 {
+ t.Fatalf("scheduler.calls = %d, want %d", scheduler.calls, 2)
}
- if got.ID != "auth-a" {
- t.Fatalf("pickNext() auth.ID = %q, want auth-a", got.ID)
+ if acrossCalls != 3 {
+ t.Fatalf("acrossCalls = %d, want %d", acrossCalls, 3)
}
- if scheduler.calls != 1 {
- t.Fatalf("scheduler.calls = %d, want %d", scheduler.calls, 1)
+}
+
+func TestManagerPluginSchedulerSnapshotConsistencyAcrossSwap(t *testing.T) {
+ for _, mode := range []string{"single", "mixed"} {
+ t.Run(mode, func(t *testing.T) {
+ manager := NewManager(nil, &RoundRobinSelector{}, nil)
+ manager.executors["gemini"] = schedulerTestExecutor{}
+ if _, errRegister := manager.Register(context.Background(), &Auth{
+ ID: "auth-high",
+ Provider: "gemini",
+ Attributes: map[string]string{"priority": "10"},
+ }); errRegister != nil {
+ t.Fatalf("Register(auth-high) error = %v", errRegister)
+ }
+ if _, errRegister := manager.Register(context.Background(), &Auth{
+ ID: "auth-low",
+ Provider: "gemini",
+ Attributes: map[string]string{"priority": "1"},
+ }); errRegister != nil {
+ t.Fatalf("Register(auth-low) error = %v", errRegister)
+ }
+
+ schedulerB := &fakePluginScheduler{
+ acrossPriorities: false,
+ handled: true,
+ pick: func(ctx context.Context, req pluginapi.SchedulerPickRequest) (pluginapi.SchedulerPickResponse, bool, error) {
+ for _, candidate := range req.Candidates {
+ if candidate.ID == "auth-low" {
+ t.Fatalf("schedulerB (acrossPriorities=false) received low-priority candidate from schedulerA capability")
+ }
+ }
+ return pluginapi.SchedulerPickResponse{Handled: true, AuthID: "auth-high"}, true, nil
+ },
+ }
+ schedulerA := &fakePluginScheduler{
+ handled: true,
+ acrossPrioritiesFunc: func() bool {
+ manager.SetPluginScheduler(schedulerB)
+ return true
+ },
+ pick: func(ctx context.Context, req pluginapi.SchedulerPickRequest) (pluginapi.SchedulerPickResponse, bool, error) {
+ if len(req.Candidates) != 2 {
+ t.Fatalf("schedulerA candidates = %d, want 2 across priorities", len(req.Candidates))
+ }
+ return pluginapi.SchedulerPickResponse{Handled: true, AuthID: "auth-low"}, true, nil
+ },
+ }
+ manager.SetPluginScheduler(schedulerA)
+
+ if mode == "single" {
+ got, _, errPick := manager.pickNext(context.Background(), "gemini", "", cliproxyexecutor.Options{}, nil)
+ if errPick != nil {
+ t.Fatalf("pickNext() error = %v", errPick)
+ }
+ if got == nil || got.ID != "auth-low" {
+ t.Fatalf("pickNext() auth = %#v, want auth-low", got)
+ }
+ } else {
+ got, _, _, errPick := manager.pickNextMixed(context.Background(), []string{"gemini"}, "", cliproxyexecutor.Options{}, nil)
+ if errPick != nil {
+ t.Fatalf("pickNextMixed() error = %v", errPick)
+ }
+ if got == nil || got.ID != "auth-low" {
+ t.Fatalf("pickNextMixed() auth = %#v, want auth-low", got)
+ }
+ }
+ })
}
}
diff --git a/sdk/cliproxy/auth/types.go b/sdk/cliproxy/auth/types.go
index 7706cecd67b..179c6363d5e 100644
--- a/sdk/cliproxy/auth/types.go
+++ b/sdk/cliproxy/auth/types.go
@@ -1,13 +1,16 @@
package auth
import (
+ "bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
+ "io"
"net/http"
"net/url"
+ "os"
"path/filepath"
"strconv"
"strings"
@@ -111,8 +114,107 @@ type Auth struct {
Success int64 `json:"-"`
Failed int64 `json:"-"`
+ // cooldownRecordedSeq is the execution sequence watermark captured when
+ // the current auth-level cooldown was recorded. Successes from executions
+ // stamped at or below it were dispatched before the cooldown existed and
+ // must not clear it. Runtime only: zero after restart so restored
+ // cooldowns can still be cleared by any success.
+ cooldownRecordedSeq uint64 `json:"-"`
+
recentRequests recentRequestRing `json:"-"`
indexAssigned bool `json:"-"`
+ // fileSnapshotPath and fileSnapshotDigest bind an unversioned file-synthesized
+ // Auth snapshot to the source file path and SHA-256 byte digest it observed.
+ // Cleared when committed to a Manager so runtime clones are not treated as
+ // unversioned file snapshots.
+ fileSnapshotPath string `json:"-"`
+ fileSnapshotDigest string `json:"-"`
+}
+
+var readAuthSnapshotFileFunc = os.ReadFile
+
+func canonicalAuthFileDigest(data []byte) string {
+ trimmed := bytes.TrimSpace(data)
+ if len(trimmed) == 0 {
+ return ""
+ }
+ decoder := json.NewDecoder(bytes.NewReader(trimmed))
+ decoder.UseNumber()
+ var parsed any
+ if errDecode := decoder.Decode(&parsed); errDecode == nil {
+ var trailing any
+ if errTrailing := decoder.Decode(&trailing); errTrailing == io.EOF {
+ if canonical, errMarshal := json.Marshal(parsed); errMarshal == nil {
+ sum := sha256.Sum256(canonical)
+ return hex.EncodeToString(sum[:])
+ }
+ }
+ }
+ sum := sha256.Sum256(trimmed)
+ return hex.EncodeToString(sum[:])
+}
+
+// BindFileSnapshot binds an unversioned file-synthesized Auth snapshot to the
+// source file path and canonical SHA-256 digest of the bytes from which it was parsed.
+func (a *Auth) BindFileSnapshot(path string, data []byte) {
+ if a == nil || len(data) == 0 {
+ return
+ }
+ digest := canonicalAuthFileDigest(data)
+ if digest == "" {
+ return
+ }
+ a.fileSnapshotPath = strings.TrimSpace(path)
+ a.fileSnapshotDigest = digest
+}
+
+// ClearFileSnapshot removes any unversioned file-observation binding once the
+// Auth is committed to runtime state or normalized for comparison.
+func (a *Auth) ClearFileSnapshot() {
+ if a == nil {
+ return
+ }
+ a.fileSnapshotPath = ""
+ a.fileSnapshotDigest = ""
+}
+
+// HasFileSnapshot reports whether this Auth is an unversioned file-synthesized
+// snapshot bound to a specific file observation digest.
+func (a *Auth) HasFileSnapshot() bool {
+ return a != nil && a.Generation == 0 && a.RegistrationEpoch == 0 && a.fileSnapshotDigest != ""
+}
+
+// SameFileSnapshot reports whether two unversioned file snapshots observed the
+// same canonical file content.
+func (a *Auth) SameFileSnapshot(other *Auth) bool {
+ if !a.HasFileSnapshot() || !other.HasFileSnapshot() {
+ return false
+ }
+ return a.fileSnapshotDigest == other.fileSnapshotDigest && a.fileSnapshotPath == other.fileSnapshotPath
+}
+
+// FileSnapshotMatchesDisk reports whether the backing file on disk still
+// exists, is readable, and matches the canonical digest observed when this
+// snapshot was synthesized.
+func (a *Auth) FileSnapshotMatchesDisk() bool {
+ if !a.HasFileSnapshot() {
+ return true
+ }
+ path := strings.TrimSpace(a.fileSnapshotPath)
+ if path == "" && a.Attributes != nil {
+ path = strings.TrimSpace(a.Attributes[AttributePath])
+ if path == "" {
+ path = strings.TrimSpace(a.Attributes[AttributeSource])
+ }
+ }
+ if path == "" {
+ return false
+ }
+ data, errRead := readAuthSnapshotFileFunc(path)
+ if errRead != nil || len(data) == 0 {
+ return false
+ }
+ return canonicalAuthFileDigest(data) == a.fileSnapshotDigest
}
const (
@@ -228,6 +330,28 @@ type ModelState struct {
Quota QuotaState `json:"quota"`
// UpdatedAt tracks the last update timestamp for this model state.
UpdatedAt time.Time `json:"updated_at"`
+ // cooldownRecordedSeq is the execution sequence watermark captured when
+ // the current model cooldown was recorded. Successes from executions
+ // stamped at or below it were dispatched before the cooldown existed and
+ // must not clear it. Runtime only: zero after restart so restored
+ // cooldowns can still be cleared by any success.
+ cooldownRecordedSeq uint64 `json:"-"`
+ // credentialQuotaShadow holds this model's independent (own-failure)
+ // cooldown layer while the outer fields present the auth-level
+ // credential quota propagated over it, so quota restoration can drop
+ // only the propagated layer and reinstate precisely what the model's
+ // own failures justify (issue #6690). It is nil when no independent
+ // live block exists beneath an outer credential_quota label. Persisted
+ // via CooldownStateRecord.IndependentState (unexported fields are
+ // invisible to JSON, so the record owns the serialized form).
+ credentialQuotaShadow *ModelState `toml:"credential_quota_shadow,omitempty"`
+ // credentialQuotaPropagated marks that credential-quota propagation has
+ // already wrapped this state, distinguishing "no independent block"
+ // from "not yet propagated" so repeated propagation never mistakes its
+ // own earlier layer for an independent block. Persisted via
+ // CooldownStateRecord.CredentialQuotaPropagated across restarts,
+ // including for shadow-less reporting models.
+ credentialQuotaPropagated bool `toml:"credential_quota_propagated,omitempty"`
}
func recentRequestBucketID(now time.Time) int64 {
@@ -475,6 +599,28 @@ func (m *ModelState) Clone() *ModelState {
HTTPStatus: m.LastError.HTTPStatus,
}
}
+ if m.credentialQuotaShadow != nil {
+ copyState.credentialQuotaShadow = m.credentialQuotaShadow.cloneShallow()
+ }
+ return ©State
+}
+
+// cloneShallow copies a shadow state one level deep; shadows never nest.
+func (m *ModelState) cloneShallow() *ModelState {
+ if m == nil {
+ return nil
+ }
+ copyState := *m
+ copyState.Quota = m.Quota.Clone()
+ copyState.credentialQuotaShadow = nil
+ if m.LastError != nil {
+ copyState.LastError = &Error{
+ Code: m.LastError.Code,
+ Message: m.LastError.Message,
+ Retryable: m.LastError.Retryable,
+ HTTPStatus: m.LastError.HTTPStatus,
+ }
+ }
return ©State
}
@@ -626,6 +772,14 @@ func (a *Auth) AccountInfo() (string, string) {
return "oauth", email
}
}
+ if sa, ok := a.Metadata["service_account"].(map[string]any); ok {
+ if v, okEmail := sa["client_email"].(string); okEmail {
+ email := strings.TrimSpace(v)
+ if email != "" {
+ return "oauth", email
+ }
+ }
+ }
}
return "oauth", ""
case AuthKindAPIKey:
@@ -695,6 +849,11 @@ func (a *Auth) ExpirationTime() (time.Time, bool) {
return a.OuterExpirationTime()
}
+// CodexOuterRefreshLead is the conservative proactive refresh lead time before
+// the nominal 10-day Codex OAuth JWT expiration, refreshing tokens well ahead
+// of early upstream access-token invalidation.
+const CodexOuterRefreshLead = 96 * time.Hour
+
// CodexSIWCRefreshLead is the minimum remaining validity required before refreshing
// a Sign in with ChatGPT (SIWC) token, matching pi's DEFAULT_OAUTH_MINIMUM_VALIDITY_MS (5 minutes).
const CodexSIWCRefreshLead = 5 * time.Minute
@@ -729,6 +888,11 @@ func CodexOuterNeedsRefresh(a *Auth, now time.Time) (dueAt time.Time, hasRefresh
}
hasRefresh = true
+ outerToken := authOuterAccessToken(a)
+ if outerToken == "" || (a.RejectedAccessToken != "" && a.RejectedAccessToken == outerToken) {
+ return now, true, true
+ }
+
var lastRefresh time.Time
if ts, ok := authLastRefreshTimestamp(a); ok && !ts.IsZero() {
lastRefresh = ts
@@ -764,7 +928,7 @@ func CodexOuterNeedsRefresh(a *Auth, now time.Time) (dueAt time.Time, hasRefresh
}
lead := ProviderRefreshLead(provider, a.Runtime)
if lead == nil && provider == "codex" {
- d := 24 * time.Hour
+ d := CodexOuterRefreshLead
lead = &d
}
if lead == nil {
@@ -1019,11 +1183,15 @@ func ProviderRefreshLead(provider string, runtime any) *time.Duration {
refreshLeadMu.RLock()
factory := refreshLeadFactories[provider]
refreshLeadMu.RUnlock()
- if factory == nil {
+ if factory != nil {
+ if lead := factory(); lead != nil && *lead > 0 {
+ return lead
+ }
return nil
}
- if lead := factory(); lead != nil && *lead > 0 {
- return lead
+ if provider == "codex" {
+ d := CodexOuterRefreshLead
+ return &d
}
return nil
}
diff --git a/sdk/cliproxy/builder.go b/sdk/cliproxy/builder.go
index 83004161880..532bde10c59 100644
--- a/sdk/cliproxy/builder.go
+++ b/sdk/cliproxy/builder.go
@@ -277,7 +277,7 @@ func (b *Builder) Build() (*Service, error) {
}
service := &Service{
- cfg: b.cfg,
+ cfg: b.cfg.CloneForRuntime(),
configPath: b.configPath,
tokenProvider: tokenProvider,
apiKeyProvider: apiKeyProvider,
@@ -321,8 +321,8 @@ func (s *Service) runtimeAuthSyncHook() coreauth.PostAuthHook {
ID: auth.ID,
Auth: auth,
}
- if s.watcher != nil {
- _, rev := s.watcher.DispatchPersistedAuthUpdateWithRevision(&update)
+ if watcher := s.currentWatcher(); watcher != nil {
+ _, rev := watcher.DispatchPersistedAuthUpdateWithRevision(&update)
if rev > 0 {
update.SetRevision(rev)
}
diff --git a/sdk/cliproxy/executor/tls_handshake_timeout.go b/sdk/cliproxy/executor/tls_handshake_timeout.go
new file mode 100644
index 00000000000..8bc59a8dc30
--- /dev/null
+++ b/sdk/cliproxy/executor/tls_handshake_timeout.go
@@ -0,0 +1,109 @@
+package executor
+
+import (
+ "context"
+ "errors"
+ "fmt"
+)
+
+// ConfiguredTLSHandshakeTimeoutError identifies a TLS handshake failure
+// caused by an internally configured establishment bound rather than by
+// the caller's request context. Transports construct it only after they
+// have established that the parent context was still live and their own
+// handshake child context expired.
+//
+// The concrete type is deliberately shared through this neutral executor
+// package so both transport helpers and auth retry classification can
+// recognize it without importing each other or trusting error text.
+type ConfiguredTLSHandshakeTimeoutError struct {
+ cause error
+}
+
+// NewConfiguredTLSHandshakeTimeoutError wraps the handshake failure that
+// resulted from an internally configured TLS handshake deadline.
+func NewConfiguredTLSHandshakeTimeoutError(cause error) error {
+ if cause == nil {
+ return nil
+ }
+ return &ConfiguredTLSHandshakeTimeoutError{cause: cause}
+}
+
+func (e *ConfiguredTLSHandshakeTimeoutError) Error() string {
+ if e == nil || e.cause == nil {
+ return "tls handshake timeout"
+ }
+ return fmt.Sprintf("tls handshake timeout: %v", e.cause)
+}
+
+// Unwrap preserves the original handshake error chain, including
+// context.DeadlineExceeded, for diagnostics and errors.Is checks.
+func (e *ConfiguredTLSHandshakeTimeoutError) Unwrap() error {
+ if e == nil {
+ return nil
+ }
+ return e.cause
+}
+
+// IsConfiguredTLSHandshakeTimeoutError reports whether err is exclusively
+// explained by configured TLS handshake deadlines.
+//
+// Every context.Canceled or context.DeadlineExceeded occurrence in the
+// error tree must be contained in a ConfiguredTLSHandshakeTimeoutError
+// subtree, and at least one configured timeout subtree must itself contain
+// a deadline. A caller-owned cancellation or deadline anywhere outside
+// such a subtree therefore wins, including when it is combined with a
+// configured timeout through errors.Join. Matching error text is never
+// sufficient on its own.
+func IsConfiguredTLSHandshakeTimeoutError(err error) bool {
+ if err == nil {
+ return false
+ }
+ foundConfiguredDeadline, hasUnownedContextError := inspectConfiguredTLSHandshakeTimeout(err)
+ return foundConfiguredDeadline && !hasUnownedContextError
+}
+
+func inspectConfiguredTLSHandshakeTimeout(err error) (foundConfiguredDeadline bool, hasUnownedContextError bool) {
+ if err == nil {
+ return false, false
+ }
+ if configured, ok := err.(*ConfiguredTLSHandshakeTimeoutError); ok && configured != nil {
+ // The transport has already proven that this subtree's deadline is
+ // internally owned and that its parent context was still live.
+ return errors.Is(configured.cause, context.DeadlineExceeded), false
+ }
+ // A node can assert caller-context identity through its own Is method
+ // while Unwrap exposes unrelated children. Inspect that own identity
+ // at every non-configured node, before descending, so such a caller
+ // error cannot hide behind a joined configured timeout.
+ hasUnownedContextError = hasOwnContextIdentity(err)
+ for _, child := range unwrapChildErrors(err) {
+ childFound, childUnowned := inspectConfiguredTLSHandshakeTimeout(child)
+ foundConfiguredDeadline = foundConfiguredDeadline || childFound
+ hasUnownedContextError = hasUnownedContextError || childUnowned
+ }
+ return foundConfiguredDeadline, hasUnownedContextError
+}
+
+func hasOwnContextIdentity(err error) bool {
+ if err == context.Canceled || err == context.DeadlineExceeded {
+ return true
+ }
+ if checker, ok := err.(interface{ Is(error) bool }); ok {
+ return checker.Is(context.Canceled) || checker.Is(context.DeadlineExceeded)
+ }
+ return false
+}
+
+func unwrapChildErrors(err error) []error {
+ if multi, ok := err.(interface{ Unwrap() []error }); ok {
+ if children := multi.Unwrap(); len(children) > 0 {
+ return children
+ }
+ }
+ if single, ok := err.(interface{ Unwrap() error }); ok {
+ if child := single.Unwrap(); child != nil {
+ return []error{child}
+ }
+ }
+ return nil
+}
diff --git a/sdk/cliproxy/executor/tls_handshake_timeout_test.go b/sdk/cliproxy/executor/tls_handshake_timeout_test.go
new file mode 100644
index 00000000000..03c9356c8d2
--- /dev/null
+++ b/sdk/cliproxy/executor/tls_handshake_timeout_test.go
@@ -0,0 +1,110 @@
+package executor
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "testing"
+)
+
+// TestConfiguredTLSHandshakeTimeoutErrorIdentity verifies that retry
+// classification can rely on the concrete configured-timeout type while
+// callers retain the original deadline through errors.Is.
+func TestConfiguredTLSHandshakeTimeoutErrorIdentity(t *testing.T) {
+ err := NewConfiguredTLSHandshakeTimeoutError(context.DeadlineExceeded)
+ if err == nil {
+ t.Fatal("NewConfiguredTLSHandshakeTimeoutError() = nil")
+ }
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("errors.Is(%v, context.DeadlineExceeded) = false, want true", err)
+ }
+ if !IsConfiguredTLSHandshakeTimeoutError(err) {
+ t.Fatalf("IsConfiguredTLSHandshakeTimeoutError(%v) = false, want true", err)
+ }
+ var configured *ConfiguredTLSHandshakeTimeoutError
+ if !errors.As(err, &configured) || configured == nil {
+ t.Fatalf("errors.As(%v) did not find the configured timeout type", err)
+ }
+ if got := NewConfiguredTLSHandshakeTimeoutError(nil); got != nil {
+ t.Fatalf("NewConfiguredTLSHandshakeTimeoutError(nil) = %v, want nil", got)
+ }
+}
+
+// contextIdentityError models an error whose own Is method reports a
+// caller context identity while Unwrap exposes an unrelated diagnostic
+// child. Classification must inspect that node's own identity, not only
+// terminal sentinels reached through Unwrap.
+type contextIdentityError struct {
+ target error
+ child error
+}
+
+func (e *contextIdentityError) Error() string {
+ if e == nil || e.child == nil {
+ return "caller context identity"
+ }
+ return "caller context identity: " + e.child.Error()
+}
+
+func (e *contextIdentityError) Is(target error) bool {
+ return e != nil && target == e.target
+}
+
+func (e *contextIdentityError) Unwrap() error {
+ if e == nil {
+ return nil
+ }
+ return e.child
+}
+
+type multiContextIdentityError struct {
+ target error
+ children []error
+}
+
+func (e *multiContextIdentityError) Error() string { return "caller context identity group" }
+
+func (e *multiContextIdentityError) Is(target error) bool {
+ return e != nil && target == e.target
+}
+
+func (e *multiContextIdentityError) Unwrap() []error {
+ if e == nil {
+ return nil
+ }
+ return e.children
+}
+
+// TestIsConfiguredTLSHandshakeTimeoutErrorCallerContextWins covers mixed
+// error trees. Matching text never substitutes for the typed identity,
+// and caller-owned context failures outside a configured subtree win.
+func TestIsConfiguredTLSHandshakeTimeoutErrorCallerContextWins(t *testing.T) {
+ configured := NewConfiguredTLSHandshakeTimeoutError(context.DeadlineExceeded)
+ tests := []struct {
+ name string
+ err error
+ want bool
+ }{
+ {name: "wrapped configured timeout", err: fmt.Errorf("claude tls: handshake upstream: %w", configured), want: true},
+ {name: "two configured timeouts", err: errors.Join(configured, NewConfiguredTLSHandshakeTimeoutError(context.DeadlineExceeded)), want: true},
+ {name: "plaintext phrase", err: fmt.Errorf("tls handshake timeout: %w", context.DeadlineExceeded), want: false},
+ {name: "encoded phrase", err: fmt.Errorf("tls%%20handshake%%20timeout: %w", context.DeadlineExceeded), want: false},
+ {name: "joined caller deadline", err: errors.Join(configured, context.DeadlineExceeded), want: false},
+ {name: "joined wrapped caller deadline", err: errors.Join(configured, fmt.Errorf("client: %w", context.DeadlineExceeded)), want: false},
+ {name: "joined caller cancellation", err: errors.Join(configured, context.Canceled), want: false},
+ {name: "outer caller cancellation", err: fmt.Errorf("outer: %w", errors.Join(configured, context.Canceled)), want: false},
+ {name: "custom Is caller cancellation with child", err: errors.Join(configured, &contextIdentityError{target: context.Canceled, child: errors.New("diagnostic")}), want: false},
+ {name: "custom Is caller deadline with child", err: errors.Join(configured, &contextIdentityError{target: context.DeadlineExceeded, child: errors.New("diagnostic")}), want: false},
+ {name: "custom Is multi unwrap caller cancellation", err: &multiContextIdentityError{target: context.Canceled, children: []error{configured, errors.New("diagnostic")}}, want: false},
+ {name: "wrapped custom Is caller cancellation", err: fmt.Errorf("outer: %w", errors.Join(configured, &contextIdentityError{target: context.Canceled, child: errors.New("diagnostic")})), want: false},
+ {name: "configured type without deadline cause", err: NewConfiguredTLSHandshakeTimeoutError(errors.New("certificate mismatch")), want: false},
+ {name: "nil", err: nil, want: false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := IsConfiguredTLSHandshakeTimeoutError(tt.err); got != tt.want {
+ t.Fatalf("IsConfiguredTLSHandshakeTimeoutError(%v) = %t, want %t", tt.err, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/sdk/cliproxy/executor/types.go b/sdk/cliproxy/executor/types.go
index d178957dfa4..0ce22e8783a 100644
--- a/sdk/cliproxy/executor/types.go
+++ b/sdk/cliproxy/executor/types.go
@@ -223,6 +223,9 @@ type Options struct {
// ProxyURL overrides the credential and global proxy for this execution only.
// Credential refresh and token exchange must ignore it.
ProxyURL string
+ // KeepTopLevelEffort tells SIWC request-body rules to keep the resolved
+ // top-level reasoning.effort when configuration_update items are collapsed.
+ KeepTopLevelEffort bool
}
// EnsureMetadata initializes and returns Metadata, ensuring it is non-nil.
diff --git a/sdk/cliproxy/model_registration_config_fence_test.go b/sdk/cliproxy/model_registration_config_fence_test.go
new file mode 100644
index 00000000000..74c76d30502
--- /dev/null
+++ b/sdk/cliproxy/model_registration_config_fence_test.go
@@ -0,0 +1,307 @@
+package cliproxy
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/pluginhost"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/watcher/synthesizer"
+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v8/sdk/auth"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
+)
+
+// Without a watcher (Home mode), a registration dropped for obsolete global exclusions
+// must still be replaced by the re-registration that runs when the config is applied.
+func TestModelRegistrationDroppedForGlobalExclusionsIsReplacedWithoutWatcher(t *testing.T) {
+ models := registry.GetClaudeModels()
+ if len(models) < 2 {
+ t.Fatal("need two Claude models to test exclusion changes")
+ }
+ modelA, modelB := models[0].ID, models[1].ID
+ oldCfg := &config.Config{OAuthExcludedModels: map[string][]string{"claude": {modelA}}}
+ newCfg := &config.Config{OAuthExcludedModels: map[string][]string{"claude": {modelB}}}
+ service := &Service{cfg: oldCfg, coreManager: coreauth.NewManager(nil, nil, nil), pluginHost: pluginhost.New()}
+ service.applyPprofConfigContextFn = func(context.Context, *config.Config) bool { return true }
+ t.Cleanup(func() {
+ sdkAuth.RegisterPluginAuthParser(nil)
+ sdktranslator.SetPluginHooks(nil)
+ })
+
+ var reads atomic.Int32
+ oldStarted, oldRelease, oldDone := make(chan struct{}), make(chan struct{}), make(chan struct{})
+ releaseOld := sync.OnceFunc(func() { close(oldRelease) })
+ auth, errRegister := service.coreManager.Register(t.Context(), &coreauth.Auth{
+ ID: t.Name(), Provider: "claude", Attributes: map[string]string{"auth_kind": "oauth"},
+ // Pause only the first registration after it captured its fence.
+ Runtime: ®istrationInterleaveRuntime{onRead: func() {
+ if reads.Add(1) == 1 {
+ close(oldStarted)
+ <-oldRelease
+ }
+ }},
+ })
+ if errRegister != nil {
+ t.Fatal(errRegister)
+ }
+ reg := GlobalModelRegistry()
+ reg.RegisterClient(auth.ID, "claude", models[:2])
+ t.Cleanup(func() { reg.UnregisterClient(auth.ID) })
+ epoch := reg.ClientRegistrationEpoch(auth.ID)
+
+ go func() {
+ defer close(oldDone)
+ service.registerModelsForAuth(t.Context(), auth)
+ }()
+ t.Cleanup(func() { releaseOld(); <-oldDone })
+ <-oldStarted
+
+ // Worst case: the obsolete registration finishes after the commit but before the runtime apply.
+ commit := service.commitConfigUpdate(newCfg)
+ if commit.cfg != newCfg {
+ t.Fatal("config update was rejected")
+ }
+ releaseOld()
+ <-oldDone
+ if got := reg.ClientRegistrationEpoch(auth.ID); got != epoch {
+ t.Fatalf("obsolete registration advanced epoch from %d to %d", epoch, got)
+ }
+
+ if !service.applyConfigRuntime(t.Context(), commit, true) {
+ t.Fatal("config runtime apply failed")
+ }
+ if !reg.ClientSupportsModel(auth.ID, modelA) || reg.ClientSupportsModel(auth.ID, modelB) {
+ t.Fatalf("final models: %s=%t, %s=%t; want new global exclusions", modelA, reg.ClientSupportsModel(auth.ID, modelA), modelB, reg.ClientSupportsModel(auth.ID, modelB))
+ }
+}
+
+func TestModelRegistrationFenceRetainsGlobalExclusionsAfterAuthUpdate(t *testing.T) {
+ for _, withManager := range []bool{false, true} {
+ t.Run(map[bool]string{false: "without-manager", true: "with-manager"}[withManager], func(t *testing.T) {
+ oldCfg := &config.Config{OAuthExcludedModels: map[string][]string{"claude": {"model-a"}}}
+ newCfg := &config.Config{OAuthExcludedModels: map[string][]string{"claude": {"model-b"}}}
+ service := &Service{cfg: oldCfg}
+ auth := &coreauth.Auth{ID: t.Name(), Provider: "claude", Attributes: map[string]string{"auth_kind": "oauth"}}
+ if withManager {
+ service.coreManager = coreauth.NewManager(nil, nil, nil)
+ var errRegister error
+ auth, errRegister = service.coreManager.Register(t.Context(), auth)
+ if errRegister != nil {
+ t.Fatal(errRegister)
+ }
+ }
+ reg := GlobalModelRegistry()
+ reg.RegisterClient(auth.ID, "claude", []*ModelInfo{{ID: "model-a"}})
+ t.Cleanup(func() { reg.UnregisterClient(auth.ID) })
+ epoch := reg.ClientRegistrationEpoch(auth.ID)
+ fence := newModelRegistrationFence(auth, oldCfg)
+ if commit := service.commitConfigUpdate(newCfg); commit.cfg != newCfg {
+ t.Fatal("config update was rejected")
+ }
+ auth = auth.Clone()
+ auth.Attributes["note"] = "updated by plugin"
+ if withManager {
+ var errUpdate error
+ auth, errUpdate = service.coreManager.Update(t.Context(), auth)
+ if errUpdate != nil {
+ t.Fatal(errUpdate)
+ }
+ }
+ // Updating auth inputs must not let an old plugin result adopt the new config.
+ fence = fence.withAuth(auth)
+ if fence.publishModels(service, auth.ID, "claude", []*ModelInfo{{ID: "model-b"}}) {
+ t.Error("obsolete config published models after auth update")
+ }
+ if fence.publishUnregister(service, auth.ID) {
+ t.Error("obsolete config removed models after auth update")
+ }
+ if got := reg.ClientRegistrationEpoch(auth.ID); got != epoch {
+ t.Fatalf("obsolete config advanced epoch from %d to %d", epoch, got)
+ }
+ // A caller supplying an old config explicitly must not capture a fresh identity.
+ service.registerModelsForAuthWithConfigAndCache(t.Context(), oldCfg, auth, nil)
+ if got := reg.ClientRegistrationEpoch(auth.ID); got != epoch {
+ t.Fatalf("explicit stale config advanced epoch from %d to %d", epoch, got)
+ }
+ current := newModelRegistrationFence(auth, newCfg)
+ if !current.publishModels(service, auth.ID, "claude", []*ModelInfo{{ID: "model-a"}}) {
+ t.Fatal("current config could not publish after obsolete work was rejected")
+ }
+ })
+ }
+}
+
+func TestModelRegistrationAllowsIrrelevantConfigReload(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ rules map[string][]string
+ kind string
+ own string
+ disabled bool
+ }{
+ {"no-op", map[string][]string{"claude": {"model-a"}}, "oauth", "", false},
+ {"normalized no-op", map[string][]string{"claude": {" MODEL-A ", "model-a", " "}}, "oauth", "", false},
+ {"other provider", map[string][]string{"claude": {"model-a"}, "codex": {"model-b"}}, "oauth", "", false},
+ {"credential override", map[string][]string{"claude": {"model-b"}}, "oauth", "own-*", false},
+ {"api key", map[string][]string{"claude": {"model-b"}}, "apikey", "", false},
+ {"disabled", map[string][]string{"claude": {"model-b"}}, "oauth", "", true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ oldCfg := &config.Config{OAuthExcludedModels: map[string][]string{"claude": {"model-a"}}}
+ // A debug-only change must not discard work that has no replacement scheduled.
+ newCfg := &config.Config{Debug: true, OAuthExcludedModels: tc.rules}
+ service := &Service{cfg: oldCfg, coreManager: coreauth.NewManager(nil, nil, nil)}
+ auth, errRegister := service.coreManager.Register(t.Context(), &coreauth.Auth{
+ ID: t.Name(), Provider: "claude", Disabled: tc.disabled,
+ Attributes: map[string]string{"auth_kind": tc.kind, "excluded_models": tc.own},
+ })
+ if errRegister != nil {
+ t.Fatal(errRegister)
+ }
+ reg := GlobalModelRegistry()
+ reg.RegisterClient(auth.ID, "claude", []*ModelInfo{{ID: "model-old"}})
+ t.Cleanup(func() { reg.UnregisterClient(auth.ID) })
+ fence := newModelRegistrationFence(auth, oldCfg)
+ if commit := service.commitConfigUpdate(newCfg); commit.cfg != newCfg {
+ t.Fatal("config update was rejected")
+ }
+ if tc.disabled {
+ if !fence.publishUnregister(service, auth.ID) {
+ t.Fatal("global rules prevented removal of a disabled credential")
+ }
+ } else if !fence.publishModels(service, auth.ID, "claude", []*ModelInfo{{ID: "allowed-model"}}) {
+ t.Fatal("irrelevant config change discarded valid registration")
+ }
+ })
+ }
+}
+
+func TestModelRegistrationSnapshotsGlobalExclusionValues(t *testing.T) {
+ cfg := &config.Config{OAuthExcludedModels: map[string][]string{"claude": {"model-a"}}}
+ service := &Service{cfg: cfg}
+ auth := &coreauth.Auth{ID: t.Name(), Provider: "claude"}
+ reg := GlobalModelRegistry()
+ reg.RegisterClient(auth.ID, "claude", []*ModelInfo{{ID: "model-a"}})
+ t.Cleanup(func() { reg.UnregisterClient(auth.ID) })
+ fence := newModelRegistrationFence(auth, cfg)
+ epoch := reg.ClientRegistrationEpoch(auth.ID)
+ // Capture rule values, not just config/map identity.
+ service.cfgMu.Lock()
+ cfg.OAuthExcludedModels["claude"][0] = "model-b"
+ service.cfgMu.Unlock()
+ if fence.publishModels(service, auth.ID, "claude", []*ModelInfo{{ID: "model-b"}}) {
+ t.Fatal("obsolete exclusion values were accepted after config mutation")
+ }
+ if got := reg.ClientRegistrationEpoch(auth.ID); got != epoch {
+ t.Fatalf("rejected registration advanced epoch from %d to %d", epoch, got)
+ }
+}
+
+func TestModelRegistrationRejectsObsoleteGlobalExclusions(t *testing.T) {
+ models := registry.GetClaudeModels()
+ if len(models) < 2 {
+ t.Fatal("need two Claude models to test exclusion changes")
+ }
+ modelA, modelB := models[0].ID, models[1].ID
+ for _, oldEmpty := range []bool{false, true} {
+ for _, oldFirst := range []bool{true, false} {
+ name := map[bool]string{false: "publish", true: "unregister"}[oldEmpty]
+ name += map[bool]string{false: "/new-first", true: "/old-first"}[oldFirst]
+ t.Run(name, func(t *testing.T) {
+ oldRule := modelA
+ if oldEmpty {
+ oldRule = "*"
+ }
+ oldCfg := &config.Config{OAuthExcludedModels: map[string][]string{"claude": {oldRule}}}
+ newCfg := &config.Config{OAuthExcludedModels: map[string][]string{"claude": {modelB}}}
+ manager := coreauth.NewManager(nil, nil, nil)
+ service := &Service{cfg: oldCfg, coreManager: manager}
+ path := filepath.Join(t.TempDir(), "credential.json")
+ data := []byte(`{"type":"claude"}`)
+ // Manager.Update validates file-synthesized snapshots against disk.
+ if errWrite := os.WriteFile(path, data, 0o600); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ synthesize := func(cfg *config.Config) *coreauth.Auth {
+ t.Helper()
+ auths, errSynthesize := synthesizer.SynthesizeAuthFile(&synthesizer.SynthesisContext{
+ Config: cfg, Now: time.Date(2026, time.October, 10, 0, 0, 0, 0, time.UTC),
+ }, path, data)
+ if errSynthesize != nil || len(auths) != 1 {
+ t.Fatalf("synthesis = (%v, %v)", auths, errSynthesize)
+ }
+ return auths[0]
+ }
+ oldAuth := synthesize(oldCfg)
+ reg := GlobalModelRegistry()
+ reg.RegisterClient(oldAuth.ID, "claude", models[:2])
+ t.Cleanup(func() { reg.UnregisterClient(oldAuth.ID) })
+ epoch := reg.ClientRegistrationEpoch(oldAuth.ID)
+
+ oldStarted, oldRelease, oldDone := make(chan struct{}), make(chan struct{}), make(chan struct{})
+ releaseOld := sync.OnceFunc(func() { close(oldRelease) })
+ oldAuth.Runtime = ®istrationInterleaveRuntime{onRead: func() {
+ close(oldStarted)
+ <-oldRelease
+ }}
+ registered, errRegister := manager.Register(t.Context(), oldAuth)
+ if errRegister != nil {
+ t.Fatal(errRegister)
+ }
+ go func() {
+ defer close(oldDone)
+ service.registerModelsForAuth(t.Context(), registered)
+ }()
+ t.Cleanup(func() { releaseOld(); <-oldDone })
+ <-oldStarted
+
+ // Reload while the old registration is paused after capturing its fence.
+ if commit := service.commitConfigUpdate(newCfg); commit.cfg != newCfg {
+ t.Fatal("config update was rejected")
+ }
+ newAuth := synthesize(newCfg)
+ newStarted, newRelease, newDone := make(chan struct{}), make(chan struct{}), make(chan struct{})
+ releaseNew := sync.OnceFunc(func() { close(newRelease) })
+ newAuth.Runtime = ®istrationInterleaveRuntime{onRead: func() {
+ close(newStarted)
+ <-newRelease
+ }}
+ updated, errUpdate := manager.Update(t.Context(), newAuth)
+ if errUpdate != nil {
+ t.Fatal(errUpdate)
+ }
+ go func() {
+ defer close(newDone)
+ service.registerModelsForAuth(t.Context(), updated)
+ }()
+ t.Cleanup(func() { releaseNew(); <-newDone })
+ <-newStarted
+
+ if oldFirst {
+ releaseOld()
+ <-oldDone
+ if got := reg.ClientRegistrationEpoch(oldAuth.ID); got != epoch {
+ t.Errorf("obsolete registration advanced epoch from %d to %d", epoch, got)
+ }
+ releaseNew()
+ <-newDone
+ } else {
+ releaseNew()
+ <-newDone
+ releaseOld()
+ <-oldDone
+ }
+ if !reg.ClientSupportsModel(oldAuth.ID, modelA) || reg.ClientSupportsModel(oldAuth.ID, modelB) {
+ t.Fatalf("final models: %s=%t, %s=%t; want new global exclusions", modelA, reg.ClientSupportsModel(oldAuth.ID, modelA), modelB, reg.ClientSupportsModel(oldAuth.ID, modelB))
+ }
+ })
+ }
+ }
+}
diff --git a/sdk/cliproxy/model_registration_issue6718_test.go b/sdk/cliproxy/model_registration_issue6718_test.go
new file mode 100644
index 00000000000..d4d41376fdb
--- /dev/null
+++ b/sdk/cliproxy/model_registration_issue6718_test.go
@@ -0,0 +1,435 @@
+package cliproxy
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/pluginhost"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
+ log "github.com/sirupsen/logrus"
+ logtest "github.com/sirupsen/logrus/hooks/test"
+)
+
+func captureSupersededRegistrationLogs(t *testing.T) *logtest.Hook {
+ t.Helper()
+ _, hook := logtest.NewNullLogger()
+ oldLevel := log.GetLevel()
+ log.SetLevel(log.WarnLevel)
+ savedHooks := make(log.LevelHooks)
+ for level, hooks := range log.StandardLogger().Hooks {
+ savedHooks[level] = append([]log.Hook(nil), hooks...)
+ }
+ log.AddHook(hook)
+ t.Cleanup(func() {
+ log.SetLevel(oldLevel)
+ log.StandardLogger().ReplaceHooks(savedHooks)
+ })
+ return hook
+}
+
+// TestModelRegistrationSupersededDuringCompletionReconcilesLatest_Issue6718
+// reproduces issue #6718: a registration that finishes after its auth inputs
+// were committed again mid-flight is fenced out. The triggering commit (a
+// plugin writing its model snapshot back through host.auth.save while
+// ModelsForAuth is still running) registers no models itself, so without a
+// post-publication reconciliation the client is left with no models in
+// /v1/models.
+func TestModelRegistrationSupersededDuringCompletionReconcilesLatest_Issue6718(t *testing.T) {
+ cfg := &config.Config{SDKConfig: config.SDKConfig{ForceModelPrefix: true}, OpenAICompatibility: []config.OpenAICompatibility{{Name: "compat-6718", Models: []config.OpenAICompatibilityModel{{Name: "upstream-model", Alias: "alias"}}}}}
+ manager := coreauth.NewManager(nil, &coreauth.RoundRobinSelector{}, nil)
+ service := &Service{cfg: cfg, coreManager: manager}
+ reg := GlobalModelRegistry()
+ id := t.Name()
+ reg.UnregisterClient(id)
+ t.Cleanup(func() { reg.UnregisterClient(id) })
+
+ interleaved := false
+ runtime := ®istrationInterleaveRuntime{onRead: func() {
+ if interleaved {
+ return
+ }
+ interleaved = true
+ current, ok := manager.GetByID(id)
+ if !ok || current == nil {
+ t.Fatal("registered auth missing")
+ }
+ current = current.Clone()
+ current.Runtime = nil
+ if current.Attributes == nil {
+ current.Attributes = make(map[string]string)
+ }
+ // Mirror the trae snapshot writeback: the auth record is committed
+ // again (new Metadata/Attributes) while model resolution is still
+ // in flight, and that commit publishes no models on its own.
+ current.Metadata = map[string]any{"model_cache": map[string]any{"realm": "cn"}}
+ current.Attributes["note"] = "models snapshot written"
+ if _, errUpdate := manager.Update(t.Context(), current); errUpdate != nil {
+ t.Fatal(errUpdate)
+ }
+ }}
+ auth := &coreauth.Auth{ID: id, Provider: "compat-6718", Prefix: "prefix-6718", Status: coreauth.StatusActive, Runtime: runtime, Attributes: map[string]string{"auth_kind": "apikey", "compat_name": "compat-6718", "provider_key": "compat-6718"}}
+ registered, errRegister := manager.Register(t.Context(), auth)
+ if errRegister != nil {
+ t.Fatal(errRegister)
+ }
+
+ service.completeModelRegistrationForAuth(t.Context(), registered)
+
+ if !interleaved {
+ t.Fatal("existing Runtime.GetClientID seam was not reached")
+ }
+ if !reg.ClientSupportsModel(id, "prefix-6718/alias") {
+ t.Fatal("models vanished: superseded registration was discarded without reconciling the latest auth (issue #6718)")
+ }
+}
+
+// TestModelRegistrationSupersededPublicationLogsWarning_Issue6718 covers the
+// observability half of issue #6718: when the publication fence discards a
+// registration, a warning must identify the discard instead of failing
+// silently.
+func TestModelRegistrationSupersededPublicationLogsWarning_Issue6718(t *testing.T) {
+ cfg := &config.Config{SDKConfig: config.SDKConfig{ForceModelPrefix: true}, OpenAICompatibility: []config.OpenAICompatibility{{Name: "compat-6718-warn", Models: []config.OpenAICompatibilityModel{{Name: "upstream-model", Alias: "alias"}}}}}
+ manager := coreauth.NewManager(nil, &coreauth.RoundRobinSelector{}, nil)
+ service := &Service{cfg: cfg, coreManager: manager}
+ reg := GlobalModelRegistry()
+ id := t.Name()
+ reg.UnregisterClient(id)
+ t.Cleanup(func() { reg.UnregisterClient(id) })
+ hook := captureSupersededRegistrationLogs(t)
+
+ interleaved := false
+ runtime := ®istrationInterleaveRuntime{onRead: func() {
+ if interleaved {
+ return
+ }
+ interleaved = true
+ current, ok := manager.GetByID(id)
+ if !ok || current == nil {
+ t.Fatal("registered auth missing")
+ }
+ current = current.Clone()
+ current.Prefix = "prefix-new"
+ current.Runtime = nil
+ updated, errUpdate := manager.Update(t.Context(), current)
+ if errUpdate != nil {
+ t.Fatal(errUpdate)
+ }
+ service.registerModelsForAuth(t.Context(), updated)
+ }}
+ auth := &coreauth.Auth{ID: id, Provider: "compat-6718-warn", Prefix: "prefix-old", Status: coreauth.StatusActive, Runtime: runtime, Attributes: map[string]string{"auth_kind": "apikey", "compat_name": "compat-6718-warn", "provider_key": "compat-6718-warn"}}
+ registered, errRegister := manager.Register(t.Context(), auth)
+ if errRegister != nil {
+ t.Fatal(errRegister)
+ }
+
+ service.registerModelsForAuth(t.Context(), registered)
+
+ if !interleaved {
+ t.Fatal("existing Runtime.GetClientID seam was not reached")
+ }
+ if !reg.ClientSupportsModel(id, "prefix-new/alias") {
+ t.Fatal("newer registration missing; cannot evaluate the discard warning")
+ }
+ for _, entry := range hook.AllEntries() {
+ if entry.Level == log.WarnLevel && strings.Contains(entry.Message, "superseded") && strings.Contains(entry.Message, id) {
+ return
+ }
+ }
+ t.Fatalf("superseded publication was discarded silently, no warning logged; entries=%#v", hook.AllEntries())
+}
+
+// TestModelRegistrationSupersededByEpochAdvanceReconcilesLatest_Issue6718
+// covers the registry-epoch arm of the fence: the auth record is re-committed
+// mid-computation with identical registration inputs (only Metadata changes,
+// like the trae plugin stamping its model_cache snapshot), so the identity
+// gate still passes, but a concurrent publication advances the client epoch
+// and the epoch CAS discards this computation. The completion pass must
+// reconcile the latest auth so its models survive.
+func TestModelRegistrationSupersededByEpochAdvanceReconcilesLatest_Issue6718(t *testing.T) {
+ cfg := &config.Config{SDKConfig: config.SDKConfig{ForceModelPrefix: true}, OpenAICompatibility: []config.OpenAICompatibility{{Name: "compat-6718-epoch", Models: []config.OpenAICompatibilityModel{{Name: "upstream-model", Alias: "alias"}}}}}
+ manager := coreauth.NewManager(nil, &coreauth.RoundRobinSelector{}, nil)
+ service := &Service{cfg: cfg, coreManager: manager}
+ reg := GlobalModelRegistry()
+ id := t.Name()
+ reg.UnregisterClient(id)
+ t.Cleanup(func() { reg.UnregisterClient(id) })
+
+ interleaved := false
+ runtime := ®istrationInterleaveRuntime{onRead: func() {
+ if interleaved {
+ return
+ }
+ interleaved = true
+ current, ok := manager.GetByID(id)
+ if !ok || current == nil {
+ t.Fatal("registered auth missing")
+ }
+ current = current.Clone()
+ current.Runtime = nil
+ // Metadata-only writeback: registration inputs are unchanged.
+ current.Metadata = map[string]any{"model_cache": map[string]any{"realm": "cn"}}
+ if _, errUpdate := manager.Update(t.Context(), current); errUpdate != nil {
+ t.Fatal(errUpdate)
+ }
+ // A concurrent publication (the watcher round racing this pass)
+ // advances the client epoch without leaving this client's models.
+ reg.RegisterClient(id, "other-provider", []*ModelInfo{{ID: "unrelated-model"}})
+ }}
+ auth := &coreauth.Auth{ID: id, Provider: "compat-6718-epoch", Prefix: "prefix-6718", Status: coreauth.StatusActive, Runtime: runtime, Attributes: map[string]string{"auth_kind": "apikey", "compat_name": "compat-6718-epoch", "provider_key": "compat-6718-epoch"}}
+ registered, errRegister := manager.Register(t.Context(), auth)
+ if errRegister != nil {
+ t.Fatal(errRegister)
+ }
+
+ service.completeModelRegistrationForAuth(t.Context(), registered)
+
+ if !interleaved {
+ t.Fatal("existing Runtime.GetClientID seam was not reached")
+ }
+ if !reg.ClientSupportsModel(id, "prefix-6718/alias") {
+ t.Fatal("models vanished after an epoch-only supersede without reconciling the latest auth (issue #6718)")
+ }
+}
+
+// TestModelRegistrationSupersededByDisableUnregisters_Issue6718 covers the
+// reconciliation edge where the mid-computation commit disables the auth:
+// the superseded publication is discarded, and the completion pass must
+// remove the previously registered models instead of leaving them behind.
+func TestModelRegistrationSupersededByDisableUnregisters_Issue6718(t *testing.T) {
+ cfg := &config.Config{SDKConfig: config.SDKConfig{ForceModelPrefix: true}, OpenAICompatibility: []config.OpenAICompatibility{{Name: "compat-6718-off", Models: []config.OpenAICompatibilityModel{{Name: "upstream-model", Alias: "alias"}}}}}
+ manager := coreauth.NewManager(nil, &coreauth.RoundRobinSelector{}, nil)
+ service := &Service{cfg: cfg, coreManager: manager}
+ reg := GlobalModelRegistry()
+ id := t.Name()
+ reg.UnregisterClient(id)
+ t.Cleanup(func() { reg.UnregisterClient(id) })
+
+ interleaved := false
+ runtime := ®istrationInterleaveRuntime{onRead: func() {
+ if interleaved {
+ return
+ }
+ interleaved = true
+ current, ok := manager.GetByID(id)
+ if !ok || current == nil {
+ t.Fatal("registered auth missing")
+ }
+ current = current.Clone()
+ current.Runtime = nil
+ current.Disabled = true
+ current.Status = coreauth.StatusDisabled
+ if _, errUpdate := manager.Update(t.Context(), current); errUpdate != nil {
+ t.Fatal(errUpdate)
+ }
+ }}
+ auth := &coreauth.Auth{ID: id, Provider: "compat-6718-off", Prefix: "prefix-6718", Status: coreauth.StatusActive, Runtime: runtime, Attributes: map[string]string{"auth_kind": "apikey", "compat_name": "compat-6718-off", "provider_key": "compat-6718-off"}}
+ registered, errRegister := manager.Register(t.Context(), auth)
+ if errRegister != nil {
+ t.Fatal(errRegister)
+ }
+ // Models from an earlier successful registration are already visible.
+ reg.RegisterClient(id, "compat-6718-off", []*ModelInfo{{ID: "prefix-6718/alias"}})
+
+ service.completeModelRegistrationForAuth(t.Context(), registered)
+
+ if !interleaved {
+ t.Fatal("existing Runtime.GetClientID seam was not reached")
+ }
+ if reg.ClientSupportsModel(id, "prefix-6718/alias") {
+ t.Fatal("models of an auth disabled mid-registration were left registered (issue #6718)")
+ }
+}
+
+type issue6718ModelProvider struct {
+ provider string
+ handle func(call int, req pluginapi.AuthModelRequest) pluginapi.ModelResponse
+ calls int
+}
+
+func (p *issue6718ModelProvider) Identifier() string { return p.provider }
+
+func (p *issue6718ModelProvider) ParseAuth(context.Context, pluginapi.AuthParseRequest) (pluginapi.AuthParseResponse, error) {
+ return pluginapi.AuthParseResponse{}, nil
+}
+
+func (p *issue6718ModelProvider) StartLogin(context.Context, pluginapi.AuthLoginStartRequest) (pluginapi.AuthLoginStartResponse, error) {
+ return pluginapi.AuthLoginStartResponse{}, nil
+}
+
+func (p *issue6718ModelProvider) PollLogin(context.Context, pluginapi.AuthLoginPollRequest) (pluginapi.AuthLoginPollResponse, error) {
+ return pluginapi.AuthLoginPollResponse{}, nil
+}
+
+func (p *issue6718ModelProvider) RefreshAuth(context.Context, pluginapi.AuthRefreshRequest) (pluginapi.AuthRefreshResponse, error) {
+ return pluginapi.AuthRefreshResponse{}, nil
+}
+
+func (p *issue6718ModelProvider) Execute(context.Context, pluginapi.ExecutorRequest) (pluginapi.ExecutorResponse, error) {
+ return pluginapi.ExecutorResponse{}, nil
+}
+
+func (p *issue6718ModelProvider) ExecuteStream(context.Context, pluginapi.ExecutorRequest) (pluginapi.ExecutorStreamResponse, error) {
+ return pluginapi.ExecutorStreamResponse{}, nil
+}
+
+func (p *issue6718ModelProvider) CountTokens(context.Context, pluginapi.ExecutorRequest) (pluginapi.ExecutorResponse, error) {
+ return pluginapi.ExecutorResponse{}, nil
+}
+
+func (p *issue6718ModelProvider) HttpRequest(context.Context, pluginapi.ExecutorHTTPRequest) (pluginapi.ExecutorHTTPResponse, error) {
+ return pluginapi.ExecutorHTTPResponse{}, nil
+}
+
+func (p *issue6718ModelProvider) StaticModels(context.Context, pluginapi.StaticModelRequest) (pluginapi.ModelResponse, error) {
+ return pluginapi.ModelResponse{Provider: p.provider, Models: []pluginapi.ModelInfo{{ID: "static-model"}}}, nil
+}
+
+func (p *issue6718ModelProvider) ModelsForAuth(_ context.Context, req pluginapi.AuthModelRequest) (pluginapi.ModelResponse, error) {
+ p.calls++
+ if p.handle != nil {
+ return p.handle(p.calls, req), nil
+ }
+ return pluginapi.ModelResponse{Provider: p.provider, Models: []pluginapi.ModelInfo{{ID: "plugin-model"}}}, nil
+}
+
+func newIssue6718PluginService(t *testing.T, provider string, handle func(call int, req pluginapi.AuthModelRequest) pluginapi.ModelResponse) (*Service, *coreauth.Manager, *issue6718ModelProvider, *coreauth.Auth) {
+ t.Helper()
+ plugin := &issue6718ModelProvider{provider: provider, handle: handle}
+ host := pluginhost.New()
+ host.RegisterPluginForTest(provider, pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
+ AuthProvider: plugin,
+ ModelProvider: plugin,
+ Executor: plugin,
+ ExecutorModelScope: pluginapi.ExecutorModelScopeOAuth,
+ }})
+ manager := coreauth.NewManager(nil, &coreauth.RoundRobinSelector{}, nil)
+ service := &Service{cfg: &config.Config{}, coreManager: manager, pluginHost: host}
+ id := t.Name()
+ reg := GlobalModelRegistry()
+ reg.UnregisterClient(id)
+ t.Cleanup(func() { reg.UnregisterClient(id) })
+ auth := &coreauth.Auth{ID: id, Provider: provider, Status: coreauth.StatusActive, Attributes: map[string]string{"auth_kind": "oauth"}}
+ registered, errRegister := manager.Register(t.Context(), auth)
+ if errRegister != nil {
+ t.Fatal(errRegister)
+ }
+ return service, manager, plugin, registered
+}
+
+// TestPluginModelRegistrationWritebackRecomputesLatestOnce_Issue6718 drives
+// the real plugin ModelsForAuth path: the first discovery commits a newer
+// auth snapshot while it is still running (the host.auth.save writeback),
+// which fences out its AuthUpdate and models. The completion pass must
+// recompute exactly once from the latest auth and publish the models.
+func TestPluginModelRegistrationWritebackRecomputesLatestOnce_Issue6718(t *testing.T) {
+ const provider = "issue6718-plugin"
+ var service *Service
+ var manager *coreauth.Manager
+ handle := func(call int, req pluginapi.AuthModelRequest) pluginapi.ModelResponse {
+ if call == 1 {
+ current, ok := manager.GetByID(req.AuthID)
+ if !ok || current == nil {
+ t.Fatal("registered auth missing")
+ }
+ current = current.Clone()
+ if current.Attributes == nil {
+ current.Attributes = make(map[string]string)
+ }
+ current.Attributes["note"] = "models snapshot written"
+ if _, errUpdate := manager.Update(t.Context(), current); errUpdate != nil {
+ t.Fatal(errUpdate)
+ }
+ }
+ return pluginapi.ModelResponse{
+ Provider: provider,
+ Models: []pluginapi.ModelInfo{{ID: "plugin-model"}},
+ AuthUpdate: pluginapi.AuthData{Provider: provider, Metadata: map[string]any{"model_cache": map[string]any{"realm": "cn"}}},
+ }
+ }
+ var plugin *issue6718ModelProvider
+ var registered *coreauth.Auth
+ service, manager, plugin, registered = newIssue6718PluginService(t, provider, handle)
+
+ service.completeModelRegistrationForAuth(t.Context(), registered)
+
+ if plugin.calls != 2 {
+ t.Fatalf("ModelsForAuth calls = %d, want exactly 2 (initial + one reconciliation)", plugin.calls)
+ }
+ if !GlobalModelRegistry().ClientSupportsModel(registered.ID, "plugin-model") {
+ t.Fatal("plugin models vanished after the writeback fenced out the first discovery (issue #6718)")
+ }
+}
+
+// TestPluginModelRegistrationInvalidAuthUpdateDoesNotRetry_Issue6718 verifies
+// that a real commit error (invalid credential weight) is not mistaken for a
+// fencing race: the plugin must not be re-invoked and the true error must be
+// logged.
+func TestPluginModelRegistrationInvalidAuthUpdateDoesNotRetry_Issue6718(t *testing.T) {
+ const provider = "issue6718-invalid"
+ hook := captureSupersededRegistrationLogs(t)
+ handle := func(_ int, _ pluginapi.AuthModelRequest) pluginapi.ModelResponse {
+ return pluginapi.ModelResponse{
+ Provider: provider,
+ Models: []pluginapi.ModelInfo{{ID: "plugin-model"}},
+ AuthUpdate: pluginapi.AuthData{Provider: provider, Metadata: map[string]any{"weight": "nonnumeric"}},
+ }
+ }
+ service, _, plugin, registered := newIssue6718PluginService(t, provider, handle)
+
+ service.completeModelRegistrationForAuth(t.Context(), registered)
+
+ if plugin.calls != 1 {
+ t.Fatalf("ModelsForAuth calls = %d, want 1 (real commit errors must not trigger reconciliation)", plugin.calls)
+ }
+ for _, entry := range hook.AllEntries() {
+ if entry.Level == log.WarnLevel && strings.Contains(entry.Message, "failed to commit") && strings.Contains(entry.Message, "weight") {
+ return
+ }
+ }
+ t.Fatalf("real commit error was not logged with its cause; entries=%#v", hook.AllEntries())
+}
+
+// TestPluginModelRegistrationCanceledContextDoesNotRetry_Issue6718 verifies
+// that canceling the context during discovery stops the completion pass
+// instead of triggering a reconciliation call.
+func TestPluginModelRegistrationCanceledContextDoesNotRetry_Issue6718(t *testing.T) {
+ const provider = "issue6718-cancel"
+ ctx, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+ var manager *coreauth.Manager
+ handle := func(call int, req pluginapi.AuthModelRequest) pluginapi.ModelResponse {
+ if call == 1 {
+ current, ok := manager.GetByID(req.AuthID)
+ if !ok || current == nil {
+ t.Fatal("registered auth missing")
+ }
+ current = current.Clone()
+ if current.Attributes == nil {
+ current.Attributes = make(map[string]string)
+ }
+ current.Attributes["note"] = "models snapshot written"
+ if _, errUpdate := manager.Update(t.Context(), current); errUpdate != nil {
+ t.Fatal(errUpdate)
+ }
+ cancel()
+ }
+ return pluginapi.ModelResponse{
+ Provider: provider,
+ Models: []pluginapi.ModelInfo{{ID: "plugin-model"}},
+ AuthUpdate: pluginapi.AuthData{Provider: provider, Metadata: map[string]any{"model_cache": map[string]any{"realm": "cn"}}},
+ }
+ }
+ var service *Service
+ var plugin *issue6718ModelProvider
+ var registered *coreauth.Auth
+ service, manager, plugin, registered = newIssue6718PluginService(t, provider, handle)
+
+ service.completeModelRegistrationForAuth(ctx, registered)
+
+ if plugin.calls != 1 {
+ t.Fatalf("ModelsForAuth calls = %d, want 1 (canceled context must not trigger reconciliation)", plugin.calls)
+ }
+}
diff --git a/sdk/cliproxy/model_registration_plugin_exclusion_fence_test.go b/sdk/cliproxy/model_registration_plugin_exclusion_fence_test.go
new file mode 100644
index 00000000000..4d63c162bb0
--- /dev/null
+++ b/sdk/cliproxy/model_registration_plugin_exclusion_fence_test.go
@@ -0,0 +1,103 @@
+package cliproxy
+
+import (
+ "context"
+ "testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/pluginhost"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
+)
+
+type exclusionFenceModelProvider struct {
+ provider string
+ onModels func() pluginapi.ModelResponse
+}
+
+func (p *exclusionFenceModelProvider) StaticModels(context.Context, pluginapi.StaticModelRequest) (pluginapi.ModelResponse, error) {
+ return pluginapi.ModelResponse{Provider: p.provider, Models: []pluginapi.ModelInfo{{ID: "model-a"}, {ID: "model-b"}}}, nil
+}
+
+func (p *exclusionFenceModelProvider) ModelsForAuth(context.Context, pluginapi.AuthModelRequest) (pluginapi.ModelResponse, error) {
+ return p.onModels(), nil
+}
+
+func TestModelRegistrationPluginExclusionSources(t *testing.T) {
+ const provider = "exclusion-fixture"
+ for _, tc := range []struct {
+ name string
+ compat bool
+ addPrimary bool
+ updateAuth bool
+ }{
+ {name: "compat fallback changed", compat: true},
+ {name: "compat primary added", compat: true, addPrimary: true},
+ {name: "plugin auth update", updateAuth: true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ channel := provider
+ if tc.compat {
+ channel = "openai-compatibility"
+ }
+ oldCfg := &config.Config{OAuthExcludedModels: map[string][]string{channel: {"model-a"}}}
+ newCfg := &config.Config{OAuthExcludedModels: map[string][]string{channel: {"model-b"}}}
+ if tc.addPrimary {
+ newCfg.OAuthExcludedModels[channel] = []string{"model-a"}
+ newCfg.OAuthExcludedModels[provider] = []string{"model-b"}
+ }
+ manager := coreauth.NewManager(nil, nil, nil)
+ host := pluginhost.New()
+ service := &Service{cfg: oldCfg, coreManager: manager, pluginHost: host}
+ calls := 0
+ modelProvider := &exclusionFenceModelProvider{provider: provider, onModels: func() pluginapi.ModelResponse {
+ calls++
+ if calls == 1 {
+ if commit := service.commitConfigUpdate(newCfg); commit.cfg != newCfg {
+ t.Fatal("config update was rejected")
+ }
+ }
+ response := pluginapi.ModelResponse{Provider: provider, Models: []pluginapi.ModelInfo{{ID: "model-a"}, {ID: "model-b"}}}
+ if tc.updateAuth {
+ response.AuthUpdate = pluginapi.AuthData{Label: "updated by plugin"}
+ }
+ return response
+ }}
+ pluginID := t.Name()
+ host.RegisterPluginForTest(pluginID, pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ModelProvider: modelProvider}})
+ host.RegisterModels(t.Context(), registry.GetGlobalRegistry())
+ reg := GlobalModelRegistry()
+ t.Cleanup(func() { reg.UnregisterClient("plugin:" + pluginID + ":" + provider) })
+ auth := &coreauth.Auth{ID: t.Name(), Provider: provider, Attributes: map[string]string{"auth_kind": "oauth"}}
+ if tc.compat {
+ auth.Attributes["compat_name"] = "compat-fixture"
+ }
+ registered, errRegister := manager.Register(t.Context(), auth)
+ if errRegister != nil {
+ t.Fatal(errRegister)
+ }
+ reg.RegisterClient(auth.ID, provider, []*ModelInfo{{ID: "model-old"}})
+ t.Cleanup(func() { reg.UnregisterClient(auth.ID) })
+ epoch := reg.ClientRegistrationEpoch(auth.ID)
+ service.registerModelsForAuth(t.Context(), registered)
+ if calls != 1 {
+ t.Fatalf("plugin calls = %d, want 1", calls)
+ }
+ if got := reg.ClientRegistrationEpoch(auth.ID); got != epoch {
+ t.Fatalf("obsolete plugin result advanced epoch from %d to %d", epoch, got)
+ }
+ current, ok := manager.GetByID(auth.ID)
+ if !ok {
+ t.Fatal("credential disappeared")
+ }
+ if tc.updateAuth && current.Label != "updated by plugin" {
+ t.Fatal("test did not exercise the plugin auth update path")
+ }
+ service.registerModelsForAuth(t.Context(), current)
+ if !reg.ClientSupportsModel(auth.ID, "model-a") || reg.ClientSupportsModel(auth.ID, "model-b") {
+ t.Fatalf("final plugin models = %v, want only model-a", reg.GetModelsForClient(auth.ID))
+ }
+ })
+ }
+}
diff --git a/sdk/cliproxy/model_registration_publication_regression_test.go b/sdk/cliproxy/model_registration_publication_regression_test.go
new file mode 100644
index 00000000000..7c7c51cd408
--- /dev/null
+++ b/sdk/cliproxy/model_registration_publication_regression_test.go
@@ -0,0 +1,167 @@
+package cliproxy
+
+import (
+ coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
+ "testing"
+)
+
+type registrationInterleaveRuntime struct{ onRead func() }
+
+func (r *registrationInterleaveRuntime) GetClientID() string { r.onRead(); return "" }
+
+func TestModelRegistrationRejectsSupersededPrefix(t *testing.T) {
+ cfg := &config.Config{SDKConfig: config.SDKConfig{ForceModelPrefix: true}, OpenAICompatibility: []config.OpenAICompatibility{{Name: "compat-synthetic", Models: []config.OpenAICompatibilityModel{{Name: "upstream-model", Alias: "alias"}}}}}
+ manager := coreauth.NewManager(nil, &coreauth.RoundRobinSelector{}, nil)
+ service := &Service{cfg: cfg, coreManager: manager}
+ reg := GlobalModelRegistry()
+ id := t.Name()
+ reg.UnregisterClient(id)
+ t.Cleanup(func() { reg.UnregisterClient(id) })
+ interleaved := false
+ runtime := ®istrationInterleaveRuntime{onRead: func() {
+ if interleaved {
+ t.Fatal("unexpected repeated old runtime read")
+ }
+ interleaved = true
+ current, ok := manager.GetByID(id)
+ if !ok {
+ t.Fatal("registered auth missing")
+ }
+ current = current.Clone()
+ current.Prefix = "prefix-new"
+ current.Runtime = nil
+ updated, err := manager.Update(t.Context(), current)
+ if err != nil {
+ t.Fatal(err)
+ }
+ service.registerModelsForAuth(t.Context(), updated)
+ if !reg.ClientSupportsModel(id, "prefix-new/alias") {
+ t.Fatal("new prefix was not registered at the interleave point")
+ }
+ }}
+ auth := &coreauth.Auth{ID: id, Provider: "compat-synthetic", Prefix: "prefix-old", Status: coreauth.StatusActive, Runtime: runtime, Attributes: map[string]string{"auth_kind": "apikey", "compat_name": "compat-synthetic", "provider_key": "compat-synthetic"}}
+ registered, err := manager.Register(t.Context(), auth)
+ if err != nil {
+ t.Fatal(err)
+ }
+ service.registerModelsForAuth(t.Context(), registered)
+ if !interleaved {
+ t.Fatal("existing Runtime.GetClientID seam was not reached")
+ }
+ if !reg.ClientSupportsModel(id, "prefix-new/alias") || reg.ClientSupportsModel(id, "prefix-old/alias") {
+ t.Fatalf("superseded auth published after current registration: new=%v old=%v", reg.ClientSupportsModel(id, "prefix-new/alias"), reg.ClientSupportsModel(id, "prefix-old/alias"))
+ }
+}
+
+// TestModelRegistrationIgnoresExecutionOnlyGenerationChange verifies that a
+// request result mutating Generation without changing model-registration
+// inputs does not invalidate an in-flight registration for the same inputs.
+func TestModelRegistrationIgnoresExecutionOnlyGenerationChange(t *testing.T) {
+ cfg := &config.Config{SDKConfig: config.SDKConfig{ForceModelPrefix: true}, OpenAICompatibility: []config.OpenAICompatibility{{Name: "compat-synthetic", Models: []config.OpenAICompatibilityModel{{Name: "upstream-model", Alias: "alias"}}}}}
+ manager := coreauth.NewManager(nil, &coreauth.RoundRobinSelector{}, nil)
+ service := &Service{cfg: cfg, coreManager: manager}
+ reg := GlobalModelRegistry()
+ id := t.Name()
+ reg.UnregisterClient(id)
+ t.Cleanup(func() { reg.UnregisterClient(id) })
+ interleaved := false
+ runtime := ®istrationInterleaveRuntime{onRead: func() {
+ if interleaved {
+ t.Fatal("unexpected repeated old runtime read")
+ }
+ interleaved = true
+ manager.MarkResult(t.Context(), coreauth.Result{AuthID: id, Provider: "compat-synthetic", Model: "alias", Success: true})
+ }}
+ auth := &coreauth.Auth{ID: id, Provider: "compat-synthetic", Prefix: "prefix-current", Status: coreauth.StatusActive, Runtime: runtime, Attributes: map[string]string{"auth_kind": "apikey", "compat_name": "compat-synthetic", "provider_key": "compat-synthetic"}}
+ registered, err := manager.Register(t.Context(), auth)
+ if err != nil {
+ t.Fatal(err)
+ }
+ service.registerModelsForAuth(t.Context(), registered)
+ if !interleaved {
+ t.Fatal("existing Runtime.GetClientID seam was not reached")
+ }
+ if !reg.ClientSupportsModel(id, "prefix-current/alias") {
+ t.Fatal("execution-only generation change rejected a current model registration")
+ }
+}
+
+// TestModelRegistrationDoesNotResurrectRemovedAuth verifies the removal arm
+// of the same publication invariant: a registration computed before its auth
+// was removed must not republish the client when it finishes afterwards.
+func TestModelRegistrationDoesNotResurrectRemovedAuth(t *testing.T) {
+ cfg := &config.Config{SDKConfig: config.SDKConfig{ForceModelPrefix: true}, OpenAICompatibility: []config.OpenAICompatibility{{Name: "compat-synthetic", Models: []config.OpenAICompatibilityModel{{Name: "upstream-model", Alias: "alias"}}}}}
+ manager := coreauth.NewManager(nil, &coreauth.RoundRobinSelector{}, nil)
+ service := &Service{cfg: cfg, coreManager: manager}
+ reg := GlobalModelRegistry()
+ id := t.Name()
+ reg.UnregisterClient(id)
+ t.Cleanup(func() { reg.UnregisterClient(id) })
+ interleaved := false
+ runtime := ®istrationInterleaveRuntime{onRead: func() {
+ if interleaved {
+ t.Fatal("unexpected repeated old runtime read")
+ }
+ interleaved = true
+ service.applyCoreAuthRemoval(t.Context(), id)
+ if reg.ClientSupportsModel(id, "prefix-old/alias") {
+ t.Fatal("removed auth still has registered models at the interleave point")
+ }
+ }}
+ auth := &coreauth.Auth{ID: id, Provider: "compat-synthetic", Prefix: "prefix-old", Status: coreauth.StatusActive, Runtime: runtime, Attributes: map[string]string{"auth_kind": "apikey", "compat_name": "compat-synthetic", "provider_key": "compat-synthetic"}}
+ registered, err := manager.Register(t.Context(), auth)
+ if err != nil {
+ t.Fatal(err)
+ }
+ service.registerModelsForAuth(t.Context(), registered)
+ if !interleaved {
+ t.Fatal("existing Runtime.GetClientID seam was not reached")
+ }
+ if reg.ClientSupportsModel(id, "prefix-old/alias") {
+ t.Fatalf("superseded registration resurrected removed auth: old=%v", reg.ClientSupportsModel(id, "prefix-old/alias"))
+ }
+}
+
+// TestModelRegistrationRejectsPreReregisterSnapshot verifies the
+// unregister/re-register identity arm of the same publication invariant: a
+// registration computed from the pre-removal identity must not overwrite the
+// models of the re-registered identity.
+func TestModelRegistrationRejectsPreReregisterSnapshot(t *testing.T) {
+ cfg := &config.Config{SDKConfig: config.SDKConfig{ForceModelPrefix: true}, OpenAICompatibility: []config.OpenAICompatibility{{Name: "compat-synthetic", Models: []config.OpenAICompatibilityModel{{Name: "upstream-model", Alias: "alias"}}}}}
+ manager := coreauth.NewManager(nil, &coreauth.RoundRobinSelector{}, nil)
+ service := &Service{cfg: cfg, coreManager: manager}
+ reg := GlobalModelRegistry()
+ id := t.Name()
+ reg.UnregisterClient(id)
+ t.Cleanup(func() { reg.UnregisterClient(id) })
+ interleaved := false
+ runtime := ®istrationInterleaveRuntime{onRead: func() {
+ if interleaved {
+ t.Fatal("unexpected repeated old runtime read")
+ }
+ interleaved = true
+ service.applyCoreAuthRemoval(t.Context(), id)
+ fresh := &coreauth.Auth{ID: id, Provider: "compat-synthetic", Prefix: "prefix-fresh", Status: coreauth.StatusActive, Attributes: map[string]string{"auth_kind": "apikey", "compat_name": "compat-synthetic", "provider_key": "compat-synthetic"}}
+ registeredFresh, err := manager.Register(t.Context(), fresh)
+ if err != nil {
+ t.Fatal(err)
+ }
+ service.registerModelsForAuth(t.Context(), registeredFresh)
+ if !reg.ClientSupportsModel(id, "prefix-fresh/alias") {
+ t.Fatal("fresh prefix was not registered at the interleave point")
+ }
+ }}
+ auth := &coreauth.Auth{ID: id, Provider: "compat-synthetic", Prefix: "prefix-old", Status: coreauth.StatusActive, Runtime: runtime, Attributes: map[string]string{"auth_kind": "apikey", "compat_name": "compat-synthetic", "provider_key": "compat-synthetic"}}
+ registered, err := manager.Register(t.Context(), auth)
+ if err != nil {
+ t.Fatal(err)
+ }
+ service.registerModelsForAuth(t.Context(), registered)
+ if !interleaved {
+ t.Fatal("existing Runtime.GetClientID seam was not reached")
+ }
+ if !reg.ClientSupportsModel(id, "prefix-fresh/alias") || reg.ClientSupportsModel(id, "prefix-old/alias") {
+ t.Fatalf("pre-reregister registration overwrote fresh identity: fresh=%v old=%v", reg.ClientSupportsModel(id, "prefix-fresh/alias"), reg.ClientSupportsModel(id, "prefix-old/alias"))
+ }
+}
diff --git a/sdk/cliproxy/model_registry.go b/sdk/cliproxy/model_registry.go
index bceee4fdb26..ae07e4018ff 100644
--- a/sdk/cliproxy/model_registry.go
+++ b/sdk/cliproxy/model_registry.go
@@ -1,6 +1,14 @@
package cliproxy
-import "github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+import (
+ "maps"
+ "strings"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/watcher/diff"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
+)
// ModelInfo re-exports the registry model info structure.
type ModelInfo = registry.ModelInfo
@@ -14,7 +22,9 @@ type ModelRegistryHook = registry.ModelRegistryHook
// ModelRegistry describes registry operations consumed by external callers.
type ModelRegistry interface {
RegisterClient(clientID, clientProvider string, models []*ModelInfo)
+ RegisterClientIfEpochCurrent(clientID, clientProvider string, expectedEpoch uint64, models []*ModelInfo, publish func() bool) bool
UnregisterClient(clientID string)
+ UnregisterClientIfEpochCurrent(clientID string, expectedEpoch uint64, publish func() bool) bool
SetModelQuotaExceeded(clientID, modelID string)
ClearModelQuotaExceeded(clientID, modelID string)
ApplyClientModelProjections(clientID string, epoch uint64, generation uint64, projections []ClientModelProjection) bool
@@ -36,3 +46,103 @@ func GlobalModelRegistry() ModelRegistry {
func SetGlobalModelRegistryHook(hook ModelRegistryHook) {
registry.GetGlobalRegistry().SetHook(hook)
}
+
+// modelRegistrationFence captures the model-registration inputs and the
+// global exclusion summaries and registry epoch observed when registration began.
+// Publication takes the auth mutation lock, then the config read lock, then the
+// registry lock. Obsolete inherited exclusions must not advance the registry epoch
+// and invalidate newer work, even if the credential attributes are unchanged.
+type modelRegistrationFence struct {
+ globalExclusions map[string]diff.ExcludedModelsSummary
+ exclusionProvider string
+ fallbackExclusionProvider string
+ registryEpoch uint64
+ registrationEpoch uint64
+ prefix string
+ provider string
+ disabled bool
+ authKind string
+ attributes map[string]string
+}
+
+func newModelRegistrationFence(a *coreauth.Auth, cfg *config.Config) modelRegistrationFence {
+ var exclusions map[string]diff.ExcludedModelsSummary
+ if cfg != nil && len(cfg.OAuthExcludedModels) > 0 {
+ exclusions = make(map[string]diff.ExcludedModelsSummary, len(cfg.OAuthExcludedModels))
+ for provider, rules := range cfg.OAuthExcludedModels {
+ exclusions[provider] = diff.SummarizeExcludedModels(rules)
+ }
+ }
+ if a == nil {
+ return modelRegistrationFence{globalExclusions: exclusions}
+ }
+ provider := strings.ToLower(strings.TrimSpace(a.Provider))
+ if _, _, compat := openAICompatInfoFromAuth(a); compat {
+ provider = "openai-compatibility"
+ }
+ return modelRegistrationFence{
+ globalExclusions: exclusions,
+ exclusionProvider: provider,
+ registryEpoch: GlobalModelRegistry().ClientRegistrationEpoch(a.ID),
+ registrationEpoch: a.RegistrationEpoch,
+ prefix: a.Prefix,
+ provider: a.Provider,
+ disabled: a.Disabled || a.Status == coreauth.StatusDisabled,
+ authKind: a.AuthKind(),
+ attributes: maps.Clone(a.Attributes),
+ }
+}
+
+// withAuth returns a copy of f with the auth-input fields replaced by a's
+// current values. Exclusion summaries and the registry epoch intentionally remain
+// unchanged so stale work cannot adopt newer global rules or publications.
+func (f modelRegistrationFence) withAuth(a *coreauth.Auth) modelRegistrationFence {
+ if a == nil {
+ return f
+ }
+ next := newModelRegistrationFence(a, nil)
+ next.globalExclusions = f.globalExclusions
+ next.fallbackExclusionProvider = f.fallbackExclusionProvider
+ next.registryEpoch = f.registryEpoch
+ return next
+}
+
+func (f modelRegistrationFence) publishModels(s *Service, authID, providerKey string, models []*ModelInfo) bool {
+ return f.publishIfCurrent(s, authID, func() bool {
+ return GlobalModelRegistry().RegisterClientIfEpochCurrent(authID, providerKey, f.registryEpoch, models, func() bool { return true })
+ })
+}
+
+func (f modelRegistrationFence) publishUnregister(s *Service, authID string) bool {
+ return f.publishIfCurrent(s, authID, func() bool {
+ return GlobalModelRegistry().UnregisterClientIfEpochCurrent(authID, f.registryEpoch, func() bool { return true })
+ })
+}
+
+func (f modelRegistrationFence) publishIfCurrent(s *Service, authID string, publish func() bool) bool {
+ publishWithConfig := func() bool {
+ if s != nil && !f.disabled && f.authKind != coreauth.AuthKindAPIKey && strings.TrimSpace(f.attributes["excluded_models"]) == "" {
+ s.cfgMu.RLock()
+ defer s.cfgMu.RUnlock()
+ // Match the watcher's normalization so no-op and unrelated reloads do not
+ // discard work. A real change always schedules a replacement: the watcher
+ // redispatches affected auths, and applyConfigRuntime re-registers every
+ // auth (which also covers Home mode, where no watcher runs).
+ for _, provider := range [2]string{f.exclusionProvider, f.fallbackExclusionProvider} {
+ if provider == "" {
+ continue
+ }
+ current := oauthExcludedModelsForConfig(s.cfg, provider, f.authKind)
+ if f.globalExclusions[provider] != diff.SummarizeExcludedModels(current) {
+ return false
+ }
+ }
+ // Hold cfgMu through registry publication, not just the comparison.
+ }
+ return publish()
+ }
+ if s == nil || s.coreManager == nil {
+ return publishWithConfig()
+ }
+ return s.coreManager.PublishIfRegistrationCurrent(authID, f.registrationEpoch, f.prefix, f.provider, f.disabled, f.authKind, f.attributes, publishWithConfig)
+}
diff --git a/sdk/cliproxy/plugin_static_model_exclusions_test.go b/sdk/cliproxy/plugin_static_model_exclusions_test.go
new file mode 100644
index 00000000000..08509dba2a8
--- /dev/null
+++ b/sdk/cliproxy/plugin_static_model_exclusions_test.go
@@ -0,0 +1,207 @@
+package cliproxy
+
+import (
+ "context"
+ "testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/pluginhost"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
+)
+
+const (
+ pluginStaticGlobalModel = "plugin-static-global"
+ pluginStaticCredentialModel = "plugin-static-credential"
+)
+
+type pluginStaticModelRegistrar struct {
+ provider string
+}
+
+func (r pluginStaticModelRegistrar) RegisterModels(context.Context, pluginapi.ModelRegistrationRequest) (pluginapi.ModelRegistrationResponse, error) {
+ return pluginapi.ModelRegistrationResponse{
+ Provider: r.provider,
+ Models: []pluginapi.ModelInfo{{ID: pluginStaticGlobalModel}, {ID: pluginStaticCredentialModel}},
+ }, nil
+}
+
+// newPluginStaticModelHost loads a static-only plugin so the provider's models
+// are appended to native registrations instead of handled per auth.
+func newPluginStaticModelHost(t *testing.T, provider string) *pluginhost.Host {
+ t.Helper()
+ host := pluginhost.New()
+ pluginID := t.Name()
+ host.RegisterPluginForTest(pluginID, pluginapi.Plugin{Capabilities: pluginapi.Capabilities{ModelRegistrar: pluginStaticModelRegistrar{provider: provider}}})
+ host.RegisterModels(t.Context(), registry.GetGlobalRegistry())
+ host.RegisterExecutors(coreauth.NewManager(nil, nil, nil), registry.GetGlobalRegistry())
+ t.Cleanup(func() { registry.GetGlobalRegistry().UnregisterClient("plugin:" + pluginID + ":" + provider) })
+ if got := len(host.ModelsForProvider(provider)); got != 2 {
+ t.Fatalf("plugin static models for %s = %d, want 2", provider, got)
+ }
+ return host
+}
+
+func TestRegisterModelsForAuth_PluginStaticModelsFollowExclusions(t *testing.T) {
+ const provider = "kimi"
+ cfg := &config.Config{OAuthExcludedModels: map[string][]string{provider: {pluginStaticGlobalModel}}}
+ service := &Service{cfg: cfg, pluginHost: newPluginStaticModelHost(t, provider)}
+ reg := GlobalModelRegistry()
+
+ for _, tc := range []struct {
+ name string
+ ownRules string
+ wantGlobal bool
+ wantCredValue bool
+ }{
+ {name: "global rules", wantGlobal: false, wantCredValue: true},
+ {name: "credential rules override global", ownRules: pluginStaticCredentialModel, wantGlobal: true, wantCredValue: false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ auth := &coreauth.Auth{ID: t.Name(), Provider: provider, Status: coreauth.StatusActive, Attributes: map[string]string{"auth_kind": "oauth"}}
+ if tc.ownRules != "" {
+ auth.Attributes["excluded_models"] = tc.ownRules
+ }
+ reg.UnregisterClient(auth.ID)
+ t.Cleanup(func() { reg.UnregisterClient(auth.ID) })
+
+ service.registerModelsForAuth(t.Context(), auth)
+
+ present := make(map[string]bool)
+ for _, model := range reg.GetModelsForClient(auth.ID) {
+ present[model.ID] = true
+ }
+ if present[pluginStaticGlobalModel] != tc.wantGlobal || present[pluginStaticCredentialModel] != tc.wantCredValue {
+ t.Fatalf("plugin static models = (%t,%t), want (%t,%t); models = %v", present[pluginStaticGlobalModel], present[pluginStaticCredentialModel], tc.wantGlobal, tc.wantCredValue, present)
+ }
+
+ // The public model list must not retain unfiltered plugin-only clients.
+ available := openAIModelIDSet(reg.GetAvailableModels("openai"))
+ _, hasGlobal := available[pluginStaticGlobalModel]
+ _, hasCredential := available[pluginStaticCredentialModel]
+ if hasGlobal != tc.wantGlobal || hasCredential != tc.wantCredValue {
+ t.Fatalf("available plugin models = (%t,%t), want (%t,%t)", hasGlobal, hasCredential, tc.wantGlobal, tc.wantCredValue)
+ }
+ })
+ }
+}
+
+func TestPluginStaticModelAvailabilityFollowsCredentials(t *testing.T) {
+ const provider = "kimi"
+ cfg := &config.Config{OAuthExcludedModels: map[string][]string{provider: {pluginStaticGlobalModel}}}
+ host := newPluginStaticModelHost(t, provider)
+ service := &Service{cfg: cfg, pluginHost: host}
+ reg := GlobalModelRegistry()
+ inherited := &coreauth.Auth{ID: t.Name() + "/inherited", Provider: provider, Status: coreauth.StatusActive, Attributes: map[string]string{"auth_kind": "oauth"}}
+ override := &coreauth.Auth{ID: t.Name() + "/override", Provider: provider, Status: coreauth.StatusActive, Attributes: map[string]string{
+ "auth_kind": "oauth",
+ "excluded_models": pluginStaticCredentialModel,
+ }}
+ t.Cleanup(func() {
+ reg.UnregisterClient(inherited.ID)
+ reg.UnregisterClient(override.ID)
+ })
+ assertAvailable := func(stage string, wantGlobal, wantCredential bool) {
+ t.Helper()
+ available := openAIModelIDSet(reg.GetAvailableModels("openai"))
+ _, hasGlobal := available[pluginStaticGlobalModel]
+ _, hasCredential := available[pluginStaticCredentialModel]
+ if hasGlobal != wantGlobal || hasCredential != wantCredential {
+ t.Fatalf("%s: available plugin models = (%t,%t), want (%t,%t)", stage, hasGlobal, hasCredential, wantGlobal, wantCredential)
+ }
+ }
+
+ assertAvailable("no credentials", false, false)
+ service.registerModelsForAuth(t.Context(), inherited)
+ assertAvailable("global exclusions", false, true)
+
+ // A credential override may still expose a globally excluded model.
+ service.registerModelsForAuth(t.Context(), override)
+ assertAvailable("different credential exclusions", true, true)
+ override.Attributes["excluded_models"] = "plugin-static-*"
+ service.registerModelsForAuth(t.Context(), override)
+ assertAvailable("last supporting credential excludes model", false, true)
+
+ cfg.OAuthExcludedModels[provider] = []string{"plugin-static-*"}
+ service.registerModelsForAuth(t.Context(), inherited)
+ assertAvailable("all credentials exclude both models", false, false)
+
+ // Re-loading the catalog must not republish models excluded by every auth.
+ host.RegisterModels(t.Context(), registry.GetGlobalRegistry())
+ host.RegisterExecutors(coreauth.NewManager(nil, nil, nil), registry.GetGlobalRegistry())
+ assertAvailable("catalog reload", false, false)
+ if models := host.ModelsForProvider(provider); len(models) != 2 {
+ t.Fatalf("source catalog = %#v, want both models retained", models)
+ }
+
+ override.Attributes["excluded_models"] = pluginStaticCredentialModel
+ service.registerModelsForAuth(t.Context(), override)
+ assertAvailable("credential override after reload", true, false)
+ override.Disabled = true
+ service.registerModelsForAuth(t.Context(), override)
+ assertAvailable("last supporting credential disabled", false, false)
+ override.Disabled = false
+ service.registerModelsForAuth(t.Context(), override)
+ assertAvailable("credential re-enabled", true, false)
+ reg.UnregisterClient(override.ID)
+ assertAvailable("last supporting credential removed", false, false)
+}
+
+func TestRegisterModelsForAuth_PluginStaticExclusionsDoNotMatchNativeAliases(t *testing.T) {
+ const provider = "kimi"
+ const nativeAlias = "native-alias"
+ catalog := registry.GetKimiModels()
+ if len(catalog) == 0 {
+ t.Fatal("kimi catalog is empty")
+ }
+ cfg := &config.Config{
+ OAuthExcludedModels: map[string][]string{provider: {pluginStaticGlobalModel, nativeAlias}},
+ OAuthModelAlias: map[string][]config.OAuthModelAlias{provider: {{Name: catalog[0].ID, Alias: nativeAlias}}},
+ }
+ service := &Service{cfg: cfg, pluginHost: newPluginStaticModelHost(t, provider)}
+ auth := &coreauth.Auth{ID: t.Name(), Provider: provider, Status: coreauth.StatusActive, Attributes: map[string]string{"auth_kind": "oauth"}}
+ reg := GlobalModelRegistry()
+ reg.UnregisterClient(auth.ID)
+ t.Cleanup(func() { reg.UnregisterClient(auth.ID) })
+
+ service.registerModelsForAuth(t.Context(), auth)
+
+ // Exclusions match original model IDs, so an alias named like a rule stays visible.
+ if !reg.ClientSupportsModel(auth.ID, nativeAlias) || reg.ClientSupportsModel(auth.ID, pluginStaticGlobalModel) {
+ t.Fatalf("models = (%s:%t, %s:%t), want native alias kept and plugin model excluded", nativeAlias, reg.ClientSupportsModel(auth.ID, nativeAlias), pluginStaticGlobalModel, reg.ClientSupportsModel(auth.ID, pluginStaticGlobalModel))
+ }
+}
+
+func TestAntigravityModelsForHints_PluginStaticModelsFollowExclusions(t *testing.T) {
+ const provider = "antigravity"
+ cfg := &config.Config{OAuthExcludedModels: map[string][]string{provider: {pluginStaticGlobalModel}}}
+ service := &Service{cfg: cfg, pluginHost: newPluginStaticModelHost(t, provider)}
+ hints := antigravityModelCapabilityHints{ModelIDs: map[string]struct{}{"gemini-3.1-flash-lite": {}}}
+
+ for _, tc := range []struct {
+ name string
+ ownRules string
+ wantGlobal bool
+ wantCredValue bool
+ }{
+ {name: "global rules", wantGlobal: false, wantCredValue: true},
+ {name: "credential rules override global", ownRules: pluginStaticCredentialModel, wantGlobal: true, wantCredValue: false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ auth := antigravityTestAuth(t.Name(), "http://127.0.0.1:1")
+ if tc.ownRules != "" {
+ auth.Attributes["excluded_models"] = tc.ownRules
+ }
+
+ present := make(map[string]bool)
+ for _, model := range service.antigravityModelsForHints(auth, hints) {
+ present[model.ID] = true
+ }
+
+ if present[pluginStaticGlobalModel] != tc.wantGlobal || present[pluginStaticCredentialModel] != tc.wantCredValue {
+ t.Fatalf("plugin static models = (%t,%t), want (%t,%t); models = %v", present[pluginStaticGlobalModel], present[pluginStaticCredentialModel], tc.wantGlobal, tc.wantCredValue, present)
+ }
+ })
+ }
+}
diff --git a/sdk/cliproxy/service_auth.go b/sdk/cliproxy/service_auth.go
index d7558ebf744..cce2773e900 100644
--- a/sdk/cliproxy/service_auth.go
+++ b/sdk/cliproxy/service_auth.go
@@ -2,6 +2,8 @@ package cliproxy
import (
"context"
+ "os"
+ "runtime"
"strings"
"time"
@@ -78,7 +80,7 @@ func (s *Service) emitAuthUpdate(ctx context.Context, update watcher.AuthUpdate)
if ctx == nil {
ctx = context.Background()
}
- if s.watcher != nil && s.watcher.DispatchRuntimeAuthUpdate(update) {
+ if watcher := s.currentWatcher(); watcher != nil && watcher.DispatchRuntimeAuthUpdate(update) {
return
}
if s.authUpdates != nil {
@@ -206,11 +208,30 @@ func (s *Service) handleAuthUpdates(ctx context.Context, updates []watcher.AuthU
if id == "" {
continue
}
- if existing, ok := s.coreManager.GetByID(id); ok && existing != nil && update.Auth != nil {
- if isStaleCoreAuth(existing, update.Auth) {
+ if existing, ok := s.coreManager.GetByID(id); ok && existing != nil {
+ if update.Auth != nil && isStaleCoreAuth(existing, update.Auth) {
log.Debugf("skipping stale auth delete for %s: incoming gen=%d, existing gen=%d", id, update.Auth.Generation, existing.Generation)
continue
}
+ // Whole-file deletes can outlive a persisted re-create whose inline
+ // sync did not carry a revision. Recheck local files, including remote
+ // store mirrors, but do not suppress per-ID content/config deletes.
+ if update.Auth == nil && update.IsFileRemoval() {
+ path := existing.Attributes[coreauth.AttributePath]
+ switch existing.AuthSourceKind() {
+ case coreauth.AuthSourceFile:
+ if path == "" {
+ path = existing.Attributes[coreauth.AttributeSource]
+ }
+ case coreauth.AuthSourceGit, coreauth.AuthSourcePostgres, coreauth.AuthSourceObjectStore:
+ // Remote stores are file-backed only when they provide a local path.
+ default:
+ path = ""
+ }
+ if path != "" && s.shouldSkipFileAuthDelete(path, id) {
+ continue
+ }
+ }
}
s.applyCoreAuthRemoval(registrationCtx, id)
needsAliasRebuild = true
@@ -230,6 +251,49 @@ func (s *Service) handleAuthUpdates(ctx context.Context, updates []watcher.AuthU
waitAuthRegistrations(skippedWaits)
}
+// shouldSkipFileAuthDelete retains re-created IDs and preserves auths on uncertain reads.
+func (s *Service) shouldSkipFileAuthDelete(path, id string) bool {
+ _, errStat := os.Stat(path)
+ if os.IsNotExist(errStat) {
+ return false
+ }
+ if errStat != nil {
+ log.Debugf("skipping auth delete for %s: backing file is not confirmed missing", id)
+ return true
+ }
+ // A file can return with only a subset of its previous plugin auth IDs.
+ // Read just this file so another file with the same ID cannot retain the auth.
+ auths, errSnapshotAuthFile := s.currentWatcher().SnapshotAuthFile(path)
+ if errSnapshotAuthFile != nil || len(auths) == 0 {
+ log.Debugf("skipping auth delete for %s: file auth snapshot is uncertain (err=%v)", id, errSnapshotAuthFile)
+ return true
+ }
+ hasAuth := false
+ for _, auth := range auths {
+ if auth == nil || auth.ID == "" {
+ continue
+ }
+ hasAuth = true
+ // Store IDs retain case, but the file synthesizer lowercases IDs on Windows.
+ // Other platforms keep exact matching for case-sensitive plugin auth IDs.
+ if authIDMatches(auth.ID, id, runtime.GOOS == "windows") {
+ log.Debugf("skipping auth delete for %s: auth ID is present in the re-created file", id)
+ return true
+ }
+ }
+ if !hasAuth {
+ log.Debugf("skipping auth delete for %s: file auth snapshot has no valid IDs", id)
+ }
+ return !hasAuth
+}
+
+func authIDMatches(snapshotID, id string, foldCase bool) bool {
+ if foldCase {
+ return strings.EqualFold(snapshotID, id)
+ }
+ return snapshotID == id
+}
+
func coalesceAuthUpdates(updates []watcher.AuthUpdate) []watcher.AuthUpdate {
if len(updates) <= 1 {
return updates
@@ -382,9 +446,21 @@ func (s *Service) prepareCoreAuthForModelRegistration(ctx context.Context, auth
}
}
op = "update"
- _, err = s.coreManager.Update(ctx, auth)
+ var updated *coreauth.Auth
+ updated, err = s.coreManager.Update(ctx, auth)
+ if err == nil && updated != nil {
+ if updated.Generation == existing.Generation {
+ log.Debugf("skipping stale auth update for %s: incoming snapshot did not advance generation (gen=%d)", auth.ID, existing.Generation)
+ return updated
+ }
+ auth = updated
+ }
} else {
- _, err = s.coreManager.Register(ctx, auth)
+ var registered *coreauth.Auth
+ registered, err = s.coreManager.Register(ctx, auth)
+ if err == nil && registered != nil {
+ auth = registered
+ }
}
if err != nil {
log.Errorf("failed to %s auth %s: %v", op, auth.ID, err)
@@ -515,10 +591,36 @@ func (s *Service) completeModelRegistrationForAuthWithCache(ctx context.Context,
if latest, ok := s.latestAuthForModelRegistration(auth.ID); ok && latest != nil {
targetAuth = latest
}
- s.registerModelsForAuthWithCache(ctx, targetAuth, compatCache)
+ published := s.registerModelsForAuthWithCache(ctx, targetAuth, compatCache)
if ctx != nil && ctx.Err() != nil {
return 0
}
+ if !published {
+ // The registration was fenced out because its registration inputs
+ // or the registry epoch changed mid-computation (e.g. a plugin
+ // persisting its model snapshot through host.auth.save during
+ // ModelsForAuth, issue #6718). The intervening change may not have
+ // left this client's models behind, so reconcile the latest auth
+ // once instead of risking an empty model list. The retry
+ // recomputes from the latest snapshot;
+ // it never republishes the superseded computation's models. A
+ // removed auth stays unregistered (removal owns cleanup), while
+ // a newly disabled auth gets a fenced unregistration so its old
+ // models do not linger.
+ if latest, ok := s.latestAuthForModelRegistration(auth.ID); ok && latest != nil {
+ targetAuth = latest
+ if latest.Disabled {
+ accepted := s.unregisterModelsForAuthFenced(latest, newModelRegistrationFence(latest, s.currentConfig()))
+ log.WithFields(log.Fields{"auth_id": latest.ID, "provider": latest.Provider, "superseded": !accepted}).Debugf("model registration reconciliation attempted for disabled auth %s", latest.ID)
+ } else {
+ accepted := s.registerModelsForAuthWithCache(ctx, targetAuth, compatCache)
+ if ctx != nil && ctx.Err() != nil {
+ return 0
+ }
+ log.WithFields(log.Fields{"auth_id": latest.ID, "provider": latest.Provider, "superseded": !accepted}).Debugf("model registration reconciliation attempted for auth %s", latest.ID)
+ }
+ }
+ }
s.reconcileRegisteredModelStates(ctx, targetAuth)
// Refresh the scheduler entry so that the auth's supportedModelSet is rebuilt
diff --git a/sdk/cliproxy/service_auth_sync_test.go b/sdk/cliproxy/service_auth_sync_test.go
index 14c7364aaa5..9ba47e70a15 100644
--- a/sdk/cliproxy/service_auth_sync_test.go
+++ b/sdk/cliproxy/service_auth_sync_test.go
@@ -184,6 +184,333 @@ func TestRuntimeAuthSyncHook_SynchronousModelAndSchedulerRestoration(t *testing.
}
}
+func TestAuthIDMatches(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ snapshotID string
+ id string
+ wantExact bool
+ wantFolded bool
+ }{
+ {name: "identical", snapshotID: "account.json", id: "account.json", wantExact: true, wantFolded: true},
+ {name: "windows_store_case", snapshotID: "account.json", id: "Account.json", wantFolded: true},
+ {name: "case_sensitive_plugin_ids", snapshotID: "plugin:Account", id: "plugin:account", wantFolded: true},
+ {name: "different_ids", snapshotID: "account.json", id: "other.json"},
+ } {
+ for _, foldCase := range []bool{false, true} {
+ t.Run(fmt.Sprintf("%s/fold_case=%t", tc.name, foldCase), func(t *testing.T) {
+ want := tc.wantExact
+ if foldCase {
+ want = tc.wantFolded
+ }
+ if got := authIDMatches(tc.snapshotID, tc.id, foldCase); got != want {
+ t.Fatalf("authIDMatches(%q, %q, %t)=%t, want %t", tc.snapshotID, tc.id, foldCase, got, want)
+ }
+ })
+ }
+ }
+}
+
+func TestHandleAuthUpdates_FileDeleteOrdering(t *testing.T) {
+ for _, mode := range []struct {
+ name string
+ withRevision bool
+ delayHook bool
+ }{
+ {name: "legacy_callback"},
+ {name: "revision_callback", withRevision: true},
+ {name: "before_persist_hook", withRevision: true, delayHook: true},
+ } {
+ for _, deleteBeforeSave := range []bool{true, false} {
+ name := fmt.Sprintf("%s/delete_before_save=%t", mode.name, deleteBeforeSave)
+ t.Run(name, func(t *testing.T) {
+ ctx := coreauth.WithSkipPersist(context.Background())
+ authID := "codex-file-delete-ordering.json"
+ path := filepath.Join(t.TempDir(), authID)
+ reg := GlobalModelRegistry()
+ reg.UnregisterClient(authID)
+ t.Cleanup(func() { reg.UnregisterClient(authID) })
+ manager := coreauth.NewManager(nil, nil, nil)
+ manager.RegisterExecutor(&syncTestExecutor{})
+ auth := &coreauth.Auth{
+ ID: authID,
+ Provider: "codex",
+ Status: coreauth.StatusActive,
+ Attributes: map[string]string{
+ "plan_type": "pro",
+ coreauth.AttributePath: path,
+ },
+ }
+ w, errNewWatcher := watcher.NewWatcher("", filepath.Dir(path), nil)
+ if errNewWatcher != nil {
+ t.Fatal(errNewWatcher)
+ }
+ w.SetConfig(&config.Config{AuthDir: filepath.Dir(path)})
+ queue := make(chan watcher.AuthUpdate, 8)
+ w.SetAuthUpdateQueue(queue)
+ t.Cleanup(func() {
+ if errStop := w.Stop(); errStop != nil {
+ t.Errorf("stop watcher: %v", errStop)
+ }
+ })
+ wrapper := &WatcherWrapper{dispatchPersistedAuth: w.DispatchPersistedAuthUpdate, snapshotAuthFile: w.SnapshotAuthFile}
+ if mode.withRevision {
+ wrapper.dispatchPersistedAuthWithRev = w.DispatchPersistedAuthUpdateWithRevision
+ }
+ service := &Service{cfg: &config.Config{}, coreManager: manager, watcher: wrapper}
+ hook := service.runtimeAuthSyncHook()
+ save := func(syncHook bool) {
+ t.Helper()
+ if errWriteFile := os.WriteFile(path, []byte(`{"type":"codex"}`), 0o600); errWriteFile != nil {
+ t.Fatalf("write auth file: %v", errWriteFile)
+ }
+ if _, errRegister := manager.Register(ctx, auth.Clone()); errRegister != nil {
+ t.Fatalf("register auth: %v", errRegister)
+ }
+ if syncHook {
+ if errHook := hook(ctx, auth.Clone()); errHook != nil {
+ t.Fatalf("sync persisted auth: %v", errHook)
+ }
+ }
+ }
+ nextUpdate := func() watcher.AuthUpdate {
+ t.Helper()
+ select {
+ case update := <-queue:
+ return update
+ case <-time.After(5 * time.Second):
+ t.Fatal("watcher did not dispatch auth update")
+ return watcher.AuthUpdate{}
+ }
+ }
+ observeDelete := func() watcher.AuthUpdate {
+ t.Helper()
+ if errRemove := os.Remove(path); errRemove != nil {
+ t.Fatalf("remove auth file: %v", errRemove)
+ }
+ // Use the same watcher stamping/dispatch machinery as file deletes,
+ // without depending on fsnotify scheduling or debounce timers.
+ deletion := watcher.AuthUpdate{Action: watcher.AuthUpdateActionDelete, ID: authID}
+ deletion.SetFileRemoval(true)
+ if !w.DispatchRuntimeAuthUpdate(deletion) {
+ t.Fatal("delete was not dispatched")
+ }
+ update := nextUpdate()
+ if update.Action != watcher.AuthUpdateActionDelete || update.Auth != nil || update.Revision() == 0 || !update.IsFileRemoval() {
+ t.Fatalf("expected stamped bare watcher delete, got %+v", update)
+ }
+ return update
+ }
+
+ save(true)
+ service.handleAuthUpdate(ctx, nextUpdate())
+ var deletion watcher.AuthUpdate
+ if deleteBeforeSave {
+ deletion = observeDelete()
+ }
+ // Management uploads publish to coreManager before invoking the persist
+ // hook. Exercise that gap as well as the legacy callback's lost revision.
+ save(!mode.delayHook)
+ if !mode.delayHook {
+ // Hold the queued add so restoration depends on the inline persist hook.
+ addition := nextUpdate()
+ if deleteBeforeSave && addition.Revision() <= deletion.Revision() {
+ t.Fatal("re-created auth must have a newer watcher revision")
+ }
+ }
+ if !deleteBeforeSave {
+ deletion = observeDelete()
+ }
+ if len(reg.GetModelsForClient(authID)) == 0 {
+ t.Fatal("expected models registered before delivering delete")
+ }
+ service.handleAuthUpdate(ctx, deletion)
+ _, present := manager.GetByID(authID)
+ modelsPresent := len(reg.GetModelsForClient(authID)) > 0
+ if present != deleteBeforeSave || modelsPresent != deleteBeforeSave {
+ t.Fatalf("after delete: auth present=%t, models present=%t; want both %t", present, modelsPresent, deleteBeforeSave)
+ }
+ })
+ }
+ }
+}
+
+func TestHandleAuthUpdates_FileDeleteRecreatedSubset(t *testing.T) {
+ ctx := coreauth.WithSkipPersist(context.Background())
+ path := filepath.Join(t.TempDir(), "accounts.json")
+ write := func(data string) {
+ t.Helper()
+ if errWriteFile := os.WriteFile(path, []byte(data), 0o600); errWriteFile != nil {
+ t.Fatal(errWriteFile)
+ }
+ }
+ write(`{"type":"plugin","ids":["a","b"]}`)
+ manager := coreauth.NewManager(nil, nil, nil)
+ reg := GlobalModelRegistry()
+ auths := make([]*coreauth.Auth, 0, 2)
+ deletes := make([]watcher.AuthUpdate, 0, 2)
+ for index, id := range []string{"codex-recreated-subset-a", "codex-recreated-subset-b"} {
+ reg.UnregisterClient(id)
+ t.Cleanup(func() { reg.UnregisterClient(id) })
+ auth := &coreauth.Auth{ID: id, Provider: "codex", Status: coreauth.StatusActive, Attributes: map[string]string{coreauth.AttributePath: path}}
+ coreauth.MarkPluginVirtualAuth(auth, path, index)
+ auths = append(auths, auth)
+ deletion := watcher.AuthUpdate{Action: watcher.AuthUpdateActionDelete, ID: id}
+ deletion.SetFileRemoval(true)
+ deletion.SetRevision(2)
+ deletes = append(deletes, deletion)
+ }
+ service := &Service{
+ cfg: &config.Config{},
+ coreManager: manager,
+ watcher: &WatcherWrapper{snapshotAuthFile: func(snapshotPath string) ([]*coreauth.Auth, error) {
+ if snapshotPath != path {
+ t.Fatalf("snapshot path=%q, want %q", snapshotPath, path)
+ }
+ // The re-created source file contains only B, independent of watcher state.
+ return []*coreauth.Auth{auths[1].Clone()}, nil
+ }},
+ }
+ for _, auth := range auths {
+ addition := watcher.AuthUpdate{Action: watcher.AuthUpdateActionAdd, ID: auth.ID, Auth: auth}
+ addition.SetRevision(1)
+ service.handleAuthUpdate(ctx, addition)
+ if len(reg.GetModelsForClient(auth.ID)) == 0 {
+ t.Fatalf("expected initial models for %s", auth.ID)
+ }
+ }
+ if errRemove := os.Remove(path); errRemove != nil {
+ t.Fatal(errRemove)
+ }
+ write(`{"type":"plugin","ids":["b"]}`)
+ // Do not deliver the create event: the delayed deletes must reconcile IDs themselves.
+ service.handleAuthUpdates(ctx, deletes)
+ if _, present := manager.GetByID(auths[0].ID); present || len(reg.GetModelsForClient(auths[0].ID)) != 0 {
+ t.Fatal("auth A, absent from the re-created file, must be removed with its models")
+ }
+ if _, present := manager.GetByID(auths[1].ID); !present || len(reg.GetModelsForClient(auths[1].ID)) == 0 {
+ t.Fatal("auth B must remain present with its models")
+ }
+}
+
+func TestHandleAuthUpdates_FileDeleteSnapshotUncertain(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ watcher *WatcherWrapper
+ }{
+ {name: "unavailable"},
+ {name: "empty", watcher: &WatcherWrapper{snapshotAuthFile: func(string) ([]*coreauth.Auth, error) { return nil, nil }}},
+ {name: "invalid_ids", watcher: &WatcherWrapper{snapshotAuthFile: func(string) ([]*coreauth.Auth, error) {
+ return []*coreauth.Auth{nil, {}}, nil
+ }}},
+ {name: "error_with_partial_result", watcher: &WatcherWrapper{snapshotAuthFile: func(string) ([]*coreauth.Auth, error) {
+ return []*coreauth.Auth{{ID: "other-auth"}}, fmt.Errorf("simulated parse failure")
+ }}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ ctx := coreauth.WithSkipPersist(context.Background())
+ path := filepath.Join(t.TempDir(), "auth.json")
+ if errWriteFile := os.WriteFile(path, []byte(`{"type":`), 0o600); errWriteFile != nil {
+ t.Fatal(errWriteFile)
+ }
+ authID := "codex-uncertain-file-delete-" + tc.name
+ reg := GlobalModelRegistry()
+ reg.UnregisterClient(authID)
+ t.Cleanup(func() { reg.UnregisterClient(authID) })
+ manager := coreauth.NewManager(nil, nil, nil)
+ auth := &coreauth.Auth{ID: authID, Provider: "codex", Status: coreauth.StatusActive, Attributes: map[string]string{coreauth.AttributePath: path}}
+ service := &Service{cfg: &config.Config{}, coreManager: manager, watcher: tc.watcher}
+ service.handleAuthUpdate(ctx, watcher.AuthUpdate{Action: watcher.AuthUpdateActionAdd, ID: authID, Auth: auth})
+ deletion := watcher.AuthUpdate{Action: watcher.AuthUpdateActionDelete, ID: authID}
+ deletion.SetFileRemoval(true)
+ service.handleAuthUpdate(ctx, deletion)
+ if _, present := manager.GetByID(authID); !present || len(reg.GetModelsForClient(authID)) == 0 {
+ t.Fatal("uncertain file snapshot must preserve the auth and its models")
+ }
+ })
+ }
+}
+
+func TestHandleAuthUpdates_FileDeleteScope(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ attributes map[string]string
+ withSnapshot bool
+ unmarked bool
+ wantPresent bool
+ }{
+ {name: "file_path", attributes: map[string]string{coreauth.AttributePath: "file"}, wantPresent: true},
+ {name: "file_source", attributes: map[string]string{coreauth.AttributeSource: "file"}, wantPresent: true},
+ {name: "git", attributes: map[string]string{coreauth.AttributePath: "file", coreauth.AttributeSourceBackend: coreauth.AuthSourceGit}, wantPresent: true},
+ {name: "postgres", attributes: map[string]string{coreauth.AttributePath: "file", coreauth.AttributeSourceBackend: coreauth.AuthSourcePostgres}, wantPresent: true},
+ {name: "objectstore", attributes: map[string]string{coreauth.AttributePath: "file", coreauth.AttributeSourceBackend: coreauth.AuthSourceObjectStore}, wantPresent: true},
+ {name: "plugin_virtual_unmarked", attributes: map[string]string{coreauth.AttributePath: "file", coreauth.AttributePluginVirtual: "true"}, unmarked: true},
+ {name: "file_unmarked", attributes: map[string]string{coreauth.AttributePath: "file"}, unmarked: true},
+ {name: "remote_without_local_path", attributes: map[string]string{coreauth.AttributeSource: "file", coreauth.AttributeSourceBackend: coreauth.AuthSourceGit}},
+ {name: "runtime_only", attributes: map[string]string{coreauth.AttributePath: "file", coreauth.AttributeRuntimeOnly: "true"}},
+ {name: "runtime_only_remote", attributes: map[string]string{coreauth.AttributePath: "file", coreauth.AttributeRuntimeOnly: "true", coreauth.AttributeSourceBackend: coreauth.AuthSourceGit}},
+ {name: "config", attributes: map[string]string{coreauth.AttributePath: "file", coreauth.AttributeSource: "config:codex[0]"}},
+ {name: "api_key", attributes: map[string]string{coreauth.AttributeAPIKey: "test-key"}},
+ {name: "snapshot", attributes: map[string]string{coreauth.AttributePath: "file"}, withSnapshot: true},
+ } {
+ for _, filePresent := range []bool{true, false} {
+ t.Run(fmt.Sprintf("%s/file_present=%t", tc.name, filePresent), func(t *testing.T) {
+ ctx := coreauth.WithSkipPersist(context.Background())
+ authID := "codex-file-delete-scope-" + tc.name
+ path := filepath.Join(t.TempDir(), "auth.json")
+ if errWriteFile := os.WriteFile(path, []byte(`{"type":"codex"}`), 0o600); errWriteFile != nil {
+ t.Fatalf("write auth file: %v", errWriteFile)
+ }
+ attributes := make(map[string]string, len(tc.attributes))
+ for key, value := range tc.attributes {
+ if value == "file" {
+ value = path
+ }
+ attributes[key] = value
+ }
+ reg := GlobalModelRegistry()
+ reg.UnregisterClient(authID)
+ t.Cleanup(func() { reg.UnregisterClient(authID) })
+ manager := coreauth.NewManager(nil, nil, nil)
+ auth := &coreauth.Auth{ID: authID, Provider: "codex", Status: coreauth.StatusActive, Attributes: attributes}
+ service := &Service{
+ cfg: &config.Config{},
+ coreManager: manager,
+ watcher: &WatcherWrapper{snapshotAuthFile: func(snapshotPath string) ([]*coreauth.Auth, error) {
+ if snapshotPath != path {
+ t.Fatalf("snapshot path=%q, want %q", snapshotPath, path)
+ }
+ return []*coreauth.Auth{auth.Clone()}, nil
+ }},
+ }
+ service.handleAuthUpdate(ctx, watcher.AuthUpdate{Action: watcher.AuthUpdateActionAdd, ID: authID, Auth: auth})
+ modelsBeforeDelete := len(reg.GetModelsForClient(authID)) > 0
+ if !modelsBeforeDelete && tc.name != "api_key" {
+ t.Fatal("expected models registered before delete")
+ }
+ if !filePresent {
+ if errRemove := os.Remove(path); errRemove != nil {
+ t.Fatalf("remove auth file: %v", errRemove)
+ }
+ }
+ deletion := watcher.AuthUpdate{Action: watcher.AuthUpdateActionDelete, ID: authID}
+ deletion.SetFileRemoval(!tc.unmarked)
+ if tc.withSnapshot {
+ deletion.Auth, _ = manager.GetByID(authID)
+ }
+ service.handleAuthUpdate(ctx, deletion)
+ _, present := manager.GetByID(authID)
+ modelsPresent := len(reg.GetModelsForClient(authID)) > 0
+ wantPresent := tc.wantPresent && filePresent
+ wantModelsPresent := wantPresent && modelsBeforeDelete
+ if present != wantPresent || modelsPresent != wantModelsPresent {
+ t.Fatalf("after delete: auth present=%t, models present=%t; want auth=%t, models=%t", present, modelsPresent, wantPresent, wantModelsPresent)
+ }
+ })
+ }
+ }
+}
+
type syncTestExecutor struct{}
func (e *syncTestExecutor) Identifier() string { return "codex" }
diff --git a/sdk/cliproxy/service_config.go b/sdk/cliproxy/service_config.go
index 52eea95b26e..890af119e08 100644
--- a/sdk/cliproxy/service_config.go
+++ b/sdk/cliproxy/service_config.go
@@ -12,6 +12,35 @@ import (
log "github.com/sirupsen/logrus"
)
+func (s *Service) currentConfig() *config.Config {
+ if s == nil {
+ return nil
+ }
+ s.cfgMu.RLock()
+ cfg := s.cfg
+ s.cfgMu.RUnlock()
+ return cfg
+}
+
+func (s *Service) currentWatcher() *WatcherWrapper {
+ if s == nil {
+ return nil
+ }
+ s.cfgMu.RLock()
+ w := s.watcher
+ s.cfgMu.RUnlock()
+ return w
+}
+
+func (s *Service) setWatcher(w *WatcherWrapper) {
+ if s == nil {
+ return
+ }
+ s.cfgMu.Lock()
+ s.watcher = w
+ s.cfgMu.Unlock()
+}
+
func (s *Service) applyConfigUpdate(newCfg *config.Config) {
s.applyConfigUpdateWithAuthSynthesis(context.Background(), newCfg, true)
}
@@ -255,10 +284,11 @@ func (s *Service) updateServerClientsContext(ctx context.Context, cfg *config.Co
}
func (s *Service) reloadConfigFromWatcher() bool {
- if s == nil || s.watcher == nil {
+ w := s.currentWatcher()
+ if w == nil {
return false
}
- return s.watcher.ReloadConfigIfChanged()
+ return w.ReloadConfigIfChanged()
}
func (s *Service) registerConfigAPIKeyAuths(ctx context.Context, cfg *config.Config) {
diff --git a/sdk/cliproxy/service_excluded_models_priority_test.go b/sdk/cliproxy/service_excluded_models_priority_test.go
new file mode 100644
index 00000000000..155a461070b
--- /dev/null
+++ b/sdk/cliproxy/service_excluded_models_priority_test.go
@@ -0,0 +1,91 @@
+package cliproxy
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ management "github.com/router-for-me/CLIProxyAPI/v8/internal/api/handlers/management"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/watcher/synthesizer"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+)
+
+func TestCodexExcludedModelsCredentialPriorityAfterPatchAndReload(t *testing.T) {
+ const globalModel = "gpt-6-luna"
+ const credentialModel = "gpt-6.1-sol"
+ cfg := &config.Config{AuthDir: t.TempDir(), OAuthExcludedModels: map[string][]string{"codex": {globalModel}}}
+ manager := coreauth.NewManager(nil, nil, nil)
+ service := &Service{cfg: cfg, coreManager: manager}
+ path := filepath.Join(cfg.AuthDir, "exclusion-priority.json")
+ sctx := &synthesizer.SynthesisContext{Config: cfg, AuthDir: cfg.AuthDir, Now: time.Now(), IDGenerator: synthesizer.NewStableIDGenerator()}
+ load := func(raw string) *coreauth.Auth {
+ t.Helper()
+ auths, err := synthesizer.SynthesizeAuthFile(sctx, path, []byte(raw))
+ if err != nil || len(auths) != 1 {
+ t.Fatalf("synthesis: %v, count=%d", err, len(auths))
+ }
+ return auths[0]
+ }
+ auth := load(`{"type":"codex"}`)
+ defer registry.GetGlobalRegistry().UnregisterClient(auth.ID)
+ if _, err := manager.Register(context.Background(), auth); err != nil {
+ t.Fatal(err)
+ }
+ hook := service.runtimeAuthSyncHook()
+ sync := func(auth *coreauth.Auth) {
+ t.Helper()
+ if err := hook(context.Background(), auth); err != nil {
+ t.Fatal(err)
+ }
+ }
+ check := func(stage string, wantGlobal, wantCredential bool) {
+ t.Helper()
+ present := map[string]bool{}
+ for _, model := range registry.GetGlobalRegistry().GetModelsForClient(auth.ID) {
+ present[model.ID] = true
+ }
+ if present[globalModel] != wantGlobal || present[credentialModel] != wantCredential {
+ t.Fatalf("%s: model availability=(%v,%v), want=(%v,%v)", stage, present[globalModel], present[credentialModel], wantGlobal, wantCredential)
+ }
+ }
+ sync(auth)
+ check("global fallback", false, true)
+ h := management.NewHandlerWithoutConfigFilePath(cfg, manager)
+ h.SetPostAuthPersistHook(hook)
+ router := gin.New()
+ router.PATCH("/v8/management/credentials/fields", h.PatchAuthFileFields)
+ patch := func(rules string) {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodPatch, "/v8/management/credentials/fields", strings.NewReader(`{"name":"exclusion-priority.json","excluded_models":`+rules+`}`))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("patch status=%d body=%s", rec.Code, rec.Body.String())
+ }
+ }
+ patch(`["` + credentialModel + `"]`)
+ check("immediate credential override", true, false)
+ sync(load(`{"type":"codex","excluded_models":["` + credentialModel + `"]}`))
+ check("reloaded credential override", true, false)
+ patch(`[]`)
+ check("immediate empty fallback", false, true)
+ inherited := load(`{"type":"codex","excluded_models":[]}`)
+ if value, ok := inherited.Attributes["excluded_models"]; ok {
+ t.Fatalf("inherited credential stores excluded_models=%q, want global rules resolved at registration", value)
+ }
+ sync(inherited)
+ check("reloaded empty fallback", false, true)
+
+ // A later global config change applies to the same credential on the next registration.
+ cfg.OAuthExcludedModels = map[string][]string{"codex": {credentialModel}}
+ sync(inherited)
+ check("updated global fallback", true, false)
+}
diff --git a/sdk/cliproxy/service_excluded_models_test.go b/sdk/cliproxy/service_excluded_models_test.go
index 86cd24f27b6..6cb08144206 100644
--- a/sdk/cliproxy/service_excluded_models_test.go
+++ b/sdk/cliproxy/service_excluded_models_test.go
@@ -12,7 +12,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
)
-func TestRegisterModelsForAuth_UsesPreMergedExcludedModelsAttribute(t *testing.T) {
+func TestRegisterModelsForAuth_CredentialExcludedModelsOverrideGlobal(t *testing.T) {
service := &Service{
cfg: &config.Config{
OAuthExcludedModels: map[string][]string{
diff --git a/sdk/cliproxy/service_executors.go b/sdk/cliproxy/service_executors.go
index 870c85cd322..2c2083d959c 100644
--- a/sdk/cliproxy/service_executors.go
+++ b/sdk/cliproxy/service_executors.go
@@ -11,9 +11,11 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
+ log "github.com/sirupsen/logrus"
)
type openAICompatibilityRegistrationCache struct {
+ cfg *config.Config
byName map[string]*openAICompatibilityRegistrationEntry
byIndex map[int]*openAICompatibilityRegistrationEntry
}
@@ -37,14 +39,16 @@ func (s *Service) newOpenAICompatibilityRegistrationCache() *openAICompatibility
if s == nil {
return nil
}
- s.cfgMu.RLock()
- cfg := s.cfg
- s.cfgMu.RUnlock()
+ return newOpenAICompatibilityRegistrationCacheForConfig(s.currentConfig())
+}
+
+func newOpenAICompatibilityRegistrationCacheForConfig(cfg *config.Config) *openAICompatibilityRegistrationCache {
if cfg == nil || len(cfg.OpenAICompatibility) == 0 {
return nil
}
cache := &openAICompatibilityRegistrationCache{
+ cfg: cfg,
byName: make(map[string]*openAICompatibilityRegistrationEntry, len(cfg.OpenAICompatibility)),
byIndex: make(map[int]*openAICompatibilityRegistrationEntry, len(cfg.OpenAICompatibility)),
}
@@ -437,14 +441,21 @@ func shouldUpgradeOpenAICompatToPluginRefresh(existing, next coreauth.ProviderEx
return bareOpenAICompat
}
-func (s *Service) registerResolvedModelsForAuth(a *coreauth.Auth, providerKey string, models []*ModelInfo) {
+// registerResolvedModelsForAuthFenced publishes models only if fence still
+// matches inherited global exclusions and the current auth snapshot, and the
+// registry epoch has not advanced. A superseded registration must not
+// overwrite or remove a newer one. The returned value is false only when
+// the fence rejected the publication; true also covers no-op inputs, so
+// it never by itself proves models were published. Callers that drive a
+// completion pass use false to reconcile the latest auth instead of
+// losing the registration.
+func (s *Service) registerResolvedModelsForAuthFenced(a *coreauth.Auth, fence modelRegistrationFence, providerKey string, models []*ModelInfo) bool {
if a == nil || a.ID == "" {
- return
+ return true
}
providerKey = strings.ToLower(strings.TrimSpace(providerKey))
if providerKey == "" {
- GlobalModelRegistry().UnregisterClient(a.ID)
- return
+ return s.unregisterModelsForAuthFenced(a, fence)
}
normalizedModels := make([]*ModelInfo, 0, len(models))
for _, model := range models {
@@ -460,10 +471,28 @@ func (s *Service) registerResolvedModelsForAuth(a *coreauth.Auth, providerKey st
normalizedModels = append(normalizedModels, &clone)
}
if len(normalizedModels) == 0 {
- GlobalModelRegistry().UnregisterClient(a.ID)
- return
+ return s.unregisterModelsForAuthFenced(a, fence)
}
- GlobalModelRegistry().RegisterClient(a.ID, providerKey, normalizedModels)
+ if !fence.publishModels(s, a.ID, providerKey, normalizedModels) {
+ log.WithFields(log.Fields{"auth_id": a.ID, "provider": providerKey, "models": len(normalizedModels)}).Warnf("model registration for auth %s was superseded during computation; discarded the stale publication instead of overwriting the latest auth state", a.ID)
+ return false
+ }
+ return true
+}
+
+// unregisterModelsForAuthFenced removes a client's models only if fence
+// still matches inherited global exclusions and the current auth snapshot, and
+// the registry epoch has not advanced since the registration began. The
+// returned value is false only when the fence rejected the removal.
+func (s *Service) unregisterModelsForAuthFenced(a *coreauth.Auth, fence modelRegistrationFence) bool {
+ if a == nil || a.ID == "" {
+ return true
+ }
+ if !fence.publishUnregister(s, a.ID) {
+ log.WithFields(log.Fields{"auth_id": a.ID}).Warnf("model unregistration for auth %s was superseded during computation; discarded the stale removal instead of deleting the latest auth state", a.ID)
+ return false
+ }
+ return true
}
func (s *Service) pluginModelsForProvider(providerKey string) []*ModelInfo {
@@ -473,8 +502,11 @@ func (s *Service) pluginModelsForProvider(providerKey string) []*ModelInfo {
return s.pluginHost.ModelsForProvider(providerKey)
}
-func (s *Service) appendPluginModels(providerKey string, models []*ModelInfo) []*ModelInfo {
- pluginModels := s.pluginModelsForProvider(providerKey)
+// appendPluginModels appends the provider's plugin static models that are not excluded.
+// Callers filter native models before applying OAuth aliases, so plugin models are filtered
+// here by their original IDs instead of re-filtering the merged list by alias names.
+func (s *Service) appendPluginModels(providerKey string, models []*ModelInfo, excluded []string) []*ModelInfo {
+ pluginModels := applyExcludedModels(s.pluginModelsForProvider(providerKey), excluded)
if len(pluginModels) == 0 {
return models
}
@@ -508,21 +540,37 @@ func (s *Service) appendPluginModels(providerKey string, models []*ModelInfo) []
}
func (s *Service) tryRegisterPluginModelsForAuth(ctx context.Context, a *coreauth.Auth, provider, authKind string, excluded []string) bool {
+ handled, _ := s.tryRegisterPluginModelsForAuthWithConfig(ctx, s.currentConfig(), a, provider, authKind, excluded)
+ return handled
+}
+
+func (s *Service) tryRegisterPluginModelsForAuthWithConfig(ctx context.Context, cfg *config.Config, a *coreauth.Auth, provider, authKind string, excluded []string) (bool, bool) {
+ return s.tryRegisterPluginModelsForAuthWithConfigFenced(ctx, cfg, a, newModelRegistrationFence(a, cfg), provider, authKind, excluded)
+}
+
+// tryRegisterPluginModelsForAuthWithConfigFenced returns whether a plugin
+// handled model registration for the auth and, when handled, whether the
+// publication fence rejected the result (reported as published=false).
+// published=true also covers plugin errors and real commit failures, so it
+// never by itself proves models were published. A handled-but-rejected
+// result means the computation was superseded and the latest auth must be
+// reconciled by the completion pass (issue #6718).
+func (s *Service) tryRegisterPluginModelsForAuthWithConfigFenced(ctx context.Context, cfg *config.Config, a *coreauth.Auth, fence modelRegistrationFence, provider, authKind string, excluded []string) (bool, bool) {
if s == nil || s.pluginHost == nil || a == nil {
- return false
+ return false, true
}
if ctx != nil && ctx.Err() != nil {
- return true
+ return true, true
}
result := s.pluginHost.ModelsForAuth(ctx, a)
if ctx != nil && ctx.Err() != nil {
- return true
+ return true, true
}
if !result.Handled {
- return false
+ return false, true
}
if result.Err != nil {
- return true
+ return true, true
}
if s.coreManager != nil && strings.EqualFold(a.Provider, "antigravity") && !s.antigravityHomeEnabled() {
defer s.coreManager.ReconcileRegistryModelStates(ctx, a.ID)
@@ -532,6 +580,7 @@ func (s *Service) tryRegisterPluginModelsForAuth(ctx context.Context, a *coreaut
if providerKey == "" {
providerKey = strings.ToLower(strings.TrimSpace(provider))
}
+ pluginAuthCommitted := false
if result.Auth != nil && s.coreManager != nil {
result.Auth.ID = a.ID
if result.Auth.Provider == "" {
@@ -548,8 +597,24 @@ func (s *Service) tryRegisterPluginModelsForAuth(ctx context.Context, a *coreaut
result.Auth.Attributes[key] = value
}
}
- if updated, errUpdate := s.coreManager.Update(ctx, result.Auth); errUpdate == nil && updated != nil {
+ updated, committed, errUpdate := s.coreManager.UpdateIfRegistrationCurrent(ctx, a, result.Auth)
+ if errUpdate != nil {
+ // A real update failure (validation, persistence) is not a
+ // fencing race: report it as handled without asking the
+ // completion pass to re-invoke the plugin against the same
+ // failing input.
+ log.WithFields(log.Fields{"auth_id": a.ID, "provider": providerKey}).Warnf("plugin model registration for auth %s failed to commit its auth update: %v", a.ID, errUpdate)
+ return true, true
+ }
+ if committed && updated != nil {
activeAuth = updated.Clone()
+ pluginAuthCommitted = true
+ } else {
+ // The plugin computation's starting identity was superseded
+ // before its auth update could commit. Its models belong to
+ // that old identity and must not be laundered into the new one.
+ log.WithFields(log.Fields{"auth_id": a.ID, "provider": providerKey}).Warnf("plugin model registration for auth %s was superseded before its auth update could commit; discarded the stale plugin result", a.ID)
+ return true, false
}
}
if activeAuth == nil {
@@ -562,25 +627,37 @@ func (s *Service) tryRegisterPluginModelsForAuth(ctx context.Context, a *coreaut
providerKey = strings.ToLower(strings.TrimSpace(provider))
}
activeAuthKind := activeAuth.AuthKind()
- activeExcluded := s.oauthExcludedModels(providerKey, activeAuthKind)
+ activeExcluded := oauthExcludedModelsForConfig(cfg, providerKey, activeAuthKind)
+ fallbackExclusionProvider := ""
if a == activeAuth && len(activeExcluded) == 0 {
activeExcluded = excluded
+ fallbackExclusionProvider = strings.ToLower(strings.TrimSpace(provider))
}
+ // Credential-level exclusions take priority over the global oauth-excluded-models config.
if activeAuth.Attributes != nil {
if val, ok := activeAuth.Attributes["excluded_models"]; ok && strings.TrimSpace(val) != "" {
activeExcluded = strings.Split(val, ",")
}
}
if ctx != nil && ctx.Err() != nil {
- return true
+ return true, true
}
models := applyExcludedModels(result.Models, activeExcluded)
- models = applyOAuthModelAliasForAuth(s.cfg, providerKey, activeAuthKind, activeAuth.Attributes, models)
+ models = applyOAuthModelAliasForAuth(cfg, providerKey, activeAuthKind, activeAuth.Attributes, models)
+ publicationFence := fence
+ if pluginAuthCommitted {
+ // A plugin-returned auth update is a deliberate identity transition,
+ // committed conditionally against the starting identity above.
+ // Refresh only the auth inputs after that commit; retain the
+ // starting exclusion summaries and registry epoch so newer global
+ // rules or publications cannot be adopted by this older computation.
+ publicationFence = fence.withAuth(activeAuth)
+ }
+ publicationFence.exclusionProvider = providerKey
+ publicationFence.fallbackExclusionProvider = fallbackExclusionProvider
if len(models) > 0 {
- models = applyOAuthSettingsForAuth(s.cfg, providerKey, activeAuthKind, models)
- s.registerResolvedModelsForAuth(activeAuth, providerKey, applyModelPrefixes(models, activeAuth.Prefix, s.cfg != nil && s.cfg.ForceModelPrefix))
- return true
+ models = applyOAuthSettingsForAuth(cfg, providerKey, activeAuthKind, models)
+ return true, s.registerResolvedModelsForAuthFenced(activeAuth, publicationFence, providerKey, applyModelPrefixes(models, activeAuth.Prefix, cfg != nil && cfg.ForceModelPrefix))
}
- GlobalModelRegistry().UnregisterClient(activeAuth.ID)
- return true
+ return true, s.unregisterModelsForAuthFenced(activeAuth, publicationFence)
}
diff --git a/sdk/cliproxy/service_lifecycle.go b/sdk/cliproxy/service_lifecycle.go
index 250c75c155b..f08eb1b4cf3 100644
--- a/sdk/cliproxy/service_lifecycle.go
+++ b/sdk/cliproxy/service_lifecycle.go
@@ -56,9 +56,13 @@ func (s *Service) Run(ctx context.Context) error {
s.startModelCatalogUpdaters(ctx)
usage.StartDefault(ctx)
- homeEnabled := s.cfg != nil && s.cfg.Home.Enabled
+ cfg := s.currentConfig()
+ homeEnabled := cfg != nil && cfg.Home.Enabled
if homeEnabled {
+ s.cfgMu.Lock()
forceHomeRuntimeConfig(s.cfg)
+ cfg = s.cfg
+ s.cfgMu.Unlock()
redisqueue.SetUsageStatisticsEnabled(true)
}
@@ -76,16 +80,16 @@ func (s *Service) Run(ctx context.Context) error {
}
}
- s.applyRetryConfig(s.cfg)
- s.configureCooldownStateStore(s.cfg)
+ s.applyRetryConfig(cfg)
+ s.configureCooldownStateStore(cfg)
s.registerPluginAuthParser()
if s.coreManager != nil && !homeEnabled {
if errLoad := s.coreManager.Load(ctx); errLoad != nil {
log.Warnf("failed to load auth store: %v", errLoad)
}
- s.registerConfigAPIKeyAuths(coreauth.WithSkipPersist(ctx), s.cfg)
- if s.cfg.SaveCooldownStatus {
+ s.registerConfigAPIKeyAuths(coreauth.WithSkipPersist(ctx), cfg)
+ if cfg != nil && cfg.SaveCooldownStatus {
if errRestoreCooldown := s.coreManager.RestoreCooldownStates(ctx); errRestoreCooldown != nil {
log.Warnf("failed to restore cooldown state: %v", errRestoreCooldown)
}
@@ -100,7 +104,7 @@ func (s *Service) Run(ctx context.Context) error {
}
if !homeEnabled {
- tokenResult, err := s.tokenProvider.Load(ctx, s.cfg)
+ tokenResult, err := s.tokenProvider.Load(ctx, cfg)
if err != nil && !errors.Is(err, context.Canceled) {
return err
}
@@ -108,7 +112,7 @@ func (s *Service) Run(ctx context.Context) error {
tokenResult = &TokenClientResult{}
}
- apiKeyResult, err := s.apiKeyProvider.Load(ctx, s.cfg)
+ apiKeyResult, err := s.apiKeyProvider.Load(ctx, cfg)
if err != nil && !errors.Is(err, context.Canceled) {
return err
}
@@ -129,10 +133,12 @@ func (s *Service) Run(ctx context.Context) error {
}
// handlers no longer depend on legacy clients; pass nil slice initially
- s.server = api.NewServer(s.cfg, s.coreManager, s.accessManager, s.configPath, s.serverOptions...)
+ s.server = api.NewServer(cfg, s.coreManager, s.accessManager, s.configPath, s.serverOptions...)
s.syncPluginRuntimeConfig(ctx)
if homeEnabled {
+ s.configRuntimeMu.Lock()
s.syncPluginModelRuntime(ctx)
+ s.configRuntimeMu.Unlock()
}
if s.authManager == nil {
@@ -164,7 +170,7 @@ func (s *Service) Run(ctx context.Context) error {
}
if s.hooks.OnBeforeStart != nil {
- s.hooks.OnBeforeStart(s.cfg)
+ s.hooks.OnBeforeStart(s.currentConfig())
}
s.serverErr = make(chan error, 1)
@@ -177,10 +183,15 @@ func (s *Service) Run(ctx context.Context) error {
}()
time.Sleep(100 * time.Millisecond)
- fmt.Printf("API server started successfully on: %s:%d\n", s.cfg.Host, s.cfg.Port)
+ if currentCfg := s.currentConfig(); currentCfg != nil {
+ fmt.Printf("API server started successfully on: %s:%d\n", currentCfg.Host, currentCfg.Port)
+ }
- s.applyPprofConfig(s.cfg)
- s.applyDiscoveryConfig(s.cfg)
+ s.configRuntimeMu.Lock()
+ currentCfg := s.currentConfig()
+ s.applyPprofConfig(currentCfg)
+ s.applyDiscoveryConfig(currentCfg)
+ s.configRuntimeMu.Unlock()
if s.hooks.OnAfterStart != nil {
s.hooks.OnAfterStart(s)
@@ -190,25 +201,33 @@ func (s *Service) Run(ctx context.Context) error {
var watcherWrapper *WatcherWrapper
reloadCallback := func(newCfg *config.Config) { s.applyWatcherConfigUpdate(newCfg) }
- watcherWrapper, errCreate := s.watcherFactory(s.configPath, s.cfg.AuthDir, reloadCallback)
+ authDir := ""
+ if currentCfg := s.currentConfig(); currentCfg != nil {
+ authDir = currentCfg.AuthDir
+ }
+ watcherWrapper, errCreate := s.watcherFactory(s.configPath, authDir, reloadCallback)
if errCreate != nil {
return fmt.Errorf("cliproxy: failed to create watcher: %w", errCreate)
}
- s.watcher = watcherWrapper
+ s.setWatcher(watcherWrapper)
s.ensureAuthUpdateQueue(ctx)
if s.authUpdates != nil {
watcherWrapper.SetAuthUpdateQueue(s.authUpdates)
}
- watcherWrapper.SetConfig(s.cfg)
+ watcherWrapper.SetConfig(s.currentConfig())
s.registerPluginAuthParser()
watcherCtx, watcherCancel := context.WithCancel(context.Background())
+ s.cfgMu.Lock()
s.watcherCancel = watcherCancel
+ s.cfgMu.Unlock()
if errStart := watcherWrapper.Start(watcherCtx); errStart != nil {
return fmt.Errorf("cliproxy: failed to start watcher: %w", errStart)
}
log.Info("file watcher started for config and auth directory changes")
+ s.configRuntimeMu.Lock()
s.syncPluginModelRuntime(ctx)
+ s.configRuntimeMu.Unlock()
}
s.registerModelRefreshCallback()
@@ -299,14 +318,17 @@ func (s *Service) Shutdown(ctx context.Context) error {
// legacy refresh loop removed; only stopping core auth manager below
- if s.watcherCancel != nil {
- s.watcherCancel()
+ s.cfgMu.RLock()
+ watcherCancel := s.watcherCancel
+ s.cfgMu.RUnlock()
+ if watcherCancel != nil {
+ watcherCancel()
}
if s.coreManager != nil {
s.coreManager.StopAutoRefresh()
}
- if s.watcher != nil {
- if err := s.watcher.Stop(); err != nil {
+ if watcher := s.currentWatcher(); watcher != nil {
+ if err := watcher.Stop(); err != nil {
log.Errorf("failed to stop file watcher: %v", err)
shutdownErr = err
}
@@ -352,8 +374,8 @@ func (s *Service) Shutdown(ctx context.Context) error {
if s.pluginHost != nil {
sdktranslator.SetPluginHooks(nil)
sdkAuth.RegisterPluginAuthParser(nil)
- if s.watcher != nil {
- s.watcher.SetPluginAuthParser(nil)
+ if watcher := s.currentWatcher(); watcher != nil {
+ watcher.SetPluginAuthParser(nil)
}
s.pluginHost.ApplyConfig(ctx, &config.Config{})
s.pluginHost.RegisterModels(ctx, registry.GetGlobalRegistry())
@@ -373,19 +395,24 @@ func (s *Service) Shutdown(ctx context.Context) error {
}
func (s *Service) ensureAuthDir() error {
- info, err := os.Stat(s.cfg.AuthDir)
+ cfg := s.currentConfig()
+ authDir := ""
+ if cfg != nil {
+ authDir = cfg.AuthDir
+ }
+ info, err := os.Stat(authDir)
if err != nil {
if os.IsNotExist(err) {
- if mkErr := os.MkdirAll(s.cfg.AuthDir, 0o755); mkErr != nil {
- return fmt.Errorf("cliproxy: failed to create auth directory %s: %w", s.cfg.AuthDir, mkErr)
+ if mkErr := os.MkdirAll(authDir, 0o755); mkErr != nil {
+ return fmt.Errorf("cliproxy: failed to create auth directory %s: %w", authDir, mkErr)
}
- log.Infof("created missing auth directory: %s", s.cfg.AuthDir)
+ log.Infof("created missing auth directory: %s", authDir)
return nil
}
- return fmt.Errorf("cliproxy: error checking auth directory %s: %w", s.cfg.AuthDir, err)
+ return fmt.Errorf("cliproxy: error checking auth directory %s: %w", authDir, err)
}
if !info.IsDir() {
- return fmt.Errorf("cliproxy: auth path exists but is not a directory: %s", s.cfg.AuthDir)
+ return fmt.Errorf("cliproxy: auth path exists but is not a directory: %s", authDir)
}
return nil
}
diff --git a/sdk/cliproxy/service_management_save_reload_test.go b/sdk/cliproxy/service_management_save_reload_test.go
new file mode 100644
index 00000000000..61e9ba6c9a2
--- /dev/null
+++ b/sdk/cliproxy/service_management_save_reload_test.go
@@ -0,0 +1,237 @@
+package cliproxy
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "golang.org/x/crypto/bcrypt"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/api"
+ internalconfig "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
+)
+
+const issue6631ManagementKey = "issue-6631-management-key"
+
+type issue6631Values struct {
+ Debug bool
+ RequestRetry int
+ MaxRetryCredentials int
+}
+
+func issue6631ValuesOf(cfg *config.Config) issue6631Values {
+ if cfg == nil {
+ return issue6631Values{}
+ }
+ return issue6631Values{Debug: cfg.Debug, RequestRetry: cfg.RequestRetry, MaxRetryCredentials: cfg.MaxRetryCredentials}
+}
+
+type issue6631Harness struct {
+ t *testing.T
+ service *Service
+ handler http.Handler
+ configPath string
+ firstRead chan issue6631Values
+ releaseFirst func()
+ firstPublished chan struct{}
+ published chan issue6631Values
+}
+
+// newIssue6631Harness wires the real Service, watcher reload, API server, and
+// management handler. The first reload that reaches the server is paused after
+// it read config.yaml, so a test can land a management save in between.
+func newIssue6631Harness(t *testing.T) *issue6631Harness {
+ t.Helper()
+ previousLevel := log.GetLevel()
+ t.Cleanup(func() { log.SetLevel(previousLevel) })
+
+ dir := t.TempDir()
+ authDir := filepath.Join(dir, "auths")
+ if errMkdir := os.MkdirAll(authDir, 0o700); errMkdir != nil {
+ t.Fatal(errMkdir)
+ }
+ hashedKey, errHash := bcrypt.GenerateFromPassword([]byte(issue6631ManagementKey), bcrypt.MinCost)
+ if errHash != nil {
+ t.Fatal(errHash)
+ }
+ configPath := filepath.Join(dir, "config.yaml")
+ raw := fmt.Sprintf("port: 8317\nauth-dir: %q\ndebug: false\nrequest-retry: 3\nmax-retry-credentials: 0\nremote-management:\n allow-remote: true\n secret-key: %q\n", filepath.ToSlash(authDir), string(hashedKey))
+ if errWrite := os.WriteFile(configPath, []byte(raw), 0o600); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ cfg, errLoad := internalconfig.LoadConfig(configPath)
+ if errLoad != nil {
+ t.Fatal(errLoad)
+ }
+
+ service, errBuild := NewBuilder().WithConfig(cfg).WithConfigPath(configPath).Build()
+ if errBuild != nil {
+ t.Fatal(errBuild)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+ watcherWrapper, errWatcher := service.watcherFactory(configPath, authDir, func(newCfg *config.Config) {
+ service.applyWatcherConfigUpdate(newCfg)
+ })
+ if errWatcher != nil {
+ t.Fatal(errWatcher)
+ }
+ service.setWatcher(watcherWrapper)
+ service.ensureAuthUpdateQueue(ctx)
+ watcherWrapper.SetAuthUpdateQueue(service.authUpdates)
+ watcherWrapper.SetConfig(service.currentConfig())
+ server := api.NewServer(service.currentConfig(), service.coreManager, service.accessManager, configPath, service.serverOptions...)
+ if server == nil {
+ t.Fatal("NewServer returned nil")
+ }
+ service.server = server
+ t.Cleanup(func() {
+ if service.authQueueStop != nil {
+ service.authQueueStop()
+ }
+ _ = watcherWrapper.Stop()
+ })
+
+ releaseCh := make(chan struct{})
+ h := &issue6631Harness{
+ t: t,
+ service: service,
+ handler: server.Handler(),
+ configPath: configPath,
+ firstRead: make(chan issue6631Values, 1),
+ releaseFirst: sync.OnceFunc(func() { close(releaseCh) }),
+ firstPublished: make(chan struct{}),
+ published: make(chan issue6631Values, 64),
+ }
+ t.Cleanup(h.releaseFirst)
+ var firstOnce sync.Once
+ service.updateServerClientsContextFn = func(ctx context.Context, cfg *config.Config) bool {
+ first := false
+ firstOnce.Do(func() { first = true })
+ if first {
+ h.firstRead <- issue6631ValuesOf(cfg)
+ <-releaseCh
+ }
+ ok := server.UpdateClientsContext(ctx, cfg)
+ if first {
+ close(h.firstPublished)
+ }
+ select {
+ case h.published <- issue6631ValuesOf(cfg):
+ default:
+ }
+ return ok
+ }
+ return h
+}
+
+func (h *issue6631Harness) put(path, value string) {
+ h.t.Helper()
+ recorder := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodPut, "/v0/management/"+path, strings.NewReader(`{"value":`+value+`}`))
+ request.Header.Set("Authorization", "Bearer "+issue6631ManagementKey)
+ request.Header.Set("Content-Type", "application/json")
+ h.handler.ServeHTTP(recorder, request)
+ if recorder.Code != http.StatusOK {
+ h.t.Fatalf("PUT %s: status=%d body=%s", path, recorder.Code, recorder.Body.String())
+ }
+}
+
+// awaitFirstReload waits until the reload for the first save has read config.yaml.
+func (h *issue6631Harness) awaitFirstReload() issue6631Values {
+ h.t.Helper()
+ select {
+ case values := <-h.firstRead:
+ return values
+ case <-time.After(10 * time.Second):
+ h.t.Fatal("reload for the first management save did not start")
+ }
+ return issue6631Values{}
+}
+
+// publishFirstReload lets the paused reload publish what it read and waits for it.
+func (h *issue6631Harness) publishFirstReload() {
+ h.t.Helper()
+ h.releaseFirst()
+ select {
+ case <-h.firstPublished:
+ case <-time.After(10 * time.Second):
+ h.t.Fatal("paused reload did not finish publishing")
+ }
+}
+
+func (h *issue6631Harness) disk() issue6631Values {
+ h.t.Helper()
+ data, errRead := os.ReadFile(h.configPath)
+ if errRead != nil {
+ h.t.Fatal(errRead)
+ }
+ cfg, errParse := internalconfig.ParseConfigBytes(data)
+ if errParse != nil {
+ h.t.Fatal(errParse)
+ }
+ return issue6631ValuesOf(cfg)
+}
+
+// waitForPublished waits until a post-save reload has published want to the server.
+// The timeout only guards against a reload that never comes.
+func (h *issue6631Harness) waitForPublished(want issue6631Values) bool {
+ timeout := time.After(10 * time.Second)
+ for {
+ select {
+ case got := <-h.published:
+ if got == want {
+ return true
+ }
+ case <-timeout:
+ return false
+ }
+ }
+}
+
+func TestManagementSequentialWritesSurviveStaleReload_Issue6631(t *testing.T) {
+ h := newIssue6631Harness(t)
+
+ h.put("debug", "true")
+ if read := h.awaitFirstReload(); read != (issue6631Values{Debug: true, RequestRetry: 3}) {
+ t.Fatalf("first reload read %+v, want the config saved by the first write", read)
+ }
+ h.put("request-retry", "5")
+ // The reload that read config.yaml before the second save publishes now.
+ h.publishFirstReload()
+ h.put("max-retry-credentials", "7")
+
+ want := issue6631Values{Debug: true, RequestRetry: 5, MaxRetryCredentials: 7}
+ published := h.waitForPublished(want)
+ runtime := issue6631ValuesOf(h.service.currentConfig())
+ disk := h.disk()
+ if !published || runtime != want || disk != want {
+ t.Fatalf("after three acknowledged writes: published=%v runtime=%+v config.yaml=%+v, want %+v", published, runtime, disk, want)
+ }
+}
+
+func TestManagementWriteDuringReloadReachesRuntime_Issue6631(t *testing.T) {
+ h := newIssue6631Harness(t)
+
+ h.put("debug", "true")
+ h.awaitFirstReload()
+ h.put("request-retry", "5")
+ h.publishFirstReload()
+
+ want := issue6631Values{Debug: true, RequestRetry: 5}
+ published := h.waitForPublished(want)
+ runtime := issue6631ValuesOf(h.service.currentConfig())
+ disk := h.disk()
+ if !published || runtime != want || disk != want {
+ t.Fatalf("after two acknowledged writes: published=%v runtime=%+v config.yaml=%+v, want %+v", published, runtime, disk, want)
+ }
+}
diff --git a/sdk/cliproxy/service_models.go b/sdk/cliproxy/service_models.go
index a444b64c60f..7e478af9420 100644
--- a/sdk/cliproxy/service_models.go
+++ b/sdk/cliproxy/service_models.go
@@ -14,33 +14,46 @@ import (
)
// registerModelsForAuth (re)binds provider models in the global registry using the core auth ID as client identifier.
-func (s *Service) registerModelsForAuth(ctx context.Context, a *coreauth.Auth) {
- s.registerModelsForAuthWithCache(ctx, a, nil)
+func (s *Service) registerModelsForAuth(ctx context.Context, a *coreauth.Auth) bool {
+ return s.registerModelsForAuthWithCache(ctx, a, nil)
}
-func (s *Service) registerModelsForAuthWithCache(ctx context.Context, a *coreauth.Auth, compatCache *openAICompatibilityRegistrationCache) {
+// registerModelsForAuthWithCache reports only whether the publication
+// fence rejected this computation: false means a newer auth state
+// superseded it mid-computation and a completion pass must reconcile the
+// latest auth. True also covers paths that attempt no publication
+// (cancellation, plugin errors, invalid input), so it never by itself
+// proves that models were published.
+func (s *Service) registerModelsForAuthWithCache(ctx context.Context, a *coreauth.Auth, compatCache *openAICompatibilityRegistrationCache) bool {
+ return s.registerModelsForAuthWithConfigAndCache(ctx, s.currentConfig(), a, compatCache)
+}
+
+func (s *Service) registerModelsForAuthWithConfigAndCache(ctx context.Context, cfg *config.Config, a *coreauth.Auth, compatCache *openAICompatibilityRegistrationCache) bool {
if a == nil || a.ID == "" {
- return
+ return true
}
if ctx == nil {
ctx = context.Background()
}
if ctx.Err() != nil {
- return
+ return true
}
+ // Capture the publication fence before any runtime read or model
+ // resolution: a registration superseded while it is being computed must
+ // not overwrite or remove the newer registration when it finishes last.
+ fence := newModelRegistrationFence(a, cfg)
s.cancelStaleAntigravityProbes(a.ID)
if a.Disabled {
if s != nil && s.coreManager != nil {
if current, ok := s.coreManager.GetByID(a.ID); ok && current != nil && !current.Disabled {
- return
+ return false
}
}
- GlobalModelRegistry().UnregisterClient(a.ID)
- return
+ return s.unregisterModelsForAuthFenced(a, fence)
}
if s != nil && s.coreManager != nil {
if current, ok := s.coreManager.GetByID(a.ID); !ok || current == nil || current.Disabled {
- return
+ return true
}
}
authKind := a.AuthKind()
@@ -57,25 +70,27 @@ func (s *Service) registerModelsForAuthWithCache(ctx context.Context, a *coreaut
if compatDetected {
provider = "openai-compatibility"
}
- excluded := s.oauthExcludedModels(provider, authKind)
- // The synthesizer pre-merges per-account and global exclusions into the "excluded_models" attribute.
- // If this attribute is present, it represents the complete list of exclusions and overrides the global config.
+ excluded := oauthExcludedModelsForConfig(cfg, provider, authKind)
+ // The "excluded_models" attribute holds only the credential's own exclusions.
+ // When present it takes priority over the global oauth-excluded-models config;
+ // otherwise the current global config applies, so global changes take effect without rewriting credentials.
if a.Attributes != nil {
if val, ok := a.Attributes["excluded_models"]; ok && strings.TrimSpace(val) != "" {
excluded = strings.Split(val, ",")
}
}
- if s.tryRegisterPluginModelsForAuth(ctx, a, provider, authKind, excluded) {
- return
+ if handled, published := s.tryRegisterPluginModelsForAuthWithConfigFenced(ctx, cfg, a, fence, provider, authKind, excluded); handled {
+ return published
}
if ctx.Err() != nil {
- return
+ return true
}
+ published := true
var models []*ModelInfo
switch provider {
case constant.Gemini:
models = registry.GetGeminiModels()
- if entry := s.resolveConfigGeminiKey(a); entry != nil {
+ if entry := resolveConfigGeminiKeyForConfig(cfg, a); entry != nil {
if len(entry.Models) > 0 {
models = buildGeminiConfigModels(entry)
}
@@ -86,7 +101,7 @@ func (s *Service) registerModelsForAuthWithCache(ctx context.Context, a *coreaut
models = applyExcludedModels(models, excluded)
case constant.GeminiInteractions:
models = registry.GetGeminiModels()
- if entry := s.resolveConfigInteractionsKey(a); entry != nil {
+ if entry := resolveConfigInteractionsKeyForConfig(cfg, a); entry != nil {
if len(entry.Models) > 0 {
models = buildGeminiConfigModels(entry)
}
@@ -98,20 +113,22 @@ func (s *Service) registerModelsForAuthWithCache(ctx context.Context, a *coreaut
case "vertex":
// Vertex AI Gemini supports the same model identifiers as Gemini.
models = registry.GetGeminiVertexModels()
- if entry := s.resolveConfigVertexCompatKey(a); entry != nil {
+ if entry := resolveConfigVertexCompatKeyForConfig(cfg, a); entry != nil {
if len(entry.Models) > 0 {
models = buildVertexCompatConfigModels(entry)
}
if authKind == "apikey" {
excluded = entry.ExcludedModels
}
+ } else {
+ models = appendVertexServiceAccountModels(cfg, a, models)
}
models = applyExcludedModels(models, excluded)
case "aistudio":
models = registry.GetAIStudioModels()
models = applyExcludedModels(models, excluded)
case "antigravity":
- if !s.antigravityHomeEnabled() {
+ if !(cfg != nil && cfg.Home.Enabled) {
expectedRegEpoch := GlobalModelRegistry().ClientRegistrationEpoch(a.ID)
expectedKey := s.antigravityCapabilityKey(a)
hints := s.cachedAntigravityHints(a)
@@ -121,7 +138,7 @@ func (s *Service) registerModelsForAuthWithCache(ctx context.Context, a *coreaut
// is pending, including its quota and suspension projections.
s.applyAntigravityModelHints(ctx, a, provider, hints, expectedKey, a.RegistrationEpoch, expectedRegEpoch)
s.asyncProbeAntigravityCapabilities(ctx, a, provider)
- return
+ return true
}
} else {
models = registry.GetAntigravityModels()
@@ -129,7 +146,7 @@ func (s *Service) registerModelsForAuthWithCache(ctx context.Context, a *coreaut
models = applyExcludedModels(models, excluded)
case "claude":
models = registry.GetClaudeModels()
- if entry := s.resolveConfigClaudeKey(a); entry != nil {
+ if entry := resolveConfigClaudeKeyForConfig(cfg, a); entry != nil {
if len(entry.Models) > 0 {
models = buildClaudeConfigModels(entry)
}
@@ -140,7 +157,7 @@ func (s *Service) registerModelsForAuthWithCache(ctx context.Context, a *coreaut
models = applyExcludedModels(models, excluded)
case "codex":
if authKind == "apikey" {
- if entry := s.resolveConfigCodexKey(a); entry != nil {
+ if entry := resolveConfigCodexKeyForConfig(cfg, a); entry != nil {
models = buildCodexConfigModels(entry)
excluded = entry.ExcludedModels
}
@@ -170,7 +187,7 @@ func (s *Service) registerModelsForAuthWithCache(ctx context.Context, a *coreaut
models = applyExcludedModels(models, excluded)
case "xai":
models = registry.GetXAIModels()
- if entry := s.resolveConfigXAIKey(a); entry != nil {
+ if entry := resolveConfigXAIKeyForConfig(cfg, a); entry != nil {
if len(entry.Models) > 0 {
models = buildXAIConfigModels(entry)
}
@@ -184,7 +201,7 @@ func (s *Service) registerModelsForAuthWithCache(ctx context.Context, a *coreaut
models = applyExcludedModels(models, excluded)
case "meta":
models = registry.GetMetaModels()
- if entry := s.resolveConfigMetaKey(a); entry != nil {
+ if entry := resolveConfigMetaKeyForConfig(cfg, a); entry != nil {
if len(entry.Models) > 0 {
models = buildMetaConfigModels(entry)
}
@@ -195,7 +212,7 @@ func (s *Service) registerModelsForAuthWithCache(ctx context.Context, a *coreaut
models = applyExcludedModels(models, excluded)
default:
// Handle OpenAI-compatibility providers by name using config
- if s.cfg != nil {
+ if cfg != nil {
providerKey := provider
compatName := strings.TrimSpace(a.Provider)
isCompatAuth := false
@@ -242,86 +259,89 @@ func (s *Service) registerModelsForAuthWithCache(ctx context.Context, a *coreaut
providerKey = "openai-compatibility"
}
if len(ms) > 0 {
- ms = s.appendPluginModels(providerKey, ms)
- s.registerResolvedModelsForAuth(a, providerKey, applyModelPrefixes(ms, a.Prefix, s.cfg.ForceModelPrefix))
+ ms = s.appendPluginModels(providerKey, ms, nil)
+ published = s.registerResolvedModelsForAuthFenced(a, fence, providerKey, applyModelPrefixes(ms, a.Prefix, cfg.ForceModelPrefix))
} else {
- ms = s.appendPluginModels(providerKey, nil)
+ ms = s.appendPluginModels(providerKey, nil, nil)
if len(ms) > 0 {
- s.registerResolvedModelsForAuth(a, providerKey, applyModelPrefixes(ms, a.Prefix, s.cfg.ForceModelPrefix))
+ published = s.registerResolvedModelsForAuthFenced(a, fence, providerKey, applyModelPrefixes(ms, a.Prefix, cfg.ForceModelPrefix))
} else {
- GlobalModelRegistry().UnregisterClient(a.ID)
+ published = s.unregisterModelsForAuthFenced(a, fence)
}
}
return true
}
- if cached, ok := compatCache.lookup(a, compatName); ok {
- isCompatAuth = true
- if providerKey == "" {
- providerKey = cached.providerKey
- }
- if providerKey == "" {
- providerKey = "openai-compatibility"
- }
- ms := cached.models
- if len(ms) > 0 {
- ms = s.appendPluginModels(providerKey, ms)
- s.registerResolvedModelsForAuth(a, providerKey, applyModelPrefixes(ms, a.Prefix, s.cfg.ForceModelPrefix))
- } else {
- ms = s.appendPluginModels(providerKey, nil)
+ if compatCache != nil && compatCache.cfg == cfg {
+ if cached, ok := compatCache.lookup(a, compatName); ok {
+ isCompatAuth = true
+ if providerKey == "" {
+ providerKey = cached.providerKey
+ }
+ if providerKey == "" {
+ providerKey = "openai-compatibility"
+ }
+ ms := cached.models
if len(ms) > 0 {
- s.registerResolvedModelsForAuth(a, providerKey, applyModelPrefixes(ms, a.Prefix, s.cfg.ForceModelPrefix))
+ ms = s.appendPluginModels(providerKey, ms, nil)
+ published = s.registerResolvedModelsForAuthFenced(a, fence, providerKey, applyModelPrefixes(ms, a.Prefix, cfg.ForceModelPrefix))
} else {
- GlobalModelRegistry().UnregisterClient(a.ID)
+ ms = s.appendPluginModels(providerKey, nil, nil)
+ if len(ms) > 0 {
+ published = s.registerResolvedModelsForAuthFenced(a, fence, providerKey, applyModelPrefixes(ms, a.Prefix, cfg.ForceModelPrefix))
+ } else {
+ published = s.unregisterModelsForAuthFenced(a, fence)
+ }
}
+ return published
}
- return
}
- if indexed := configEntryForAuthIndex(a, s.cfg.OpenAICompatibility); indexed != nil && registerCompat(indexed) {
- return
+ if indexed := configEntryForAuthIndex(a, cfg.OpenAICompatibility); indexed != nil && registerCompat(indexed) {
+ return published
}
- for i := range s.cfg.OpenAICompatibility {
- compat := &s.cfg.OpenAICompatibility[i]
+ for i := range cfg.OpenAICompatibility {
+ compat := &cfg.OpenAICompatibility[i]
if strings.EqualFold(compat.Name, compatName) && registerCompat(compat) {
- return
+ return published
}
}
if isCompatAuth {
- models = s.appendPluginModels(providerKey, nil)
+ models = s.appendPluginModels(providerKey, nil, nil)
if len(models) > 0 {
- s.registerResolvedModelsForAuth(a, providerKey, applyModelPrefixes(models, a.Prefix, s.cfg != nil && s.cfg.ForceModelPrefix))
+ published = s.registerResolvedModelsForAuthFenced(a, fence, providerKey, applyModelPrefixes(models, a.Prefix, cfg.ForceModelPrefix))
} else {
// No matching provider found or models removed entirely; drop any prior registration.
- GlobalModelRegistry().UnregisterClient(a.ID)
+ published = s.unregisterModelsForAuthFenced(a, fence)
}
- return
+ return published
}
}
}
if ctx.Err() != nil {
- return
+ return true
}
- models = applyOAuthModelAliasForAuth(s.cfg, provider, authKind, a.Attributes, models)
+ models = applyOAuthModelAliasForAuth(cfg, provider, authKind, a.Attributes, models)
if ctx.Err() != nil {
- return
+ return true
}
key := provider
if key == "" {
key = strings.ToLower(strings.TrimSpace(a.Provider))
}
- models = s.appendPluginModels(key, models)
+ models = s.appendPluginModels(key, models, excluded)
if len(models) > 0 {
- models = applyOAuthSettingsForAuth(s.cfg, provider, authKind, models)
- s.registerResolvedModelsForAuth(a, key, applyModelPrefixes(models, a.Prefix, s.cfg != nil && s.cfg.ForceModelPrefix))
+ models = applyOAuthSettingsForAuth(cfg, provider, authKind, models)
+ published = s.registerResolvedModelsForAuthFenced(a, fence, key, applyModelPrefixes(models, a.Prefix, cfg != nil && cfg.ForceModelPrefix))
if strings.EqualFold(strings.TrimSpace(a.Provider), "antigravity") {
s.asyncProbeAntigravityCapabilities(ctx, a, key)
}
- return
+ return published
}
- GlobalModelRegistry().UnregisterClient(a.ID)
+ published = s.unregisterModelsForAuthFenced(a, fence)
if provider == "antigravity" {
s.asyncProbeAntigravityCapabilities(ctx, a, key)
}
+ return published
}
// refreshModelRegistrationForAuth re-applies the latest model registration for
@@ -360,7 +380,15 @@ func (s *Service) refreshModelRegistrationForAuthWithContext(ctx context.Context
latest, ok := s.latestAuthForModelRegistration(current.ID)
if !ok || latest.Disabled {
- GlobalModelRegistry().UnregisterClient(current.ID)
+ if ok && latest != nil {
+ // Conditional cleanup: only remove the registration that
+ // still belongs to the disabled snapshot just observed. A
+ // concurrent re-enable/re-register advances identity or
+ // epoch and must not lose its newer publication.
+ s.unregisterModelsForAuthFenced(latest, newModelRegistrationFence(latest, s.currentConfig()))
+ }
+ // If the auth is absent, applyCoreAuthRemoval owns unregistration;
+ // a stale refresh must not delete a later re-registration.
s.coreManager.RefreshSchedulerEntry(current.ID)
return false
}
@@ -404,10 +432,14 @@ func configEntryForAuthIndex[T any](auth *coreauth.Auth, entries []T) *T {
}
func (s *Service) resolveConfigClaudeKey(auth *coreauth.Auth) *config.ClaudeKey {
- if auth == nil || s.cfg == nil {
+ return resolveConfigClaudeKeyForConfig(s.currentConfig(), auth)
+}
+
+func resolveConfigClaudeKeyForConfig(cfg *config.Config, auth *coreauth.Auth) *config.ClaudeKey {
+ if auth == nil || cfg == nil {
return nil
}
- if entry := configEntryForAuthIndex(auth, s.cfg.ClaudeKey); entry != nil {
+ if entry := configEntryForAuthIndex(auth, cfg.ClaudeKey); entry != nil {
return entry
}
var attrKey, attrBase string
@@ -415,8 +447,8 @@ func (s *Service) resolveConfigClaudeKey(auth *coreauth.Auth) *config.ClaudeKey
attrKey = strings.TrimSpace(auth.Attributes["api_key"])
attrBase = strings.TrimSpace(auth.Attributes["base_url"])
}
- for i := range s.cfg.ClaudeKey {
- entry := &s.cfg.ClaudeKey[i]
+ for i := range cfg.ClaudeKey {
+ entry := &cfg.ClaudeKey[i]
cfgKey := strings.TrimSpace(entry.APIKey)
cfgBase := strings.TrimSpace(entry.BaseURL)
if attrKey != "" && attrBase != "" {
@@ -435,8 +467,8 @@ func (s *Service) resolveConfigClaudeKey(auth *coreauth.Auth) *config.ClaudeKey
}
}
if attrKey != "" {
- for i := range s.cfg.ClaudeKey {
- entry := &s.cfg.ClaudeKey[i]
+ for i := range cfg.ClaudeKey {
+ entry := &cfg.ClaudeKey[i]
if strings.EqualFold(strings.TrimSpace(entry.APIKey), attrKey) {
return entry
}
@@ -446,21 +478,33 @@ func (s *Service) resolveConfigClaudeKey(auth *coreauth.Auth) *config.ClaudeKey
}
func (s *Service) resolveConfigGeminiKey(auth *coreauth.Auth) *config.GeminiKey {
- if s == nil || s.cfg == nil {
+ return resolveConfigGeminiKeyForConfig(s.currentConfig(), auth)
+}
+
+func resolveConfigGeminiKeyForConfig(cfg *config.Config, auth *coreauth.Auth) *config.GeminiKey {
+ if cfg == nil {
return nil
}
- return s.resolveConfigGeminiKeyEntry(auth, s.cfg.GeminiKey)
+ return resolveConfigGeminiKeyEntryForConfig(cfg, auth, cfg.GeminiKey)
}
func (s *Service) resolveConfigInteractionsKey(auth *coreauth.Auth) *config.GeminiKey {
- if s == nil || s.cfg == nil {
+ return resolveConfigInteractionsKeyForConfig(s.currentConfig(), auth)
+}
+
+func resolveConfigInteractionsKeyForConfig(cfg *config.Config, auth *coreauth.Auth) *config.GeminiKey {
+ if cfg == nil {
return nil
}
- return s.resolveConfigGeminiKeyEntry(auth, s.cfg.InteractionsKey)
+ return resolveConfigGeminiKeyEntryForConfig(cfg, auth, cfg.InteractionsKey)
}
func (s *Service) resolveConfigGeminiKeyEntry(auth *coreauth.Auth, entries []config.GeminiKey) *config.GeminiKey {
- if auth == nil || s.cfg == nil {
+ return resolveConfigGeminiKeyEntryForConfig(s.currentConfig(), auth, entries)
+}
+
+func resolveConfigGeminiKeyEntryForConfig(cfg *config.Config, auth *coreauth.Auth, entries []config.GeminiKey) *config.GeminiKey {
+ if auth == nil || cfg == nil {
return nil
}
if entry := configEntryForAuthIndex(auth, entries); entry != nil {
@@ -489,10 +533,14 @@ func (s *Service) resolveConfigGeminiKeyEntry(auth *coreauth.Auth, entries []con
}
func (s *Service) resolveConfigVertexCompatKey(auth *coreauth.Auth) *config.VertexCompatKey {
- if auth == nil || s.cfg == nil {
+ return resolveConfigVertexCompatKeyForConfig(s.currentConfig(), auth)
+}
+
+func resolveConfigVertexCompatKeyForConfig(cfg *config.Config, auth *coreauth.Auth) *config.VertexCompatKey {
+ if auth == nil || cfg == nil {
return nil
}
- if entry := configEntryForAuthIndex(auth, s.cfg.VertexCompatAPIKey); entry != nil {
+ if entry := configEntryForAuthIndex(auth, cfg.VertexCompatAPIKey); entry != nil {
return entry
}
var attrKey, attrBase string
@@ -500,8 +548,8 @@ func (s *Service) resolveConfigVertexCompatKey(auth *coreauth.Auth) *config.Vert
attrKey = strings.TrimSpace(auth.Attributes["api_key"])
attrBase = strings.TrimSpace(auth.Attributes["base_url"])
}
- for i := range s.cfg.VertexCompatAPIKey {
- entry := &s.cfg.VertexCompatAPIKey[i]
+ for i := range cfg.VertexCompatAPIKey {
+ entry := &cfg.VertexCompatAPIKey[i]
cfgKey := strings.TrimSpace(entry.APIKey)
cfgBase := strings.TrimSpace(entry.BaseURL)
if attrKey != "" && strings.EqualFold(cfgKey, attrKey) {
@@ -515,8 +563,8 @@ func (s *Service) resolveConfigVertexCompatKey(auth *coreauth.Auth) *config.Vert
}
}
if attrKey != "" {
- for i := range s.cfg.VertexCompatAPIKey {
- entry := &s.cfg.VertexCompatAPIKey[i]
+ for i := range cfg.VertexCompatAPIKey {
+ entry := &cfg.VertexCompatAPIKey[i]
if strings.EqualFold(strings.TrimSpace(entry.APIKey), attrKey) {
return entry
}
@@ -526,24 +574,36 @@ func (s *Service) resolveConfigVertexCompatKey(auth *coreauth.Auth) *config.Vert
}
func (s *Service) resolveConfigCodexKey(auth *coreauth.Auth) *config.CodexKey {
- if s == nil || s.cfg == nil {
+ return resolveConfigCodexKeyForConfig(s.currentConfig(), auth)
+}
+
+func resolveConfigCodexKeyForConfig(cfg *config.Config, auth *coreauth.Auth) *config.CodexKey {
+ if cfg == nil {
return nil
}
- return resolveConfigCodexStyleKey(auth, s.cfg.CodexKey, true)
+ return resolveConfigCodexStyleKey(auth, cfg.CodexKey, true)
}
func (s *Service) resolveConfigXAIKey(auth *coreauth.Auth) *config.XAIKey {
- if s == nil || s.cfg == nil {
+ return resolveConfigXAIKeyForConfig(s.currentConfig(), auth)
+}
+
+func resolveConfigXAIKeyForConfig(cfg *config.Config, auth *coreauth.Auth) *config.XAIKey {
+ if cfg == nil {
return nil
}
- return resolveConfigCodexStyleKey(auth, s.cfg.XAIKey, false)
+ return resolveConfigCodexStyleKey(auth, cfg.XAIKey, false)
}
func (s *Service) resolveConfigMetaKey(auth *coreauth.Auth) *config.MetaKey {
- if s == nil || s.cfg == nil {
+ return resolveConfigMetaKeyForConfig(s.currentConfig(), auth)
+}
+
+func resolveConfigMetaKeyForConfig(cfg *config.Config, auth *coreauth.Auth) *config.MetaKey {
+ if cfg == nil {
return nil
}
- return resolveConfigCodexStyleKey(auth, s.cfg.MetaKey, false)
+ return resolveConfigCodexStyleKey(auth, cfg.MetaKey, false)
}
func resolveConfigCodexStyleKey(auth *coreauth.Auth, entries []config.CodexKey, validateIndexCredentials bool) *config.CodexKey {
@@ -578,7 +638,10 @@ func resolveConfigCodexStyleKey(auth *coreauth.Auth, entries []config.CodexKey,
}
func (s *Service) oauthExcludedModels(provider, authKind string) []string {
- cfg := s.cfg
+ return oauthExcludedModelsForConfig(s.currentConfig(), provider, authKind)
+}
+
+func oauthExcludedModelsForConfig(cfg *config.Config, provider, authKind string) []string {
if cfg == nil {
return nil
}
@@ -884,6 +947,106 @@ func buildVertexCompatConfigModels(entry *config.VertexCompatKey) []*ModelInfo {
return buildConfigModels(entry.Models, "google", "vertex", "vertex")
}
+func appendVertexServiceAccountModels(cfg *config.Config, a *coreauth.Auth, baseModels []*ModelInfo) []*ModelInfo {
+ if a == nil {
+ return baseModels
+ }
+ seen := make(map[string]struct{}, len(baseModels))
+ for _, model := range baseModels {
+ if model == nil {
+ continue
+ }
+ if id := strings.ToLower(strings.TrimSpace(model.ID)); id != "" {
+ seen[id] = struct{}{}
+ }
+ }
+
+ var extraNames []string
+ addName := func(raw string) {
+ trimmed := strings.TrimSpace(raw)
+ if trimmed == "" {
+ return
+ }
+ key := strings.ToLower(trimmed)
+ if _, exists := seen[key]; exists {
+ return
+ }
+ seen[key] = struct{}{}
+ extraNames = append(extraNames, trimmed)
+ }
+
+ if a.Attributes != nil {
+ if raw := strings.TrimSpace(a.Attributes["openapi_models"]); raw != "" {
+ for _, part := range strings.Split(raw, ",") {
+ addName(part)
+ }
+ }
+ }
+ if a.Metadata != nil {
+ for _, key := range []string{"openapi_models", "openapi-models", "models"} {
+ raw, ok := a.Metadata[key]
+ if !ok || raw == nil {
+ continue
+ }
+ switch v := raw.(type) {
+ case string:
+ for _, part := range strings.Split(v, ",") {
+ addName(part)
+ }
+ case []string:
+ for _, item := range v {
+ addName(item)
+ }
+ case []any:
+ for _, item := range v {
+ switch elem := item.(type) {
+ case string:
+ addName(elem)
+ case map[string]any:
+ if name, okName := elem["name"].(string); okName {
+ addName(name)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ for _, aliasEntry := range oauthModelAliasesForAuth(cfg, "vertex", a.Attributes) {
+ addName(aliasEntry.Name)
+ }
+
+ if len(extraNames) == 0 {
+ return baseModels
+ }
+
+ now := time.Now().Unix()
+ out := make([]*ModelInfo, 0, len(baseModels)+len(extraNames))
+ out = append(out, baseModels...)
+ for _, name := range extraNames {
+ ownedBy := "google"
+ if publisher, _, ok := strings.Cut(name, "/"); ok && strings.TrimSpace(publisher) != "" {
+ ownedBy = strings.TrimSpace(publisher)
+ }
+ info := &ModelInfo{
+ ID: name,
+ MetadataModelID: name,
+ Object: "model",
+ Created: now,
+ OwnedBy: ownedBy,
+ Type: "vertex",
+ DisplayName: name,
+ UserDefined: true,
+ Thinking: ®istry.ThinkingSupport{Levels: []string{"low", "medium", "high"}},
+ }
+ if resolved := modelconfig.ResolveModelInfo(name, "vertex", nil); resolved.Thinking != nil {
+ info.Thinking = resolved.Thinking
+ }
+ out = append(out, info)
+ }
+ return out
+}
+
func buildGeminiConfigModels(entry *config.GeminiKey) []*ModelInfo {
if entry == nil {
return nil
diff --git a/sdk/cliproxy/service_openai_compat_race_test.go b/sdk/cliproxy/service_openai_compat_race_test.go
new file mode 100644
index 00000000000..03e8706195e
--- /dev/null
+++ b/sdk/cliproxy/service_openai_compat_race_test.go
@@ -0,0 +1,353 @@
+package cliproxy
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+
+ "golang.org/x/crypto/bcrypt"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/api"
+ internalregistry "github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
+)
+
+func TestPutOpenAICompatConcurrentWithAuthFieldsPatch_Issue6559(t *testing.T) {
+ authDir := t.TempDir()
+ fileName := "codex-race-6559.json"
+ filePath := filepath.Join(authDir, fileName)
+
+ teamIDToken := "eyJhbGciOiJub25lIn0.eyJlbWFpbCI6ICJ1c2VyQGV4YW1wbGUuY29tIiwgImh0dHBzOi8vYXBpLm9wZW5haS5jb20vYXV0aCI6IHsiY2hhdGdwdF9wbGFuX3R5cGUiOiAidGVhbSIsICJjaGF0Z3B0X2FjY291bnRfaWQiOiAiYWNjLTEyMyJ9fQ.sig"
+ initialContent := fmt.Sprintf(`{"type":"codex","email":"user@example.com","access_token":"token-initial","id_token":%q}`, teamIDToken)
+ if errWrite := os.WriteFile(filePath, []byte(initialContent), 0o600); errWrite != nil {
+ t.Fatalf("write auth file: %v", errWrite)
+ }
+
+ secretKey := "test-secret"
+ hashedSecret, errHash := bcrypt.GenerateFromPassword([]byte(secretKey), bcrypt.MinCost)
+ if errHash != nil {
+ t.Fatalf("bcrypt hash failed: %v", errHash)
+ }
+
+ configPath := filepath.Join(authDir, "config.yaml")
+ initialYAML := fmt.Sprintf("port: 8317\nauth-dir: %q\nremote-management:\n allow-remote: true\n secret-key: %q\n", filepath.ToSlash(authDir), string(hashedSecret))
+ if errWrite := os.WriteFile(configPath, []byte(initialYAML), 0o600); errWrite != nil {
+ t.Fatalf("write config file: %v", errWrite)
+ }
+
+ reg := internalregistry.GetGlobalRegistry()
+ authID := fileName
+ reg.UnregisterClient(authID)
+ t.Cleanup(func() {
+ reg.UnregisterClient(authID)
+ })
+
+ cfg := &config.Config{
+ AuthDir: authDir,
+ RemoteManagement: config.RemoteManagement{
+ SecretKey: string(hashedSecret),
+ AllowRemote: true,
+ },
+ OpenAICompatibility: []config.OpenAICompatibility{
+ {
+ Name: "compat-initial",
+ BaseURL: "https://compat.example.com/v1",
+ APIKeyEntries: []config.OpenAICompatibilityAPIKey{
+ {APIKey: "sk-initial"},
+ },
+ Models: []config.OpenAICompatibilityModel{
+ {Name: "model-initial", Alias: "alias-initial"},
+ },
+ },
+ },
+ }
+
+ service, errBuild := NewBuilder().
+ WithConfig(cfg).
+ WithConfigPath(configPath).
+ Build()
+ if errBuild != nil {
+ t.Fatalf("Build() failed: %v", errBuild)
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+
+ watcherWrapper, errWatcher := service.watcherFactory(configPath, authDir, func(newCfg *config.Config) {
+ service.applyWatcherConfigUpdate(newCfg)
+ })
+ if errWatcher != nil {
+ t.Fatalf("watcherFactory failed: %v", errWatcher)
+ }
+ service.watcher = watcherWrapper
+ service.ensureAuthUpdateQueue(ctx)
+ watcherWrapper.SetAuthUpdateQueue(service.authUpdates)
+ watcherWrapper.SetConfig(service.cfg)
+ t.Cleanup(func() {
+ if service.authQueueStop != nil {
+ service.authQueueStop()
+ }
+ for _, a := range service.coreManager.List() {
+ if a != nil {
+ reg.UnregisterClient(a.ID)
+ }
+ }
+ })
+
+ server := api.NewServer(service.cfg, service.coreManager, service.accessManager, service.configPath, service.serverOptions...)
+ if server == nil {
+ t.Fatal("NewServer() returned nil")
+ }
+ service.server = server
+ handler := server.Handler()
+ service.coreManager.RegisterExecutor(&syncTestExecutor{})
+
+ auth := &coreauth.Auth{
+ ID: authID,
+ FileName: fileName,
+ Provider: "codex",
+ Status: coreauth.StatusActive,
+ Metadata: map[string]any{
+ "type": "codex",
+ "email": "user@example.com",
+ "access_token": "token-initial",
+ "id_token": teamIDToken,
+ },
+ Attributes: map[string]string{
+ "plan_type": "team",
+ "path": filePath,
+ },
+ }
+ if _, errRegister := service.coreManager.Register(ctx, auth); errRegister != nil {
+ t.Fatalf("register auth: %v", errRegister)
+ }
+ service.registerConfigAPIKeyAuths(ctx, service.cfg)
+
+ const iterations = 10
+ start := make(chan struct{})
+ var wg sync.WaitGroup
+ wg.Add(2)
+
+ go func() {
+ defer wg.Done()
+ <-start
+ for i := 0; i < iterations; i++ {
+ putBody := fmt.Sprintf(`[{"name":"compat-%d","base-url":"https://compat.example.com/v1","api-key-entries":[{"api-key":"sk-%d"}],"models":[{"name":"model-%d","alias":"alias-%d"}]}]`, i, i, i, i)
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPut, "/v0/management/openai-compatibility", strings.NewReader(putBody))
+ req.Header.Set("Authorization", "Bearer "+secretKey)
+ req.Header.Set("Content-Type", "application/json")
+ handler.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Errorf("PUT openai-compatibility failed: status=%d body=%s", rec.Code, rec.Body.String())
+ return
+ }
+ }
+ }()
+
+ go func() {
+ defer wg.Done()
+ <-start
+ for i := 0; i < iterations; i++ {
+ patchBody := fmt.Sprintf(`{"name":%q,"prefix":"pfx-%d"}`, fileName, i)
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPatch, "/v0/management/auth-files/fields", strings.NewReader(patchBody))
+ req.Header.Set("Authorization", "Bearer "+secretKey)
+ req.Header.Set("Content-Type", "application/json")
+ handler.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Errorf("PATCH auth-files/fields failed: status=%d body=%s", rec.Code, rec.Body.String())
+ return
+ }
+ }
+ }()
+
+ close(start)
+ wg.Wait()
+
+ // Ensure the final persisted configuration is reloaded through the watcher pipeline
+ // and verify both the patched auth prefix and final openai-compatibility model are registered.
+ service.reloadConfigFromWatcher()
+ service.registerConfigAPIKeyAuths(ctx, service.currentConfig())
+
+ finalPrefixModel := fmt.Sprintf("pfx-%d/gpt-6-astra", iterations-1)
+ if !reg.ClientSupportsModel(authID, finalPrefixModel) {
+ t.Fatalf("expected auth %s to support final prefixed model %q", authID, finalPrefixModel)
+ }
+
+ finalCompatAlias := fmt.Sprintf("alias-%d", iterations-1)
+ foundCompatModel := false
+ for _, a := range service.coreManager.List() {
+ if a != nil && reg.ClientSupportsModel(a.ID, finalCompatAlias) {
+ foundCompatModel = true
+ break
+ }
+ }
+ if !foundCompatModel {
+ t.Fatalf("expected registered openai-compatibility auth to support final model %q", finalCompatAlias)
+ }
+}
+
+func TestPutOpenAICompatConcurrentWithConfigReloadAndAuthUpdate_Issue6559(t *testing.T) {
+ authDir := t.TempDir()
+ fileName := "codex-reload-race-6559.json"
+ filePath := filepath.Join(authDir, fileName)
+
+ teamIDToken := "eyJhbGciOiJub25lIn0.eyJlbWFpbCI6ICJ1c2VyQGV4YW1wbGUuY29tIiwgImh0dHBzOi8vYXBpLm9wZW5haS5jb20vYXV0aCI6IHsiY2hhdGdwdF9wbGFuX3R5cGUiOiAidGVhbSIsICJjaGF0Z3B0X2FjY291bnRfaWQiOiAiYWNjLTEyMyJ9fQ.sig"
+ initialContent := fmt.Sprintf(`{"type":"codex","email":"user@example.com","access_token":"token-initial","id_token":%q}`, teamIDToken)
+ if errWrite := os.WriteFile(filePath, []byte(initialContent), 0o600); errWrite != nil {
+ t.Fatalf("write auth file: %v", errWrite)
+ }
+
+ configPath := filepath.Join(authDir, "config.yaml")
+ if errWrite := os.WriteFile(configPath, []byte("port: 8317\n"), 0o600); errWrite != nil {
+ t.Fatalf("write config file: %v", errWrite)
+ }
+
+ reg := internalregistry.GetGlobalRegistry()
+ authID := fileName
+ reg.UnregisterClient(authID)
+ t.Cleanup(func() {
+ reg.UnregisterClient(authID)
+ })
+
+ secretKey := "test-secret"
+ hashedSecret, errHash := bcrypt.GenerateFromPassword([]byte(secretKey), bcrypt.MinCost)
+ if errHash != nil {
+ t.Fatalf("bcrypt hash failed: %v", errHash)
+ }
+
+ buildCfg := func(iter int) *config.Config {
+ return &config.Config{
+ AuthDir: authDir,
+ RemoteManagement: config.RemoteManagement{
+ SecretKey: string(hashedSecret),
+ AllowRemote: true,
+ },
+ OpenAICompatibility: []config.OpenAICompatibility{
+ {
+ Name: fmt.Sprintf("compat-reload-%d", iter),
+ BaseURL: "https://compat.example.com/v1",
+ APIKeyEntries: []config.OpenAICompatibilityAPIKey{
+ {APIKey: fmt.Sprintf("sk-reload-%d", iter)},
+ },
+ Models: []config.OpenAICompatibilityModel{
+ {Name: fmt.Sprintf("model-reload-%d", iter), Alias: fmt.Sprintf("alias-reload-%d", iter)},
+ },
+ },
+ },
+ }
+ }
+
+ service, errBuild := NewBuilder().
+ WithConfig(buildCfg(0)).
+ WithConfigPath(configPath).
+ Build()
+ if errBuild != nil {
+ t.Fatalf("Build() failed: %v", errBuild)
+ }
+ t.Cleanup(func() {
+ for _, a := range service.coreManager.List() {
+ if a != nil {
+ reg.UnregisterClient(a.ID)
+ }
+ }
+ })
+
+ server := api.NewServer(service.cfg, service.coreManager, service.accessManager, service.configPath, service.serverOptions...)
+ if server == nil {
+ t.Fatal("NewServer() returned nil")
+ }
+ service.server = server
+ handler := server.Handler()
+ service.coreManager.RegisterExecutor(&syncTestExecutor{})
+
+ auth := &coreauth.Auth{
+ ID: authID,
+ FileName: fileName,
+ Provider: "codex",
+ Status: coreauth.StatusActive,
+ Metadata: map[string]any{
+ "type": "codex",
+ "email": "user@example.com",
+ "access_token": "token-initial",
+ "id_token": teamIDToken,
+ },
+ Attributes: map[string]string{
+ "plan_type": "team",
+ "path": filePath,
+ },
+ }
+ if _, errRegister := service.coreManager.Register(context.Background(), auth); errRegister != nil {
+ t.Fatalf("register auth: %v", errRegister)
+ }
+
+ const iterations = 10
+ start := make(chan struct{})
+ var wg sync.WaitGroup
+ wg.Add(3)
+
+ go func() {
+ defer wg.Done()
+ <-start
+ for i := 0; i < iterations; i++ {
+ putBody := fmt.Sprintf(`[{"name":"compat-%d","base-url":"https://compat.example.com/v1","api-key-entries":[{"api-key":"sk-%d"}],"models":[{"name":"model-%d","alias":"alias-%d"}]}]`, i, i, i, i)
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPut, "/v0/management/openai-compatibility", strings.NewReader(putBody))
+ req.Header.Set("Authorization", "Bearer "+secretKey)
+ req.Header.Set("Content-Type", "application/json")
+ handler.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Errorf("PUT openai-compatibility failed: status=%d body=%s", rec.Code, rec.Body.String())
+ return
+ }
+ }
+ }()
+
+ go func() {
+ defer wg.Done()
+ <-start
+ for i := 0; i < iterations; i++ {
+ patchBody := fmt.Sprintf(`{"name":%q,"prefix":"pfx-%d"}`, fileName, i)
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPatch, "/v0/management/auth-files/fields", strings.NewReader(patchBody))
+ req.Header.Set("Authorization", "Bearer "+secretKey)
+ req.Header.Set("Content-Type", "application/json")
+ handler.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Errorf("PATCH auth-files/fields failed: status=%d body=%s", rec.Code, rec.Body.String())
+ return
+ }
+ }
+ }()
+
+ go func() {
+ defer wg.Done()
+ <-start
+ for i := 0; i < iterations; i++ {
+ service.applyConfigUpdateWithAuthSynthesis(context.Background(), buildCfg(i+1), true)
+ }
+ }()
+
+ close(start)
+ wg.Wait()
+
+ finalReloadAlias := fmt.Sprintf("alias-reload-%d", iterations)
+ foundReloadModel := false
+ for _, a := range service.coreManager.List() {
+ if a != nil && reg.ClientSupportsModel(a.ID, finalReloadAlias) {
+ foundReloadModel = true
+ break
+ }
+ }
+ if !foundReloadModel {
+ t.Fatalf("expected registered openai-compatibility auth to support final reloaded model %q", finalReloadAlias)
+ }
+}
diff --git a/sdk/cliproxy/service_plugins.go b/sdk/cliproxy/service_plugins.go
index 237b2ee41d0..5b7fb89c138 100644
--- a/sdk/cliproxy/service_plugins.go
+++ b/sdk/cliproxy/service_plugins.go
@@ -74,8 +74,8 @@ func (s *Service) registerPluginAuthParser() {
parser = s.pluginHost
}
sdkAuth.RegisterPluginAuthParser(parser)
- if s != nil && s.watcher != nil {
- s.watcher.SetPluginAuthParser(parser)
+ if watcher := s.currentWatcher(); watcher != nil {
+ watcher.SetPluginAuthParser(parser)
}
}
diff --git a/sdk/cliproxy/service_run_startup_race_test.go b/sdk/cliproxy/service_run_startup_race_test.go
new file mode 100644
index 00000000000..fe59a816b6c
--- /dev/null
+++ b/sdk/cliproxy/service_run_startup_race_test.go
@@ -0,0 +1,520 @@
+package cliproxy
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "os"
+ "path/filepath"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ internalregistry "github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+)
+
+func TestServiceRun_WatcherReloadConcurrentWithStartupModelRegistration_Issue6583(t *testing.T) {
+ ln, errListen := net.Listen("tcp", "127.0.0.1:0")
+ if errListen != nil {
+ t.Fatalf("failed to listen: %v", errListen)
+ }
+ port := ln.Addr().(*net.TCPAddr).Port
+ if errClose := ln.Close(); errClose != nil {
+ t.Fatalf("failed to close listener: %v", errClose)
+ }
+
+ authDir := t.TempDir()
+ fileName := "codex-startup-race-6583.json"
+ filePath := filepath.Join(authDir, fileName)
+ teamIDToken := "eyJhbGciOiJub25lIn0.eyJlbWFpbCI6ICJ1c2VyQGV4YW1wbGUuY29tIiwgImh0dHBzOi8vYXBpLm9wZW5haS5jb20vYXV0aCI6IHsiY2hhdGdwdF9wbGFuX3R5cGUiOiAidGVhbSIsICJjaGF0Z3B0X2FjY291bnRfaWQiOiAiYWNjLTEyMyJ9fQ.sig"
+ initialAuthJSON := fmt.Sprintf(`{"type":"codex","email":"user@example.com","access_token":"token-initial","id_token":%q,"prefix":"acct"}`, teamIDToken)
+ if errWrite := os.WriteFile(filePath, []byte(initialAuthJSON), 0o600); errWrite != nil {
+ t.Fatalf("write auth file: %v", errWrite)
+ }
+
+ cfgPath := filepath.Join(authDir, "config.yaml")
+ if errWrite := os.WriteFile(cfgPath, []byte("{}"), 0o600); errWrite != nil {
+ t.Fatalf("write dummy config: %v", errWrite)
+ }
+
+ initialCfg := &config.Config{
+ SDKConfig: config.SDKConfig{
+ ForceModelPrefix: false,
+ },
+ Host: "127.0.0.1",
+ Port: port,
+ AuthDir: authDir,
+ OpenAICompatibility: []config.OpenAICompatibility{
+ {
+ Name: "compat-startup",
+ BaseURL: "https://compat.example.com/v1",
+ APIKeyEntries: []config.OpenAICompatibilityAPIKey{
+ {APIKey: "sk-startup"},
+ },
+ Models: []config.OpenAICompatibilityModel{
+ {Name: "model-initial", Alias: "alias-initial"},
+ },
+ },
+ },
+ }
+
+ updatedCfg := &config.Config{
+ SDKConfig: config.SDKConfig{
+ ForceModelPrefix: true,
+ },
+ Host: "127.0.0.1",
+ Port: port,
+ AuthDir: authDir,
+ OpenAICompatibility: []config.OpenAICompatibility{
+ {
+ Name: "compat-startup",
+ BaseURL: "https://compat.example.com/v1",
+ APIKeyEntries: []config.OpenAICompatibilityAPIKey{
+ {APIKey: "sk-startup"},
+ },
+ Models: []config.OpenAICompatibilityModel{
+ {Name: "model-updated", Alias: "alias-updated"},
+ },
+ },
+ },
+ OAuthModelAlias: map[string][]config.OAuthModelAlias{
+ "codex": {
+ {Name: "gpt-6-astra", Alias: "astra-aliased", Fork: true},
+ },
+ },
+ }
+
+ reg := internalregistry.GetGlobalRegistry()
+ secondAuthID := "codex-added-during-batch-6583"
+ reg.UnregisterClient(fileName)
+ reg.UnregisterClient(secondAuthID)
+ t.Cleanup(func() {
+ reg.UnregisterClient(fileName)
+ reg.UnregisterClient(secondAuthID)
+ modelRegistrationTaskHook = nil
+ modelRegistrationTaskPostRunHook = nil
+ })
+
+ var service *Service
+ var reloadFn func(*config.Config)
+ var reloadWg sync.WaitGroup
+ watcherStarted := make(chan struct{})
+ var watcherStartedOnce sync.Once
+ startupBatchEntered := make(chan struct{})
+ var startupBatchEnteredOnce sync.Once
+
+ var watcherStartReached atomic.Bool
+ var addedSecondAuth atomic.Bool
+ modelRegistrationTaskPostRunHook = func(authID string) {
+ if !watcherStartReached.Load() || authID != fileName {
+ return
+ }
+ if addedSecondAuth.CompareAndSwap(false, true) && service != nil && service.coreManager != nil {
+ _, _ = service.coreManager.Register(context.Background(), &coreauth.Auth{
+ ID: secondAuthID,
+ Provider: "codex",
+ Prefix: "acct2",
+ Status: coreauth.StatusActive,
+ Attributes: map[string]string{
+ "auth_kind": "oauth",
+ "plan_type": "team",
+ },
+ })
+ if reloadFn != nil {
+ clonedReload := updatedCfg.CloneForRuntime()
+ reloadWg.Add(1)
+ go func() {
+ defer reloadWg.Done()
+ reloadFn(clonedReload)
+ }()
+ }
+ // Do not block inside the hook on commit completion so the second pass of
+ // registerModelsForAuthBatch in Service.Run races freely against reloadFn.
+ startupBatchEnteredOnce.Do(func() { close(startupBatchEntered) })
+ }
+ }
+
+ customWatcherFactory := func(configPath, dir string, reload func(*config.Config)) (*WatcherWrapper, error) {
+ reloadFn = reload
+ clonedFactoryReload := updatedCfg.CloneForRuntime()
+ reloadWg.Add(1)
+ go func() {
+ defer reloadWg.Done()
+ reload(clonedFactoryReload)
+ }()
+ return &WatcherWrapper{
+ start: func(ctx context.Context) error {
+ watcherStartReached.Store(true)
+ watcherStartedOnce.Do(func() { close(watcherStarted) })
+ return nil
+ },
+ stop: func() error {
+ return nil
+ },
+ setConfig: func(cfg *config.Config) {
+ if cfg != nil {
+ _ = cfg.AuthDir
+ }
+ },
+ setPluginAuthParser: func(parser PluginAuthParser) {},
+ }, nil
+ }
+
+ var errBuild error
+ service, errBuild = NewBuilder().
+ WithConfig(initialCfg).
+ WithConfigPath(cfgPath).
+ WithWatcherFactory(customWatcherFactory).
+ Build()
+ if errBuild != nil {
+ t.Fatalf("Build() failed: %v", errBuild)
+ }
+
+ waitReloadWg := func() bool {
+ done := make(chan struct{})
+ go func() {
+ reloadWg.Wait()
+ close(done)
+ }()
+ select {
+ case <-done:
+ return true
+ case <-time.After(5 * time.Second):
+ return false
+ }
+ }
+
+ waitRuntimeSettled := func() bool {
+ done := make(chan struct{})
+ go func() {
+ service.configRuntimeMu.Lock()
+ service.configRuntimeMu.Unlock()
+ close(done)
+ }()
+ select {
+ case <-done:
+ return true
+ case <-time.After(5 * time.Second):
+ return false
+ }
+ }
+
+ runCtx, cancel := context.WithCancel(context.Background())
+ runErrCh := make(chan error, 1)
+ var stopOnce sync.Once
+ stopRun := func() {
+ stopOnce.Do(func() {
+ cancel()
+ if !waitReloadWg() {
+ t.Errorf("timed out waiting for reload goroutines to exit")
+ }
+ select {
+ case errRun := <-runErrCh:
+ if errRun != nil && !errors.Is(errRun, context.Canceled) {
+ t.Errorf("unexpected Run error: %v", errRun)
+ }
+ case <-time.After(5 * time.Second):
+ t.Errorf("timed out waiting for Run to exit")
+ }
+ })
+ }
+ t.Cleanup(func() {
+ stopRun()
+ for _, a := range service.coreManager.List() {
+ if a != nil {
+ reg.UnregisterClient(a.ID)
+ }
+ }
+ })
+
+ go func() {
+ runErrCh <- service.Run(runCtx)
+ }()
+
+ select {
+ case <-watcherStarted:
+ case <-time.After(5 * time.Second):
+ t.Fatal("timed out waiting for watcher Start callback")
+ }
+
+ select {
+ case <-startupBatchEntered:
+ case <-time.After(5 * time.Second):
+ t.Fatal("timed out waiting for Service.Run startup batch model registration hook")
+ }
+
+ if !waitReloadWg() {
+ t.Fatal("timed out waiting for concurrent watcher reload to complete")
+ }
+ if !waitRuntimeSettled() {
+ t.Fatal("timed out waiting for Service.Run and config runtime updates to settle")
+ }
+ if reloadFn == nil {
+ t.Fatal("expected watcherFactory to provide reload callback")
+ }
+ if !addedSecondAuth.Load() {
+ t.Fatal("expected startup batch model registration hook to add secondAuthID")
+ }
+
+ if !reg.ClientSupportsModel(secondAuthID, "acct2/astra-aliased") {
+ t.Fatalf("expected secondAuthID %q to support aliased prefixed model %q from updatedCfg", secondAuthID, "acct2/astra-aliased")
+ }
+ if reg.ClientSupportsModel(secondAuthID, "gpt-6-astra") {
+ t.Fatalf("expected ForceModelPrefix=true from updatedCfg to suppress unprefixed model for %q", secondAuthID)
+ }
+ foundUpdatedCompat := false
+ for _, a := range service.coreManager.List() {
+ if a != nil && reg.ClientSupportsModel(a.ID, "alias-updated") {
+ foundUpdatedCompat = true
+ }
+ if a != nil && reg.ClientSupportsModel(a.ID, "alias-initial") {
+ t.Fatalf("expected stale startup compat model alias-initial to not overwrite updatedCfg on %q", a.ID)
+ }
+ }
+ if !foundUpdatedCompat {
+ t.Fatal("expected registered openai-compatibility auth to support alias-updated from updatedCfg")
+ }
+
+ stopRun()
+}
+
+func TestRegisterModelsForAuthBatch_ConsistentConfigSnapshotAcrossConcurrentCommit_Issue6583(t *testing.T) {
+ ctx := context.Background()
+ manager := coreauth.NewManager(nil, &coreauth.RoundRobinSelector{}, nil)
+ manager.RegisterExecutor(mockBatchTestExecutor{provider: "compat-snap"})
+
+ initialCfg := &config.Config{
+ SDKConfig: config.SDKConfig{
+ ForceModelPrefix: false,
+ },
+ OpenAICompatibility: []config.OpenAICompatibility{
+ {
+ Name: "compat-snap",
+ BaseURL: "https://compat.example.com/v1",
+ APIKeyEntries: []config.OpenAICompatibilityAPIKey{
+ {APIKey: "sk-snap"},
+ },
+ Models: []config.OpenAICompatibilityModel{
+ {Name: "model-v1", Alias: "alias-v1"},
+ },
+ },
+ },
+ }
+
+ updatedCfg := &config.Config{
+ SDKConfig: config.SDKConfig{
+ ForceModelPrefix: true,
+ },
+ OpenAICompatibility: []config.OpenAICompatibility{
+ {
+ Name: "compat-snap",
+ BaseURL: "https://compat.example.com/v1",
+ APIKeyEntries: []config.OpenAICompatibilityAPIKey{
+ {APIKey: "sk-snap"},
+ },
+ Models: []config.OpenAICompatibilityModel{
+ {Name: "model-v2", Alias: "alias-v2"},
+ },
+ },
+ },
+ }
+
+ service := &Service{
+ cfg: initialCfg.CloneForRuntime(),
+ coreManager: manager,
+ }
+
+ authID := "compat-auth-6583"
+ auth := &coreauth.Auth{
+ ID: authID,
+ Provider: "compat-snap",
+ Prefix: "pfx",
+ Status: coreauth.StatusActive,
+ Attributes: map[string]string{
+ "auth_kind": "apikey",
+ "compat_name": "compat-snap",
+ "provider_key": "compat-snap",
+ coreauth.AttributeSource: "config:compat-snap[token-1]",
+ coreauth.AttributeConfigIndex: "0",
+ },
+ }
+ if _, errRegister := manager.Register(ctx, auth); errRegister != nil {
+ t.Fatalf("failed to register auth: %v", errRegister)
+ }
+
+ reg := internalregistry.GetGlobalRegistry()
+ reg.UnregisterClient(authID)
+ t.Cleanup(func() {
+ reg.UnregisterClient(authID)
+ modelRegistrationTaskHook = nil
+ })
+
+ var committed atomic.Bool
+ modelRegistrationTaskHook = func() {
+ if committed.CompareAndSwap(false, true) {
+ service.commitConfigUpdate(updatedCfg.CloneForRuntime())
+ }
+ }
+
+ service.registerModelsForAuthBatch(ctx, manager.List())
+
+ // Because commitConfigUpdate ran before registerModelsForAuthWithCache read the config,
+ // registration must not mix compatCache from initialCfg (alias-v1) with ForceModelPrefix from updatedCfg.
+ if reg.ClientSupportsModel(authID, "pfx/alias-v1") && !reg.ClientSupportsModel(authID, "alias-v1") {
+ t.Fatalf("detected torn config snapshot: registered initialCfg model alias-v1 with updatedCfg ForceModelPrefix=true (only pfx/alias-v1 present)")
+ }
+ if !reg.ClientSupportsModel(authID, "pfx/alias-v2") {
+ t.Fatalf("expected auth %q to support pfx/alias-v2 from updatedCfg", authID)
+ }
+}
+
+func TestRegisterModelsForAuthBatch_ConcurrentCommitConfigUpdateRace_Issue6583(t *testing.T) {
+ ctx := context.Background()
+ manager := coreauth.NewManager(nil, &coreauth.RoundRobinSelector{}, nil)
+ manager.RegisterExecutor(mockBatchTestExecutor{provider: "codex"})
+ manager.RegisterExecutor(mockBatchTestExecutor{provider: "claude"})
+ manager.RegisterExecutor(mockBatchTestExecutor{provider: "compat-race"})
+
+ makeCfg := func(forcePrefix bool, aliasSuffix int) *config.Config {
+ return &config.Config{
+ SDKConfig: config.SDKConfig{
+ ForceModelPrefix: forcePrefix,
+ },
+ OAuthExcludedModels: map[string][]string{
+ "codex": {"gpt-5.5"},
+ },
+ OAuthModelAlias: map[string][]config.OAuthModelAlias{
+ "codex": {
+ {Name: "gpt-6-astra", Alias: fmt.Sprintf("astra-alias-%d", aliasSuffix), Fork: true},
+ },
+ },
+ OAuthSettings: map[string][]config.OAuthModelSetting{
+ "codex": {
+ {Name: "gpt-6-astra", MaxContextLength: 128000},
+ },
+ },
+ ClaudeKey: []config.ClaudeKey{
+ {
+ APIKey: "sk-claude-race",
+ Models: []config.ClaudeModel{
+ {Name: "claude-opus-4-6", Alias: fmt.Sprintf("claude-alias-%d", aliasSuffix)},
+ },
+ },
+ },
+ OpenAICompatibility: []config.OpenAICompatibility{
+ {
+ Name: "compat-race",
+ BaseURL: "https://compat.example.com/v1",
+ APIKeyEntries: []config.OpenAICompatibilityAPIKey{
+ {APIKey: "sk-compat-race"},
+ },
+ Models: []config.OpenAICompatibilityModel{
+ {Name: "compat-model", Alias: fmt.Sprintf("compat-alias-%d", aliasSuffix)},
+ },
+ },
+ },
+ }
+ }
+
+ service := &Service{
+ cfg: makeCfg(false, 0),
+ coreManager: manager,
+ }
+
+ codexFirstID := "codex-first-pass-6583"
+ claudeID := "claude-apikey-6583"
+ compatID := "compat-apikey-6583"
+ codexSecondID := "codex-second-pass-6583"
+
+ initialAuths := []*coreauth.Auth{
+ {
+ ID: codexFirstID,
+ Provider: "codex",
+ Prefix: "c1",
+ Status: coreauth.StatusActive,
+ Attributes: map[string]string{
+ "auth_kind": "oauth",
+ "plan_type": "team",
+ },
+ },
+ {
+ ID: claudeID,
+ Provider: "claude",
+ Prefix: "cl",
+ Status: coreauth.StatusActive,
+ Attributes: map[string]string{
+ "auth_kind": "apikey",
+ coreauth.AttributeAPIKey: "sk-claude-race",
+ coreauth.AttributeSource: "config:claude[token-1]",
+ coreauth.AttributeConfigIndex: "0",
+ },
+ },
+ {
+ ID: compatID,
+ Provider: "compat-race",
+ Prefix: "cp",
+ Status: coreauth.StatusActive,
+ Attributes: map[string]string{
+ "auth_kind": "apikey",
+ "compat_name": "compat-race",
+ "provider_key": "compat-race",
+ coreauth.AttributeSource: "config:compat-race[token-1]",
+ coreauth.AttributeConfigIndex: "0",
+ },
+ },
+ }
+
+ reg := internalregistry.GetGlobalRegistry()
+ for _, a := range initialAuths {
+ reg.UnregisterClient(a.ID)
+ if _, errRegister := manager.Register(ctx, a); errRegister != nil {
+ t.Fatalf("failed to register auth %s: %v", a.ID, errRegister)
+ }
+ }
+ reg.UnregisterClient(codexSecondID)
+ t.Cleanup(func() {
+ for _, a := range initialAuths {
+ reg.UnregisterClient(a.ID)
+ }
+ reg.UnregisterClient(codexSecondID)
+ modelRegistrationTaskPostRunHook = nil
+ })
+
+ startCommits := make(chan struct{})
+ var startCommitsOnce sync.Once
+ modelRegistrationTaskPostRunHook = func(authID string) {
+ if authID == codexFirstID {
+ _, _ = manager.Register(ctx, &coreauth.Auth{
+ ID: codexSecondID,
+ Provider: "codex",
+ Prefix: "c2",
+ Status: coreauth.StatusActive,
+ Attributes: map[string]string{
+ "auth_kind": "oauth",
+ "plan_type": "team",
+ },
+ })
+ startCommitsOnce.Do(func() { close(startCommits) })
+ }
+ }
+
+ commitDone := make(chan struct{})
+ go func() {
+ defer close(commitDone)
+ <-startCommits
+ for i := 1; i <= 20; i++ {
+ service.commitConfigUpdate(makeCfg(i%2 == 1, i))
+ }
+ }()
+
+ service.registerModelsForAuthBatch(ctx, manager.List())
+ select {
+ case <-commitDone:
+ case <-time.After(5 * time.Second):
+ t.Fatal("timed out waiting for concurrent commitConfigUpdate loop")
+ }
+}
diff --git a/sdk/cliproxy/service_vertex_openapi_models_test.go b/sdk/cliproxy/service_vertex_openapi_models_test.go
new file mode 100644
index 00000000000..134cb85a263
--- /dev/null
+++ b/sdk/cliproxy/service_vertex_openapi_models_test.go
@@ -0,0 +1,119 @@
+package cliproxy
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/watcher/synthesizer"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+)
+
+func TestRegisterModelsForAuth_VertexServiceAccount_OpenAPIModelsAndAliases(t *testing.T) {
+ cfg := &config.Config{
+ OAuthModelAlias: map[string][]config.OAuthModelAlias{
+ "vertex": {
+ {
+ Name: "deepseek-ai/deepseek-v4.1-flash-maas",
+ Alias: "deepseek-flash",
+ },
+ },
+ },
+ }
+ cfg.SanitizeOAuthModelAlias()
+
+ manager := coreauth.NewManager(nil, nil, nil)
+ manager.SetOAuthModelAlias(cfg.OAuthModelAlias)
+ service := &Service{
+ cfg: cfg,
+ coreManager: manager,
+ }
+
+ auth := &coreauth.Auth{
+ ID: "vertex-sa-maas-test",
+ Provider: "vertex",
+ Status: coreauth.StatusActive,
+ Attributes: map[string]string{
+ "auth_kind": "oauth",
+ },
+ Metadata: map[string]any{
+ "type": "vertex",
+ "project_id": "test-proj",
+ "location": "global",
+ "openapi_models": []any{
+ "qwen/qwen3-coder-480b-a35b-instruct-maas",
+ },
+ },
+ }
+ t.Cleanup(func() {
+ GlobalModelRegistry().UnregisterClient(auth.ID)
+ })
+
+ registered, errReg := service.coreManager.Register(context.Background(), auth)
+ if errReg != nil {
+ t.Fatalf("Register auth error: %v", errReg)
+ }
+ service.registerModelsForAuth(context.Background(), registered)
+
+ if !GlobalModelRegistry().ClientSupportsModel(auth.ID, "deepseek-flash") {
+ t.Fatalf("expected Vertex SA auth to support aliased MaaS model 'deepseek-flash'")
+ }
+ if !GlobalModelRegistry().ClientSupportsModel(auth.ID, "qwen/qwen3-coder-480b-a35b-instruct-maas") {
+ t.Fatalf("expected Vertex SA auth to support openapi_models entry 'qwen/qwen3-coder-480b-a35b-instruct-maas'")
+ }
+ if !GlobalModelRegistry().ClientSupportsModel(auth.ID, "gemini-2.5-pro") {
+ t.Fatalf("expected Vertex SA auth to retain native Gemini model 'gemini-2.5-pro'")
+ }
+ if gotUpstream := service.coreManager.ResolveExecutionModel(registered, "deepseek-flash"); gotUpstream != "deepseek-ai/deepseek-v4.1-flash-maas" {
+ t.Fatalf("ResolveExecutionModel(deepseek-flash) = %q, want deepseek-ai/deepseek-v4.1-flash-maas", gotUpstream)
+ }
+}
+
+func TestSynthesizeAuthFile_VertexModelsAndAliases(t *testing.T) {
+ rawJSON := []byte(`{
+ "type": "vertex",
+ "project_id": "test-proj",
+ "location": "global",
+ "models": [
+ {"name": "deepseek-ai/deepseek-v4.1-flash-maas", "alias": "ds-maas"},
+ {"name": "xai/grok-4-maas"}
+ ],
+ "service_account": {"client_email": "sa@test.iam.gserviceaccount.com"}
+ }`)
+
+ auths, errSynth := synthesizer.SynthesizeAuthFile(&synthesizer.SynthesisContext{
+ Now: time.Unix(1700000000, 0),
+ }, "/tmp/vertex-test.json", rawJSON)
+ if errSynth != nil {
+ t.Fatalf("SynthesizeAuthFile error: %v", errSynth)
+ }
+ if len(auths) != 1 {
+ t.Fatalf("expected 1 synthesized auth, got %d", len(auths))
+ }
+
+ service := &Service{
+ cfg: &config.Config{},
+ coreManager: coreauth.NewManager(nil, nil, nil),
+ }
+ auth := auths[0]
+ t.Cleanup(func() {
+ GlobalModelRegistry().UnregisterClient(auth.ID)
+ })
+
+ registered, errReg := service.coreManager.Register(context.Background(), auth)
+ if errReg != nil {
+ t.Fatalf("Register auth error: %v", errReg)
+ }
+ service.registerModelsForAuth(context.Background(), registered)
+
+ if !GlobalModelRegistry().ClientSupportsModel(auth.ID, "ds-maas") {
+ t.Fatalf("expected synthesized Vertex SA auth to support alias 'ds-maas'")
+ }
+ if !GlobalModelRegistry().ClientSupportsModel(auth.ID, "xai/grok-4-maas") {
+ t.Fatalf("expected synthesized Vertex SA auth to support direct model 'xai/grok-4-maas'")
+ }
+ if gotUpstream := service.coreManager.ResolveExecutionModel(registered, "ds-maas"); gotUpstream != "deepseek-ai/deepseek-v4.1-flash-maas" {
+ t.Fatalf("ResolveExecutionModel(ds-maas) = %q, want deepseek-ai/deepseek-v4.1-flash-maas", gotUpstream)
+ }
+}
diff --git a/sdk/cliproxy/types.go b/sdk/cliproxy/types.go
index bf02660dc9e..84bf1a1a8ac 100644
--- a/sdk/cliproxy/types.go
+++ b/sdk/cliproxy/types.go
@@ -5,6 +5,7 @@ package cliproxy
import (
"context"
+ "fmt"
"github.com/router-for-me/CLIProxyAPI/v8/internal/watcher"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
@@ -106,6 +107,7 @@ type WatcherWrapper struct {
setConfig func(cfg *config.Config)
snapshotAuths func() []*coreauth.Auth
+ snapshotAuthFile func(path string) ([]*coreauth.Auth, error)
setUpdateQueue func(queue chan<- watcher.AuthUpdate)
dispatchRuntimeUpdate func(update watcher.AuthUpdate) bool
dispatchPersistedAuth func(update watcher.AuthUpdate) bool
@@ -210,6 +212,14 @@ func (w *WatcherWrapper) SnapshotAuths() []*coreauth.Auth {
return w.snapshotAuths()
}
+// SnapshotAuthFile re-synthesizes a single file and preserves parsing errors.
+func (w *WatcherWrapper) SnapshotAuthFile(path string) ([]*coreauth.Auth, error) {
+ if w == nil || w.snapshotAuthFile == nil {
+ return nil, fmt.Errorf("file auth snapshot is unavailable")
+ }
+ return w.snapshotAuthFile(path)
+}
+
// SetAuthUpdateQueue registers the channel used to propagate auth updates.
func (w *WatcherWrapper) SetAuthUpdateQueue(queue chan<- watcher.AuthUpdate) {
if w == nil || w.setUpdateQueue == nil {
diff --git a/sdk/cliproxy/watcher.go b/sdk/cliproxy/watcher.go
index d8bf3fa4891..4635659e317 100644
--- a/sdk/cliproxy/watcher.go
+++ b/sdk/cliproxy/watcher.go
@@ -25,6 +25,9 @@ func defaultWatcherFactory(configPath, authDir string, reload func(*config.Confi
w.SetConfig(cfg)
},
snapshotAuths: func() []*coreauth.Auth { return w.SnapshotCoreAuths() },
+ snapshotAuthFile: func(path string) ([]*coreauth.Auth, error) {
+ return w.SnapshotAuthFile(path)
+ },
setUpdateQueue: func(queue chan<- watcher.AuthUpdate) {
w.SetAuthUpdateQueue(queue)
},
diff --git a/sdk/config/config.go b/sdk/config/config.go
index 47f477d6bec..e584ab82f78 100644
--- a/sdk/config/config.go
+++ b/sdk/config/config.go
@@ -14,6 +14,7 @@ type ModelCatalogs = internalconfig.ModelCatalogs
type ClientConfig = internalconfig.ClientConfig
type CodexClientConfig = internalconfig.CodexClientConfig
+type CodexWebSearchResultCacheConfig = internalconfig.CodexWebSearchResultCacheConfig
type StreamingConfig = internalconfig.StreamingConfig
type ClaudeCodeConfig = internalconfig.ClaudeCodeConfig
type TLSConfig = internalconfig.TLSConfig
diff --git a/sdk/httptransport/decorator.go b/sdk/httptransport/decorator.go
new file mode 100644
index 00000000000..7ef524e5974
--- /dev/null
+++ b/sdk/httptransport/decorator.go
@@ -0,0 +1,75 @@
+// Package httptransport exposes an additive, request-scoped observation seam.
+// A decorator wraps the transport already selected by the executor: it must not
+// replace provider fingerprints, proxy routing, pools, or HTTP protocol policy.
+package httptransport
+
+import (
+ "context"
+ "net/http"
+)
+
+type decoratorKey struct{}
+
+// Decorator may wrap next to observe final HTTP requests and response bodies.
+// It is called during client construction; implementations must be concurrency
+// safe, must delegate to next, and must not retain secrets or consume bodies.
+// A decorator does not observe WebSocket messages after an upgrade.
+type Decorator func(next http.RoundTripper) http.RoundTripper
+
+// WithDecorator attaches a decorator to one execution context. It does not
+// enable request logging or modify executor credential/selection policy.
+func WithDecorator(ctx context.Context, decorator Decorator) context.Context {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ return context.WithValue(ctx, decoratorKey{}, decorator)
+}
+
+// WithoutDecorator suppresses intermediate helper decoration. A specialized
+// executor that further configures its helper's transport must apply Decorate
+// once, after its own protocol/pool policy is complete.
+func WithoutDecorator(ctx context.Context) context.Context {
+ return WithDecorator(ctx, nil)
+}
+
+// InheritDecorator copies a decorator from fallback into ctx when ctx does not
+// already specify a decorator or explicit suppression via WithoutDecorator.
+func InheritDecorator(ctx, fallback context.Context) context.Context {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ if fallback == nil {
+ return ctx
+ }
+ if _, ok := ctx.Value(decoratorKey{}).(Decorator); ok {
+ return ctx
+ }
+ if decorator, ok := fallback.Value(decoratorKey{}).(Decorator); ok && decorator != nil {
+ return WithDecorator(ctx, decorator)
+ }
+ return ctx
+}
+
+// Decorate applies a context decorator AFTER transport selection. With no
+// decorator, the exact original client is returned. Only the client value is
+// copied; the original selected transport/pool and all client policy survive.
+func Decorate(ctx context.Context, client *http.Client) *http.Client {
+ if ctx == nil || client == nil {
+ return client
+ }
+ decorator, _ := ctx.Value(decoratorKey{}).(Decorator)
+ if decorator == nil {
+ return client
+ }
+ next := client.Transport
+ if next == nil {
+ next = http.DefaultTransport
+ }
+ wrapped := decorator(next)
+ if wrapped == nil {
+ return client
+ }
+ clone := *client
+ clone.Transport = wrapped
+ return &clone
+}
diff --git a/sdk/httptransport/decorator_test.go b/sdk/httptransport/decorator_test.go
new file mode 100644
index 00000000000..116417529a0
--- /dev/null
+++ b/sdk/httptransport/decorator_test.go
@@ -0,0 +1,91 @@
+package httptransport
+
+import (
+ "context"
+ "net/http"
+ "testing"
+ "time"
+)
+
+type wrappedRoundTripper struct {
+ http.RoundTripper
+ tag string
+}
+
+func TestDecoratorPreservesSelectionAndClientPolicy(t *testing.T) {
+ tr := http.DefaultTransport.(*http.Transport).Clone()
+ tr.ForceAttemptHTTP2 = false
+ tr.MaxIdleConnsPerHost = 37
+ c := &http.Client{
+ Transport: tr,
+ Timeout: time.Second,
+ CheckRedirect: func(*http.Request, []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ }
+ if Decorate(context.Background(), c) != c {
+ t.Fatal("absent decorator changed client identity")
+ }
+ if Decorate(WithDecorator(context.Background(), nil), c) != c {
+ t.Fatal("nil decorator changed client identity")
+ }
+
+ calls := 0
+ ctx := WithDecorator(context.Background(), func(next http.RoundTripper) http.RoundTripper {
+ calls++
+ if next != tr {
+ t.Fatal("selected transport replaced")
+ }
+ return &wrappedRoundTripper{RoundTripper: next, tag: "first"}
+ })
+ got := Decorate(ctx, c)
+ if got == c || c.Transport != tr || got.Transport.(*wrappedRoundTripper).RoundTripper != tr || got.Timeout != c.Timeout || got.CheckRedirect == nil {
+ t.Fatal("client or transport policy changed")
+ }
+ if Decorate(WithoutDecorator(ctx), c) != c || calls != 1 {
+ t.Fatal("intermediate suppression failed")
+ }
+ if Decorate(WithDecorator(ctx, func(http.RoundTripper) http.RoundTripper { return nil }), c) != c {
+ t.Fatal("nil decorator result changed behavior")
+ }
+
+ defaultClient := &http.Client{}
+ gotDefault := Decorate(WithDecorator(context.Background(), func(next http.RoundTripper) http.RoundTripper {
+ if next != http.DefaultTransport {
+ t.Fatalf("nil client.Transport resolved to %T, want http.DefaultTransport", next)
+ }
+ return &wrappedRoundTripper{RoundTripper: next, tag: "default"}
+ }), defaultClient)
+ if gotDefault == defaultClient || defaultClient.Transport != nil {
+ t.Fatal("default client mutated or not decorated")
+ }
+}
+
+func TestInheritDecoratorPreservesExplicitParentAndPropagatesFallback(t *testing.T) {
+ tr := http.DefaultTransport.(*http.Transport).Clone()
+ c := &http.Client{Transport: tr}
+
+ reqCtx := WithDecorator(context.Background(), func(next http.RoundTripper) http.RoundTripper {
+ return &wrappedRoundTripper{RoundTripper: next, tag: "request"}
+ })
+
+ inherited := InheritDecorator(context.Background(), reqCtx)
+ gotInherited := Decorate(inherited, c)
+ if gotInherited == c || gotInherited.Transport.(*wrappedRoundTripper).tag != "request" {
+ t.Fatal("expected decorator inherited from fallback request context")
+ }
+
+ parentCtx := WithDecorator(context.Background(), func(next http.RoundTripper) http.RoundTripper {
+ return &wrappedRoundTripper{RoundTripper: next, tag: "parent"}
+ })
+ keptParent := InheritDecorator(parentCtx, reqCtx)
+ gotParent := Decorate(keptParent, c)
+ if gotParent == c || gotParent.Transport.(*wrappedRoundTripper).tag != "parent" {
+ t.Fatal("expected explicit parent decorator to take precedence")
+ }
+
+ suppressedParent := InheritDecorator(WithoutDecorator(context.Background()), reqCtx)
+ if Decorate(suppressedParent, c) != c {
+ t.Fatal("expected WithoutDecorator on parent context to remain suppressed")
+ }
+}
diff --git a/sdk/pluginapi/types.go b/sdk/pluginapi/types.go
index 6060977886c..edd280319ca 100644
--- a/sdk/pluginapi/types.go
+++ b/sdk/pluginapi/types.go
@@ -62,6 +62,8 @@ type ConfigField struct {
Name string
// Type classifies the field value for management clients.
Type ConfigFieldType
+ // Default is the value used when the configuration key is omitted.
+ Default any
// EnumValues lists allowed values when Type is ConfigFieldTypeEnum.
EnumValues []string
// Description explains how the plugin uses the field.
@@ -194,6 +196,9 @@ type ThinkingSupport struct {
ZeroAllowed bool
// DynamicAllowed reports whether automatic reasoning budget selection is supported.
DynamicAllowed bool
+ // DisableType is the Claude thinking.type that turns thinking off for this
+ // model. Empty means "disabled"; claude-sonnet-5-5 needs "between_tools".
+ DisableType string
// Levels lists supported named reasoning levels.
Levels []string
}
@@ -372,6 +377,8 @@ type AuthRefreshResponse struct {
}
// ModelRegistrar registers plugin-provided models with the host.
+// Without an Executor, these models are published only through matching credentials
+// after applying their model exclusions.
type ModelRegistrar interface {
RegisterModels(context.Context, ModelRegistrationRequest) (ModelRegistrationResponse, error)
}
@@ -391,6 +398,7 @@ type ModelRegistrationResponse struct {
}
// ModelProvider contributes provider-native static and per-auth model metadata.
+// StaticModels alone does not make models available without an Executor or matching credentials.
type ModelProvider interface {
StaticModels(context.Context, StaticModelRequest) (ModelResponse, error)
ModelsForAuth(context.Context, AuthModelRequest) (ModelResponse, error)
diff --git a/sdk/pluginapi/types_test.go b/sdk/pluginapi/types_test.go
index 5443beed05f..975025edd35 100644
--- a/sdk/pluginapi/types_test.go
+++ b/sdk/pluginapi/types_test.go
@@ -45,6 +45,7 @@ func TestMetadataConfigFieldsExposePluginSchema(t *testing.T) {
ConfigFields: []ConfigField{{
Name: "mode",
Type: ConfigFieldTypeEnum,
+ Default: "safe",
EnumValues: []string{"safe", "fast"},
Description: "Execution mode.",
}},
@@ -52,6 +53,9 @@ func TestMetadataConfigFieldsExposePluginSchema(t *testing.T) {
if meta.Logo == "" || len(meta.ConfigFields) != 1 {
t.Fatalf("metadata missing logo or config fields: %#v", meta)
}
+ if meta.ConfigFields[0].Default != "safe" {
+ t.Fatalf("config field default = %#v, want safe", meta.ConfigFields[0].Default)
+ }
}
func TestAuthParseResponseSupportsMultipleAuths(t *testing.T) {
diff --git a/sdk/pluginhost/host.go b/sdk/pluginhost/host.go
index 0326b4894c1..077ad81bace 100644
--- a/sdk/pluginhost/host.go
+++ b/sdk/pluginhost/host.go
@@ -90,12 +90,13 @@ func (h *Host) ShutdownAllContext(ctx context.Context) {
h.inner.ShutdownAllContext(ctx)
}
-// PluginBusy reports whether a plugin dynamic library is loaded or being loaded.
+// PluginBusy reports whether a plugin is active in the host runtime or being loaded.
func (h *Host) PluginBusy(id string) bool {
return h != nil && h.inner != nil && h.inner.PluginBusy(id)
}
-// UnloadPlugin removes one plugin from the active runtime and closes its dynamic library.
+// UnloadPlugin removes one plugin from the active runtime.
+// The plugin image itself stays mapped for the lifetime of the process.
func (h *Host) UnloadPlugin(id string) bool {
return h.UnloadPluginContext(context.Background(), id)
}
@@ -321,6 +322,7 @@ func thinkingSupportToPlugin(thinking *internalregistry.ThinkingSupport) *Thinki
Max: thinking.Max,
ZeroAllowed: thinking.ZeroAllowed,
DynamicAllowed: thinking.DynamicAllowed,
+ DisableType: thinking.DisableType,
Levels: cloneStringSlice(thinking.Levels),
}
}
diff --git a/sdk/pluginhost/thinking_disable_type_test.go b/sdk/pluginhost/thinking_disable_type_test.go
new file mode 100644
index 00000000000..6a6090de879
--- /dev/null
+++ b/sdk/pluginhost/thinking_disable_type_test.go
@@ -0,0 +1,17 @@
+package pluginhost
+
+import (
+ "testing"
+
+ internalregistry "github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+)
+
+func TestRegistryModelToPluginModelKeepsDisableType(t *testing.T) {
+ got := registryModelToPluginModel(&internalregistry.ModelInfo{
+ ID: "claude-sonnet-5-5",
+ Thinking: &internalregistry.ThinkingSupport{Levels: []string{"low"}, DisableType: "between_tools"},
+ })
+ if got.Thinking == nil || got.Thinking.DisableType != "between_tools" {
+ t.Fatalf("Thinking = %+v, want DisableType between_tools", got.Thinking)
+ }
+}